@llblab/pi-telegram 0.17.5 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +36 -15
- package/README.md +63 -35
- package/docs/README.md +3 -1
- package/docs/architecture.md +55 -23
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound.md +1 -1
- package/docs/locks.md +0 -2
- package/docs/multi-instance-bus.md +483 -0
- package/docs/outbound.md +4 -3
- package/docs/public-api.md +12 -10
- package/docs/sections.md +2 -2
- package/docs/ui-style.md +76 -0
- package/index.ts +789 -32
- package/lib/bindings.ts +68 -12
- package/lib/bus-api.ts +314 -0
- package/lib/bus-follower.ts +853 -0
- package/lib/bus-leader.ts +915 -0
- package/lib/bus.ts +866 -0
- package/lib/command-templates.ts +9 -11
- package/lib/commands.ts +133 -47
- package/lib/config.ts +53 -5
- package/lib/lifecycle.ts +23 -7
- package/lib/locks.ts +230 -66
- package/lib/media.ts +30 -2
- package/lib/menu-model.ts +48 -17
- package/lib/menu-queue.ts +51 -20
- package/lib/menu-settings.ts +9 -5
- package/lib/menu-status.ts +3 -0
- package/lib/menu-thinking.ts +3 -0
- package/lib/menu.ts +67 -26
- package/lib/outbound-attachments.ts +102 -17
- package/lib/outbound-buttons.ts +6 -2
- package/lib/outbound-voice.ts +31 -11
- package/lib/outbound.ts +6 -4
- package/lib/ownership.ts +119 -0
- package/lib/pi.ts +26 -3
- package/lib/polling.ts +477 -7
- package/lib/preview.ts +141 -88
- package/lib/prompt-templates.ts +3 -3
- package/lib/prompts.ts +80 -30
- package/lib/queue.ts +193 -91
- package/lib/rendering.ts +0 -25
- package/lib/replies.ts +187 -55
- package/lib/routing.ts +1673 -9
- package/lib/runtime-log.ts +123 -0
- package/lib/runtime.ts +84 -12
- package/lib/sections.ts +28 -21
- package/lib/setup.ts +1 -1
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +405 -40
- package/lib/text-groups.ts +5 -1
- package/lib/thread-reconciler.ts +915 -0
- package/lib/threads.ts +2205 -0
- package/lib/turns.ts +48 -3
- package/lib/updates.ts +355 -32
- package/package.json +24 -2
- package/docs/telegram-bot-api-rich-messages.md +0 -890
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram thread lifecycle reconciler
|
|
3
|
+
* Zones: telegram, multi-instance bus, synchronization control plane
|
|
4
|
+
* Owns pure planning for Telegram thread/tab lifecycle reconciliation and destructive topic cleanup authority
|
|
5
|
+
* Excludes live Telegram API calls, inbound routing, menu rendering, and direct thread-store persistence
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TelegramTarget } from "./target.ts";
|
|
9
|
+
|
|
10
|
+
type ThreadTarget = TelegramTarget & { threadId: number };
|
|
11
|
+
|
|
12
|
+
type ThreadRecordStatus =
|
|
13
|
+
| "active"
|
|
14
|
+
| "offline"
|
|
15
|
+
| "stale"
|
|
16
|
+
| "pending"
|
|
17
|
+
| "starting"
|
|
18
|
+
| "failed";
|
|
19
|
+
|
|
20
|
+
export interface ThreadReconciliationRecord {
|
|
21
|
+
target: ThreadTarget;
|
|
22
|
+
status: ThreadRecordStatus;
|
|
23
|
+
instanceId?: string;
|
|
24
|
+
profileKey?: string;
|
|
25
|
+
ownerKind?: "leader" | "manual-follower" | "pending-topic" | "legacy";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ThreadReconciliationReservation {
|
|
29
|
+
target: ThreadTarget;
|
|
30
|
+
expiresAtMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ThreadReconciliationObservation {
|
|
34
|
+
target: ThreadTarget;
|
|
35
|
+
syncStatus: "open" | "closed" | "deleted" | "unknown";
|
|
36
|
+
observedAtMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TelegramThreadPendingProvision {
|
|
40
|
+
id: string;
|
|
41
|
+
owner: "leader" | "manual-follower";
|
|
42
|
+
instanceId: string;
|
|
43
|
+
slot?: string;
|
|
44
|
+
target?: TelegramTarget & { threadId: number };
|
|
45
|
+
startedAtMs: number;
|
|
46
|
+
expiresAtMs?: number;
|
|
47
|
+
leaderEpoch?: number | string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TelegramUnboundThreadMessageObservation {
|
|
51
|
+
target: TelegramTarget & { threadId: number };
|
|
52
|
+
observedAtMs: number;
|
|
53
|
+
messageId?: number;
|
|
54
|
+
leaderEpoch?: number | string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TelegramReservedThreadMessageObservation {
|
|
58
|
+
target: TelegramTarget & { threadId: number };
|
|
59
|
+
observedAtMs: number;
|
|
60
|
+
messageId?: number;
|
|
61
|
+
leaderEpoch?: number | string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ThreadReservationProbeResult {
|
|
65
|
+
target: ThreadTarget;
|
|
66
|
+
stale: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReplacedInstanceBindingInput {
|
|
70
|
+
instanceId: string;
|
|
71
|
+
replacementTarget: ThreadTarget;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PreviousLeaderCleanupInput {
|
|
75
|
+
currentInstanceId: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ThreadReconciliationAction =
|
|
79
|
+
| {
|
|
80
|
+
kind: "mark-topic-active";
|
|
81
|
+
target: TelegramTarget & { threadId: number };
|
|
82
|
+
reason: "observed-open";
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
kind: "mark-topic-stale";
|
|
86
|
+
target: TelegramTarget & { threadId: number };
|
|
87
|
+
syncStatus: "closed" | "deleted";
|
|
88
|
+
reason: "observed-closed" | "observed-deleted";
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
kind: "close-delete-unbound-topic";
|
|
92
|
+
target: TelegramTarget & { threadId: number };
|
|
93
|
+
observedAtMs: number;
|
|
94
|
+
messageId?: number;
|
|
95
|
+
reason: "unbound-user-message";
|
|
96
|
+
leaderEpoch?: number | string;
|
|
97
|
+
}
|
|
98
|
+
| {
|
|
99
|
+
kind: "close-delete-reserved-topic";
|
|
100
|
+
target: TelegramTarget & { threadId: number };
|
|
101
|
+
observedAtMs: number;
|
|
102
|
+
messageId?: number;
|
|
103
|
+
reason: "reserved-user-message" | "startup-reservation";
|
|
104
|
+
leaderEpoch?: number | string;
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
kind: "close-stale-replaced-topic";
|
|
108
|
+
target: TelegramTarget & { threadId: number };
|
|
109
|
+
reason: "replaced-instance-binding";
|
|
110
|
+
instanceId?: string;
|
|
111
|
+
leaderEpoch?: number | string;
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
kind: "close-delete-pruned-follower-topic";
|
|
115
|
+
target: TelegramTarget & { threadId: number };
|
|
116
|
+
reason: "pruned-follower";
|
|
117
|
+
instanceId?: string;
|
|
118
|
+
messageId?: number;
|
|
119
|
+
leaderEpoch?: number | string;
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
kind: "close-delete-replaced-follower-topic";
|
|
123
|
+
target: TelegramTarget & { threadId: number };
|
|
124
|
+
reason: "replaced-follower";
|
|
125
|
+
instanceId?: string;
|
|
126
|
+
messageId?: number;
|
|
127
|
+
leaderEpoch?: number | string;
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
kind: "close-delete-previous-leader-topic";
|
|
131
|
+
target: TelegramTarget & { threadId: number };
|
|
132
|
+
reason: "previous-leader";
|
|
133
|
+
instanceId?: string;
|
|
134
|
+
messageId?: number;
|
|
135
|
+
leaderEpoch?: number | string;
|
|
136
|
+
}
|
|
137
|
+
| {
|
|
138
|
+
kind: "close-delete-disconnected-instance-topic";
|
|
139
|
+
target: TelegramTarget & { threadId: number };
|
|
140
|
+
reason: "manual-disconnect";
|
|
141
|
+
instanceId?: string;
|
|
142
|
+
messageId?: number;
|
|
143
|
+
leaderEpoch?: number | string;
|
|
144
|
+
}
|
|
145
|
+
| {
|
|
146
|
+
kind: "close-delete-expired-pending-provision-topic";
|
|
147
|
+
target: TelegramTarget & { threadId: number };
|
|
148
|
+
reason: "expired-pending-provision";
|
|
149
|
+
pendingProvisionId: string;
|
|
150
|
+
instanceId?: string;
|
|
151
|
+
leaderEpoch?: number | string;
|
|
152
|
+
}
|
|
153
|
+
| {
|
|
154
|
+
kind: "remove-reservation";
|
|
155
|
+
target: TelegramTarget & { threadId: number };
|
|
156
|
+
reason: "reservation-probe-stale";
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export type ThreadReconciliationPhase =
|
|
160
|
+
| "stable"
|
|
161
|
+
| "provisioning"
|
|
162
|
+
| "sync-required"
|
|
163
|
+
| "cleanup-required";
|
|
164
|
+
|
|
165
|
+
export type ThreadReconciliationEvent =
|
|
166
|
+
| "settled"
|
|
167
|
+
| "pending-provision"
|
|
168
|
+
| "sync-required"
|
|
169
|
+
| "cleanup-required";
|
|
170
|
+
|
|
171
|
+
export interface ThreadReconciliationMachineState {
|
|
172
|
+
phase: ThreadReconciliationPhase;
|
|
173
|
+
event: ThreadReconciliationEvent;
|
|
174
|
+
atMs: number;
|
|
175
|
+
leaderEpoch?: number | string;
|
|
176
|
+
pendingProvisionCount: number;
|
|
177
|
+
syncActionCount: number;
|
|
178
|
+
cleanupActionCount: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ThreadReconciliationTransition {
|
|
182
|
+
from: ThreadReconciliationPhase;
|
|
183
|
+
to: ThreadReconciliationPhase;
|
|
184
|
+
event: ThreadReconciliationEvent;
|
|
185
|
+
atMs: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ThreadReconciliationPlan {
|
|
189
|
+
actions: ThreadReconciliationAction[];
|
|
190
|
+
state?: ThreadReconciliationMachineState;
|
|
191
|
+
transition?: ThreadReconciliationTransition;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ThreadReconciliationApplyResult {
|
|
195
|
+
changed: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ThreadReconciliationApplyPorts {
|
|
199
|
+
callApi?: <TResponse>(
|
|
200
|
+
method: string,
|
|
201
|
+
body: Record<string, unknown>,
|
|
202
|
+
) => Promise<TResponse>;
|
|
203
|
+
markActiveByTarget?: (target: ThreadTarget) => boolean;
|
|
204
|
+
markStaleByTarget?: (
|
|
205
|
+
target: ThreadTarget,
|
|
206
|
+
syncStatus?: "closed" | "deleted",
|
|
207
|
+
lastSyncError?: string,
|
|
208
|
+
) => boolean;
|
|
209
|
+
persist?: () => Promise<void>;
|
|
210
|
+
removeReservationByTarget?: (target: ThreadTarget) => boolean;
|
|
211
|
+
removePendingProvisionById?: (id: string) => boolean;
|
|
212
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
213
|
+
recordRuntimeEvent?: (
|
|
214
|
+
category: string,
|
|
215
|
+
error: unknown,
|
|
216
|
+
details?: Record<string, unknown>,
|
|
217
|
+
) => void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ThreadReconciliationInput {
|
|
221
|
+
nowMs: number;
|
|
222
|
+
currentLeaderEpoch?: number | string;
|
|
223
|
+
records: readonly ThreadReconciliationRecord[];
|
|
224
|
+
reservations?: readonly ThreadReconciliationReservation[];
|
|
225
|
+
observations?: readonly ThreadReconciliationObservation[];
|
|
226
|
+
pendingProvisions?: readonly TelegramThreadPendingProvision[];
|
|
227
|
+
unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
|
|
228
|
+
reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
|
|
229
|
+
proactiveReservationCleanup?: boolean;
|
|
230
|
+
reservationProbeResults?: readonly ThreadReservationProbeResult[];
|
|
231
|
+
prunedFollowerInstanceIds?: readonly string[];
|
|
232
|
+
replacedBindings?: readonly ReplacedInstanceBindingInput[];
|
|
233
|
+
previousLeaderCleanup?: PreviousLeaderCleanupInput;
|
|
234
|
+
previousState?: ThreadReconciliationMachineState;
|
|
235
|
+
freshCreationGraceMs?: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const DEFAULT_FRESH_CREATION_GRACE_MS = 30_000;
|
|
239
|
+
|
|
240
|
+
function targetKey(target: ThreadTarget): string {
|
|
241
|
+
return `${target.chatId}:${target.threadId}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isCurrentRecord(record: ThreadReconciliationRecord): boolean {
|
|
245
|
+
return (
|
|
246
|
+
record.status === "active" ||
|
|
247
|
+
record.status === "starting" ||
|
|
248
|
+
record.status === "pending"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isActiveOrStartingRecord(record: ThreadReconciliationRecord): boolean {
|
|
253
|
+
return record.status === "active" || record.status === "starting";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isPreviousLeaderRecord(record: ThreadReconciliationRecord): boolean {
|
|
257
|
+
return (
|
|
258
|
+
record.ownerKind === "leader" ||
|
|
259
|
+
(!record.ownerKind &&
|
|
260
|
+
(record.profileKey?.startsWith("cwd:") === true ||
|
|
261
|
+
record.profileKey?.startsWith("leader:") === true))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isReservationAlive(
|
|
266
|
+
reservation: ThreadReconciliationReservation,
|
|
267
|
+
nowMs: number,
|
|
268
|
+
): boolean {
|
|
269
|
+
return (
|
|
270
|
+
reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isPendingProvisionExpired(
|
|
275
|
+
provision: TelegramThreadPendingProvision,
|
|
276
|
+
nowMs: number,
|
|
277
|
+
): boolean {
|
|
278
|
+
return provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isPendingProvisionAlive(
|
|
282
|
+
provision: TelegramThreadPendingProvision,
|
|
283
|
+
nowMs: number,
|
|
284
|
+
currentLeaderEpoch: number | string | undefined,
|
|
285
|
+
): boolean {
|
|
286
|
+
if (isPendingProvisionExpired(provision, nowMs)) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
currentLeaderEpoch !== undefined &&
|
|
291
|
+
provision.leaderEpoch !== undefined &&
|
|
292
|
+
provision.leaderEpoch !== currentLeaderEpoch
|
|
293
|
+
) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isRecentOpenObservation(
|
|
300
|
+
observation: ThreadReconciliationObservation,
|
|
301
|
+
nowMs: number,
|
|
302
|
+
graceMs: number,
|
|
303
|
+
): boolean {
|
|
304
|
+
return (
|
|
305
|
+
observation.syncStatus === "open" &&
|
|
306
|
+
nowMs - observation.observedAtMs >= 0 &&
|
|
307
|
+
nowMs - observation.observedAtMs <= graceMs
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
312
|
+
return (
|
|
313
|
+
action.kind === "close-delete-unbound-topic" ||
|
|
314
|
+
action.kind === "close-delete-reserved-topic" ||
|
|
315
|
+
action.kind === "close-stale-replaced-topic" ||
|
|
316
|
+
action.kind === "close-delete-pruned-follower-topic" ||
|
|
317
|
+
action.kind === "close-delete-replaced-follower-topic" ||
|
|
318
|
+
action.kind === "close-delete-previous-leader-topic" ||
|
|
319
|
+
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
320
|
+
action.kind === "close-delete-expired-pending-provision-topic"
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isSyncAction(action: ThreadReconciliationAction): boolean {
|
|
325
|
+
return (
|
|
326
|
+
action.kind === "mark-topic-active" ||
|
|
327
|
+
action.kind === "mark-topic-stale" ||
|
|
328
|
+
action.kind === "remove-reservation"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function createThreadReconciliationMachineState(
|
|
333
|
+
input: ThreadReconciliationInput,
|
|
334
|
+
actions: readonly ThreadReconciliationAction[],
|
|
335
|
+
): ThreadReconciliationMachineState {
|
|
336
|
+
const pendingProvisionCount = (input.pendingProvisions ?? []).filter(
|
|
337
|
+
(provision) =>
|
|
338
|
+
isPendingProvisionAlive(provision, input.nowMs, input.currentLeaderEpoch),
|
|
339
|
+
).length;
|
|
340
|
+
const cleanupActionCount = actions.filter(isCleanupAction).length;
|
|
341
|
+
const syncActionCount = actions.filter(isSyncAction).length;
|
|
342
|
+
const phase: ThreadReconciliationPhase =
|
|
343
|
+
cleanupActionCount > 0
|
|
344
|
+
? "cleanup-required"
|
|
345
|
+
: pendingProvisionCount > 0
|
|
346
|
+
? "provisioning"
|
|
347
|
+
: syncActionCount > 0
|
|
348
|
+
? "sync-required"
|
|
349
|
+
: "stable";
|
|
350
|
+
const event: ThreadReconciliationEvent =
|
|
351
|
+
phase === "cleanup-required"
|
|
352
|
+
? "cleanup-required"
|
|
353
|
+
: phase === "provisioning"
|
|
354
|
+
? "pending-provision"
|
|
355
|
+
: phase === "sync-required"
|
|
356
|
+
? "sync-required"
|
|
357
|
+
: "settled";
|
|
358
|
+
return {
|
|
359
|
+
phase,
|
|
360
|
+
event,
|
|
361
|
+
atMs: input.nowMs,
|
|
362
|
+
...(input.currentLeaderEpoch !== undefined
|
|
363
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
364
|
+
: {}),
|
|
365
|
+
pendingProvisionCount,
|
|
366
|
+
syncActionCount,
|
|
367
|
+
cleanupActionCount,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function createThreadReconciliationTransition(
|
|
372
|
+
previous: ThreadReconciliationMachineState | undefined,
|
|
373
|
+
current: ThreadReconciliationMachineState,
|
|
374
|
+
): ThreadReconciliationTransition | undefined {
|
|
375
|
+
if (!previous || previous.phase === current.phase) return undefined;
|
|
376
|
+
return {
|
|
377
|
+
from: previous.phase,
|
|
378
|
+
to: current.phase,
|
|
379
|
+
event: current.event,
|
|
380
|
+
atMs: current.atMs,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getActionLeaderEpoch(
|
|
385
|
+
action: ThreadReconciliationAction,
|
|
386
|
+
): number | string | undefined {
|
|
387
|
+
return "leaderEpoch" in action ? action.leaderEpoch : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isTelegramTopicTargetGoneError(error: unknown): boolean {
|
|
391
|
+
if (!(error instanceof Error)) return false;
|
|
392
|
+
const message = error.message.toLowerCase();
|
|
393
|
+
return (
|
|
394
|
+
message.includes("topic_id_invalid") ||
|
|
395
|
+
message.includes("message thread not found") ||
|
|
396
|
+
message.includes("thread not found") ||
|
|
397
|
+
message.includes("topic not found") ||
|
|
398
|
+
message.includes("topic deleted") ||
|
|
399
|
+
message.includes("topic closed") ||
|
|
400
|
+
message.includes("thread closed") ||
|
|
401
|
+
message.includes("forum topic closed") ||
|
|
402
|
+
message.includes("message thread closed")
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function shouldSkipForStaleLeaderEpoch(
|
|
407
|
+
action: ThreadReconciliationAction,
|
|
408
|
+
ports: ThreadReconciliationApplyPorts,
|
|
409
|
+
): boolean {
|
|
410
|
+
const actionLeaderEpoch = getActionLeaderEpoch(action);
|
|
411
|
+
const currentLeaderEpoch = ports.getCurrentLeaderEpoch?.();
|
|
412
|
+
if (actionLeaderEpoch === undefined) {
|
|
413
|
+
if (currentLeaderEpoch !== undefined) {
|
|
414
|
+
ports.recordRuntimeEvent?.(
|
|
415
|
+
"telegram",
|
|
416
|
+
"Thread reconciliation destructive action has no leader epoch",
|
|
417
|
+
{
|
|
418
|
+
phase: "thread-reconciler-missing-leader-epoch",
|
|
419
|
+
action: action.kind,
|
|
420
|
+
currentLeaderEpoch,
|
|
421
|
+
chatId: action.target.chatId,
|
|
422
|
+
threadId: action.target.threadId,
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (currentLeaderEpoch === actionLeaderEpoch) return false;
|
|
429
|
+
ports.recordRuntimeEvent?.(
|
|
430
|
+
"telegram",
|
|
431
|
+
"Skipped stale-epoch thread reconciliation action",
|
|
432
|
+
{
|
|
433
|
+
phase: "thread-reconciler-stale-epoch-skip",
|
|
434
|
+
action: action.kind,
|
|
435
|
+
actionLeaderEpoch,
|
|
436
|
+
currentLeaderEpoch,
|
|
437
|
+
chatId: action.target.chatId,
|
|
438
|
+
threadId: action.target.threadId,
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export interface ThreadReconciliationRuntime {
|
|
445
|
+
getState: () => ThreadReconciliationMachineState | undefined;
|
|
446
|
+
recordPlan: (plan: ThreadReconciliationPlan) => void;
|
|
447
|
+
plan: (
|
|
448
|
+
input: Omit<ThreadReconciliationInput, "previousState">,
|
|
449
|
+
) => ThreadReconciliationPlan;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function createThreadReconciliationRuntime(deps: {
|
|
453
|
+
recordRuntimeEvent: (
|
|
454
|
+
category: string,
|
|
455
|
+
message: unknown,
|
|
456
|
+
details?: Record<string, unknown>,
|
|
457
|
+
) => void;
|
|
458
|
+
scheduleSnapshotPersist: () => void;
|
|
459
|
+
}): ThreadReconciliationRuntime {
|
|
460
|
+
let state: ThreadReconciliationMachineState | undefined;
|
|
461
|
+
const recordPlan = (plan: ThreadReconciliationPlan): void => {
|
|
462
|
+
if (!plan.state) return;
|
|
463
|
+
state = plan.state;
|
|
464
|
+
if (plan.transition) {
|
|
465
|
+
deps.recordRuntimeEvent(
|
|
466
|
+
"telegram",
|
|
467
|
+
"Thread reconciliation phase changed",
|
|
468
|
+
{
|
|
469
|
+
phase: "thread-reconciliation-transition",
|
|
470
|
+
from: plan.transition.from,
|
|
471
|
+
to: plan.transition.to,
|
|
472
|
+
event: plan.transition.event,
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
deps.scheduleSnapshotPersist();
|
|
477
|
+
};
|
|
478
|
+
return {
|
|
479
|
+
getState: () => state,
|
|
480
|
+
recordPlan,
|
|
481
|
+
plan(input) {
|
|
482
|
+
const plan = planThreadReconciliation({
|
|
483
|
+
...input,
|
|
484
|
+
previousState: state,
|
|
485
|
+
});
|
|
486
|
+
recordPlan(plan);
|
|
487
|
+
return plan;
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function planDisconnectedInstanceThreadCleanup(input: {
|
|
493
|
+
target: TelegramTarget & { threadId: number };
|
|
494
|
+
instanceId?: string;
|
|
495
|
+
}): ThreadReconciliationPlan {
|
|
496
|
+
return {
|
|
497
|
+
actions: [
|
|
498
|
+
{
|
|
499
|
+
kind: "close-delete-disconnected-instance-topic",
|
|
500
|
+
target: input.target,
|
|
501
|
+
reason: "manual-disconnect",
|
|
502
|
+
instanceId: input.instanceId,
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function applyThreadReconciliationPlan(
|
|
509
|
+
plan: ThreadReconciliationPlan,
|
|
510
|
+
ports: ThreadReconciliationApplyPorts,
|
|
511
|
+
): Promise<ThreadReconciliationApplyResult> {
|
|
512
|
+
let shouldPersist = false;
|
|
513
|
+
for (const action of plan.actions) {
|
|
514
|
+
if (action.kind === "remove-reservation") {
|
|
515
|
+
shouldPersist =
|
|
516
|
+
ports.removeReservationByTarget?.(action.target) || shouldPersist;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (action.kind === "mark-topic-active") {
|
|
520
|
+
shouldPersist =
|
|
521
|
+
ports.markActiveByTarget?.(action.target) || shouldPersist;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (action.kind === "mark-topic-stale") {
|
|
525
|
+
shouldPersist =
|
|
526
|
+
ports.markStaleByTarget?.(action.target, action.syncStatus) ||
|
|
527
|
+
shouldPersist;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (action.kind === "close-stale-replaced-topic") {
|
|
531
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
532
|
+
if (!ports.callApi) {
|
|
533
|
+
ports.recordRuntimeEvent?.(
|
|
534
|
+
"telegram",
|
|
535
|
+
"Skipped topic close without API port",
|
|
536
|
+
{
|
|
537
|
+
phase: "thread-reconciler-close-skip-no-api",
|
|
538
|
+
reason: action.reason,
|
|
539
|
+
chatId: action.target.chatId,
|
|
540
|
+
threadId: action.target.threadId,
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
await ports.callApi("closeForumTopic", {
|
|
547
|
+
chat_id: action.target.chatId,
|
|
548
|
+
message_thread_id: action.target.threadId,
|
|
549
|
+
});
|
|
550
|
+
shouldPersist =
|
|
551
|
+
ports.markStaleByTarget?.(action.target, "closed") || shouldPersist;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
ports.recordRuntimeEvent?.("telegram", error, {
|
|
554
|
+
phase: `thread-reconciler-${action.reason}-closeForumTopic`,
|
|
555
|
+
instanceId: action.instanceId,
|
|
556
|
+
chatId: action.target.chatId,
|
|
557
|
+
threadId: action.target.threadId,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (
|
|
563
|
+
action.kind === "close-delete-unbound-topic" ||
|
|
564
|
+
action.kind === "close-delete-reserved-topic" ||
|
|
565
|
+
action.kind === "close-delete-pruned-follower-topic" ||
|
|
566
|
+
action.kind === "close-delete-replaced-follower-topic" ||
|
|
567
|
+
action.kind === "close-delete-previous-leader-topic" ||
|
|
568
|
+
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
569
|
+
action.kind === "close-delete-expired-pending-provision-topic"
|
|
570
|
+
) {
|
|
571
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
572
|
+
if (!ports.callApi) {
|
|
573
|
+
ports.recordRuntimeEvent?.(
|
|
574
|
+
"telegram",
|
|
575
|
+
"Skipped topic cleanup without API port",
|
|
576
|
+
{
|
|
577
|
+
phase: "thread-reconciler-cleanup-skip-no-api",
|
|
578
|
+
reason: action.reason,
|
|
579
|
+
chatId: action.target.chatId,
|
|
580
|
+
threadId: action.target.threadId,
|
|
581
|
+
},
|
|
582
|
+
);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
let deleteConfirmed = false;
|
|
586
|
+
for (const method of ["closeForumTopic", "deleteForumTopic"]) {
|
|
587
|
+
try {
|
|
588
|
+
await ports.callApi(method, {
|
|
589
|
+
chat_id: action.target.chatId,
|
|
590
|
+
message_thread_id: action.target.threadId,
|
|
591
|
+
});
|
|
592
|
+
if (method === "deleteForumTopic") deleteConfirmed = true;
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (isTelegramTopicTargetGoneError(error)) {
|
|
595
|
+
if (method === "deleteForumTopic") deleteConfirmed = true;
|
|
596
|
+
} else {
|
|
597
|
+
ports.recordRuntimeEvent?.("telegram", error, {
|
|
598
|
+
phase: `thread-reconciler-${action.reason}-${method}`,
|
|
599
|
+
chatId: action.target.chatId,
|
|
600
|
+
threadId: action.target.threadId,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!deleteConfirmed) {
|
|
606
|
+
ports.recordRuntimeEvent?.(
|
|
607
|
+
"telegram",
|
|
608
|
+
"Telegram topic cleanup incomplete",
|
|
609
|
+
{
|
|
610
|
+
phase: "thread-reconciler-cleanup-incomplete",
|
|
611
|
+
action: action.kind,
|
|
612
|
+
reason: action.reason,
|
|
613
|
+
chatId: action.target.chatId,
|
|
614
|
+
threadId: action.target.threadId,
|
|
615
|
+
...("messageId" in action ? { messageId: action.messageId } : {}),
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (
|
|
621
|
+
action.kind !== "close-delete-previous-leader-topic" &&
|
|
622
|
+
action.kind !== "close-delete-pruned-follower-topic" &&
|
|
623
|
+
action.kind !== "close-delete-replaced-follower-topic" &&
|
|
624
|
+
action.kind !== "close-delete-disconnected-instance-topic" &&
|
|
625
|
+
action.kind !== "close-delete-expired-pending-provision-topic"
|
|
626
|
+
) {
|
|
627
|
+
shouldPersist =
|
|
628
|
+
ports.markStaleByTarget?.(action.target, "deleted") || shouldPersist;
|
|
629
|
+
}
|
|
630
|
+
ports.recordRuntimeEvent?.(
|
|
631
|
+
"telegram",
|
|
632
|
+
action.kind === "close-delete-unbound-topic"
|
|
633
|
+
? "Unbound Telegram topic deleted"
|
|
634
|
+
: action.kind === "close-delete-reserved-topic"
|
|
635
|
+
? "Reserved Telegram topic deleted"
|
|
636
|
+
: action.kind === "close-delete-pruned-follower-topic"
|
|
637
|
+
? "Pruned follower Telegram topic deleted"
|
|
638
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
639
|
+
? "Replaced follower Telegram topic deleted"
|
|
640
|
+
: action.kind === "close-delete-previous-leader-topic"
|
|
641
|
+
? "Previous leader Telegram topic deleted"
|
|
642
|
+
: action.kind === "close-delete-disconnected-instance-topic"
|
|
643
|
+
? "Disconnected instance Telegram topic deleted"
|
|
644
|
+
: "Expired pending provision Telegram topic deleted",
|
|
645
|
+
{
|
|
646
|
+
phase:
|
|
647
|
+
action.kind === "close-delete-unbound-topic"
|
|
648
|
+
? "thread-reconciler-unbound-topic-delete"
|
|
649
|
+
: action.kind === "close-delete-reserved-topic"
|
|
650
|
+
? "thread-reconciler-reserved-topic-delete"
|
|
651
|
+
: action.kind === "close-delete-pruned-follower-topic"
|
|
652
|
+
? "thread-reconciler-pruned-follower-topic-delete"
|
|
653
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
654
|
+
? "thread-reconciler-replaced-follower-topic-delete"
|
|
655
|
+
: action.kind === "close-delete-previous-leader-topic"
|
|
656
|
+
? "thread-reconciler-previous-leader-topic-delete"
|
|
657
|
+
: action.kind ===
|
|
658
|
+
"close-delete-disconnected-instance-topic"
|
|
659
|
+
? "thread-reconciler-disconnected-instance-topic-delete"
|
|
660
|
+
: "thread-reconciler-expired-pending-provision-topic-delete",
|
|
661
|
+
chatId: action.target.chatId,
|
|
662
|
+
threadId: action.target.threadId,
|
|
663
|
+
...("messageId" in action ? { messageId: action.messageId } : {}),
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
if (
|
|
667
|
+
action.kind === "close-delete-expired-pending-provision-topic" &&
|
|
668
|
+
deleteConfirmed
|
|
669
|
+
) {
|
|
670
|
+
shouldPersist =
|
|
671
|
+
ports.removePendingProvisionById?.(action.pendingProvisionId) ||
|
|
672
|
+
shouldPersist;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (shouldPersist) await ports.persist?.();
|
|
677
|
+
return { changed: shouldPersist };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function planThreadReconciliation(
|
|
681
|
+
input: ThreadReconciliationInput,
|
|
682
|
+
): ThreadReconciliationPlan {
|
|
683
|
+
const graceMs = input.freshCreationGraceMs ?? DEFAULT_FRESH_CREATION_GRACE_MS;
|
|
684
|
+
const knownTargets = new Set(
|
|
685
|
+
input.records.map((record) => targetKey(record.target)),
|
|
686
|
+
);
|
|
687
|
+
const currentTargets = new Set(
|
|
688
|
+
input.records
|
|
689
|
+
.filter(isCurrentRecord)
|
|
690
|
+
.map((record) => targetKey(record.target)),
|
|
691
|
+
);
|
|
692
|
+
const allReservationTargets = new Set(
|
|
693
|
+
(input.reservations ?? []).map((reservation) =>
|
|
694
|
+
targetKey(reservation.target),
|
|
695
|
+
),
|
|
696
|
+
);
|
|
697
|
+
const reservedTargets = new Set(
|
|
698
|
+
(input.reservations ?? [])
|
|
699
|
+
.filter((reservation) => isReservationAlive(reservation, input.nowMs))
|
|
700
|
+
.map((reservation) => targetKey(reservation.target)),
|
|
701
|
+
);
|
|
702
|
+
const pendingTargets = new Set(
|
|
703
|
+
(input.pendingProvisions ?? [])
|
|
704
|
+
.filter((provision) =>
|
|
705
|
+
isPendingProvisionAlive(
|
|
706
|
+
provision,
|
|
707
|
+
input.nowMs,
|
|
708
|
+
input.currentLeaderEpoch,
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
.flatMap((provision) =>
|
|
712
|
+
provision.target ? [targetKey(provision.target)] : [],
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
const recentOpenTargets = new Set(
|
|
716
|
+
(input.observations ?? [])
|
|
717
|
+
.filter((observation) =>
|
|
718
|
+
isRecentOpenObservation(observation, input.nowMs, graceMs),
|
|
719
|
+
)
|
|
720
|
+
.map((observation) => targetKey(observation.target)),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const actions: ThreadReconciliationAction[] = [];
|
|
724
|
+
for (const provision of input.pendingProvisions ?? []) {
|
|
725
|
+
if (!provision.target) continue;
|
|
726
|
+
if (!isPendingProvisionExpired(provision, input.nowMs)) continue;
|
|
727
|
+
const key = targetKey(provision.target);
|
|
728
|
+
if (currentTargets.has(key)) continue;
|
|
729
|
+
if (reservedTargets.has(key)) continue;
|
|
730
|
+
if (recentOpenTargets.has(key)) continue;
|
|
731
|
+
actions.push({
|
|
732
|
+
kind: "close-delete-expired-pending-provision-topic",
|
|
733
|
+
target: provision.target,
|
|
734
|
+
reason: "expired-pending-provision",
|
|
735
|
+
pendingProvisionId: provision.id,
|
|
736
|
+
instanceId: provision.instanceId,
|
|
737
|
+
...(input.currentLeaderEpoch !== undefined
|
|
738
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
739
|
+
: {}),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
for (const observation of input.observations ?? []) {
|
|
744
|
+
if (observation.syncStatus === "open") {
|
|
745
|
+
if (knownTargets.has(targetKey(observation.target))) {
|
|
746
|
+
actions.push({
|
|
747
|
+
kind: "mark-topic-active",
|
|
748
|
+
target: observation.target,
|
|
749
|
+
reason: "observed-open",
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (
|
|
755
|
+
observation.syncStatus === "closed" ||
|
|
756
|
+
observation.syncStatus === "deleted"
|
|
757
|
+
) {
|
|
758
|
+
if (currentTargets.has(targetKey(observation.target))) {
|
|
759
|
+
actions.push({
|
|
760
|
+
kind: "mark-topic-stale",
|
|
761
|
+
target: observation.target,
|
|
762
|
+
syncStatus: observation.syncStatus,
|
|
763
|
+
reason:
|
|
764
|
+
observation.syncStatus === "closed"
|
|
765
|
+
? "observed-closed"
|
|
766
|
+
: "observed-deleted",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
|
|
773
|
+
for (const record of input.records) {
|
|
774
|
+
if (record.instanceId !== prunedInstanceId) continue;
|
|
775
|
+
if (!isActiveOrStartingRecord(record)) continue;
|
|
776
|
+
actions.push({
|
|
777
|
+
kind: "close-delete-pruned-follower-topic",
|
|
778
|
+
target: record.target,
|
|
779
|
+
reason: "pruned-follower",
|
|
780
|
+
instanceId: prunedInstanceId,
|
|
781
|
+
...(input.currentLeaderEpoch !== undefined
|
|
782
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
783
|
+
: {}),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const replacement of input.replacedBindings ?? []) {
|
|
789
|
+
for (const record of input.records) {
|
|
790
|
+
if (record.instanceId !== replacement.instanceId) continue;
|
|
791
|
+
if (!isActiveOrStartingRecord(record)) continue;
|
|
792
|
+
if (
|
|
793
|
+
targetKey(record.target) === targetKey(replacement.replacementTarget)
|
|
794
|
+
) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
actions.push({
|
|
798
|
+
kind: "close-stale-replaced-topic",
|
|
799
|
+
target: record.target,
|
|
800
|
+
reason: "replaced-instance-binding",
|
|
801
|
+
instanceId: replacement.instanceId,
|
|
802
|
+
...(input.currentLeaderEpoch !== undefined
|
|
803
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
804
|
+
: {}),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (input.previousLeaderCleanup) {
|
|
810
|
+
for (const record of input.records) {
|
|
811
|
+
if (!record.instanceId) continue;
|
|
812
|
+
if (record.instanceId === input.previousLeaderCleanup.currentInstanceId) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (!isActiveOrStartingRecord(record)) continue;
|
|
816
|
+
if (!isPreviousLeaderRecord(record)) continue;
|
|
817
|
+
actions.push({
|
|
818
|
+
kind: "close-delete-previous-leader-topic",
|
|
819
|
+
target: record.target,
|
|
820
|
+
reason: "previous-leader",
|
|
821
|
+
instanceId: input.previousLeaderCleanup.currentInstanceId,
|
|
822
|
+
...(input.currentLeaderEpoch !== undefined
|
|
823
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
824
|
+
: {}),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (input.proactiveReservationCleanup) {
|
|
830
|
+
for (const reservation of input.reservations ?? []) {
|
|
831
|
+
const key = targetKey(reservation.target);
|
|
832
|
+
if (currentTargets.has(key)) continue;
|
|
833
|
+
if (pendingTargets.has(key)) continue;
|
|
834
|
+
actions.push({
|
|
835
|
+
kind: "close-delete-reserved-topic",
|
|
836
|
+
target: reservation.target,
|
|
837
|
+
observedAtMs: input.nowMs,
|
|
838
|
+
reason: "startup-reservation",
|
|
839
|
+
...(input.currentLeaderEpoch !== undefined
|
|
840
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
841
|
+
: {}),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
for (const probe of input.reservationProbeResults ?? []) {
|
|
847
|
+
if (!probe.stale) continue;
|
|
848
|
+
if (!allReservationTargets.has(targetKey(probe.target))) continue;
|
|
849
|
+
actions.push({
|
|
850
|
+
kind: "remove-reservation",
|
|
851
|
+
target: probe.target,
|
|
852
|
+
reason: "reservation-probe-stale",
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
for (const message of input.reservedMessages ?? []) {
|
|
857
|
+
const key = targetKey(message.target);
|
|
858
|
+
if (!reservedTargets.has(key)) continue;
|
|
859
|
+
if (currentTargets.has(key)) continue;
|
|
860
|
+
if (pendingTargets.has(key)) continue;
|
|
861
|
+
if (
|
|
862
|
+
input.currentLeaderEpoch !== undefined &&
|
|
863
|
+
message.leaderEpoch !== undefined &&
|
|
864
|
+
message.leaderEpoch !== input.currentLeaderEpoch
|
|
865
|
+
) {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
actions.push({
|
|
869
|
+
kind: "close-delete-reserved-topic",
|
|
870
|
+
target: message.target,
|
|
871
|
+
observedAtMs: message.observedAtMs,
|
|
872
|
+
messageId: message.messageId,
|
|
873
|
+
reason: "reserved-user-message",
|
|
874
|
+
...(input.currentLeaderEpoch !== undefined
|
|
875
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
876
|
+
: {}),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
for (const message of input.unboundMessages ?? []) {
|
|
881
|
+
const key = targetKey(message.target);
|
|
882
|
+
if (currentTargets.has(key)) continue;
|
|
883
|
+
if (reservedTargets.has(key)) continue;
|
|
884
|
+
if (pendingTargets.has(key)) continue;
|
|
885
|
+
if (recentOpenTargets.has(key)) continue;
|
|
886
|
+
if (
|
|
887
|
+
input.currentLeaderEpoch !== undefined &&
|
|
888
|
+
message.leaderEpoch !== undefined &&
|
|
889
|
+
message.leaderEpoch !== input.currentLeaderEpoch
|
|
890
|
+
) {
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
actions.push({
|
|
894
|
+
kind: "close-delete-unbound-topic",
|
|
895
|
+
target: message.target,
|
|
896
|
+
observedAtMs: message.observedAtMs,
|
|
897
|
+
messageId: message.messageId,
|
|
898
|
+
reason: "unbound-user-message",
|
|
899
|
+
...(input.currentLeaderEpoch !== undefined
|
|
900
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
901
|
+
: {}),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const state = createThreadReconciliationMachineState(input, actions);
|
|
906
|
+
const transition = createThreadReconciliationTransition(
|
|
907
|
+
input.previousState,
|
|
908
|
+
state,
|
|
909
|
+
);
|
|
910
|
+
return {
|
|
911
|
+
actions,
|
|
912
|
+
state,
|
|
913
|
+
...(transition ? { transition } : {}),
|
|
914
|
+
};
|
|
915
|
+
}
|