@llblab/pi-kit 0.6.0 → 0.7.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/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
- package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
- package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
- package/node_modules/@llblab/pi-state-flow/README.md +203 -107
- package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
- package/node_modules/@llblab/pi-state-flow/package.json +8 -2
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +13 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -3
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/README.md +12 -8
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +12 -5
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
- package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +28 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
- package/node_modules/@llblab/pi-telegram/lib/config.ts +177 -24
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +51 -10
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +52 -18
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
- package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +279 -4
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
- package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
- package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
- package/node_modules/@llblab/skills/package.json +2 -3
- package/package.json +6 -5
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import {
|
|
9
9
|
chmodSync,
|
|
10
10
|
existsSync,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
unlink,
|
|
21
21
|
writeFile,
|
|
22
22
|
} from "node:fs/promises";
|
|
23
|
-
import { dirname } from "node:path";
|
|
23
|
+
import { dirname, posix, resolve } from "node:path";
|
|
24
24
|
import { isDeepStrictEqual } from "node:util";
|
|
25
25
|
|
|
26
26
|
import {
|
|
@@ -31,6 +31,11 @@ import {
|
|
|
31
31
|
import type { TelegramTarget } from "./target.ts";
|
|
32
32
|
import { withTelegramFileTransaction } from "./locks.ts";
|
|
33
33
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
34
|
+
import {
|
|
35
|
+
planTelegramWorkspaceSlotAllocation,
|
|
36
|
+
TELEGRAM_WORKSPACE_SLOTS,
|
|
37
|
+
type TelegramWorkspaceSlotOccupancy,
|
|
38
|
+
} from "./workspace-slots.ts";
|
|
34
39
|
import {
|
|
35
40
|
resolveAgentDir,
|
|
36
41
|
resolveTelegramProfileTempFilePath,
|
|
@@ -44,6 +49,101 @@ export interface TelegramThreadNameInput {
|
|
|
44
49
|
slot?: string;
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
export interface TelegramWorkspaceBindingIdentity {
|
|
53
|
+
cwd: string;
|
|
54
|
+
workspaceKey: string;
|
|
55
|
+
/** Immutable legacy binding-key component, not the displayed global letter. */
|
|
56
|
+
instanceSlot: string;
|
|
57
|
+
bindingKey: string;
|
|
58
|
+
/** Profile-wide letter reserved by the transient claim. */
|
|
59
|
+
slot?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
|
|
63
|
+
|
|
64
|
+
export function normalizeTelegramWorkspacePath(
|
|
65
|
+
cwd: string,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const trimmed = cwd.trim();
|
|
68
|
+
if (!trimmed) return undefined;
|
|
69
|
+
const normalized = (trimmed.startsWith("/")
|
|
70
|
+
? posix.normalize(trimmed)
|
|
71
|
+
: resolve(trimmed).replaceAll("\\", "/"));
|
|
72
|
+
const withoutTrailingSeparators =
|
|
73
|
+
normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
|
|
74
|
+
return process.platform === "win32"
|
|
75
|
+
? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive: string) =>
|
|
76
|
+
`${drive.toLowerCase()}:`,
|
|
77
|
+
)
|
|
78
|
+
: withoutTrailingSeparators;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createTelegramWorkspaceDirectoryKey(
|
|
82
|
+
cwd: string,
|
|
83
|
+
): string | undefined {
|
|
84
|
+
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
85
|
+
if (!normalized) return undefined;
|
|
86
|
+
const readable =
|
|
87
|
+
normalized
|
|
88
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
89
|
+
.replace(/^-+|-+$/gu, "") || "root";
|
|
90
|
+
const candidate = `--${readable}--`;
|
|
91
|
+
if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH) return candidate;
|
|
92
|
+
const digest = createHash("sha256")
|
|
93
|
+
.update(normalized)
|
|
94
|
+
.digest("hex")
|
|
95
|
+
.slice(0, 12);
|
|
96
|
+
const prefixLength =
|
|
97
|
+
TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
|
|
98
|
+
return `--${readable.slice(0, prefixLength)}-${digest}--`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createTelegramWorkspaceInstanceSlot(
|
|
102
|
+
ordinal: number,
|
|
103
|
+
): string | undefined {
|
|
104
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 0) return undefined;
|
|
105
|
+
let value = ordinal + 1;
|
|
106
|
+
let slot = "";
|
|
107
|
+
while (value > 0) {
|
|
108
|
+
value -= 1;
|
|
109
|
+
slot = String.fromCharCode(97 + (value % 26)) + slot;
|
|
110
|
+
value = Math.floor(value / 26);
|
|
111
|
+
}
|
|
112
|
+
return slot;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createTelegramWorkspaceBindingIdentityWithKey(
|
|
116
|
+
cwd: string,
|
|
117
|
+
workspaceKey: string,
|
|
118
|
+
ordinal: number,
|
|
119
|
+
): TelegramWorkspaceBindingIdentity | undefined {
|
|
120
|
+
const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
|
|
121
|
+
if (!instanceSlot) return undefined;
|
|
122
|
+
return {
|
|
123
|
+
cwd,
|
|
124
|
+
workspaceKey,
|
|
125
|
+
instanceSlot,
|
|
126
|
+
bindingKey:
|
|
127
|
+
instanceSlot === "a" ? workspaceKey : `${workspaceKey}${instanceSlot}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createTelegramWorkspaceBindingIdentity(
|
|
132
|
+
cwd: string,
|
|
133
|
+
ordinal = 0,
|
|
134
|
+
): TelegramWorkspaceBindingIdentity | undefined {
|
|
135
|
+
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
136
|
+
const workspaceKey = normalized
|
|
137
|
+
? createTelegramWorkspaceDirectoryKey(normalized)
|
|
138
|
+
: undefined;
|
|
139
|
+
if (!normalized || !workspaceKey) return undefined;
|
|
140
|
+
return createTelegramWorkspaceBindingIdentityWithKey(
|
|
141
|
+
normalized,
|
|
142
|
+
workspaceKey,
|
|
143
|
+
ordinal,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
47
147
|
export type TelegramTopicTargetStatus =
|
|
48
148
|
| "active"
|
|
49
149
|
| "offline"
|
|
@@ -86,6 +186,7 @@ export interface TelegramThreadPendingProvision {
|
|
|
86
186
|
profileKey?: string;
|
|
87
187
|
status?: "in-flight" | "ambiguous";
|
|
88
188
|
threadName?: string;
|
|
189
|
+
displayTitle?: string;
|
|
89
190
|
slot?: string;
|
|
90
191
|
target?: TelegramTarget & { threadId: number };
|
|
91
192
|
startedAtMs: number;
|
|
@@ -125,6 +226,8 @@ export interface TelegramTopicTargetRecord {
|
|
|
125
226
|
createdAtMs: number;
|
|
126
227
|
updatedAtMs: number;
|
|
127
228
|
threadName?: string;
|
|
229
|
+
/** Explicit per-Workspace display override set by the operator. */
|
|
230
|
+
manualThreadName?: string;
|
|
128
231
|
instanceId?: string;
|
|
129
232
|
slot?: string;
|
|
130
233
|
lastError?: string;
|
|
@@ -143,6 +246,61 @@ export interface TelegramThreadIdentityRecord {
|
|
|
143
246
|
updatedAtMs: number;
|
|
144
247
|
}
|
|
145
248
|
|
|
249
|
+
export interface TelegramWorkspaceThreadBinding {
|
|
250
|
+
cwd: string;
|
|
251
|
+
workspaceKey: string;
|
|
252
|
+
instanceSlot: string;
|
|
253
|
+
bindingKey: string;
|
|
254
|
+
target: TelegramTarget & { threadId: number };
|
|
255
|
+
/** Stable generated identity retained for compatibility and recovery. */
|
|
256
|
+
threadName?: string;
|
|
257
|
+
/** Explicit display override; absence selects the profile's automatic mode. */
|
|
258
|
+
manualThreadName?: string;
|
|
259
|
+
slot?: string;
|
|
260
|
+
/** Last title acknowledged by Telegram; never replaces the stable name. */
|
|
261
|
+
displayTitle?: string;
|
|
262
|
+
/** Historical follower-journal routing keys that may retain accepted work. */
|
|
263
|
+
journalBindingKeys?: string[];
|
|
264
|
+
/** True only when the historical journal-key set is proven complete. */
|
|
265
|
+
journalBindingsComplete?: true;
|
|
266
|
+
/** Sticky once this directory has multiple retained bindings. */
|
|
267
|
+
showSlotSuffix?: boolean;
|
|
268
|
+
/** First continuously proven no-owner transition; absent means active or unproven. */
|
|
269
|
+
inactiveSinceMs?: number;
|
|
270
|
+
updatedAtMs: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type TelegramWorkspaceDisplayBinding = Pick<
|
|
274
|
+
TelegramWorkspaceThreadBinding,
|
|
275
|
+
"bindingKey" | "cwd" | "slot" | "threadName" | "manualThreadName" |
|
|
276
|
+
"showSlotSuffix"
|
|
277
|
+
>;
|
|
278
|
+
|
|
279
|
+
export interface TelegramWorkspaceRetirementIntent {
|
|
280
|
+
id: string;
|
|
281
|
+
reason: "pressure";
|
|
282
|
+
profileKey: string;
|
|
283
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
284
|
+
leaderEpoch: number | string;
|
|
285
|
+
requestedAtMs: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type TelegramWorkspaceProtectionState =
|
|
289
|
+
| "clear"
|
|
290
|
+
| "protected"
|
|
291
|
+
| "unknown";
|
|
292
|
+
|
|
293
|
+
export interface TelegramWorkspaceExternalProtectionEvidence {
|
|
294
|
+
liveOwner: TelegramWorkspaceProtectionState;
|
|
295
|
+
acceptedWork: TelegramWorkspaceProtectionState;
|
|
296
|
+
deliveryAuthority: TelegramWorkspaceProtectionState;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface TelegramWorkspaceSlotOccupancySnapshot {
|
|
300
|
+
bindings: TelegramWorkspaceSlotOccupancy[];
|
|
301
|
+
reservedSlots: string[];
|
|
302
|
+
}
|
|
303
|
+
|
|
146
304
|
export type TelegramBotThreadMode = "unknown" | "enabled" | "disabled";
|
|
147
305
|
|
|
148
306
|
export interface TelegramBotStateSnapshot {
|
|
@@ -162,6 +320,8 @@ export interface TelegramTopicTargetFile {
|
|
|
162
320
|
diagnostics?: Record<string, unknown>;
|
|
163
321
|
threads: TelegramTopicTargetRecord[];
|
|
164
322
|
identities?: TelegramThreadIdentityRecord[];
|
|
323
|
+
workspaceBindings?: TelegramWorkspaceThreadBinding[];
|
|
324
|
+
workspaceRetirements?: TelegramWorkspaceRetirementIntent[];
|
|
165
325
|
reservations?: TelegramThreadReservation[];
|
|
166
326
|
pendingProvisions?: TelegramThreadPendingProvision[];
|
|
167
327
|
pendingCleanups?: TelegramThreadCleanupIntent[];
|
|
@@ -269,6 +429,70 @@ export interface TelegramTopicTargetStore {
|
|
|
269
429
|
profileKey: string,
|
|
270
430
|
) => TelegramThreadIdentityRecord | undefined;
|
|
271
431
|
forgetIdentityByProfileKey: (profileKey: string) => boolean;
|
|
432
|
+
listWorkspaceBindings: () => TelegramWorkspaceThreadBinding[];
|
|
433
|
+
listWorkspaceRetirementIntents: () => TelegramWorkspaceRetirementIntent[];
|
|
434
|
+
commitWorkspaceJournalEvidence: (
|
|
435
|
+
expected: TelegramWorkspaceThreadBinding,
|
|
436
|
+
journalBindingKeys: readonly string[],
|
|
437
|
+
complete: boolean,
|
|
438
|
+
) => TelegramWorkspaceThreadBinding | undefined;
|
|
439
|
+
upsertWorkspaceRetirementIntent: (
|
|
440
|
+
intent: TelegramWorkspaceRetirementIntent,
|
|
441
|
+
) => boolean;
|
|
442
|
+
removeWorkspaceRetirementIntent: (
|
|
443
|
+
expected: TelegramWorkspaceRetirementIntent,
|
|
444
|
+
) => boolean;
|
|
445
|
+
replaceWorkspaceRetirementIntent: (
|
|
446
|
+
expected: TelegramWorkspaceRetirementIntent,
|
|
447
|
+
replacement: TelegramWorkspaceRetirementIntent,
|
|
448
|
+
isCurrent: () => boolean,
|
|
449
|
+
) => Promise<boolean>;
|
|
450
|
+
commitWorkspaceRetirement: (
|
|
451
|
+
expected: TelegramWorkspaceRetirementIntent,
|
|
452
|
+
isCurrent: () => boolean,
|
|
453
|
+
) => Promise<boolean>;
|
|
454
|
+
commitInactiveWorkspaceCleanup: (
|
|
455
|
+
expected: TelegramWorkspaceThreadBinding | {
|
|
456
|
+
cwd: string; workspaceKey: string; instanceSlot: string; slot: string; bindingKey: string;
|
|
457
|
+
target: { chatId: number; threadId: number }; inactiveSinceMs: number; bindingUpdatedAtMs: number;
|
|
458
|
+
},
|
|
459
|
+
isCurrent: () => boolean,
|
|
460
|
+
) => Promise<boolean>;
|
|
461
|
+
/** Caller must separately prove no external live owner, accepted work, or delivery authority. */
|
|
462
|
+
captureWorkspaceSlotOccupancy: (
|
|
463
|
+
getExternalProtection: (
|
|
464
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
465
|
+
) => TelegramWorkspaceExternalProtectionEvidence,
|
|
466
|
+
options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
|
|
467
|
+
) => TelegramWorkspaceSlotOccupancySnapshot;
|
|
468
|
+
hasWorkspaceBinding: (cwd: string) => boolean;
|
|
469
|
+
setWorkspaceDisplayTitle: (
|
|
470
|
+
expected: TelegramWorkspaceThreadBinding,
|
|
471
|
+
title: string,
|
|
472
|
+
) => boolean;
|
|
473
|
+
markWorkspaceBindingInactiveByTarget: (
|
|
474
|
+
target: TelegramTarget,
|
|
475
|
+
inactiveSinceMs?: number,
|
|
476
|
+
) => boolean;
|
|
477
|
+
markWorkspaceBindingActiveByTarget: (target: TelegramTarget) => boolean;
|
|
478
|
+
getWorkspaceBinding: (
|
|
479
|
+
cwd: string,
|
|
480
|
+
instanceSlot?: string,
|
|
481
|
+
) => TelegramWorkspaceThreadBinding | undefined;
|
|
482
|
+
claimWorkspaceIdentity: (
|
|
483
|
+
cwd: string,
|
|
484
|
+
instanceId: string,
|
|
485
|
+
previousInstanceId?: string,
|
|
486
|
+
options?: {
|
|
487
|
+
existingBindingOnly?: boolean;
|
|
488
|
+
onCapacityUnavailable?: () => void;
|
|
489
|
+
},
|
|
490
|
+
) => TelegramWorkspaceBindingIdentity | undefined;
|
|
491
|
+
releaseWorkspaceClaim: (instanceId: string) => boolean;
|
|
492
|
+
upsertWorkspaceBinding: (
|
|
493
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
494
|
+
claimInstanceId?: string,
|
|
495
|
+
) => TelegramWorkspaceThreadBinding | undefined;
|
|
272
496
|
upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord;
|
|
273
497
|
markOfflineByInstanceId: (instanceId: string) => number;
|
|
274
498
|
markStaleByTarget: (
|
|
@@ -280,10 +504,17 @@ export interface TelegramTopicTargetStore {
|
|
|
280
504
|
renameByTarget: (
|
|
281
505
|
target: TelegramTarget,
|
|
282
506
|
threadName: string,
|
|
507
|
+
options?: { updateDisplayTitle: boolean },
|
|
508
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
509
|
+
clearManualNameByTarget: (
|
|
510
|
+
target: TelegramTarget,
|
|
511
|
+
automaticTitle: string,
|
|
283
512
|
) => TelegramTopicTargetRecord | undefined;
|
|
284
513
|
allocateSlot: (
|
|
285
514
|
profileKey: string,
|
|
286
515
|
preferredSlot?: string,
|
|
516
|
+
workspaceBindingKey?: string,
|
|
517
|
+
options?: { excludeCurrentRecord?: boolean },
|
|
287
518
|
) => string | undefined;
|
|
288
519
|
/** Claim the first reusable inactive thread for an instance, linking it to instanceId. */
|
|
289
520
|
claimReusableTarget: (
|
|
@@ -371,9 +602,11 @@ export function createTelegramCleanupTargetProtection(
|
|
|
371
602
|
|
|
372
603
|
export interface TelegramTopicTargetStoreOptions {
|
|
373
604
|
path: string | (() => string);
|
|
605
|
+
telegramProfile?: string | (() => string | undefined);
|
|
374
606
|
getNowMs?: () => number;
|
|
375
607
|
canPersist?: () => boolean;
|
|
376
608
|
commitPersist?: (commit: () => void) => boolean;
|
|
609
|
+
getExternalReservedSlots?: () => readonly string[];
|
|
377
610
|
}
|
|
378
611
|
|
|
379
612
|
export interface TelegramTopicTargetProvisionerDeps {
|
|
@@ -389,10 +622,13 @@ export interface TelegramTopicTargetProvisionerDeps {
|
|
|
389
622
|
| "markStaleByTarget"
|
|
390
623
|
| "allocateSlot"
|
|
391
624
|
| "claimReusableTarget"
|
|
625
|
+
| "listWorkspaceBindings"
|
|
392
626
|
| "listPendingProvisions"
|
|
393
627
|
| "upsertPendingProvision"
|
|
394
628
|
| "recordPendingProvisionTargetRecovery"
|
|
395
629
|
| "removePendingProvision"
|
|
630
|
+
| "listSyncObservations"
|
|
631
|
+
| "listPendingCleanups"
|
|
396
632
|
| "persist"
|
|
397
633
|
>;
|
|
398
634
|
callApi: <TResponse>(
|
|
@@ -401,6 +637,9 @@ export interface TelegramTopicTargetProvisionerDeps {
|
|
|
401
637
|
options?: TelegramApiCallOptions,
|
|
402
638
|
) => Promise<TResponse>;
|
|
403
639
|
topicNameTemplate?: string;
|
|
640
|
+
resolveInitialWorkspaceDisplayTitle?: (
|
|
641
|
+
binding: TelegramWorkspaceDisplayBinding,
|
|
642
|
+
) => string | undefined;
|
|
404
643
|
getNowMs?: () => number;
|
|
405
644
|
getRandom?: () => number;
|
|
406
645
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
@@ -408,11 +647,19 @@ export interface TelegramTopicTargetProvisionerDeps {
|
|
|
408
647
|
}
|
|
409
648
|
|
|
410
649
|
export interface TelegramTopicTargetRenamerDeps {
|
|
411
|
-
store: Pick<
|
|
650
|
+
store: Pick<
|
|
651
|
+
TelegramTopicTargetStore,
|
|
652
|
+
| "renameByTarget"
|
|
653
|
+
| "list"
|
|
654
|
+
| "listWorkspaceBindings"
|
|
655
|
+
| "listPendingProvisions"
|
|
656
|
+
>;
|
|
412
657
|
callApi: <TResponse>(
|
|
413
658
|
method: string,
|
|
414
659
|
body: Record<string, unknown>,
|
|
415
660
|
) => Promise<TResponse>;
|
|
661
|
+
assertAuthority?: () => void;
|
|
662
|
+
shouldRenameDisplayedTitle?: () => boolean;
|
|
416
663
|
topicNameTemplate?: string;
|
|
417
664
|
}
|
|
418
665
|
|
|
@@ -423,6 +670,57 @@ export interface TelegramTopicTargetProvisionRequest {
|
|
|
423
670
|
profileKey: string;
|
|
424
671
|
threadName?: string;
|
|
425
672
|
preferredSlot?: string;
|
|
673
|
+
workspaceBindingKey?: string;
|
|
674
|
+
workspaceCwd?: string;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Pending creation evidence cannot bypass an exact target's unresolved cleanup or closure. */
|
|
678
|
+
export function assertTelegramPendingTopicRecoveryAllowed(
|
|
679
|
+
store: Pick<TelegramTopicTargetStore,
|
|
680
|
+
"listPendingProvisions" | "listPendingCleanups" | "listSyncObservations">,
|
|
681
|
+
target: TelegramTarget,
|
|
682
|
+
): void {
|
|
683
|
+
if (!store.listPendingProvisions().some((entry) =>
|
|
684
|
+
entry.target && targetMatches(entry.target, target),
|
|
685
|
+
)) return;
|
|
686
|
+
if (store.listPendingCleanups().some((entry) => targetMatches(entry.target, target)) ||
|
|
687
|
+
store.listSyncObservations().some((entry) =>
|
|
688
|
+
targetMatches(entry.target, target) && entry.syncStatus === "closed",
|
|
689
|
+
)) {
|
|
690
|
+
throw new Error("Telegram pending topic requires reconciliation before recovery.");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Settle creation-title evidence with the exact Workspace claim; caller fences and persists. */
|
|
695
|
+
export function commitTelegramWorkspaceProvisionBinding(input: {
|
|
696
|
+
store: Pick<TelegramTopicTargetStore,
|
|
697
|
+
"upsertWorkspaceBinding" | "setWorkspaceDisplayTitle" |
|
|
698
|
+
"listPendingProvisions" | "removePendingProvision" |
|
|
699
|
+
"listPendingCleanups" | "listSyncObservations">;
|
|
700
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
701
|
+
instanceId: string;
|
|
702
|
+
profileKey: string;
|
|
703
|
+
displayTitle?: string;
|
|
704
|
+
}): TelegramWorkspaceThreadBinding {
|
|
705
|
+
assertTelegramPendingTopicRecoveryAllowed(input.store, input.binding.target);
|
|
706
|
+
const pending = input.store.listPendingProvisions().filter((provision) =>
|
|
707
|
+
provision.target && targetMatches(provision.target, input.binding.target) &&
|
|
708
|
+
provision.slot === input.binding.slot &&
|
|
709
|
+
(provision.instanceId === input.instanceId || provision.profileKey === input.profileKey),
|
|
710
|
+
);
|
|
711
|
+
const titles = new Set(pending.map((provision) => provision.displayTitle)
|
|
712
|
+
.filter((title) => title !== undefined));
|
|
713
|
+
if (input.displayTitle !== undefined) titles.add(input.displayTitle);
|
|
714
|
+
if (titles.size > 1) throw new Error("Telegram Workspace creation title evidence conflicts.");
|
|
715
|
+
const displayTitle = titles.values().next().value;
|
|
716
|
+
const committed = input.store.upsertWorkspaceBinding(input.binding, input.instanceId);
|
|
717
|
+
if (!committed) throw new Error("Telegram Workspace binding claim changed.");
|
|
718
|
+
if (displayTitle !== undefined &&
|
|
719
|
+
!input.store.setWorkspaceDisplayTitle(committed, displayTitle)) {
|
|
720
|
+
throw new Error("Telegram Workspace display title commit changed binding.");
|
|
721
|
+
}
|
|
722
|
+
for (const provision of pending) input.store.removePendingProvision(provision.id);
|
|
723
|
+
return displayTitle === undefined ? committed : { ...committed, displayTitle };
|
|
426
724
|
}
|
|
427
725
|
|
|
428
726
|
export interface TelegramTopicTargetRenameRequest {
|
|
@@ -435,6 +733,7 @@ export interface TelegramTopicTargetProvisionResult {
|
|
|
435
733
|
target: TelegramTarget & { threadId: number };
|
|
436
734
|
reused: boolean;
|
|
437
735
|
record: TelegramTopicTargetRecord;
|
|
736
|
+
displayTitle?: string;
|
|
438
737
|
}
|
|
439
738
|
|
|
440
739
|
interface TelegramTopicResult {
|
|
@@ -620,13 +919,22 @@ function parseThreadOwner(value: unknown): TelegramThreadOwner | undefined {
|
|
|
620
919
|
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
621
920
|
instanceId:
|
|
622
921
|
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
922
|
+
...(typeof record.telegramProfile === "string"
|
|
923
|
+
? { telegramProfile: record.telegramProfile }
|
|
924
|
+
: {}),
|
|
623
925
|
};
|
|
624
926
|
}
|
|
625
927
|
if (
|
|
626
928
|
record.kind === "manual-follower" &&
|
|
627
929
|
typeof record.instanceId === "string"
|
|
628
930
|
) {
|
|
629
|
-
return {
|
|
931
|
+
return {
|
|
932
|
+
kind: "manual-follower",
|
|
933
|
+
instanceId: record.instanceId,
|
|
934
|
+
...(typeof record.telegramProfile === "string"
|
|
935
|
+
? { telegramProfile: record.telegramProfile }
|
|
936
|
+
: {}),
|
|
937
|
+
};
|
|
630
938
|
}
|
|
631
939
|
if (
|
|
632
940
|
record.kind === "pending-topic" &&
|
|
@@ -729,6 +1037,11 @@ function normalizeRecord(
|
|
|
729
1037
|
createdAtMs: record.createdAtMs,
|
|
730
1038
|
updatedAtMs: record.updatedAtMs,
|
|
731
1039
|
threadName: getPersistedThreadName(record),
|
|
1040
|
+
...(typeof record.manualThreadName === "string" &&
|
|
1041
|
+
normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
1042
|
+
? { manualThreadName:
|
|
1043
|
+
normalizeTelegramTopicTargetThreadName(record.manualThreadName) }
|
|
1044
|
+
: {}),
|
|
732
1045
|
instanceId:
|
|
733
1046
|
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
734
1047
|
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
@@ -810,6 +1123,134 @@ function cloneIdentityRecord(
|
|
|
810
1123
|
return { ...identity };
|
|
811
1124
|
}
|
|
812
1125
|
|
|
1126
|
+
function getWorkspaceBindingMapKey(
|
|
1127
|
+
binding: Pick<TelegramWorkspaceThreadBinding, "cwd" | "instanceSlot">,
|
|
1128
|
+
): string {
|
|
1129
|
+
return `${binding.cwd}\u0000${binding.instanceSlot}`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function normalizeWorkspaceBindingRecord(
|
|
1133
|
+
value: unknown,
|
|
1134
|
+
): TelegramWorkspaceThreadBinding | undefined {
|
|
1135
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1136
|
+
return undefined;
|
|
1137
|
+
const record = value as Record<string, unknown>;
|
|
1138
|
+
if (
|
|
1139
|
+
typeof record.cwd !== "string" ||
|
|
1140
|
+
typeof record.workspaceKey !== "string" ||
|
|
1141
|
+
typeof record.instanceSlot !== "string" ||
|
|
1142
|
+
typeof record.bindingKey !== "string" ||
|
|
1143
|
+
typeof record.updatedAtMs !== "number"
|
|
1144
|
+
) {
|
|
1145
|
+
return undefined;
|
|
1146
|
+
}
|
|
1147
|
+
const cwd = normalizeTelegramWorkspacePath(record.cwd);
|
|
1148
|
+
if (
|
|
1149
|
+
!cwd ||
|
|
1150
|
+
cwd !== record.cwd ||
|
|
1151
|
+
!record.workspaceKey ||
|
|
1152
|
+
!/^[a-z]+$/u.test(record.instanceSlot) ||
|
|
1153
|
+
record.bindingKey !==
|
|
1154
|
+
(record.instanceSlot === "a"
|
|
1155
|
+
? record.workspaceKey
|
|
1156
|
+
: `${record.workspaceKey}${record.instanceSlot}`)
|
|
1157
|
+
) {
|
|
1158
|
+
return undefined;
|
|
1159
|
+
}
|
|
1160
|
+
const targetValue = record.target;
|
|
1161
|
+
if (
|
|
1162
|
+
!targetValue ||
|
|
1163
|
+
typeof targetValue !== "object" ||
|
|
1164
|
+
Array.isArray(targetValue)
|
|
1165
|
+
) {
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
const target = targetValue as Record<string, unknown>;
|
|
1169
|
+
if (
|
|
1170
|
+
typeof target.chatId !== "number" ||
|
|
1171
|
+
typeof target.threadId !== "number" ||
|
|
1172
|
+
!Number.isInteger(target.threadId)
|
|
1173
|
+
) {
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
const threadName = getPersistedThreadName(record);
|
|
1177
|
+
const manualThreadName =
|
|
1178
|
+
typeof record.manualThreadName === "string"
|
|
1179
|
+
? normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
1180
|
+
: undefined;
|
|
1181
|
+
const slot =
|
|
1182
|
+
typeof record.slot === "string" && /^[A-Z]$/u.test(record.slot)
|
|
1183
|
+
? record.slot
|
|
1184
|
+
: undefined;
|
|
1185
|
+
const journalBindingKeys = Array.isArray(record.journalBindingKeys) &&
|
|
1186
|
+
record.journalBindingKeys.every((key) =>
|
|
1187
|
+
typeof key === "string" && key.length > 0 && key.length <= 512,
|
|
1188
|
+
)
|
|
1189
|
+
? Array.from(new Set(record.journalBindingKeys as string[]))
|
|
1190
|
+
: undefined;
|
|
1191
|
+
return {
|
|
1192
|
+
cwd,
|
|
1193
|
+
workspaceKey: record.workspaceKey,
|
|
1194
|
+
instanceSlot: record.instanceSlot,
|
|
1195
|
+
bindingKey: record.bindingKey,
|
|
1196
|
+
...(record.showSlotSuffix === true ? { showSlotSuffix: true } : {}),
|
|
1197
|
+
...(typeof record.displayTitle === "string" && record.displayTitle.trim()
|
|
1198
|
+
? { displayTitle: record.displayTitle }
|
|
1199
|
+
: {}),
|
|
1200
|
+
...(typeof record.inactiveSinceMs === "number" &&
|
|
1201
|
+
Number.isFinite(record.inactiveSinceMs) && record.inactiveSinceMs >= 0
|
|
1202
|
+
? { inactiveSinceMs: record.inactiveSinceMs }
|
|
1203
|
+
: {}),
|
|
1204
|
+
...(journalBindingKeys ? { journalBindingKeys } : {}),
|
|
1205
|
+
...(record.journalBindingsComplete === true && journalBindingKeys
|
|
1206
|
+
? { journalBindingsComplete: true as const }
|
|
1207
|
+
: {}),
|
|
1208
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
1209
|
+
...(threadName ? { threadName } : {}),
|
|
1210
|
+
...(manualThreadName ? { manualThreadName } : {}),
|
|
1211
|
+
...(slot ? { slot } : {}),
|
|
1212
|
+
updatedAtMs: record.updatedAtMs,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function cloneWorkspaceBinding(
|
|
1217
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
1218
|
+
): TelegramWorkspaceThreadBinding {
|
|
1219
|
+
return { ...binding, target: { ...binding.target } };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function normalizeWorkspaceRetirementIntent(
|
|
1223
|
+
value: unknown,
|
|
1224
|
+
): TelegramWorkspaceRetirementIntent | undefined {
|
|
1225
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1226
|
+
const record = value as Record<string, unknown>;
|
|
1227
|
+
const binding = normalizeWorkspaceBindingRecord(record.binding);
|
|
1228
|
+
if (
|
|
1229
|
+
typeof record.id !== "string" || !record.id ||
|
|
1230
|
+
record.reason !== "pressure" ||
|
|
1231
|
+
typeof record.profileKey !== "string" || !record.profileKey ||
|
|
1232
|
+
!binding?.slot || binding.inactiveSinceMs === undefined ||
|
|
1233
|
+
!((typeof record.leaderEpoch === "number" && Number.isFinite(record.leaderEpoch)) ||
|
|
1234
|
+
(typeof record.leaderEpoch === "string" && record.leaderEpoch.length > 0)) ||
|
|
1235
|
+
typeof record.requestedAtMs !== "number" || !Number.isFinite(record.requestedAtMs) ||
|
|
1236
|
+
record.requestedAtMs < 0
|
|
1237
|
+
) return undefined;
|
|
1238
|
+
return {
|
|
1239
|
+
id: record.id,
|
|
1240
|
+
reason: record.reason,
|
|
1241
|
+
profileKey: record.profileKey,
|
|
1242
|
+
binding,
|
|
1243
|
+
leaderEpoch: record.leaderEpoch,
|
|
1244
|
+
requestedAtMs: record.requestedAtMs,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function cloneWorkspaceRetirementIntent(
|
|
1249
|
+
intent: TelegramWorkspaceRetirementIntent,
|
|
1250
|
+
): TelegramWorkspaceRetirementIntent {
|
|
1251
|
+
return { ...intent, binding: cloneWorkspaceBinding(intent.binding) };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
813
1254
|
function normalizeBotStateSnapshot(value: unknown): TelegramBotStateSnapshot {
|
|
814
1255
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
815
1256
|
return { threadMode: "unknown" };
|
|
@@ -925,6 +1366,9 @@ function normalizePendingProvision(
|
|
|
925
1366
|
...(typeof record.threadName === "string"
|
|
926
1367
|
? { threadName: record.threadName }
|
|
927
1368
|
: {}),
|
|
1369
|
+
...(typeof record.displayTitle === "string" && record.displayTitle.trim()
|
|
1370
|
+
? { displayTitle: record.displayTitle }
|
|
1371
|
+
: {}),
|
|
928
1372
|
...(typeof record.slot === "string" ? { slot: record.slot } : {}),
|
|
929
1373
|
...(target ? { target } : {}),
|
|
930
1374
|
startedAtMs: record.startedAtMs,
|
|
@@ -1070,6 +1514,18 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1070
1514
|
return normalized ? [normalized] : [];
|
|
1071
1515
|
})
|
|
1072
1516
|
: [],
|
|
1517
|
+
workspaceBindings: Array.isArray(file.workspaceBindings)
|
|
1518
|
+
? file.workspaceBindings.flatMap((binding) => {
|
|
1519
|
+
const normalized = normalizeWorkspaceBindingRecord(binding);
|
|
1520
|
+
return normalized ? [normalized] : [];
|
|
1521
|
+
})
|
|
1522
|
+
: [],
|
|
1523
|
+
workspaceRetirements: Array.isArray(file.workspaceRetirements)
|
|
1524
|
+
? file.workspaceRetirements.flatMap((intent) => {
|
|
1525
|
+
const normalized = normalizeWorkspaceRetirementIntent(intent);
|
|
1526
|
+
return normalized ? [normalized] : [];
|
|
1527
|
+
})
|
|
1528
|
+
: [],
|
|
1073
1529
|
reservations: Array.isArray(file.reservations)
|
|
1074
1530
|
? file.reservations.flatMap((reservation) => {
|
|
1075
1531
|
const normalized = normalizeReservation(reservation);
|
|
@@ -1097,16 +1553,16 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1097
1553
|
};
|
|
1098
1554
|
}
|
|
1099
1555
|
|
|
1100
|
-
function
|
|
1556
|
+
function getTelegramStateSemanticSnapshot(
|
|
1101
1557
|
value: unknown,
|
|
1102
|
-
): string | undefined {
|
|
1558
|
+
): Record<string, unknown> | undefined {
|
|
1103
1559
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1104
1560
|
return undefined;
|
|
1105
1561
|
const { writtenAtMs: _writtenAtMs, ...semantic } = value as Record<
|
|
1106
1562
|
string,
|
|
1107
1563
|
unknown
|
|
1108
1564
|
>;
|
|
1109
|
-
return
|
|
1565
|
+
return semantic;
|
|
1110
1566
|
}
|
|
1111
1567
|
|
|
1112
1568
|
function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
|
|
@@ -1174,7 +1630,7 @@ function getInstanceProcessKey(
|
|
|
1174
1630
|
return pid && /^\d+$/.test(pid) ? pid : undefined;
|
|
1175
1631
|
}
|
|
1176
1632
|
|
|
1177
|
-
function
|
|
1633
|
+
export function isSameTelegramProcessInstance(
|
|
1178
1634
|
left: string | undefined,
|
|
1179
1635
|
right: string | undefined,
|
|
1180
1636
|
): boolean {
|
|
@@ -1197,9 +1653,39 @@ export function createTelegramTopicTargetStore(
|
|
|
1197
1653
|
options: TelegramTopicTargetStoreOptions,
|
|
1198
1654
|
): TelegramTopicTargetStore {
|
|
1199
1655
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
1656
|
+
const captureExternalReservedSlots = (): string[] | undefined => {
|
|
1657
|
+
try {
|
|
1658
|
+
const slots = options.getExternalReservedSlots?.() ?? [];
|
|
1659
|
+
if (!Array.isArray(slots) ||
|
|
1660
|
+
slots.some((slot) => typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) {
|
|
1661
|
+
return undefined;
|
|
1662
|
+
}
|
|
1663
|
+
return Array.from(new Set(slots));
|
|
1664
|
+
} catch {
|
|
1665
|
+
return undefined;
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1200
1668
|
let botState: TelegramBotStateSnapshot = { threadMode: "unknown" };
|
|
1201
1669
|
let records = new Map<string, TelegramTopicTargetRecord>();
|
|
1202
1670
|
let identities = new Map<string, TelegramThreadIdentityRecord>();
|
|
1671
|
+
let workspaceBindings = new Map<string, TelegramWorkspaceThreadBinding>();
|
|
1672
|
+
let workspaceRetirements: TelegramWorkspaceRetirementIntent[] = [];
|
|
1673
|
+
let workspaceRetirementCommitInFlight = false;
|
|
1674
|
+
const hasWorkspaceRetirementConflict = (input: {
|
|
1675
|
+
bindingKey?: string;
|
|
1676
|
+
cwd?: string;
|
|
1677
|
+
target?: TelegramTarget;
|
|
1678
|
+
slot?: string;
|
|
1679
|
+
}): boolean => workspaceRetirements.some((intent) =>
|
|
1680
|
+
(input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
|
|
1681
|
+
(input.cwd !== undefined && intent.binding.cwd === input.cwd) ||
|
|
1682
|
+
(input.target !== undefined && targetMatches(intent.binding.target, input.target)) ||
|
|
1683
|
+
(input.slot !== undefined && intent.binding.slot === input.slot),
|
|
1684
|
+
);
|
|
1685
|
+
let workspaceClaims = new Map<
|
|
1686
|
+
string,
|
|
1687
|
+
{ identity: TelegramWorkspaceBindingIdentity; instanceId: string }
|
|
1688
|
+
>();
|
|
1203
1689
|
let reservations: TelegramThreadReservation[] = [];
|
|
1204
1690
|
let pendingProvisions: TelegramThreadPendingProvision[] = [];
|
|
1205
1691
|
let pendingCleanups: TelegramThreadCleanupIntent[] = [];
|
|
@@ -1220,6 +1706,19 @@ export function createTelegramTopicTargetStore(
|
|
|
1220
1706
|
diagnostics?: Record<string, unknown>;
|
|
1221
1707
|
} = {};
|
|
1222
1708
|
|
|
1709
|
+
const reconcileWorkspaceSuffixExposure = (): void => {
|
|
1710
|
+
const directoryCounts = new Map<string, number>();
|
|
1711
|
+
const exposed = new Set<string>();
|
|
1712
|
+
for (const binding of workspaceBindings.values()) {
|
|
1713
|
+
directoryCounts.set(binding.cwd, (directoryCounts.get(binding.cwd) ?? 0) + 1);
|
|
1714
|
+
if (binding.showSlotSuffix) exposed.add(binding.cwd);
|
|
1715
|
+
}
|
|
1716
|
+
for (const binding of workspaceBindings.values()) {
|
|
1717
|
+
if ((directoryCounts.get(binding.cwd) ?? 0) > 1 || exposed.has(binding.cwd)) {
|
|
1718
|
+
binding.showSlotSuffix = true;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1223
1722
|
const rememberSlot = (slot: string | undefined, nowMs = getNowMs()) => {
|
|
1224
1723
|
if (!slot || !/^[A-Z]$/.test(slot)) return;
|
|
1225
1724
|
botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
|
|
@@ -1234,9 +1733,84 @@ export function createTelegramTopicTargetStore(
|
|
|
1234
1733
|
updatedAtMs: record.updatedAtMs,
|
|
1235
1734
|
});
|
|
1236
1735
|
};
|
|
1736
|
+
const resolveWorkspaceKey = (cwd: string): string | undefined => {
|
|
1737
|
+
const known = [
|
|
1738
|
+
...Array.from(workspaceBindings.values()),
|
|
1739
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity),
|
|
1740
|
+
];
|
|
1741
|
+
const existing = known.find((binding) => binding.cwd === cwd);
|
|
1742
|
+
if (existing) return existing.workspaceKey;
|
|
1743
|
+
const readable = createTelegramWorkspaceDirectoryKey(cwd);
|
|
1744
|
+
if (!readable) return undefined;
|
|
1745
|
+
const collision = known.some(
|
|
1746
|
+
(binding) =>
|
|
1747
|
+
binding.workspaceKey === readable && binding.cwd !== cwd,
|
|
1748
|
+
);
|
|
1749
|
+
if (!collision) return readable;
|
|
1750
|
+
const digest = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
1751
|
+
const prefix = readable.endsWith("--") ? readable.slice(0, -2) : readable;
|
|
1752
|
+
return `${prefix.slice(0, TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 3)}-${digest}--`;
|
|
1753
|
+
};
|
|
1754
|
+
const isWorkspaceTargetLive = (
|
|
1755
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
1756
|
+
): TelegramTopicTargetRecord | undefined =>
|
|
1757
|
+
Array.from(records.values()).find(
|
|
1758
|
+
(record) =>
|
|
1759
|
+
isCurrentThreadRecord(record) &&
|
|
1760
|
+
targetMatches(record.target, binding.target),
|
|
1761
|
+
);
|
|
1762
|
+
const findLegacyWorkspaceMigrationRecord = (
|
|
1763
|
+
cwd: string,
|
|
1764
|
+
instanceId: string,
|
|
1765
|
+
previousInstanceId?: string,
|
|
1766
|
+
): TelegramTopicTargetRecord | undefined => {
|
|
1767
|
+
const processIds = new Set(
|
|
1768
|
+
[instanceId, previousInstanceId].filter(
|
|
1769
|
+
(value): value is string => !!value,
|
|
1770
|
+
),
|
|
1771
|
+
);
|
|
1772
|
+
return Array.from(records.values())
|
|
1773
|
+
.filter((record) => {
|
|
1774
|
+
const owner = getRecordOwner(record);
|
|
1775
|
+
const belongsToWorkspace =
|
|
1776
|
+
owner.kind === "leader"
|
|
1777
|
+
? owner.cwd
|
|
1778
|
+
? normalizeTelegramWorkspacePath(owner.cwd) === cwd
|
|
1779
|
+
: !!record.instanceId && Array.from(processIds).some((processId) =>
|
|
1780
|
+
isSameTelegramProcessInstance(record.instanceId, processId),
|
|
1781
|
+
)
|
|
1782
|
+
: owner.kind === "manual-follower" &&
|
|
1783
|
+
(processIds.has(owner.instanceId) ||
|
|
1784
|
+
(!!record.instanceId && processIds.has(record.instanceId)));
|
|
1785
|
+
if (!belongsToWorkspace) return false;
|
|
1786
|
+
const targetBinding = Array.from(workspaceBindings.values()).find(
|
|
1787
|
+
(binding) => targetMatches(binding.target, record.target),
|
|
1788
|
+
);
|
|
1789
|
+
return !targetBinding || targetBinding.cwd === cwd;
|
|
1790
|
+
})
|
|
1791
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
|
|
1792
|
+
};
|
|
1237
1793
|
|
|
1238
1794
|
const getPath = () =>
|
|
1239
1795
|
typeof options.path === "function" ? options.path() : options.path;
|
|
1796
|
+
const getTelegramProfile = () =>
|
|
1797
|
+
typeof options.telegramProfile === "function"
|
|
1798
|
+
? options.telegramProfile()
|
|
1799
|
+
: options.telegramProfile;
|
|
1800
|
+
const scopeOwnerToActiveProfile = (
|
|
1801
|
+
owner: TelegramThreadOwner,
|
|
1802
|
+
): TelegramThreadOwner => {
|
|
1803
|
+
const telegramProfile = getTelegramProfile();
|
|
1804
|
+
if (
|
|
1805
|
+
!telegramProfile ||
|
|
1806
|
+
owner.kind === "pending-topic" ||
|
|
1807
|
+
owner.kind === "legacy" ||
|
|
1808
|
+
owner.telegramProfile
|
|
1809
|
+
) {
|
|
1810
|
+
return owner;
|
|
1811
|
+
}
|
|
1812
|
+
return { ...owner, telegramProfile };
|
|
1813
|
+
};
|
|
1240
1814
|
const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
|
|
1241
1815
|
const readProvisionRecoveries = (
|
|
1242
1816
|
path: string,
|
|
@@ -1257,6 +1831,10 @@ export function createTelegramTopicTargetStore(
|
|
|
1257
1831
|
botState = { threadMode: "unknown" };
|
|
1258
1832
|
records = new Map();
|
|
1259
1833
|
identities = new Map();
|
|
1834
|
+
workspaceBindings = new Map();
|
|
1835
|
+
workspaceRetirements = [];
|
|
1836
|
+
workspaceRetirementCommitInFlight = false;
|
|
1837
|
+
workspaceClaims = new Map();
|
|
1260
1838
|
reservations = [];
|
|
1261
1839
|
pendingProvisions = [];
|
|
1262
1840
|
pendingCleanups = [];
|
|
@@ -1275,6 +1853,9 @@ export function createTelegramTopicTargetStore(
|
|
|
1275
1853
|
botState = { threadMode: "unknown" };
|
|
1276
1854
|
records = new Map();
|
|
1277
1855
|
identities = new Map();
|
|
1856
|
+
workspaceBindings = new Map();
|
|
1857
|
+
workspaceRetirements = [];
|
|
1858
|
+
workspaceRetirementCommitInFlight = false;
|
|
1278
1859
|
reservations = [];
|
|
1279
1860
|
pendingProvisions = [];
|
|
1280
1861
|
pendingCleanups = [];
|
|
@@ -1292,18 +1873,37 @@ export function createTelegramTopicTargetStore(
|
|
|
1292
1873
|
const file = parseTopicTargetFile(rawFile);
|
|
1293
1874
|
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
1294
1875
|
botState = file.bot;
|
|
1876
|
+
const scopedRecords = file.threads.map((record) =>
|
|
1877
|
+
cloneRecord({
|
|
1878
|
+
...record,
|
|
1879
|
+
owner: scopeOwnerToActiveProfile(getRecordOwner(record)),
|
|
1880
|
+
}),
|
|
1881
|
+
);
|
|
1295
1882
|
records = new Map(
|
|
1296
|
-
|
|
1297
|
-
getRecordOwnerKey(record),
|
|
1298
|
-
cloneRecord(record),
|
|
1299
|
-
]),
|
|
1883
|
+
scopedRecords.map((record) => [getRecordOwnerKey(record), record]),
|
|
1300
1884
|
);
|
|
1301
1885
|
identities = new Map(
|
|
1302
|
-
(file.identities ?? []).map((identity) =>
|
|
1303
|
-
|
|
1304
|
-
|
|
1886
|
+
(file.identities ?? []).map((identity) => {
|
|
1887
|
+
const owner = scopeOwnerToActiveProfile(
|
|
1888
|
+
getTelegramThreadOwnerFromProfileKey(identity.profileKey),
|
|
1889
|
+
);
|
|
1890
|
+
const profileKey = getTelegramThreadOwnerKey(owner);
|
|
1891
|
+
return [
|
|
1892
|
+
profileKey,
|
|
1893
|
+
cloneIdentityRecord({ ...identity, profileKey }),
|
|
1894
|
+
];
|
|
1895
|
+
}),
|
|
1896
|
+
);
|
|
1897
|
+
workspaceBindings = new Map(
|
|
1898
|
+
(file.workspaceBindings ?? []).map((binding) => [
|
|
1899
|
+
getWorkspaceBindingMapKey(binding),
|
|
1900
|
+
cloneWorkspaceBinding(binding),
|
|
1305
1901
|
]),
|
|
1306
1902
|
);
|
|
1903
|
+
workspaceRetirements = (file.workspaceRetirements ?? []).map(
|
|
1904
|
+
cloneWorkspaceRetirementIntent,
|
|
1905
|
+
);
|
|
1906
|
+
reconcileWorkspaceSuffixExposure();
|
|
1307
1907
|
for (const record of records.values()) rememberIdentity(record);
|
|
1308
1908
|
const nowMs = getNowMs();
|
|
1309
1909
|
reservations = (file.reservations ?? [])
|
|
@@ -1364,8 +1964,6 @@ export function createTelegramTopicTargetStore(
|
|
|
1364
1964
|
}
|
|
1365
1965
|
if (!dirty || !loaded) await loadFromDisk();
|
|
1366
1966
|
if (invalidation && !invalidation.isCurrent()) return false;
|
|
1367
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1368
|
-
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1369
1967
|
const nowMs = getNowMs();
|
|
1370
1968
|
reservations = reservations.filter(
|
|
1371
1969
|
(reservation) =>
|
|
@@ -1393,6 +1991,12 @@ export function createTelegramTopicTargetStore(
|
|
|
1393
1991
|
bot: botState,
|
|
1394
1992
|
...statusSnapshot,
|
|
1395
1993
|
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
1994
|
+
workspaceBindings: Array.from(workspaceBindings.values()).map(
|
|
1995
|
+
cloneWorkspaceBinding,
|
|
1996
|
+
),
|
|
1997
|
+
workspaceRetirements: workspaceRetirements.map(
|
|
1998
|
+
cloneWorkspaceRetirementIntent,
|
|
1999
|
+
),
|
|
1396
2000
|
reservations: reservations.map((reservation) => ({ ...reservation })),
|
|
1397
2001
|
pendingProvisions: pendingProvisions.map((provision) => ({
|
|
1398
2002
|
...provision,
|
|
@@ -1426,24 +2030,30 @@ export function createTelegramTopicTargetStore(
|
|
|
1426
2030
|
lastReconcileAction: "mark-stale",
|
|
1427
2031
|
});
|
|
1428
2032
|
}
|
|
1429
|
-
let persistedSemanticSnapshot: string | undefined;
|
|
2033
|
+
let persistedSemanticSnapshot: Record<string, unknown> | undefined;
|
|
1430
2034
|
try {
|
|
1431
|
-
persistedSemanticSnapshot =
|
|
2035
|
+
persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(
|
|
1432
2036
|
JSON.parse(await readFile(path, "utf8")),
|
|
1433
2037
|
);
|
|
1434
2038
|
} catch {
|
|
1435
2039
|
/* missing or invalid snapshots must be replaced */
|
|
1436
2040
|
}
|
|
2041
|
+
// Normalize optional fields to wire JSON; object key order is not a state change.
|
|
1437
2042
|
if (
|
|
1438
|
-
|
|
1439
|
-
|
|
2043
|
+
isDeepStrictEqual(
|
|
2044
|
+
persistedSemanticSnapshot,
|
|
2045
|
+
getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
|
|
2046
|
+
) &&
|
|
1440
2047
|
mutationRevision === persistedRevision &&
|
|
1441
2048
|
statusRevision === persistedStatusRevision
|
|
1442
2049
|
) {
|
|
2050
|
+
loadedPath = path;
|
|
1443
2051
|
loaded = true;
|
|
1444
2052
|
dirty = false;
|
|
1445
2053
|
return true;
|
|
1446
2054
|
}
|
|
2055
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2056
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1447
2057
|
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1448
2058
|
encoding: "utf8",
|
|
1449
2059
|
mode: 0o600,
|
|
@@ -1458,6 +2068,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1458
2068
|
// Fence and rename share one synchronous commit boundary. No stale
|
|
1459
2069
|
// invalidation enters the live projection before durable commit.
|
|
1460
2070
|
renameSync(tempPath, path);
|
|
2071
|
+
loadedPath = path;
|
|
1461
2072
|
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
|
|
1462
2073
|
syncObservations = file.syncObservations;
|
|
1463
2074
|
mutationRevision += 1;
|
|
@@ -1488,6 +2099,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1488
2099
|
await unlink(tempPath).catch(() => undefined);
|
|
1489
2100
|
throw error;
|
|
1490
2101
|
}
|
|
2102
|
+
loadedPath = path;
|
|
1491
2103
|
loaded = true;
|
|
1492
2104
|
if (mutationRevision === persistedRevision) dirty = false;
|
|
1493
2105
|
return true;
|
|
@@ -1678,6 +2290,620 @@ export function createTelegramTopicTargetStore(
|
|
|
1678
2290
|
markDirty();
|
|
1679
2291
|
return true;
|
|
1680
2292
|
},
|
|
2293
|
+
listWorkspaceBindings() {
|
|
2294
|
+
return Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding);
|
|
2295
|
+
},
|
|
2296
|
+
listWorkspaceRetirementIntents() {
|
|
2297
|
+
return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
|
|
2298
|
+
},
|
|
2299
|
+
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
|
|
2300
|
+
if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
|
|
2301
|
+
return undefined;
|
|
2302
|
+
}
|
|
2303
|
+
const key = getWorkspaceBindingMapKey(expected);
|
|
2304
|
+
const current = workspaceBindings.get(key);
|
|
2305
|
+
if (!current || !isDeepStrictEqual(current, expected) ||
|
|
2306
|
+
!journalBindingKeys.every((bindingKey) =>
|
|
2307
|
+
typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512,
|
|
2308
|
+
)) return undefined;
|
|
2309
|
+
const keys = Array.from(new Set(journalBindingKeys));
|
|
2310
|
+
if ((current.journalBindingsComplete === true) === complete &&
|
|
2311
|
+
isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
|
|
2312
|
+
return cloneWorkspaceBinding(current);
|
|
2313
|
+
}
|
|
2314
|
+
const next = { ...current, updatedAtMs: getNowMs() };
|
|
2315
|
+
if (keys.length) next.journalBindingKeys = keys;
|
|
2316
|
+
else delete next.journalBindingKeys;
|
|
2317
|
+
if (complete) next.journalBindingsComplete = true;
|
|
2318
|
+
else delete next.journalBindingsComplete;
|
|
2319
|
+
workspaceBindings.set(key, next);
|
|
2320
|
+
markDirty();
|
|
2321
|
+
return cloneWorkspaceBinding(next);
|
|
2322
|
+
},
|
|
2323
|
+
upsertWorkspaceRetirementIntent(intent) {
|
|
2324
|
+
const next = normalizeWorkspaceRetirementIntent(intent);
|
|
2325
|
+
if (!next) return false;
|
|
2326
|
+
const current = workspaceBindings.get(getWorkspaceBindingMapKey(next.binding));
|
|
2327
|
+
if (!current || !isDeepStrictEqual(current, next.binding)) return false;
|
|
2328
|
+
const sameId = workspaceRetirements.find((candidate) => candidate.id === next.id);
|
|
2329
|
+
if (sameId) return isDeepStrictEqual(sameId, next);
|
|
2330
|
+
if (workspaceRetirements.some((candidate) =>
|
|
2331
|
+
candidate.binding.bindingKey === next.binding.bindingKey ||
|
|
2332
|
+
candidate.binding.slot === next.binding.slot ||
|
|
2333
|
+
targetMatches(candidate.binding.target, next.binding.target),
|
|
2334
|
+
)) return false;
|
|
2335
|
+
workspaceRetirements.push(cloneWorkspaceRetirementIntent(next));
|
|
2336
|
+
markDirty();
|
|
2337
|
+
return true;
|
|
2338
|
+
},
|
|
2339
|
+
removeWorkspaceRetirementIntent(expected) {
|
|
2340
|
+
const index = workspaceRetirements.findIndex((candidate) =>
|
|
2341
|
+
isDeepStrictEqual(candidate, expected),
|
|
2342
|
+
);
|
|
2343
|
+
if (index < 0) return false;
|
|
2344
|
+
workspaceRetirements.splice(index, 1);
|
|
2345
|
+
markDirty();
|
|
2346
|
+
return true;
|
|
2347
|
+
},
|
|
2348
|
+
async replaceWorkspaceRetirementIntent(expected, replacement, isCurrent) {
|
|
2349
|
+
if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
|
|
2350
|
+
const previous = normalizeWorkspaceRetirementIntent(expected);
|
|
2351
|
+
const next = normalizeWorkspaceRetirementIntent(replacement);
|
|
2352
|
+
if (!previous || !next || previous.id !== next.id ||
|
|
2353
|
+
previous.reason !== next.reason || previous.profileKey !== next.profileKey ||
|
|
2354
|
+
previous.requestedAtMs !== next.requestedAtMs ||
|
|
2355
|
+
!isDeepStrictEqual(previous.binding, next.binding)) return false;
|
|
2356
|
+
const binding = workspaceBindings.get(getWorkspaceBindingMapKey(previous.binding));
|
|
2357
|
+
const intentIndex = workspaceRetirements.findIndex((candidate) =>
|
|
2358
|
+
isDeepStrictEqual(candidate, previous),
|
|
2359
|
+
);
|
|
2360
|
+
if (!binding || !isDeepStrictEqual(binding, previous.binding) || intentIndex < 0) {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
if (isDeepStrictEqual(previous, next)) return true;
|
|
2364
|
+
workspaceRetirementCommitInFlight = true;
|
|
2365
|
+
workspaceRetirements[intentIndex] = cloneWorkspaceRetirementIntent(next);
|
|
2366
|
+
markDirty();
|
|
2367
|
+
const restoreInMemory = () => {
|
|
2368
|
+
workspaceRetirements = workspaceRetirements.filter(
|
|
2369
|
+
(candidate) => candidate.id !== previous.id,
|
|
2370
|
+
);
|
|
2371
|
+
workspaceRetirements.push(cloneWorkspaceRetirementIntent(previous));
|
|
2372
|
+
markDirty();
|
|
2373
|
+
};
|
|
2374
|
+
try {
|
|
2375
|
+
if (!isCurrent()) {
|
|
2376
|
+
restoreInMemory();
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
const persisted = await persistSnapshot();
|
|
2380
|
+
if (!persisted) {
|
|
2381
|
+
restoreInMemory();
|
|
2382
|
+
return false;
|
|
2383
|
+
}
|
|
2384
|
+
return true;
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
try {
|
|
2387
|
+
await loadFromDisk();
|
|
2388
|
+
} catch {
|
|
2389
|
+
restoreInMemory();
|
|
2390
|
+
throw error;
|
|
2391
|
+
}
|
|
2392
|
+
if (workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, next))) {
|
|
2393
|
+
return true;
|
|
2394
|
+
}
|
|
2395
|
+
if (!workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, previous))) {
|
|
2396
|
+
restoreInMemory();
|
|
2397
|
+
}
|
|
2398
|
+
throw error;
|
|
2399
|
+
} finally {
|
|
2400
|
+
workspaceRetirementCommitInFlight = false;
|
|
2401
|
+
}
|
|
2402
|
+
},
|
|
2403
|
+
async commitInactiveWorkspaceCleanup(expected, isCurrent) {
|
|
2404
|
+
if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
|
|
2405
|
+
const cleanupSnapshot = "bindingUpdatedAtMs" in expected ? expected : undefined;
|
|
2406
|
+
const normalized = cleanupSnapshot ? undefined : normalizeWorkspaceBindingRecord(expected);
|
|
2407
|
+
if (cleanupSnapshot && (!cleanupSnapshot.cwd || !cleanupSnapshot.workspaceKey ||
|
|
2408
|
+
!cleanupSnapshot.instanceSlot || !cleanupSnapshot.slot || !cleanupSnapshot.bindingKey ||
|
|
2409
|
+
!Number.isSafeInteger(cleanupSnapshot.inactiveSinceMs) ||
|
|
2410
|
+
!Number.isSafeInteger(cleanupSnapshot.bindingUpdatedAtMs) ||
|
|
2411
|
+
!Number.isSafeInteger(cleanupSnapshot.target.chatId) ||
|
|
2412
|
+
!Number.isSafeInteger(cleanupSnapshot.target.threadId) || cleanupSnapshot.target.threadId <= 0)) return false;
|
|
2413
|
+
if (!cleanupSnapshot && (!normalized?.slot || normalized.inactiveSinceMs === undefined)) return false;
|
|
2414
|
+
const mapKey = getWorkspaceBindingMapKey((cleanupSnapshot ?? normalized)!);
|
|
2415
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2416
|
+
if (!binding) return isCurrent();
|
|
2417
|
+
const exact = cleanupSnapshot ? binding.cwd === cleanupSnapshot.cwd &&
|
|
2418
|
+
binding.workspaceKey === cleanupSnapshot.workspaceKey &&
|
|
2419
|
+
binding.instanceSlot === cleanupSnapshot.instanceSlot && binding.slot === cleanupSnapshot.slot &&
|
|
2420
|
+
binding.bindingKey === cleanupSnapshot.bindingKey && targetMatches(binding.target, cleanupSnapshot.target) &&
|
|
2421
|
+
binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
|
|
2422
|
+
binding.updatedAtMs === cleanupSnapshot.bindingUpdatedAtMs : isDeepStrictEqual(binding, normalized);
|
|
2423
|
+
if (!exact) return false;
|
|
2424
|
+
const nowMs = getNowMs();
|
|
2425
|
+
const targetOrSlotMatches = (candidate: { target?: TelegramTarget; slot?: string }) =>
|
|
2426
|
+
(candidate.target && targetMatches(candidate.target, binding.target)) || candidate.slot === binding.slot;
|
|
2427
|
+
const protectedLocally = Array.from(records.values()).some((record) =>
|
|
2428
|
+
isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
|
|
2429
|
+
Array.from(workspaceClaims.values()).some((claim) =>
|
|
2430
|
+
claim.identity.bindingKey === binding.bindingKey || claim.identity.slot === binding.slot) ||
|
|
2431
|
+
reservations.some((reservation) =>
|
|
2432
|
+
(reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) && targetOrSlotMatches(reservation)) ||
|
|
2433
|
+
pendingProvisions.some((provision) =>
|
|
2434
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs) && targetOrSlotMatches(provision)) ||
|
|
2435
|
+
pendingCleanups.some(targetOrSlotMatches) || workspaceRetirements.some((intent) =>
|
|
2436
|
+
intent.binding.bindingKey === binding.bindingKey || targetOrSlotMatches(intent.binding));
|
|
2437
|
+
if (protectedLocally) return false;
|
|
2438
|
+
workspaceRetirementCommitInFlight = true;
|
|
2439
|
+
workspaceBindings.delete(mapKey);
|
|
2440
|
+
markDirty();
|
|
2441
|
+
const restore = () => { workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding)); markDirty(); };
|
|
2442
|
+
try {
|
|
2443
|
+
if (!isCurrent()) { restore(); return false; }
|
|
2444
|
+
if (!await persistSnapshot()) { restore(); return false; }
|
|
2445
|
+
return true;
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
try { await loadFromDisk(); } catch { restore(); throw error; }
|
|
2448
|
+
if (!workspaceBindings.has(mapKey)) return true;
|
|
2449
|
+
if (!isDeepStrictEqual(workspaceBindings.get(mapKey), binding)) restore();
|
|
2450
|
+
throw error;
|
|
2451
|
+
} finally { workspaceRetirementCommitInFlight = false; }
|
|
2452
|
+
},
|
|
2453
|
+
async commitWorkspaceRetirement(expected, isCurrent) {
|
|
2454
|
+
if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
|
|
2455
|
+
const normalized = normalizeWorkspaceRetirementIntent(expected);
|
|
2456
|
+
if (!normalized) return false;
|
|
2457
|
+
const mapKey = getWorkspaceBindingMapKey(normalized.binding);
|
|
2458
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2459
|
+
const intentIndex = workspaceRetirements.findIndex((candidate) =>
|
|
2460
|
+
isDeepStrictEqual(candidate, normalized),
|
|
2461
|
+
);
|
|
2462
|
+
if (!binding || !isDeepStrictEqual(binding, normalized.binding) || intentIndex < 0) {
|
|
2463
|
+
return false;
|
|
2464
|
+
}
|
|
2465
|
+
const nowMs = getNowMs();
|
|
2466
|
+
const targetOrSlotMatches = (candidate: {
|
|
2467
|
+
target?: TelegramTarget;
|
|
2468
|
+
slot?: string;
|
|
2469
|
+
}) =>
|
|
2470
|
+
(candidate.target && targetMatches(candidate.target, binding.target)) ||
|
|
2471
|
+
candidate.slot === binding.slot;
|
|
2472
|
+
const locallyProtected =
|
|
2473
|
+
Array.from(records.values()).some((record) =>
|
|
2474
|
+
isCurrentThreadRecord(record) && targetOrSlotMatches(record),
|
|
2475
|
+
) ||
|
|
2476
|
+
Array.from(workspaceClaims.values()).some((claim) =>
|
|
2477
|
+
claim.identity.bindingKey === binding.bindingKey ||
|
|
2478
|
+
claim.identity.slot === binding.slot,
|
|
2479
|
+
) ||
|
|
2480
|
+
reservations.some((reservation) =>
|
|
2481
|
+
(reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
|
|
2482
|
+
targetOrSlotMatches(reservation),
|
|
2483
|
+
) ||
|
|
2484
|
+
pendingProvisions.some((provision) =>
|
|
2485
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs) &&
|
|
2486
|
+
targetOrSlotMatches(provision),
|
|
2487
|
+
) ||
|
|
2488
|
+
pendingCleanups.some(targetOrSlotMatches) ||
|
|
2489
|
+
workspaceRetirements.some((candidate, index) =>
|
|
2490
|
+
index !== intentIndex &&
|
|
2491
|
+
(candidate.binding.bindingKey === binding.bindingKey ||
|
|
2492
|
+
targetOrSlotMatches(candidate.binding)),
|
|
2493
|
+
);
|
|
2494
|
+
if (locallyProtected) return false;
|
|
2495
|
+
const previousRetirements = workspaceRetirements.map(
|
|
2496
|
+
cloneWorkspaceRetirementIntent,
|
|
2497
|
+
);
|
|
2498
|
+
workspaceRetirementCommitInFlight = true;
|
|
2499
|
+
workspaceBindings.delete(mapKey);
|
|
2500
|
+
workspaceRetirements.splice(intentIndex, 1);
|
|
2501
|
+
markDirty();
|
|
2502
|
+
const restoreInMemory = () => {
|
|
2503
|
+
workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding));
|
|
2504
|
+
workspaceRetirements = previousRetirements.map(
|
|
2505
|
+
cloneWorkspaceRetirementIntent,
|
|
2506
|
+
);
|
|
2507
|
+
markDirty();
|
|
2508
|
+
};
|
|
2509
|
+
try {
|
|
2510
|
+
if (!isCurrent()) {
|
|
2511
|
+
restoreInMemory();
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|
|
2514
|
+
const persisted = await persistSnapshot();
|
|
2515
|
+
if (!persisted) {
|
|
2516
|
+
restoreInMemory();
|
|
2517
|
+
return false;
|
|
2518
|
+
}
|
|
2519
|
+
return true;
|
|
2520
|
+
} catch (error) {
|
|
2521
|
+
try {
|
|
2522
|
+
await loadFromDisk();
|
|
2523
|
+
} catch {
|
|
2524
|
+
restoreInMemory();
|
|
2525
|
+
throw error;
|
|
2526
|
+
}
|
|
2527
|
+
const committed = !workspaceBindings.has(mapKey) &&
|
|
2528
|
+
!workspaceRetirements.some((candidate) => candidate.id === normalized.id);
|
|
2529
|
+
if (committed) return true;
|
|
2530
|
+
if (!workspaceBindings.has(mapKey) ||
|
|
2531
|
+
!workspaceRetirements.some((candidate) => candidate.id === normalized.id)) {
|
|
2532
|
+
restoreInMemory();
|
|
2533
|
+
}
|
|
2534
|
+
throw error;
|
|
2535
|
+
} finally {
|
|
2536
|
+
workspaceRetirementCommitInFlight = false;
|
|
2537
|
+
}
|
|
2538
|
+
},
|
|
2539
|
+
captureWorkspaceSlotOccupancy(getExternalProtection, options) {
|
|
2540
|
+
const nowMs = getNowMs();
|
|
2541
|
+
const bindings = Array.from(workspaceBindings.values()).map((binding) => {
|
|
2542
|
+
const slot = binding.slot?.toLowerCase() ?? "";
|
|
2543
|
+
const targetOrSlotMatches = (candidate: {
|
|
2544
|
+
target?: TelegramTarget;
|
|
2545
|
+
slot?: string;
|
|
2546
|
+
}) =>
|
|
2547
|
+
(candidate.target && targetMatches(candidate.target, binding.target)) ||
|
|
2548
|
+
(!!binding.slot && candidate.slot === binding.slot);
|
|
2549
|
+
const locallyProtected =
|
|
2550
|
+
Array.from(records.values()).some((record) =>
|
|
2551
|
+
isCurrentThreadRecord(record) && targetOrSlotMatches(record),
|
|
2552
|
+
) ||
|
|
2553
|
+
Array.from(workspaceClaims.values()).some((claim) =>
|
|
2554
|
+
claim.identity.bindingKey === binding.bindingKey ||
|
|
2555
|
+
(!!binding.slot && claim.identity.slot === binding.slot),
|
|
2556
|
+
) ||
|
|
2557
|
+
reservations.some((reservation) =>
|
|
2558
|
+
(reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
|
|
2559
|
+
targetOrSlotMatches(reservation),
|
|
2560
|
+
) ||
|
|
2561
|
+
pendingProvisions.some((provision) =>
|
|
2562
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs) &&
|
|
2563
|
+
targetOrSlotMatches(provision),
|
|
2564
|
+
) ||
|
|
2565
|
+
pendingCleanups.some((intent) => targetOrSlotMatches(intent)) ||
|
|
2566
|
+
workspaceRetirements.some((intent) =>
|
|
2567
|
+
!isDeepStrictEqual(intent, options?.expectedRetirement) &&
|
|
2568
|
+
(intent.binding.bindingKey === binding.bindingKey ||
|
|
2569
|
+
targetOrSlotMatches(intent.binding)),
|
|
2570
|
+
);
|
|
2571
|
+
const externalProtection = getExternalProtection(cloneWorkspaceBinding(binding));
|
|
2572
|
+
const externalStates = [
|
|
2573
|
+
externalProtection.liveOwner,
|
|
2574
|
+
externalProtection.acceptedWork,
|
|
2575
|
+
externalProtection.deliveryAuthority,
|
|
2576
|
+
];
|
|
2577
|
+
const hasValidInactivity = binding.inactiveSinceMs !== undefined &&
|
|
2578
|
+
Number.isFinite(binding.inactiveSinceMs) &&
|
|
2579
|
+
binding.inactiveSinceMs >= 0 && binding.inactiveSinceMs <= nowMs;
|
|
2580
|
+
const protection: TelegramWorkspaceSlotOccupancy["protection"] =
|
|
2581
|
+
locallyProtected || externalStates.includes("protected")
|
|
2582
|
+
? "protected"
|
|
2583
|
+
: externalStates.every((state) => state === "clear") && hasValidInactivity
|
|
2584
|
+
? "eligible"
|
|
2585
|
+
: "unknown";
|
|
2586
|
+
return {
|
|
2587
|
+
bindingKey: binding.bindingKey,
|
|
2588
|
+
slot,
|
|
2589
|
+
...(binding.inactiveSinceMs !== undefined
|
|
2590
|
+
? { inactiveSinceMs: binding.inactiveSinceMs }
|
|
2591
|
+
: {}),
|
|
2592
|
+
protection,
|
|
2593
|
+
};
|
|
2594
|
+
});
|
|
2595
|
+
const localReservedSlots = [
|
|
2596
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
|
|
2597
|
+
...Array.from(records.values()).filter(isCurrentThreadRecord).map((record) => record.slot),
|
|
2598
|
+
...reservations.filter((reservation) =>
|
|
2599
|
+
reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs,
|
|
2600
|
+
).map((reservation) => reservation.slot),
|
|
2601
|
+
...pendingProvisions.filter((provision) =>
|
|
2602
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
2603
|
+
).map((provision) => provision.slot),
|
|
2604
|
+
].filter((slot): slot is string => !!slot && /^[A-Z]$/u.test(slot))
|
|
2605
|
+
.map((slot) => slot.toLowerCase());
|
|
2606
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
2607
|
+
const reservedSlots = externalReservedSlots
|
|
2608
|
+
? [
|
|
2609
|
+
...localReservedSlots,
|
|
2610
|
+
...externalReservedSlots.map((slot) => slot.toLowerCase()),
|
|
2611
|
+
]
|
|
2612
|
+
: [...localReservedSlots, "invalid"];
|
|
2613
|
+
return { bindings, reservedSlots };
|
|
2614
|
+
},
|
|
2615
|
+
hasWorkspaceBinding(cwd) {
|
|
2616
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
2617
|
+
return !!normalizedCwd && Array.from(workspaceBindings.values()).some(
|
|
2618
|
+
(binding) => binding.cwd === normalizedCwd,
|
|
2619
|
+
);
|
|
2620
|
+
},
|
|
2621
|
+
setWorkspaceDisplayTitle(expected, title) {
|
|
2622
|
+
if (hasWorkspaceRetirementConflict(expected)) return false;
|
|
2623
|
+
const key = getWorkspaceBindingMapKey(expected);
|
|
2624
|
+
const current = workspaceBindings.get(key);
|
|
2625
|
+
if (!current || !isDeepStrictEqual(current, expected) || !title.trim() ||
|
|
2626
|
+
title.length > 128) return false;
|
|
2627
|
+
if (current.displayTitle === title) return true;
|
|
2628
|
+
workspaceBindings.set(key, { ...current, displayTitle: title });
|
|
2629
|
+
markDirty();
|
|
2630
|
+
return true;
|
|
2631
|
+
},
|
|
2632
|
+
markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
|
|
2633
|
+
if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
|
|
2634
|
+
for (const [key, binding] of workspaceBindings) {
|
|
2635
|
+
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
|
|
2636
|
+
workspaceBindings.set(key, { ...binding, inactiveSinceMs });
|
|
2637
|
+
markDirty();
|
|
2638
|
+
return true;
|
|
2639
|
+
}
|
|
2640
|
+
return false;
|
|
2641
|
+
},
|
|
2642
|
+
markWorkspaceBindingActiveByTarget(target) {
|
|
2643
|
+
if (hasWorkspaceRetirementConflict({ target })) return false;
|
|
2644
|
+
for (const [key, binding] of workspaceBindings) {
|
|
2645
|
+
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs === undefined) continue;
|
|
2646
|
+
const next = { ...binding };
|
|
2647
|
+
delete next.inactiveSinceMs;
|
|
2648
|
+
workspaceBindings.set(key, next);
|
|
2649
|
+
markDirty();
|
|
2650
|
+
return true;
|
|
2651
|
+
}
|
|
2652
|
+
return false;
|
|
2653
|
+
},
|
|
2654
|
+
getWorkspaceBinding(cwd, instanceSlot = "a") {
|
|
2655
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
2656
|
+
if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot)) return undefined;
|
|
2657
|
+
const binding = workspaceBindings.get(
|
|
2658
|
+
getWorkspaceBindingMapKey({ cwd: normalizedCwd, instanceSlot }),
|
|
2659
|
+
);
|
|
2660
|
+
return binding ? cloneWorkspaceBinding(binding) : undefined;
|
|
2661
|
+
},
|
|
2662
|
+
claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
|
|
2663
|
+
if (workspaceRetirementCommitInFlight) return undefined;
|
|
2664
|
+
const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
|
|
2665
|
+
if (!normalizedCwd || !instanceId ||
|
|
2666
|
+
hasWorkspaceRetirementConflict({ cwd: normalizedCwd })) return undefined;
|
|
2667
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
2668
|
+
if (!externalReservedSlots) {
|
|
2669
|
+
options?.onCapacityUnavailable?.();
|
|
2670
|
+
return undefined;
|
|
2671
|
+
}
|
|
2672
|
+
const externalReservedSlotKeys = externalReservedSlots.map((slot) =>
|
|
2673
|
+
slot.toLowerCase(),
|
|
2674
|
+
);
|
|
2675
|
+
for (const claim of workspaceClaims.values()) {
|
|
2676
|
+
if (claim.instanceId !== instanceId) continue;
|
|
2677
|
+
if (claim.identity.cwd !== normalizedCwd || !claim.identity.slot ||
|
|
2678
|
+
externalReservedSlotKeys.includes(claim.identity.slot.toLowerCase())) {
|
|
2679
|
+
return undefined;
|
|
2680
|
+
}
|
|
2681
|
+
if (
|
|
2682
|
+
options?.existingBindingOnly &&
|
|
2683
|
+
!workspaceBindings.has(getWorkspaceBindingMapKey(claim.identity))
|
|
2684
|
+
) return undefined;
|
|
2685
|
+
return { ...claim.identity };
|
|
2686
|
+
}
|
|
2687
|
+
const workspaceKey = resolveWorkspaceKey(normalizedCwd);
|
|
2688
|
+
if (!workspaceKey) return undefined;
|
|
2689
|
+
let legacyRecord = findLegacyWorkspaceMigrationRecord(
|
|
2690
|
+
normalizedCwd,
|
|
2691
|
+
instanceId,
|
|
2692
|
+
previousInstanceId,
|
|
2693
|
+
);
|
|
2694
|
+
const legacyTarget = legacyRecord?.target;
|
|
2695
|
+
const targetBinding = legacyTarget
|
|
2696
|
+
? Array.from(workspaceBindings.values()).find(
|
|
2697
|
+
(binding) =>
|
|
2698
|
+
binding.cwd === normalizedCwd &&
|
|
2699
|
+
targetMatches(binding.target, legacyTarget),
|
|
2700
|
+
)
|
|
2701
|
+
: undefined;
|
|
2702
|
+
let capacityUnavailable = false;
|
|
2703
|
+
const claimIdentity = (
|
|
2704
|
+
identity: TelegramWorkspaceBindingIdentity,
|
|
2705
|
+
): TelegramWorkspaceBindingIdentity | undefined => {
|
|
2706
|
+
const mapKey = getWorkspaceBindingMapKey(identity);
|
|
2707
|
+
const existingClaim = workspaceClaims.get(mapKey);
|
|
2708
|
+
if (existingClaim) {
|
|
2709
|
+
return existingClaim.instanceId === instanceId
|
|
2710
|
+
? { ...existingClaim.identity }
|
|
2711
|
+
: undefined;
|
|
2712
|
+
}
|
|
2713
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2714
|
+
const liveRecord = binding
|
|
2715
|
+
? isWorkspaceTargetLive(binding)
|
|
2716
|
+
: undefined;
|
|
2717
|
+
if (
|
|
2718
|
+
liveRecord &&
|
|
2719
|
+
liveRecord.instanceId !== instanceId &&
|
|
2720
|
+
liveRecord.instanceId !== previousInstanceId
|
|
2721
|
+
) {
|
|
2722
|
+
return undefined;
|
|
2723
|
+
}
|
|
2724
|
+
const retainedTarget = binding?.target ?? legacyRecord?.target;
|
|
2725
|
+
const retainedSlot = binding?.slot ?? legacyRecord?.slot;
|
|
2726
|
+
const otherBindings = Array.from(workspaceBindings.values())
|
|
2727
|
+
.filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
|
|
2728
|
+
.map((other) => ({
|
|
2729
|
+
bindingKey: other.bindingKey,
|
|
2730
|
+
slot: other.slot!.toLowerCase(),
|
|
2731
|
+
protection: "unknown" as const,
|
|
2732
|
+
}));
|
|
2733
|
+
const nowMs = getNowMs();
|
|
2734
|
+
const reservedSlots = [
|
|
2735
|
+
...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
|
|
2736
|
+
...Array.from(records.values())
|
|
2737
|
+
.filter((record) => isCurrentThreadRecord(record) &&
|
|
2738
|
+
!(retainedTarget && targetMatches(record.target, retainedTarget)))
|
|
2739
|
+
.map((record) => record.slot),
|
|
2740
|
+
...reservations.filter((reservation) =>
|
|
2741
|
+
reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs,
|
|
2742
|
+
).map((reservation) => reservation.slot),
|
|
2743
|
+
...pendingProvisions.filter((provision) =>
|
|
2744
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
2745
|
+
).map((provision) => provision.slot),
|
|
2746
|
+
...externalReservedSlots,
|
|
2747
|
+
].filter((slot): slot is string => !!slot).map((slot) => slot.toLowerCase());
|
|
2748
|
+
const retainedSlotKey = retainedSlot?.toLowerCase();
|
|
2749
|
+
if (retainedSlotKey && reservedSlots.includes(retainedSlotKey)) return undefined;
|
|
2750
|
+
const retainedSlotConflicts = !!retainedSlotKey &&
|
|
2751
|
+
otherBindings.some((other) => other.slot === retainedSlotKey);
|
|
2752
|
+
let slot = retainedSlotConflicts
|
|
2753
|
+
? Array.from(TELEGRAM_WORKSPACE_SLOTS).find((candidate) =>
|
|
2754
|
+
!reservedSlots.includes(candidate) &&
|
|
2755
|
+
!otherBindings.some((other) => other.slot === candidate),
|
|
2756
|
+
)?.toUpperCase()
|
|
2757
|
+
: retainedSlot;
|
|
2758
|
+
if (!slot) {
|
|
2759
|
+
const allocation = planTelegramWorkspaceSlotAllocation({
|
|
2760
|
+
bindings: otherBindings,
|
|
2761
|
+
reservedSlots,
|
|
2762
|
+
nowMs,
|
|
2763
|
+
});
|
|
2764
|
+
if (allocation.kind === "blocked" && allocation.reason === "invalid-state") {
|
|
2765
|
+
return undefined;
|
|
2766
|
+
}
|
|
2767
|
+
if (allocation.kind !== "free") {
|
|
2768
|
+
capacityUnavailable = true;
|
|
2769
|
+
return undefined;
|
|
2770
|
+
}
|
|
2771
|
+
slot = allocation.slot.toUpperCase();
|
|
2772
|
+
}
|
|
2773
|
+
if (!slot || !/^[A-Z]$/u.test(slot)) return undefined;
|
|
2774
|
+
const claimedIdentity = { ...identity, slot };
|
|
2775
|
+
workspaceClaims.set(mapKey, { identity: claimedIdentity, instanceId });
|
|
2776
|
+
return { ...claimedIdentity };
|
|
2777
|
+
};
|
|
2778
|
+
if (options?.existingBindingOnly) {
|
|
2779
|
+
const candidates = Array.from(workspaceBindings.values())
|
|
2780
|
+
.filter((binding) => binding.cwd === normalizedCwd)
|
|
2781
|
+
.sort((left, right) =>
|
|
2782
|
+
left.instanceSlot.length - right.instanceSlot.length ||
|
|
2783
|
+
left.instanceSlot.localeCompare(right.instanceSlot),
|
|
2784
|
+
);
|
|
2785
|
+
for (const binding of candidates) {
|
|
2786
|
+
const claimed = claimIdentity({
|
|
2787
|
+
cwd: binding.cwd,
|
|
2788
|
+
workspaceKey: binding.workspaceKey,
|
|
2789
|
+
instanceSlot: binding.instanceSlot,
|
|
2790
|
+
bindingKey: binding.bindingKey,
|
|
2791
|
+
});
|
|
2792
|
+
if (claimed) return claimed;
|
|
2793
|
+
}
|
|
2794
|
+
if (!legacyRecord) return undefined;
|
|
2795
|
+
}
|
|
2796
|
+
if (targetBinding) {
|
|
2797
|
+
const claimed = claimIdentity({
|
|
2798
|
+
cwd: targetBinding.cwd,
|
|
2799
|
+
workspaceKey: targetBinding.workspaceKey,
|
|
2800
|
+
instanceSlot: targetBinding.instanceSlot,
|
|
2801
|
+
bindingKey: targetBinding.bindingKey,
|
|
2802
|
+
});
|
|
2803
|
+
if (claimed) return claimed;
|
|
2804
|
+
if (options?.existingBindingOnly) return undefined;
|
|
2805
|
+
// A migrated live peer is not a handoff or a target for another slot.
|
|
2806
|
+
legacyRecord = undefined;
|
|
2807
|
+
}
|
|
2808
|
+
const attemptLimit = workspaceBindings.size + workspaceClaims.size + 1;
|
|
2809
|
+
for (let ordinal = 0; ordinal < attemptLimit; ordinal += 1) {
|
|
2810
|
+
const identity = createTelegramWorkspaceBindingIdentityWithKey(
|
|
2811
|
+
normalizedCwd,
|
|
2812
|
+
workspaceKey,
|
|
2813
|
+
ordinal,
|
|
2814
|
+
);
|
|
2815
|
+
if (!identity) return undefined;
|
|
2816
|
+
const mapKey = getWorkspaceBindingMapKey(identity);
|
|
2817
|
+
const binding = workspaceBindings.get(mapKey);
|
|
2818
|
+
if (legacyRecord && binding) continue;
|
|
2819
|
+
const claimed = claimIdentity(identity);
|
|
2820
|
+
if (!claimed) continue;
|
|
2821
|
+
if (legacyRecord && !binding) {
|
|
2822
|
+
workspaceBindings.set(mapKey, {
|
|
2823
|
+
...identity,
|
|
2824
|
+
target: { ...legacyRecord.target },
|
|
2825
|
+
...(legacyRecord.threadName
|
|
2826
|
+
? { threadName: legacyRecord.threadName }
|
|
2827
|
+
: {}),
|
|
2828
|
+
...(legacyRecord.slot ? { slot: legacyRecord.slot } : {}),
|
|
2829
|
+
updatedAtMs: getNowMs(),
|
|
2830
|
+
});
|
|
2831
|
+
reconcileWorkspaceSuffixExposure();
|
|
2832
|
+
markDirty();
|
|
2833
|
+
}
|
|
2834
|
+
return claimed;
|
|
2835
|
+
}
|
|
2836
|
+
if (capacityUnavailable) options?.onCapacityUnavailable?.();
|
|
2837
|
+
return undefined;
|
|
2838
|
+
},
|
|
2839
|
+
releaseWorkspaceClaim(instanceId) {
|
|
2840
|
+
let released = false;
|
|
2841
|
+
for (const [key, claim] of workspaceClaims) {
|
|
2842
|
+
if (claim.instanceId !== instanceId) continue;
|
|
2843
|
+
workspaceClaims.delete(key);
|
|
2844
|
+
released = true;
|
|
2845
|
+
}
|
|
2846
|
+
return released;
|
|
2847
|
+
},
|
|
2848
|
+
upsertWorkspaceBinding(binding, claimInstanceId) {
|
|
2849
|
+
if (workspaceRetirementCommitInFlight) return undefined;
|
|
2850
|
+
const next = normalizeWorkspaceBindingRecord(binding);
|
|
2851
|
+
if (next && hasWorkspaceRetirementConflict(next)) return undefined;
|
|
2852
|
+
if (!next) return undefined;
|
|
2853
|
+
if (next.slot && Array.from(workspaceBindings.values()).some((existing) =>
|
|
2854
|
+
existing.bindingKey !== next.bindingKey && existing.slot === next.slot,
|
|
2855
|
+
)) return undefined;
|
|
2856
|
+
for (const existing of workspaceBindings.values()) {
|
|
2857
|
+
if (
|
|
2858
|
+
existing.workspaceKey === next.workspaceKey &&
|
|
2859
|
+
existing.cwd !== next.cwd
|
|
2860
|
+
) {
|
|
2861
|
+
return undefined;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
const nextMapKey = getWorkspaceBindingMapKey(next);
|
|
2865
|
+
const previous = workspaceBindings.get(nextMapKey);
|
|
2866
|
+
if (previous?.showSlotSuffix) next.showSlotSuffix = true;
|
|
2867
|
+
if (previous?.manualThreadName && !next.manualThreadName) {
|
|
2868
|
+
next.manualThreadName = previous.manualThreadName;
|
|
2869
|
+
}
|
|
2870
|
+
if (previous) {
|
|
2871
|
+
const journalBindingKeys = Array.from(new Set([
|
|
2872
|
+
...(previous.journalBindingKeys ?? []),
|
|
2873
|
+
...(next.journalBindingKeys ?? []),
|
|
2874
|
+
]));
|
|
2875
|
+
if (journalBindingKeys.length) next.journalBindingKeys = journalBindingKeys;
|
|
2876
|
+
else delete next.journalBindingKeys;
|
|
2877
|
+
if (previous.journalBindingsComplete) next.journalBindingsComplete = true;
|
|
2878
|
+
else delete next.journalBindingsComplete;
|
|
2879
|
+
}
|
|
2880
|
+
if (previous && !targetMatches(previous.target, next.target)) {
|
|
2881
|
+
delete next.displayTitle;
|
|
2882
|
+
delete next.inactiveSinceMs;
|
|
2883
|
+
} else if (previous) {
|
|
2884
|
+
if (previous.displayTitle) next.displayTitle = previous.displayTitle;
|
|
2885
|
+
if (previous.inactiveSinceMs !== undefined) next.inactiveSinceMs = previous.inactiveSinceMs;
|
|
2886
|
+
}
|
|
2887
|
+
const claim = workspaceClaims.get(nextMapKey);
|
|
2888
|
+
if (claimInstanceId) {
|
|
2889
|
+
if (claim?.instanceId !== claimInstanceId) return undefined;
|
|
2890
|
+
if (next.slot !== undefined && claim.identity.slot !== next.slot) return undefined;
|
|
2891
|
+
next.slot = claim.identity.slot;
|
|
2892
|
+
} else if (claim) {
|
|
2893
|
+
return undefined;
|
|
2894
|
+
}
|
|
2895
|
+
for (const [key, existing] of workspaceBindings) {
|
|
2896
|
+
if (key === nextMapKey) continue;
|
|
2897
|
+
if (targetMatches(existing.target, next.target)) {
|
|
2898
|
+
workspaceBindings.delete(key);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
workspaceBindings.set(nextMapKey, next);
|
|
2902
|
+
reconcileWorkspaceSuffixExposure();
|
|
2903
|
+
if (claim) workspaceClaims.delete(nextMapKey);
|
|
2904
|
+
markDirty();
|
|
2905
|
+
return cloneWorkspaceBinding(next);
|
|
2906
|
+
},
|
|
1681
2907
|
upsert(record) {
|
|
1682
2908
|
const next = cloneRecord(record);
|
|
1683
2909
|
const nextOwnerKey = getRecordOwnerKey(next);
|
|
@@ -1735,27 +2961,31 @@ export function createTelegramTopicTargetStore(
|
|
|
1735
2961
|
return count;
|
|
1736
2962
|
},
|
|
1737
2963
|
markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2964
|
+
const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
|
|
2965
|
+
const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) =>
|
|
2966
|
+
entry.target && targetMatches(entry.target, target),
|
|
2967
|
+
) : undefined;
|
|
2968
|
+
const source = record ?? pending;
|
|
2969
|
+
if (!source?.target) return false;
|
|
2970
|
+
syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
|
|
2971
|
+
syncObservations.push({
|
|
2972
|
+
target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
|
|
2973
|
+
...(source.instanceId ? { instanceId: source.instanceId } : {}),
|
|
2974
|
+
...(source.slot ? { slot: source.slot } : {}),
|
|
2975
|
+
...(lastSyncError ? { lastSyncError } : {}),
|
|
2976
|
+
lastReconcileAction: "mark-stale",
|
|
2977
|
+
});
|
|
2978
|
+
if (record) {
|
|
1753
2979
|
rememberIdentity(record);
|
|
1754
2980
|
records.delete(getRecordOwnerKey(record));
|
|
1755
|
-
markDirty();
|
|
1756
|
-
return true;
|
|
1757
2981
|
}
|
|
1758
|
-
|
|
2982
|
+
if (syncStatus === "deleted") {
|
|
2983
|
+
pendingProvisions = pendingProvisions.filter((entry) =>
|
|
2984
|
+
!entry.target || !targetMatches(entry.target, target),
|
|
2985
|
+
);
|
|
2986
|
+
}
|
|
2987
|
+
markDirty();
|
|
2988
|
+
return true;
|
|
1759
2989
|
},
|
|
1760
2990
|
markActiveByTarget(target) {
|
|
1761
2991
|
const nowMs = getNowMs();
|
|
@@ -1773,16 +3003,45 @@ export function createTelegramTopicTargetStore(
|
|
|
1773
3003
|
}
|
|
1774
3004
|
return false;
|
|
1775
3005
|
},
|
|
1776
|
-
renameByTarget(target, threadName) {
|
|
3006
|
+
renameByTarget(target, threadName, options) {
|
|
3007
|
+
if (hasWorkspaceRetirementConflict({ target })) return undefined;
|
|
1777
3008
|
const nowMs = getNowMs();
|
|
1778
3009
|
const normalizedThreadName =
|
|
1779
3010
|
normalizeTelegramTopicTargetThreadName(threadName);
|
|
1780
3011
|
if (!normalizedThreadName) return undefined;
|
|
1781
3012
|
for (const record of records.values()) {
|
|
1782
3013
|
if (!targetMatches(record.target, target)) continue;
|
|
1783
|
-
record.
|
|
3014
|
+
record.manualThreadName = normalizedThreadName;
|
|
1784
3015
|
record.updatedAtMs = nowMs;
|
|
1785
|
-
|
|
3016
|
+
for (const binding of workspaceBindings.values()) {
|
|
3017
|
+
if (!targetMatches(binding.target, target)) continue;
|
|
3018
|
+
const previousTitle = binding.displayTitle ?? binding.manualThreadName ??
|
|
3019
|
+
binding.threadName;
|
|
3020
|
+
binding.manualThreadName = normalizedThreadName;
|
|
3021
|
+
binding.displayTitle = options?.updateDisplayTitle === false
|
|
3022
|
+
? previousTitle : normalizedThreadName;
|
|
3023
|
+
binding.updatedAtMs = nowMs;
|
|
3024
|
+
}
|
|
3025
|
+
markDirty();
|
|
3026
|
+
return cloneRecord(record);
|
|
3027
|
+
}
|
|
3028
|
+
return undefined;
|
|
3029
|
+
},
|
|
3030
|
+
clearManualNameByTarget(target, automaticTitle) {
|
|
3031
|
+
if (hasWorkspaceRetirementConflict({ target })) return undefined;
|
|
3032
|
+
const title = normalizeTelegramTopicTargetThreadName(automaticTitle);
|
|
3033
|
+
if (!title) return undefined;
|
|
3034
|
+
const nowMs = getNowMs();
|
|
3035
|
+
for (const record of records.values()) {
|
|
3036
|
+
if (!targetMatches(record.target, target)) continue;
|
|
3037
|
+
delete record.manualThreadName;
|
|
3038
|
+
record.updatedAtMs = nowMs;
|
|
3039
|
+
for (const binding of workspaceBindings.values()) {
|
|
3040
|
+
if (!targetMatches(binding.target, target)) continue;
|
|
3041
|
+
delete binding.manualThreadName;
|
|
3042
|
+
binding.displayTitle = title;
|
|
3043
|
+
binding.updatedAtMs = nowMs;
|
|
3044
|
+
}
|
|
1786
3045
|
markDirty();
|
|
1787
3046
|
return cloneRecord(record);
|
|
1788
3047
|
}
|
|
@@ -1793,9 +3052,9 @@ export function createTelegramTopicTargetStore(
|
|
|
1793
3052
|
const candidates = Array.from(records.values())
|
|
1794
3053
|
.filter((record) => {
|
|
1795
3054
|
if (record.instanceId) return false;
|
|
3055
|
+
if (!record.slot || !/^[A-Z]$/u.test(record.slot)) return false;
|
|
1796
3056
|
if (record.slot === "A") return false;
|
|
1797
3057
|
if (record.status !== "pending") return false;
|
|
1798
|
-
if (!record.slot) return true;
|
|
1799
3058
|
return !Array.from(records.values()).some(
|
|
1800
3059
|
(other) =>
|
|
1801
3060
|
other !== record &&
|
|
@@ -1825,17 +3084,64 @@ export function createTelegramTopicTargetStore(
|
|
|
1825
3084
|
markDirty();
|
|
1826
3085
|
return cloneRecord(record);
|
|
1827
3086
|
},
|
|
1828
|
-
allocateSlot(profileKey, preferredSlot) {
|
|
3087
|
+
allocateSlot(profileKey, preferredSlot, workspaceBindingKey, options) {
|
|
3088
|
+
if (workspaceRetirementCommitInFlight) return undefined;
|
|
3089
|
+
const externalReservedSlots = captureExternalReservedSlots();
|
|
3090
|
+
if (!externalReservedSlots) return undefined;
|
|
3091
|
+
const isExternalSlotOccupied = (slot: string): boolean =>
|
|
3092
|
+
externalReservedSlots.includes(slot);
|
|
1829
3093
|
const ownerKey = getTelegramThreadOwnerKey(
|
|
1830
3094
|
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1831
3095
|
);
|
|
1832
3096
|
const existing = records.get(ownerKey) ?? records.get(profileKey);
|
|
1833
|
-
if (existing?.slot && isCurrentThreadRecord(existing)) {
|
|
1834
|
-
return existing.slot;
|
|
1835
|
-
}
|
|
1836
3097
|
const nowMs = getNowMs();
|
|
3098
|
+
const isWorkspaceSlotOccupied = (slot: string): boolean =>
|
|
3099
|
+
Array.from(workspaceBindings.values()).some((binding) =>
|
|
3100
|
+
binding.bindingKey !== workspaceBindingKey && binding.slot === slot,
|
|
3101
|
+
);
|
|
3102
|
+
const isWorkspaceClaimSlotOccupied = (slot: string): boolean =>
|
|
3103
|
+
Array.from(workspaceClaims.values()).some((claim) =>
|
|
3104
|
+
claim.identity.bindingKey !== workspaceBindingKey &&
|
|
3105
|
+
claim.identity.slot === slot,
|
|
3106
|
+
);
|
|
3107
|
+
if (existing?.slot && isCurrentThreadRecord(existing) &&
|
|
3108
|
+
!options?.excludeCurrentRecord) {
|
|
3109
|
+
const bindingConflict = Array.from(workspaceBindings.values()).some(
|
|
3110
|
+
(binding) => binding.bindingKey !== workspaceBindingKey &&
|
|
3111
|
+
binding.slot === existing.slot &&
|
|
3112
|
+
!targetMatches(binding.target, existing.target),
|
|
3113
|
+
);
|
|
3114
|
+
const claimConflict = Array.from(workspaceClaims.values()).some(
|
|
3115
|
+
(claim) => claim.identity.bindingKey !== workspaceBindingKey &&
|
|
3116
|
+
claim.identity.slot === existing.slot &&
|
|
3117
|
+
claim.instanceId !== existing.instanceId,
|
|
3118
|
+
);
|
|
3119
|
+
return bindingConflict || claimConflict ||
|
|
3120
|
+
isExternalSlotOccupied(existing.slot) ? undefined : existing.slot;
|
|
3121
|
+
}
|
|
3122
|
+
if (workspaceBindingKey) {
|
|
3123
|
+
const claim = Array.from(workspaceClaims.values()).find((claim) =>
|
|
3124
|
+
claim.identity.bindingKey === workspaceBindingKey,
|
|
3125
|
+
);
|
|
3126
|
+
const slot = claim?.identity.slot;
|
|
3127
|
+
if (!slot) return undefined;
|
|
3128
|
+
const foreignClaim = Array.from(workspaceClaims.values()).some((other) =>
|
|
3129
|
+
other !== claim && other.identity.slot === slot,
|
|
3130
|
+
);
|
|
3131
|
+
const foreignBinding = Array.from(workspaceBindings.values()).some((binding) =>
|
|
3132
|
+
binding.bindingKey !== workspaceBindingKey && binding.slot === slot,
|
|
3133
|
+
);
|
|
3134
|
+
if (foreignClaim || foreignBinding || isExternalSlotOccupied(slot) ||
|
|
3135
|
+
isTelegramTopicTargetSlotOccupied(
|
|
3136
|
+
slot, records, reservations, pendingProvisions, nowMs,
|
|
3137
|
+
)) return undefined;
|
|
3138
|
+
return slot;
|
|
3139
|
+
}
|
|
1837
3140
|
if (
|
|
1838
3141
|
preferredSlot &&
|
|
3142
|
+
!isExternalSlotOccupied(preferredSlot) &&
|
|
3143
|
+
!isWorkspaceSlotOccupied(preferredSlot) &&
|
|
3144
|
+
!isWorkspaceClaimSlotOccupied(preferredSlot) &&
|
|
1839
3145
|
!isTelegramTopicTargetSlotOccupied(
|
|
1840
3146
|
preferredSlot,
|
|
1841
3147
|
records,
|
|
@@ -1846,13 +3152,25 @@ export function createTelegramTopicTargetStore(
|
|
|
1846
3152
|
) {
|
|
1847
3153
|
return preferredSlot;
|
|
1848
3154
|
}
|
|
1849
|
-
|
|
3155
|
+
const next = getNextMonotonicSlot(
|
|
1850
3156
|
records,
|
|
1851
3157
|
reservations,
|
|
1852
3158
|
pendingProvisions,
|
|
1853
3159
|
nowMs,
|
|
1854
3160
|
botState.lastSlot,
|
|
1855
3161
|
);
|
|
3162
|
+
if (next && !isExternalSlotOccupied(next) &&
|
|
3163
|
+
!isWorkspaceSlotOccupied(next) &&
|
|
3164
|
+
!isWorkspaceClaimSlotOccupied(next)) return next;
|
|
3165
|
+
return Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase())
|
|
3166
|
+
.find((slot) =>
|
|
3167
|
+
!isExternalSlotOccupied(slot) &&
|
|
3168
|
+
!isWorkspaceSlotOccupied(slot) &&
|
|
3169
|
+
!isWorkspaceClaimSlotOccupied(slot) &&
|
|
3170
|
+
!isTelegramTopicTargetSlotOccupied(
|
|
3171
|
+
slot, records, reservations, pendingProvisions, nowMs,
|
|
3172
|
+
),
|
|
3173
|
+
);
|
|
1856
3174
|
},
|
|
1857
3175
|
};
|
|
1858
3176
|
}
|
|
@@ -1944,15 +3262,58 @@ const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
|
|
|
1944
3262
|
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
1945
3263
|
};
|
|
1946
3264
|
|
|
3265
|
+
export function listOccupiedTelegramThreadIdentities(input: {
|
|
3266
|
+
records: readonly TelegramTopicTargetRecord[];
|
|
3267
|
+
workspaceBindings?: readonly TelegramWorkspaceThreadBinding[];
|
|
3268
|
+
pendingProvisions?: readonly TelegramThreadPendingProvision[];
|
|
3269
|
+
exceptTarget?: TelegramTarget;
|
|
3270
|
+
exceptWorkspaceBindingKey?: string;
|
|
3271
|
+
}): string[] {
|
|
3272
|
+
const occupied = new Set<string>();
|
|
3273
|
+
const add = (threadName: string | undefined): void => {
|
|
3274
|
+
if (!threadName) return;
|
|
3275
|
+
occupied.add(getTelegramTopicIdentityName(threadName));
|
|
3276
|
+
};
|
|
3277
|
+
for (const record of input.records) {
|
|
3278
|
+
if (!isCurrentThreadRecord(record)) continue;
|
|
3279
|
+
if (input.exceptTarget && targetMatches(record.target, input.exceptTarget))
|
|
3280
|
+
continue;
|
|
3281
|
+
add(record.manualThreadName);
|
|
3282
|
+
add(record.threadName);
|
|
3283
|
+
}
|
|
3284
|
+
for (const binding of input.workspaceBindings ?? []) {
|
|
3285
|
+
if (binding.bindingKey === input.exceptWorkspaceBindingKey) continue;
|
|
3286
|
+
if (input.exceptTarget && targetMatches(binding.target, input.exceptTarget))
|
|
3287
|
+
continue;
|
|
3288
|
+
add(binding.manualThreadName);
|
|
3289
|
+
add(binding.threadName);
|
|
3290
|
+
}
|
|
3291
|
+
for (const pending of input.pendingProvisions ?? []) {
|
|
3292
|
+
if (
|
|
3293
|
+
input.exceptTarget &&
|
|
3294
|
+
pending.target &&
|
|
3295
|
+
targetMatches(pending.target, input.exceptTarget)
|
|
3296
|
+
) {
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
add(pending.threadName);
|
|
3300
|
+
}
|
|
3301
|
+
return Array.from(occupied);
|
|
3302
|
+
}
|
|
3303
|
+
|
|
1947
3304
|
export function chooseTelegramThreadName(input: {
|
|
1948
3305
|
slot: string | undefined;
|
|
1949
3306
|
entropy?: number | string;
|
|
1950
3307
|
getRandom?: () => number;
|
|
3308
|
+
occupied?: readonly string[];
|
|
1951
3309
|
}): string | undefined {
|
|
1952
3310
|
if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
|
|
1953
3311
|
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
1954
3312
|
if (!names || names.length === 0) return undefined;
|
|
1955
|
-
const
|
|
3313
|
+
const occupied = new Set(
|
|
3314
|
+
(input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)),
|
|
3315
|
+
);
|
|
3316
|
+
const start = input.getRandom
|
|
1956
3317
|
? Math.max(
|
|
1957
3318
|
0,
|
|
1958
3319
|
Math.min(
|
|
@@ -1961,7 +3322,16 @@ export function chooseTelegramThreadName(input: {
|
|
|
1961
3322
|
),
|
|
1962
3323
|
)
|
|
1963
3324
|
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
1964
|
-
|
|
3325
|
+
for (let offset = 0; offset < names.length; offset += 1) {
|
|
3326
|
+
const name = names[(start + offset) % names.length];
|
|
3327
|
+
if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
|
|
3328
|
+
}
|
|
3329
|
+
for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
3330
|
+
for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
|
|
3331
|
+
if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return undefined;
|
|
1965
3335
|
}
|
|
1966
3336
|
|
|
1967
3337
|
function getTelegramThreadNameLeadingSlot(
|
|
@@ -1972,32 +3342,6 @@ function getTelegramThreadNameLeadingSlot(
|
|
|
1972
3342
|
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
1973
3343
|
}
|
|
1974
3344
|
|
|
1975
|
-
function isTelegramSlotOccupiedByOtherCurrentRecord(
|
|
1976
|
-
records: readonly TelegramTopicTargetRecord[],
|
|
1977
|
-
slot: string,
|
|
1978
|
-
currentRecord: TelegramTopicTargetRecord,
|
|
1979
|
-
): boolean {
|
|
1980
|
-
return records.some(
|
|
1981
|
-
(record) =>
|
|
1982
|
-
!targetMatches(record.target, currentRecord.target) &&
|
|
1983
|
-
isCurrentThreadRecord(record) &&
|
|
1984
|
-
record.slot === slot,
|
|
1985
|
-
);
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
function normalizeCurrentThreadNameSlots(
|
|
1989
|
-
store: Pick<TelegramTopicTargetStore, "list" | "upsert">,
|
|
1990
|
-
): void {
|
|
1991
|
-
for (const record of store.list()) {
|
|
1992
|
-
if (!isCurrentThreadRecord(record)) continue;
|
|
1993
|
-
const slot = getTelegramThreadNameLeadingSlot(record.threadName);
|
|
1994
|
-
if (!slot || record.slot === slot) continue;
|
|
1995
|
-
if (isTelegramSlotOccupiedByOtherCurrentRecord(store.list(), slot, record))
|
|
1996
|
-
continue;
|
|
1997
|
-
store.upsert({ ...record, slot });
|
|
1998
|
-
}
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
3345
|
function getNextTelegramThreadNamePaletteSlot(
|
|
2002
3346
|
records: readonly TelegramTopicTargetRecord[],
|
|
2003
3347
|
fallbackSlot: string | undefined,
|
|
@@ -2049,13 +3393,29 @@ export function getTelegramTopicThreadNameValidationError(
|
|
|
2049
3393
|
if (genericLabels.has(identity.toLowerCase())) {
|
|
2050
3394
|
reasons.push("it is a generic role label");
|
|
2051
3395
|
}
|
|
2052
|
-
if (/^[A-Z]$/.test(identity))
|
|
2053
|
-
reasons.push("it is only a bare slot letter");
|
|
2054
|
-
}
|
|
3396
|
+
if (/^[A-Z]$/.test(identity)) reasons.push("it is only a bare slot letter");
|
|
2055
3397
|
if (reasons.length === 0) return undefined;
|
|
2056
3398
|
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.`;
|
|
2057
3399
|
}
|
|
2058
3400
|
|
|
3401
|
+
export function getTelegramManualThreadDisplayNameValidationError(
|
|
3402
|
+
threadName: string,
|
|
3403
|
+
): string | undefined {
|
|
3404
|
+
const trimmed = threadName.trim();
|
|
3405
|
+
const normalized = trimmed.replace(/\s+/g, " ");
|
|
3406
|
+
const reasons: string[] = [];
|
|
3407
|
+
if (!trimmed) reasons.push("it is empty after trimming");
|
|
3408
|
+
if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
|
|
3409
|
+
reasons.push("it contains characters outside printable ASCII");
|
|
3410
|
+
}
|
|
3411
|
+
if (normalized.length > 96) reasons.push("it is longer than 96 characters");
|
|
3412
|
+
if (/^[A-Z]$/.test(normalized)) {
|
|
3413
|
+
reasons.push("a bare slot letter is reserved for reset to automatic");
|
|
3414
|
+
}
|
|
3415
|
+
if (reasons.length === 0) return undefined;
|
|
3416
|
+
return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
2059
3419
|
export function isTelegramTopicThreadNameValidForSlot(
|
|
2060
3420
|
threadName: string,
|
|
2061
3421
|
slot: string | undefined,
|
|
@@ -2121,6 +3481,35 @@ export async function promoteTelegramFollowerBindingToLeader(
|
|
|
2121
3481
|
record.target.chatId === target.chatId &&
|
|
2122
3482
|
record.target.threadId === target.threadId,
|
|
2123
3483
|
);
|
|
3484
|
+
const workspaceIdentity = deps.cwd
|
|
3485
|
+
? deps.store.claimWorkspaceIdentity(deps.cwd, deps.instanceId,
|
|
3486
|
+
existing?.instanceId, { existingBindingOnly: true })
|
|
3487
|
+
: undefined;
|
|
3488
|
+
const workspaceBinding = workspaceIdentity
|
|
3489
|
+
? deps.store.getWorkspaceBinding(
|
|
3490
|
+
workspaceIdentity.cwd, workspaceIdentity.instanceSlot,
|
|
3491
|
+
)
|
|
3492
|
+
: undefined;
|
|
3493
|
+
const exactWorkspaceBinding = workspaceBinding &&
|
|
3494
|
+
targetMatches(workspaceBinding.target, target)
|
|
3495
|
+
? workspaceBinding : undefined;
|
|
3496
|
+
const slot = existing
|
|
3497
|
+
? deps.store.allocateSlot(existing.profileKey)
|
|
3498
|
+
: exactWorkspaceBinding ? workspaceIdentity?.slot : undefined;
|
|
3499
|
+
if (!slot || (deps.slot && deps.slot !== slot)) {
|
|
3500
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
3501
|
+
return undefined;
|
|
3502
|
+
}
|
|
3503
|
+
if (exactWorkspaceBinding && exactWorkspaceBinding.slot !== slot) {
|
|
3504
|
+
const committed = deps.store.upsertWorkspaceBinding({
|
|
3505
|
+
...exactWorkspaceBinding, slot, updatedAtMs: nowMs,
|
|
3506
|
+
}, deps.instanceId);
|
|
3507
|
+
if (!committed) {
|
|
3508
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
3509
|
+
return undefined;
|
|
3510
|
+
}
|
|
3511
|
+
await deps.store.persist();
|
|
3512
|
+
}
|
|
2124
3513
|
const owner: TelegramThreadOwner = {
|
|
2125
3514
|
kind: "leader",
|
|
2126
3515
|
cwd: deps.cwd,
|
|
@@ -2138,9 +3527,7 @@ export async function promoteTelegramFollowerBindingToLeader(
|
|
|
2138
3527
|
? { threadName: existing?.threadName ?? deps.threadName }
|
|
2139
3528
|
: {}),
|
|
2140
3529
|
instanceId: deps.instanceId,
|
|
2141
|
-
|
|
2142
|
-
? { slot: existing?.slot ?? deps.slot }
|
|
2143
|
-
: {}),
|
|
3530
|
+
slot,
|
|
2144
3531
|
...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
|
|
2145
3532
|
...(existing?.lastSyncObservedAtMs !== undefined
|
|
2146
3533
|
? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
|
|
@@ -2151,6 +3538,7 @@ export async function promoteTelegramFollowerBindingToLeader(
|
|
|
2151
3538
|
: {}),
|
|
2152
3539
|
});
|
|
2153
3540
|
await deps.store.persist();
|
|
3541
|
+
deps.store.releaseWorkspaceClaim(deps.instanceId);
|
|
2154
3542
|
return record;
|
|
2155
3543
|
}
|
|
2156
3544
|
|
|
@@ -2159,6 +3547,12 @@ export interface TelegramOwnTopicProvisionDeps {
|
|
|
2159
3547
|
instanceId: string;
|
|
2160
3548
|
cwd?: string;
|
|
2161
3549
|
telegramProfile?: string;
|
|
3550
|
+
requestedThreadName?: string;
|
|
3551
|
+
preferredSlot?: string;
|
|
3552
|
+
workspaceBindingKey?: string;
|
|
3553
|
+
resolveInitialWorkspaceDisplayTitle?: (
|
|
3554
|
+
binding: TelegramWorkspaceDisplayBinding,
|
|
3555
|
+
) => string | undefined;
|
|
2162
3556
|
getNowMs?: () => number;
|
|
2163
3557
|
getRandom?: () => number;
|
|
2164
3558
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
@@ -2183,6 +3577,7 @@ export interface TelegramOwnTopicProvisionResult {
|
|
|
2183
3577
|
target: TelegramTarget & { threadId: number };
|
|
2184
3578
|
slot: string;
|
|
2185
3579
|
threadName?: string;
|
|
3580
|
+
displayTitle?: string;
|
|
2186
3581
|
reused: boolean;
|
|
2187
3582
|
}
|
|
2188
3583
|
|
|
@@ -2335,7 +3730,7 @@ export async function provisionOwnBusTopic(
|
|
|
2335
3730
|
});
|
|
2336
3731
|
continue;
|
|
2337
3732
|
}
|
|
2338
|
-
if (
|
|
3733
|
+
if (isSameTelegramProcessInstance(record.instanceId, deps.instanceId)) {
|
|
2339
3734
|
deps.store.upsert({
|
|
2340
3735
|
...record,
|
|
2341
3736
|
profileKey,
|
|
@@ -2413,16 +3808,18 @@ export async function provisionOwnBusTopic(
|
|
|
2413
3808
|
}
|
|
2414
3809
|
if (isCleanupTargetProtected(action.target, action)) continue;
|
|
2415
3810
|
deps.store.markStaleByTarget(record.target);
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
3811
|
+
if (record.slot) {
|
|
3812
|
+
deps.store.reserveThread({
|
|
3813
|
+
target: record.target,
|
|
3814
|
+
slot: record.slot,
|
|
3815
|
+
reason: "previous-process-cleaned-without-visible-probe",
|
|
3816
|
+
createdAtMs: nowMs,
|
|
3817
|
+
updatedAtMs: nowMs,
|
|
3818
|
+
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
3819
|
+
instanceId: record.instanceId,
|
|
3820
|
+
lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
2426
3823
|
deps.store.setBotState({
|
|
2427
3824
|
threadMode: "enabled",
|
|
2428
3825
|
updatedAtMs: nowMs,
|
|
@@ -2448,12 +3845,24 @@ export async function provisionOwnBusTopic(
|
|
|
2448
3845
|
getNowMs: deps.getNowMs,
|
|
2449
3846
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2450
3847
|
getRandom: deps.getRandom,
|
|
3848
|
+
resolveInitialWorkspaceDisplayTitle:
|
|
3849
|
+
deps.resolveInitialWorkspaceDisplayTitle,
|
|
2451
3850
|
claimPendingTargets: false,
|
|
2452
3851
|
});
|
|
2453
3852
|
let result = await provision({
|
|
2454
3853
|
instanceId: deps.instanceId,
|
|
2455
3854
|
owner: currentLeaderOwner,
|
|
2456
3855
|
profileKey,
|
|
3856
|
+
...(deps.requestedThreadName
|
|
3857
|
+
? { threadName: deps.requestedThreadName }
|
|
3858
|
+
: {}),
|
|
3859
|
+
...(deps.preferredSlot ? { preferredSlot: deps.preferredSlot } : {}),
|
|
3860
|
+
...(deps.workspaceBindingKey
|
|
3861
|
+
? {
|
|
3862
|
+
workspaceBindingKey: deps.workspaceBindingKey,
|
|
3863
|
+
...(deps.cwd ? { workspaceCwd: deps.cwd } : {}),
|
|
3864
|
+
}
|
|
3865
|
+
: {}),
|
|
2457
3866
|
});
|
|
2458
3867
|
if (result.reused) {
|
|
2459
3868
|
// Reused topics may already have a human-chosen Telegram title. Do not edit
|
|
@@ -2488,12 +3897,16 @@ export async function provisionOwnBusTopic(
|
|
|
2488
3897
|
threadName: result.record.threadName,
|
|
2489
3898
|
reused: result.reused,
|
|
2490
3899
|
});
|
|
3900
|
+
if (!result.record.slot) {
|
|
3901
|
+
throw new Error("Telegram Thread slot authority is unavailable.");
|
|
3902
|
+
}
|
|
2491
3903
|
return {
|
|
2492
3904
|
target: result.target,
|
|
2493
|
-
slot: result.record.slot
|
|
3905
|
+
slot: result.record.slot,
|
|
2494
3906
|
...(result.record.threadName
|
|
2495
3907
|
? { threadName: result.record.threadName }
|
|
2496
3908
|
: {}),
|
|
3909
|
+
...(result.displayTitle ? { displayTitle: result.displayTitle } : {}),
|
|
2497
3910
|
reused: result.reused,
|
|
2498
3911
|
};
|
|
2499
3912
|
}
|
|
@@ -2721,6 +4134,7 @@ export interface TelegramThreadStatusProjectionRuntimeDeps {
|
|
|
2721
4134
|
}
|
|
2722
4135
|
| undefined;
|
|
2723
4136
|
getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
|
|
4137
|
+
getDisplayTitle?: (target: TelegramTarget) => string | undefined;
|
|
2724
4138
|
}
|
|
2725
4139
|
|
|
2726
4140
|
export function createTelegramThreadStatusProjectionRuntime(
|
|
@@ -2736,25 +4150,32 @@ export function createTelegramThreadStatusProjectionRuntime(
|
|
|
2736
4150
|
return listTelegramThreadStatusFollowers({
|
|
2737
4151
|
followers: deps.listFollowers(),
|
|
2738
4152
|
records: deps.listRecords(),
|
|
2739
|
-
})
|
|
4153
|
+
}).map((follower) => ({
|
|
4154
|
+
...follower,
|
|
4155
|
+
threadName: (follower.target ? deps.getDisplayTitle?.(follower.target) : undefined) ?? follower.threadName,
|
|
4156
|
+
}));
|
|
2740
4157
|
},
|
|
2741
4158
|
getLocalBus() {
|
|
2742
4159
|
const leaderSocketPath = deps.getLeaderSocketPath();
|
|
2743
4160
|
const followerSocketPath = deps.getFollowerSocketPath();
|
|
2744
4161
|
const leaderProtocol = deps.getLeaderProtocol?.();
|
|
4162
|
+
const followerTarget = deps.getFollowerTarget();
|
|
2745
4163
|
return {
|
|
2746
4164
|
leaderSocketPath,
|
|
2747
4165
|
leaderTransport: deps.getTransportKind(leaderSocketPath),
|
|
2748
4166
|
followerSocketPath,
|
|
2749
4167
|
followerTransport: deps.getTransportKind(followerSocketPath),
|
|
2750
4168
|
followerRegistered: deps.isFollowerRegistered(),
|
|
2751
|
-
followerTarget
|
|
4169
|
+
followerTarget,
|
|
2752
4170
|
followerSlot: deps.getFollowerSlot(),
|
|
2753
|
-
followerThreadName: deps.
|
|
4171
|
+
followerThreadName: (followerTarget ? deps.getDisplayTitle?.(followerTarget) : undefined) ??
|
|
4172
|
+
deps.getFollowerThreadName(),
|
|
2754
4173
|
...(leaderProtocol ? { leaderProtocol } : {}),
|
|
2755
4174
|
};
|
|
2756
4175
|
},
|
|
2757
|
-
getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords())
|
|
4176
|
+
getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()).map((record) => ({
|
|
4177
|
+
...record, threadName: deps.getDisplayTitle?.(record.target) ?? record.threadName,
|
|
4178
|
+
})),
|
|
2758
4179
|
getThreadReservations: () =>
|
|
2759
4180
|
listTelegramThreadStatusReservations(deps.listReservations()),
|
|
2760
4181
|
getTopicSyncObservations: () =>
|
|
@@ -2773,6 +4194,8 @@ export function createTelegramThreadStatusProjectionRuntime(
|
|
|
2773
4194
|
export interface TelegramCurrentThreadAssemblyDeps {
|
|
2774
4195
|
instanceId: string;
|
|
2775
4196
|
listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
|
|
4197
|
+
listWorkspaceBindings?: () => readonly TelegramWorkspaceThreadBinding[];
|
|
4198
|
+
getFollowerDisplayTitle?: () => string | undefined;
|
|
2776
4199
|
getActiveTurnTarget(): TelegramTarget | undefined;
|
|
2777
4200
|
getFollowerTarget(): TelegramTarget | undefined;
|
|
2778
4201
|
isFollowerRegistered(): boolean;
|
|
@@ -2795,6 +4218,7 @@ export interface TelegramCurrentThreadAssemblyDeps {
|
|
|
2795
4218
|
}
|
|
2796
4219
|
|
|
2797
4220
|
export interface TelegramCurrentThreadAssembly {
|
|
4221
|
+
getDisplayTitle: (target: TelegramTarget) => string | undefined;
|
|
2798
4222
|
current: TelegramCurrentInstanceThreadRuntime;
|
|
2799
4223
|
status: TelegramThreadStatusProjectionRuntime;
|
|
2800
4224
|
}
|
|
@@ -2813,7 +4237,7 @@ export function createTelegramCurrentThreadAssembly(
|
|
|
2813
4237
|
threadName: deps.getFollowerThreadName(),
|
|
2814
4238
|
};
|
|
2815
4239
|
};
|
|
2816
|
-
const
|
|
4240
|
+
const namedCurrent = createTelegramCurrentInstanceThreadRuntime({
|
|
2817
4241
|
instanceId: deps.instanceId,
|
|
2818
4242
|
listRecords: deps.listRecords,
|
|
2819
4243
|
getPreferredTarget: () =>
|
|
@@ -2823,7 +4247,25 @@ export function createTelegramCurrentThreadAssembly(
|
|
|
2823
4247
|
getFollower,
|
|
2824
4248
|
getLeader: deps.getLeaderIdentity,
|
|
2825
4249
|
});
|
|
4250
|
+
const getDisplayTitle = (target: TelegramTarget): string | undefined => {
|
|
4251
|
+
const followerTarget = deps.getFollowerTarget();
|
|
4252
|
+
if (followerTarget && targetMatches(followerTarget, target) && deps.isFollowerRegistered()) {
|
|
4253
|
+
return deps.getFollowerDisplayTitle?.();
|
|
4254
|
+
}
|
|
4255
|
+
return deps.listWorkspaceBindings?.().find((binding) =>
|
|
4256
|
+
targetMatches(binding.target, target),
|
|
4257
|
+
)?.displayTitle;
|
|
4258
|
+
};
|
|
4259
|
+
const displayIdentity = (identity: TelegramInstanceThreadIdentityCandidate) => {
|
|
4260
|
+
const title = identity.target ? getDisplayTitle(identity.target) : undefined;
|
|
4261
|
+
return title ? { ...identity, threadName: title } : identity;
|
|
4262
|
+
};
|
|
4263
|
+
const current = {
|
|
4264
|
+
...namedCurrent,
|
|
4265
|
+
getIdentity: (target?: TelegramTarget) => displayIdentity(namedCurrent.getIdentity(target)),
|
|
4266
|
+
};
|
|
2826
4267
|
return {
|
|
4268
|
+
getDisplayTitle,
|
|
2827
4269
|
current,
|
|
2828
4270
|
status: createTelegramThreadStatusProjectionRuntime({
|
|
2829
4271
|
...deps.status,
|
|
@@ -2833,7 +4275,8 @@ export function createTelegramCurrentThreadAssembly(
|
|
|
2833
4275
|
getFollowerSlot: deps.getFollowerSlot,
|
|
2834
4276
|
getFollowerThreadName: deps.getFollowerThreadName,
|
|
2835
4277
|
getLeaderProtocol: deps.getLeaderProtocol,
|
|
2836
|
-
|
|
4278
|
+
getDisplayTitle,
|
|
4279
|
+
getCurrentIdentity: () => displayIdentity(namedCurrent.getRestorationIdentity()),
|
|
2837
4280
|
}),
|
|
2838
4281
|
};
|
|
2839
4282
|
}
|
|
@@ -3048,20 +4491,37 @@ export function createTelegramTopicTargetRenamer(
|
|
|
3048
4491
|
);
|
|
3049
4492
|
if (
|
|
3050
4493
|
!threadName ||
|
|
3051
|
-
|
|
4494
|
+
!!getTelegramManualThreadDisplayNameValidationError(threadName)
|
|
3052
4495
|
)
|
|
3053
4496
|
return undefined;
|
|
4497
|
+
const occupied = new Set(
|
|
4498
|
+
listOccupiedTelegramThreadIdentities({
|
|
4499
|
+
records: deps.store.list(),
|
|
4500
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
4501
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
4502
|
+
exceptTarget: request.target,
|
|
4503
|
+
}),
|
|
4504
|
+
);
|
|
4505
|
+
if (occupied.has(getTelegramTopicIdentityName(threadName))) return undefined;
|
|
3054
4506
|
const name = getTelegramTopicTitleForThreadName(
|
|
3055
4507
|
threadName,
|
|
3056
4508
|
request.slot ?? "",
|
|
3057
4509
|
deps.topicNameTemplate,
|
|
3058
4510
|
);
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
4511
|
+
deps.assertAuthority?.();
|
|
4512
|
+
const updateDisplayTitle = deps.shouldRenameDisplayedTitle?.() ?? true;
|
|
4513
|
+
if (updateDisplayTitle) {
|
|
4514
|
+
await deps.callApi("editForumTopic", {
|
|
4515
|
+
chat_id: request.target.chatId,
|
|
4516
|
+
message_thread_id: request.target.threadId,
|
|
4517
|
+
name,
|
|
4518
|
+
});
|
|
4519
|
+
}
|
|
4520
|
+
deps.assertAuthority?.();
|
|
4521
|
+
if ((deps.shouldRenameDisplayedTitle?.() ?? true) !== updateDisplayTitle) {
|
|
4522
|
+
throw new Error("Telegram display mode changed during Workspace rename.");
|
|
4523
|
+
}
|
|
4524
|
+
return deps.store.renameByTarget(request.target, threadName, { updateDisplayTitle });
|
|
3065
4525
|
};
|
|
3066
4526
|
}
|
|
3067
4527
|
|
|
@@ -3086,22 +4546,53 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3086
4546
|
}
|
|
3087
4547
|
};
|
|
3088
4548
|
assertLeaderEpoch("start");
|
|
3089
|
-
normalizeCurrentThreadNameSlots(deps.store);
|
|
3090
|
-
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
3091
4549
|
const isManualFollowerRequest = request.owner?.kind === "manual-follower";
|
|
3092
4550
|
const identity = deps.store.getIdentityByProfileKey(request.profileKey);
|
|
3093
4551
|
const nowMs = getNowMs();
|
|
4552
|
+
let pendingForRequest = deps.store
|
|
4553
|
+
.listPendingProvisions()
|
|
4554
|
+
.find((pending) =>
|
|
4555
|
+
pending.profileKey === request.profileKey ||
|
|
4556
|
+
pending.instanceId === request.instanceId,
|
|
4557
|
+
);
|
|
4558
|
+
if (pendingForRequest?.target) {
|
|
4559
|
+
const target = pendingForRequest.target;
|
|
4560
|
+
const observation = deps.store.listSyncObservations().find((entry) =>
|
|
4561
|
+
targetMatches(entry.target, target),
|
|
4562
|
+
);
|
|
4563
|
+
assertTelegramPendingTopicRecoveryAllowed(deps.store, target);
|
|
4564
|
+
if (observation?.syncStatus === "deleted") {
|
|
4565
|
+
assertLeaderEpoch("before-deleted-provision-settlement");
|
|
4566
|
+
deps.store.markStaleByTarget(target, "deleted");
|
|
4567
|
+
await deps.store.persist();
|
|
4568
|
+
assertLeaderEpoch("after-deleted-provision-settlement");
|
|
4569
|
+
pendingForRequest = undefined;
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
3094
4573
|
if (existing && isCurrentThreadRecord(existing)) {
|
|
3095
4574
|
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
4575
|
+
if (!slot) {
|
|
4576
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
4577
|
+
}
|
|
4578
|
+
const occupied = listOccupiedTelegramThreadIdentities({
|
|
4579
|
+
records: deps.store.list(),
|
|
4580
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
4581
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
4582
|
+
exceptTarget: existing.target,
|
|
4583
|
+
exceptWorkspaceBindingKey: request.workspaceBindingKey,
|
|
4584
|
+
});
|
|
3096
4585
|
const identityThreadName =
|
|
3097
4586
|
identity?.threadName &&
|
|
3098
|
-
isTelegramTopicThreadNameValidForSlot(identity.threadName, slot)
|
|
4587
|
+
isTelegramTopicThreadNameValidForSlot(identity.threadName, slot) &&
|
|
4588
|
+
!occupied.includes(getTelegramTopicIdentityName(identity.threadName))
|
|
3099
4589
|
? identity.threadName
|
|
3100
4590
|
: undefined;
|
|
3101
4591
|
const bakedThreadName = chooseTelegramThreadName({
|
|
3102
4592
|
slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
|
|
3103
4593
|
entropy: nowMs,
|
|
3104
4594
|
getRandom,
|
|
4595
|
+
occupied,
|
|
3105
4596
|
});
|
|
3106
4597
|
const record = deps.store.upsert({
|
|
3107
4598
|
...existing,
|
|
@@ -3114,16 +4605,23 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3114
4605
|
owner: request.owner ?? existing.owner,
|
|
3115
4606
|
lastError: undefined,
|
|
3116
4607
|
});
|
|
3117
|
-
|
|
4608
|
+
const recoveredTitle = pendingForRequest?.target &&
|
|
4609
|
+
targetMatches(pendingForRequest.target, record.target)
|
|
4610
|
+
? pendingForRequest.displayTitle : undefined;
|
|
4611
|
+
if (pendingForRequest?.target && targetMatches(pendingForRequest.target, record.target)) {
|
|
4612
|
+
if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingForRequest.id);
|
|
4613
|
+
await deps.store.persist();
|
|
4614
|
+
assertLeaderEpoch("after-recovered-current-binding");
|
|
4615
|
+
}
|
|
4616
|
+
return {
|
|
4617
|
+
target: record.target, reused: true, record,
|
|
4618
|
+
...(recoveredTitle ? { displayTitle: recoveredTitle } : {}),
|
|
4619
|
+
};
|
|
3118
4620
|
}
|
|
3119
|
-
const pendingForRequest = deps.store
|
|
3120
|
-
.listPendingProvisions()
|
|
3121
|
-
.find(
|
|
3122
|
-
(pending) =>
|
|
3123
|
-
pending.profileKey === request.profileKey ||
|
|
3124
|
-
pending.instanceId === request.instanceId,
|
|
3125
|
-
);
|
|
3126
4621
|
if (pendingForRequest?.target) {
|
|
4622
|
+
if (!pendingForRequest.slot) {
|
|
4623
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
4624
|
+
}
|
|
3127
4625
|
const record = deps.store.upsert({
|
|
3128
4626
|
profileKey: request.profileKey,
|
|
3129
4627
|
owner: request.owner,
|
|
@@ -3135,10 +4633,17 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3135
4633
|
instanceId: request.instanceId,
|
|
3136
4634
|
slot: pendingForRequest.slot,
|
|
3137
4635
|
});
|
|
3138
|
-
deps.store.removePendingProvision(pendingForRequest.id);
|
|
4636
|
+
if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingForRequest.id);
|
|
3139
4637
|
await deps.store.persist();
|
|
3140
4638
|
assertLeaderEpoch("after-recovered-binding");
|
|
3141
|
-
return {
|
|
4639
|
+
return {
|
|
4640
|
+
target: record.target,
|
|
4641
|
+
reused: true,
|
|
4642
|
+
record,
|
|
4643
|
+
...(pendingForRequest.displayTitle
|
|
4644
|
+
? { displayTitle: pendingForRequest.displayTitle }
|
|
4645
|
+
: {}),
|
|
4646
|
+
};
|
|
3142
4647
|
}
|
|
3143
4648
|
if (pendingForRequest) {
|
|
3144
4649
|
throw new Error(
|
|
@@ -3149,6 +4654,9 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3149
4654
|
request.instanceId,
|
|
3150
4655
|
);
|
|
3151
4656
|
if (activeForInstance) {
|
|
4657
|
+
if (!activeForInstance.slot) {
|
|
4658
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
4659
|
+
}
|
|
3152
4660
|
return {
|
|
3153
4661
|
target: activeForInstance.target,
|
|
3154
4662
|
reused: true,
|
|
@@ -3165,25 +4673,67 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3165
4673
|
return { target: claimed.target, reused: true, record: claimed };
|
|
3166
4674
|
}
|
|
3167
4675
|
}
|
|
3168
|
-
const
|
|
4676
|
+
const occupied = listOccupiedTelegramThreadIdentities({
|
|
4677
|
+
records: deps.store.list(),
|
|
4678
|
+
workspaceBindings: deps.store.listWorkspaceBindings(),
|
|
4679
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
4680
|
+
exceptWorkspaceBindingKey: request.workspaceBindingKey,
|
|
4681
|
+
});
|
|
4682
|
+
const requestedThreadName =
|
|
4683
|
+
request.threadName &&
|
|
4684
|
+
isTelegramTopicThreadNameValidForSlot(request.threadName, undefined) &&
|
|
4685
|
+
!occupied.includes(getTelegramTopicIdentityName(request.threadName))
|
|
4686
|
+
? normalizeTelegramTopicTargetThreadName(request.threadName)
|
|
4687
|
+
: undefined;
|
|
4688
|
+
const candidateThreadName = requestedThreadName ?? identity?.threadName;
|
|
3169
4689
|
const preferredNameSlot =
|
|
3170
4690
|
getTelegramThreadNameLeadingSlot(candidateThreadName) ??
|
|
3171
4691
|
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
3172
4692
|
request.preferredSlot;
|
|
3173
4693
|
const slot =
|
|
3174
4694
|
existing?.slot ??
|
|
3175
|
-
(candidateThreadName ? undefined : identity?.slot) ??
|
|
3176
4695
|
deps.store.allocateSlot(
|
|
3177
4696
|
request.profileKey,
|
|
3178
4697
|
isManualFollowerRequest
|
|
3179
4698
|
? request.preferredSlot
|
|
3180
|
-
: (request.preferredSlot ??
|
|
4699
|
+
: (request.preferredSlot ??
|
|
4700
|
+
(candidateThreadName ? undefined : identity?.slot) ??
|
|
4701
|
+
preferredNameSlot),
|
|
4702
|
+
request.workspaceBindingKey,
|
|
3181
4703
|
);
|
|
3182
|
-
|
|
4704
|
+
if (!slot) {
|
|
4705
|
+
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
4706
|
+
}
|
|
4707
|
+
const uniqueCandidate =
|
|
3183
4708
|
candidateThreadName &&
|
|
3184
|
-
isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot)
|
|
4709
|
+
isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&
|
|
4710
|
+
!occupied.includes(getTelegramTopicIdentityName(candidateThreadName))
|
|
3185
4711
|
? candidateThreadName
|
|
3186
|
-
:
|
|
4712
|
+
: undefined;
|
|
4713
|
+
const requestThreadName =
|
|
4714
|
+
uniqueCandidate ??
|
|
4715
|
+
chooseTelegramThreadName({ slot, entropy: nowMs, getRandom, occupied });
|
|
4716
|
+
let displayTitle: string | undefined;
|
|
4717
|
+
if (
|
|
4718
|
+
deps.resolveInitialWorkspaceDisplayTitle &&
|
|
4719
|
+
request.workspaceBindingKey &&
|
|
4720
|
+
request.workspaceCwd
|
|
4721
|
+
) {
|
|
4722
|
+
const projectedTitle = deps.resolveInitialWorkspaceDisplayTitle({
|
|
4723
|
+
bindingKey: request.workspaceBindingKey,
|
|
4724
|
+
cwd: request.workspaceCwd,
|
|
4725
|
+
slot,
|
|
4726
|
+
threadName: requestThreadName,
|
|
4727
|
+
});
|
|
4728
|
+
if (!projectedTitle?.trim()) {
|
|
4729
|
+
throw new Error("Telegram Thread display identity is missing or ambiguous.");
|
|
4730
|
+
}
|
|
4731
|
+
displayTitle = getTelegramTopicName(
|
|
4732
|
+
{ ...request, threadName: projectedTitle },
|
|
4733
|
+
"{threadName}",
|
|
4734
|
+
slot,
|
|
4735
|
+
);
|
|
4736
|
+
}
|
|
3187
4737
|
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
3188
4738
|
const pendingOwner =
|
|
3189
4739
|
request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
@@ -3194,6 +4744,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3194
4744
|
instanceId: request.instanceId,
|
|
3195
4745
|
profileKey: request.profileKey,
|
|
3196
4746
|
threadName: requestThreadName,
|
|
4747
|
+
...(displayTitle ? { displayTitle } : {}),
|
|
3197
4748
|
slot,
|
|
3198
4749
|
startedAtMs: nowMs,
|
|
3199
4750
|
...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
|
|
@@ -3208,7 +4759,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3208
4759
|
"createForumTopic",
|
|
3209
4760
|
{
|
|
3210
4761
|
chat_id: deps.topicChatId,
|
|
3211
|
-
name: getTelegramTopicName(
|
|
4762
|
+
name: displayTitle ?? getTelegramTopicName(
|
|
3212
4763
|
{
|
|
3213
4764
|
...request,
|
|
3214
4765
|
...(requestThreadName ? { threadName: requestThreadName } : {}),
|
|
@@ -3256,10 +4807,16 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
3256
4807
|
instanceId: request.instanceId,
|
|
3257
4808
|
slot,
|
|
3258
4809
|
});
|
|
3259
|
-
|
|
4810
|
+
// Workspace commit consumes the exact title evidence in the same publication.
|
|
4811
|
+
if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingId);
|
|
3260
4812
|
await deps.store.persist();
|
|
3261
4813
|
assertLeaderEpoch("after-active-binding");
|
|
3262
|
-
return {
|
|
4814
|
+
return {
|
|
4815
|
+
target: record.target,
|
|
4816
|
+
reused: false,
|
|
4817
|
+
record,
|
|
4818
|
+
...(displayTitle ? { displayTitle } : {}),
|
|
4819
|
+
};
|
|
3263
4820
|
} catch (error) {
|
|
3264
4821
|
if (
|
|
3265
4822
|
threadId !== undefined &&
|