@llblab/pi-telegram 0.45.11 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +5 -1
- package/CHANGELOG.md +12 -0
- package/README.md +6 -6
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1608 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +116 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1006 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +17 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +2 -2
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +11 -3
- package/lib/bus-leader.ts +57 -20
- package/lib/bus.ts +12 -2
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +26 -1
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +21 -5
- package/lib/outbound-markup.ts +16 -7
- package/lib/pi.ts +4 -0
- package/lib/sync.ts +3 -0
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-cleanup-manager.ts +36 -6
- package/lib/thread-display.ts +96 -18
- package/lib/threads.ts +150 -30
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/show-me/SKILL.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
|
@@ -0,0 +1,3679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram thread binding helpers
|
|
3
|
+
* Zones: multi-instance bus, Telegram UI threads, volatile extension state
|
|
4
|
+
* Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile, } from "node:fs/promises";
|
|
9
|
+
import { dirname, posix, resolve } from "node:path";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, } from "./telegram-api.js";
|
|
12
|
+
import { withTelegramFileTransaction } from "./locks.js";
|
|
13
|
+
import * as ThreadReconciler from "./thread-reconciler.js";
|
|
14
|
+
import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, } from "./workspace-slots.js";
|
|
15
|
+
import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.js";
|
|
16
|
+
const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
|
|
17
|
+
const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
|
|
18
|
+
export function normalizeTelegramSessionId(sessionId) {
|
|
19
|
+
if (typeof sessionId !== "string")
|
|
20
|
+
return undefined;
|
|
21
|
+
const normalized = sessionId.trim();
|
|
22
|
+
return normalized && Buffer.byteLength(normalized, "utf8") <=
|
|
23
|
+
TELEGRAM_SESSION_ID_MAX_LENGTH
|
|
24
|
+
? normalized
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
export function createTelegramSessionKey(sessionId) {
|
|
28
|
+
const normalized = normalizeTelegramSessionId(sessionId);
|
|
29
|
+
return normalized
|
|
30
|
+
? createHash("sha256").update(normalized).digest("hex")
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
export function normalizeTelegramWorkspacePath(cwd) {
|
|
34
|
+
const trimmed = cwd.trim();
|
|
35
|
+
if (!trimmed)
|
|
36
|
+
return undefined;
|
|
37
|
+
const normalized = (trimmed.startsWith("/")
|
|
38
|
+
? posix.normalize(trimmed)
|
|
39
|
+
: resolve(trimmed).replaceAll("\\", "/"));
|
|
40
|
+
const withoutTrailingSeparators = normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
|
|
41
|
+
return process.platform === "win32"
|
|
42
|
+
? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive) => `${drive.toLowerCase()}:`)
|
|
43
|
+
: withoutTrailingSeparators;
|
|
44
|
+
}
|
|
45
|
+
export function createTelegramWorkspaceDirectoryKey(cwd) {
|
|
46
|
+
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
47
|
+
if (!normalized)
|
|
48
|
+
return undefined;
|
|
49
|
+
const readable = normalized
|
|
50
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
51
|
+
.replace(/^-+|-+$/gu, "") || "root";
|
|
52
|
+
const candidate = `--${readable}--`;
|
|
53
|
+
if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH)
|
|
54
|
+
return candidate;
|
|
55
|
+
const digest = createHash("sha256")
|
|
56
|
+
.update(normalized)
|
|
57
|
+
.digest("hex")
|
|
58
|
+
.slice(0, 12);
|
|
59
|
+
const prefixLength = TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
|
|
60
|
+
return `--${readable.slice(0, prefixLength)}-${digest}--`;
|
|
61
|
+
}
|
|
62
|
+
function createTelegramWorkspaceInstanceSlot(ordinal) {
|
|
63
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
let value = ordinal + 1;
|
|
66
|
+
let slot = "";
|
|
67
|
+
while (value > 0) {
|
|
68
|
+
value -= 1;
|
|
69
|
+
slot = String.fromCharCode(97 + (value % 26)) + slot;
|
|
70
|
+
value = Math.floor(value / 26);
|
|
71
|
+
}
|
|
72
|
+
return slot;
|
|
73
|
+
}
|
|
74
|
+
function createTelegramWorkspaceBindingIdentityWithKey(cwd, workspaceKey, ordinal, sessionId) {
|
|
75
|
+
const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
|
|
76
|
+
if (!instanceSlot)
|
|
77
|
+
return undefined;
|
|
78
|
+
const normalizedSessionId = sessionId === undefined
|
|
79
|
+
? undefined
|
|
80
|
+
: normalizeTelegramSessionId(sessionId);
|
|
81
|
+
const sessionKey = normalizedSessionId
|
|
82
|
+
? createTelegramSessionKey(normalizedSessionId)
|
|
83
|
+
: undefined;
|
|
84
|
+
if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const legacyBindingKey = instanceSlot === "a"
|
|
88
|
+
? workspaceKey
|
|
89
|
+
: `${workspaceKey}${instanceSlot}`;
|
|
90
|
+
return {
|
|
91
|
+
cwd,
|
|
92
|
+
workspaceKey,
|
|
93
|
+
...(normalizedSessionId && sessionKey
|
|
94
|
+
? { sessionId: normalizedSessionId, sessionKey }
|
|
95
|
+
: {}),
|
|
96
|
+
instanceSlot,
|
|
97
|
+
bindingKey: sessionKey
|
|
98
|
+
? `${legacyBindingKey}-s-${sessionKey}`
|
|
99
|
+
: legacyBindingKey,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function createTelegramWorkspaceBindingIdentity(cwd, ordinal = 0, sessionId) {
|
|
103
|
+
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
104
|
+
const workspaceKey = normalized
|
|
105
|
+
? createTelegramWorkspaceDirectoryKey(normalized)
|
|
106
|
+
: undefined;
|
|
107
|
+
if (!normalized || !workspaceKey)
|
|
108
|
+
return undefined;
|
|
109
|
+
return createTelegramWorkspaceBindingIdentityWithKey(normalized, workspaceKey, ordinal, sessionId);
|
|
110
|
+
}
|
|
111
|
+
const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
|
|
112
|
+
function getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, lastSlot) {
|
|
113
|
+
let cursorCode;
|
|
114
|
+
if (lastSlot && /^[A-Z]$/.test(lastSlot)) {
|
|
115
|
+
cursorCode = lastSlot.charCodeAt(0);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
cursorCode = "A".charCodeAt(0) - 1;
|
|
119
|
+
for (const record of records.values()) {
|
|
120
|
+
if (!record.slot || !isCurrentThreadRecord(record))
|
|
121
|
+
continue;
|
|
122
|
+
cursorCode = Math.max(cursorCode, record.slot.charCodeAt(0));
|
|
123
|
+
}
|
|
124
|
+
for (const reservation of reservations) {
|
|
125
|
+
if (reservation.expiresAtMs !== undefined &&
|
|
126
|
+
reservation.expiresAtMs <= nowMs)
|
|
127
|
+
continue;
|
|
128
|
+
if (!reservation.slot)
|
|
129
|
+
continue;
|
|
130
|
+
cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0));
|
|
131
|
+
}
|
|
132
|
+
for (const provision of pendingProvisions) {
|
|
133
|
+
if (provision.status !== "ambiguous" &&
|
|
134
|
+
provision.expiresAtMs !== undefined &&
|
|
135
|
+
provision.expiresAtMs <= nowMs)
|
|
136
|
+
continue;
|
|
137
|
+
if (!provision.slot)
|
|
138
|
+
continue;
|
|
139
|
+
cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let code = cursorCode + 1;
|
|
143
|
+
if (code > "Z".charCodeAt(0))
|
|
144
|
+
code = "A".charCodeAt(0);
|
|
145
|
+
for (let attempt = 0; attempt < 26; attempt++) {
|
|
146
|
+
const candidate = String.fromCharCode(code);
|
|
147
|
+
if (!isTelegramTopicTargetSlotOccupied(candidate, records, reservations, pendingProvisions, nowMs)) {
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
code += 1;
|
|
151
|
+
if (code > "Z".charCodeAt(0))
|
|
152
|
+
code = "A".charCodeAt(0);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
export function reconcileTelegramFreshAllocationCursor(store, nowMs = Date.now()) {
|
|
157
|
+
const currentCursor = store.getBotState().lastSlot;
|
|
158
|
+
const slottedRecords = store
|
|
159
|
+
.list()
|
|
160
|
+
.filter((record) => !!record.slot && /^[A-Z]$/.test(record.slot));
|
|
161
|
+
if (slottedRecords.some((record) => record.slot === currentCursor)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const latestLiveRecord = slottedRecords.reduce((latest, record) => {
|
|
165
|
+
if (!latest)
|
|
166
|
+
return record;
|
|
167
|
+
if (record.createdAtMs !== latest.createdAtMs) {
|
|
168
|
+
return record.createdAtMs > latest.createdAtMs ? record : latest;
|
|
169
|
+
}
|
|
170
|
+
return record.updatedAtMs > latest.updatedAtMs ? record : latest;
|
|
171
|
+
}, undefined);
|
|
172
|
+
const nextCursor = latestLiveRecord?.slot;
|
|
173
|
+
if (nextCursor === currentCursor)
|
|
174
|
+
return false;
|
|
175
|
+
store.setBotState({
|
|
176
|
+
lastSlot: nextCursor,
|
|
177
|
+
updatedAtMs: nowMs,
|
|
178
|
+
lastReconcileAction: "live-cursor-realignment",
|
|
179
|
+
});
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
export function createTelegramCleanupTargetProtection(store, departingRecord) {
|
|
183
|
+
const records = store.list();
|
|
184
|
+
const reservations = store.listReservations?.() ?? [];
|
|
185
|
+
const provisions = store.listPendingProvisions?.() ?? [];
|
|
186
|
+
const intents = store.listPendingCleanups?.() ?? [];
|
|
187
|
+
// Persistence may reconstruct keys in another order and omit undefined
|
|
188
|
+
// optional fields; neither changes the authority represented by a snapshot.
|
|
189
|
+
const sameSnapshot = (left, right) => isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
190
|
+
return (target, action) => {
|
|
191
|
+
for (const record of store.list()) {
|
|
192
|
+
if (!targetMatches(record.target, target))
|
|
193
|
+
continue;
|
|
194
|
+
// A persisted shutdown intent may retire only its original pre-intent
|
|
195
|
+
// binding. Registration/rebinding after that intent supersedes it.
|
|
196
|
+
const intent = action.kind === "close-delete-graceful-shutdown-topic"
|
|
197
|
+
? intents.find((candidate) => candidate.id === action.cleanupIntentId && candidate.runtimeGeneration === action.runtimeGeneration)
|
|
198
|
+
: undefined;
|
|
199
|
+
const expectedDeparting = departingRecord ?? (intent && records.find((candidate) => candidate.instanceId === intent.instanceId && targetMatches(candidate.target, intent.target) &&
|
|
200
|
+
candidate.updatedAtMs <= intent.requestedAtMs));
|
|
201
|
+
if (expectedDeparting && "instanceId" in action &&
|
|
202
|
+
(action.kind === "close-delete-previous-leader-topic" || action.instanceId === expectedDeparting.instanceId) &&
|
|
203
|
+
sameSnapshot(record, expectedDeparting)) {
|
|
204
|
+
if (action.kind === "close-delete-previous-leader-topic" || action.kind === "close-stale-replaced-topic")
|
|
205
|
+
continue;
|
|
206
|
+
if (action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
207
|
+
store.listPendingCleanups?.().some((intent) => intent.id === action.cleanupIntentId &&
|
|
208
|
+
intent.instanceId === action.instanceId && intent.runtimeGeneration === action.runtimeGeneration &&
|
|
209
|
+
targetMatches(intent.target, target)))
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (record.status === "active" || record.status === "starting" || record.status === "pending" || record.status === "probe-required")
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
for (const reservation of store.listReservations?.() ?? []) {
|
|
216
|
+
if (!targetMatches(reservation.target, target))
|
|
217
|
+
continue;
|
|
218
|
+
if (action.kind !== "close-delete-reserved-topic" || !reservations.some((initial) => sameSnapshot(initial, reservation)))
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
for (const provision of store.listPendingProvisions?.() ?? []) {
|
|
222
|
+
if (!provision.target || !targetMatches(provision.target, target))
|
|
223
|
+
continue;
|
|
224
|
+
if (action.kind !== "close-delete-expired-pending-provision-topic" || provision.id !== action.pendingProvisionId ||
|
|
225
|
+
!provisions.some((initial) => sameSnapshot(initial, provision)))
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Pending creation evidence cannot bypass an exact target's unresolved cleanup or closure. */
|
|
232
|
+
export function assertTelegramPendingTopicRecoveryAllowed(store, target) {
|
|
233
|
+
if (!store.listPendingProvisions().some((entry) => entry.target && targetMatches(entry.target, target)))
|
|
234
|
+
return;
|
|
235
|
+
if (store.listPendingCleanups().some((entry) => targetMatches(entry.target, target)) ||
|
|
236
|
+
store.listSyncObservations().some((entry) => targetMatches(entry.target, target) && entry.syncStatus === "closed")) {
|
|
237
|
+
throw new Error("Telegram pending topic requires reconciliation before recovery.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Settle creation-title evidence with the exact Workspace claim; caller fences and persists. */
|
|
241
|
+
export function commitTelegramWorkspaceProvisionBinding(input) {
|
|
242
|
+
assertTelegramPendingTopicRecoveryAllowed(input.store, input.binding.target);
|
|
243
|
+
const pending = input.store.listPendingProvisions().filter((provision) => provision.target && targetMatches(provision.target, input.binding.target) &&
|
|
244
|
+
provision.slot === input.binding.slot &&
|
|
245
|
+
(provision.instanceId === input.instanceId || provision.profileKey === input.profileKey));
|
|
246
|
+
const titles = new Set(pending.map((provision) => provision.displayTitle)
|
|
247
|
+
.filter((title) => title !== undefined));
|
|
248
|
+
if (input.displayTitle !== undefined)
|
|
249
|
+
titles.add(input.displayTitle);
|
|
250
|
+
if (titles.size > 1)
|
|
251
|
+
throw new Error("Telegram Workspace creation title evidence conflicts.");
|
|
252
|
+
const displayTitle = titles.values().next().value;
|
|
253
|
+
const committed = input.store.upsertWorkspaceBinding(input.binding, input.instanceId);
|
|
254
|
+
if (!committed)
|
|
255
|
+
throw new Error("Telegram Workspace binding claim changed.");
|
|
256
|
+
if (displayTitle !== undefined &&
|
|
257
|
+
!input.store.setWorkspaceDisplayTitle(committed, displayTitle)) {
|
|
258
|
+
throw new Error("Telegram Workspace display title commit changed binding.");
|
|
259
|
+
}
|
|
260
|
+
for (const provision of pending)
|
|
261
|
+
input.store.removePendingProvision(provision.id);
|
|
262
|
+
return displayTitle === undefined ? committed : { ...committed, displayTitle };
|
|
263
|
+
}
|
|
264
|
+
function hashString(value) {
|
|
265
|
+
let hash = 2166136261;
|
|
266
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
267
|
+
hash ^= value.charCodeAt(index);
|
|
268
|
+
hash = Math.imul(hash, 16777619);
|
|
269
|
+
}
|
|
270
|
+
return hash >>> 0;
|
|
271
|
+
}
|
|
272
|
+
function getWorkspaceHint(cwd) {
|
|
273
|
+
if (!cwd)
|
|
274
|
+
return undefined;
|
|
275
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
276
|
+
const last = parts.at(-1)?.trim();
|
|
277
|
+
if (!last)
|
|
278
|
+
return undefined;
|
|
279
|
+
return (last
|
|
280
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
281
|
+
.trim()
|
|
282
|
+
.slice(0, 32) || undefined);
|
|
283
|
+
}
|
|
284
|
+
export function createTelegramThreadName(input) {
|
|
285
|
+
const workspace = getWorkspaceHint(input.cwd);
|
|
286
|
+
const roleMark = input.role === "leader"
|
|
287
|
+
? "Leader"
|
|
288
|
+
: input.role === "follower"
|
|
289
|
+
? "Follower"
|
|
290
|
+
: undefined;
|
|
291
|
+
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
292
|
+
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
293
|
+
const fallback = `Instance ${hashString(`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`)
|
|
294
|
+
.toString(36)
|
|
295
|
+
.slice(0, 4)}`;
|
|
296
|
+
return ([slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
297
|
+
fallback);
|
|
298
|
+
}
|
|
299
|
+
export function getTelegramStatePath(agentDir = resolveAgentDir(), profileName) {
|
|
300
|
+
return resolveTelegramProfileTempFilePath("state", "json", agentDir, profileName);
|
|
301
|
+
}
|
|
302
|
+
export function getTelegramTopicTargetsPath(agentDir = resolveAgentDir(), profileName) {
|
|
303
|
+
return getTelegramStatePath(agentDir, profileName);
|
|
304
|
+
}
|
|
305
|
+
const TELEGRAM_LEADER_SESSION_HANDOFF_KEY = "__piTelegramLeaderSessionHandoff";
|
|
306
|
+
export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
307
|
+
export function getTelegramLeaderSessionHandoff() {
|
|
308
|
+
const value = globalThis[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
|
|
309
|
+
if (!value || typeof value !== "object")
|
|
310
|
+
return undefined;
|
|
311
|
+
const handoff = value;
|
|
312
|
+
if (typeof handoff.pid !== "number" ||
|
|
313
|
+
typeof handoff.instanceId !== "string" ||
|
|
314
|
+
typeof handoff.createdAtMs !== "number" ||
|
|
315
|
+
typeof handoff.profileKey !== "string" ||
|
|
316
|
+
typeof handoff.target?.chatId !== "number" ||
|
|
317
|
+
typeof handoff.target.threadId !== "number") {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
return handoff;
|
|
321
|
+
}
|
|
322
|
+
export function setTelegramLeaderSessionHandoff(handoff) {
|
|
323
|
+
const store = globalThis;
|
|
324
|
+
if (!handoff)
|
|
325
|
+
delete store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
|
|
326
|
+
else
|
|
327
|
+
store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
|
|
328
|
+
}
|
|
329
|
+
export function isTelegramLeaderSessionHandoffFresh(handoff, options = {}) {
|
|
330
|
+
if (!handoff)
|
|
331
|
+
return false;
|
|
332
|
+
const pid = options.pid ?? process.pid;
|
|
333
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
334
|
+
const ttlMs = options.ttlMs ?? TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
|
|
335
|
+
return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs;
|
|
336
|
+
}
|
|
337
|
+
export function getTelegramThreadOwnerKey(owner) {
|
|
338
|
+
switch (owner.kind) {
|
|
339
|
+
case "leader": {
|
|
340
|
+
const base = owner.cwd
|
|
341
|
+
? `cwd:${owner.cwd}`
|
|
342
|
+
: `leader:${owner.instanceId ?? "default"}`;
|
|
343
|
+
return owner.telegramProfile
|
|
344
|
+
? `profile:${owner.telegramProfile}:${base}`
|
|
345
|
+
: base;
|
|
346
|
+
}
|
|
347
|
+
case "manual-follower":
|
|
348
|
+
return owner.telegramProfile
|
|
349
|
+
? `profile:${owner.telegramProfile}:manual:${owner.instanceId}`
|
|
350
|
+
: `manual:${owner.instanceId}`;
|
|
351
|
+
case "pending-topic":
|
|
352
|
+
return `topic:${owner.chatId}:${owner.threadId}`;
|
|
353
|
+
case "legacy":
|
|
354
|
+
return `legacy:${owner.key}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export function getTelegramThreadOwnerFromProfileKey(profileKey) {
|
|
358
|
+
if (profileKey.startsWith("profile:")) {
|
|
359
|
+
const [, telegramProfile, ownerKind, ...rest] = profileKey.split(":");
|
|
360
|
+
const value = rest.join(":");
|
|
361
|
+
if (ownerKind === "cwd")
|
|
362
|
+
return { kind: "leader", cwd: value, telegramProfile };
|
|
363
|
+
if (ownerKind === "leader")
|
|
364
|
+
return { kind: "leader", instanceId: value, telegramProfile };
|
|
365
|
+
if (ownerKind === "manual")
|
|
366
|
+
return { kind: "manual-follower", instanceId: value, telegramProfile };
|
|
367
|
+
}
|
|
368
|
+
if (profileKey.startsWith("cwd:"))
|
|
369
|
+
return { kind: "leader", cwd: profileKey.slice(4) };
|
|
370
|
+
if (profileKey.startsWith("manual:")) {
|
|
371
|
+
return { kind: "manual-follower", instanceId: profileKey.slice(7) };
|
|
372
|
+
}
|
|
373
|
+
if (profileKey.startsWith("topic:")) {
|
|
374
|
+
const [, chatIdText, threadIdText] = profileKey.split(":");
|
|
375
|
+
const chatId = Number(chatIdText);
|
|
376
|
+
const threadId = Number(threadIdText);
|
|
377
|
+
if (Number.isInteger(chatId) && Number.isInteger(threadId)) {
|
|
378
|
+
return { kind: "pending-topic", chatId, threadId };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (profileKey.startsWith("leader:")) {
|
|
382
|
+
return { kind: "leader", instanceId: profileKey.slice(7) };
|
|
383
|
+
}
|
|
384
|
+
return { kind: "legacy", key: profileKey };
|
|
385
|
+
}
|
|
386
|
+
function parseThreadOwner(value) {
|
|
387
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
388
|
+
return undefined;
|
|
389
|
+
const record = value;
|
|
390
|
+
if (record.kind === "leader") {
|
|
391
|
+
return {
|
|
392
|
+
kind: "leader",
|
|
393
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
394
|
+
instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
395
|
+
...(typeof record.telegramProfile === "string"
|
|
396
|
+
? { telegramProfile: record.telegramProfile }
|
|
397
|
+
: {}),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (record.kind === "manual-follower" &&
|
|
401
|
+
typeof record.instanceId === "string") {
|
|
402
|
+
return {
|
|
403
|
+
kind: "manual-follower",
|
|
404
|
+
instanceId: record.instanceId,
|
|
405
|
+
...(typeof record.telegramProfile === "string"
|
|
406
|
+
? { telegramProfile: record.telegramProfile }
|
|
407
|
+
: {}),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (record.kind === "pending-topic" &&
|
|
411
|
+
typeof record.chatId === "number" &&
|
|
412
|
+
typeof record.threadId === "number" &&
|
|
413
|
+
Number.isInteger(record.threadId)) {
|
|
414
|
+
return {
|
|
415
|
+
kind: "pending-topic",
|
|
416
|
+
chatId: record.chatId,
|
|
417
|
+
threadId: record.threadId,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (record.kind === "legacy" && typeof record.key === "string") {
|
|
421
|
+
return { kind: "legacy", key: record.key };
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
function getRecordOwner(record) {
|
|
426
|
+
return (record.owner ?? getTelegramThreadOwnerFromProfileKey(record.profileKey));
|
|
427
|
+
}
|
|
428
|
+
function getRecordOwnerKey(record) {
|
|
429
|
+
return getTelegramThreadOwnerKey(getRecordOwner(record));
|
|
430
|
+
}
|
|
431
|
+
function cloneRecord(record) {
|
|
432
|
+
const owner = getRecordOwner(record);
|
|
433
|
+
return {
|
|
434
|
+
...record,
|
|
435
|
+
owner: { ...owner },
|
|
436
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
437
|
+
target: { ...record.target },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function getPersistedThreadName(record) {
|
|
441
|
+
const value = typeof record.threadName === "string"
|
|
442
|
+
? record.threadName
|
|
443
|
+
: typeof record.displayName === "string"
|
|
444
|
+
? record.displayName
|
|
445
|
+
: undefined;
|
|
446
|
+
return value ? normalizeTelegramTopicTargetThreadName(value) : undefined;
|
|
447
|
+
}
|
|
448
|
+
function normalizeRecord(value) {
|
|
449
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
450
|
+
return undefined;
|
|
451
|
+
const record = value;
|
|
452
|
+
const target = record.target;
|
|
453
|
+
const owner = parseThreadOwner(record.owner) ??
|
|
454
|
+
(typeof record.profileKey === "string" && record.profileKey.length > 0
|
|
455
|
+
? getTelegramThreadOwnerFromProfileKey(record.profileKey)
|
|
456
|
+
: undefined);
|
|
457
|
+
if (!owner)
|
|
458
|
+
return undefined;
|
|
459
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
460
|
+
return undefined;
|
|
461
|
+
const targetRecord = target;
|
|
462
|
+
if (typeof targetRecord.chatId !== "number" ||
|
|
463
|
+
typeof targetRecord.threadId !== "number" ||
|
|
464
|
+
!Number.isInteger(targetRecord.threadId)) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const status = record.status;
|
|
468
|
+
if (status !== "active" &&
|
|
469
|
+
status !== "offline" &&
|
|
470
|
+
status !== "stale" &&
|
|
471
|
+
status !== "pending" &&
|
|
472
|
+
status !== "starting" &&
|
|
473
|
+
status !== "probe-required" &&
|
|
474
|
+
status !== "failed")
|
|
475
|
+
return undefined;
|
|
476
|
+
if (typeof record.createdAtMs !== "number" ||
|
|
477
|
+
typeof record.updatedAtMs !== "number")
|
|
478
|
+
return undefined;
|
|
479
|
+
const normalized = {
|
|
480
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
481
|
+
owner,
|
|
482
|
+
target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId },
|
|
483
|
+
status,
|
|
484
|
+
createdAtMs: record.createdAtMs,
|
|
485
|
+
updatedAtMs: record.updatedAtMs,
|
|
486
|
+
threadName: getPersistedThreadName(record),
|
|
487
|
+
...(typeof record.manualThreadName === "string" &&
|
|
488
|
+
normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
489
|
+
? { manualThreadName: normalizeTelegramTopicTargetThreadName(record.manualThreadName) }
|
|
490
|
+
: {}),
|
|
491
|
+
instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
492
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
493
|
+
};
|
|
494
|
+
const syncStatus = record.syncStatus ?? record.twinStatus;
|
|
495
|
+
if (syncStatus === "open" ||
|
|
496
|
+
syncStatus === "closed" ||
|
|
497
|
+
syncStatus === "deleted" ||
|
|
498
|
+
syncStatus === "unknown") {
|
|
499
|
+
normalized.syncStatus = syncStatus;
|
|
500
|
+
}
|
|
501
|
+
if (typeof record.lastError === "string")
|
|
502
|
+
normalized.lastError = record.lastError;
|
|
503
|
+
const lastSyncObservedAtMs = record.lastSyncObservedAtMs ?? record.lastTwinObservedAtMs;
|
|
504
|
+
if (typeof lastSyncObservedAtMs === "number") {
|
|
505
|
+
normalized.lastSyncObservedAtMs = lastSyncObservedAtMs;
|
|
506
|
+
}
|
|
507
|
+
const lastSyncProbeAtMs = record.lastSyncProbeAtMs ?? record.lastTwinProbeAtMs;
|
|
508
|
+
if (typeof lastSyncProbeAtMs === "number") {
|
|
509
|
+
normalized.lastSyncProbeAtMs = lastSyncProbeAtMs;
|
|
510
|
+
}
|
|
511
|
+
const lastSyncError = record.lastSyncError ?? record.lastTwinError;
|
|
512
|
+
if (typeof lastSyncError === "string") {
|
|
513
|
+
normalized.lastSyncError = lastSyncError;
|
|
514
|
+
}
|
|
515
|
+
if (typeof record.lastReconcileAction === "string") {
|
|
516
|
+
normalized.lastReconcileAction = record.lastReconcileAction;
|
|
517
|
+
}
|
|
518
|
+
if (typeof record.rerouteConfirmedAtMs === "number") {
|
|
519
|
+
normalized.rerouteConfirmedAtMs = record.rerouteConfirmedAtMs;
|
|
520
|
+
}
|
|
521
|
+
return normalized;
|
|
522
|
+
}
|
|
523
|
+
function isCurrentThreadRecord(record) {
|
|
524
|
+
return (record.status === "active" ||
|
|
525
|
+
record.status === "starting" ||
|
|
526
|
+
record.status === "pending");
|
|
527
|
+
}
|
|
528
|
+
function isPersistedThreadRecord(record) {
|
|
529
|
+
return isCurrentThreadRecord(record) || record.status === "probe-required";
|
|
530
|
+
}
|
|
531
|
+
function normalizeIdentityRecord(value) {
|
|
532
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
533
|
+
return undefined;
|
|
534
|
+
const record = value;
|
|
535
|
+
if (typeof record.profileKey !== "string" || record.profileKey.length === 0)
|
|
536
|
+
return undefined;
|
|
537
|
+
if (typeof record.updatedAtMs !== "number")
|
|
538
|
+
return undefined;
|
|
539
|
+
const identity = {
|
|
540
|
+
profileKey: record.profileKey,
|
|
541
|
+
updatedAtMs: record.updatedAtMs,
|
|
542
|
+
};
|
|
543
|
+
const persistedThreadName = getPersistedThreadName(record);
|
|
544
|
+
if (persistedThreadName) {
|
|
545
|
+
const threadName = normalizeTelegramTopicTargetThreadName(persistedThreadName);
|
|
546
|
+
if (threadName)
|
|
547
|
+
identity.threadName = threadName;
|
|
548
|
+
}
|
|
549
|
+
if (typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)) {
|
|
550
|
+
identity.slot = record.slot;
|
|
551
|
+
}
|
|
552
|
+
return identity.threadName || identity.slot ? identity : undefined;
|
|
553
|
+
}
|
|
554
|
+
function cloneIdentityRecord(identity) {
|
|
555
|
+
return { ...identity };
|
|
556
|
+
}
|
|
557
|
+
function getWorkspaceBindingMapKey(binding) {
|
|
558
|
+
return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
|
|
559
|
+
}
|
|
560
|
+
function normalizeWorkspaceBindingRecord(value) {
|
|
561
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
562
|
+
return undefined;
|
|
563
|
+
const record = value;
|
|
564
|
+
if (typeof record.cwd !== "string" ||
|
|
565
|
+
typeof record.workspaceKey !== "string" ||
|
|
566
|
+
typeof record.instanceSlot !== "string" ||
|
|
567
|
+
typeof record.bindingKey !== "string" ||
|
|
568
|
+
typeof record.updatedAtMs !== "number") {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
const cwd = normalizeTelegramWorkspacePath(record.cwd);
|
|
572
|
+
const sessionId = typeof record.sessionId === "string"
|
|
573
|
+
? normalizeTelegramSessionId(record.sessionId)
|
|
574
|
+
: undefined;
|
|
575
|
+
const sessionKey = typeof record.sessionKey === "string"
|
|
576
|
+
? record.sessionKey
|
|
577
|
+
: undefined;
|
|
578
|
+
const hasSessionFields = record.sessionId !== undefined ||
|
|
579
|
+
record.sessionKey !== undefined;
|
|
580
|
+
const expectedSessionKey = sessionId
|
|
581
|
+
? createTelegramSessionKey(sessionId)
|
|
582
|
+
: undefined;
|
|
583
|
+
const legacyBindingKey = record.instanceSlot === "a"
|
|
584
|
+
? record.workspaceKey
|
|
585
|
+
: `${record.workspaceKey}${record.instanceSlot}`;
|
|
586
|
+
const expectedBindingKey = expectedSessionKey
|
|
587
|
+
? `${legacyBindingKey}-s-${expectedSessionKey}`
|
|
588
|
+
: legacyBindingKey;
|
|
589
|
+
if (!cwd ||
|
|
590
|
+
cwd !== record.cwd ||
|
|
591
|
+
!record.workspaceKey ||
|
|
592
|
+
!/^[a-z]+$/u.test(record.instanceSlot) ||
|
|
593
|
+
(hasSessionFields && (!sessionId || sessionKey !== expectedSessionKey)) ||
|
|
594
|
+
record.bindingKey !== expectedBindingKey) {
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
const targetValue = record.target;
|
|
598
|
+
if (!targetValue ||
|
|
599
|
+
typeof targetValue !== "object" ||
|
|
600
|
+
Array.isArray(targetValue)) {
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
const target = targetValue;
|
|
604
|
+
if (typeof target.chatId !== "number" ||
|
|
605
|
+
typeof target.threadId !== "number" ||
|
|
606
|
+
!Number.isInteger(target.threadId)) {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
const threadName = getPersistedThreadName(record);
|
|
610
|
+
const manualThreadName = typeof record.manualThreadName === "string"
|
|
611
|
+
? normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
612
|
+
: undefined;
|
|
613
|
+
const slot = typeof record.slot === "string" && /^[A-Z]$/u.test(record.slot)
|
|
614
|
+
? record.slot
|
|
615
|
+
: undefined;
|
|
616
|
+
const journalBindingKeys = Array.isArray(record.journalBindingKeys) &&
|
|
617
|
+
record.journalBindingKeys.every((key) => typeof key === "string" && key.length > 0 && key.length <= 512)
|
|
618
|
+
? Array.from(new Set(record.journalBindingKeys))
|
|
619
|
+
: undefined;
|
|
620
|
+
return {
|
|
621
|
+
cwd,
|
|
622
|
+
workspaceKey: record.workspaceKey,
|
|
623
|
+
...(sessionId && sessionKey ? { sessionId, sessionKey } : {}),
|
|
624
|
+
instanceSlot: record.instanceSlot,
|
|
625
|
+
bindingKey: record.bindingKey,
|
|
626
|
+
...(record.showSlotSuffix === true ? { showSlotSuffix: true } : {}),
|
|
627
|
+
...(typeof record.displayTitle === "string" && record.displayTitle.trim()
|
|
628
|
+
? { displayTitle: record.displayTitle }
|
|
629
|
+
: {}),
|
|
630
|
+
...(typeof record.inactiveSinceMs === "number" &&
|
|
631
|
+
Number.isFinite(record.inactiveSinceMs) && record.inactiveSinceMs >= 0
|
|
632
|
+
? { inactiveSinceMs: record.inactiveSinceMs }
|
|
633
|
+
: {}),
|
|
634
|
+
...(journalBindingKeys ? { journalBindingKeys } : {}),
|
|
635
|
+
...(record.journalBindingsComplete === true && journalBindingKeys
|
|
636
|
+
? { journalBindingsComplete: true }
|
|
637
|
+
: {}),
|
|
638
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
639
|
+
...(threadName ? { threadName } : {}),
|
|
640
|
+
...(manualThreadName ? { manualThreadName } : {}),
|
|
641
|
+
...(slot ? { slot } : {}),
|
|
642
|
+
updatedAtMs: record.updatedAtMs,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function cloneWorkspaceBinding(binding) {
|
|
646
|
+
return { ...binding, target: { ...binding.target } };
|
|
647
|
+
}
|
|
648
|
+
function normalizeWorkspaceRetirementIntent(value) {
|
|
649
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
650
|
+
return undefined;
|
|
651
|
+
const record = value;
|
|
652
|
+
const binding = normalizeWorkspaceBindingRecord(record.binding);
|
|
653
|
+
if (typeof record.id !== "string" || !record.id ||
|
|
654
|
+
record.reason !== "pressure" ||
|
|
655
|
+
typeof record.profileKey !== "string" || !record.profileKey ||
|
|
656
|
+
!binding?.slot || binding.inactiveSinceMs === undefined ||
|
|
657
|
+
!((typeof record.leaderEpoch === "number" && Number.isFinite(record.leaderEpoch)) ||
|
|
658
|
+
(typeof record.leaderEpoch === "string" && record.leaderEpoch.length > 0)) ||
|
|
659
|
+
typeof record.requestedAtMs !== "number" || !Number.isFinite(record.requestedAtMs) ||
|
|
660
|
+
record.requestedAtMs < 0)
|
|
661
|
+
return undefined;
|
|
662
|
+
return {
|
|
663
|
+
id: record.id,
|
|
664
|
+
reason: record.reason,
|
|
665
|
+
profileKey: record.profileKey,
|
|
666
|
+
binding,
|
|
667
|
+
leaderEpoch: record.leaderEpoch,
|
|
668
|
+
requestedAtMs: record.requestedAtMs,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function cloneWorkspaceRetirementIntent(intent) {
|
|
672
|
+
return { ...intent, binding: cloneWorkspaceBinding(intent.binding) };
|
|
673
|
+
}
|
|
674
|
+
function normalizeBotStateSnapshot(value) {
|
|
675
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
676
|
+
return { threadMode: "unknown" };
|
|
677
|
+
}
|
|
678
|
+
const record = value;
|
|
679
|
+
const threadMode = record.threadMode === "enabled" || record.threadMode === "disabled"
|
|
680
|
+
? record.threadMode
|
|
681
|
+
: "unknown";
|
|
682
|
+
return {
|
|
683
|
+
threadMode,
|
|
684
|
+
updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : undefined,
|
|
685
|
+
lastSlot: typeof record.lastSlot === "string" && /^[A-Z]$/.test(record.lastSlot)
|
|
686
|
+
? record.lastSlot
|
|
687
|
+
: undefined,
|
|
688
|
+
lastReconcileAction: typeof record.lastReconcileAction === "string"
|
|
689
|
+
? record.lastReconcileAction
|
|
690
|
+
: undefined,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function normalizeSyncObservation(value) {
|
|
694
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
695
|
+
return undefined;
|
|
696
|
+
const record = value;
|
|
697
|
+
const targetValue = record.target;
|
|
698
|
+
if (!targetValue ||
|
|
699
|
+
typeof targetValue !== "object" ||
|
|
700
|
+
Array.isArray(targetValue)) {
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
const targetRecord = targetValue;
|
|
704
|
+
const target = typeof targetRecord.chatId === "number" &&
|
|
705
|
+
typeof targetRecord.threadId === "number" &&
|
|
706
|
+
Number.isInteger(targetRecord.threadId)
|
|
707
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
708
|
+
: undefined;
|
|
709
|
+
const syncStatus = record.syncStatus;
|
|
710
|
+
if (!target ||
|
|
711
|
+
(syncStatus !== "open" &&
|
|
712
|
+
syncStatus !== "closed" &&
|
|
713
|
+
syncStatus !== "deleted" &&
|
|
714
|
+
syncStatus !== "unknown")) {
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
target,
|
|
719
|
+
syncStatus,
|
|
720
|
+
observedAtMs: typeof record.observedAtMs === "number" ? record.observedAtMs : 0,
|
|
721
|
+
instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
722
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
723
|
+
lastSyncError: typeof record.lastSyncError === "string"
|
|
724
|
+
? record.lastSyncError
|
|
725
|
+
: undefined,
|
|
726
|
+
lastReconcileAction: typeof record.lastReconcileAction === "string"
|
|
727
|
+
? record.lastReconcileAction
|
|
728
|
+
: undefined,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function normalizePendingProvision(value) {
|
|
732
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
733
|
+
return undefined;
|
|
734
|
+
const record = value;
|
|
735
|
+
const owner = record.owner;
|
|
736
|
+
if (owner !== "leader" && owner !== "manual-follower")
|
|
737
|
+
return undefined;
|
|
738
|
+
if (typeof record.id !== "string" || record.id.length === 0)
|
|
739
|
+
return undefined;
|
|
740
|
+
if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
|
|
741
|
+
return undefined;
|
|
742
|
+
if (typeof record.startedAtMs !== "number")
|
|
743
|
+
return undefined;
|
|
744
|
+
let target;
|
|
745
|
+
const targetValue = record.target;
|
|
746
|
+
if (targetValue &&
|
|
747
|
+
typeof targetValue === "object" &&
|
|
748
|
+
!Array.isArray(targetValue)) {
|
|
749
|
+
const targetRecord = targetValue;
|
|
750
|
+
if (typeof targetRecord.chatId === "number" &&
|
|
751
|
+
typeof targetRecord.threadId === "number" &&
|
|
752
|
+
Number.isInteger(targetRecord.threadId)) {
|
|
753
|
+
target = { chatId: targetRecord.chatId, threadId: targetRecord.threadId };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
id: record.id,
|
|
758
|
+
owner,
|
|
759
|
+
instanceId: record.instanceId,
|
|
760
|
+
...(typeof record.profileKey === "string"
|
|
761
|
+
? { profileKey: record.profileKey }
|
|
762
|
+
: {}),
|
|
763
|
+
...(record.status === "in-flight" || record.status === "ambiguous"
|
|
764
|
+
? { status: record.status }
|
|
765
|
+
: {}),
|
|
766
|
+
...(typeof record.threadName === "string"
|
|
767
|
+
? { threadName: record.threadName }
|
|
768
|
+
: {}),
|
|
769
|
+
...(typeof record.displayTitle === "string" && record.displayTitle.trim()
|
|
770
|
+
? { displayTitle: record.displayTitle }
|
|
771
|
+
: {}),
|
|
772
|
+
...(typeof record.slot === "string" ? { slot: record.slot } : {}),
|
|
773
|
+
...(target ? { target } : {}),
|
|
774
|
+
startedAtMs: record.startedAtMs,
|
|
775
|
+
...(typeof record.expiresAtMs === "number"
|
|
776
|
+
? { expiresAtMs: record.expiresAtMs }
|
|
777
|
+
: {}),
|
|
778
|
+
...(typeof record.leaderEpoch === "number" ||
|
|
779
|
+
typeof record.leaderEpoch === "string"
|
|
780
|
+
? { leaderEpoch: record.leaderEpoch }
|
|
781
|
+
: {}),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function normalizePendingCleanup(value) {
|
|
785
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
786
|
+
return undefined;
|
|
787
|
+
const record = value;
|
|
788
|
+
const targetValue = record.target;
|
|
789
|
+
if (!targetValue ||
|
|
790
|
+
typeof targetValue !== "object" ||
|
|
791
|
+
Array.isArray(targetValue)) {
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|
|
794
|
+
const targetRecord = targetValue;
|
|
795
|
+
const owner = record.owner;
|
|
796
|
+
if (owner !== "leader" && owner !== "manual-follower")
|
|
797
|
+
return undefined;
|
|
798
|
+
if (typeof record.id !== "string" || record.id.length === 0)
|
|
799
|
+
return undefined;
|
|
800
|
+
if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
|
|
801
|
+
return undefined;
|
|
802
|
+
if (typeof record.runtimeGeneration !== "string" ||
|
|
803
|
+
record.runtimeGeneration.length === 0) {
|
|
804
|
+
return undefined;
|
|
805
|
+
}
|
|
806
|
+
if (typeof targetRecord.chatId !== "number" ||
|
|
807
|
+
typeof targetRecord.threadId !== "number" ||
|
|
808
|
+
!Number.isInteger(targetRecord.threadId) ||
|
|
809
|
+
typeof record.requestedAtMs !== "number") {
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
id: record.id,
|
|
814
|
+
owner,
|
|
815
|
+
instanceId: record.instanceId,
|
|
816
|
+
runtimeGeneration: record.runtimeGeneration,
|
|
817
|
+
...(typeof record.profileKey === "string"
|
|
818
|
+
? { profileKey: record.profileKey }
|
|
819
|
+
: {}),
|
|
820
|
+
target: {
|
|
821
|
+
chatId: targetRecord.chatId,
|
|
822
|
+
threadId: targetRecord.threadId,
|
|
823
|
+
},
|
|
824
|
+
requestedAtMs: record.requestedAtMs,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function normalizeReservation(value) {
|
|
828
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
829
|
+
return undefined;
|
|
830
|
+
const record = value;
|
|
831
|
+
const targetValue = record.target;
|
|
832
|
+
if (!targetValue ||
|
|
833
|
+
typeof targetValue !== "object" ||
|
|
834
|
+
Array.isArray(targetValue)) {
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
const targetRecord = targetValue;
|
|
838
|
+
const target = typeof targetRecord.chatId === "number" &&
|
|
839
|
+
typeof targetRecord.threadId === "number" &&
|
|
840
|
+
Number.isInteger(targetRecord.threadId)
|
|
841
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
842
|
+
: undefined;
|
|
843
|
+
const slot = typeof record.slot === "string" ? record.slot : undefined;
|
|
844
|
+
const reason = typeof record.reason === "string" ? record.reason : undefined;
|
|
845
|
+
if (!target || !slot || !reason)
|
|
846
|
+
return undefined;
|
|
847
|
+
return {
|
|
848
|
+
target,
|
|
849
|
+
slot,
|
|
850
|
+
reason,
|
|
851
|
+
createdAtMs: typeof record.createdAtMs === "number" ? record.createdAtMs : 0,
|
|
852
|
+
updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : 0,
|
|
853
|
+
expiresAtMs: typeof record.expiresAtMs === "number" ? record.expiresAtMs : undefined,
|
|
854
|
+
instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
855
|
+
lastReconcileAction: typeof record.lastReconcileAction === "string"
|
|
856
|
+
? record.lastReconcileAction
|
|
857
|
+
: undefined,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function parseTopicTargetFile(value) {
|
|
861
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
862
|
+
return {
|
|
863
|
+
version: 1,
|
|
864
|
+
source: "snapshot",
|
|
865
|
+
writtenAtMs: 0,
|
|
866
|
+
bot: { threadMode: "unknown" },
|
|
867
|
+
threads: [],
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const file = value;
|
|
871
|
+
if (file.version !== 1) {
|
|
872
|
+
return {
|
|
873
|
+
version: 1,
|
|
874
|
+
source: "snapshot",
|
|
875
|
+
writtenAtMs: 0,
|
|
876
|
+
bot: { threadMode: "unknown" },
|
|
877
|
+
threads: [],
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const rawThreads = Array.isArray(file.threads) ? file.threads : [];
|
|
881
|
+
const threads = rawThreads
|
|
882
|
+
.map((record) => normalizeRecord(record))
|
|
883
|
+
.filter((record) => !!record && isPersistedThreadRecord(record));
|
|
884
|
+
return {
|
|
885
|
+
version: 1,
|
|
886
|
+
source: "snapshot",
|
|
887
|
+
writtenAtMs: typeof file.writtenAtMs === "number" ? file.writtenAtMs : 0,
|
|
888
|
+
bot: normalizeBotStateSnapshot(file.bot),
|
|
889
|
+
threads,
|
|
890
|
+
identities: Array.isArray(file.identities)
|
|
891
|
+
? file.identities.flatMap((identity) => {
|
|
892
|
+
const normalized = normalizeIdentityRecord(identity);
|
|
893
|
+
return normalized ? [normalized] : [];
|
|
894
|
+
})
|
|
895
|
+
: [],
|
|
896
|
+
workspaceBindings: Array.isArray(file.workspaceBindings)
|
|
897
|
+
? file.workspaceBindings.flatMap((binding) => {
|
|
898
|
+
const normalized = normalizeWorkspaceBindingRecord(binding);
|
|
899
|
+
return normalized ? [normalized] : [];
|
|
900
|
+
})
|
|
901
|
+
: [],
|
|
902
|
+
workspaceRetirements: Array.isArray(file.workspaceRetirements)
|
|
903
|
+
? file.workspaceRetirements.flatMap((intent) => {
|
|
904
|
+
const normalized = normalizeWorkspaceRetirementIntent(intent);
|
|
905
|
+
return normalized ? [normalized] : [];
|
|
906
|
+
})
|
|
907
|
+
: [],
|
|
908
|
+
reservations: Array.isArray(file.reservations)
|
|
909
|
+
? file.reservations.flatMap((reservation) => {
|
|
910
|
+
const normalized = normalizeReservation(reservation);
|
|
911
|
+
return normalized ? [normalized] : [];
|
|
912
|
+
})
|
|
913
|
+
: [],
|
|
914
|
+
pendingProvisions: Array.isArray(file.pendingProvisions)
|
|
915
|
+
? file.pendingProvisions.flatMap((provision) => {
|
|
916
|
+
const normalized = normalizePendingProvision(provision);
|
|
917
|
+
return normalized ? [normalized] : [];
|
|
918
|
+
})
|
|
919
|
+
: [],
|
|
920
|
+
pendingCleanups: Array.isArray(file.pendingCleanups)
|
|
921
|
+
? file.pendingCleanups.flatMap((intent) => {
|
|
922
|
+
const normalized = normalizePendingCleanup(intent);
|
|
923
|
+
return normalized ? [normalized] : [];
|
|
924
|
+
})
|
|
925
|
+
: [],
|
|
926
|
+
syncObservations: Array.isArray(file.syncObservations)
|
|
927
|
+
? file.syncObservations.flatMap((observation) => {
|
|
928
|
+
const normalized = normalizeSyncObservation(observation);
|
|
929
|
+
return normalized ? [normalized] : [];
|
|
930
|
+
})
|
|
931
|
+
: [],
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function getTelegramStateSemanticSnapshot(value) {
|
|
935
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
936
|
+
return undefined;
|
|
937
|
+
const { writtenAtMs: _writtenAtMs, ...semantic } = value;
|
|
938
|
+
return semantic;
|
|
939
|
+
}
|
|
940
|
+
function targetMatches(left, right) {
|
|
941
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
942
|
+
}
|
|
943
|
+
function getTargetRecoveryHintKey(target) {
|
|
944
|
+
return `${target.chatId}:${target.threadId ?? "private"}`;
|
|
945
|
+
}
|
|
946
|
+
function parseFollowerRecoveryHints(value) {
|
|
947
|
+
const hints = new Map();
|
|
948
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
949
|
+
return hints;
|
|
950
|
+
const liveRoster = value.liveRoster;
|
|
951
|
+
if (!liveRoster ||
|
|
952
|
+
typeof liveRoster !== "object" ||
|
|
953
|
+
Array.isArray(liveRoster))
|
|
954
|
+
return hints;
|
|
955
|
+
const followers = liveRoster.busFollowers;
|
|
956
|
+
if (!Array.isArray(followers))
|
|
957
|
+
return hints;
|
|
958
|
+
for (const follower of followers) {
|
|
959
|
+
if (!follower || typeof follower !== "object" || Array.isArray(follower))
|
|
960
|
+
continue;
|
|
961
|
+
const record = follower;
|
|
962
|
+
const target = record.target;
|
|
963
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
964
|
+
continue;
|
|
965
|
+
const targetRecord = target;
|
|
966
|
+
if (typeof targetRecord.chatId !== "number")
|
|
967
|
+
continue;
|
|
968
|
+
const normalizedTarget = {
|
|
969
|
+
chatId: targetRecord.chatId,
|
|
970
|
+
...(typeof targetRecord.threadId === "number"
|
|
971
|
+
? { threadId: targetRecord.threadId }
|
|
972
|
+
: {}),
|
|
973
|
+
};
|
|
974
|
+
const slot = typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot)
|
|
975
|
+
? targetRecord.slot
|
|
976
|
+
: typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)
|
|
977
|
+
? record.slot
|
|
978
|
+
: undefined;
|
|
979
|
+
const threadName = typeof targetRecord.threadName === "string"
|
|
980
|
+
? targetRecord.threadName
|
|
981
|
+
: typeof record.threadName === "string"
|
|
982
|
+
? record.threadName
|
|
983
|
+
: undefined;
|
|
984
|
+
hints.set(getTargetRecoveryHintKey(normalizedTarget), {
|
|
985
|
+
...(slot ? { slot } : {}),
|
|
986
|
+
...(threadName ? { threadName } : {}),
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
return hints;
|
|
990
|
+
}
|
|
991
|
+
function getInstanceProcessKey(instanceId) {
|
|
992
|
+
if (!instanceId)
|
|
993
|
+
return undefined;
|
|
994
|
+
const [pid] = instanceId.split(":", 1);
|
|
995
|
+
return pid && /^\d+$/.test(pid) ? pid : undefined;
|
|
996
|
+
}
|
|
997
|
+
export function isSameTelegramProcessInstance(left, right) {
|
|
998
|
+
const leftProcess = getInstanceProcessKey(left);
|
|
999
|
+
return !!leftProcess && leftProcess === getInstanceProcessKey(right);
|
|
1000
|
+
}
|
|
1001
|
+
function isPendingProvisionLiveOrTargeted(provision, nowMs) {
|
|
1002
|
+
if (provision.status === "ambiguous")
|
|
1003
|
+
return true;
|
|
1004
|
+
if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
return !!provision.target;
|
|
1008
|
+
}
|
|
1009
|
+
export function createTelegramTopicTargetStore(options) {
|
|
1010
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
1011
|
+
const captureExternalReservedSlots = () => {
|
|
1012
|
+
try {
|
|
1013
|
+
const slots = options.getExternalReservedSlots?.() ?? [];
|
|
1014
|
+
if (!Array.isArray(slots) ||
|
|
1015
|
+
slots.some((slot) => typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) {
|
|
1016
|
+
return undefined;
|
|
1017
|
+
}
|
|
1018
|
+
return Array.from(new Set(slots));
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
let botState = { threadMode: "unknown" };
|
|
1025
|
+
let records = new Map();
|
|
1026
|
+
let identities = new Map();
|
|
1027
|
+
let workspaceBindings = new Map();
|
|
1028
|
+
let workspaceRetirements = [];
|
|
1029
|
+
let workspaceRetirementCommitInFlight = false;
|
|
1030
|
+
const hasWorkspaceRetirementConflict = (input) => workspaceRetirements.some((intent) => (input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
|
|
1031
|
+
(input.cwd !== undefined && intent.binding.cwd === input.cwd) ||
|
|
1032
|
+
(input.target !== undefined && targetMatches(intent.binding.target, input.target)) ||
|
|
1033
|
+
(input.slot !== undefined && intent.binding.slot === input.slot));
|
|
1034
|
+
let workspaceClaims = new Map();
|
|
1035
|
+
let reservations = [];
|
|
1036
|
+
let pendingProvisions = [];
|
|
1037
|
+
let pendingCleanups = [];
|
|
1038
|
+
let syncObservations = [];
|
|
1039
|
+
let followerRecoveryHints = new Map();
|
|
1040
|
+
let loaded = false;
|
|
1041
|
+
let loadedPath;
|
|
1042
|
+
let dirty = false;
|
|
1043
|
+
let mutationRevision = 0;
|
|
1044
|
+
let statusRevision = 0;
|
|
1045
|
+
let persistQueue = Promise.resolve();
|
|
1046
|
+
let statusSnapshot = {};
|
|
1047
|
+
const reconcileWorkspaceSuffixExposure = () => {
|
|
1048
|
+
const directoryCounts = new Map();
|
|
1049
|
+
const exposed = new Set();
|
|
1050
|
+
for (const binding of workspaceBindings.values()) {
|
|
1051
|
+
directoryCounts.set(binding.cwd, (directoryCounts.get(binding.cwd) ?? 0) + 1);
|
|
1052
|
+
if (binding.showSlotSuffix)
|
|
1053
|
+
exposed.add(binding.cwd);
|
|
1054
|
+
}
|
|
1055
|
+
for (const binding of workspaceBindings.values()) {
|
|
1056
|
+
if ((directoryCounts.get(binding.cwd) ?? 0) > 1 || exposed.has(binding.cwd)) {
|
|
1057
|
+
binding.showSlotSuffix = true;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
const rememberSlot = (slot, nowMs = getNowMs()) => {
|
|
1062
|
+
if (!slot || !/^[A-Z]$/.test(slot))
|
|
1063
|
+
return;
|
|
1064
|
+
botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
|
|
1065
|
+
};
|
|
1066
|
+
const rememberIdentity = (record) => {
|
|
1067
|
+
const profileKey = getRecordOwnerKey(record);
|
|
1068
|
+
if (!record.threadName && !record.slot)
|
|
1069
|
+
return;
|
|
1070
|
+
identities.set(profileKey, {
|
|
1071
|
+
profileKey,
|
|
1072
|
+
...(record.threadName ? { threadName: record.threadName } : {}),
|
|
1073
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1074
|
+
updatedAtMs: record.updatedAtMs,
|
|
1075
|
+
});
|
|
1076
|
+
};
|
|
1077
|
+
const resolveWorkspaceKey = (cwd) => {
|
|
1078
|
+
const known = [
|
|
1079
|
+
...Array.from(workspaceBindings.values()),
|
|
1080
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity),
|
|
1081
|
+
];
|
|
1082
|
+
const existing = known.find((binding) => binding.cwd === cwd);
|
|
1083
|
+
if (existing)
|
|
1084
|
+
return existing.workspaceKey;
|
|
1085
|
+
const readable = createTelegramWorkspaceDirectoryKey(cwd);
|
|
1086
|
+
if (!readable)
|
|
1087
|
+
return undefined;
|
|
1088
|
+
const collision = known.some((binding) => binding.workspaceKey === readable && binding.cwd !== cwd);
|
|
1089
|
+
if (!collision)
|
|
1090
|
+
return readable;
|
|
1091
|
+
const digest = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
1092
|
+
const prefix = readable.endsWith("--") ? readable.slice(0, -2) : readable;
|
|
1093
|
+
return `${prefix.slice(0, TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 3)}-${digest}--`;
|
|
1094
|
+
};
|
|
1095
|
+
const isWorkspaceTargetLive = (binding) => Array.from(records.values()).find((record) => isCurrentThreadRecord(record) &&
|
|
1096
|
+
targetMatches(record.target, binding.target));
|
|
1097
|
+
const findLegacyWorkspaceMigrationRecord = (cwd, instanceId, previousInstanceId) => {
|
|
1098
|
+
const processIds = new Set([instanceId, previousInstanceId].filter((value) => !!value));
|
|
1099
|
+
return Array.from(records.values())
|
|
1100
|
+
.filter((record) => {
|
|
1101
|
+
const owner = getRecordOwner(record);
|
|
1102
|
+
const belongsToWorkspace = owner.kind === "leader"
|
|
1103
|
+
? owner.cwd
|
|
1104
|
+
? normalizeTelegramWorkspacePath(owner.cwd) === cwd
|
|
1105
|
+
: !!record.instanceId && Array.from(processIds).some((processId) => isSameTelegramProcessInstance(record.instanceId, processId))
|
|
1106
|
+
: owner.kind === "manual-follower" &&
|
|
1107
|
+
(processIds.has(owner.instanceId) ||
|
|
1108
|
+
(!!record.instanceId && processIds.has(record.instanceId)));
|
|
1109
|
+
if (!belongsToWorkspace)
|
|
1110
|
+
return false;
|
|
1111
|
+
const targetBinding = Array.from(workspaceBindings.values()).find((binding) => targetMatches(binding.target, record.target));
|
|
1112
|
+
return !targetBinding || targetBinding.cwd === cwd;
|
|
1113
|
+
})
|
|
1114
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
|
|
1115
|
+
};
|
|
1116
|
+
const getPath = () => typeof options.path === "function" ? options.path() : options.path;
|
|
1117
|
+
const getTelegramProfile = () => typeof options.telegramProfile === "function"
|
|
1118
|
+
? options.telegramProfile()
|
|
1119
|
+
: options.telegramProfile;
|
|
1120
|
+
const scopeOwnerToActiveProfile = (owner) => {
|
|
1121
|
+
const telegramProfile = getTelegramProfile();
|
|
1122
|
+
if (!telegramProfile ||
|
|
1123
|
+
owner.kind === "pending-topic" ||
|
|
1124
|
+
owner.kind === "legacy" ||
|
|
1125
|
+
owner.telegramProfile) {
|
|
1126
|
+
return owner;
|
|
1127
|
+
}
|
|
1128
|
+
return { ...owner, telegramProfile };
|
|
1129
|
+
};
|
|
1130
|
+
const getRecoveryPath = (path) => `${path}.provision-recovery.json`;
|
|
1131
|
+
const readProvisionRecoveries = (path) => {
|
|
1132
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1133
|
+
if (!existsSync(recoveryPath))
|
|
1134
|
+
return {};
|
|
1135
|
+
try {
|
|
1136
|
+
const value = JSON.parse(readFileSync(recoveryPath, "utf8"));
|
|
1137
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1138
|
+
? value
|
|
1139
|
+
: {};
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return {};
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
const resetForPath = (path) => {
|
|
1146
|
+
if (loadedPath === path)
|
|
1147
|
+
return;
|
|
1148
|
+
botState = { threadMode: "unknown" };
|
|
1149
|
+
records = new Map();
|
|
1150
|
+
identities = new Map();
|
|
1151
|
+
workspaceBindings = new Map();
|
|
1152
|
+
workspaceRetirements = [];
|
|
1153
|
+
workspaceRetirementCommitInFlight = false;
|
|
1154
|
+
workspaceClaims = new Map();
|
|
1155
|
+
reservations = [];
|
|
1156
|
+
pendingProvisions = [];
|
|
1157
|
+
pendingCleanups = [];
|
|
1158
|
+
syncObservations = [];
|
|
1159
|
+
followerRecoveryHints = new Map();
|
|
1160
|
+
statusSnapshot = {};
|
|
1161
|
+
loaded = false;
|
|
1162
|
+
dirty = false;
|
|
1163
|
+
loadedPath = path;
|
|
1164
|
+
};
|
|
1165
|
+
const loadFromDisk = async () => {
|
|
1166
|
+
const path = getPath();
|
|
1167
|
+
resetForPath(path);
|
|
1168
|
+
if (!existsSync(path)) {
|
|
1169
|
+
botState = { threadMode: "unknown" };
|
|
1170
|
+
records = new Map();
|
|
1171
|
+
identities = new Map();
|
|
1172
|
+
workspaceBindings = new Map();
|
|
1173
|
+
workspaceRetirements = [];
|
|
1174
|
+
workspaceRetirementCommitInFlight = false;
|
|
1175
|
+
reservations = [];
|
|
1176
|
+
pendingProvisions = [];
|
|
1177
|
+
pendingCleanups = [];
|
|
1178
|
+
syncObservations = [];
|
|
1179
|
+
followerRecoveryHints = new Map();
|
|
1180
|
+
loaded = true;
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const revision = mutationRevision;
|
|
1184
|
+
const content = await readFile(path, "utf8");
|
|
1185
|
+
// A read begun before a local mutation must not replace the newly admitted
|
|
1186
|
+
// binding/cleanup state with its older disk snapshot.
|
|
1187
|
+
if (mutationRevision !== revision || getPath() !== path)
|
|
1188
|
+
return;
|
|
1189
|
+
const rawFile = JSON.parse(content);
|
|
1190
|
+
const file = parseTopicTargetFile(rawFile);
|
|
1191
|
+
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
1192
|
+
botState = file.bot;
|
|
1193
|
+
const scopedRecords = file.threads.map((record) => cloneRecord({
|
|
1194
|
+
...record,
|
|
1195
|
+
owner: scopeOwnerToActiveProfile(getRecordOwner(record)),
|
|
1196
|
+
}));
|
|
1197
|
+
records = new Map(scopedRecords.map((record) => [getRecordOwnerKey(record), record]));
|
|
1198
|
+
identities = new Map((file.identities ?? []).map((identity) => {
|
|
1199
|
+
const owner = scopeOwnerToActiveProfile(getTelegramThreadOwnerFromProfileKey(identity.profileKey));
|
|
1200
|
+
const profileKey = getTelegramThreadOwnerKey(owner);
|
|
1201
|
+
return [
|
|
1202
|
+
profileKey,
|
|
1203
|
+
cloneIdentityRecord({ ...identity, profileKey }),
|
|
1204
|
+
];
|
|
1205
|
+
}));
|
|
1206
|
+
workspaceBindings = new Map((file.workspaceBindings ?? []).map((binding) => [
|
|
1207
|
+
getWorkspaceBindingMapKey(binding),
|
|
1208
|
+
cloneWorkspaceBinding(binding),
|
|
1209
|
+
]));
|
|
1210
|
+
workspaceRetirements = (file.workspaceRetirements ?? []).map(cloneWorkspaceRetirementIntent);
|
|
1211
|
+
reconcileWorkspaceSuffixExposure();
|
|
1212
|
+
for (const record of records.values())
|
|
1213
|
+
rememberIdentity(record);
|
|
1214
|
+
const nowMs = getNowMs();
|
|
1215
|
+
reservations = (file.reservations ?? [])
|
|
1216
|
+
.filter((reservation) => reservation.expiresAtMs === undefined ||
|
|
1217
|
+
reservation.expiresAtMs > nowMs)
|
|
1218
|
+
.map((reservation) => ({ ...reservation }));
|
|
1219
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1220
|
+
pendingProvisions = (file.pendingProvisions ?? [])
|
|
1221
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
1222
|
+
.map((provision) => {
|
|
1223
|
+
const recovery = recoveries[provision.id];
|
|
1224
|
+
const recoveryMatches = recovery?.instanceId === provision.instanceId &&
|
|
1225
|
+
recovery.profileKey === provision.profileKey &&
|
|
1226
|
+
recovery.leaderEpoch === provision.leaderEpoch &&
|
|
1227
|
+
Number.isInteger(recovery.target?.threadId);
|
|
1228
|
+
return {
|
|
1229
|
+
...provision,
|
|
1230
|
+
...(recoveryMatches
|
|
1231
|
+
? { target: { ...recovery.target }, status: "ambiguous" }
|
|
1232
|
+
: provision.target
|
|
1233
|
+
? { target: { ...provision.target } }
|
|
1234
|
+
: {}),
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1237
|
+
pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
|
|
1238
|
+
...intent,
|
|
1239
|
+
target: { ...intent.target },
|
|
1240
|
+
}));
|
|
1241
|
+
syncObservations = (file.syncObservations ?? []).map((observation) => ({
|
|
1242
|
+
...observation,
|
|
1243
|
+
target: { ...observation.target },
|
|
1244
|
+
}));
|
|
1245
|
+
loaded = true;
|
|
1246
|
+
dirty = false;
|
|
1247
|
+
};
|
|
1248
|
+
const markDirty = () => {
|
|
1249
|
+
loaded = true;
|
|
1250
|
+
dirty = true;
|
|
1251
|
+
mutationRevision += 1;
|
|
1252
|
+
};
|
|
1253
|
+
const persistSnapshot = (invalidation) => {
|
|
1254
|
+
const persist = persistQueue.then(async () => {
|
|
1255
|
+
const path = getPath();
|
|
1256
|
+
if (loadedPath !== path && !dirty)
|
|
1257
|
+
resetForPath(path);
|
|
1258
|
+
if (options.canPersist && !options.canPersist()) {
|
|
1259
|
+
if (!invalidation)
|
|
1260
|
+
await loadFromDisk();
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
if (!dirty || !loaded)
|
|
1264
|
+
await loadFromDisk();
|
|
1265
|
+
if (invalidation && !invalidation.isCurrent())
|
|
1266
|
+
return false;
|
|
1267
|
+
const nowMs = getNowMs();
|
|
1268
|
+
reservations = reservations.filter((reservation) => reservation.expiresAtMs === undefined ||
|
|
1269
|
+
reservation.expiresAtMs > nowMs);
|
|
1270
|
+
pendingProvisions = pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs));
|
|
1271
|
+
const currentRecords = Array.from(records.values())
|
|
1272
|
+
.filter(isPersistedThreadRecord)
|
|
1273
|
+
.map(cloneRecord);
|
|
1274
|
+
records = new Map(currentRecords.map((record) => [
|
|
1275
|
+
getRecordOwnerKey(record),
|
|
1276
|
+
cloneRecord(record),
|
|
1277
|
+
]));
|
|
1278
|
+
const persistedRevision = mutationRevision;
|
|
1279
|
+
const persistedStatusRevision = statusRevision;
|
|
1280
|
+
const file = {
|
|
1281
|
+
version: 1,
|
|
1282
|
+
source: "snapshot",
|
|
1283
|
+
writtenAtMs: nowMs,
|
|
1284
|
+
bot: botState,
|
|
1285
|
+
...statusSnapshot,
|
|
1286
|
+
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
1287
|
+
workspaceBindings: Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding),
|
|
1288
|
+
workspaceRetirements: workspaceRetirements.map(cloneWorkspaceRetirementIntent),
|
|
1289
|
+
reservations: reservations.map((reservation) => ({ ...reservation })),
|
|
1290
|
+
pendingProvisions: pendingProvisions.map((provision) => ({
|
|
1291
|
+
...provision,
|
|
1292
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1293
|
+
})),
|
|
1294
|
+
pendingCleanups: pendingCleanups.map((intent) => ({
|
|
1295
|
+
...intent,
|
|
1296
|
+
target: { ...intent.target },
|
|
1297
|
+
})),
|
|
1298
|
+
syncObservations: syncObservations.map((observation) => ({
|
|
1299
|
+
...observation,
|
|
1300
|
+
target: { ...observation.target },
|
|
1301
|
+
})),
|
|
1302
|
+
threads: currentRecords.map((record) => {
|
|
1303
|
+
const { profileKey: _profileKey, ...serialized } = record;
|
|
1304
|
+
return serialized;
|
|
1305
|
+
}),
|
|
1306
|
+
};
|
|
1307
|
+
if (invalidation) {
|
|
1308
|
+
const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
|
|
1309
|
+
if (!record)
|
|
1310
|
+
return false;
|
|
1311
|
+
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
1312
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
1313
|
+
file.syncObservations.push({
|
|
1314
|
+
target: { ...record.target },
|
|
1315
|
+
syncStatus: "deleted",
|
|
1316
|
+
observedAtMs: nowMs,
|
|
1317
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
1318
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1319
|
+
lastSyncError: invalidation.lastSyncError,
|
|
1320
|
+
lastReconcileAction: "mark-stale",
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
let persistedSemanticSnapshot;
|
|
1324
|
+
try {
|
|
1325
|
+
persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(JSON.parse(await readFile(path, "utf8")));
|
|
1326
|
+
}
|
|
1327
|
+
catch {
|
|
1328
|
+
/* missing or invalid snapshots must be replaced */
|
|
1329
|
+
}
|
|
1330
|
+
// Normalize optional fields to wire JSON; object key order is not a state change.
|
|
1331
|
+
if (isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
|
|
1332
|
+
mutationRevision === persistedRevision &&
|
|
1333
|
+
statusRevision === persistedStatusRevision) {
|
|
1334
|
+
loadedPath = path;
|
|
1335
|
+
loaded = true;
|
|
1336
|
+
dirty = false;
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1340
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1341
|
+
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1342
|
+
encoding: "utf8",
|
|
1343
|
+
mode: 0o600,
|
|
1344
|
+
});
|
|
1345
|
+
await chmod(tempPath, 0o600);
|
|
1346
|
+
try {
|
|
1347
|
+
if (invalidation) {
|
|
1348
|
+
let applied = false;
|
|
1349
|
+
const commit = () => {
|
|
1350
|
+
if (getPath() !== path || mutationRevision !== persistedRevision ||
|
|
1351
|
+
statusRevision !== persistedStatusRevision || !invalidation.isCurrent())
|
|
1352
|
+
return;
|
|
1353
|
+
// Fence and rename share one synchronous commit boundary. No stale
|
|
1354
|
+
// invalidation enters the live projection before durable commit.
|
|
1355
|
+
renameSync(tempPath, path);
|
|
1356
|
+
loadedPath = path;
|
|
1357
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
|
|
1358
|
+
syncObservations = file.syncObservations;
|
|
1359
|
+
mutationRevision += 1;
|
|
1360
|
+
dirty = false;
|
|
1361
|
+
applied = true;
|
|
1362
|
+
};
|
|
1363
|
+
if (options.commitPersist)
|
|
1364
|
+
options.commitPersist(commit);
|
|
1365
|
+
else if (!options.canPersist || options.canPersist())
|
|
1366
|
+
commit();
|
|
1367
|
+
if (!applied)
|
|
1368
|
+
await unlink(tempPath).catch(() => undefined);
|
|
1369
|
+
return applied;
|
|
1370
|
+
}
|
|
1371
|
+
if (options.commitPersist) {
|
|
1372
|
+
const committed = options.commitPersist(() => {
|
|
1373
|
+
renameSync(tempPath, path);
|
|
1374
|
+
chmodSync(path, 0o600);
|
|
1375
|
+
});
|
|
1376
|
+
if (!committed) {
|
|
1377
|
+
await loadFromDisk();
|
|
1378
|
+
throw new Error("Telegram thread snapshot lost exact transport ownership before commit.");
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
await rename(tempPath, path);
|
|
1383
|
+
await chmod(path, 0o600);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
await unlink(tempPath).catch(() => undefined);
|
|
1388
|
+
throw error;
|
|
1389
|
+
}
|
|
1390
|
+
loadedPath = path;
|
|
1391
|
+
loaded = true;
|
|
1392
|
+
if (mutationRevision === persistedRevision)
|
|
1393
|
+
dirty = false;
|
|
1394
|
+
return true;
|
|
1395
|
+
});
|
|
1396
|
+
persistQueue = persist.then(() => undefined, () => undefined);
|
|
1397
|
+
return persist;
|
|
1398
|
+
};
|
|
1399
|
+
return {
|
|
1400
|
+
async load() {
|
|
1401
|
+
if (dirty)
|
|
1402
|
+
return;
|
|
1403
|
+
await loadFromDisk();
|
|
1404
|
+
},
|
|
1405
|
+
refresh() {
|
|
1406
|
+
const refresh = persistQueue.then(loadFromDisk);
|
|
1407
|
+
persistQueue = refresh.catch(() => undefined);
|
|
1408
|
+
return refresh;
|
|
1409
|
+
},
|
|
1410
|
+
async persist() {
|
|
1411
|
+
await persistSnapshot();
|
|
1412
|
+
},
|
|
1413
|
+
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
1414
|
+
return persistSnapshot({ target, isCurrent, lastSyncError });
|
|
1415
|
+
},
|
|
1416
|
+
list() {
|
|
1417
|
+
return Array.from(records.values()).map(cloneRecord);
|
|
1418
|
+
},
|
|
1419
|
+
getFollowerRecoveryHintByTarget(target) {
|
|
1420
|
+
const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target));
|
|
1421
|
+
return hint ? { ...hint } : undefined;
|
|
1422
|
+
},
|
|
1423
|
+
listReservations() {
|
|
1424
|
+
const nowMs = getNowMs();
|
|
1425
|
+
return reservations
|
|
1426
|
+
.filter((reservation) => reservation.expiresAtMs === undefined ||
|
|
1427
|
+
reservation.expiresAtMs > nowMs)
|
|
1428
|
+
.map((reservation) => ({ ...reservation }));
|
|
1429
|
+
},
|
|
1430
|
+
listPendingProvisions() {
|
|
1431
|
+
const nowMs = getNowMs();
|
|
1432
|
+
return pendingProvisions
|
|
1433
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
1434
|
+
.map((provision) => ({
|
|
1435
|
+
...provision,
|
|
1436
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1437
|
+
}));
|
|
1438
|
+
},
|
|
1439
|
+
listPendingCleanups() {
|
|
1440
|
+
return pendingCleanups.map((intent) => ({
|
|
1441
|
+
...intent,
|
|
1442
|
+
target: { ...intent.target },
|
|
1443
|
+
}));
|
|
1444
|
+
},
|
|
1445
|
+
listSyncObservations() {
|
|
1446
|
+
return syncObservations.map((observation) => ({
|
|
1447
|
+
...observation,
|
|
1448
|
+
target: { ...observation.target },
|
|
1449
|
+
}));
|
|
1450
|
+
},
|
|
1451
|
+
reserveThread(reservation) {
|
|
1452
|
+
const next = { ...reservation };
|
|
1453
|
+
reservations = reservations.filter((existing) => existing.slot !== next.slot &&
|
|
1454
|
+
!targetMatches(existing.target, next.target));
|
|
1455
|
+
reservations.push(next);
|
|
1456
|
+
markDirty();
|
|
1457
|
+
},
|
|
1458
|
+
upsertPendingProvision(provision) {
|
|
1459
|
+
const next = {
|
|
1460
|
+
...provision,
|
|
1461
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1462
|
+
};
|
|
1463
|
+
pendingProvisions = pendingProvisions.filter((existing) => existing.id !== next.id);
|
|
1464
|
+
pendingProvisions.push(next);
|
|
1465
|
+
markDirty();
|
|
1466
|
+
},
|
|
1467
|
+
async recordPendingProvisionTargetRecovery(provision, target) {
|
|
1468
|
+
const path = getPath();
|
|
1469
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1470
|
+
await mkdir(dirname(recoveryPath), { recursive: true });
|
|
1471
|
+
withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
|
|
1472
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1473
|
+
recoveries[provision.id] = {
|
|
1474
|
+
instanceId: provision.instanceId,
|
|
1475
|
+
...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
|
|
1476
|
+
...(provision.leaderEpoch !== undefined
|
|
1477
|
+
? { leaderEpoch: provision.leaderEpoch }
|
|
1478
|
+
: {}),
|
|
1479
|
+
target: { ...target },
|
|
1480
|
+
};
|
|
1481
|
+
const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1482
|
+
writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
|
|
1483
|
+
encoding: "utf8",
|
|
1484
|
+
mode: 0o600,
|
|
1485
|
+
});
|
|
1486
|
+
renameSync(tempPath, recoveryPath);
|
|
1487
|
+
chmodSync(recoveryPath, 0o600);
|
|
1488
|
+
});
|
|
1489
|
+
const current = pendingProvisions.find((entry) => entry.id === provision.id &&
|
|
1490
|
+
entry.instanceId === provision.instanceId &&
|
|
1491
|
+
entry.profileKey === provision.profileKey &&
|
|
1492
|
+
entry.leaderEpoch === provision.leaderEpoch);
|
|
1493
|
+
if (!current)
|
|
1494
|
+
return false;
|
|
1495
|
+
current.target = { ...target };
|
|
1496
|
+
current.status = "ambiguous";
|
|
1497
|
+
return true;
|
|
1498
|
+
},
|
|
1499
|
+
removePendingProvision(id) {
|
|
1500
|
+
const before = pendingProvisions.length;
|
|
1501
|
+
pendingProvisions = pendingProvisions.filter((provision) => provision.id !== id);
|
|
1502
|
+
const changed = pendingProvisions.length !== before;
|
|
1503
|
+
if (changed)
|
|
1504
|
+
markDirty();
|
|
1505
|
+
return changed;
|
|
1506
|
+
},
|
|
1507
|
+
upsertPendingCleanup(intent) {
|
|
1508
|
+
const next = { ...intent, target: { ...intent.target } };
|
|
1509
|
+
pendingCleanups = pendingCleanups.filter((existing) => existing.id !== next.id);
|
|
1510
|
+
pendingCleanups.push(next);
|
|
1511
|
+
markDirty();
|
|
1512
|
+
},
|
|
1513
|
+
removePendingCleanup(id) {
|
|
1514
|
+
const before = pendingCleanups.length;
|
|
1515
|
+
pendingCleanups = pendingCleanups.filter((intent) => intent.id !== id);
|
|
1516
|
+
const changed = pendingCleanups.length !== before;
|
|
1517
|
+
if (changed)
|
|
1518
|
+
markDirty();
|
|
1519
|
+
return changed;
|
|
1520
|
+
},
|
|
1521
|
+
getBotState() {
|
|
1522
|
+
return Object.fromEntries(Object.entries(botState).filter(([, value]) => value !== undefined));
|
|
1523
|
+
},
|
|
1524
|
+
setBotState(state) {
|
|
1525
|
+
botState = { ...botState, ...state };
|
|
1526
|
+
markDirty();
|
|
1527
|
+
},
|
|
1528
|
+
setStatusSnapshot(snapshot) {
|
|
1529
|
+
if (!loadedPath)
|
|
1530
|
+
loadedPath = getPath();
|
|
1531
|
+
statusSnapshot = { ...snapshot };
|
|
1532
|
+
statusRevision += 1;
|
|
1533
|
+
},
|
|
1534
|
+
getByProfileKey(profileKey) {
|
|
1535
|
+
const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
|
|
1536
|
+
const record = records.get(ownerKey) ?? records.get(profileKey);
|
|
1537
|
+
return record ? cloneRecord(record) : undefined;
|
|
1538
|
+
},
|
|
1539
|
+
getActiveByInstanceId(instanceId) {
|
|
1540
|
+
for (const record of records.values()) {
|
|
1541
|
+
if (record.instanceId !== instanceId)
|
|
1542
|
+
continue;
|
|
1543
|
+
if (record.status !== "active" && record.status !== "starting")
|
|
1544
|
+
continue;
|
|
1545
|
+
return cloneRecord(record);
|
|
1546
|
+
}
|
|
1547
|
+
return undefined;
|
|
1548
|
+
},
|
|
1549
|
+
getIdentityByProfileKey(profileKey) {
|
|
1550
|
+
const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
|
|
1551
|
+
const identity = identities.get(ownerKey) ?? identities.get(profileKey);
|
|
1552
|
+
return identity ? cloneIdentityRecord(identity) : undefined;
|
|
1553
|
+
},
|
|
1554
|
+
forgetIdentityByProfileKey(profileKey) {
|
|
1555
|
+
const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
|
|
1556
|
+
const removedOwner = identities.delete(ownerKey);
|
|
1557
|
+
const removedProfile = identities.delete(profileKey);
|
|
1558
|
+
if (!removedOwner && !removedProfile)
|
|
1559
|
+
return false;
|
|
1560
|
+
markDirty();
|
|
1561
|
+
return true;
|
|
1562
|
+
},
|
|
1563
|
+
listWorkspaceBindings() {
|
|
1564
|
+
return Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding);
|
|
1565
|
+
},
|
|
1566
|
+
listWorkspaceRetirementIntents() {
|
|
1567
|
+
return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
|
|
1568
|
+
},
|
|
1569
|
+
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
|
|
1570
|
+
if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
|
|
1571
|
+
return undefined;
|
|
1572
|
+
}
|
|
1573
|
+
const key = getWorkspaceBindingMapKey(expected);
|
|
1574
|
+
const current = workspaceBindings.get(key);
|
|
1575
|
+
if (!current || !isDeepStrictEqual(current, expected) ||
|
|
1576
|
+
!journalBindingKeys.every((bindingKey) => typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512))
|
|
1577
|
+
return undefined;
|
|
1578
|
+
const keys = Array.from(new Set(journalBindingKeys));
|
|
1579
|
+
if ((current.journalBindingsComplete === true) === complete &&
|
|
1580
|
+
isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
|
|
1581
|
+
return cloneWorkspaceBinding(current);
|
|
1582
|
+
}
|
|
1583
|
+
const next = { ...current, updatedAtMs: getNowMs() };
|
|
1584
|
+
if (keys.length)
|
|
1585
|
+
next.journalBindingKeys = keys;
|
|
1586
|
+
else
|
|
1587
|
+
delete next.journalBindingKeys;
|
|
1588
|
+
if (complete)
|
|
1589
|
+
next.journalBindingsComplete = true;
|
|
1590
|
+
else
|
|
1591
|
+
delete next.journalBindingsComplete;
|
|
1592
|
+
workspaceBindings.set(key, next);
|
|
1593
|
+
markDirty();
|
|
1594
|
+
return cloneWorkspaceBinding(next);
|
|
1595
|
+
},
|
|
1596
|
+
upsertWorkspaceRetirementIntent(intent) {
|
|
1597
|
+
const next = normalizeWorkspaceRetirementIntent(intent);
|
|
1598
|
+
if (!next)
|
|
1599
|
+
return false;
|
|
1600
|
+
const current = workspaceBindings.get(getWorkspaceBindingMapKey(next.binding));
|
|
1601
|
+
if (!current || !isDeepStrictEqual(current, next.binding))
|
|
1602
|
+
return false;
|
|
1603
|
+
const sameId = workspaceRetirements.find((candidate) => candidate.id === next.id);
|
|
1604
|
+
if (sameId)
|
|
1605
|
+
return isDeepStrictEqual(sameId, next);
|
|
1606
|
+
if (workspaceRetirements.some((candidate) => candidate.binding.bindingKey === next.binding.bindingKey ||
|
|
1607
|
+
candidate.binding.slot === next.binding.slot ||
|
|
1608
|
+
targetMatches(candidate.binding.target, next.binding.target)))
|
|
1609
|
+
return false;
|
|
1610
|
+
workspaceRetirements.push(cloneWorkspaceRetirementIntent(next));
|
|
1611
|
+
markDirty();
|
|
1612
|
+
return true;
|
|
1613
|
+
},
|
|
1614
|
+
removeWorkspaceRetirementIntent(expected) {
|
|
1615
|
+
const index = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, expected));
|
|
1616
|
+
if (index < 0)
|
|
1617
|
+
return false;
|
|
1618
|
+
workspaceRetirements.splice(index, 1);
|
|
1619
|
+
markDirty();
|
|
1620
|
+
return true;
|
|
1621
|
+
},
|
|
1622
|
+
async replaceWorkspaceRetirementIntent(expected, replacement, isCurrent) {
|
|
1623
|
+
if (workspaceRetirementCommitInFlight || !isCurrent())
|
|
1624
|
+
return false;
|
|
1625
|
+
const previous = normalizeWorkspaceRetirementIntent(expected);
|
|
1626
|
+
const next = normalizeWorkspaceRetirementIntent(replacement);
|
|
1627
|
+
if (!previous || !next || previous.id !== next.id ||
|
|
1628
|
+
previous.reason !== next.reason || previous.profileKey !== next.profileKey ||
|
|
1629
|
+
previous.requestedAtMs !== next.requestedAtMs ||
|
|
1630
|
+
!isDeepStrictEqual(previous.binding, next.binding))
|
|
1631
|
+
return false;
|
|
1632
|
+
const binding = workspaceBindings.get(getWorkspaceBindingMapKey(previous.binding));
|
|
1633
|
+
const intentIndex = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, previous));
|
|
1634
|
+
if (!binding || !isDeepStrictEqual(binding, previous.binding) || intentIndex < 0) {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
if (isDeepStrictEqual(previous, next))
|
|
1638
|
+
return true;
|
|
1639
|
+
workspaceRetirementCommitInFlight = true;
|
|
1640
|
+
workspaceRetirements[intentIndex] = cloneWorkspaceRetirementIntent(next);
|
|
1641
|
+
markDirty();
|
|
1642
|
+
const restoreInMemory = () => {
|
|
1643
|
+
workspaceRetirements = workspaceRetirements.filter((candidate) => candidate.id !== previous.id);
|
|
1644
|
+
workspaceRetirements.push(cloneWorkspaceRetirementIntent(previous));
|
|
1645
|
+
markDirty();
|
|
1646
|
+
};
|
|
1647
|
+
try {
|
|
1648
|
+
if (!isCurrent()) {
|
|
1649
|
+
restoreInMemory();
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
const persisted = await persistSnapshot();
|
|
1653
|
+
if (!persisted) {
|
|
1654
|
+
restoreInMemory();
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
catch (error) {
|
|
1660
|
+
try {
|
|
1661
|
+
await loadFromDisk();
|
|
1662
|
+
}
|
|
1663
|
+
catch {
|
|
1664
|
+
restoreInMemory();
|
|
1665
|
+
throw error;
|
|
1666
|
+
}
|
|
1667
|
+
if (workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, next))) {
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
if (!workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, previous))) {
|
|
1671
|
+
restoreInMemory();
|
|
1672
|
+
}
|
|
1673
|
+
throw error;
|
|
1674
|
+
}
|
|
1675
|
+
finally {
|
|
1676
|
+
workspaceRetirementCommitInFlight = false;
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
async commitInactiveWorkspaceCleanup(expected, isCurrent) {
|
|
1680
|
+
if (workspaceRetirementCommitInFlight || !isCurrent())
|
|
1681
|
+
return false;
|
|
1682
|
+
const cleanupSnapshot = "bindingUpdatedAtMs" in expected ? expected : undefined;
|
|
1683
|
+
const normalized = cleanupSnapshot ? undefined : normalizeWorkspaceBindingRecord(expected);
|
|
1684
|
+
const cleanupSessionId = cleanupSnapshot?.sessionId === undefined
|
|
1685
|
+
? undefined
|
|
1686
|
+
: normalizeTelegramSessionId(cleanupSnapshot.sessionId);
|
|
1687
|
+
const cleanupSessionKey = cleanupSessionId
|
|
1688
|
+
? createTelegramSessionKey(cleanupSessionId)
|
|
1689
|
+
: undefined;
|
|
1690
|
+
if (cleanupSnapshot && (!cleanupSnapshot.cwd || !cleanupSnapshot.workspaceKey ||
|
|
1691
|
+
!cleanupSnapshot.instanceSlot || !cleanupSnapshot.slot || !cleanupSnapshot.bindingKey ||
|
|
1692
|
+
((cleanupSnapshot.sessionId !== undefined || cleanupSnapshot.sessionKey !== undefined) &&
|
|
1693
|
+
(!cleanupSessionId || cleanupSnapshot.sessionKey !== cleanupSessionKey)) ||
|
|
1694
|
+
!Number.isSafeInteger(cleanupSnapshot.inactiveSinceMs) ||
|
|
1695
|
+
!Number.isSafeInteger(cleanupSnapshot.bindingUpdatedAtMs) ||
|
|
1696
|
+
!Number.isSafeInteger(cleanupSnapshot.target.chatId) ||
|
|
1697
|
+
!Number.isSafeInteger(cleanupSnapshot.target.threadId) || cleanupSnapshot.target.threadId <= 0))
|
|
1698
|
+
return false;
|
|
1699
|
+
if (!cleanupSnapshot && (!normalized?.slot || normalized.inactiveSinceMs === undefined))
|
|
1700
|
+
return false;
|
|
1701
|
+
const mapKey = getWorkspaceBindingMapKey((cleanupSnapshot ?? normalized));
|
|
1702
|
+
const binding = workspaceBindings.get(mapKey);
|
|
1703
|
+
if (!binding)
|
|
1704
|
+
return isCurrent();
|
|
1705
|
+
const exact = cleanupSnapshot ? binding.cwd === cleanupSnapshot.cwd &&
|
|
1706
|
+
binding.workspaceKey === cleanupSnapshot.workspaceKey &&
|
|
1707
|
+
binding.sessionId === cleanupSessionId && binding.sessionKey === cleanupSessionKey &&
|
|
1708
|
+
binding.instanceSlot === cleanupSnapshot.instanceSlot && binding.slot === cleanupSnapshot.slot &&
|
|
1709
|
+
binding.bindingKey === cleanupSnapshot.bindingKey && targetMatches(binding.target, cleanupSnapshot.target) &&
|
|
1710
|
+
binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
|
|
1711
|
+
binding.updatedAtMs === cleanupSnapshot.bindingUpdatedAtMs : isDeepStrictEqual(binding, normalized);
|
|
1712
|
+
if (!exact)
|
|
1713
|
+
return false;
|
|
1714
|
+
const nowMs = getNowMs();
|
|
1715
|
+
const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) || candidate.slot === binding.slot;
|
|
1716
|
+
const protectedLocally = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
|
|
1717
|
+
Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey || claim.identity.slot === binding.slot) ||
|
|
1718
|
+
reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) && targetOrSlotMatches(reservation)) ||
|
|
1719
|
+
pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) && targetOrSlotMatches(provision)) ||
|
|
1720
|
+
pendingCleanups.some(targetOrSlotMatches) || workspaceRetirements.some((intent) => intent.binding.bindingKey === binding.bindingKey || targetOrSlotMatches(intent.binding));
|
|
1721
|
+
if (protectedLocally)
|
|
1722
|
+
return false;
|
|
1723
|
+
workspaceRetirementCommitInFlight = true;
|
|
1724
|
+
workspaceBindings.delete(mapKey);
|
|
1725
|
+
markDirty();
|
|
1726
|
+
const restore = () => { workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding)); markDirty(); };
|
|
1727
|
+
try {
|
|
1728
|
+
if (!isCurrent()) {
|
|
1729
|
+
restore();
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
if (!await persistSnapshot()) {
|
|
1733
|
+
restore();
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
try {
|
|
1740
|
+
await loadFromDisk();
|
|
1741
|
+
}
|
|
1742
|
+
catch {
|
|
1743
|
+
restore();
|
|
1744
|
+
throw error;
|
|
1745
|
+
}
|
|
1746
|
+
if (!workspaceBindings.has(mapKey))
|
|
1747
|
+
return true;
|
|
1748
|
+
if (!isDeepStrictEqual(workspaceBindings.get(mapKey), binding))
|
|
1749
|
+
restore();
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
finally {
|
|
1753
|
+
workspaceRetirementCommitInFlight = false;
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
async commitWorkspaceRetirement(expected, isCurrent) {
|
|
1757
|
+
if (workspaceRetirementCommitInFlight || !isCurrent())
|
|
1758
|
+
return false;
|
|
1759
|
+
const normalized = normalizeWorkspaceRetirementIntent(expected);
|
|
1760
|
+
if (!normalized)
|
|
1761
|
+
return false;
|
|
1762
|
+
const mapKey = getWorkspaceBindingMapKey(normalized.binding);
|
|
1763
|
+
const binding = workspaceBindings.get(mapKey);
|
|
1764
|
+
const intentIndex = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, normalized));
|
|
1765
|
+
if (!binding || !isDeepStrictEqual(binding, normalized.binding) || intentIndex < 0) {
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
const nowMs = getNowMs();
|
|
1769
|
+
const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) ||
|
|
1770
|
+
candidate.slot === binding.slot;
|
|
1771
|
+
const locallyProtected = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
|
|
1772
|
+
Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey ||
|
|
1773
|
+
claim.identity.slot === binding.slot) ||
|
|
1774
|
+
reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
|
|
1775
|
+
targetOrSlotMatches(reservation)) ||
|
|
1776
|
+
pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) &&
|
|
1777
|
+
targetOrSlotMatches(provision)) ||
|
|
1778
|
+
pendingCleanups.some(targetOrSlotMatches) ||
|
|
1779
|
+
workspaceRetirements.some((candidate, index) => index !== intentIndex &&
|
|
1780
|
+
(candidate.binding.bindingKey === binding.bindingKey ||
|
|
1781
|
+
targetOrSlotMatches(candidate.binding)));
|
|
1782
|
+
if (locallyProtected)
|
|
1783
|
+
return false;
|
|
1784
|
+
const previousRetirements = workspaceRetirements.map(cloneWorkspaceRetirementIntent);
|
|
1785
|
+
workspaceRetirementCommitInFlight = true;
|
|
1786
|
+
workspaceBindings.delete(mapKey);
|
|
1787
|
+
workspaceRetirements.splice(intentIndex, 1);
|
|
1788
|
+
markDirty();
|
|
1789
|
+
const restoreInMemory = () => {
|
|
1790
|
+
workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding));
|
|
1791
|
+
workspaceRetirements = previousRetirements.map(cloneWorkspaceRetirementIntent);
|
|
1792
|
+
markDirty();
|
|
1793
|
+
};
|
|
1794
|
+
try {
|
|
1795
|
+
if (!isCurrent()) {
|
|
1796
|
+
restoreInMemory();
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
const persisted = await persistSnapshot();
|
|
1800
|
+
if (!persisted) {
|
|
1801
|
+
restoreInMemory();
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
return true;
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
try {
|
|
1808
|
+
await loadFromDisk();
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
restoreInMemory();
|
|
1812
|
+
throw error;
|
|
1813
|
+
}
|
|
1814
|
+
const committed = !workspaceBindings.has(mapKey) &&
|
|
1815
|
+
!workspaceRetirements.some((candidate) => candidate.id === normalized.id);
|
|
1816
|
+
if (committed)
|
|
1817
|
+
return true;
|
|
1818
|
+
if (!workspaceBindings.has(mapKey) ||
|
|
1819
|
+
!workspaceRetirements.some((candidate) => candidate.id === normalized.id)) {
|
|
1820
|
+
restoreInMemory();
|
|
1821
|
+
}
|
|
1822
|
+
throw error;
|
|
1823
|
+
}
|
|
1824
|
+
finally {
|
|
1825
|
+
workspaceRetirementCommitInFlight = false;
|
|
1826
|
+
}
|
|
1827
|
+
},
|
|
1828
|
+
captureWorkspaceSlotOccupancy(getExternalProtection, options) {
|
|
1829
|
+
const nowMs = getNowMs();
|
|
1830
|
+
const bindings = Array.from(workspaceBindings.values()).map((binding) => {
|
|
1831
|
+
const slot = binding.slot?.toLowerCase() ?? "";
|
|
1832
|
+
const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) ||
|
|
1833
|
+
(!!binding.slot && candidate.slot === binding.slot);
|
|
1834
|
+
const locallyProtected = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
|
|
1835
|
+
Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey ||
|
|
1836
|
+
(!!binding.slot && claim.identity.slot === binding.slot)) ||
|
|
1837
|
+
reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
|
|
1838
|
+
targetOrSlotMatches(reservation)) ||
|
|
1839
|
+
pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) &&
|
|
1840
|
+
targetOrSlotMatches(provision)) ||
|
|
1841
|
+
pendingCleanups.some((intent) => targetOrSlotMatches(intent)) ||
|
|
1842
|
+
workspaceRetirements.some((intent) => !isDeepStrictEqual(intent, options?.expectedRetirement) &&
|
|
1843
|
+
(intent.binding.bindingKey === binding.bindingKey ||
|
|
1844
|
+
targetOrSlotMatches(intent.binding)));
|
|
1845
|
+
const externalProtection = getExternalProtection(cloneWorkspaceBinding(binding));
|
|
1846
|
+
const externalStates = [
|
|
1847
|
+
externalProtection.liveOwner,
|
|
1848
|
+
externalProtection.acceptedWork,
|
|
1849
|
+
externalProtection.deliveryAuthority,
|
|
1850
|
+
];
|
|
1851
|
+
const hasValidInactivity = binding.inactiveSinceMs !== undefined &&
|
|
1852
|
+
Number.isFinite(binding.inactiveSinceMs) &&
|
|
1853
|
+
binding.inactiveSinceMs >= 0 && binding.inactiveSinceMs <= nowMs;
|
|
1854
|
+
const protection = locallyProtected || externalStates.includes("protected")
|
|
1855
|
+
? "protected"
|
|
1856
|
+
: externalStates.every((state) => state === "clear") && hasValidInactivity
|
|
1857
|
+
? "eligible"
|
|
1858
|
+
: "unknown";
|
|
1859
|
+
return {
|
|
1860
|
+
bindingKey: binding.bindingKey,
|
|
1861
|
+
slot,
|
|
1862
|
+
...(binding.inactiveSinceMs !== undefined
|
|
1863
|
+
? { inactiveSinceMs: binding.inactiveSinceMs }
|
|
1864
|
+
: {}),
|
|
1865
|
+
protection,
|
|
1866
|
+
};
|
|
1867
|
+
});
|
|
1868
|
+
const localReservedSlots = [
|
|
1869
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
|
|
1870
|
+
...Array.from(records.values()).filter(isCurrentThreadRecord).map((record) => record.slot),
|
|
1871
|
+
...reservations.filter((reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs).map((reservation) => reservation.slot),
|
|
1872
|
+
...pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs)).map((provision) => provision.slot),
|
|
1873
|
+
].filter((slot) => !!slot && /^[A-Z]$/u.test(slot))
|
|
1874
|
+
.map((slot) => slot.toLowerCase());
|
|
1875
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
1876
|
+
const reservedSlots = externalReservedSlots
|
|
1877
|
+
? [
|
|
1878
|
+
...localReservedSlots,
|
|
1879
|
+
...externalReservedSlots.map((slot) => slot.toLowerCase()),
|
|
1880
|
+
]
|
|
1881
|
+
: [...localReservedSlots, "invalid"];
|
|
1882
|
+
return { bindings, reservedSlots };
|
|
1883
|
+
},
|
|
1884
|
+
hasWorkspaceBinding(cwd, sessionId) {
|
|
1885
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
1886
|
+
const normalizedSessionId = sessionId === undefined
|
|
1887
|
+
? undefined
|
|
1888
|
+
: normalizeTelegramSessionId(sessionId);
|
|
1889
|
+
if (!normalizedCwd || (sessionId !== undefined && !normalizedSessionId)) {
|
|
1890
|
+
return false;
|
|
1891
|
+
}
|
|
1892
|
+
return Array.from(workspaceBindings.values()).some((binding) => binding.cwd === normalizedCwd &&
|
|
1893
|
+
binding.sessionId === normalizedSessionId);
|
|
1894
|
+
},
|
|
1895
|
+
setWorkspaceDisplayTitle(expected, title) {
|
|
1896
|
+
if (hasWorkspaceRetirementConflict(expected))
|
|
1897
|
+
return false;
|
|
1898
|
+
const key = getWorkspaceBindingMapKey(expected);
|
|
1899
|
+
const current = workspaceBindings.get(key);
|
|
1900
|
+
if (!current || !isDeepStrictEqual(current, expected) || !title.trim() ||
|
|
1901
|
+
title.length > 128)
|
|
1902
|
+
return false;
|
|
1903
|
+
if (current.displayTitle === title)
|
|
1904
|
+
return true;
|
|
1905
|
+
workspaceBindings.set(key, { ...current, displayTitle: title });
|
|
1906
|
+
markDirty();
|
|
1907
|
+
return true;
|
|
1908
|
+
},
|
|
1909
|
+
markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
|
|
1910
|
+
if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
|
|
1911
|
+
return false;
|
|
1912
|
+
for (const [key, binding] of workspaceBindings) {
|
|
1913
|
+
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
|
|
1914
|
+
continue;
|
|
1915
|
+
workspaceBindings.set(key, { ...binding, inactiveSinceMs });
|
|
1916
|
+
markDirty();
|
|
1917
|
+
return true;
|
|
1918
|
+
}
|
|
1919
|
+
return false;
|
|
1920
|
+
},
|
|
1921
|
+
markWorkspaceBindingActiveByTarget(target) {
|
|
1922
|
+
if (hasWorkspaceRetirementConflict({ target }))
|
|
1923
|
+
return false;
|
|
1924
|
+
for (const [key, binding] of workspaceBindings) {
|
|
1925
|
+
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs === undefined)
|
|
1926
|
+
continue;
|
|
1927
|
+
const next = { ...binding };
|
|
1928
|
+
delete next.inactiveSinceMs;
|
|
1929
|
+
workspaceBindings.set(key, next);
|
|
1930
|
+
markDirty();
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
1933
|
+
return false;
|
|
1934
|
+
},
|
|
1935
|
+
getWorkspaceBinding(cwd, instanceSlot = "a", sessionId) {
|
|
1936
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
1937
|
+
const normalizedSessionId = sessionId === undefined
|
|
1938
|
+
? undefined
|
|
1939
|
+
: normalizeTelegramSessionId(sessionId);
|
|
1940
|
+
if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot) ||
|
|
1941
|
+
(sessionId !== undefined && !normalizedSessionId))
|
|
1942
|
+
return undefined;
|
|
1943
|
+
const mapKey = getWorkspaceBindingMapKey({
|
|
1944
|
+
cwd: normalizedCwd,
|
|
1945
|
+
instanceSlot,
|
|
1946
|
+
...(normalizedSessionId ? { sessionId: normalizedSessionId } : {}),
|
|
1947
|
+
});
|
|
1948
|
+
const binding = workspaceBindings.get(mapKey);
|
|
1949
|
+
return binding ? cloneWorkspaceBinding(binding) : undefined;
|
|
1950
|
+
},
|
|
1951
|
+
claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
|
|
1952
|
+
if (workspaceRetirementCommitInFlight)
|
|
1953
|
+
return undefined;
|
|
1954
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
1955
|
+
const normalizedSessionId = options?.sessionId === undefined
|
|
1956
|
+
? undefined
|
|
1957
|
+
: normalizeTelegramSessionId(options.sessionId);
|
|
1958
|
+
if (!normalizedCwd || !instanceId ||
|
|
1959
|
+
(options?.sessionId !== undefined && !normalizedSessionId) ||
|
|
1960
|
+
hasWorkspaceRetirementConflict({ cwd: normalizedCwd }))
|
|
1961
|
+
return undefined;
|
|
1962
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
1963
|
+
if (!externalReservedSlots) {
|
|
1964
|
+
options?.onCapacityUnavailable?.();
|
|
1965
|
+
return undefined;
|
|
1966
|
+
}
|
|
1967
|
+
const externalReservedSlotKeys = externalReservedSlots.map((slot) => slot.toLowerCase());
|
|
1968
|
+
for (const claim of workspaceClaims.values()) {
|
|
1969
|
+
if (claim.instanceId !== instanceId)
|
|
1970
|
+
continue;
|
|
1971
|
+
if (claim.identity.cwd !== normalizedCwd ||
|
|
1972
|
+
claim.identity.sessionId !== normalizedSessionId ||
|
|
1973
|
+
!claim.identity.slot ||
|
|
1974
|
+
externalReservedSlotKeys.includes(claim.identity.slot.toLowerCase())) {
|
|
1975
|
+
return undefined;
|
|
1976
|
+
}
|
|
1977
|
+
if (options?.existingBindingOnly &&
|
|
1978
|
+
!workspaceBindings.has(getWorkspaceBindingMapKey(claim.identity)))
|
|
1979
|
+
return undefined;
|
|
1980
|
+
return { ...claim.identity };
|
|
1981
|
+
}
|
|
1982
|
+
const workspaceKey = resolveWorkspaceKey(normalizedCwd);
|
|
1983
|
+
if (!workspaceKey)
|
|
1984
|
+
return undefined;
|
|
1985
|
+
let legacyRecord = normalizedSessionId
|
|
1986
|
+
? undefined
|
|
1987
|
+
: findLegacyWorkspaceMigrationRecord(normalizedCwd, instanceId, previousInstanceId);
|
|
1988
|
+
const legacyTarget = legacyRecord?.target;
|
|
1989
|
+
const targetBinding = legacyTarget
|
|
1990
|
+
? Array.from(workspaceBindings.values()).find((binding) => binding.cwd === normalizedCwd &&
|
|
1991
|
+
targetMatches(binding.target, legacyTarget))
|
|
1992
|
+
: undefined;
|
|
1993
|
+
let capacityUnavailable = false;
|
|
1994
|
+
const claimIdentity = (identity) => {
|
|
1995
|
+
const mapKey = getWorkspaceBindingMapKey(identity);
|
|
1996
|
+
const existingClaim = workspaceClaims.get(mapKey);
|
|
1997
|
+
if (existingClaim) {
|
|
1998
|
+
return existingClaim.instanceId === instanceId
|
|
1999
|
+
? { ...existingClaim.identity }
|
|
2000
|
+
: undefined;
|
|
2001
|
+
}
|
|
2002
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2003
|
+
const liveRecord = binding
|
|
2004
|
+
? isWorkspaceTargetLive(binding)
|
|
2005
|
+
: undefined;
|
|
2006
|
+
if (liveRecord &&
|
|
2007
|
+
liveRecord.instanceId !== instanceId &&
|
|
2008
|
+
liveRecord.instanceId !== previousInstanceId) {
|
|
2009
|
+
return undefined;
|
|
2010
|
+
}
|
|
2011
|
+
const retainedTarget = binding?.target ?? legacyRecord?.target;
|
|
2012
|
+
const retainedSlot = binding?.slot ?? legacyRecord?.slot;
|
|
2013
|
+
const otherBindings = Array.from(workspaceBindings.values())
|
|
2014
|
+
.filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
|
|
2015
|
+
.map((other) => ({
|
|
2016
|
+
bindingKey: other.bindingKey,
|
|
2017
|
+
slot: other.slot.toLowerCase(),
|
|
2018
|
+
protection: "unknown",
|
|
2019
|
+
}));
|
|
2020
|
+
const nowMs = getNowMs();
|
|
2021
|
+
const reservedSlots = [
|
|
2022
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
|
|
2023
|
+
...Array.from(records.values())
|
|
2024
|
+
.filter((record) => isCurrentThreadRecord(record) &&
|
|
2025
|
+
!(retainedTarget && targetMatches(record.target, retainedTarget)))
|
|
2026
|
+
.map((record) => record.slot),
|
|
2027
|
+
...reservations.filter((reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs).map((reservation) => reservation.slot),
|
|
2028
|
+
...pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs)).map((provision) => provision.slot),
|
|
2029
|
+
...externalReservedSlots,
|
|
2030
|
+
].filter((slot) => !!slot).map((slot) => slot.toLowerCase());
|
|
2031
|
+
const retainedSlotKey = retainedSlot?.toLowerCase();
|
|
2032
|
+
if (retainedSlotKey && reservedSlots.includes(retainedSlotKey))
|
|
2033
|
+
return undefined;
|
|
2034
|
+
const retainedSlotConflicts = !!retainedSlotKey &&
|
|
2035
|
+
otherBindings.some((other) => other.slot === retainedSlotKey);
|
|
2036
|
+
let slot = retainedSlotConflicts
|
|
2037
|
+
? Array.from(TELEGRAM_WORKSPACE_SLOTS).find((candidate) => !reservedSlots.includes(candidate) &&
|
|
2038
|
+
!otherBindings.some((other) => other.slot === candidate))?.toUpperCase()
|
|
2039
|
+
: retainedSlot;
|
|
2040
|
+
if (!slot) {
|
|
2041
|
+
const allocation = planTelegramWorkspaceSlotAllocation({
|
|
2042
|
+
bindings: otherBindings,
|
|
2043
|
+
reservedSlots,
|
|
2044
|
+
nowMs,
|
|
2045
|
+
});
|
|
2046
|
+
if (allocation.kind === "blocked" && allocation.reason === "invalid-state") {
|
|
2047
|
+
return undefined;
|
|
2048
|
+
}
|
|
2049
|
+
if (allocation.kind !== "free") {
|
|
2050
|
+
capacityUnavailable = true;
|
|
2051
|
+
return undefined;
|
|
2052
|
+
}
|
|
2053
|
+
slot = allocation.slot.toUpperCase();
|
|
2054
|
+
}
|
|
2055
|
+
if (!slot || !/^[A-Z]$/u.test(slot))
|
|
2056
|
+
return undefined;
|
|
2057
|
+
const claimedIdentity = { ...identity, slot };
|
|
2058
|
+
workspaceClaims.set(mapKey, {
|
|
2059
|
+
identity: claimedIdentity,
|
|
2060
|
+
instanceId,
|
|
2061
|
+
});
|
|
2062
|
+
return { ...claimedIdentity };
|
|
2063
|
+
};
|
|
2064
|
+
if (options?.existingBindingOnly) {
|
|
2065
|
+
const candidates = Array.from(workspaceBindings.values())
|
|
2066
|
+
.filter((binding) => binding.cwd === normalizedCwd &&
|
|
2067
|
+
binding.sessionId === normalizedSessionId)
|
|
2068
|
+
.sort((left, right) => left.instanceSlot.length - right.instanceSlot.length ||
|
|
2069
|
+
left.instanceSlot.localeCompare(right.instanceSlot));
|
|
2070
|
+
for (const binding of candidates) {
|
|
2071
|
+
const claimed = claimIdentity({
|
|
2072
|
+
cwd: binding.cwd,
|
|
2073
|
+
workspaceKey: binding.workspaceKey,
|
|
2074
|
+
...(binding.sessionId && binding.sessionKey
|
|
2075
|
+
? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
|
|
2076
|
+
: {}),
|
|
2077
|
+
instanceSlot: binding.instanceSlot,
|
|
2078
|
+
bindingKey: binding.bindingKey,
|
|
2079
|
+
});
|
|
2080
|
+
if (claimed)
|
|
2081
|
+
return claimed;
|
|
2082
|
+
}
|
|
2083
|
+
if (!legacyRecord)
|
|
2084
|
+
return undefined;
|
|
2085
|
+
}
|
|
2086
|
+
if (targetBinding) {
|
|
2087
|
+
const claimed = claimIdentity({
|
|
2088
|
+
cwd: targetBinding.cwd,
|
|
2089
|
+
workspaceKey: targetBinding.workspaceKey,
|
|
2090
|
+
instanceSlot: targetBinding.instanceSlot,
|
|
2091
|
+
bindingKey: targetBinding.bindingKey,
|
|
2092
|
+
});
|
|
2093
|
+
if (claimed)
|
|
2094
|
+
return claimed;
|
|
2095
|
+
if (options?.existingBindingOnly)
|
|
2096
|
+
return undefined;
|
|
2097
|
+
// A migrated live peer is not a handoff or a target for another slot.
|
|
2098
|
+
legacyRecord = undefined;
|
|
2099
|
+
}
|
|
2100
|
+
const attemptLimit = workspaceBindings.size + workspaceClaims.size + 1;
|
|
2101
|
+
for (let ordinal = 0; ordinal < attemptLimit; ordinal += 1) {
|
|
2102
|
+
const identity = createTelegramWorkspaceBindingIdentityWithKey(normalizedCwd, workspaceKey, ordinal, normalizedSessionId);
|
|
2103
|
+
if (!identity)
|
|
2104
|
+
return undefined;
|
|
2105
|
+
const mapKey = getWorkspaceBindingMapKey(identity);
|
|
2106
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2107
|
+
if (legacyRecord && binding)
|
|
2108
|
+
continue;
|
|
2109
|
+
const claimed = claimIdentity(identity);
|
|
2110
|
+
if (!claimed)
|
|
2111
|
+
continue;
|
|
2112
|
+
if (legacyRecord && !binding) {
|
|
2113
|
+
workspaceBindings.set(mapKey, {
|
|
2114
|
+
...identity,
|
|
2115
|
+
target: { ...legacyRecord.target },
|
|
2116
|
+
...(legacyRecord.threadName
|
|
2117
|
+
? { threadName: legacyRecord.threadName }
|
|
2118
|
+
: {}),
|
|
2119
|
+
...(legacyRecord.slot ? { slot: legacyRecord.slot } : {}),
|
|
2120
|
+
updatedAtMs: getNowMs(),
|
|
2121
|
+
});
|
|
2122
|
+
reconcileWorkspaceSuffixExposure();
|
|
2123
|
+
markDirty();
|
|
2124
|
+
}
|
|
2125
|
+
return claimed;
|
|
2126
|
+
}
|
|
2127
|
+
if (capacityUnavailable)
|
|
2128
|
+
options?.onCapacityUnavailable?.();
|
|
2129
|
+
return undefined;
|
|
2130
|
+
},
|
|
2131
|
+
releaseWorkspaceClaim(instanceId) {
|
|
2132
|
+
let released = false;
|
|
2133
|
+
for (const [key, claim] of workspaceClaims) {
|
|
2134
|
+
if (claim.instanceId !== instanceId)
|
|
2135
|
+
continue;
|
|
2136
|
+
workspaceClaims.delete(key);
|
|
2137
|
+
released = true;
|
|
2138
|
+
}
|
|
2139
|
+
return released;
|
|
2140
|
+
},
|
|
2141
|
+
upsertWorkspaceBinding(binding, claimInstanceId) {
|
|
2142
|
+
if (workspaceRetirementCommitInFlight)
|
|
2143
|
+
return undefined;
|
|
2144
|
+
const next = normalizeWorkspaceBindingRecord(binding);
|
|
2145
|
+
if (next && hasWorkspaceRetirementConflict(next))
|
|
2146
|
+
return undefined;
|
|
2147
|
+
if (!next)
|
|
2148
|
+
return undefined;
|
|
2149
|
+
const nextMapKey = getWorkspaceBindingMapKey(next);
|
|
2150
|
+
const claim = workspaceClaims.get(nextMapKey);
|
|
2151
|
+
if (next.slot && Array.from(workspaceBindings.values()).some((existing) => existing.bindingKey !== next.bindingKey && existing.slot === next.slot))
|
|
2152
|
+
return undefined;
|
|
2153
|
+
for (const existing of workspaceBindings.values()) {
|
|
2154
|
+
if (existing.workspaceKey === next.workspaceKey &&
|
|
2155
|
+
existing.cwd !== next.cwd) {
|
|
2156
|
+
return undefined;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
const previous = workspaceBindings.get(nextMapKey);
|
|
2160
|
+
if (previous?.showSlotSuffix)
|
|
2161
|
+
next.showSlotSuffix = true;
|
|
2162
|
+
if (previous?.threadName && !next.threadName) {
|
|
2163
|
+
next.threadName = previous.threadName;
|
|
2164
|
+
}
|
|
2165
|
+
if (previous?.manualThreadName && !next.manualThreadName) {
|
|
2166
|
+
next.manualThreadName = previous.manualThreadName;
|
|
2167
|
+
}
|
|
2168
|
+
if (previous) {
|
|
2169
|
+
const journalBindingKeys = Array.from(new Set([
|
|
2170
|
+
...(previous.journalBindingKeys ?? []),
|
|
2171
|
+
...(next.journalBindingKeys ?? []),
|
|
2172
|
+
]));
|
|
2173
|
+
if (journalBindingKeys.length)
|
|
2174
|
+
next.journalBindingKeys = journalBindingKeys;
|
|
2175
|
+
else
|
|
2176
|
+
delete next.journalBindingKeys;
|
|
2177
|
+
if (previous.journalBindingsComplete)
|
|
2178
|
+
next.journalBindingsComplete = true;
|
|
2179
|
+
else
|
|
2180
|
+
delete next.journalBindingsComplete;
|
|
2181
|
+
}
|
|
2182
|
+
if (previous && !targetMatches(previous.target, next.target)) {
|
|
2183
|
+
delete next.displayTitle;
|
|
2184
|
+
delete next.inactiveSinceMs;
|
|
2185
|
+
}
|
|
2186
|
+
else if (previous) {
|
|
2187
|
+
if (previous.displayTitle)
|
|
2188
|
+
next.displayTitle = previous.displayTitle;
|
|
2189
|
+
if (previous.inactiveSinceMs !== undefined)
|
|
2190
|
+
next.inactiveSinceMs = previous.inactiveSinceMs;
|
|
2191
|
+
}
|
|
2192
|
+
if (claimInstanceId) {
|
|
2193
|
+
if (claim?.instanceId !== claimInstanceId)
|
|
2194
|
+
return undefined;
|
|
2195
|
+
if (next.slot !== undefined && claim.identity.slot !== next.slot)
|
|
2196
|
+
return undefined;
|
|
2197
|
+
next.slot = claim.identity.slot;
|
|
2198
|
+
}
|
|
2199
|
+
else if (claim) {
|
|
2200
|
+
return undefined;
|
|
2201
|
+
}
|
|
2202
|
+
for (const [key, existing] of workspaceBindings) {
|
|
2203
|
+
if (key === nextMapKey)
|
|
2204
|
+
continue;
|
|
2205
|
+
if (targetMatches(existing.target, next.target)) {
|
|
2206
|
+
workspaceBindings.delete(key);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
workspaceBindings.set(nextMapKey, next);
|
|
2210
|
+
reconcileWorkspaceSuffixExposure();
|
|
2211
|
+
if (claim)
|
|
2212
|
+
workspaceClaims.delete(nextMapKey);
|
|
2213
|
+
markDirty();
|
|
2214
|
+
return cloneWorkspaceBinding(next);
|
|
2215
|
+
},
|
|
2216
|
+
upsert(record) {
|
|
2217
|
+
const next = cloneRecord(record);
|
|
2218
|
+
const nextOwnerKey = getRecordOwnerKey(next);
|
|
2219
|
+
const previousRecord = records.get(nextOwnerKey);
|
|
2220
|
+
if (isCurrentThreadRecord(next)) {
|
|
2221
|
+
for (const existing of Array.from(records.values())) {
|
|
2222
|
+
const existingOwnerKey = getRecordOwnerKey(existing);
|
|
2223
|
+
if (existingOwnerKey === nextOwnerKey)
|
|
2224
|
+
continue;
|
|
2225
|
+
if (!targetMatches(existing.target, next.target))
|
|
2226
|
+
continue;
|
|
2227
|
+
records.delete(existingOwnerKey);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
if (next.instanceId &&
|
|
2231
|
+
(next.status === "active" || next.status === "starting")) {
|
|
2232
|
+
for (const existing of records.values()) {
|
|
2233
|
+
if (existing.instanceId !== next.instanceId)
|
|
2234
|
+
continue;
|
|
2235
|
+
if (getRecordOwnerKey(existing) === nextOwnerKey)
|
|
2236
|
+
continue;
|
|
2237
|
+
if (targetMatches(existing.target, next.target))
|
|
2238
|
+
continue;
|
|
2239
|
+
if (existing.status !== "active" && existing.status !== "starting")
|
|
2240
|
+
continue;
|
|
2241
|
+
records.delete(getRecordOwnerKey(existing));
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
if (!isPersistedThreadRecord(next)) {
|
|
2245
|
+
rememberIdentity(next);
|
|
2246
|
+
records.delete(nextOwnerKey);
|
|
2247
|
+
markDirty();
|
|
2248
|
+
return cloneRecord(next);
|
|
2249
|
+
}
|
|
2250
|
+
records.set(nextOwnerKey, next);
|
|
2251
|
+
if (!previousRecord ||
|
|
2252
|
+
!targetMatches(previousRecord.target, next.target)) {
|
|
2253
|
+
rememberSlot(next.slot, next.updatedAtMs);
|
|
2254
|
+
}
|
|
2255
|
+
rememberIdentity(next);
|
|
2256
|
+
markDirty();
|
|
2257
|
+
return cloneRecord(next);
|
|
2258
|
+
},
|
|
2259
|
+
markOfflineByInstanceId(instanceId) {
|
|
2260
|
+
let count = 0;
|
|
2261
|
+
for (const record of Array.from(records.values())) {
|
|
2262
|
+
if (record.instanceId !== instanceId ||
|
|
2263
|
+
(record.status !== "active" && record.status !== "starting"))
|
|
2264
|
+
continue;
|
|
2265
|
+
records.delete(getRecordOwnerKey(record));
|
|
2266
|
+
count += 1;
|
|
2267
|
+
}
|
|
2268
|
+
if (count > 0)
|
|
2269
|
+
markDirty();
|
|
2270
|
+
return count;
|
|
2271
|
+
},
|
|
2272
|
+
markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
|
|
2273
|
+
const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
|
|
2274
|
+
const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) => entry.target && targetMatches(entry.target, target)) : undefined;
|
|
2275
|
+
const source = record ?? pending;
|
|
2276
|
+
if (!source?.target)
|
|
2277
|
+
return false;
|
|
2278
|
+
syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
|
|
2279
|
+
syncObservations.push({
|
|
2280
|
+
target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
|
|
2281
|
+
...(source.instanceId ? { instanceId: source.instanceId } : {}),
|
|
2282
|
+
...(source.slot ? { slot: source.slot } : {}),
|
|
2283
|
+
...(lastSyncError ? { lastSyncError } : {}),
|
|
2284
|
+
lastReconcileAction: "mark-stale",
|
|
2285
|
+
});
|
|
2286
|
+
if (record) {
|
|
2287
|
+
rememberIdentity(record);
|
|
2288
|
+
records.delete(getRecordOwnerKey(record));
|
|
2289
|
+
}
|
|
2290
|
+
if (syncStatus === "deleted") {
|
|
2291
|
+
pendingProvisions = pendingProvisions.filter((entry) => !entry.target || !targetMatches(entry.target, target));
|
|
2292
|
+
}
|
|
2293
|
+
markDirty();
|
|
2294
|
+
return true;
|
|
2295
|
+
},
|
|
2296
|
+
markActiveByTarget(target) {
|
|
2297
|
+
const nowMs = getNowMs();
|
|
2298
|
+
for (const record of records.values()) {
|
|
2299
|
+
if (!targetMatches(record.target, target))
|
|
2300
|
+
continue;
|
|
2301
|
+
record.status = "active";
|
|
2302
|
+
record.updatedAtMs = nowMs;
|
|
2303
|
+
record.syncStatus = "open";
|
|
2304
|
+
record.lastSyncObservedAtMs = nowMs;
|
|
2305
|
+
record.lastReconcileAction = "mark-active";
|
|
2306
|
+
delete record.lastError;
|
|
2307
|
+
delete record.lastSyncError;
|
|
2308
|
+
markDirty();
|
|
2309
|
+
return true;
|
|
2310
|
+
}
|
|
2311
|
+
return false;
|
|
2312
|
+
},
|
|
2313
|
+
renameByTarget(target, threadName, options) {
|
|
2314
|
+
if (hasWorkspaceRetirementConflict({ target }))
|
|
2315
|
+
return undefined;
|
|
2316
|
+
const nowMs = getNowMs();
|
|
2317
|
+
const normalizedThreadName = normalizeTelegramTopicTargetThreadName(threadName);
|
|
2318
|
+
if (!normalizedThreadName)
|
|
2319
|
+
return undefined;
|
|
2320
|
+
for (const record of records.values()) {
|
|
2321
|
+
if (!targetMatches(record.target, target))
|
|
2322
|
+
continue;
|
|
2323
|
+
record.manualThreadName = normalizedThreadName;
|
|
2324
|
+
record.updatedAtMs = nowMs;
|
|
2325
|
+
for (const binding of workspaceBindings.values()) {
|
|
2326
|
+
if (!targetMatches(binding.target, target))
|
|
2327
|
+
continue;
|
|
2328
|
+
const previousTitle = binding.displayTitle ?? binding.manualThreadName ??
|
|
2329
|
+
binding.threadName;
|
|
2330
|
+
binding.manualThreadName = normalizedThreadName;
|
|
2331
|
+
binding.displayTitle = options?.updateDisplayTitle === false
|
|
2332
|
+
? previousTitle : normalizedThreadName;
|
|
2333
|
+
binding.updatedAtMs = nowMs;
|
|
2334
|
+
}
|
|
2335
|
+
markDirty();
|
|
2336
|
+
return cloneRecord(record);
|
|
2337
|
+
}
|
|
2338
|
+
return undefined;
|
|
2339
|
+
},
|
|
2340
|
+
clearManualNameByTarget(target, automaticTitle) {
|
|
2341
|
+
if (hasWorkspaceRetirementConflict({ target }))
|
|
2342
|
+
return undefined;
|
|
2343
|
+
const title = normalizeTelegramTopicTargetThreadName(automaticTitle);
|
|
2344
|
+
if (!title)
|
|
2345
|
+
return undefined;
|
|
2346
|
+
const nowMs = getNowMs();
|
|
2347
|
+
for (const record of records.values()) {
|
|
2348
|
+
if (!targetMatches(record.target, target))
|
|
2349
|
+
continue;
|
|
2350
|
+
delete record.manualThreadName;
|
|
2351
|
+
record.updatedAtMs = nowMs;
|
|
2352
|
+
for (const binding of workspaceBindings.values()) {
|
|
2353
|
+
if (!targetMatches(binding.target, target))
|
|
2354
|
+
continue;
|
|
2355
|
+
delete binding.manualThreadName;
|
|
2356
|
+
binding.displayTitle = title;
|
|
2357
|
+
binding.updatedAtMs = nowMs;
|
|
2358
|
+
}
|
|
2359
|
+
markDirty();
|
|
2360
|
+
return cloneRecord(record);
|
|
2361
|
+
}
|
|
2362
|
+
return undefined;
|
|
2363
|
+
},
|
|
2364
|
+
claimReusableTarget(instanceId, threadName) {
|
|
2365
|
+
const nowMs = getNowMs();
|
|
2366
|
+
const candidates = Array.from(records.values())
|
|
2367
|
+
.filter((record) => {
|
|
2368
|
+
if (record.instanceId)
|
|
2369
|
+
return false;
|
|
2370
|
+
if (!record.slot || !/^[A-Z]$/u.test(record.slot))
|
|
2371
|
+
return false;
|
|
2372
|
+
if (record.slot === "A")
|
|
2373
|
+
return false;
|
|
2374
|
+
if (record.status !== "pending")
|
|
2375
|
+
return false;
|
|
2376
|
+
return !Array.from(records.values()).some((other) => other !== record &&
|
|
2377
|
+
other.slot === record.slot &&
|
|
2378
|
+
(other.status === "active" || other.status === "starting"));
|
|
2379
|
+
})
|
|
2380
|
+
.sort((left, right) => {
|
|
2381
|
+
const leftSlot = left.slot ?? "Z";
|
|
2382
|
+
const rightSlot = right.slot ?? "Z";
|
|
2383
|
+
if (leftSlot !== rightSlot)
|
|
2384
|
+
return leftSlot.localeCompare(rightSlot);
|
|
2385
|
+
return left.createdAtMs - right.createdAtMs;
|
|
2386
|
+
});
|
|
2387
|
+
const record = candidates[0];
|
|
2388
|
+
if (!record)
|
|
2389
|
+
return undefined;
|
|
2390
|
+
record.status = "active";
|
|
2391
|
+
record.instanceId = instanceId;
|
|
2392
|
+
record.updatedAtMs = nowMs;
|
|
2393
|
+
if (!record.threadName &&
|
|
2394
|
+
threadName &&
|
|
2395
|
+
isTelegramTopicThreadNameValidForSlot(threadName, record.slot))
|
|
2396
|
+
record.threadName = threadName;
|
|
2397
|
+
delete record.lastError;
|
|
2398
|
+
rememberIdentity(record);
|
|
2399
|
+
markDirty();
|
|
2400
|
+
return cloneRecord(record);
|
|
2401
|
+
},
|
|
2402
|
+
allocateSlot(profileKey, preferredSlot, workspaceBindingKey, options) {
|
|
2403
|
+
if (workspaceRetirementCommitInFlight)
|
|
2404
|
+
return undefined;
|
|
2405
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
2406
|
+
if (!externalReservedSlots)
|
|
2407
|
+
return undefined;
|
|
2408
|
+
const isExternalSlotOccupied = (slot) => externalReservedSlots.includes(slot);
|
|
2409
|
+
const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
|
|
2410
|
+
const existing = records.get(ownerKey) ?? records.get(profileKey);
|
|
2411
|
+
const nowMs = getNowMs();
|
|
2412
|
+
const isWorkspaceSlotOccupied = (slot) => Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey && binding.slot === slot);
|
|
2413
|
+
const isWorkspaceClaimSlotOccupied = (slot) => Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey !== workspaceBindingKey &&
|
|
2414
|
+
claim.identity.slot === slot);
|
|
2415
|
+
if (existing?.slot && isCurrentThreadRecord(existing) &&
|
|
2416
|
+
!options?.excludeCurrentRecord) {
|
|
2417
|
+
const bindingConflict = Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey &&
|
|
2418
|
+
binding.slot === existing.slot &&
|
|
2419
|
+
!targetMatches(binding.target, existing.target));
|
|
2420
|
+
const claimConflict = Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey !== workspaceBindingKey &&
|
|
2421
|
+
claim.identity.slot === existing.slot &&
|
|
2422
|
+
claim.instanceId !== existing.instanceId);
|
|
2423
|
+
return bindingConflict || claimConflict ||
|
|
2424
|
+
isExternalSlotOccupied(existing.slot) ? undefined : existing.slot;
|
|
2425
|
+
}
|
|
2426
|
+
if (workspaceBindingKey) {
|
|
2427
|
+
const claim = Array.from(workspaceClaims.values()).find((claim) => claim.identity.bindingKey === workspaceBindingKey);
|
|
2428
|
+
const slot = claim?.identity.slot;
|
|
2429
|
+
if (!slot)
|
|
2430
|
+
return undefined;
|
|
2431
|
+
const foreignClaim = Array.from(workspaceClaims.values()).some((other) => other !== claim && other.identity.slot === slot);
|
|
2432
|
+
const foreignBinding = Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey && binding.slot === slot);
|
|
2433
|
+
if (foreignClaim || foreignBinding || isExternalSlotOccupied(slot) ||
|
|
2434
|
+
isTelegramTopicTargetSlotOccupied(slot, records, reservations, pendingProvisions, nowMs))
|
|
2435
|
+
return undefined;
|
|
2436
|
+
return slot;
|
|
2437
|
+
}
|
|
2438
|
+
if (preferredSlot &&
|
|
2439
|
+
!isExternalSlotOccupied(preferredSlot) &&
|
|
2440
|
+
!isWorkspaceSlotOccupied(preferredSlot) &&
|
|
2441
|
+
!isWorkspaceClaimSlotOccupied(preferredSlot) &&
|
|
2442
|
+
!isTelegramTopicTargetSlotOccupied(preferredSlot, records, reservations, pendingProvisions, nowMs)) {
|
|
2443
|
+
return preferredSlot;
|
|
2444
|
+
}
|
|
2445
|
+
const next = getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, botState.lastSlot);
|
|
2446
|
+
if (next && !isExternalSlotOccupied(next) &&
|
|
2447
|
+
!isWorkspaceSlotOccupied(next) &&
|
|
2448
|
+
!isWorkspaceClaimSlotOccupied(next))
|
|
2449
|
+
return next;
|
|
2450
|
+
return Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase())
|
|
2451
|
+
.find((slot) => !isExternalSlotOccupied(slot) &&
|
|
2452
|
+
!isWorkspaceSlotOccupied(slot) &&
|
|
2453
|
+
!isWorkspaceClaimSlotOccupied(slot) &&
|
|
2454
|
+
!isTelegramTopicTargetSlotOccupied(slot, records, reservations, pendingProvisions, nowMs));
|
|
2455
|
+
},
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
function isTelegramTopicTargetSlotOccupied(slot, records, reservations = [], pendingProvisions = [], nowMs = Date.now()) {
|
|
2459
|
+
for (const record of records.values()) {
|
|
2460
|
+
if (record.slot === slot && isCurrentThreadRecord(record))
|
|
2461
|
+
return true;
|
|
2462
|
+
}
|
|
2463
|
+
for (const reservation of reservations) {
|
|
2464
|
+
if (reservation.expiresAtMs !== undefined &&
|
|
2465
|
+
reservation.expiresAtMs <= nowMs)
|
|
2466
|
+
continue;
|
|
2467
|
+
if (reservation.slot === slot)
|
|
2468
|
+
return true;
|
|
2469
|
+
}
|
|
2470
|
+
for (const provision of pendingProvisions) {
|
|
2471
|
+
if (provision.status !== "ambiguous" &&
|
|
2472
|
+
provision.expiresAtMs !== undefined &&
|
|
2473
|
+
provision.expiresAtMs <= nowMs)
|
|
2474
|
+
continue;
|
|
2475
|
+
if (provision.slot === slot)
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
export function normalizeTelegramTopicTargetThreadName(threadName) {
|
|
2481
|
+
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
2482
|
+
}
|
|
2483
|
+
function getGraphemeSegments(value) {
|
|
2484
|
+
const segmenter = Intl.Segmenter;
|
|
2485
|
+
if (!segmenter)
|
|
2486
|
+
return Array.from(value);
|
|
2487
|
+
return Array.from(new segmenter(undefined, { granularity: "grapheme" }).segment(value), (part) => part.segment);
|
|
2488
|
+
}
|
|
2489
|
+
export function getTelegramTopicIdentityName(threadName) {
|
|
2490
|
+
return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
|
|
2491
|
+
.join("")
|
|
2492
|
+
.trim();
|
|
2493
|
+
}
|
|
2494
|
+
const TELEGRAM_THREAD_NAME_PALETTE = {
|
|
2495
|
+
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
2496
|
+
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
2497
|
+
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
2498
|
+
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
2499
|
+
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
2500
|
+
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
2501
|
+
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
2502
|
+
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
2503
|
+
I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
|
|
2504
|
+
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
2505
|
+
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
2506
|
+
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
2507
|
+
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
2508
|
+
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
2509
|
+
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
2510
|
+
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
2511
|
+
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
2512
|
+
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
2513
|
+
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
2514
|
+
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
2515
|
+
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
2516
|
+
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
2517
|
+
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
2518
|
+
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
2519
|
+
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
2520
|
+
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
2521
|
+
};
|
|
2522
|
+
export function listOccupiedTelegramThreadIdentities(input) {
|
|
2523
|
+
const occupied = new Set();
|
|
2524
|
+
const add = (threadName) => {
|
|
2525
|
+
if (!threadName)
|
|
2526
|
+
return;
|
|
2527
|
+
occupied.add(getTelegramTopicIdentityName(threadName));
|
|
2528
|
+
};
|
|
2529
|
+
for (const record of input.records) {
|
|
2530
|
+
if (!isCurrentThreadRecord(record))
|
|
2531
|
+
continue;
|
|
2532
|
+
if (input.exceptTarget && targetMatches(record.target, input.exceptTarget))
|
|
2533
|
+
continue;
|
|
2534
|
+
add(record.manualThreadName);
|
|
2535
|
+
add(record.threadName);
|
|
2536
|
+
}
|
|
2537
|
+
for (const binding of input.workspaceBindings ?? []) {
|
|
2538
|
+
if (binding.bindingKey === input.exceptWorkspaceBindingKey)
|
|
2539
|
+
continue;
|
|
2540
|
+
if (input.exceptTarget && targetMatches(binding.target, input.exceptTarget))
|
|
2541
|
+
continue;
|
|
2542
|
+
add(binding.manualThreadName);
|
|
2543
|
+
add(binding.threadName);
|
|
2544
|
+
}
|
|
2545
|
+
for (const pending of input.pendingProvisions ?? []) {
|
|
2546
|
+
if (input.exceptTarget &&
|
|
2547
|
+
pending.target &&
|
|
2548
|
+
targetMatches(pending.target, input.exceptTarget)) {
|
|
2549
|
+
continue;
|
|
2550
|
+
}
|
|
2551
|
+
add(pending.threadName);
|
|
2552
|
+
}
|
|
2553
|
+
return Array.from(occupied);
|
|
2554
|
+
}
|
|
2555
|
+
export function chooseTelegramThreadName(input) {
|
|
2556
|
+
if (!input.slot || !/^[A-Z]$/.test(input.slot))
|
|
2557
|
+
return undefined;
|
|
2558
|
+
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
2559
|
+
if (!names || names.length === 0)
|
|
2560
|
+
return undefined;
|
|
2561
|
+
const occupied = new Set((input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)));
|
|
2562
|
+
const start = input.getRandom
|
|
2563
|
+
? Math.max(0, Math.min(names.length - 1, Math.floor(input.getRandom() * names.length)))
|
|
2564
|
+
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
2565
|
+
for (let offset = 0; offset < names.length; offset += 1) {
|
|
2566
|
+
const name = names[(start + offset) % names.length];
|
|
2567
|
+
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
2568
|
+
return name;
|
|
2569
|
+
}
|
|
2570
|
+
for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
2571
|
+
for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
|
|
2572
|
+
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
2573
|
+
return name;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return undefined;
|
|
2577
|
+
}
|
|
2578
|
+
function getTelegramThreadNameLeadingSlot(threadName) {
|
|
2579
|
+
if (!threadName)
|
|
2580
|
+
return undefined;
|
|
2581
|
+
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
2582
|
+
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
2583
|
+
}
|
|
2584
|
+
function getNextTelegramThreadNamePaletteSlot(records, fallbackSlot) {
|
|
2585
|
+
let maxCode = "A".charCodeAt(0) - 1;
|
|
2586
|
+
for (const record of records) {
|
|
2587
|
+
if (!isCurrentThreadRecord(record) || !record.threadName)
|
|
2588
|
+
continue;
|
|
2589
|
+
const identity = getTelegramTopicIdentityName(record.threadName);
|
|
2590
|
+
const first = identity[0];
|
|
2591
|
+
if (!first || !/^[A-Z]$/.test(first))
|
|
2592
|
+
continue;
|
|
2593
|
+
maxCode = Math.max(maxCode, first.charCodeAt(0));
|
|
2594
|
+
}
|
|
2595
|
+
if (maxCode < "A".charCodeAt(0))
|
|
2596
|
+
return fallbackSlot;
|
|
2597
|
+
let code = maxCode + 1;
|
|
2598
|
+
if (code > "Z".charCodeAt(0))
|
|
2599
|
+
code = "A".charCodeAt(0);
|
|
2600
|
+
return String.fromCharCode(code);
|
|
2601
|
+
}
|
|
2602
|
+
function getTelegramThreadNameEntropyIndex(entropy, length) {
|
|
2603
|
+
if (length <= 1)
|
|
2604
|
+
return 0;
|
|
2605
|
+
if (typeof entropy === "number" && entropy < 1_000_000_000_000)
|
|
2606
|
+
return 0;
|
|
2607
|
+
const value = entropy === undefined ? "0" : String(entropy);
|
|
2608
|
+
let hash = 2166136261;
|
|
2609
|
+
for (const char of value) {
|
|
2610
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
2611
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
2612
|
+
}
|
|
2613
|
+
return hash % length;
|
|
2614
|
+
}
|
|
2615
|
+
export function getTelegramTopicThreadNameValidationError(threadName, _slot) {
|
|
2616
|
+
const identity = getTelegramTopicIdentityName(threadName);
|
|
2617
|
+
const reasons = [];
|
|
2618
|
+
if (!identity)
|
|
2619
|
+
reasons.push("it is empty after trimming");
|
|
2620
|
+
if (/\s/.test(identity))
|
|
2621
|
+
reasons.push("it contains spaces");
|
|
2622
|
+
if (/[^A-Za-z]/.test(identity)) {
|
|
2623
|
+
reasons.push("it contains characters outside Latin A-Z letters");
|
|
2624
|
+
}
|
|
2625
|
+
if (!/^[A-Z]/.test(identity)) {
|
|
2626
|
+
reasons.push("it does not start with an uppercase Latin letter");
|
|
2627
|
+
}
|
|
2628
|
+
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
2629
|
+
if (genericLabels.has(identity.toLowerCase())) {
|
|
2630
|
+
reasons.push("it is a generic role label");
|
|
2631
|
+
}
|
|
2632
|
+
if (/^[A-Z]$/.test(identity))
|
|
2633
|
+
reasons.push("it is only a bare slot letter");
|
|
2634
|
+
if (reasons.length === 0)
|
|
2635
|
+
return undefined;
|
|
2636
|
+
return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
|
|
2637
|
+
}
|
|
2638
|
+
export function getTelegramManualThreadDisplayNameValidationError(threadName) {
|
|
2639
|
+
const trimmed = threadName.trim();
|
|
2640
|
+
const normalized = trimmed.replace(/\s+/g, " ");
|
|
2641
|
+
const reasons = [];
|
|
2642
|
+
if (!trimmed)
|
|
2643
|
+
reasons.push("it is empty after trimming");
|
|
2644
|
+
if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
|
|
2645
|
+
reasons.push("it contains characters outside printable ASCII");
|
|
2646
|
+
}
|
|
2647
|
+
if (normalized.length > 96)
|
|
2648
|
+
reasons.push("it is longer than 96 characters");
|
|
2649
|
+
if (/^[A-Z]$/.test(normalized)) {
|
|
2650
|
+
reasons.push("a bare slot letter is reserved for reset to automatic");
|
|
2651
|
+
}
|
|
2652
|
+
if (reasons.length === 0)
|
|
2653
|
+
return undefined;
|
|
2654
|
+
return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
|
|
2655
|
+
}
|
|
2656
|
+
export function isTelegramTopicThreadNameValidForSlot(threadName, slot) {
|
|
2657
|
+
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
2658
|
+
}
|
|
2659
|
+
function applyTopicNameTemplate(template, request, slot) {
|
|
2660
|
+
const threadName = request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
2661
|
+
let result = template
|
|
2662
|
+
.replaceAll("{threadName}", threadName)
|
|
2663
|
+
.replaceAll("{profileKey}", request.profileKey)
|
|
2664
|
+
.replaceAll("{instanceId}", request.instanceId);
|
|
2665
|
+
if (slot)
|
|
2666
|
+
result = result.replaceAll("{slot}", slot);
|
|
2667
|
+
return result;
|
|
2668
|
+
}
|
|
2669
|
+
export function getTelegramTopicName(request, template = "{slot}", slot) {
|
|
2670
|
+
const name = applyTopicNameTemplate(template, request, slot)
|
|
2671
|
+
.replace(/\s+/g, " ")
|
|
2672
|
+
.trim();
|
|
2673
|
+
return (name || slot || "Pi").slice(0, 128);
|
|
2674
|
+
}
|
|
2675
|
+
function asInteger(value) {
|
|
2676
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
2677
|
+
return value;
|
|
2678
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
2679
|
+
return undefined;
|
|
2680
|
+
const parsed = Number(value);
|
|
2681
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
2682
|
+
}
|
|
2683
|
+
export async function promoteTelegramFollowerBindingToLeader(deps) {
|
|
2684
|
+
const target = deps.target;
|
|
2685
|
+
if (typeof target?.threadId !== "number")
|
|
2686
|
+
return undefined;
|
|
2687
|
+
await deps.store.load();
|
|
2688
|
+
const nowMs = deps.nowMs ?? Date.now();
|
|
2689
|
+
const existing = deps.store
|
|
2690
|
+
.list()
|
|
2691
|
+
.find((record) => record.target.chatId === target.chatId &&
|
|
2692
|
+
record.target.threadId === target.threadId);
|
|
2693
|
+
const workspaceIdentity = deps.cwd
|
|
2694
|
+
? deps.store.claimWorkspaceIdentity(deps.cwd, deps.instanceId, existing?.instanceId, {
|
|
2695
|
+
existingBindingOnly: true,
|
|
2696
|
+
sessionId: deps.sessionId,
|
|
2697
|
+
})
|
|
2698
|
+
: undefined;
|
|
2699
|
+
const workspaceBinding = workspaceIdentity
|
|
2700
|
+
? deps.store.getWorkspaceBinding(workspaceIdentity.cwd, workspaceIdentity.instanceSlot, workspaceIdentity.sessionId)
|
|
2701
|
+
: undefined;
|
|
2702
|
+
const exactWorkspaceBinding = workspaceBinding &&
|
|
2703
|
+
targetMatches(workspaceBinding.target, target)
|
|
2704
|
+
? workspaceBinding : undefined;
|
|
2705
|
+
const slot = existing
|
|
2706
|
+
? deps.store.allocateSlot(existing.profileKey)
|
|
2707
|
+
: exactWorkspaceBinding ? workspaceIdentity?.slot : undefined;
|
|
2708
|
+
if (!slot || (deps.slot && deps.slot !== slot)) {
|
|
2709
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
2710
|
+
return undefined;
|
|
2711
|
+
}
|
|
2712
|
+
if (exactWorkspaceBinding && exactWorkspaceBinding.slot !== slot) {
|
|
2713
|
+
const committed = deps.store.upsertWorkspaceBinding({
|
|
2714
|
+
...exactWorkspaceBinding, slot, updatedAtMs: nowMs,
|
|
2715
|
+
}, deps.instanceId);
|
|
2716
|
+
if (!committed) {
|
|
2717
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
2718
|
+
return undefined;
|
|
2719
|
+
}
|
|
2720
|
+
await deps.store.persist();
|
|
2721
|
+
}
|
|
2722
|
+
const owner = {
|
|
2723
|
+
kind: "leader",
|
|
2724
|
+
cwd: deps.cwd,
|
|
2725
|
+
instanceId: deps.instanceId,
|
|
2726
|
+
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
|
|
2727
|
+
};
|
|
2728
|
+
const record = deps.store.upsert({
|
|
2729
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
2730
|
+
owner,
|
|
2731
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2732
|
+
status: "active",
|
|
2733
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
2734
|
+
updatedAtMs: nowMs,
|
|
2735
|
+
...((existing?.threadName ?? deps.threadName)
|
|
2736
|
+
? { threadName: existing?.threadName ?? deps.threadName }
|
|
2737
|
+
: {}),
|
|
2738
|
+
instanceId: deps.instanceId,
|
|
2739
|
+
slot,
|
|
2740
|
+
...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
|
|
2741
|
+
...(existing?.lastSyncObservedAtMs !== undefined
|
|
2742
|
+
? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
|
|
2743
|
+
: {}),
|
|
2744
|
+
lastReconcileAction: "follower-promoted-to-leader",
|
|
2745
|
+
...(existing?.rerouteConfirmedAtMs !== undefined
|
|
2746
|
+
? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
|
|
2747
|
+
: {}),
|
|
2748
|
+
});
|
|
2749
|
+
await deps.store.persist();
|
|
2750
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
2751
|
+
return record;
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Provision a topic for the bus leader's own use (slot A).
|
|
2755
|
+
* This is a thread-binding primitive; sync policy decides when startup/connect
|
|
2756
|
+
* should call it to ensure the leader has a visible working thread.
|
|
2757
|
+
*/
|
|
2758
|
+
export async function provisionOwnBusTopic(deps) {
|
|
2759
|
+
const chatId = deps.getAllowedUserId();
|
|
2760
|
+
let profileKey = getTelegramThreadOwnerKey({
|
|
2761
|
+
kind: "leader",
|
|
2762
|
+
cwd: deps.cwd,
|
|
2763
|
+
instanceId: deps.instanceId,
|
|
2764
|
+
telegramProfile: deps.telegramProfile,
|
|
2765
|
+
});
|
|
2766
|
+
if (typeof chatId !== "number")
|
|
2767
|
+
return undefined;
|
|
2768
|
+
await deps.store.load();
|
|
2769
|
+
const reservationCleanupPorts = {
|
|
2770
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.store),
|
|
2771
|
+
callApi: deps.callApi,
|
|
2772
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
2773
|
+
removePendingProvisionById: (id) => deps.store.removePendingProvision(id),
|
|
2774
|
+
persist: () => deps.store.persist(),
|
|
2775
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2776
|
+
recordRuntimeEvent(category, error, details) {
|
|
2777
|
+
deps.recordEvent(category, error instanceof Error ? error.message : String(error), details);
|
|
2778
|
+
},
|
|
2779
|
+
};
|
|
2780
|
+
const reservationCleanupNowMs = Date.now();
|
|
2781
|
+
const reservationsBeforeCleanup = deps.store.listReservations();
|
|
2782
|
+
const reservationCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
2783
|
+
nowMs: reservationCleanupNowMs,
|
|
2784
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
2785
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
2786
|
+
records: deps.store.list(),
|
|
2787
|
+
reservations: reservationsBeforeCleanup,
|
|
2788
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
2789
|
+
proactiveReservationCleanup: true,
|
|
2790
|
+
});
|
|
2791
|
+
deps.recordThreadReconciliationPlan?.(reservationCleanupPlan);
|
|
2792
|
+
const reservationCleanupApplyStartedAtMs = Date.now();
|
|
2793
|
+
await ThreadReconciler.applyThreadReconciliationPlan(reservationCleanupPlan, reservationCleanupPorts);
|
|
2794
|
+
deps.recordEvent("bus", "Bus leader reservation cleanup reconciled", {
|
|
2795
|
+
phase: "leader-topic-reservation-cleanup-duration",
|
|
2796
|
+
durationMs: Date.now() - reservationCleanupApplyStartedAtMs,
|
|
2797
|
+
actions: reservationCleanupPlan.actions.length,
|
|
2798
|
+
});
|
|
2799
|
+
const nowMs = Date.now();
|
|
2800
|
+
const currentLeaderOwner = {
|
|
2801
|
+
kind: "leader",
|
|
2802
|
+
cwd: deps.cwd,
|
|
2803
|
+
instanceId: deps.instanceId,
|
|
2804
|
+
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
|
|
2805
|
+
};
|
|
2806
|
+
const leaderSessionHandoff = getTelegramLeaderSessionHandoff();
|
|
2807
|
+
if (isTelegramLeaderSessionHandoffFresh(leaderSessionHandoff) &&
|
|
2808
|
+
leaderSessionHandoff.profileKey === profileKey) {
|
|
2809
|
+
const existingHandoffRecord = deps.store
|
|
2810
|
+
.list()
|
|
2811
|
+
.find((record) => targetMatches(record.target, leaderSessionHandoff.target));
|
|
2812
|
+
deps.store.upsert({
|
|
2813
|
+
profileKey,
|
|
2814
|
+
owner: currentLeaderOwner,
|
|
2815
|
+
target: { ...leaderSessionHandoff.target },
|
|
2816
|
+
status: "active",
|
|
2817
|
+
createdAtMs: existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
|
|
2818
|
+
updatedAtMs: nowMs,
|
|
2819
|
+
threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
|
|
2820
|
+
instanceId: deps.instanceId,
|
|
2821
|
+
slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
|
|
2822
|
+
...(existingHandoffRecord?.syncStatus
|
|
2823
|
+
? { syncStatus: existingHandoffRecord.syncStatus }
|
|
2824
|
+
: {}),
|
|
2825
|
+
...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined
|
|
2826
|
+
? {
|
|
2827
|
+
lastSyncObservedAtMs: existingHandoffRecord.lastSyncObservedAtMs,
|
|
2828
|
+
}
|
|
2829
|
+
: {}),
|
|
2830
|
+
lastReconcileAction: "leader-session-handoff-restored",
|
|
2831
|
+
});
|
|
2832
|
+
await deps.store.persist();
|
|
2833
|
+
setTelegramLeaderSessionHandoff(undefined);
|
|
2834
|
+
deps.recordEvent("bus", "Bus leader session handoff restored", {
|
|
2835
|
+
phase: "leader-session-handoff-restore",
|
|
2836
|
+
chatId: leaderSessionHandoff.target.chatId,
|
|
2837
|
+
threadId: leaderSessionHandoff.target.threadId,
|
|
2838
|
+
slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
|
|
2839
|
+
threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
|
|
2840
|
+
previousInstanceId: leaderSessionHandoff.instanceId,
|
|
2841
|
+
instanceId: deps.instanceId,
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
else if (leaderSessionHandoff) {
|
|
2845
|
+
setTelegramLeaderSessionHandoff(undefined);
|
|
2846
|
+
}
|
|
2847
|
+
const recordsBeforePreviousLeaderCleanup = deps.store.list();
|
|
2848
|
+
const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
2849
|
+
nowMs,
|
|
2850
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
2851
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
2852
|
+
records: recordsBeforePreviousLeaderCleanup.map((record) => ({
|
|
2853
|
+
...record,
|
|
2854
|
+
ownerKind: record.owner?.kind,
|
|
2855
|
+
})),
|
|
2856
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
2857
|
+
previousLeaderCleanup: { currentInstanceId: deps.instanceId },
|
|
2858
|
+
});
|
|
2859
|
+
deps.recordThreadReconciliationPlan?.(previousLeaderCleanupPlan);
|
|
2860
|
+
for (const action of previousLeaderCleanupPlan.actions) {
|
|
2861
|
+
if (action.kind !== "close-delete-previous-leader-topic")
|
|
2862
|
+
continue;
|
|
2863
|
+
const record = recordsBeforePreviousLeaderCleanup.find((candidate) => targetMatches(candidate.target, action.target));
|
|
2864
|
+
if (!record)
|
|
2865
|
+
continue;
|
|
2866
|
+
const isSameProfile = record.profileKey === profileKey;
|
|
2867
|
+
if (isSameProfile) {
|
|
2868
|
+
deps.recordEvent("bus", "Bus leader same-profile topic preserved", {
|
|
2869
|
+
phase: "leader-topic-same-profile-preserve",
|
|
2870
|
+
chatId: record.target.chatId,
|
|
2871
|
+
threadId: record.target.threadId,
|
|
2872
|
+
slot: record.slot,
|
|
2873
|
+
previousInstanceId: record.instanceId,
|
|
2874
|
+
instanceId: deps.instanceId,
|
|
2875
|
+
profileKey,
|
|
2876
|
+
});
|
|
2877
|
+
continue;
|
|
2878
|
+
}
|
|
2879
|
+
if (isSameTelegramProcessInstance(record.instanceId, deps.instanceId)) {
|
|
2880
|
+
deps.store.upsert({
|
|
2881
|
+
...record,
|
|
2882
|
+
profileKey,
|
|
2883
|
+
owner: currentLeaderOwner,
|
|
2884
|
+
status: "active",
|
|
2885
|
+
instanceId: deps.instanceId,
|
|
2886
|
+
updatedAtMs: nowMs,
|
|
2887
|
+
lastError: undefined,
|
|
2888
|
+
lastReconcileAction: "leader-topic-same-process-preserve",
|
|
2889
|
+
});
|
|
2890
|
+
deps.recordEvent("bus", "Bus leader same-process topic preserved", {
|
|
2891
|
+
phase: "leader-topic-same-process-preserve",
|
|
2892
|
+
chatId: record.target.chatId,
|
|
2893
|
+
threadId: record.target.threadId,
|
|
2894
|
+
slot: record.slot,
|
|
2895
|
+
previousInstanceId: record.instanceId,
|
|
2896
|
+
instanceId: deps.instanceId,
|
|
2897
|
+
profileKey,
|
|
2898
|
+
});
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
const previousLeaderCleanupStartedAtMs = Date.now();
|
|
2902
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.store, record);
|
|
2903
|
+
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan({ actions: [action] }, {
|
|
2904
|
+
isCleanupTargetProtected,
|
|
2905
|
+
callApi: deps.callApi,
|
|
2906
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
2907
|
+
persist: () => deps.store.persist(),
|
|
2908
|
+
removePendingProvisionById: (id) => deps.store.removePendingProvision(id),
|
|
2909
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2910
|
+
recordRuntimeEvent(category, error, details) {
|
|
2911
|
+
deps.recordEvent(category, error instanceof Error ? error.message : String(error), details);
|
|
2912
|
+
},
|
|
2913
|
+
});
|
|
2914
|
+
deps.recordEvent("bus", "Bus leader previous-topic cleanup applied", {
|
|
2915
|
+
phase: "leader-topic-previous-cleanup-duration",
|
|
2916
|
+
durationMs: Date.now() - previousLeaderCleanupStartedAtMs,
|
|
2917
|
+
chatId: record.target.chatId,
|
|
2918
|
+
threadId: record.target.threadId,
|
|
2919
|
+
slot: record.slot,
|
|
2920
|
+
});
|
|
2921
|
+
if (deps.getCurrentLeaderEpoch &&
|
|
2922
|
+
(action.leaderEpoch === undefined ||
|
|
2923
|
+
deps.getCurrentLeaderEpoch() !== action.leaderEpoch)) {
|
|
2924
|
+
deps.recordEvent("bus", "Skipped previous-topic local cleanup after leader epoch loss", {
|
|
2925
|
+
phase: "leader-topic-previous-cleanup-stale-epoch-skip",
|
|
2926
|
+
actionLeaderEpoch: action.leaderEpoch,
|
|
2927
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch(),
|
|
2928
|
+
chatId: record.target.chatId,
|
|
2929
|
+
threadId: record.target.threadId,
|
|
2930
|
+
});
|
|
2931
|
+
throw new Error("Telegram leader ownership changed during topic reconciliation.");
|
|
2932
|
+
}
|
|
2933
|
+
if (cleanup.incompleteActions?.length) {
|
|
2934
|
+
throw new Error("Previous Telegram leader topic deletion was not confirmed.");
|
|
2935
|
+
}
|
|
2936
|
+
if (isCleanupTargetProtected(action.target, action))
|
|
2937
|
+
continue;
|
|
2938
|
+
deps.store.markStaleByTarget(record.target);
|
|
2939
|
+
if (record.slot) {
|
|
2940
|
+
deps.store.reserveThread({
|
|
2941
|
+
target: record.target,
|
|
2942
|
+
slot: record.slot,
|
|
2943
|
+
reason: "previous-process-cleaned-without-visible-probe",
|
|
2944
|
+
createdAtMs: nowMs,
|
|
2945
|
+
updatedAtMs: nowMs,
|
|
2946
|
+
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
2947
|
+
instanceId: record.instanceId,
|
|
2948
|
+
lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
deps.store.setBotState({
|
|
2952
|
+
threadMode: "enabled",
|
|
2953
|
+
updatedAtMs: nowMs,
|
|
2954
|
+
lastReconcileAction: "leader-topic-next-slot-after-unprobed-previous",
|
|
2955
|
+
});
|
|
2956
|
+
deps.recordEvent("bus", "Bus leader previous-process topic reserved after cleanup without visible probe", {
|
|
2957
|
+
phase: "leader-topic-previous-instance-reserve-no-probe",
|
|
2958
|
+
chatId: record.target.chatId,
|
|
2959
|
+
threadId: record.target.threadId,
|
|
2960
|
+
slot: record.slot,
|
|
2961
|
+
previousInstanceId: record.instanceId,
|
|
2962
|
+
instanceId: deps.instanceId,
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
const provision = createTelegramTopicTargetProvisioner({
|
|
2966
|
+
topicChatId: chatId,
|
|
2967
|
+
store: deps.store,
|
|
2968
|
+
callApi: deps.callApi,
|
|
2969
|
+
getNowMs: deps.getNowMs,
|
|
2970
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2971
|
+
getRandom: deps.getRandom,
|
|
2972
|
+
resolveInitialWorkspaceDisplayTitle: deps.resolveInitialWorkspaceDisplayTitle,
|
|
2973
|
+
claimPendingTargets: false,
|
|
2974
|
+
});
|
|
2975
|
+
let result = await provision({
|
|
2976
|
+
instanceId: deps.instanceId,
|
|
2977
|
+
owner: currentLeaderOwner,
|
|
2978
|
+
profileKey,
|
|
2979
|
+
...(deps.requestedThreadName
|
|
2980
|
+
? { threadName: deps.requestedThreadName }
|
|
2981
|
+
: {}),
|
|
2982
|
+
...(deps.preferredSlot ? { preferredSlot: deps.preferredSlot } : {}),
|
|
2983
|
+
...(deps.workspaceBindingKey
|
|
2984
|
+
? {
|
|
2985
|
+
workspaceBindingKey: deps.workspaceBindingKey,
|
|
2986
|
+
...(deps.cwd ? { workspaceCwd: deps.cwd } : {}),
|
|
2987
|
+
}
|
|
2988
|
+
: {}),
|
|
2989
|
+
});
|
|
2990
|
+
if (result.reused) {
|
|
2991
|
+
// Reused topics may already have a human-chosen Telegram title. Do not edit
|
|
2992
|
+
// them during leader startup: startup reconciliation must not reset a named
|
|
2993
|
+
// topic back to its bare slot or create redundant "renamed the thread" service
|
|
2994
|
+
// messages. Also do not probe with Bot API chat actions: every chat action is
|
|
2995
|
+
// user-visible as native typing/activity, so reload would falsely signal that
|
|
2996
|
+
// the agent is working. Treat the reused binding as optimistically open;
|
|
2997
|
+
// ordinary target-scoped sends still detect stale topics and trigger the
|
|
2998
|
+
// stale-api-error reconciliation path when real delivery happens.
|
|
2999
|
+
const nowMs = Date.now();
|
|
3000
|
+
deps.store.upsert({
|
|
3001
|
+
...result.record,
|
|
3002
|
+
syncStatus: "open",
|
|
3003
|
+
lastSyncObservedAtMs: nowMs,
|
|
3004
|
+
lastReconcileAction: "leader-startup-skip-probe",
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
deps.store.setBotState({
|
|
3008
|
+
threadMode: "enabled",
|
|
3009
|
+
updatedAtMs: Date.now(),
|
|
3010
|
+
lastReconcileAction: result.reused
|
|
3011
|
+
? "leader-startup-skip-probe"
|
|
3012
|
+
: "leader-topic-created",
|
|
3013
|
+
});
|
|
3014
|
+
await deps.store.persist();
|
|
3015
|
+
deps.recordEvent("bus", "Bus leader own topic assigned", {
|
|
3016
|
+
phase: "leader-topic",
|
|
3017
|
+
chatId: result.target.chatId,
|
|
3018
|
+
threadId: result.target.threadId,
|
|
3019
|
+
slot: result.record.slot,
|
|
3020
|
+
threadName: result.record.threadName,
|
|
3021
|
+
reused: result.reused,
|
|
3022
|
+
});
|
|
3023
|
+
if (!result.record.slot) {
|
|
3024
|
+
throw new Error("Telegram Thread slot authority is unavailable.");
|
|
3025
|
+
}
|
|
3026
|
+
return {
|
|
3027
|
+
target: result.target,
|
|
3028
|
+
slot: result.record.slot,
|
|
3029
|
+
...(result.record.threadName
|
|
3030
|
+
? { threadName: result.record.threadName }
|
|
3031
|
+
: {}),
|
|
3032
|
+
...(result.displayTitle ? { displayTitle: result.displayTitle } : {}),
|
|
3033
|
+
reused: result.reused,
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
export function resolveTelegramInstanceThreadIdentity(options) {
|
|
3037
|
+
const targetMatchesCandidate = (candidate) => {
|
|
3038
|
+
if (!candidate)
|
|
3039
|
+
return false;
|
|
3040
|
+
if (!options.target)
|
|
3041
|
+
return true;
|
|
3042
|
+
return (!!candidate.target && targetMatches(candidate.target, options.target));
|
|
3043
|
+
};
|
|
3044
|
+
const local = targetMatchesCandidate(options.follower)
|
|
3045
|
+
? options.follower
|
|
3046
|
+
: targetMatchesCandidate(options.leader)
|
|
3047
|
+
? options.leader
|
|
3048
|
+
: undefined;
|
|
3049
|
+
const record = options.record &&
|
|
3050
|
+
(!options.target || targetMatches(options.record.target, options.target))
|
|
3051
|
+
? options.record
|
|
3052
|
+
: undefined;
|
|
3053
|
+
return {
|
|
3054
|
+
...((local?.target ?? record?.target)
|
|
3055
|
+
? { target: local?.target ?? record?.target }
|
|
3056
|
+
: {}),
|
|
3057
|
+
...((local?.slot ?? record?.slot)
|
|
3058
|
+
? { slot: local?.slot ?? record?.slot }
|
|
3059
|
+
: {}),
|
|
3060
|
+
...((local?.threadName ?? record?.threadName)
|
|
3061
|
+
? { threadName: local?.threadName ?? record?.threadName }
|
|
3062
|
+
: {}),
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
export function createTelegramLeaderThreadStateRuntime() {
|
|
3066
|
+
let identity;
|
|
3067
|
+
return {
|
|
3068
|
+
getTarget: () => identity?.target,
|
|
3069
|
+
getIdentity: () => identity,
|
|
3070
|
+
set(input) {
|
|
3071
|
+
identity = { ...input, target: { ...input.target } };
|
|
3072
|
+
},
|
|
3073
|
+
clear() {
|
|
3074
|
+
identity = undefined;
|
|
3075
|
+
},
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
export function createTelegramCurrentInstanceThreadRuntime(deps) {
|
|
3079
|
+
const findRecord = function () {
|
|
3080
|
+
return findCurrentTelegramInstanceThreadRecord({
|
|
3081
|
+
records: deps.listRecords(),
|
|
3082
|
+
instanceId: deps.instanceId,
|
|
3083
|
+
preferredTarget: deps.getPreferredTarget(),
|
|
3084
|
+
});
|
|
3085
|
+
};
|
|
3086
|
+
const getRecord = function () {
|
|
3087
|
+
const record = findRecord();
|
|
3088
|
+
const follower = deps.getFollower();
|
|
3089
|
+
if (record?.owner?.kind === "manual-follower" && !follower?.registered) {
|
|
3090
|
+
return undefined;
|
|
3091
|
+
}
|
|
3092
|
+
return record;
|
|
3093
|
+
};
|
|
3094
|
+
return {
|
|
3095
|
+
findRecord,
|
|
3096
|
+
getRecord,
|
|
3097
|
+
getIdentity(target) {
|
|
3098
|
+
const follower = deps.getFollower();
|
|
3099
|
+
const record = target
|
|
3100
|
+
? findCurrentTelegramInstanceThreadRecord({
|
|
3101
|
+
records: deps.listRecords(),
|
|
3102
|
+
instanceId: deps.instanceId,
|
|
3103
|
+
preferredTarget: target,
|
|
3104
|
+
})
|
|
3105
|
+
: getRecord();
|
|
3106
|
+
return resolveTelegramInstanceThreadIdentity({
|
|
3107
|
+
target,
|
|
3108
|
+
follower: follower?.registered ? follower : undefined,
|
|
3109
|
+
leader: deps.getLeader(),
|
|
3110
|
+
record,
|
|
3111
|
+
});
|
|
3112
|
+
},
|
|
3113
|
+
getRestorationIdentity() {
|
|
3114
|
+
const follower = deps.getFollower();
|
|
3115
|
+
return resolveTelegramInstanceThreadIdentity({
|
|
3116
|
+
follower: follower?.registered ? follower : undefined,
|
|
3117
|
+
leader: deps.getLeader(),
|
|
3118
|
+
record: findRecord(),
|
|
3119
|
+
});
|
|
3120
|
+
},
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
export function findCurrentTelegramInstanceThreadRecord(options) {
|
|
3124
|
+
const target = options.preferredTarget;
|
|
3125
|
+
if (typeof target?.threadId === "number") {
|
|
3126
|
+
const targetRecord = options.records.find((record) => {
|
|
3127
|
+
return (record.target.chatId === target.chatId &&
|
|
3128
|
+
record.target.threadId === target.threadId);
|
|
3129
|
+
});
|
|
3130
|
+
if (targetRecord)
|
|
3131
|
+
return targetRecord;
|
|
3132
|
+
}
|
|
3133
|
+
return options.records.find((record) => {
|
|
3134
|
+
return (record.instanceId === options.instanceId && record.status === "active");
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
export function resolveTelegramInstanceThreadTarget(options) {
|
|
3138
|
+
const raw = typeof options.followerTarget?.threadId === "number"
|
|
3139
|
+
? options.followerTarget
|
|
3140
|
+
: (options.currentRecord?.target ?? options.leaderTarget);
|
|
3141
|
+
return raw &&
|
|
3142
|
+
typeof raw.chatId === "number" &&
|
|
3143
|
+
typeof raw.threadId === "number"
|
|
3144
|
+
? { chatId: raw.chatId, threadId: raw.threadId }
|
|
3145
|
+
: undefined;
|
|
3146
|
+
}
|
|
3147
|
+
export function createTelegramThreadStatusProjectionRuntime(deps) {
|
|
3148
|
+
return {
|
|
3149
|
+
getBusRole() {
|
|
3150
|
+
if (deps.getThreadMode() === "disabled")
|
|
3151
|
+
return undefined;
|
|
3152
|
+
if (deps.isBusPollingStarted())
|
|
3153
|
+
return "leader";
|
|
3154
|
+
return deps.isFollowerRegistered() ? "follower" : undefined;
|
|
3155
|
+
},
|
|
3156
|
+
getBusFollowers() {
|
|
3157
|
+
return listTelegramThreadStatusFollowers({
|
|
3158
|
+
followers: deps.listFollowers(),
|
|
3159
|
+
records: deps.listRecords(),
|
|
3160
|
+
}).map((follower) => ({
|
|
3161
|
+
...follower,
|
|
3162
|
+
threadName: (follower.target ? deps.getDisplayTitle?.(follower.target) : undefined) ?? follower.threadName,
|
|
3163
|
+
}));
|
|
3164
|
+
},
|
|
3165
|
+
getLocalBus() {
|
|
3166
|
+
const leaderSocketPath = deps.getLeaderSocketPath();
|
|
3167
|
+
const followerSocketPath = deps.getFollowerSocketPath();
|
|
3168
|
+
const leaderProtocol = deps.getLeaderProtocol?.();
|
|
3169
|
+
const followerTarget = deps.getFollowerTarget();
|
|
3170
|
+
return {
|
|
3171
|
+
leaderSocketPath,
|
|
3172
|
+
leaderTransport: deps.getTransportKind(leaderSocketPath),
|
|
3173
|
+
followerSocketPath,
|
|
3174
|
+
followerTransport: deps.getTransportKind(followerSocketPath),
|
|
3175
|
+
followerRegistered: deps.isFollowerRegistered(),
|
|
3176
|
+
followerTarget,
|
|
3177
|
+
followerSlot: deps.getFollowerSlot(),
|
|
3178
|
+
followerThreadName: (followerTarget ? deps.getDisplayTitle?.(followerTarget) : undefined) ??
|
|
3179
|
+
deps.getFollowerThreadName(),
|
|
3180
|
+
...(leaderProtocol ? { leaderProtocol } : {}),
|
|
3181
|
+
};
|
|
3182
|
+
},
|
|
3183
|
+
getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()).map((record) => ({
|
|
3184
|
+
...record, threadName: deps.getDisplayTitle?.(record.target) ?? record.threadName,
|
|
3185
|
+
})),
|
|
3186
|
+
getThreadReservations: () => listTelegramThreadStatusReservations(deps.listReservations()),
|
|
3187
|
+
getTopicSyncObservations: () => listTelegramThreadStatusObservations(deps.listSyncObservations()),
|
|
3188
|
+
getInstanceSlot() {
|
|
3189
|
+
if (deps.getThreadMode() === "disabled")
|
|
3190
|
+
return undefined;
|
|
3191
|
+
return deps.getCurrentIdentity().slot;
|
|
3192
|
+
},
|
|
3193
|
+
getInstanceThreadName() {
|
|
3194
|
+
if (deps.getThreadMode() === "disabled")
|
|
3195
|
+
return undefined;
|
|
3196
|
+
return deps.getCurrentIdentity().threadName;
|
|
3197
|
+
},
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
/** Own current-thread preference and its matching status projection. */
|
|
3201
|
+
export function createTelegramCurrentThreadAssembly(deps) {
|
|
3202
|
+
const getFollower = () => {
|
|
3203
|
+
const target = deps.getFollowerTarget();
|
|
3204
|
+
if (!target)
|
|
3205
|
+
return undefined;
|
|
3206
|
+
return {
|
|
3207
|
+
registered: deps.isFollowerRegistered(),
|
|
3208
|
+
target,
|
|
3209
|
+
slot: deps.getFollowerSlot(),
|
|
3210
|
+
threadName: deps.getFollowerThreadName(),
|
|
3211
|
+
};
|
|
3212
|
+
};
|
|
3213
|
+
const namedCurrent = createTelegramCurrentInstanceThreadRuntime({
|
|
3214
|
+
instanceId: deps.instanceId,
|
|
3215
|
+
listRecords: deps.listRecords,
|
|
3216
|
+
getPreferredTarget: () => deps.getActiveTurnTarget() ??
|
|
3217
|
+
deps.getFollowerTarget() ??
|
|
3218
|
+
deps.getLeaderTarget(),
|
|
3219
|
+
getFollower,
|
|
3220
|
+
getLeader: deps.getLeaderIdentity,
|
|
3221
|
+
});
|
|
3222
|
+
const getDisplayTitle = (target) => {
|
|
3223
|
+
const followerTarget = deps.getFollowerTarget();
|
|
3224
|
+
if (followerTarget && targetMatches(followerTarget, target) && deps.isFollowerRegistered()) {
|
|
3225
|
+
return deps.getFollowerDisplayTitle?.();
|
|
3226
|
+
}
|
|
3227
|
+
return deps.listWorkspaceBindings?.().find((binding) => targetMatches(binding.target, target))?.displayTitle;
|
|
3228
|
+
};
|
|
3229
|
+
const displayIdentity = (identity) => {
|
|
3230
|
+
const title = identity.target ? getDisplayTitle(identity.target) : undefined;
|
|
3231
|
+
return title ? { ...identity, threadName: title } : identity;
|
|
3232
|
+
};
|
|
3233
|
+
const current = {
|
|
3234
|
+
...namedCurrent,
|
|
3235
|
+
getIdentity: (target) => displayIdentity(namedCurrent.getIdentity(target)),
|
|
3236
|
+
};
|
|
3237
|
+
return {
|
|
3238
|
+
getDisplayTitle,
|
|
3239
|
+
current,
|
|
3240
|
+
status: createTelegramThreadStatusProjectionRuntime({
|
|
3241
|
+
...deps.status,
|
|
3242
|
+
isFollowerRegistered: deps.isFollowerRegistered,
|
|
3243
|
+
listRecords: deps.listRecords,
|
|
3244
|
+
getFollowerTarget: deps.getFollowerTarget,
|
|
3245
|
+
getFollowerSlot: deps.getFollowerSlot,
|
|
3246
|
+
getFollowerThreadName: deps.getFollowerThreadName,
|
|
3247
|
+
getLeaderProtocol: deps.getLeaderProtocol,
|
|
3248
|
+
getDisplayTitle,
|
|
3249
|
+
getCurrentIdentity: () => displayIdentity(namedCurrent.getRestorationIdentity()),
|
|
3250
|
+
}),
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3253
|
+
function getTelegramThreadStatusName(record) {
|
|
3254
|
+
if (!record)
|
|
3255
|
+
return undefined;
|
|
3256
|
+
if (record.threadName &&
|
|
3257
|
+
isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot))
|
|
3258
|
+
return record.threadName;
|
|
3259
|
+
return chooseTelegramThreadName({ slot: record.slot });
|
|
3260
|
+
}
|
|
3261
|
+
export function listTelegramThreadStatusFollowers(options) {
|
|
3262
|
+
return options.followers.map((follower) => {
|
|
3263
|
+
const record = options.records.find((record) => {
|
|
3264
|
+
return (record.target.chatId === follower.target?.chatId &&
|
|
3265
|
+
record.target.threadId === follower.target?.threadId);
|
|
3266
|
+
});
|
|
3267
|
+
return {
|
|
3268
|
+
instanceId: follower.instanceId,
|
|
3269
|
+
cwd: follower.cwd,
|
|
3270
|
+
lastHeartbeatMs: follower.lastHeartbeatMs,
|
|
3271
|
+
target: follower.target,
|
|
3272
|
+
...(follower.protocol ? { protocol: follower.protocol } : {}),
|
|
3273
|
+
slot: record?.slot,
|
|
3274
|
+
threadName: getTelegramThreadStatusName(record),
|
|
3275
|
+
status: record?.status,
|
|
3276
|
+
};
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
export function listTelegramThreadStatusTargets(records) {
|
|
3280
|
+
return records.map((record) => {
|
|
3281
|
+
return {
|
|
3282
|
+
instanceId: record.instanceId,
|
|
3283
|
+
status: record.status,
|
|
3284
|
+
target: record.target,
|
|
3285
|
+
slot: record.slot,
|
|
3286
|
+
threadName: getTelegramThreadStatusName(record),
|
|
3287
|
+
syncStatus: record.syncStatus,
|
|
3288
|
+
lastSyncObservedAtMs: record.lastSyncObservedAtMs,
|
|
3289
|
+
lastSyncProbeAtMs: record.lastSyncProbeAtMs,
|
|
3290
|
+
lastSyncError: record.lastSyncError,
|
|
3291
|
+
lastReconcileAction: record.lastReconcileAction,
|
|
3292
|
+
};
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
export function listTelegramThreadStatusReservations(reservations) {
|
|
3296
|
+
return reservations.map((reservation) => {
|
|
3297
|
+
return {
|
|
3298
|
+
target: reservation.target,
|
|
3299
|
+
slot: reservation.slot,
|
|
3300
|
+
reason: reservation.reason,
|
|
3301
|
+
instanceId: reservation.instanceId,
|
|
3302
|
+
expiresAtMs: reservation.expiresAtMs,
|
|
3303
|
+
lastReconcileAction: reservation.lastReconcileAction,
|
|
3304
|
+
};
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
export function listTelegramThreadStatusObservations(observations) {
|
|
3308
|
+
return observations.map((observation) => {
|
|
3309
|
+
return {
|
|
3310
|
+
target: observation.target,
|
|
3311
|
+
syncStatus: observation.syncStatus,
|
|
3312
|
+
observedAtMs: observation.observedAtMs,
|
|
3313
|
+
instanceId: observation.instanceId,
|
|
3314
|
+
slot: observation.slot,
|
|
3315
|
+
lastSyncError: observation.lastSyncError,
|
|
3316
|
+
lastReconcileAction: observation.lastReconcileAction,
|
|
3317
|
+
};
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
export function getTelegramTargetFromApiBody(body) {
|
|
3321
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
3322
|
+
return undefined;
|
|
3323
|
+
const record = body;
|
|
3324
|
+
const chatId = asInteger(record.chat_id);
|
|
3325
|
+
const threadId = asInteger(record.message_thread_id);
|
|
3326
|
+
return chatId !== undefined && threadId !== undefined
|
|
3327
|
+
? { chatId, threadId }
|
|
3328
|
+
: undefined;
|
|
3329
|
+
}
|
|
3330
|
+
export function isTelegramTopicTargetStaleError(error) {
|
|
3331
|
+
if (!(error instanceof Error))
|
|
3332
|
+
return false;
|
|
3333
|
+
const status = "status" in error && typeof error.status === "number"
|
|
3334
|
+
? error.status
|
|
3335
|
+
: undefined;
|
|
3336
|
+
if (status !== undefined && status !== 400)
|
|
3337
|
+
return false;
|
|
3338
|
+
const message = error.message.toLowerCase();
|
|
3339
|
+
return (message.includes("topic_id_invalid") ||
|
|
3340
|
+
message.includes("message thread not found") ||
|
|
3341
|
+
message.includes("thread not found") ||
|
|
3342
|
+
message.includes("topic not found") ||
|
|
3343
|
+
message.includes("topic deleted") ||
|
|
3344
|
+
message.includes("topic closed") ||
|
|
3345
|
+
message.includes("thread closed") ||
|
|
3346
|
+
message.includes("forum topic closed") ||
|
|
3347
|
+
message.includes("message thread closed"));
|
|
3348
|
+
}
|
|
3349
|
+
export function isTelegramTopicModeUnavailableError(error) {
|
|
3350
|
+
if (!(error instanceof Error))
|
|
3351
|
+
return false;
|
|
3352
|
+
const message = error.message.toLowerCase();
|
|
3353
|
+
return (message.includes("not a forum") ||
|
|
3354
|
+
message.includes("forum topic") ||
|
|
3355
|
+
message.includes("topics are disabled") ||
|
|
3356
|
+
message.includes("threaded mode") ||
|
|
3357
|
+
message.includes("method is available only for"));
|
|
3358
|
+
}
|
|
3359
|
+
export function getTelegramTopicTitleForThreadName(threadName, slot, template = "{threadName}") {
|
|
3360
|
+
return getTelegramTopicName({
|
|
3361
|
+
instanceId: "",
|
|
3362
|
+
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
3363
|
+
threadName,
|
|
3364
|
+
}, template, slot);
|
|
3365
|
+
}
|
|
3366
|
+
export function createTelegramTopicTargetRenamer(deps) {
|
|
3367
|
+
return async (request) => {
|
|
3368
|
+
const threadName = normalizeTelegramTopicTargetThreadName(request.threadName);
|
|
3369
|
+
if (!threadName ||
|
|
3370
|
+
!!getTelegramManualThreadDisplayNameValidationError(threadName))
|
|
3371
|
+
return undefined;
|
|
3372
|
+
const occupied = new Set(listOccupiedTelegramThreadIdentities({
|
|
3373
|
+
records: deps.store.list(),
|
|
3374
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
3375
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
3376
|
+
exceptTarget: request.target,
|
|
3377
|
+
}));
|
|
3378
|
+
if (occupied.has(getTelegramTopicIdentityName(threadName)))
|
|
3379
|
+
return undefined;
|
|
3380
|
+
const name = getTelegramTopicTitleForThreadName(threadName, request.slot ?? "", deps.topicNameTemplate);
|
|
3381
|
+
deps.assertAuthority?.();
|
|
3382
|
+
const updateDisplayTitle = deps.shouldRenameDisplayedTitle?.() ?? true;
|
|
3383
|
+
if (updateDisplayTitle) {
|
|
3384
|
+
await deps.callApi("editForumTopic", {
|
|
3385
|
+
chat_id: request.target.chatId,
|
|
3386
|
+
message_thread_id: request.target.threadId,
|
|
3387
|
+
name,
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
deps.assertAuthority?.();
|
|
3391
|
+
if ((deps.shouldRenameDisplayedTitle?.() ?? true) !== updateDisplayTitle) {
|
|
3392
|
+
throw new Error("Telegram display mode changed during Workspace rename.");
|
|
3393
|
+
}
|
|
3394
|
+
return deps.store.renameByTarget(request.target, threadName, { updateDisplayTitle });
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
export function createTelegramTopicTargetProvisioner(deps) {
|
|
3398
|
+
const getNowMs = deps.getNowMs ?? (() => 0);
|
|
3399
|
+
const getRandom = deps.getRandom;
|
|
3400
|
+
return async (request) => {
|
|
3401
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
3402
|
+
const assertLeaderEpoch = (phase) => {
|
|
3403
|
+
if (deps.getCurrentLeaderEpoch &&
|
|
3404
|
+
(leaderEpoch === undefined ||
|
|
3405
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch)) {
|
|
3406
|
+
throw new Error(`Telegram topic provisioning lost leader ownership (${phase}).`);
|
|
3407
|
+
}
|
|
3408
|
+
};
|
|
3409
|
+
assertLeaderEpoch("start");
|
|
3410
|
+
const isManualFollowerRequest = request.owner?.kind === "manual-follower";
|
|
3411
|
+
const identity = deps.store.getIdentityByProfileKey(request.profileKey);
|
|
3412
|
+
const nowMs = getNowMs();
|
|
3413
|
+
let pendingForRequest = deps.store
|
|
3414
|
+
.listPendingProvisions()
|
|
3415
|
+
.find((pending) => pending.profileKey === request.profileKey ||
|
|
3416
|
+
pending.instanceId === request.instanceId);
|
|
3417
|
+
if (pendingForRequest?.target) {
|
|
3418
|
+
const target = pendingForRequest.target;
|
|
3419
|
+
const observation = deps.store.listSyncObservations().find((entry) => targetMatches(entry.target, target));
|
|
3420
|
+
assertTelegramPendingTopicRecoveryAllowed(deps.store, target);
|
|
3421
|
+
if (observation?.syncStatus === "deleted") {
|
|
3422
|
+
assertLeaderEpoch("before-deleted-provision-settlement");
|
|
3423
|
+
deps.store.markStaleByTarget(target, "deleted");
|
|
3424
|
+
await deps.store.persist();
|
|
3425
|
+
assertLeaderEpoch("after-deleted-provision-settlement");
|
|
3426
|
+
pendingForRequest = undefined;
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
3430
|
+
if (existing && isCurrentThreadRecord(existing)) {
|
|
3431
|
+
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
3432
|
+
if (!slot) {
|
|
3433
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
3434
|
+
}
|
|
3435
|
+
const occupied = listOccupiedTelegramThreadIdentities({
|
|
3436
|
+
records: deps.store.list(),
|
|
3437
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
3438
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
3439
|
+
exceptTarget: existing.target,
|
|
3440
|
+
exceptWorkspaceBindingKey: request.workspaceBindingKey,
|
|
3441
|
+
});
|
|
3442
|
+
const identityThreadName = identity?.threadName &&
|
|
3443
|
+
isTelegramTopicThreadNameValidForSlot(identity.threadName, slot) &&
|
|
3444
|
+
!occupied.includes(getTelegramTopicIdentityName(identity.threadName))
|
|
3445
|
+
? identity.threadName
|
|
3446
|
+
: undefined;
|
|
3447
|
+
const bakedThreadName = chooseTelegramThreadName({
|
|
3448
|
+
slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
|
|
3449
|
+
entropy: nowMs,
|
|
3450
|
+
getRandom,
|
|
3451
|
+
occupied,
|
|
3452
|
+
});
|
|
3453
|
+
const record = deps.store.upsert({
|
|
3454
|
+
...existing,
|
|
3455
|
+
status: "active",
|
|
3456
|
+
updatedAtMs: nowMs,
|
|
3457
|
+
threadName: existing.threadName ?? identityThreadName ?? bakedThreadName,
|
|
3458
|
+
instanceId: request.instanceId,
|
|
3459
|
+
slot,
|
|
3460
|
+
owner: request.owner ?? existing.owner,
|
|
3461
|
+
lastError: undefined,
|
|
3462
|
+
});
|
|
3463
|
+
const recoveredTitle = pendingForRequest?.target &&
|
|
3464
|
+
targetMatches(pendingForRequest.target, record.target)
|
|
3465
|
+
? pendingForRequest.displayTitle : undefined;
|
|
3466
|
+
if (pendingForRequest?.target && targetMatches(pendingForRequest.target, record.target)) {
|
|
3467
|
+
if (!request.workspaceBindingKey)
|
|
3468
|
+
deps.store.removePendingProvision(pendingForRequest.id);
|
|
3469
|
+
await deps.store.persist();
|
|
3470
|
+
assertLeaderEpoch("after-recovered-current-binding");
|
|
3471
|
+
}
|
|
3472
|
+
return {
|
|
3473
|
+
target: record.target, reused: true, record,
|
|
3474
|
+
...(recoveredTitle ? { displayTitle: recoveredTitle } : {}),
|
|
3475
|
+
};
|
|
3476
|
+
}
|
|
3477
|
+
if (pendingForRequest?.target) {
|
|
3478
|
+
if (!pendingForRequest.slot) {
|
|
3479
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
3480
|
+
}
|
|
3481
|
+
const record = deps.store.upsert({
|
|
3482
|
+
profileKey: request.profileKey,
|
|
3483
|
+
owner: request.owner,
|
|
3484
|
+
target: pendingForRequest.target,
|
|
3485
|
+
status: "active",
|
|
3486
|
+
createdAtMs: pendingForRequest.startedAtMs,
|
|
3487
|
+
updatedAtMs: nowMs,
|
|
3488
|
+
threadName: pendingForRequest.threadName,
|
|
3489
|
+
instanceId: request.instanceId,
|
|
3490
|
+
slot: pendingForRequest.slot,
|
|
3491
|
+
});
|
|
3492
|
+
if (!request.workspaceBindingKey)
|
|
3493
|
+
deps.store.removePendingProvision(pendingForRequest.id);
|
|
3494
|
+
await deps.store.persist();
|
|
3495
|
+
assertLeaderEpoch("after-recovered-binding");
|
|
3496
|
+
return {
|
|
3497
|
+
target: record.target,
|
|
3498
|
+
reused: true,
|
|
3499
|
+
record,
|
|
3500
|
+
...(pendingForRequest.displayTitle
|
|
3501
|
+
? { displayTitle: pendingForRequest.displayTitle }
|
|
3502
|
+
: {}),
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
if (pendingForRequest) {
|
|
3506
|
+
throw new Error(`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`);
|
|
3507
|
+
}
|
|
3508
|
+
const activeForInstance = deps.store.getActiveByInstanceId(request.instanceId);
|
|
3509
|
+
if (activeForInstance) {
|
|
3510
|
+
if (!activeForInstance.slot) {
|
|
3511
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
3512
|
+
}
|
|
3513
|
+
return {
|
|
3514
|
+
target: activeForInstance.target,
|
|
3515
|
+
reused: true,
|
|
3516
|
+
record: activeForInstance,
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
// No profileKey match — try to claim an existing inactive thread before creating another Telegram tab.
|
|
3520
|
+
if (deps.claimPendingTargets !== false) {
|
|
3521
|
+
const claimed = deps.store.claimReusableTarget(request.instanceId, identity?.threadName);
|
|
3522
|
+
if (claimed) {
|
|
3523
|
+
return { target: claimed.target, reused: true, record: claimed };
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
const occupied = listOccupiedTelegramThreadIdentities({
|
|
3527
|
+
records: deps.store.list(),
|
|
3528
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
3529
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
3530
|
+
exceptWorkspaceBindingKey: request.workspaceBindingKey,
|
|
3531
|
+
});
|
|
3532
|
+
const requestedThreadName = request.threadName &&
|
|
3533
|
+
isTelegramTopicThreadNameValidForSlot(request.threadName, undefined) &&
|
|
3534
|
+
!occupied.includes(getTelegramTopicIdentityName(request.threadName))
|
|
3535
|
+
? normalizeTelegramTopicTargetThreadName(request.threadName)
|
|
3536
|
+
: undefined;
|
|
3537
|
+
const candidateThreadName = requestedThreadName ?? identity?.threadName;
|
|
3538
|
+
const preferredNameSlot = getTelegramThreadNameLeadingSlot(candidateThreadName) ??
|
|
3539
|
+
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
3540
|
+
request.preferredSlot;
|
|
3541
|
+
const slot = existing?.slot ??
|
|
3542
|
+
deps.store.allocateSlot(request.profileKey, isManualFollowerRequest
|
|
3543
|
+
? request.preferredSlot
|
|
3544
|
+
: (request.preferredSlot ??
|
|
3545
|
+
(candidateThreadName ? undefined : identity?.slot) ??
|
|
3546
|
+
preferredNameSlot), request.workspaceBindingKey);
|
|
3547
|
+
if (!slot) {
|
|
3548
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
3549
|
+
}
|
|
3550
|
+
const uniqueCandidate = candidateThreadName &&
|
|
3551
|
+
isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&
|
|
3552
|
+
!occupied.includes(getTelegramTopicIdentityName(candidateThreadName))
|
|
3553
|
+
? candidateThreadName
|
|
3554
|
+
: undefined;
|
|
3555
|
+
const requestThreadName = uniqueCandidate ??
|
|
3556
|
+
chooseTelegramThreadName({ slot, entropy: nowMs, getRandom, occupied });
|
|
3557
|
+
let displayTitle;
|
|
3558
|
+
if (deps.resolveInitialWorkspaceDisplayTitle &&
|
|
3559
|
+
request.workspaceBindingKey &&
|
|
3560
|
+
request.workspaceCwd) {
|
|
3561
|
+
const projectedTitle = deps.resolveInitialWorkspaceDisplayTitle({
|
|
3562
|
+
bindingKey: request.workspaceBindingKey,
|
|
3563
|
+
cwd: request.workspaceCwd,
|
|
3564
|
+
slot,
|
|
3565
|
+
threadName: requestThreadName,
|
|
3566
|
+
});
|
|
3567
|
+
if (!projectedTitle?.trim()) {
|
|
3568
|
+
throw new Error("Telegram Thread display identity is missing or ambiguous.");
|
|
3569
|
+
}
|
|
3570
|
+
displayTitle = getTelegramTopicName({ ...request, threadName: projectedTitle }, "{threadName}", slot);
|
|
3571
|
+
}
|
|
3572
|
+
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
3573
|
+
const pendingOwner = request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
3574
|
+
assertLeaderEpoch("before-pending-intent");
|
|
3575
|
+
const pendingBase = {
|
|
3576
|
+
id: pendingId,
|
|
3577
|
+
owner: pendingOwner,
|
|
3578
|
+
instanceId: request.instanceId,
|
|
3579
|
+
profileKey: request.profileKey,
|
|
3580
|
+
threadName: requestThreadName,
|
|
3581
|
+
...(displayTitle ? { displayTitle } : {}),
|
|
3582
|
+
slot,
|
|
3583
|
+
startedAtMs: nowMs,
|
|
3584
|
+
...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
|
|
3585
|
+
};
|
|
3586
|
+
deps.store.upsertPendingProvision(pendingBase);
|
|
3587
|
+
await deps.store.persist();
|
|
3588
|
+
assertLeaderEpoch("after-pending-intent");
|
|
3589
|
+
let threadId;
|
|
3590
|
+
try {
|
|
3591
|
+
assertLeaderEpoch("before-createForumTopic");
|
|
3592
|
+
const topic = await deps.callApi("createForumTopic", {
|
|
3593
|
+
chat_id: deps.topicChatId,
|
|
3594
|
+
name: displayTitle ?? getTelegramTopicName({
|
|
3595
|
+
...request,
|
|
3596
|
+
...(requestThreadName ? { threadName: requestThreadName } : {}),
|
|
3597
|
+
}, deps.topicNameTemplate ??
|
|
3598
|
+
(requestThreadName ? "{threadName}" : "{slot}"), slot),
|
|
3599
|
+
}, { maxAttempts: 1 });
|
|
3600
|
+
threadId = topic.message_thread_id;
|
|
3601
|
+
if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
|
|
3602
|
+
throw new TelegramApiCommitUnknownError("createForumTopic", new Error("Telegram createForumTopic returned no message_thread_id."));
|
|
3603
|
+
}
|
|
3604
|
+
assertLeaderEpoch("after-createForumTopic");
|
|
3605
|
+
const target = { chatId: deps.topicChatId, threadId };
|
|
3606
|
+
deps.store.upsertPendingProvision({ ...pendingBase, target });
|
|
3607
|
+
await deps.store.persist();
|
|
3608
|
+
assertLeaderEpoch("after-pending-target");
|
|
3609
|
+
deps.store.upsert({
|
|
3610
|
+
profileKey: request.profileKey,
|
|
3611
|
+
owner: request.owner,
|
|
3612
|
+
target,
|
|
3613
|
+
status: "starting",
|
|
3614
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
3615
|
+
updatedAtMs: nowMs,
|
|
3616
|
+
threadName: requestThreadName,
|
|
3617
|
+
instanceId: request.instanceId,
|
|
3618
|
+
slot,
|
|
3619
|
+
});
|
|
3620
|
+
await deps.store.persist();
|
|
3621
|
+
assertLeaderEpoch("after-starting-binding");
|
|
3622
|
+
const record = deps.store.upsert({
|
|
3623
|
+
profileKey: request.profileKey,
|
|
3624
|
+
owner: request.owner,
|
|
3625
|
+
target,
|
|
3626
|
+
status: "active",
|
|
3627
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
3628
|
+
updatedAtMs: nowMs,
|
|
3629
|
+
threadName: requestThreadName,
|
|
3630
|
+
instanceId: request.instanceId,
|
|
3631
|
+
slot,
|
|
3632
|
+
});
|
|
3633
|
+
// Workspace commit consumes the exact title evidence in the same publication.
|
|
3634
|
+
if (!request.workspaceBindingKey)
|
|
3635
|
+
deps.store.removePendingProvision(pendingId);
|
|
3636
|
+
await deps.store.persist();
|
|
3637
|
+
assertLeaderEpoch("after-active-binding");
|
|
3638
|
+
return {
|
|
3639
|
+
target: record.target,
|
|
3640
|
+
reused: false,
|
|
3641
|
+
record,
|
|
3642
|
+
...(displayTitle ? { displayTitle } : {}),
|
|
3643
|
+
};
|
|
3644
|
+
}
|
|
3645
|
+
catch (error) {
|
|
3646
|
+
if (threadId !== undefined &&
|
|
3647
|
+
deps.getCurrentLeaderEpoch &&
|
|
3648
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch) {
|
|
3649
|
+
await deps.store.recordPendingProvisionTargetRecovery(pendingBase, {
|
|
3650
|
+
chatId: deps.topicChatId,
|
|
3651
|
+
threadId,
|
|
3652
|
+
});
|
|
3653
|
+
throw error;
|
|
3654
|
+
}
|
|
3655
|
+
assertLeaderEpoch("failure-cleanup");
|
|
3656
|
+
if (threadId === undefined) {
|
|
3657
|
+
if (isTelegramApiCommitUnknownError(error)) {
|
|
3658
|
+
deps.store.upsertPendingProvision({
|
|
3659
|
+
...pendingBase,
|
|
3660
|
+
status: "ambiguous",
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
else {
|
|
3664
|
+
deps.store.removePendingProvision(pendingId);
|
|
3665
|
+
}
|
|
3666
|
+
await deps.store.persist();
|
|
3667
|
+
}
|
|
3668
|
+
else {
|
|
3669
|
+
try {
|
|
3670
|
+
await deps.store.persist();
|
|
3671
|
+
}
|
|
3672
|
+
catch {
|
|
3673
|
+
// Keep the original post-create failure visible to the caller.
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
throw error;
|
|
3677
|
+
}
|
|
3678
|
+
};
|
|
3679
|
+
}
|