@llblab/pi-telegram 0.51.5 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -8
- package/BACKLOG.md +1 -8
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +16 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +9 -2
- package/dist/lib/bindings.js +22 -13
- package/dist/lib/bus-api.js +4 -2
- package/dist/lib/bus-follower.d.ts +55 -57
- package/dist/lib/bus-follower.js +213 -140
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +12 -26
- package/dist/lib/commands.js +152 -98
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +281 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +177 -16
- package/dist/lib/journal.js +1115 -249
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.d.ts +57 -2
- package/dist/lib/lifecycle.js +110 -5
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/model.js +2 -4
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +3 -15
- package/dist/lib/queue.js +69 -8
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +73 -8
- package/dist/lib/routing.js +2206 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +46 -9
- package/dist/lib/status.js +179 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +9 -8
- package/dist/lib/telegram-api.d.ts +11 -3
- package/dist/lib/telegram-api.js +34 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +292 -59
- package/dist/lib/threads.js +1664 -373
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +184 -28
- package/dist/lib/updates.js +1032 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +45 -10
- package/dist/lib/workspace-retirement.js +196 -64
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +158 -39
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +127 -19
- package/docs/public-api.md +5 -3
- package/docs/ui-style.md +26 -8
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +23 -10
- package/lib/bus-api.ts +5 -2
- package/lib/bus-follower.ts +251 -226
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +150 -112
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +282 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1182 -326
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +141 -5
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/model.ts +2 -4
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +67 -9
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +1955 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +185 -36
- package/lib/sync.ts +8 -8
- package/lib/telegram-api.ts +48 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1718 -491
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1072 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +234 -102
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/dist/lib/threads.js
CHANGED
|
@@ -1,113 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram thread binding helpers
|
|
3
|
-
* Zones: multi-instance bus, Telegram UI threads,
|
|
4
|
-
* Owns
|
|
3
|
+
* Zones: multi-instance bus, Telegram UI threads, durable Workspace state
|
|
4
|
+
* Owns live mappings, Workspace bindings and guarded Restore transitions; routing and transport effects stay outside.
|
|
5
5
|
*/
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
8
|
-
import { chmod, mkdir,
|
|
9
|
-
import {
|
|
10
|
-
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFile as readFileCallback, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { chmod, mkdir, unlink, writeFile, } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
11
11
|
import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, } from "./telegram-api.js";
|
|
12
|
-
import {
|
|
12
|
+
import { areTelegramTargetsEqual as targetMatches, getTelegramTargetKey as getTargetRecoveryHintKey, } from "./target.js";
|
|
13
|
+
import { isWireRecord as restoreObject, isNonEmptyWireString as restoreText } from "./wire.js";
|
|
14
|
+
import { chooseTelegramThreadName, getTelegramManualThreadDisplayNameValidationError, getTelegramThreadNameLeadingSlot, getTelegramTopicIdentityName, getTelegramTopicName as buildTelegramTopicName, getTelegramTopicTitleForThreadName, isTelegramTopicThreadNameValidForSlot, normalizeTelegramTopicTargetThreadName, } from "./thread-naming.js";
|
|
15
|
+
export { chooseTelegramThreadName, createTelegramThreadName, getTelegramManualThreadDisplayNameValidationError, getTelegramTopicIdentityName, getTelegramTopicThreadNameValidationError, getTelegramTopicTitleForThreadName, isTelegramTopicThreadNameValidForSlot, } from "./thread-naming.js";
|
|
16
|
+
// Preserve the legacy provision-request signature without a runtime wrapper.
|
|
17
|
+
export const getTelegramTopicName = buildTelegramTopicName;
|
|
18
|
+
import { TELEGRAM_WORKSPACE_KEY_MAX_LENGTH, createTelegramSessionKey, createTelegramWorkspaceBindingIdentityWithKey, createTelegramWorkspaceDirectoryKey, normalizeTelegramSessionId, normalizeTelegramWorkspacePath, } from "./workspace-identity.js";
|
|
19
|
+
export { createTelegramWorkspaceBindingIdentity, createTelegramWorkspaceDirectoryKey, normalizeTelegramSessionId, normalizeTelegramWorkspacePath, } from "./workspace-identity.js";
|
|
20
|
+
import { withTelegramFileTransaction, readTelegramRuntimeState, } from "./locks.js";
|
|
13
21
|
import * as ThreadReconciler from "./thread-reconciler.js";
|
|
22
|
+
import { createTelegramRuntimeProjectionStore } from "./status.js";
|
|
14
23
|
import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
|
|
15
24
|
import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.js";
|
|
16
|
-
const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
|
|
17
|
-
const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
|
|
18
|
-
export function normalizeTelegramSessionId(sessionId) {
|
|
19
|
-
if (typeof sessionId !== "string")
|
|
20
|
-
return undefined;
|
|
21
|
-
const normalized = sessionId.trim();
|
|
22
|
-
return normalized && Buffer.byteLength(normalized, "utf8") <=
|
|
23
|
-
TELEGRAM_SESSION_ID_MAX_LENGTH
|
|
24
|
-
? normalized
|
|
25
|
-
: undefined;
|
|
26
|
-
}
|
|
27
|
-
export function createTelegramSessionKey(sessionId) {
|
|
28
|
-
const normalized = normalizeTelegramSessionId(sessionId);
|
|
29
|
-
return normalized
|
|
30
|
-
? createHash("sha256").update(normalized).digest("hex")
|
|
31
|
-
: undefined;
|
|
32
|
-
}
|
|
33
|
-
export function normalizeTelegramWorkspacePath(cwd) {
|
|
34
|
-
const trimmed = cwd.trim();
|
|
35
|
-
if (!trimmed)
|
|
36
|
-
return undefined;
|
|
37
|
-
const normalized = (trimmed.startsWith("/")
|
|
38
|
-
? posix.normalize(trimmed)
|
|
39
|
-
: resolve(trimmed).replaceAll("\\", "/"));
|
|
40
|
-
const withoutTrailingSeparators = normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
|
|
41
|
-
return process.platform === "win32"
|
|
42
|
-
? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive) => `${drive.toLowerCase()}:`)
|
|
43
|
-
: withoutTrailingSeparators;
|
|
44
|
-
}
|
|
45
|
-
export function createTelegramWorkspaceDirectoryKey(cwd) {
|
|
46
|
-
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
47
|
-
if (!normalized)
|
|
48
|
-
return undefined;
|
|
49
|
-
const readable = normalized
|
|
50
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
51
|
-
.replace(/^-+|-+$/gu, "") || "root";
|
|
52
|
-
const candidate = `--${readable}--`;
|
|
53
|
-
if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH)
|
|
54
|
-
return candidate;
|
|
55
|
-
const digest = createHash("sha256")
|
|
56
|
-
.update(normalized)
|
|
57
|
-
.digest("hex")
|
|
58
|
-
.slice(0, 12);
|
|
59
|
-
const prefixLength = TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
|
|
60
|
-
return `--${readable.slice(0, prefixLength)}-${digest}--`;
|
|
61
|
-
}
|
|
62
|
-
function createTelegramWorkspaceInstanceSlot(ordinal) {
|
|
63
|
-
if (!Number.isSafeInteger(ordinal) || ordinal < 0)
|
|
64
|
-
return undefined;
|
|
65
|
-
let value = ordinal + 1;
|
|
66
|
-
let slot = "";
|
|
67
|
-
while (value > 0) {
|
|
68
|
-
value -= 1;
|
|
69
|
-
slot = String.fromCharCode(97 + (value % 26)) + slot;
|
|
70
|
-
value = Math.floor(value / 26);
|
|
71
|
-
}
|
|
72
|
-
return slot;
|
|
73
|
-
}
|
|
74
|
-
function createTelegramWorkspaceBindingIdentityWithKey(cwd, workspaceKey, ordinal, sessionId) {
|
|
75
|
-
const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
|
|
76
|
-
if (!instanceSlot)
|
|
77
|
-
return undefined;
|
|
78
|
-
const normalizedSessionId = sessionId === undefined
|
|
79
|
-
? undefined
|
|
80
|
-
: normalizeTelegramSessionId(sessionId);
|
|
81
|
-
const sessionKey = normalizedSessionId
|
|
82
|
-
? createTelegramSessionKey(normalizedSessionId)
|
|
83
|
-
: undefined;
|
|
84
|
-
if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
const legacyBindingKey = instanceSlot === "a"
|
|
88
|
-
? workspaceKey
|
|
89
|
-
: `${workspaceKey}${instanceSlot}`;
|
|
90
|
-
return {
|
|
91
|
-
cwd,
|
|
92
|
-
workspaceKey,
|
|
93
|
-
...(normalizedSessionId && sessionKey
|
|
94
|
-
? { sessionId: normalizedSessionId, sessionKey }
|
|
95
|
-
: {}),
|
|
96
|
-
instanceSlot,
|
|
97
|
-
bindingKey: sessionKey
|
|
98
|
-
? `${legacyBindingKey}-s-${sessionKey}`
|
|
99
|
-
: legacyBindingKey,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
export function createTelegramWorkspaceBindingIdentity(cwd, ordinal = 0, sessionId) {
|
|
103
|
-
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
104
|
-
const workspaceKey = normalized
|
|
105
|
-
? createTelegramWorkspaceDirectoryKey(normalized)
|
|
106
|
-
: undefined;
|
|
107
|
-
if (!normalized || !workspaceKey)
|
|
108
|
-
return undefined;
|
|
109
|
-
return createTelegramWorkspaceBindingIdentityWithKey(normalized, workspaceKey, ordinal, sessionId);
|
|
110
|
-
}
|
|
111
25
|
const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
|
|
112
26
|
function getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, lastSlot) {
|
|
113
27
|
let cursorCode;
|
|
@@ -188,6 +102,16 @@ export function createTelegramCleanupTargetProtection(store, departingRecord) {
|
|
|
188
102
|
// optional fields; neither changes the authority represented by a snapshot.
|
|
189
103
|
const sameSnapshot = (left, right) => isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
190
104
|
return (target, action) => {
|
|
105
|
+
// A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
|
|
106
|
+
if (store.listTemporaryThreadTargets) {
|
|
107
|
+
try {
|
|
108
|
+
if (store.listTemporaryThreadTargets().some(candidate => targetMatches(candidate, target)))
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
191
115
|
for (const record of store.list()) {
|
|
192
116
|
if (!targetMatches(record.target, target))
|
|
193
117
|
continue;
|
|
@@ -261,41 +185,6 @@ export function commitTelegramWorkspaceProvisionBinding(input) {
|
|
|
261
185
|
input.store.removePendingProvision(provision.id);
|
|
262
186
|
return displayTitle === undefined ? committed : { ...committed, displayTitle };
|
|
263
187
|
}
|
|
264
|
-
function hashString(value) {
|
|
265
|
-
let hash = 2166136261;
|
|
266
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
267
|
-
hash ^= value.charCodeAt(index);
|
|
268
|
-
hash = Math.imul(hash, 16777619);
|
|
269
|
-
}
|
|
270
|
-
return hash >>> 0;
|
|
271
|
-
}
|
|
272
|
-
function getWorkspaceHint(cwd) {
|
|
273
|
-
if (!cwd)
|
|
274
|
-
return undefined;
|
|
275
|
-
const parts = cwd.split("/").filter(Boolean);
|
|
276
|
-
const last = parts.at(-1)?.trim();
|
|
277
|
-
if (!last)
|
|
278
|
-
return undefined;
|
|
279
|
-
return (last
|
|
280
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
281
|
-
.trim()
|
|
282
|
-
.slice(0, 32) || undefined);
|
|
283
|
-
}
|
|
284
|
-
export function createTelegramThreadName(input) {
|
|
285
|
-
const workspace = getWorkspaceHint(input.cwd);
|
|
286
|
-
const roleMark = input.role === "leader"
|
|
287
|
-
? "Leader"
|
|
288
|
-
: input.role === "follower"
|
|
289
|
-
? "Follower"
|
|
290
|
-
: undefined;
|
|
291
|
-
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
292
|
-
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
293
|
-
const fallback = `Instance ${hashString(`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`)
|
|
294
|
-
.toString(36)
|
|
295
|
-
.slice(0, 4)}`;
|
|
296
|
-
return ([slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
297
|
-
fallback);
|
|
298
|
-
}
|
|
299
188
|
export function getTelegramStatePath(agentDir = resolveAgentDir(), profileName) {
|
|
300
189
|
return resolveTelegramProfileTempFilePath("state", "json", agentDir, profileName);
|
|
301
190
|
}
|
|
@@ -326,7 +215,7 @@ export function setTelegramLeaderSessionHandoff(handoff) {
|
|
|
326
215
|
else
|
|
327
216
|
store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
|
|
328
217
|
}
|
|
329
|
-
|
|
218
|
+
function isTelegramLeaderSessionHandoffFresh(handoff, options = {}) {
|
|
330
219
|
if (!handoff)
|
|
331
220
|
return false;
|
|
332
221
|
const pid = options.pid ?? process.pid;
|
|
@@ -476,6 +365,7 @@ function normalizeRecord(value) {
|
|
|
476
365
|
if (typeof record.createdAtMs !== "number" ||
|
|
477
366
|
typeof record.updatedAtMs !== "number")
|
|
478
367
|
return undefined;
|
|
368
|
+
const threadName = getPersistedThreadName(record);
|
|
479
369
|
const normalized = {
|
|
480
370
|
profileKey: getTelegramThreadOwnerKey(owner),
|
|
481
371
|
owner,
|
|
@@ -483,7 +373,7 @@ function normalizeRecord(value) {
|
|
|
483
373
|
status,
|
|
484
374
|
createdAtMs: record.createdAtMs,
|
|
485
375
|
updatedAtMs: record.updatedAtMs,
|
|
486
|
-
threadName:
|
|
376
|
+
...(threadName !== undefined ? { threadName } : {}),
|
|
487
377
|
...(typeof record.manualThreadName === "string" &&
|
|
488
378
|
normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
489
379
|
? { manualThreadName: normalizeTelegramTopicTargetThreadName(record.manualThreadName) }
|
|
@@ -557,6 +447,23 @@ function cloneIdentityRecord(identity) {
|
|
|
557
447
|
function getWorkspaceBindingMapKey(binding) {
|
|
558
448
|
return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
|
|
559
449
|
}
|
|
450
|
+
function normalizeWorkspaceJournalSources(value) {
|
|
451
|
+
if (!Array.isArray(value) || value.length > 256)
|
|
452
|
+
return undefined;
|
|
453
|
+
const sources = new Map();
|
|
454
|
+
for (const entry of value) {
|
|
455
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
456
|
+
return undefined;
|
|
457
|
+
const source = entry;
|
|
458
|
+
if (Object.keys(source).some(key => key !== "sessionId" && key !== "recipientBindingKey") ||
|
|
459
|
+
typeof source.sessionId !== "string" || normalizeTelegramSessionId(source.sessionId) !== source.sessionId ||
|
|
460
|
+
typeof source.recipientBindingKey !== "string" || !source.recipientBindingKey || source.recipientBindingKey.length > 512)
|
|
461
|
+
return undefined;
|
|
462
|
+
const normalized = { sessionId: source.sessionId, recipientBindingKey: source.recipientBindingKey };
|
|
463
|
+
sources.set(JSON.stringify(normalized), normalized);
|
|
464
|
+
}
|
|
465
|
+
return Array.from(sources.values());
|
|
466
|
+
}
|
|
560
467
|
function normalizeWorkspaceBindingRecord(value) {
|
|
561
468
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
562
469
|
return undefined;
|
|
@@ -617,6 +524,9 @@ function normalizeWorkspaceBindingRecord(value) {
|
|
|
617
524
|
record.journalBindingKeys.every((key) => typeof key === "string" && key.length > 0 && key.length <= 512)
|
|
618
525
|
? Array.from(new Set(record.journalBindingKeys))
|
|
619
526
|
: undefined;
|
|
527
|
+
const journalSources = record.journalSources === undefined ? undefined : normalizeWorkspaceJournalSources(record.journalSources);
|
|
528
|
+
if (record.journalSources !== undefined && !journalSources)
|
|
529
|
+
return undefined;
|
|
620
530
|
return {
|
|
621
531
|
cwd,
|
|
622
532
|
workspaceKey: record.workspaceKey,
|
|
@@ -632,7 +542,8 @@ function normalizeWorkspaceBindingRecord(value) {
|
|
|
632
542
|
? { inactiveSinceMs: record.inactiveSinceMs }
|
|
633
543
|
: {}),
|
|
634
544
|
...(journalBindingKeys ? { journalBindingKeys } : {}),
|
|
635
|
-
...(
|
|
545
|
+
...(journalSources ? { journalSources } : {}),
|
|
546
|
+
...(record.journalBindingsComplete === true && (journalBindingKeys || journalSources)
|
|
636
547
|
? { journalBindingsComplete: true }
|
|
637
548
|
: {}),
|
|
638
549
|
target: { chatId: target.chatId, threadId: target.threadId },
|
|
@@ -643,7 +554,9 @@ function normalizeWorkspaceBindingRecord(value) {
|
|
|
643
554
|
};
|
|
644
555
|
}
|
|
645
556
|
function cloneWorkspaceBinding(binding) {
|
|
646
|
-
return { ...binding, target: { ...binding.target }
|
|
557
|
+
return { ...binding, target: { ...binding.target },
|
|
558
|
+
...(binding.journalBindingKeys ? { journalBindingKeys: [...binding.journalBindingKeys] } : {}),
|
|
559
|
+
...(binding.journalSources ? { journalSources: binding.journalSources.map(source => ({ ...source })) } : {}) };
|
|
647
560
|
}
|
|
648
561
|
function cloneSessionReplacementIntent(intent) {
|
|
649
562
|
return { ...intent, target: { ...intent.target } };
|
|
@@ -812,6 +725,9 @@ function normalizePendingProvision(value) {
|
|
|
812
725
|
...(record.status === "in-flight" || record.status === "ambiguous"
|
|
813
726
|
? { status: record.status }
|
|
814
727
|
: {}),
|
|
728
|
+
...(typeof record.workspaceBindingKey === "string"
|
|
729
|
+
? { workspaceBindingKey: record.workspaceBindingKey }
|
|
730
|
+
: {}),
|
|
815
731
|
...(typeof record.threadName === "string"
|
|
816
732
|
? { threadName: record.threadName }
|
|
817
733
|
: {}),
|
|
@@ -906,6 +822,351 @@ function normalizeReservation(value) {
|
|
|
906
822
|
: undefined,
|
|
907
823
|
};
|
|
908
824
|
}
|
|
825
|
+
function normalizeTelegramWorkspaceRelocationRequest(value) {
|
|
826
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
827
|
+
return undefined;
|
|
828
|
+
const request = value;
|
|
829
|
+
const binding = normalizeWorkspaceBindingRecord(request.binding);
|
|
830
|
+
const owner = normalizeRecord(request.owner);
|
|
831
|
+
const target = request.target;
|
|
832
|
+
if (typeof request.operationId !== "string" || !request.operationId ||
|
|
833
|
+
!binding?.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) || binding.inactiveSinceMs !== undefined ||
|
|
834
|
+
!owner || owner.status !== "active" || !owner.instanceId || owner.slot !== binding.slot ||
|
|
835
|
+
!["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
|
|
836
|
+
(owner.owner?.kind === "leader" &&
|
|
837
|
+
(owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
|
|
838
|
+
!targetMatches(binding.target, owner.target) ||
|
|
839
|
+
!Number.isSafeInteger(binding.target.chatId) || binding.target.chatId === 0 ||
|
|
840
|
+
!Number.isSafeInteger(binding.target.threadId) || binding.target.threadId <= 0 ||
|
|
841
|
+
!target || target.chatId !== binding.target.chatId ||
|
|
842
|
+
!Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
|
|
843
|
+
targetMatches(target, binding.target))
|
|
844
|
+
return undefined;
|
|
845
|
+
const normalized = { operationId: request.operationId, binding, owner,
|
|
846
|
+
target: { chatId: target.chatId, threadId: target.threadId } };
|
|
847
|
+
return isDeepStrictEqual(value, normalized) ? normalized : undefined;
|
|
848
|
+
}
|
|
849
|
+
const restoreInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
850
|
+
const restoreKeys = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
|
|
851
|
+
const isTelegramWorkspaceRestoreExecutor = (value) => restoreObject(value) &&
|
|
852
|
+
restoreKeys(value, ["instanceId", "leaderEpoch"]) && restoreText(value.instanceId) && restoreText(value.leaderEpoch);
|
|
853
|
+
export const isTelegramWorkspaceRestoreRecipient = (value) => restoreObject(value) &&
|
|
854
|
+
restoreKeys(value, ["kind", "instanceId", "sessionId", "generation"]) &&
|
|
855
|
+
(value.kind === "leader" || value.kind === "follower") && restoreText(value.instanceId) && restoreText(value.sessionId) && restoreText(value.generation);
|
|
856
|
+
export function isTelegramWorkspaceRestoreRequest(value) {
|
|
857
|
+
if (!restoreObject(value))
|
|
858
|
+
return false;
|
|
859
|
+
const { source, ...relocation } = value;
|
|
860
|
+
return !!normalizeTelegramWorkspaceRelocationRequest(relocation) && restoreObject(source) &&
|
|
861
|
+
restoreKeys(source, ["journalBindingKey", "updateIds"]) && restoreText(source.journalBindingKey) &&
|
|
862
|
+
Array.isArray(source.updateIds) && source.updateIds.length > 0 &&
|
|
863
|
+
source.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
|
|
864
|
+
}
|
|
865
|
+
function isTelegramWorkspaceRestoreSettlement(value, source) {
|
|
866
|
+
if (!restoreObject(value) || value.journalBindingKey !== source.journalBindingKey || !Array.isArray(value.updateIds) ||
|
|
867
|
+
value.updateIds.length === 0 || !value.updateIds.every((id, index, ids) => restoreInteger(id) && source.updateIds.includes(id) &&
|
|
868
|
+
(index === 0 || id > ids[index - 1])))
|
|
869
|
+
return false;
|
|
870
|
+
return value.kind === "completed" ? restoreKeys(value, ["journalBindingKey", "updateIds", "kind"]) :
|
|
871
|
+
(value.kind === "queued" || value.kind === "queue-completed") && restoreKeys(value, ["journalBindingKey", "updateIds", "kind", "receiptId", "queueKind"]) &&
|
|
872
|
+
restoreText(value.receiptId) && (value.queueKind === "prompt" || value.queueKind === "control");
|
|
873
|
+
}
|
|
874
|
+
function isWorkspaceRestoreSourceAcceptance(value, operation) {
|
|
875
|
+
if (!restoreObject(value) || value.journalBindingKey !== operation.request.source.journalBindingKey || !restoreInteger(value.updateId) ||
|
|
876
|
+
!operation.request.source.updateIds.includes(value.updateId) || typeof value.sourceSha256 !== "string" ||
|
|
877
|
+
!/^[a-f0-9]{64}$/u.test(value.sourceSha256) || !isTelegramWorkspaceRestoreRecipient(value.recipient) ||
|
|
878
|
+
value.recipient.sessionId !== operation.request.binding.sessionId || value.recipient.kind !== operation.recipient?.kind)
|
|
879
|
+
return false;
|
|
880
|
+
const keys = ["journalBindingKey", "updateId", "sourceSha256", "recipient", "kind"];
|
|
881
|
+
if (value.kind === "completed")
|
|
882
|
+
return value.recipient.kind === "leader" && restoreKeys(value, keys);
|
|
883
|
+
if (value.kind === "forwarded")
|
|
884
|
+
return value.recipient.kind === "follower" &&
|
|
885
|
+
restoreKeys(value, [...keys, "deliveryId", "recipientBindingKey"]) && typeof value.deliveryId === "string" &&
|
|
886
|
+
/^telegram-follower-v1-[a-f0-9]{64}$/u.test(value.deliveryId) && restoreText(value.recipientBindingKey);
|
|
887
|
+
return value.kind === "queued" && value.recipient.kind === "leader" &&
|
|
888
|
+
restoreKeys(value, [...keys, "receiptId", "queueKind", "queueOwnerSha256"]) && restoreText(value.receiptId) &&
|
|
889
|
+
(value.queueKind === "prompt" || value.queueKind === "control") && typeof value.queueOwnerSha256 === "string" &&
|
|
890
|
+
/^[a-f0-9]{64}$/u.test(value.queueOwnerSha256);
|
|
891
|
+
}
|
|
892
|
+
/** Stable acceptance scope, not a removal ACK; adoption and mutable progress cannot change it. */
|
|
893
|
+
export function getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) {
|
|
894
|
+
if (!isTelegramWorkspaceRestoreRequest(operation.request) || operation.operatorUserId !== operation.request.target.chatId ||
|
|
895
|
+
!isWorkspaceRestoreSourceAcceptance(acceptance, operation) ||
|
|
896
|
+
!operation.routing?.acceptances?.some(value => isDeepStrictEqual(value, acceptance))) {
|
|
897
|
+
throw new Error("Workspace Restore retained acceptance scope is unavailable.");
|
|
898
|
+
}
|
|
899
|
+
const frame = { kind: "workspace-restore-source-completion-v1", request: operation.request,
|
|
900
|
+
operatorUserId: operation.operatorUserId, acceptance };
|
|
901
|
+
return createHash("sha256").update(JSON.stringify(frame, function (_key, value) {
|
|
902
|
+
return restoreObject(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value;
|
|
903
|
+
})).digest("hex");
|
|
904
|
+
}
|
|
905
|
+
function workspaceRestoreAcceptancesConsistent(acceptances) {
|
|
906
|
+
return acceptances.every((value, index) => (index === 0 || value.updateId > acceptances[index - 1].updateId) &&
|
|
907
|
+
acceptances.slice(0, index).every(previous => {
|
|
908
|
+
if (value.kind === "forwarded" && previous.kind === "forwarded")
|
|
909
|
+
return value.deliveryId !== previous.deliveryId;
|
|
910
|
+
if (value.kind !== "queued" || previous.kind !== "queued" || value.receiptId !== previous.receiptId)
|
|
911
|
+
return true;
|
|
912
|
+
return value.queueKind === previous.queueKind && value.queueOwnerSha256 === previous.queueOwnerSha256 &&
|
|
913
|
+
isDeepStrictEqual(value.recipient, previous.recipient);
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
function workspaceRestoreSettlementMatchesAcceptance(evidence, acceptances) {
|
|
917
|
+
return evidence.updateIds.every(id => {
|
|
918
|
+
const accepted = acceptances.find(value => value.updateId === id);
|
|
919
|
+
if (!accepted)
|
|
920
|
+
return evidence.kind !== "queue-completed"; // Legacy admission is retained, never terminal receipt proof.
|
|
921
|
+
return evidence.kind === "completed" ? accepted.kind !== "queued" : accepted.kind === "queued" &&
|
|
922
|
+
evidence.receiptId === accepted.receiptId && evidence.queueKind === accepted.queueKind;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
function workspaceRestoreSourcesSettled(operation) {
|
|
926
|
+
const settled = new Set(operation.routing?.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
|
|
927
|
+
return operation.request.source.updateIds.every(id => settled.has(id));
|
|
928
|
+
}
|
|
929
|
+
function isWorkspaceRestoreIntent(value) {
|
|
930
|
+
if (!restoreObject(value) || !restoreKeys(value, ["request", "operatorUserId", "executor", "revision", "createdAtMs",
|
|
931
|
+
"updatedAtMs", "phase", "committedAtMs", "recipient", "readyRecipient", "routing"]) || !isTelegramWorkspaceRestoreRequest(value.request) ||
|
|
932
|
+
!restoreInteger(value.operatorUserId) || value.operatorUserId !== value.request.target.chatId ||
|
|
933
|
+
!isTelegramWorkspaceRestoreExecutor(value.executor) || !restoreInteger(value.revision) || !restoreInteger(value.createdAtMs) ||
|
|
934
|
+
!restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs ||
|
|
935
|
+
!restoreInteger(value.committedAtMs))
|
|
936
|
+
return false;
|
|
937
|
+
const source = value.request.source;
|
|
938
|
+
if (value.routing !== undefined) {
|
|
939
|
+
if (value.phase !== "ready" || !restoreObject(value.routing) || !restoreKeys(value.routing, ["acceptances", "settlements", "cleanup"]) ||
|
|
940
|
+
!Array.isArray(value.routing.settlements) || !value.routing.settlements.every(item => isTelegramWorkspaceRestoreSettlement(item, source)))
|
|
941
|
+
return false;
|
|
942
|
+
if (value.routing.acceptances !== undefined) {
|
|
943
|
+
const acceptances = value.routing.acceptances;
|
|
944
|
+
if (!Array.isArray(acceptances) || !acceptances.length ||
|
|
945
|
+
!acceptances.every(item => isWorkspaceRestoreSourceAcceptance(item, value)) ||
|
|
946
|
+
!workspaceRestoreAcceptancesConsistent(acceptances))
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
const acceptances = (value.routing.acceptances ?? []);
|
|
950
|
+
if (!value.routing.settlements.every(item => workspaceRestoreSettlementMatchesAcceptance(item, acceptances)))
|
|
951
|
+
return false;
|
|
952
|
+
const ids = value.routing.settlements.flatMap(item => item.updateIds);
|
|
953
|
+
if (new Set(ids).size !== ids.length || (value.routing.cleanup !== undefined &&
|
|
954
|
+
(!["issued", "completed", "not-issued"].includes(value.routing.cleanup) ||
|
|
955
|
+
!workspaceRestoreSourcesSettled(value))))
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
if (value.readyRecipient !== undefined && (value.phase !== "ready" || !isTelegramWorkspaceRestoreRecipient(value.readyRecipient) ||
|
|
959
|
+
value.readyRecipient.sessionId !== value.request.binding.sessionId))
|
|
960
|
+
return false;
|
|
961
|
+
if (value.phase === "relocated")
|
|
962
|
+
return value.recipient === undefined;
|
|
963
|
+
return (value.phase === "recipient-issued" || value.phase === "ready") && isTelegramWorkspaceRestoreRecipient(value.recipient) &&
|
|
964
|
+
value.recipient.sessionId === value.request.binding.sessionId;
|
|
965
|
+
}
|
|
966
|
+
function isTemporaryThreadInput(value) {
|
|
967
|
+
return restoreObject(value) && restoreKeys(value, ["journalBindingKey", "updateIds"]) && restoreText(value.journalBindingKey) &&
|
|
968
|
+
Array.isArray(value.updateIds) && value.updateIds.length > 0 && value.updateIds.length <= TEMPORARY_THREAD_INPUT_CAPACITY &&
|
|
969
|
+
value.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
|
|
970
|
+
}
|
|
971
|
+
export function getTelegramTemporaryThreadInputs(entry) {
|
|
972
|
+
return structuredClone(entry.inputs ?? [{ journalBindingKey: entry.source.journalBindingKey, updateIds: [entry.source.updateId] }]);
|
|
973
|
+
}
|
|
974
|
+
/** Every known group is durably cancelled or Forward-completed; still not deletion authority by itself. */
|
|
975
|
+
export function isTelegramTemporaryThreadFullyResolved(entry) {
|
|
976
|
+
const inputs = getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
977
|
+
return resolved.length > 0 && inputs.every(input => resolved.some(other => isDeepStrictEqual(other, input)));
|
|
978
|
+
}
|
|
979
|
+
/** One unresolved group may release protection after completion when every other group has a durable terminal fact. */
|
|
980
|
+
function isTemporaryThreadReleasedByCompletion(entry, completed) {
|
|
981
|
+
if (!completed || !isTemporaryThreadInput(completed))
|
|
982
|
+
return false;
|
|
983
|
+
const inputs = getTelegramTemporaryThreadInputs(entry), cancelled = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
984
|
+
const same = (left, right) => isDeepStrictEqual(left, right);
|
|
985
|
+
return inputs.some(input => same(input, completed)) && !cancelled.some(input => same(input, completed)) &&
|
|
986
|
+
inputs.every(input => same(input, completed) || cancelled.some(other => same(other, input)));
|
|
987
|
+
}
|
|
988
|
+
function isTemporaryThreadEntry(value) {
|
|
989
|
+
if (!restoreObject(value) || !restoreKeys(value, ["source", "inputs", "cancelledInputs", "completedInputs", "forwardedInputs", "forwardProtocol", "cleanupIssued", "operatorUserId", "executor", "token", "phase", "target", "revision",
|
|
990
|
+
"createdAtMs", "updatedAtMs"]) || !restoreObject(value.source) || !restoreKeys(value.source, ["journalBindingKey", "updateId"]) ||
|
|
991
|
+
!restoreText(value.source.journalBindingKey) || !restoreInteger(value.source.updateId) ||
|
|
992
|
+
!restoreInteger(value.operatorUserId) || value.operatorUserId === 0 || !isTelegramWorkspaceRestoreExecutor(value.executor) ||
|
|
993
|
+
typeof value.token !== "string" || !/^[a-f0-9]{32}$/u.test(value.token) || !restoreInteger(value.revision) ||
|
|
994
|
+
!restoreInteger(value.createdAtMs) || !restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs)
|
|
995
|
+
return false;
|
|
996
|
+
const source = value.source;
|
|
997
|
+
if (value.forwardProtocol !== undefined && (value.forwardProtocol !== "one-shot-v1" || value.inputs === undefined))
|
|
998
|
+
return false;
|
|
999
|
+
if (value.inputs !== undefined) {
|
|
1000
|
+
const inputs = value.inputs;
|
|
1001
|
+
if (!Array.isArray(inputs) || inputs.length === 0 || inputs.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1002
|
+
!inputs.every(isTemporaryThreadInput) || !isDeepStrictEqual(inputs[0], { journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }) ||
|
|
1003
|
+
inputs.some((input, index) => input.journalBindingKey !== source.journalBindingKey ||
|
|
1004
|
+
inputs.slice(index + 1).some(other => other.updateIds.some(id => input.updateIds.includes(id)))))
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
if (value.cancelledInputs !== undefined) {
|
|
1008
|
+
const cancelled = value.cancelledInputs;
|
|
1009
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1010
|
+
if (!Array.isArray(cancelled) || cancelled.length === 0 || cancelled.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1011
|
+
!cancelled.every(isTemporaryThreadInput) || cancelled.some((input, index) => !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.slice(index + 1).some(other => isDeepStrictEqual(other, input))))
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
if (value.completedInputs !== undefined) {
|
|
1015
|
+
const completed = value.completedInputs;
|
|
1016
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1017
|
+
const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
|
|
1018
|
+
if (!Array.isArray(completed) || completed.length === 0 || completed.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1019
|
+
!completed.every(isTemporaryThreadInput) || completed.some((input, index) => !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
|
|
1020
|
+
completed.slice(index + 1).some(other => isDeepStrictEqual(other, input))))
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
if (value.forwardedInputs !== undefined) {
|
|
1024
|
+
const forwarded = value.forwardedInputs;
|
|
1025
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1026
|
+
const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
|
|
1027
|
+
if (!Array.isArray(forwarded) || forwarded.length === 0 || forwarded.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1028
|
+
!forwarded.every(isTemporaryThreadInput) || forwarded.some((input, index) => !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
|
|
1029
|
+
forwarded.slice(index + 1).some(other => isDeepStrictEqual(other, input))))
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
if (value.cleanupIssued !== undefined && (value.cleanupIssued !== true ||
|
|
1033
|
+
!isTelegramTemporaryThreadFullyResolved(value)))
|
|
1034
|
+
return false;
|
|
1035
|
+
if (value.phase === "creating")
|
|
1036
|
+
return value.target === undefined && value.cancelledInputs === undefined && value.completedInputs === undefined && value.forwardedInputs === undefined && value.cleanupIssued === undefined &&
|
|
1037
|
+
(!Array.isArray(value.inputs) || value.inputs.length === 1);
|
|
1038
|
+
const target = value.target;
|
|
1039
|
+
return value.phase === "created" && restoreObject(target) && restoreKeys(target, ["chatId", "threadId"]) &&
|
|
1040
|
+
target.chatId === value.operatorUserId && Number.isSafeInteger(target.threadId) && target.threadId > 0;
|
|
1041
|
+
}
|
|
1042
|
+
/** Only an exact recorded source group may Restore into its temporary tab. */
|
|
1043
|
+
function isTemporaryThreadRestore(entry, operations) {
|
|
1044
|
+
return operations.some(({ request }) => !!entry.target && targetMatches(request.target, entry.target) &&
|
|
1045
|
+
![...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])].some(input => input.journalBindingKey === request.source.journalBindingKey &&
|
|
1046
|
+
input.updateIds.some(id => request.source.updateIds.includes(id))) &&
|
|
1047
|
+
(entry.inputs ? entry.inputs.some(input => isDeepStrictEqual(input, request.source)) :
|
|
1048
|
+
request.source.journalBindingKey === entry.source.journalBindingKey && request.source.updateIds.includes(entry.source.updateId)));
|
|
1049
|
+
}
|
|
1050
|
+
function conflictsWithTemporaryThread(snapshot, target) {
|
|
1051
|
+
return !!target && !!snapshot?.temporaryThreads?.some(entry => !!entry.target && targetMatches(entry.target, target));
|
|
1052
|
+
}
|
|
1053
|
+
function conflictsWithWorkspaceRestoreProvision(request, provision) {
|
|
1054
|
+
if ("owner" in provision && !provision.workspaceBindingKey &&
|
|
1055
|
+
(provision.profileKey === request.owner.profileKey || provision.instanceId === request.owner.instanceId))
|
|
1056
|
+
return true;
|
|
1057
|
+
return ("workspaceBindingKey" in provision && provision.workspaceBindingKey === request.binding.bindingKey) || provision.slot === request.binding.slot ||
|
|
1058
|
+
!!provision.target && (targetMatches(provision.target, request.binding.target) || targetMatches(provision.target, request.target));
|
|
1059
|
+
}
|
|
1060
|
+
function assertWorkspaceRestoreBindingProtection(file) {
|
|
1061
|
+
for (const { request } of file.workspaceRestore?.operations ?? []) {
|
|
1062
|
+
if (file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
|
|
1063
|
+
file.reservations?.some(value => conflictsWithWorkspaceRestoreProvision(request, value))) {
|
|
1064
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
1065
|
+
}
|
|
1066
|
+
const bindings = file.workspaceBindings ?? [];
|
|
1067
|
+
const retained = bindings.filter(value => value.bindingKey === request.binding.bindingKey);
|
|
1068
|
+
const current = retained[0];
|
|
1069
|
+
const identity = ["bindingKey", "cwd", "workspaceKey", "instanceSlot", "sessionId", "sessionKey", "slot"];
|
|
1070
|
+
if (retained.length !== 1 || !current || identity.some(key => current[key] !== request.binding[key]) ||
|
|
1071
|
+
!targetMatches(current.target, request.target) || bindings.some(value => value !== current &&
|
|
1072
|
+
(value.slot === current.slot || targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target)))) {
|
|
1073
|
+
throw new Error("Protected Workspace Restore binding changed.");
|
|
1074
|
+
}
|
|
1075
|
+
const owners = (file.threads ?? []).filter(value => value.slot === current.slot ||
|
|
1076
|
+
targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target));
|
|
1077
|
+
if (owners.length > 1 || owners.some(value => value.slot !== current.slot || !targetMatches(value.target, request.target))) {
|
|
1078
|
+
throw new Error("Protected Workspace Restore owner target changed.");
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const operations = file.workspaceRestore?.operations ?? [];
|
|
1082
|
+
for (const entry of file.workspaceRestore?.temporaryThreads ?? []) {
|
|
1083
|
+
const target = entry.target;
|
|
1084
|
+
if (!target)
|
|
1085
|
+
continue;
|
|
1086
|
+
const claimed = [...(file.workspaceBindings ?? []), ...(file.threads ?? [])].some(value => targetMatches(value.target, target));
|
|
1087
|
+
if (file.pendingProvisions?.some(value => !!value.target && targetMatches(value.target, target)) ||
|
|
1088
|
+
file.reservations?.some(value => targetMatches(value.target, target)) ||
|
|
1089
|
+
(claimed && !isTemporaryThreadRestore(entry, operations))) {
|
|
1090
|
+
throw new Error("Protected temporary Thread target conflict.");
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function workspaceRestoresConflict(left, right) {
|
|
1095
|
+
const targets = [left.binding.target, left.target];
|
|
1096
|
+
return left.operationId === right.operationId || left.binding.bindingKey === right.binding.bindingKey ||
|
|
1097
|
+
left.binding.slot === right.binding.slot || targets.some(target => targetMatches(target, right.binding.target) || targetMatches(target, right.target)) ||
|
|
1098
|
+
(left.source.journalBindingKey === right.source.journalBindingKey && right.source.updateIds.some(id => left.source.updateIds.includes(id)));
|
|
1099
|
+
}
|
|
1100
|
+
const WORKSPACE_RESTORE_MAX_BYTES = 1024 * 1024;
|
|
1101
|
+
const TEMPORARY_THREAD_CAPACITY = 26;
|
|
1102
|
+
const TEMPORARY_THREAD_INPUT_CAPACITY = 100;
|
|
1103
|
+
const WORKSPACE_SNAPSHOT_MAX_BYTES = 8 * WORKSPACE_RESTORE_MAX_BYTES;
|
|
1104
|
+
function parseWorkspaceRestore(value) {
|
|
1105
|
+
if (value === undefined)
|
|
1106
|
+
return undefined;
|
|
1107
|
+
const temporary = restoreObject(value) ? value.temporaryThreads : undefined;
|
|
1108
|
+
if (!restoreObject(value) || !restoreKeys(value, ["version", "profileName", "tokenSha256", "revision", "operations", "temporaryThreads"]) ||
|
|
1109
|
+
value.version !== 1 || !restoreText(value.profileName) || typeof value.tokenSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.tokenSha256) ||
|
|
1110
|
+
!restoreInteger(value.revision) || !Array.isArray(value.operations) || value.operations.length > 26 ||
|
|
1111
|
+
(temporary !== undefined && (!Array.isArray(temporary) || temporary.length === 0 ||
|
|
1112
|
+
temporary.length > TEMPORARY_THREAD_CAPACITY || !temporary.every(isTemporaryThreadEntry))) ||
|
|
1113
|
+
((value.operations.length > 0 || temporary !== undefined) && value.revision === 0) || !value.operations.every(isWorkspaceRestoreIntent) ||
|
|
1114
|
+
Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_RESTORE_MAX_BYTES)
|
|
1115
|
+
throw new Error("Invalid Workspace Restore evidence.");
|
|
1116
|
+
const snapshot = value;
|
|
1117
|
+
if (snapshot.operations.some((operation, index) => snapshot.operations.slice(index + 1).some(other => workspaceRestoresConflict(operation.request, other.request))))
|
|
1118
|
+
throw new Error("Conflicting Workspace Restore evidence.");
|
|
1119
|
+
const entries = snapshot.temporaryThreads ?? [];
|
|
1120
|
+
if (entries.some((entry, index) => entries.slice(index + 1).some(other => other.token === entry.token ||
|
|
1121
|
+
getTelegramTemporaryThreadInputs(entry).some(input => getTelegramTemporaryThreadInputs(other).some(candidate => input.journalBindingKey === candidate.journalBindingKey && input.updateIds.some(id => candidate.updateIds.includes(id)))) ||
|
|
1122
|
+
(!!entry.target && !!other.target && targetMatches(entry.target, other.target)))) ||
|
|
1123
|
+
entries.some(entry => !!entry.target && snapshot.operations.some(({ request }) => targetMatches(request.binding.target, entry.target) ||
|
|
1124
|
+
(targetMatches(request.target, entry.target) && !isTemporaryThreadRestore(entry, [{ request }])))))
|
|
1125
|
+
throw new Error("Conflicting Workspace Restore evidence.");
|
|
1126
|
+
return snapshot;
|
|
1127
|
+
}
|
|
1128
|
+
/** All Restore reads and publishers share these physical-file checks; there is no repairing read. */
|
|
1129
|
+
const readWorkspaceDescriptor = promisify(readFileCallback);
|
|
1130
|
+
function readWorkspaceSnapshot(path, requirePrivate = true, asynchronous = false) {
|
|
1131
|
+
let before;
|
|
1132
|
+
try {
|
|
1133
|
+
before = lstatSync(path, { bigint: true });
|
|
1134
|
+
}
|
|
1135
|
+
catch (error) {
|
|
1136
|
+
if (error.code === "ENOENT")
|
|
1137
|
+
return undefined;
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
1140
|
+
const uid = process.getuid?.();
|
|
1141
|
+
const privateFile = uid === undefined || (before.uid === BigInt(uid) && (before.mode & 63n) === 0n);
|
|
1142
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > BigInt(WORKSPACE_SNAPSHOT_MAX_BYTES) ||
|
|
1143
|
+
(requirePrivate && !privateFile))
|
|
1144
|
+
throw new Error("Workspace snapshot must be a bounded private regular file.");
|
|
1145
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
1146
|
+
let closeImmediately = true;
|
|
1147
|
+
try {
|
|
1148
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
1149
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs)
|
|
1150
|
+
throw new Error("Workspace snapshot changed during inspection.");
|
|
1151
|
+
const decode = (content) => {
|
|
1152
|
+
const value = JSON.parse(content);
|
|
1153
|
+
// Legacy metadata may predate private storage; a Restore-bearing snapshot never may.
|
|
1154
|
+
if (!privateFile && restoreObject(value) && value.workspaceRestore !== undefined)
|
|
1155
|
+
throw new Error("Workspace snapshot must be a bounded private regular file.");
|
|
1156
|
+
return value;
|
|
1157
|
+
};
|
|
1158
|
+
if (asynchronous) {
|
|
1159
|
+
const reading = readWorkspaceDescriptor(fd, "utf8").then(decode);
|
|
1160
|
+
closeImmediately = false;
|
|
1161
|
+
return reading.finally(() => closeSync(fd));
|
|
1162
|
+
}
|
|
1163
|
+
return decode(readFileSync(fd, "utf8"));
|
|
1164
|
+
}
|
|
1165
|
+
finally {
|
|
1166
|
+
if (closeImmediately)
|
|
1167
|
+
closeSync(fd);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
909
1170
|
function parseTopicTargetFile(value) {
|
|
910
1171
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
911
1172
|
return {
|
|
@@ -917,6 +1178,13 @@ function parseTopicTargetFile(value) {
|
|
|
917
1178
|
};
|
|
918
1179
|
}
|
|
919
1180
|
const file = value;
|
|
1181
|
+
// The unpublished two-file draft is not migration or source-reconstruction authority.
|
|
1182
|
+
if ((file.workspaceRelocations !== undefined && (!Array.isArray(file.workspaceRelocations) || file.workspaceRelocations.length !== 0)) ||
|
|
1183
|
+
(file.workspaceRelocationRevision !== undefined && file.workspaceRelocationRevision !== 0))
|
|
1184
|
+
throw new Error("Unmigrated Workspace relocation evidence.");
|
|
1185
|
+
const workspaceRestore = parseWorkspaceRestore(file.workspaceRestore);
|
|
1186
|
+
if (file.version !== 1 && workspaceRestore)
|
|
1187
|
+
throw new Error("Unsupported Workspace Restore snapshot.");
|
|
920
1188
|
if (file.version !== 1) {
|
|
921
1189
|
return {
|
|
922
1190
|
version: 1,
|
|
@@ -945,6 +1213,8 @@ function parseTopicTargetFile(value) {
|
|
|
945
1213
|
workspaceBindings: Array.isArray(file.workspaceBindings)
|
|
946
1214
|
? file.workspaceBindings.flatMap((binding) => {
|
|
947
1215
|
const normalized = normalizeWorkspaceBindingRecord(binding);
|
|
1216
|
+
if (!normalized && binding && typeof binding === "object" && "journalSources" in binding)
|
|
1217
|
+
throw new Error("Invalid Workspace session journal evidence.");
|
|
948
1218
|
return normalized ? [normalized] : [];
|
|
949
1219
|
})
|
|
950
1220
|
: [],
|
|
@@ -954,6 +1224,7 @@ function parseTopicTargetFile(value) {
|
|
|
954
1224
|
return normalized ? [normalized] : [];
|
|
955
1225
|
})
|
|
956
1226
|
: [],
|
|
1227
|
+
...(workspaceRestore ? { workspaceRestore } : {}),
|
|
957
1228
|
sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
|
|
958
1229
|
reservations: Array.isArray(file.reservations)
|
|
959
1230
|
? file.reservations.flatMap((reservation) => {
|
|
@@ -981,18 +1252,91 @@ function parseTopicTargetFile(value) {
|
|
|
981
1252
|
: [],
|
|
982
1253
|
};
|
|
983
1254
|
}
|
|
1255
|
+
/** The unified section accepts only lossless current-format Workspace evidence, never tolerant legacy repair. */
|
|
1256
|
+
export function parseTelegramWorkspaceStateSection(value, profile) {
|
|
1257
|
+
if (value === undefined)
|
|
1258
|
+
return undefined;
|
|
1259
|
+
if (!restoreObject(value) || value.version !== 1 || value.source !== "snapshot" ||
|
|
1260
|
+
!Number.isSafeInteger(value.writtenAtMs) || value.writtenAtMs < 0 ||
|
|
1261
|
+
!restoreObject(value.bot) || !Array.isArray(value.threads))
|
|
1262
|
+
throw new Error("Invalid consolidated Workspace section.");
|
|
1263
|
+
if (Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_SNAPSHOT_MAX_BYTES)
|
|
1264
|
+
throw new Error("Workspace snapshot byte capacity reached.");
|
|
1265
|
+
const parsed = parseTopicTargetFile(value), rawThreads = value.threads;
|
|
1266
|
+
const wire = JSON.parse(JSON.stringify(parsed));
|
|
1267
|
+
wire.threads = wire.threads.map((record, index) => {
|
|
1268
|
+
const copied = { ...record };
|
|
1269
|
+
const original = rawThreads[index];
|
|
1270
|
+
if (!restoreObject(original))
|
|
1271
|
+
throw new Error("Invalid consolidated Workspace thread evidence.");
|
|
1272
|
+
if (!Object.hasOwn(original, "profileKey"))
|
|
1273
|
+
delete copied.profileKey;
|
|
1274
|
+
return copied;
|
|
1275
|
+
});
|
|
1276
|
+
for (const key of Object.keys(value)) {
|
|
1277
|
+
if (!Object.hasOwn(wire, key) || !isDeepStrictEqual(value[key], wire[key]))
|
|
1278
|
+
throw new Error("Consolidated Workspace evidence cannot drop or normalize unknown facts.");
|
|
1279
|
+
}
|
|
1280
|
+
if ((parsed.workspaceRestore && parsed.workspaceRestore.profileName !== profile) ||
|
|
1281
|
+
(parsed.sessionReplacement && parsed.sessionReplacement.profileName !== profile) ||
|
|
1282
|
+
parsed.threads.some(record => record.owner && "telegramProfile" in record.owner &&
|
|
1283
|
+
record.owner.telegramProfile !== undefined && record.owner.telegramProfile !== profile))
|
|
1284
|
+
throw new Error("Foreign consolidated Workspace profile evidence.");
|
|
1285
|
+
assertWorkspaceRestoreBindingProtection(parsed);
|
|
1286
|
+
return parsed;
|
|
1287
|
+
}
|
|
1288
|
+
export function resolveTelegramWorkspaceProvisionRecoveryPath(statePath, profileName = "default", layout) {
|
|
1289
|
+
if (layout !== undefined && layout !== "consolidated")
|
|
1290
|
+
throw new Error("Invalid Workspace provisioning recovery layout.");
|
|
1291
|
+
return layout === "consolidated"
|
|
1292
|
+
? join(dirname(statePath), "runtime", `${basename(statePath)}.provision-recovery.${createHash("sha256").update(profileName).digest("hex").slice(0, 16)}.json`)
|
|
1293
|
+
: `${statePath}.provision-recovery.json`;
|
|
1294
|
+
}
|
|
1295
|
+
/** Prepared Workspace IO adapter; transition/CAS policy remains with the existing Threads owner. */
|
|
1296
|
+
export function createTelegramConsolidatedWorkspaceStorage(options) {
|
|
1297
|
+
const { getPath, getProfile, captureAuthority, publishIfOwned } = options;
|
|
1298
|
+
const profileName = () => getProfile() ?? "default";
|
|
1299
|
+
const readRaw = () => {
|
|
1300
|
+
const path = getPath(), profile = profileName(), root = readTelegramRuntimeState(path);
|
|
1301
|
+
const raw = Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.workspace : undefined;
|
|
1302
|
+
parseTelegramWorkspaceStateSection(raw, profile);
|
|
1303
|
+
if (path !== getPath() || profile !== profileName())
|
|
1304
|
+
throw new Error("Workspace storage scope changed during observation.");
|
|
1305
|
+
return raw;
|
|
1306
|
+
};
|
|
1307
|
+
return {
|
|
1308
|
+
readRaw,
|
|
1309
|
+
read: () => parseTelegramWorkspaceStateSection(readRaw(), profileName()),
|
|
1310
|
+
capturePublication() {
|
|
1311
|
+
const path = getPath(), profile = profileName(), authority = captureAuthority();
|
|
1312
|
+
if (!authority)
|
|
1313
|
+
return undefined;
|
|
1314
|
+
const isCurrent = () => path === getPath() && profile === profileName() && authority() === true;
|
|
1315
|
+
if (!isCurrent())
|
|
1316
|
+
return undefined;
|
|
1317
|
+
return (mutate, publication) => {
|
|
1318
|
+
const sourceCurrent = publication?.isCurrent;
|
|
1319
|
+
const currentGrant = () => isCurrent() && (sourceCurrent?.() ?? true);
|
|
1320
|
+
if (!currentGrant())
|
|
1321
|
+
return { committed: false };
|
|
1322
|
+
return publishIfOwned("workspace", current => {
|
|
1323
|
+
parseTelegramWorkspaceStateSection(current, profile);
|
|
1324
|
+
const next = mutate(current);
|
|
1325
|
+
if (next.value === undefined && current !== undefined)
|
|
1326
|
+
throw new Error("Consolidated Workspace section removal is not authorized.");
|
|
1327
|
+
parseTelegramWorkspaceStateSection(next.value, profile);
|
|
1328
|
+
return next;
|
|
1329
|
+
}, { ...publication, isCurrent: currentGrant, expectedScope: { path, profile } });
|
|
1330
|
+
};
|
|
1331
|
+
},
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
984
1334
|
function getTelegramStateSemanticSnapshot(value) {
|
|
985
1335
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
986
1336
|
return undefined;
|
|
987
1337
|
const { writtenAtMs: _writtenAtMs, ...semantic } = value;
|
|
988
1338
|
return semantic;
|
|
989
1339
|
}
|
|
990
|
-
function targetMatches(left, right) {
|
|
991
|
-
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
992
|
-
}
|
|
993
|
-
function getTargetRecoveryHintKey(target) {
|
|
994
|
-
return `${target.chatId}:${target.threadId ?? "private"}`;
|
|
995
|
-
}
|
|
996
1340
|
function parseFollowerRecoveryHints(value) {
|
|
997
1341
|
const hints = new Map();
|
|
998
1342
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -1056,7 +1400,32 @@ function isPendingProvisionLiveOrTargeted(provision, nowMs) {
|
|
|
1056
1400
|
}
|
|
1057
1401
|
return !!provision.target;
|
|
1058
1402
|
}
|
|
1403
|
+
/** Resolving selects a scope-bound view of the existing Workspace owner; it creates no files. */
|
|
1404
|
+
export function createTelegramWorkspaceRestoreResolver(deps) {
|
|
1405
|
+
const { getProfileName, getBotToken, threadStore, agentDir } = deps;
|
|
1406
|
+
let active;
|
|
1407
|
+
return () => {
|
|
1408
|
+
const profile = getProfileName();
|
|
1409
|
+
const profileName = profile ?? "default";
|
|
1410
|
+
const token = getBotToken();
|
|
1411
|
+
if (!token)
|
|
1412
|
+
return undefined;
|
|
1413
|
+
const tokenSha256 = createHash("sha256").update(token).digest("hex");
|
|
1414
|
+
if (active?.profileName === profileName && active.tokenSha256 === tokenSha256)
|
|
1415
|
+
return active.store;
|
|
1416
|
+
const store = threadStore.workspaceRestore({ profileName, tokenSha256,
|
|
1417
|
+
legacyPath: resolveTelegramProfileTempFilePath("workspace-restore", "json", agentDir, profile),
|
|
1418
|
+
isCurrentScope: () => (getProfileName() ?? "default") === profileName &&
|
|
1419
|
+
createHash("sha256").update(getBotToken() ?? "").digest("hex") === tokenSha256 });
|
|
1420
|
+
active = { profileName, tokenSha256, store };
|
|
1421
|
+
return store;
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1059
1424
|
export function createTelegramTopicTargetStore(options) {
|
|
1425
|
+
if (options.consolidated && options.commitPersist)
|
|
1426
|
+
throw new Error("Workspace must select one publication backend.");
|
|
1427
|
+
const consolidated = options.consolidated ? { ...options.consolidated } : undefined;
|
|
1428
|
+
const commitPersist = options.commitPersist?.bind(options);
|
|
1060
1429
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
1061
1430
|
const captureExternalReservedSlots = () => {
|
|
1062
1431
|
try {
|
|
@@ -1076,6 +1445,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1076
1445
|
let identities = new Map();
|
|
1077
1446
|
let workspaceBindings = new Map();
|
|
1078
1447
|
let workspaceRetirements = [];
|
|
1448
|
+
let workspaceRestore;
|
|
1079
1449
|
let sessionReplacement;
|
|
1080
1450
|
let workspaceRetirementCommitInFlight = false;
|
|
1081
1451
|
const hasWorkspaceRetirementConflict = (input) => workspaceRetirements.some((intent) => (input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
|
|
@@ -1090,10 +1460,14 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1090
1460
|
let followerRecoveryHints = new Map();
|
|
1091
1461
|
let loaded = false;
|
|
1092
1462
|
let loadedPath;
|
|
1463
|
+
let loadedProfile;
|
|
1464
|
+
let observedWorkspaceSemantic;
|
|
1093
1465
|
let dirty = false;
|
|
1094
1466
|
let mutationRevision = 0;
|
|
1095
|
-
let statusRevision = 0;
|
|
1096
1467
|
let persistQueue = Promise.resolve();
|
|
1468
|
+
let statusQueue = Promise.resolve();
|
|
1469
|
+
let lastStatusSemantic;
|
|
1470
|
+
let lastStatusPath;
|
|
1097
1471
|
let statusSnapshot = {};
|
|
1098
1472
|
const reconcileWorkspaceSuffixExposure = () => {
|
|
1099
1473
|
const directoryCounts = new Map();
|
|
@@ -1165,6 +1539,11 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1165
1539
|
.sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
|
|
1166
1540
|
};
|
|
1167
1541
|
const getPath = () => typeof options.path === "function" ? options.path() : options.path;
|
|
1542
|
+
/** The non-canonical projection sits beside canonical state (`state*.json` → `status*.json`). */
|
|
1543
|
+
const getStatusPath = () => {
|
|
1544
|
+
const statePath = getPath(), name = basename(statePath);
|
|
1545
|
+
return join(dirname(statePath), /^state/u.test(name) ? name.replace(/^state/u, "status") : `${name}.status`);
|
|
1546
|
+
};
|
|
1168
1547
|
const getTelegramProfile = () => typeof options.telegramProfile === "function"
|
|
1169
1548
|
? options.telegramProfile()
|
|
1170
1549
|
: options.telegramProfile;
|
|
@@ -1178,9 +1557,72 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1178
1557
|
}
|
|
1179
1558
|
return { ...owner, telegramProfile };
|
|
1180
1559
|
};
|
|
1181
|
-
const
|
|
1182
|
-
const
|
|
1560
|
+
const activeProfile = () => getTelegramProfile() ?? "default";
|
|
1561
|
+
const scopeMatches = (path, profile) => getPath() === path && activeProfile() === profile;
|
|
1562
|
+
const loadedScopeMatches = (path) => loadedPath === path && (!consolidated || loadedProfile === activeProfile());
|
|
1563
|
+
const workspaceIO = consolidated ? createTelegramConsolidatedWorkspaceStorage({ getPath, getProfile: getTelegramProfile, ...consolidated }) : undefined;
|
|
1564
|
+
const runtimeProjection = consolidated ? createTelegramRuntimeProjectionStore({ getPath, getProfile: getTelegramProfile,
|
|
1565
|
+
captureAuthority: consolidated.captureAuthority, getNowMs,
|
|
1566
|
+
storage: {
|
|
1567
|
+
read({ path, profile }) { const root = readTelegramRuntimeState(path); return Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.runtime : undefined; },
|
|
1568
|
+
publish(expectedScope, mutate, isCurrent) {
|
|
1569
|
+
const outcome = consolidated.publishIfOwned("runtime", current => {
|
|
1570
|
+
const next = mutate(current);
|
|
1571
|
+
return { value: next.value, result: next.changed };
|
|
1572
|
+
}, { isCurrent, expectedScope });
|
|
1573
|
+
return outcome.committed && outcome.result;
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
}) : undefined;
|
|
1577
|
+
let workspaceFrame;
|
|
1578
|
+
const readStoreSnapshot = (path, requirePrivate = true, asynchronous = false) => {
|
|
1579
|
+
if (!workspaceIO)
|
|
1580
|
+
return readWorkspaceSnapshot(path, requirePrivate, asynchronous);
|
|
1581
|
+
if (path !== getPath())
|
|
1582
|
+
throw new Error("Workspace storage scope changed during observation.");
|
|
1583
|
+
if (workspaceFrame) {
|
|
1584
|
+
if (!scopeMatches(workspaceFrame.path, workspaceFrame.profile))
|
|
1585
|
+
throw new Error("Workspace transaction scope changed.");
|
|
1586
|
+
return workspaceFrame.value;
|
|
1587
|
+
}
|
|
1588
|
+
const value = workspaceIO.readRaw();
|
|
1589
|
+
return asynchronous ? Promise.resolve(value) : value;
|
|
1590
|
+
};
|
|
1591
|
+
const withStoreTransaction = (path, operation, publication) => {
|
|
1592
|
+
if (!workspaceIO)
|
|
1593
|
+
return withTelegramFileTransaction(`${path}.transaction`, operation);
|
|
1594
|
+
if (workspaceFrame)
|
|
1595
|
+
throw new Error("Nested Workspace transaction is not allowed.");
|
|
1596
|
+
const profile = activeProfile(), publish = workspaceIO.capturePublication();
|
|
1597
|
+
if (!publish || path !== getPath())
|
|
1598
|
+
throw new Error("Workspace publication authority changed.");
|
|
1599
|
+
const outcome = publish(value => {
|
|
1600
|
+
workspaceFrame = { path, profile, value };
|
|
1601
|
+
try {
|
|
1602
|
+
const result = operation();
|
|
1603
|
+
return { value: workspaceFrame.value, result };
|
|
1604
|
+
}
|
|
1605
|
+
finally {
|
|
1606
|
+
workspaceFrame = undefined;
|
|
1607
|
+
}
|
|
1608
|
+
}, publication);
|
|
1609
|
+
if (!outcome.committed)
|
|
1610
|
+
throw new Error("Workspace publication authority changed.");
|
|
1611
|
+
return outcome.result;
|
|
1612
|
+
};
|
|
1613
|
+
const getRecoveryPath = (path) => consolidated
|
|
1614
|
+
? resolveTelegramWorkspaceProvisionRecoveryPath(path, activeProfile(), "consolidated")
|
|
1615
|
+
: resolveTelegramWorkspaceProvisionRecoveryPath(path);
|
|
1616
|
+
const readProvisionRecoveries = (path, strict = false) => {
|
|
1183
1617
|
const recoveryPath = getRecoveryPath(path);
|
|
1618
|
+
if (strict) {
|
|
1619
|
+
const value = readWorkspaceSnapshot(recoveryPath);
|
|
1620
|
+
if (value === undefined)
|
|
1621
|
+
return {};
|
|
1622
|
+
if (!restoreObject(value))
|
|
1623
|
+
throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
1624
|
+
return value;
|
|
1625
|
+
}
|
|
1184
1626
|
if (!existsSync(recoveryPath))
|
|
1185
1627
|
return {};
|
|
1186
1628
|
try {
|
|
@@ -1193,14 +1635,43 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1193
1635
|
return {};
|
|
1194
1636
|
}
|
|
1195
1637
|
};
|
|
1638
|
+
const assertRestoreRecoveryProtection = (path, operations, provisions, requireKnownTargets = false, recoveryEvidence) => {
|
|
1639
|
+
if (!operations.length)
|
|
1640
|
+
return;
|
|
1641
|
+
const recoveries = recoveryEvidence ?? readProvisionRecoveries(path, true);
|
|
1642
|
+
for (const provision of provisions) {
|
|
1643
|
+
let target = provision.target;
|
|
1644
|
+
if (Object.hasOwn(recoveries, provision.id)) {
|
|
1645
|
+
const recovery = recoveries[provision.id];
|
|
1646
|
+
if (!restoreObject(recovery))
|
|
1647
|
+
throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
1648
|
+
if (recovery.instanceId === provision.instanceId && recovery.profileKey === provision.profileKey &&
|
|
1649
|
+
recovery.leaderEpoch === provision.leaderEpoch) {
|
|
1650
|
+
const observed = recovery.target;
|
|
1651
|
+
if (!restoreObject(observed) || typeof observed.chatId !== "number" || !Number.isSafeInteger(observed.chatId) ||
|
|
1652
|
+
!restoreInteger(observed.threadId) || observed.threadId === 0)
|
|
1653
|
+
throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
1654
|
+
const recovered = { chatId: observed.chatId, threadId: observed.threadId };
|
|
1655
|
+
if (target && !targetMatches(target, recovered))
|
|
1656
|
+
throw new Error("Conflicting Workspace provisioning target evidence.");
|
|
1657
|
+
target = recovered;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (requireKnownTargets && !target)
|
|
1661
|
+
throw new Error("Workspace Restore target availability is unknown during unfinished creation.");
|
|
1662
|
+
if (operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, { ...provision, target })))
|
|
1663
|
+
throw new Error("Protected Workspace Restore provisioning recovery conflict.");
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1196
1666
|
const resetForPath = (path) => {
|
|
1197
|
-
if (
|
|
1667
|
+
if (loadedScopeMatches(path))
|
|
1198
1668
|
return;
|
|
1199
1669
|
botState = { threadMode: "unknown" };
|
|
1200
1670
|
records = new Map();
|
|
1201
1671
|
identities = new Map();
|
|
1202
1672
|
workspaceBindings = new Map();
|
|
1203
1673
|
workspaceRetirements = [];
|
|
1674
|
+
workspaceRestore = undefined;
|
|
1204
1675
|
sessionReplacement = undefined;
|
|
1205
1676
|
workspaceRetirementCommitInFlight = false;
|
|
1206
1677
|
workspaceClaims = new Map();
|
|
@@ -1213,16 +1684,62 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1213
1684
|
loaded = false;
|
|
1214
1685
|
dirty = false;
|
|
1215
1686
|
loadedPath = path;
|
|
1687
|
+
loadedProfile = activeProfile();
|
|
1688
|
+
observedWorkspaceSemantic = undefined;
|
|
1216
1689
|
};
|
|
1217
|
-
const
|
|
1690
|
+
const observeWorkspaceRestore = (incoming) => {
|
|
1691
|
+
if (workspaceRestore && (!incoming || incoming.profileName !== workspaceRestore.profileName ||
|
|
1692
|
+
incoming.tokenSha256 !== workspaceRestore.tokenSha256 || incoming.revision < workspaceRestore.revision ||
|
|
1693
|
+
(incoming.revision === workspaceRestore.revision && !isDeepStrictEqual(incoming, workspaceRestore))))
|
|
1694
|
+
throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
|
|
1695
|
+
};
|
|
1696
|
+
const withRestoreRegistration = (candidate, publish) => {
|
|
1218
1697
|
const path = getPath();
|
|
1698
|
+
withStoreTransaction(path, () => {
|
|
1699
|
+
const file = parseTopicTargetFile(readStoreSnapshot(path, false));
|
|
1700
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
1701
|
+
const operations = file.workspaceRestore?.operations ?? [];
|
|
1702
|
+
if (operations.length) {
|
|
1703
|
+
if ((options.canPersist && !options.canPersist()) || getPath() !== path)
|
|
1704
|
+
throw new Error("Workspace Restore registration authority changed.");
|
|
1705
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
1706
|
+
assertRestoreRecoveryProtection(path, operations, file.pendingProvisions ?? []);
|
|
1707
|
+
for (const { request } of operations) {
|
|
1708
|
+
if (targetMatches(candidate.target, request.binding.target) ||
|
|
1709
|
+
((candidate.bindingKey === request.binding.bindingKey || candidate.slot === request.binding.slot ||
|
|
1710
|
+
targetMatches(candidate.target, request.target)) &&
|
|
1711
|
+
(candidate.bindingKey !== request.binding.bindingKey || candidate.slot !== request.binding.slot ||
|
|
1712
|
+
!targetMatches(candidate.target, request.target))))
|
|
1713
|
+
throw new Error("Protected Workspace Restore registration conflicts with its binding or target.");
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (publish && candidate.bindingKey !== undefined) {
|
|
1717
|
+
const bindings = (file.workspaceBindings ?? []).filter(binding => binding.bindingKey === candidate.bindingKey);
|
|
1718
|
+
if (bindings.length !== 1 || bindings[0]?.slot !== candidate.slot || !targetMatches(bindings[0].target, candidate.target))
|
|
1719
|
+
throw new Error("Workspace registration binding changed before publication.");
|
|
1720
|
+
}
|
|
1721
|
+
publish?.();
|
|
1722
|
+
});
|
|
1723
|
+
};
|
|
1724
|
+
const loadFromDisk = async () => {
|
|
1725
|
+
const path = getPath(), profile = activeProfile();
|
|
1219
1726
|
resetForPath(path);
|
|
1220
|
-
|
|
1727
|
+
const revision = mutationRevision;
|
|
1728
|
+
const rawFile = await readStoreSnapshot(path, !!workspaceRestore, true);
|
|
1729
|
+
// A read begun before a local mutation cannot replace the newly admitted projection.
|
|
1730
|
+
if (mutationRevision !== revision || getPath() !== path || (consolidated && !scopeMatches(path, profile)))
|
|
1731
|
+
return;
|
|
1732
|
+
if (consolidated)
|
|
1733
|
+
observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(rawFile);
|
|
1734
|
+
if (rawFile === undefined) {
|
|
1735
|
+
if (workspaceRestore?.revision)
|
|
1736
|
+
throw new Error("Workspace Restore evidence disappeared.");
|
|
1221
1737
|
botState = { threadMode: "unknown" };
|
|
1222
1738
|
records = new Map();
|
|
1223
1739
|
identities = new Map();
|
|
1224
1740
|
workspaceBindings = new Map();
|
|
1225
1741
|
workspaceRetirements = [];
|
|
1742
|
+
workspaceRestore = undefined;
|
|
1226
1743
|
sessionReplacement = undefined;
|
|
1227
1744
|
workspaceRetirementCommitInFlight = false;
|
|
1228
1745
|
reservations = [];
|
|
@@ -1233,15 +1750,18 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1233
1750
|
loaded = true;
|
|
1234
1751
|
return;
|
|
1235
1752
|
}
|
|
1236
|
-
const revision = mutationRevision;
|
|
1237
|
-
const content = await readFile(path, "utf8");
|
|
1238
|
-
// A read begun before a local mutation must not replace the newly admitted
|
|
1239
|
-
// binding/cleanup state with its older disk snapshot.
|
|
1240
|
-
if (mutationRevision !== revision || getPath() !== path)
|
|
1241
|
-
return;
|
|
1242
|
-
const rawFile = JSON.parse(content);
|
|
1243
1753
|
const file = parseTopicTargetFile(rawFile);
|
|
1244
|
-
|
|
1754
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
1755
|
+
const recoveries = readProvisionRecoveries(path, !!file.workspaceRestore?.operations.length);
|
|
1756
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? [], false, recoveries);
|
|
1757
|
+
try {
|
|
1758
|
+
followerRecoveryHints = parseFollowerRecoveryHints(consolidated
|
|
1759
|
+
? runtimeProjection?.read()
|
|
1760
|
+
: JSON.parse(readFileSync(getStatusPath(), "utf8")));
|
|
1761
|
+
}
|
|
1762
|
+
catch {
|
|
1763
|
+
followerRecoveryHints = new Map();
|
|
1764
|
+
}
|
|
1245
1765
|
botState = file.bot;
|
|
1246
1766
|
const scopedRecords = file.threads.map((record) => cloneRecord({
|
|
1247
1767
|
...record,
|
|
@@ -1261,6 +1781,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1261
1781
|
cloneWorkspaceBinding(binding),
|
|
1262
1782
|
]));
|
|
1263
1783
|
workspaceRetirements = (file.workspaceRetirements ?? []).map(cloneWorkspaceRetirementIntent);
|
|
1784
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
1264
1785
|
sessionReplacement = file.sessionReplacement
|
|
1265
1786
|
? cloneSessionReplacementIntent(file.sessionReplacement)
|
|
1266
1787
|
: undefined;
|
|
@@ -1272,9 +1793,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1272
1793
|
.filter((reservation) => reservation.expiresAtMs === undefined ||
|
|
1273
1794
|
reservation.expiresAtMs > nowMs)
|
|
1274
1795
|
.map((reservation) => ({ ...reservation }));
|
|
1275
|
-
const recoveries = readProvisionRecoveries(path);
|
|
1276
1796
|
pendingProvisions = (file.pendingProvisions ?? [])
|
|
1277
|
-
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
1278
1797
|
.map((provision) => {
|
|
1279
1798
|
const recovery = recoveries[provision.id];
|
|
1280
1799
|
const recoveryMatches = recovery?.instanceId === provision.instanceId &&
|
|
@@ -1289,7 +1808,8 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1289
1808
|
? { target: { ...provision.target } }
|
|
1290
1809
|
: {}),
|
|
1291
1810
|
};
|
|
1292
|
-
})
|
|
1811
|
+
})
|
|
1812
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs));
|
|
1293
1813
|
pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
|
|
1294
1814
|
...intent,
|
|
1295
1815
|
target: { ...intent.target },
|
|
@@ -1302,6 +1822,12 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1302
1822
|
dirty = false;
|
|
1303
1823
|
};
|
|
1304
1824
|
const markDirty = () => {
|
|
1825
|
+
if (consolidated && !loadedScopeMatches(getPath())) {
|
|
1826
|
+
if (loadedPath !== undefined)
|
|
1827
|
+
throw new Error("Workspace projection must refresh after scope change.");
|
|
1828
|
+
loadedPath = getPath();
|
|
1829
|
+
loadedProfile = activeProfile();
|
|
1830
|
+
}
|
|
1305
1831
|
loaded = true;
|
|
1306
1832
|
dirty = true;
|
|
1307
1833
|
mutationRevision += 1;
|
|
@@ -1318,10 +1844,14 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1318
1844
|
}
|
|
1319
1845
|
return false;
|
|
1320
1846
|
};
|
|
1321
|
-
const persistSnapshot = (transition) => {
|
|
1847
|
+
const persistSnapshot = (transition, isExactJournalFrame) => {
|
|
1848
|
+
const submittedPath = getPath(), submittedProfile = activeProfile();
|
|
1849
|
+
const publishWorkspace = workspaceIO?.capturePublication();
|
|
1322
1850
|
const persist = persistQueue.then(async () => {
|
|
1323
1851
|
const path = getPath();
|
|
1324
|
-
if (
|
|
1852
|
+
if (workspaceIO && (!publishWorkspace || !scopeMatches(submittedPath, submittedProfile) || (dirty && !loadedScopeMatches(path))))
|
|
1853
|
+
return false;
|
|
1854
|
+
if (!loadedScopeMatches(path) && !dirty)
|
|
1325
1855
|
resetForPath(path);
|
|
1326
1856
|
if (options.canPersist && !options.canPersist()) {
|
|
1327
1857
|
if (!transition)
|
|
@@ -1330,6 +1860,8 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1330
1860
|
}
|
|
1331
1861
|
if (!dirty || !loaded)
|
|
1332
1862
|
await loadFromDisk();
|
|
1863
|
+
if (workspaceIO && (!scopeMatches(submittedPath, submittedProfile) || !loadedScopeMatches(path)))
|
|
1864
|
+
return false;
|
|
1333
1865
|
if (transition && !transition.isCurrent())
|
|
1334
1866
|
return false;
|
|
1335
1867
|
const nowMs = getNowMs();
|
|
@@ -1344,21 +1876,71 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1344
1876
|
if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner))
|
|
1345
1877
|
return false;
|
|
1346
1878
|
}
|
|
1879
|
+
if (transition?.kind === "relocate") {
|
|
1880
|
+
const { binding, owner, nextTarget } = transition;
|
|
1881
|
+
const slots = captureExternalReservedSlots();
|
|
1882
|
+
const sourceBindings = Array.from(workspaceBindings.values())
|
|
1883
|
+
.filter((value) => targetMatches(value.target, binding.target));
|
|
1884
|
+
const sourceOwners = currentRecords.filter((value) => targetMatches(value.target, binding.target));
|
|
1885
|
+
if ((workspaceRestore?.operations ?? []).some((operation) => workspaceRestoresConflict(operation.request, transition.restore.operations.at(-1).request)) ||
|
|
1886
|
+
!binding.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) ||
|
|
1887
|
+
binding.inactiveSinceMs !== undefined || owner.status !== "active" ||
|
|
1888
|
+
!owner.instanceId || owner.slot !== binding.slot ||
|
|
1889
|
+
!["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
|
|
1890
|
+
(owner.owner?.kind === "leader" &&
|
|
1891
|
+
(owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
|
|
1892
|
+
!targetMatches(owner.target, binding.target) ||
|
|
1893
|
+
sourceBindings.length !== 1 || !isDeepStrictEqual(sourceBindings[0], binding) ||
|
|
1894
|
+
sourceOwners.length !== 1 || !isDeepStrictEqual(sourceOwners[0], owner) ||
|
|
1895
|
+
!slots || slots.includes(binding.slot) || workspaceRetirementCommitInFlight ||
|
|
1896
|
+
hasWorkspaceRetirementConflict(binding) || hasWorkspaceRetirementConflict({ target: nextTarget }) ||
|
|
1897
|
+
Array.from(workspaceBindings.values()).some((value) => value.bindingKey !== binding.bindingKey &&
|
|
1898
|
+
(value.slot === binding.slot || targetMatches(value.target, nextTarget))) ||
|
|
1899
|
+
currentRecords.some((value) => targetMatches(value.target, nextTarget) ||
|
|
1900
|
+
(!targetMatches(value.target, binding.target) && value.slot === binding.slot)) ||
|
|
1901
|
+
Array.from(workspaceClaims.values()).some((value) => value.identity.bindingKey === binding.bindingKey || value.identity.slot === binding.slot) ||
|
|
1902
|
+
reservations.some((value) => value.slot === binding.slot ||
|
|
1903
|
+
targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)) ||
|
|
1904
|
+
pendingProvisions.some((value) => value.workspaceBindingKey === binding.bindingKey ||
|
|
1905
|
+
value.instanceId === owner.instanceId || value.profileKey === owner.profileKey ||
|
|
1906
|
+
value.slot === binding.slot || (value.target &&
|
|
1907
|
+
(targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)))) ||
|
|
1908
|
+
pendingCleanups.some((value) => targetMatches(value.target, binding.target) ||
|
|
1909
|
+
targetMatches(value.target, nextTarget)) ||
|
|
1910
|
+
syncObservations.some((value) => targetMatches(value.target, nextTarget) &&
|
|
1911
|
+
(value.syncStatus === "deleted" || value.syncStatus === "closed")) ||
|
|
1912
|
+
(sessionReplacement && ((sessionReplacement.cwd === binding.cwd &&
|
|
1913
|
+
sessionReplacement.sourceSessionId === binding.sessionId) ||
|
|
1914
|
+
targetMatches(sessionReplacement.target, binding.target) ||
|
|
1915
|
+
targetMatches(sessionReplacement.target, nextTarget))))
|
|
1916
|
+
return false;
|
|
1917
|
+
}
|
|
1347
1918
|
records = new Map(currentRecords.map((record) => [
|
|
1348
1919
|
getRecordOwnerKey(record),
|
|
1349
1920
|
cloneRecord(record),
|
|
1350
1921
|
]));
|
|
1351
1922
|
const persistedRevision = mutationRevision;
|
|
1352
|
-
const
|
|
1923
|
+
const expectedRestore = structuredClone(workspaceRestore);
|
|
1924
|
+
const assertRestoreEvidenceCurrent = (raw, fromTransaction = false) => {
|
|
1925
|
+
if (isExactJournalFrame?.() === false)
|
|
1926
|
+
throw new Error("Telegram Workspace journal evidence publication lost its exact frame or authority.");
|
|
1927
|
+
const disk = parseTopicTargetFile(fromTransaction ? raw : readStoreSnapshot(path, transition?.kind === "relocate" || !!expectedRestore));
|
|
1928
|
+
if (!isDeepStrictEqual(disk.workspaceRestore, expectedRestore))
|
|
1929
|
+
throw new Error("Workspace Restore evidence changed before publication.");
|
|
1930
|
+
assertWorkspaceRestoreBindingProtection(disk);
|
|
1931
|
+
assertRestoreRecoveryProtection(path, [...(disk.workspaceRestore?.operations ?? []), ...(file.workspaceRestore?.operations ?? [])], [...(disk.pendingProvisions ?? []), ...(file.pendingProvisions ?? [])]);
|
|
1932
|
+
const additions = (file.workspaceRestore?.operations ?? []).filter(operation => !disk.workspaceRestore?.operations.some(retained => retained.request.operationId === operation.request.operationId));
|
|
1933
|
+
assertRestoreRecoveryProtection(path, additions, disk.pendingProvisions ?? [], true);
|
|
1934
|
+
};
|
|
1353
1935
|
const file = {
|
|
1354
1936
|
version: 1,
|
|
1355
1937
|
source: "snapshot",
|
|
1356
1938
|
writtenAtMs: nowMs,
|
|
1357
1939
|
bot: botState,
|
|
1358
|
-
...statusSnapshot,
|
|
1359
1940
|
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
1360
1941
|
workspaceBindings: Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding),
|
|
1361
1942
|
workspaceRetirements: workspaceRetirements.map(cloneWorkspaceRetirementIntent),
|
|
1943
|
+
...(workspaceRestore ? { workspaceRestore: structuredClone(workspaceRestore) } : {}),
|
|
1362
1944
|
...(sessionReplacement
|
|
1363
1945
|
? { sessionReplacement: cloneSessionReplacementIntent(sessionReplacement) }
|
|
1364
1946
|
: {}),
|
|
@@ -1390,60 +1972,153 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1390
1972
|
bindings[0].slot !== record.slot)
|
|
1391
1973
|
return false;
|
|
1392
1974
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1975
|
+
if (transition.kind === "relocate") {
|
|
1976
|
+
if (transition.restore.revision !== (workspaceRestore?.revision ?? 0) + 1 ||
|
|
1977
|
+
!isDeepStrictEqual(transition.restore.operations.slice(0, -1), workspaceRestore?.operations ?? []))
|
|
1978
|
+
return false;
|
|
1979
|
+
file.workspaceRestore = structuredClone(transition.restore);
|
|
1980
|
+
parseWorkspaceRestore(file.workspaceRestore);
|
|
1981
|
+
if (Buffer.byteLength(JSON.stringify(file.workspaceRestore)) > transition.maxBytes)
|
|
1982
|
+
throw new Error("Workspace Restore byte capacity reached.");
|
|
1983
|
+
const binding = file.workspaceBindings.find((value) => value.bindingKey === transition.binding.bindingKey);
|
|
1984
|
+
binding.target = { ...transition.nextTarget };
|
|
1985
|
+
binding.updatedAtMs = nowMs;
|
|
1986
|
+
delete binding.displayTitle;
|
|
1987
|
+
record.target = { ...transition.nextTarget };
|
|
1988
|
+
record.updatedAtMs = nowMs;
|
|
1989
|
+
record.rerouteConfirmedAtMs = nowMs;
|
|
1990
|
+
record.syncStatus = "unknown";
|
|
1991
|
+
record.lastReconcileAction = "workspace-restore";
|
|
1992
|
+
delete record.lastError;
|
|
1993
|
+
delete record.lastSyncError;
|
|
1994
|
+
delete record.lastSyncObservedAtMs;
|
|
1995
|
+
delete record.lastSyncProbeAtMs;
|
|
1996
|
+
}
|
|
1997
|
+
else {
|
|
1998
|
+
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
1999
|
+
for (const binding of file.workspaceBindings) {
|
|
2000
|
+
if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
|
|
2001
|
+
binding.inactiveSinceMs = nowMs;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (transition.kind === "invalidate") {
|
|
2005
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
2006
|
+
file.syncObservations.push({
|
|
2007
|
+
target: { ...record.target },
|
|
2008
|
+
syncStatus: "deleted",
|
|
2009
|
+
observedAtMs: nowMs,
|
|
2010
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
2011
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
2012
|
+
lastSyncError: transition.lastSyncError,
|
|
2013
|
+
lastReconcileAction: "mark-stale",
|
|
2014
|
+
});
|
|
1397
2015
|
}
|
|
1398
2016
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
2017
|
+
}
|
|
2018
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
2019
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? []);
|
|
2020
|
+
if (publishWorkspace) {
|
|
2021
|
+
const wire = JSON.parse(JSON.stringify(file));
|
|
2022
|
+
const outcome = publishWorkspace(current => {
|
|
2023
|
+
if (!scopeMatches(submittedPath, submittedProfile) || mutationRevision !== persistedRevision ||
|
|
2024
|
+
transition?.isCurrent() === false || isExactJournalFrame?.() === false)
|
|
2025
|
+
return { value: current, result: false };
|
|
2026
|
+
const semanticCurrent = getTelegramStateSemanticSnapshot(current);
|
|
2027
|
+
if (!isDeepStrictEqual(semanticCurrent, observedWorkspaceSemantic))
|
|
2028
|
+
throw new Error("Workspace canonical snapshot changed before publication.");
|
|
2029
|
+
// A late whole-Workspace replacement cannot erase a concurrent canonical mutation.
|
|
2030
|
+
if (workspaceRestore && !isDeepStrictEqual(parseTopicTargetFile(current).workspaceRestore, expectedRestore))
|
|
2031
|
+
throw new Error("Workspace Restore evidence changed before publication.");
|
|
2032
|
+
assertRestoreEvidenceCurrent(current, true);
|
|
2033
|
+
if (transition?.kind === "relocate") {
|
|
2034
|
+
const slots = captureExternalReservedSlots();
|
|
2035
|
+
if (!slots || slots.includes(transition.binding.slot) || workspaceRetirementCommitInFlight ||
|
|
2036
|
+
Array.from(workspaceClaims.values()).some(value => value.identity.bindingKey === transition.binding.bindingKey || value.identity.slot === transition.binding.slot))
|
|
2037
|
+
return { value: current, result: false };
|
|
2038
|
+
}
|
|
2039
|
+
const unchanged = !transition && isDeepStrictEqual(semanticCurrent, getTelegramStateSemanticSnapshot(wire));
|
|
2040
|
+
return { value: unchanged ? current : wire, result: true };
|
|
2041
|
+
}, { isCurrent: () => scopeMatches(submittedPath, submittedProfile) && mutationRevision === persistedRevision &&
|
|
2042
|
+
(transition?.isCurrent() ?? true) && (isExactJournalFrame?.() ?? true),
|
|
2043
|
+
onPublicationBoundary: transition?.kind === "relocate" ? boundary => {
|
|
2044
|
+
if (boundary !== "before-write")
|
|
2045
|
+
transition.onPublicationBoundary?.(boundary);
|
|
2046
|
+
} : undefined });
|
|
2047
|
+
if (!outcome.committed || !outcome.result)
|
|
2048
|
+
return false;
|
|
2049
|
+
loadedPath = path;
|
|
2050
|
+
loadedProfile = submittedProfile;
|
|
2051
|
+
loaded = true;
|
|
2052
|
+
observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(wire);
|
|
2053
|
+
if (transition) {
|
|
2054
|
+
records = new Map(file.threads.map(record => {
|
|
2055
|
+
const normalized = normalizeRecord(record);
|
|
2056
|
+
return [getRecordOwnerKey(normalized), cloneRecord(normalized)];
|
|
2057
|
+
}));
|
|
2058
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
2059
|
+
syncObservations = file.syncObservations;
|
|
2060
|
+
workspaceBindings = new Map(file.workspaceBindings.map(binding => [getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding)]));
|
|
2061
|
+
mutationRevision += 1;
|
|
1410
2062
|
}
|
|
2063
|
+
dirty = false;
|
|
2064
|
+
return true;
|
|
1411
2065
|
}
|
|
1412
2066
|
let persistedSemanticSnapshot;
|
|
1413
2067
|
try {
|
|
1414
|
-
persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(
|
|
2068
|
+
persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(await readStoreSnapshot(path, !!workspaceRestore, true));
|
|
1415
2069
|
}
|
|
1416
2070
|
catch {
|
|
1417
|
-
/* missing or
|
|
2071
|
+
/* Final Restore validation fences missing or unverifiable snapshots before publication. */
|
|
1418
2072
|
}
|
|
1419
2073
|
// Normalize optional fields to wire JSON; object key order is not a state change.
|
|
1420
2074
|
if (!transition && isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
|
|
1421
|
-
mutationRevision === persistedRevision
|
|
1422
|
-
statusRevision === persistedStatusRevision) {
|
|
2075
|
+
mutationRevision === persistedRevision) {
|
|
1423
2076
|
loadedPath = path;
|
|
1424
2077
|
loaded = true;
|
|
1425
2078
|
dirty = false;
|
|
1426
2079
|
return true;
|
|
1427
2080
|
}
|
|
2081
|
+
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
2082
|
+
if (file.workspaceRestore && Buffer.byteLength(serialized) > WORKSPACE_SNAPSHOT_MAX_BYTES)
|
|
2083
|
+
throw new Error("Workspace snapshot byte capacity reached.");
|
|
1428
2084
|
await mkdir(dirname(path), { recursive: true });
|
|
1429
2085
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1430
|
-
await writeFile(tempPath,
|
|
1431
|
-
encoding: "utf8",
|
|
1432
|
-
mode: 0o600,
|
|
1433
|
-
});
|
|
2086
|
+
await writeFile(tempPath, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1434
2087
|
await chmod(tempPath, 0o600);
|
|
1435
2088
|
try {
|
|
1436
2089
|
if (transition) {
|
|
2090
|
+
if (transition.kind === "relocate")
|
|
2091
|
+
transition.onPublicationBoundary?.("after-write-before-rename");
|
|
1437
2092
|
let applied = false;
|
|
1438
2093
|
const commit = () => {
|
|
1439
|
-
if (
|
|
1440
|
-
|
|
2094
|
+
if (!transition.isCurrent())
|
|
2095
|
+
return;
|
|
2096
|
+
if (transition.kind === "relocate") {
|
|
2097
|
+
// Transient claims and external fences do not advance snapshot revisions.
|
|
2098
|
+
const slots = captureExternalReservedSlots();
|
|
2099
|
+
if (!slots || slots.includes(transition.binding.slot) || workspaceRetirementCommitInFlight ||
|
|
2100
|
+
Array.from(workspaceClaims.values()).some((value) => value.identity.bindingKey === transition.binding.bindingKey ||
|
|
2101
|
+
value.identity.slot === transition.binding.slot))
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (getPath() !== path || mutationRevision !== persistedRevision)
|
|
2105
|
+
return;
|
|
2106
|
+
// All publishers fence the same retained operation, including after its terminal removal.
|
|
2107
|
+
assertRestoreEvidenceCurrent();
|
|
2108
|
+
if (!transition.isCurrent() || getPath() !== path || mutationRevision !== persistedRevision)
|
|
1441
2109
|
return;
|
|
1442
|
-
// Fence and rename share one synchronous commit boundary. No stale
|
|
1443
|
-
// transition enters the live projection before durable commit.
|
|
1444
2110
|
renameSync(tempPath, path);
|
|
1445
2111
|
loadedPath = path;
|
|
1446
|
-
|
|
2112
|
+
if (transition.kind === "relocate") {
|
|
2113
|
+
const relocated = file.threads.find((record) => targetMatches(record.target, transition.nextTarget));
|
|
2114
|
+
records.set(getRecordOwnerKey(transition.owner), {
|
|
2115
|
+
...relocated, profileKey: transition.owner.profileKey,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
else {
|
|
2119
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
|
|
2120
|
+
}
|
|
2121
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
1447
2122
|
syncObservations = file.syncObservations;
|
|
1448
2123
|
workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
|
|
1449
2124
|
getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
|
|
@@ -1451,28 +2126,36 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1451
2126
|
mutationRevision += 1;
|
|
1452
2127
|
dirty = false;
|
|
1453
2128
|
applied = true;
|
|
2129
|
+
if (transition.kind === "relocate")
|
|
2130
|
+
transition.onPublicationBoundary?.("after-rename");
|
|
1454
2131
|
};
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2132
|
+
withTelegramFileTransaction(`${path}.transaction`, () => {
|
|
2133
|
+
if (commitPersist)
|
|
2134
|
+
commitPersist(commit);
|
|
2135
|
+
else if (!options.canPersist || options.canPersist())
|
|
2136
|
+
commit();
|
|
2137
|
+
});
|
|
1459
2138
|
if (!applied)
|
|
1460
2139
|
await unlink(tempPath).catch(() => undefined);
|
|
1461
2140
|
return applied;
|
|
1462
2141
|
}
|
|
1463
|
-
if (
|
|
1464
|
-
const committed =
|
|
2142
|
+
if (commitPersist) {
|
|
2143
|
+
const committed = withTelegramFileTransaction(`${path}.transaction`, () => commitPersist(() => {
|
|
2144
|
+
assertRestoreEvidenceCurrent();
|
|
1465
2145
|
renameSync(tempPath, path);
|
|
1466
2146
|
chmodSync(path, 0o600);
|
|
1467
|
-
});
|
|
2147
|
+
}));
|
|
1468
2148
|
if (!committed) {
|
|
1469
2149
|
await loadFromDisk();
|
|
1470
2150
|
throw new Error("Telegram thread snapshot lost exact transport ownership before commit.");
|
|
1471
2151
|
}
|
|
1472
2152
|
}
|
|
1473
2153
|
else {
|
|
1474
|
-
|
|
1475
|
-
|
|
2154
|
+
withTelegramFileTransaction(`${path}.transaction`, () => {
|
|
2155
|
+
assertRestoreEvidenceCurrent();
|
|
2156
|
+
renameSync(tempPath, path);
|
|
2157
|
+
chmodSync(path, 0o600);
|
|
2158
|
+
});
|
|
1476
2159
|
}
|
|
1477
2160
|
}
|
|
1478
2161
|
catch (error) {
|
|
@@ -1490,6 +2173,8 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1490
2173
|
};
|
|
1491
2174
|
return {
|
|
1492
2175
|
async load() {
|
|
2176
|
+
if (workspaceIO && dirty && !loadedScopeMatches(getPath()))
|
|
2177
|
+
throw new Error("Workspace projection must refresh after scope change.");
|
|
1493
2178
|
if (dirty)
|
|
1494
2179
|
return;
|
|
1495
2180
|
await loadFromDisk();
|
|
@@ -1500,7 +2185,9 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1500
2185
|
return refresh;
|
|
1501
2186
|
},
|
|
1502
2187
|
async persist() {
|
|
1503
|
-
await persistSnapshot();
|
|
2188
|
+
const committed = await persistSnapshot();
|
|
2189
|
+
if (workspaceIO && !committed)
|
|
2190
|
+
throw new Error("Workspace snapshot lost captured publication authority.");
|
|
1504
2191
|
},
|
|
1505
2192
|
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
1506
2193
|
return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
|
|
@@ -1511,6 +2198,677 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1511
2198
|
return Promise.resolve(false);
|
|
1512
2199
|
return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
|
|
1513
2200
|
},
|
|
2201
|
+
assertWorkspaceRestoreRegistration(candidate) {
|
|
2202
|
+
withRestoreRegistration(candidate);
|
|
2203
|
+
},
|
|
2204
|
+
commitWorkspaceRestoreRegistration: withRestoreRegistration,
|
|
2205
|
+
listTemporaryThreadTargets() {
|
|
2206
|
+
const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
|
|
2207
|
+
return (file.workspaceRestore?.temporaryThreads ?? []).flatMap(entry => entry.target ? [{ ...entry.target }] : []);
|
|
2208
|
+
},
|
|
2209
|
+
withWorkspaceRestoreSnapshot(expected, observe) {
|
|
2210
|
+
const path = getPath();
|
|
2211
|
+
withStoreTransaction(path, () => {
|
|
2212
|
+
const file = parseTopicTargetFile(readStoreSnapshot(path, false));
|
|
2213
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
2214
|
+
if (getPath() !== path || !isDeepStrictEqual(file.workspaceRestore?.operations.find(intent => intent.request.operationId === expected.request.operationId), expected))
|
|
2215
|
+
throw new Error("Workspace Restore observation changed.");
|
|
2216
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
2217
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore.operations, file.pendingProvisions ?? []);
|
|
2218
|
+
observe({ threads: file.threads, workspaceBindings: file.workspaceBindings });
|
|
2219
|
+
});
|
|
2220
|
+
},
|
|
2221
|
+
workspaceRestore(storageOptions) {
|
|
2222
|
+
const path = getPath(), profile = activeProfile();
|
|
2223
|
+
const now = storageOptions.getNowMs ?? Date.now;
|
|
2224
|
+
const { profileName, tokenSha256, isCurrentScope, legacyPath, onPublicationBoundary } = storageOptions;
|
|
2225
|
+
if (consolidated && profileName !== profile)
|
|
2226
|
+
throw new Error("Foreign Workspace Restore storage scope.");
|
|
2227
|
+
const maxBytes = Math.min(storageOptions.maxBytes ?? WORKSPACE_RESTORE_MAX_BYTES, WORKSPACE_RESTORE_MAX_BYTES);
|
|
2228
|
+
if (!restoreText(profileName) || !/^[a-f0-9]{64}$/u.test(tokenSha256) || !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
2229
|
+
throw new Error("Invalid Workspace Restore storage scope.");
|
|
2230
|
+
const empty = { version: 1, profileName, tokenSha256, revision: 0, operations: [] };
|
|
2231
|
+
let observed;
|
|
2232
|
+
const currentScope = () => getPath() === path && (!consolidated || scopeMatches(path, profile)) && (isCurrentScope?.() ?? true) && (options.canPersist?.() ?? true);
|
|
2233
|
+
const read = () => {
|
|
2234
|
+
if (legacyPath) {
|
|
2235
|
+
try {
|
|
2236
|
+
lstatSync(legacyPath);
|
|
2237
|
+
throw new Error("Unmigrated Workspace Restore file.");
|
|
2238
|
+
}
|
|
2239
|
+
catch (error) {
|
|
2240
|
+
if (error.code !== "ENOENT")
|
|
2241
|
+
throw error;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
const raw = readStoreSnapshot(path);
|
|
2245
|
+
if (raw !== undefined && (!restoreObject(raw) || raw.version !== 1))
|
|
2246
|
+
throw new Error("Invalid Workspace snapshot.");
|
|
2247
|
+
const file = (raw ?? (consolidated ? { version: 1, source: "snapshot", writtenAtMs: 0, bot: { threadMode: "unknown" }, threads: [] }
|
|
2248
|
+
: { version: 1, records: [] }));
|
|
2249
|
+
const state = parseTopicTargetFile(file).workspaceRestore;
|
|
2250
|
+
if (state && (state.profileName !== profileName || state.tokenSha256 !== tokenSha256))
|
|
2251
|
+
throw new Error("Invalid or foreign Workspace Restore evidence.");
|
|
2252
|
+
if (observed && (!state || state.revision < observed.revision ||
|
|
2253
|
+
(state.revision === observed.revision && !isDeepStrictEqual(state, observed))))
|
|
2254
|
+
throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
|
|
2255
|
+
if (state && Buffer.byteLength(JSON.stringify(state)) > maxBytes)
|
|
2256
|
+
throw new Error("Workspace Restore byte capacity reached.");
|
|
2257
|
+
observed = structuredClone(state);
|
|
2258
|
+
if (loadedScopeMatches(path))
|
|
2259
|
+
observeWorkspaceRestore(state);
|
|
2260
|
+
return { file, state: structuredClone(state ?? empty) };
|
|
2261
|
+
};
|
|
2262
|
+
const inspectTemporaryOwnership = (before, entry) => {
|
|
2263
|
+
if (entry.phase !== "created" || !entry.target)
|
|
2264
|
+
return { kind: "unknown" };
|
|
2265
|
+
const file = parseTopicTargetFile(before.file);
|
|
2266
|
+
// Normalization omissions never prove an unbound target.
|
|
2267
|
+
for (const key of ["threads", "workspaceBindings", "reservations", "pendingProvisions", "pendingCleanups", "workspaceRetirements"]) {
|
|
2268
|
+
const raw = before.file[key];
|
|
2269
|
+
if ((key === "threads" && raw === undefined) || (raw !== undefined &&
|
|
2270
|
+
(!Array.isArray(raw) || raw.length !== (file[key]?.length ?? 0))))
|
|
2271
|
+
throw new Error("Invalid temporary Thread target evidence.");
|
|
2272
|
+
}
|
|
2273
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
2274
|
+
assertRestoreRecoveryProtection(path, before.state.operations, file.pendingProvisions ?? [], true);
|
|
2275
|
+
const target = entry.target;
|
|
2276
|
+
const bindings = (file.workspaceBindings ?? []).filter(value => targetMatches(value.target, target));
|
|
2277
|
+
if (bindings.length === 1)
|
|
2278
|
+
return { kind: "bound", binding: structuredClone(bindings[0]) };
|
|
2279
|
+
if (bindings.length > 1 || file.threads.some(value => targetMatches(value.target, target)) ||
|
|
2280
|
+
file.reservations?.some(value => targetMatches(value.target, target)) ||
|
|
2281
|
+
file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, target)) ||
|
|
2282
|
+
file.pendingCleanups?.some(value => targetMatches(value.target, target)) ||
|
|
2283
|
+
file.workspaceRetirements?.some(value => targetMatches(value.binding.target, target)) ||
|
|
2284
|
+
before.state.operations.some(value => targetMatches(value.request.target, target)))
|
|
2285
|
+
return { kind: "unknown" };
|
|
2286
|
+
return { kind: "temporary" };
|
|
2287
|
+
};
|
|
2288
|
+
const classifyTemporaryTarget = (before, entry) => entry.cleanupIssued ? { kind: "unknown" } : inspectTemporaryOwnership(before, entry);
|
|
2289
|
+
const storage = {
|
|
2290
|
+
read: () => read().state,
|
|
2291
|
+
async relocate(input, isCurrent) {
|
|
2292
|
+
if (!currentScope() || !isCurrent())
|
|
2293
|
+
return false;
|
|
2294
|
+
const intent = structuredClone(input);
|
|
2295
|
+
if (!currentScope() || !isCurrent() || !isWorkspaceRestoreIntent(intent) || intent.phase !== "relocated" || intent.revision !== 0 ||
|
|
2296
|
+
intent.recipient !== undefined || intent.routing !== undefined)
|
|
2297
|
+
return false;
|
|
2298
|
+
const state = read().state;
|
|
2299
|
+
if (state.operations.some(operation => workspaceRestoresConflict(operation.request, intent.request)))
|
|
2300
|
+
return false;
|
|
2301
|
+
if (state.revision === Number.MAX_SAFE_INTEGER)
|
|
2302
|
+
throw new Error("Workspace Restore revision exhausted.");
|
|
2303
|
+
if (state.operations.length >= 26)
|
|
2304
|
+
throw new Error("Workspace Restore operation capacity reached.");
|
|
2305
|
+
const restore = { ...state, revision: state.revision + 1, operations: [...state.operations, intent] };
|
|
2306
|
+
const request = intent.request;
|
|
2307
|
+
return persistSnapshot({ kind: "relocate", operationId: request.operationId, binding: request.binding, owner: request.owner,
|
|
2308
|
+
target: { ...request.binding.target }, nextTarget: { ...request.target }, restore, maxBytes, onPublicationBoundary,
|
|
2309
|
+
isCurrent: () => currentScope() && isCurrent() });
|
|
2310
|
+
},
|
|
2311
|
+
update(expectedInput, nextInput, isCurrent) {
|
|
2312
|
+
const expected = structuredClone(expectedInput), next = structuredClone(nextInput);
|
|
2313
|
+
if (!currentScope() || !isCurrent())
|
|
2314
|
+
return false;
|
|
2315
|
+
if (expected.revision === Number.MAX_SAFE_INTEGER)
|
|
2316
|
+
throw new Error("Workspace Restore revision exhausted.");
|
|
2317
|
+
if (next.profileName !== profileName || next.tokenSha256 !== tokenSha256 || next.revision !== expected.revision + 1 || !parseWorkspaceRestore(next))
|
|
2318
|
+
throw new Error("Invalid Workspace Restore publication.");
|
|
2319
|
+
if (Buffer.byteLength(JSON.stringify(next)) > maxBytes)
|
|
2320
|
+
throw new Error("Workspace Restore byte capacity reached.");
|
|
2321
|
+
if (!workspaceIO)
|
|
2322
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2323
|
+
let committedBefore;
|
|
2324
|
+
let committedAfter;
|
|
2325
|
+
const committed = withStoreTransaction(path, () => {
|
|
2326
|
+
const before = read();
|
|
2327
|
+
if (!isDeepStrictEqual(before.state, expected) || !currentScope() || !isCurrent())
|
|
2328
|
+
return false;
|
|
2329
|
+
for (const entry of next.temporaryThreads ?? []) {
|
|
2330
|
+
const prior = expected.temporaryThreads?.find(value => value.token === entry.token);
|
|
2331
|
+
if (!prior && entry.phase === "created" && classifyTemporaryTarget(before, entry).kind !== "temporary")
|
|
2332
|
+
return false;
|
|
2333
|
+
if (!entry.cleanupIssued)
|
|
2334
|
+
continue;
|
|
2335
|
+
if (!prior?.cleanupIssued && (!prior || classifyTemporaryTarget(before, prior).kind !== "temporary"))
|
|
2336
|
+
return false;
|
|
2337
|
+
}
|
|
2338
|
+
assertWorkspaceRestoreBindingProtection(before.file);
|
|
2339
|
+
const provisions = parseTopicTargetFile(before.file).pendingProvisions ?? [];
|
|
2340
|
+
assertRestoreRecoveryProtection(path, before.state.operations, provisions);
|
|
2341
|
+
const after = { ...before.file, workspaceRestore: next };
|
|
2342
|
+
// New temporary targets must be checked against the file they will be published into.
|
|
2343
|
+
assertWorkspaceRestoreBindingProtection(parseTopicTargetFile(after));
|
|
2344
|
+
const content = JSON.stringify(after);
|
|
2345
|
+
if (Buffer.byteLength(content) > WORKSPACE_SNAPSHOT_MAX_BYTES)
|
|
2346
|
+
throw new Error("Workspace snapshot byte capacity reached.");
|
|
2347
|
+
if (workspaceIO) {
|
|
2348
|
+
if (!workspaceFrame || !currentScope() || !isCurrent())
|
|
2349
|
+
return false;
|
|
2350
|
+
assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
|
|
2351
|
+
committedBefore = before;
|
|
2352
|
+
committedAfter = JSON.parse(content);
|
|
2353
|
+
workspaceFrame.value = committedAfter;
|
|
2354
|
+
return true;
|
|
2355
|
+
}
|
|
2356
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
2357
|
+
try {
|
|
2358
|
+
writeFileSync(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
2359
|
+
onPublicationBoundary?.("after-write-before-rename");
|
|
2360
|
+
let published = false;
|
|
2361
|
+
const publish = () => {
|
|
2362
|
+
if (!currentScope() || !isCurrent())
|
|
2363
|
+
return;
|
|
2364
|
+
if (!isDeepStrictEqual(read().file, before.file))
|
|
2365
|
+
throw new Error("Workspace Restore evidence changed before publication.");
|
|
2366
|
+
assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
|
|
2367
|
+
if (!currentScope() || !isCurrent())
|
|
2368
|
+
return;
|
|
2369
|
+
renameSync(temporary, path);
|
|
2370
|
+
observed = structuredClone(next);
|
|
2371
|
+
// A read-only Restore view must never bless a stale binding/owner projection.
|
|
2372
|
+
if (loadedPath === path && isDeepStrictEqual(workspaceRestore, before.file.workspaceRestore))
|
|
2373
|
+
workspaceRestore = structuredClone(next);
|
|
2374
|
+
mutationRevision += 1;
|
|
2375
|
+
published = true;
|
|
2376
|
+
onPublicationBoundary?.("after-rename");
|
|
2377
|
+
};
|
|
2378
|
+
const owned = commitPersist ? commitPersist(publish) : (publish(), true);
|
|
2379
|
+
return owned && published;
|
|
2380
|
+
}
|
|
2381
|
+
finally {
|
|
2382
|
+
try {
|
|
2383
|
+
unlinkSync(temporary);
|
|
2384
|
+
}
|
|
2385
|
+
catch (error) {
|
|
2386
|
+
if (error.code !== "ENOENT")
|
|
2387
|
+
throw error;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
}, { isCurrent: () => currentScope() && isCurrent(), onPublicationBoundary: boundary => {
|
|
2391
|
+
if (boundary !== "before-write")
|
|
2392
|
+
onPublicationBoundary?.(boundary);
|
|
2393
|
+
} });
|
|
2394
|
+
if (workspaceIO && committed && committedBefore && committedAfter) {
|
|
2395
|
+
observed = structuredClone(next);
|
|
2396
|
+
if (loadedScopeMatches(path) && isDeepStrictEqual(workspaceRestore, committedBefore.file.workspaceRestore))
|
|
2397
|
+
workspaceRestore = structuredClone(next);
|
|
2398
|
+
if (isDeepStrictEqual(observedWorkspaceSemantic, getTelegramStateSemanticSnapshot(committedBefore.file)))
|
|
2399
|
+
observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(committedAfter);
|
|
2400
|
+
mutationRevision += 1;
|
|
2401
|
+
}
|
|
2402
|
+
return committed;
|
|
2403
|
+
},
|
|
2404
|
+
};
|
|
2405
|
+
const mutate = (authority, change) => {
|
|
2406
|
+
if (!authority.isCurrent())
|
|
2407
|
+
return undefined;
|
|
2408
|
+
const executor = structuredClone(authority.executor);
|
|
2409
|
+
const operator = authority.operatorUserId;
|
|
2410
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator))
|
|
2411
|
+
return undefined;
|
|
2412
|
+
const current = () => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
|
|
2413
|
+
const before = storage.read();
|
|
2414
|
+
const file = structuredClone(before);
|
|
2415
|
+
const operation = change(file, executor, operator);
|
|
2416
|
+
if (!operation || !current())
|
|
2417
|
+
return undefined;
|
|
2418
|
+
if (isDeepStrictEqual(file, before))
|
|
2419
|
+
return structuredClone(operation);
|
|
2420
|
+
if (file.revision === Number.MAX_SAFE_INTEGER)
|
|
2421
|
+
throw new Error("Workspace Restore revision exhausted.");
|
|
2422
|
+
file.revision += 1;
|
|
2423
|
+
return storage.update(before, file, current) ? structuredClone(operation) : undefined;
|
|
2424
|
+
};
|
|
2425
|
+
const advance = (expected, authority, change, adopt = false) => mutate(authority, (file, executor, operator) => {
|
|
2426
|
+
const operation = file.operations.find(candidate => candidate.request.operationId === expected.request.operationId);
|
|
2427
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
2428
|
+
(!adopt && !isDeepStrictEqual(operation.executor, executor)))
|
|
2429
|
+
return undefined;
|
|
2430
|
+
const changed = change(operation);
|
|
2431
|
+
if (!changed)
|
|
2432
|
+
return undefined;
|
|
2433
|
+
if (changed === "unchanged")
|
|
2434
|
+
return operation;
|
|
2435
|
+
if (operation.revision === Number.MAX_SAFE_INTEGER)
|
|
2436
|
+
throw new Error("Workspace Restore operation revision exhausted.");
|
|
2437
|
+
const at = now();
|
|
2438
|
+
if (!restoreInteger(at))
|
|
2439
|
+
throw new Error("Invalid Workspace Restore clock.");
|
|
2440
|
+
operation.executor = executor;
|
|
2441
|
+
operation.revision += 1;
|
|
2442
|
+
operation.updatedAtMs = Math.max(at, operation.updatedAtMs);
|
|
2443
|
+
return operation;
|
|
2444
|
+
});
|
|
2445
|
+
const advanceTemporary = (expected, authority, change, adopt = false) => mutate(authority, (file, executor, operator) => {
|
|
2446
|
+
const entry = file.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
2447
|
+
if (!entry || entry.operatorUserId !== operator || (!adopt && !isDeepStrictEqual(entry.executor, executor)))
|
|
2448
|
+
return undefined;
|
|
2449
|
+
const changed = change(entry, file, executor);
|
|
2450
|
+
if (!changed)
|
|
2451
|
+
return undefined;
|
|
2452
|
+
if (changed === "unchanged")
|
|
2453
|
+
return entry;
|
|
2454
|
+
if (entry.revision === Number.MAX_SAFE_INTEGER)
|
|
2455
|
+
throw new Error("Workspace Restore operation revision exhausted.");
|
|
2456
|
+
const at = now();
|
|
2457
|
+
if (!restoreInteger(at))
|
|
2458
|
+
throw new Error("Invalid Workspace Restore clock.");
|
|
2459
|
+
entry.executor = executor;
|
|
2460
|
+
entry.revision += 1;
|
|
2461
|
+
entry.updatedAtMs = Math.max(at, entry.updatedAtMs);
|
|
2462
|
+
return entry;
|
|
2463
|
+
});
|
|
2464
|
+
return {
|
|
2465
|
+
list: () => structuredClone(storage.read().operations),
|
|
2466
|
+
inspectTemporaryThreadTarget(expected, authority) {
|
|
2467
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
2468
|
+
const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
|
|
2469
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
2470
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current())
|
|
2471
|
+
return undefined;
|
|
2472
|
+
return withStoreTransaction(path, () => {
|
|
2473
|
+
if (!current())
|
|
2474
|
+
return undefined;
|
|
2475
|
+
const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
2476
|
+
if (!entry || entry.operatorUserId !== operator || !current())
|
|
2477
|
+
return undefined;
|
|
2478
|
+
const result = classifyTemporaryTarget(before, entry);
|
|
2479
|
+
return current() ? result : undefined;
|
|
2480
|
+
});
|
|
2481
|
+
},
|
|
2482
|
+
isTemporaryThreadCleanupCurrent(expected, authority) {
|
|
2483
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
2484
|
+
const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
|
|
2485
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
2486
|
+
if (!expected.cleanupIssued || expected.operatorUserId !== operator || !isDeepStrictEqual(expected.executor, executor) ||
|
|
2487
|
+
!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current())
|
|
2488
|
+
return false;
|
|
2489
|
+
return withStoreTransaction(path, () => {
|
|
2490
|
+
if (!current())
|
|
2491
|
+
return false;
|
|
2492
|
+
const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
2493
|
+
return !!entry && inspectTemporaryOwnership(before, entry).kind === "temporary" && current();
|
|
2494
|
+
});
|
|
2495
|
+
},
|
|
2496
|
+
/** The first durable state already includes both relocation and exact original references. */
|
|
2497
|
+
async commit(requestInput, authority) {
|
|
2498
|
+
if (!authority.isCurrent())
|
|
2499
|
+
return undefined;
|
|
2500
|
+
const request = structuredClone(requestInput), executor = structuredClone(authority.executor), operatorUserId = authority.operatorUserId;
|
|
2501
|
+
const current = () => authority.isCurrent() && authority.operatorUserId === operatorUserId && isDeepStrictEqual(authority.executor, executor);
|
|
2502
|
+
if (!current() || !isTelegramWorkspaceRestoreRequest(request) || !isTelegramWorkspaceRestoreExecutor(executor) ||
|
|
2503
|
+
!restoreInteger(operatorUserId) || request.target.chatId !== operatorUserId)
|
|
2504
|
+
return undefined;
|
|
2505
|
+
const retained = () => {
|
|
2506
|
+
if (!current())
|
|
2507
|
+
return undefined;
|
|
2508
|
+
const operation = storage.read().operations.find(value => value.request.operationId === request.operationId);
|
|
2509
|
+
return current() && operation?.phase === "relocated" && isDeepStrictEqual(operation.request, request) &&
|
|
2510
|
+
operation.operatorUserId === operatorUserId && isDeepStrictEqual(operation.executor, executor) ? operation : undefined;
|
|
2511
|
+
};
|
|
2512
|
+
const existing = retained();
|
|
2513
|
+
if (existing)
|
|
2514
|
+
return existing;
|
|
2515
|
+
await (dirty ? undefined : loadFromDisk());
|
|
2516
|
+
if (!current())
|
|
2517
|
+
return undefined;
|
|
2518
|
+
const at = now();
|
|
2519
|
+
if (!restoreInteger(at))
|
|
2520
|
+
throw new Error("Invalid Workspace Restore clock.");
|
|
2521
|
+
const intent = { request, executor, operatorUserId, revision: 0,
|
|
2522
|
+
createdAtMs: at, updatedAtMs: at, committedAtMs: at, phase: "relocated" };
|
|
2523
|
+
let failure;
|
|
2524
|
+
try {
|
|
2525
|
+
await storage.relocate(intent, current);
|
|
2526
|
+
}
|
|
2527
|
+
catch (error) {
|
|
2528
|
+
failure = { error };
|
|
2529
|
+
}
|
|
2530
|
+
if (!current())
|
|
2531
|
+
return undefined;
|
|
2532
|
+
// A lost rename reply is reconciled from the complete operation, never target equality.
|
|
2533
|
+
const committed = retained();
|
|
2534
|
+
if (!committed && failure)
|
|
2535
|
+
throw failure.error;
|
|
2536
|
+
return committed;
|
|
2537
|
+
},
|
|
2538
|
+
adopt(expected, authority) {
|
|
2539
|
+
return advance(expected, authority, () => !isDeepStrictEqual(expected.executor, authority.executor), true);
|
|
2540
|
+
},
|
|
2541
|
+
/** Caller validates the live recipient under admission. Only this fresh result grants one RPC issuance. */
|
|
2542
|
+
issueRecipient(expected, recipient, authority) {
|
|
2543
|
+
const intent = advance(expected, authority, operation => {
|
|
2544
|
+
if (operation.phase !== "relocated" || !isTelegramWorkspaceRestoreRecipient(recipient) || recipient.sessionId !== operation.request.binding.sessionId)
|
|
2545
|
+
return false;
|
|
2546
|
+
operation.phase = "recipient-issued";
|
|
2547
|
+
operation.recipient = structuredClone(recipient);
|
|
2548
|
+
return true;
|
|
2549
|
+
});
|
|
2550
|
+
return intent ? { issued: true, intent } : undefined;
|
|
2551
|
+
},
|
|
2552
|
+
/** One source-dispatch grant; uncertainty never resets it. Caller retains the original carriers. */
|
|
2553
|
+
issueRouting(expected, authority) {
|
|
2554
|
+
const intent = advance(expected, authority, operation => {
|
|
2555
|
+
if (operation.phase !== "ready" || operation.routing)
|
|
2556
|
+
return false;
|
|
2557
|
+
operation.routing = { settlements: [] };
|
|
2558
|
+
return true;
|
|
2559
|
+
});
|
|
2560
|
+
return intent ? { issued: true, intent } : undefined;
|
|
2561
|
+
},
|
|
2562
|
+
/** Caller owns the positive result and exact source hash; this grants neither source removal nor cleanup. */
|
|
2563
|
+
recordSourceAcceptance(expected, evidenceInput, authority) {
|
|
2564
|
+
if (!authority.isCurrent())
|
|
2565
|
+
return undefined;
|
|
2566
|
+
const evidence = structuredClone(evidenceInput);
|
|
2567
|
+
return advance(expected, authority, operation => {
|
|
2568
|
+
if (!operation.routing || !isWorkspaceRestoreSourceAcceptance(evidence, operation))
|
|
2569
|
+
return false;
|
|
2570
|
+
const existing = operation.routing.acceptances?.find(value => value.updateId === evidence.updateId);
|
|
2571
|
+
if (existing)
|
|
2572
|
+
return isDeepStrictEqual(existing, evidence) ? "unchanged" : false;
|
|
2573
|
+
if (operation.routing.cleanup !== undefined ||
|
|
2574
|
+
operation.routing.settlements.some(value => value.updateIds.includes(evidence.updateId)) ||
|
|
2575
|
+
!isDeepStrictEqual(evidence.recipient, operation.readyRecipient ?? operation.recipient))
|
|
2576
|
+
return false;
|
|
2577
|
+
const acceptances = [...operation.routing.acceptances ?? [], evidence].sort((a, b) => a.updateId - b.updateId);
|
|
2578
|
+
if (!workspaceRestoreAcceptancesConsistent(acceptances))
|
|
2579
|
+
return false;
|
|
2580
|
+
operation.routing.acceptances = acceptances;
|
|
2581
|
+
return true;
|
|
2582
|
+
});
|
|
2583
|
+
},
|
|
2584
|
+
/** Queue admission is nonterminal; only its exact journal-owner completion ACK may upgrade it. */
|
|
2585
|
+
recordSourceSettlement(expected, evidenceInput, authority) {
|
|
2586
|
+
const evidence = structuredClone(evidenceInput);
|
|
2587
|
+
return advance(expected, authority, operation => {
|
|
2588
|
+
if (!operation.routing || !isTelegramWorkspaceRestoreSettlement(evidence, operation.request.source) ||
|
|
2589
|
+
!workspaceRestoreSettlementMatchesAcceptance(evidence, operation.routing.acceptances ?? []))
|
|
2590
|
+
return false;
|
|
2591
|
+
const overlaps = operation.routing.settlements.filter(value => value.updateIds.some(id => evidence.updateIds.includes(id)));
|
|
2592
|
+
if (overlaps.some(value => evidence.kind !== "queue-completed" || value.kind !== "queued" ||
|
|
2593
|
+
value.receiptId !== evidence.receiptId || value.queueKind !== evidence.queueKind))
|
|
2594
|
+
return false;
|
|
2595
|
+
operation.routing.settlements = operation.routing.settlements.flatMap(value => {
|
|
2596
|
+
const updateIds = value.updateIds.filter(id => !evidence.updateIds.includes(id));
|
|
2597
|
+
return updateIds.length ? [{ ...value, updateIds }] : [];
|
|
2598
|
+
});
|
|
2599
|
+
operation.routing.settlements.push(evidence);
|
|
2600
|
+
return true;
|
|
2601
|
+
});
|
|
2602
|
+
},
|
|
2603
|
+
/** Existing cleanup owner retains deletion authority; this merely fences one invocation. */
|
|
2604
|
+
issueCleanup(expected, authority) {
|
|
2605
|
+
const intent = advance(expected, authority, operation => {
|
|
2606
|
+
if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || operation.routing.cleanup !== undefined)
|
|
2607
|
+
return false;
|
|
2608
|
+
operation.routing.cleanup = "issued";
|
|
2609
|
+
return true;
|
|
2610
|
+
});
|
|
2611
|
+
return intent ? { issued: true, intent } : undefined;
|
|
2612
|
+
},
|
|
2613
|
+
recordCleanup(expected, result, authority) {
|
|
2614
|
+
return advance(expected, authority, operation => {
|
|
2615
|
+
if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || !isDeepStrictEqual(result.target, operation.request.binding.target) ||
|
|
2616
|
+
(result.kind === "completed" ? operation.routing.cleanup !== "issued" :
|
|
2617
|
+
result.kind !== "not-issued" || operation.routing.cleanup !== undefined))
|
|
2618
|
+
return false;
|
|
2619
|
+
operation.routing.cleanup = result.kind;
|
|
2620
|
+
return true;
|
|
2621
|
+
});
|
|
2622
|
+
},
|
|
2623
|
+
/** Exact terminal CAS releases Restore protection only; it cannot settle or cancel recipient queue work. */
|
|
2624
|
+
retire(expected, authority) {
|
|
2625
|
+
return mutate(authority, (file, executor, operator) => {
|
|
2626
|
+
const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
|
|
2627
|
+
const operation = file.operations[index];
|
|
2628
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
2629
|
+
!isDeepStrictEqual(operation.executor, executor) || !workspaceRestoreSourcesSettled(operation) ||
|
|
2630
|
+
!["completed", "not-issued"].includes(operation.routing?.cleanup ?? ""))
|
|
2631
|
+
return undefined;
|
|
2632
|
+
file.operations.splice(index, 1);
|
|
2633
|
+
return operation;
|
|
2634
|
+
});
|
|
2635
|
+
},
|
|
2636
|
+
listTemporaryThreads: () => structuredClone(storage.read().temporaryThreads ?? []),
|
|
2637
|
+
reserveTemporaryThread(sourceInput, token, authority) {
|
|
2638
|
+
const source = structuredClone(sourceInput);
|
|
2639
|
+
let reserved = false;
|
|
2640
|
+
const entry = mutate(authority, (file, executor, operator) => {
|
|
2641
|
+
const entries = file.temporaryThreads ?? [];
|
|
2642
|
+
const existing = entries.find(value => value.source.journalBindingKey === source.journalBindingKey &&
|
|
2643
|
+
value.source.updateId === source.updateId);
|
|
2644
|
+
// A retained entry proves an earlier attempt; it is never a license for another creation.
|
|
2645
|
+
if (existing)
|
|
2646
|
+
return existing.operatorUserId === operator ? existing : undefined;
|
|
2647
|
+
const at = now();
|
|
2648
|
+
if (!restoreInteger(at))
|
|
2649
|
+
throw new Error("Invalid Workspace Restore clock.");
|
|
2650
|
+
const next = { source,
|
|
2651
|
+
inputs: [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }],
|
|
2652
|
+
operatorUserId: operator, executor, token, phase: "creating", forwardProtocol: "one-shot-v1",
|
|
2653
|
+
revision: 0, createdAtMs: at, updatedAtMs: at };
|
|
2654
|
+
if (!isTemporaryThreadEntry(next) || entries.some(value => value.token === token ||
|
|
2655
|
+
getTelegramTemporaryThreadInputs(value).some(input => input.journalBindingKey === source.journalBindingKey &&
|
|
2656
|
+
input.updateIds.includes(source.updateId))))
|
|
2657
|
+
return undefined;
|
|
2658
|
+
if (entries.length >= TEMPORARY_THREAD_CAPACITY)
|
|
2659
|
+
throw new Error("Temporary Thread capacity reached.");
|
|
2660
|
+
file.temporaryThreads = [...entries, next];
|
|
2661
|
+
reserved = true;
|
|
2662
|
+
return next;
|
|
2663
|
+
});
|
|
2664
|
+
return entry ? { reserved, entry } : undefined;
|
|
2665
|
+
},
|
|
2666
|
+
registerImplicitTemporaryThread(inputValue, targetInput, token, authority) {
|
|
2667
|
+
const input = structuredClone(inputValue), target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
|
|
2668
|
+
if (!isTemporaryThreadInput(input) || input.updateIds.length !== 1 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0)
|
|
2669
|
+
return undefined;
|
|
2670
|
+
return mutate(authority, (file, executor, operator) => {
|
|
2671
|
+
const entries = file.temporaryThreads ?? [];
|
|
2672
|
+
if (target.chatId !== operator || entries.some(value => value.token === token ||
|
|
2673
|
+
(value.target && targetMatches(value.target, target)) || getTelegramTemporaryThreadInputs(value).some(other => other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id)))))
|
|
2674
|
+
return undefined;
|
|
2675
|
+
const at = now();
|
|
2676
|
+
if (!restoreInteger(at))
|
|
2677
|
+
throw new Error("Invalid Workspace Restore clock.");
|
|
2678
|
+
const entry = { source: { journalBindingKey: input.journalBindingKey, updateId: input.updateIds[0] },
|
|
2679
|
+
inputs: [input], target, operatorUserId: operator, executor, token, phase: "created", forwardProtocol: "one-shot-v1",
|
|
2680
|
+
revision: 0, createdAtMs: at, updatedAtMs: at };
|
|
2681
|
+
if (!isTemporaryThreadEntry(entry))
|
|
2682
|
+
return undefined;
|
|
2683
|
+
if (entries.length >= TEMPORARY_THREAD_CAPACITY)
|
|
2684
|
+
throw new Error("Temporary Thread capacity reached.");
|
|
2685
|
+
file.temporaryThreads = [...entries, entry];
|
|
2686
|
+
return entry;
|
|
2687
|
+
});
|
|
2688
|
+
},
|
|
2689
|
+
acknowledgeTemporaryThread(expected, targetInput, authority) {
|
|
2690
|
+
const target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
|
|
2691
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
2692
|
+
if (entry.phase !== "creating" || target.chatId !== entry.operatorUserId || !Number.isSafeInteger(target.threadId) ||
|
|
2693
|
+
target.threadId <= 0 || file.temporaryThreads.some(value => !!value.target && targetMatches(value.target, target)) ||
|
|
2694
|
+
file.operations.some(({ request }) => targetMatches(request.target, target) || targetMatches(request.binding.target, target)))
|
|
2695
|
+
return false;
|
|
2696
|
+
entry.phase = "created";
|
|
2697
|
+
entry.target = target;
|
|
2698
|
+
return true;
|
|
2699
|
+
});
|
|
2700
|
+
},
|
|
2701
|
+
adoptTemporaryThread(expected, authority) {
|
|
2702
|
+
return advanceTemporary(expected, authority, (entry, _file, executor) => !isDeepStrictEqual(entry.executor, executor), true);
|
|
2703
|
+
},
|
|
2704
|
+
recordTemporaryThreadInput(expected, inputValue, authority) {
|
|
2705
|
+
const input = structuredClone(inputValue);
|
|
2706
|
+
if (!isTemporaryThreadInput(input))
|
|
2707
|
+
return undefined;
|
|
2708
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
2709
|
+
if (entry.phase !== "created" || input.journalBindingKey !== entry.source.journalBindingKey ||
|
|
2710
|
+
file.operations.some(({ request }) => targetMatches(request.target, entry.target)))
|
|
2711
|
+
return false;
|
|
2712
|
+
const inputs = getTelegramTemporaryThreadInputs(entry);
|
|
2713
|
+
if (inputs.some(value => isDeepStrictEqual(value, input)))
|
|
2714
|
+
return "unchanged";
|
|
2715
|
+
if (entry.cleanupIssued || inputs.some(value => value.updateIds.some(id => input.updateIds.includes(id))) ||
|
|
2716
|
+
file.temporaryThreads.some(value => value !== entry && getTelegramTemporaryThreadInputs(value).some(other => other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id)))))
|
|
2717
|
+
return false;
|
|
2718
|
+
if (inputs.length >= TEMPORARY_THREAD_INPUT_CAPACITY)
|
|
2719
|
+
throw new Error("Temporary Thread input capacity reached.");
|
|
2720
|
+
entry.inputs = [...inputs, input];
|
|
2721
|
+
return true;
|
|
2722
|
+
});
|
|
2723
|
+
},
|
|
2724
|
+
recordTemporaryThreadInputCancellation(expected, inputValue, authority, inspect) {
|
|
2725
|
+
const input = structuredClone(inputValue);
|
|
2726
|
+
if (!isTemporaryThreadInput(input) || typeof inspect !== "function")
|
|
2727
|
+
return undefined;
|
|
2728
|
+
const operatorUserId = authority.operatorUserId, executor = structuredClone(authority.executor);
|
|
2729
|
+
const owned = () => authority.isCurrent() && authority.operatorUserId === operatorUserId &&
|
|
2730
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
2731
|
+
// Every publication fence observes retained proof again; neither cached cancellation nor absence suffices.
|
|
2732
|
+
const current = () => owned() && input.updateIds.every(updateId => {
|
|
2733
|
+
const evidence = inspect(updateId);
|
|
2734
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
|
|
2735
|
+
evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
2736
|
+
}) && owned();
|
|
2737
|
+
const recorded = advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
|
|
2738
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2739
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
2740
|
+
request.source.updateIds.some(id => input.updateIds.includes(id))))
|
|
2741
|
+
return false;
|
|
2742
|
+
const cancelled = entry.cancelledInputs ?? [];
|
|
2743
|
+
if (cancelled.some(candidate => isDeepStrictEqual(candidate, input)))
|
|
2744
|
+
return "unchanged";
|
|
2745
|
+
if (entry.completedInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2746
|
+
entry.forwardedInputs?.some(candidate => isDeepStrictEqual(candidate, input)))
|
|
2747
|
+
return false;
|
|
2748
|
+
entry.cancelledInputs = [...cancelled, input];
|
|
2749
|
+
return true;
|
|
2750
|
+
});
|
|
2751
|
+
return recorded && current() ? recorded : undefined;
|
|
2752
|
+
},
|
|
2753
|
+
recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
|
|
2754
|
+
const input = structuredClone(inputValue);
|
|
2755
|
+
if (!isTemporaryThreadInput(input))
|
|
2756
|
+
return undefined;
|
|
2757
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
2758
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2759
|
+
[...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])]
|
|
2760
|
+
.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2761
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
2762
|
+
request.source.updateIds.some(id => input.updateIds.includes(id))))
|
|
2763
|
+
return false;
|
|
2764
|
+
entry.forwardedInputs = [...(entry.forwardedInputs ?? []), input];
|
|
2765
|
+
return true;
|
|
2766
|
+
});
|
|
2767
|
+
},
|
|
2768
|
+
recordTemporaryThreadInputCompletion(expected, inputValue, authority) {
|
|
2769
|
+
const input = structuredClone(inputValue);
|
|
2770
|
+
if (!isTemporaryThreadInput(input))
|
|
2771
|
+
return undefined;
|
|
2772
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
2773
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2774
|
+
entry.cancelledInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
2775
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
2776
|
+
request.source.updateIds.some(id => input.updateIds.includes(id))))
|
|
2777
|
+
return false;
|
|
2778
|
+
const completed = entry.completedInputs ?? [];
|
|
2779
|
+
if (completed.some(candidate => isDeepStrictEqual(candidate, input)))
|
|
2780
|
+
return "unchanged";
|
|
2781
|
+
entry.completedInputs = [...completed, input];
|
|
2782
|
+
return true;
|
|
2783
|
+
});
|
|
2784
|
+
},
|
|
2785
|
+
issueTemporaryThreadCleanup(expected, authority) {
|
|
2786
|
+
const entry = advanceTemporary(expected, authority, value => {
|
|
2787
|
+
if (value.phase !== "created" || value.cleanupIssued || !isTelegramTemporaryThreadFullyResolved(value))
|
|
2788
|
+
return false;
|
|
2789
|
+
value.cleanupIssued = true;
|
|
2790
|
+
return true;
|
|
2791
|
+
});
|
|
2792
|
+
return entry ? { issued: true, entry } : undefined;
|
|
2793
|
+
},
|
|
2794
|
+
retireTemporaryThread(expected, authority, completed) {
|
|
2795
|
+
return mutate(authority, (file, executor, operator) => {
|
|
2796
|
+
const entries = file.temporaryThreads ?? [];
|
|
2797
|
+
const index = entries.findIndex(value => isDeepStrictEqual(value, expected));
|
|
2798
|
+
const entry = entries[index];
|
|
2799
|
+
if (!entry || entry.operatorUserId !== operator || !isDeepStrictEqual(entry.executor, executor) ||
|
|
2800
|
+
(getTelegramTemporaryThreadInputs(entry).length > 1 && !isTelegramTemporaryThreadFullyResolved(entry) &&
|
|
2801
|
+
!isTemporaryThreadReleasedByCompletion(entry, completed)))
|
|
2802
|
+
return undefined;
|
|
2803
|
+
const remaining = entries.filter((_value, position) => position !== index);
|
|
2804
|
+
if (remaining.length)
|
|
2805
|
+
file.temporaryThreads = remaining;
|
|
2806
|
+
else
|
|
2807
|
+
delete file.temporaryThreads;
|
|
2808
|
+
return entry;
|
|
2809
|
+
});
|
|
2810
|
+
},
|
|
2811
|
+
forgetPreviousWorld(authority) {
|
|
2812
|
+
if (!authority.isCurrent())
|
|
2813
|
+
return undefined;
|
|
2814
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
2815
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator))
|
|
2816
|
+
return undefined;
|
|
2817
|
+
const current = () => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
|
|
2818
|
+
const previous = (value) => value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
|
|
2819
|
+
const before = storage.read(), file = structuredClone(before);
|
|
2820
|
+
const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(previous);
|
|
2821
|
+
if (!current())
|
|
2822
|
+
return undefined;
|
|
2823
|
+
if (!operations.length && !temporaryThreads.length)
|
|
2824
|
+
return { operations, temporaryThreads };
|
|
2825
|
+
file.operations = file.operations.filter(value => !previous(value));
|
|
2826
|
+
const remaining = (file.temporaryThreads ?? []).filter(value => !previous(value));
|
|
2827
|
+
if (remaining.length)
|
|
2828
|
+
file.temporaryThreads = remaining;
|
|
2829
|
+
else
|
|
2830
|
+
delete file.temporaryThreads;
|
|
2831
|
+
if (file.revision === Number.MAX_SAFE_INTEGER)
|
|
2832
|
+
throw new Error("Workspace Restore revision exhausted.");
|
|
2833
|
+
file.revision += 1;
|
|
2834
|
+
return storage.update(before, file, current) ? structuredClone({ operations, temporaryThreads }) : undefined;
|
|
2835
|
+
},
|
|
2836
|
+
/** Ends protection without cleanup: the old Thread is kept, and no source was ever dispatched. */
|
|
2837
|
+
retireAbandoned(expected, abandonedUpdateIds, authority) {
|
|
2838
|
+
const abandoned = [...new Set(abandonedUpdateIds)].sort((left, right) => left - right);
|
|
2839
|
+
return mutate(authority, (file, executor, operator) => {
|
|
2840
|
+
const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
|
|
2841
|
+
const operation = file.operations[index];
|
|
2842
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
2843
|
+
!isDeepStrictEqual(operation.executor, executor) || operation.phase !== "ready" || operation.routing !== undefined ||
|
|
2844
|
+
!isDeepStrictEqual(abandoned, operation.request.source.updateIds))
|
|
2845
|
+
return undefined;
|
|
2846
|
+
file.operations.splice(index, 1);
|
|
2847
|
+
return operation;
|
|
2848
|
+
});
|
|
2849
|
+
},
|
|
2850
|
+
/** Caller proves current canonical ownership and authenticates a read-only observation, including successors. */
|
|
2851
|
+
confirmInspectedReady(expected, observed, authority) {
|
|
2852
|
+
return advance(expected, authority, operation => {
|
|
2853
|
+
if ((operation.phase !== "recipient-issued" && operation.phase !== "ready") || !isTelegramWorkspaceRestoreRecipient(observed) ||
|
|
2854
|
+
observed.sessionId !== operation.request.binding.sessionId)
|
|
2855
|
+
return false;
|
|
2856
|
+
operation.phase = "ready";
|
|
2857
|
+
operation.readyRecipient = structuredClone(observed);
|
|
2858
|
+
return true;
|
|
2859
|
+
});
|
|
2860
|
+
},
|
|
2861
|
+
/** Caller authenticates the exact-generation ACK; stored metadata alone is never readiness proof. */
|
|
2862
|
+
confirmReady(expected, acknowledged, authority) {
|
|
2863
|
+
return advance(expected, authority, operation => {
|
|
2864
|
+
if (operation.phase !== "recipient-issued" || !isDeepStrictEqual(operation.recipient, acknowledged))
|
|
2865
|
+
return false;
|
|
2866
|
+
operation.phase = "ready";
|
|
2867
|
+
return true;
|
|
2868
|
+
});
|
|
2869
|
+
},
|
|
2870
|
+
};
|
|
2871
|
+
},
|
|
1514
2872
|
list() {
|
|
1515
2873
|
return Array.from(records.values()).map(cloneRecord);
|
|
1516
2874
|
},
|
|
@@ -1547,6 +2905,10 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1547
2905
|
}));
|
|
1548
2906
|
},
|
|
1549
2907
|
reserveThread(reservation) {
|
|
2908
|
+
if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, reservation)) ||
|
|
2909
|
+
conflictsWithTemporaryThread(workspaceRestore, reservation.target)) {
|
|
2910
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
2911
|
+
}
|
|
1550
2912
|
const next = { ...reservation };
|
|
1551
2913
|
reservations = reservations.filter((existing) => existing.slot !== next.slot &&
|
|
1552
2914
|
!targetMatches(existing.target, next.target));
|
|
@@ -1554,6 +2916,10 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1554
2916
|
markDirty();
|
|
1555
2917
|
},
|
|
1556
2918
|
upsertPendingProvision(provision) {
|
|
2919
|
+
if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision)) ||
|
|
2920
|
+
conflictsWithTemporaryThread(workspaceRestore, provision.target)) {
|
|
2921
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
2922
|
+
}
|
|
1557
2923
|
const next = {
|
|
1558
2924
|
...provision,
|
|
1559
2925
|
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
@@ -1566,9 +2932,9 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1566
2932
|
const path = getPath();
|
|
1567
2933
|
const recoveryPath = getRecoveryPath(path);
|
|
1568
2934
|
await mkdir(dirname(recoveryPath), { recursive: true });
|
|
1569
|
-
|
|
1570
|
-
const recoveries = readProvisionRecoveries(path);
|
|
1571
|
-
|
|
2935
|
+
withStoreTransaction(path, () => {
|
|
2936
|
+
const recoveries = readProvisionRecoveries(path, true);
|
|
2937
|
+
const recovery = {
|
|
1572
2938
|
instanceId: provision.instanceId,
|
|
1573
2939
|
...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
|
|
1574
2940
|
...(provision.leaderEpoch !== undefined
|
|
@@ -1576,6 +2942,12 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1576
2942
|
: {}),
|
|
1577
2943
|
target: { ...target },
|
|
1578
2944
|
};
|
|
2945
|
+
if (Object.hasOwn(recoveries, provision.id)) {
|
|
2946
|
+
if (!isDeepStrictEqual(recoveries[provision.id], recovery))
|
|
2947
|
+
throw new Error("Conflicting Workspace provisioning recovery evidence.");
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
recoveries[provision.id] = recovery;
|
|
1579
2951
|
const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1580
2952
|
writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
|
|
1581
2953
|
encoding: "utf8",
|
|
@@ -1624,10 +2996,41 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1624
2996
|
markDirty();
|
|
1625
2997
|
},
|
|
1626
2998
|
setStatusSnapshot(snapshot) {
|
|
1627
|
-
if (!loadedPath)
|
|
2999
|
+
if (!loadedPath) {
|
|
1628
3000
|
loadedPath = getPath();
|
|
3001
|
+
loadedProfile = activeProfile();
|
|
3002
|
+
}
|
|
1629
3003
|
statusSnapshot = { ...snapshot };
|
|
1630
|
-
|
|
3004
|
+
},
|
|
3005
|
+
persistStatus() {
|
|
3006
|
+
if (runtimeProjection)
|
|
3007
|
+
return runtimeProjection.persist({ runtime: statusSnapshot.runtime ?? {},
|
|
3008
|
+
liveRoster: statusSnapshot.liveRoster ?? {}, diagnostics: statusSnapshot.diagnostics ?? {} }).then(() => undefined);
|
|
3009
|
+
const write = statusQueue.then(async () => {
|
|
3010
|
+
if (options.canPersist && !options.canPersist())
|
|
3011
|
+
return;
|
|
3012
|
+
const body = { version: 1, source: "snapshot", ...statusSnapshot };
|
|
3013
|
+
// Normalize to wire JSON; object key order and omitted undefined are not changes.
|
|
3014
|
+
const semantic = JSON.parse(JSON.stringify(body));
|
|
3015
|
+
const statusPath = getStatusPath();
|
|
3016
|
+
if (isDeepStrictEqual(semantic, lastStatusSemantic) && statusPath === lastStatusPath)
|
|
3017
|
+
return;
|
|
3018
|
+
await mkdir(dirname(statusPath), { recursive: true });
|
|
3019
|
+
const tempPath = `${statusPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
3020
|
+
await writeFile(tempPath, `${JSON.stringify({ ...body, writtenAtMs: (options.getNowMs ?? Date.now)() }, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
3021
|
+
try {
|
|
3022
|
+
renameSync(tempPath, statusPath);
|
|
3023
|
+
chmodSync(statusPath, 0o600);
|
|
3024
|
+
}
|
|
3025
|
+
catch (error) {
|
|
3026
|
+
await unlink(tempPath).catch(() => undefined);
|
|
3027
|
+
throw error;
|
|
3028
|
+
}
|
|
3029
|
+
lastStatusSemantic = semantic;
|
|
3030
|
+
lastStatusPath = statusPath;
|
|
3031
|
+
});
|
|
3032
|
+
statusQueue = write.then(() => undefined, () => undefined);
|
|
3033
|
+
return write;
|
|
1631
3034
|
},
|
|
1632
3035
|
getByProfileKey(profileKey) {
|
|
1633
3036
|
const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
|
|
@@ -1698,7 +3101,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1698
3101
|
listWorkspaceRetirementIntents() {
|
|
1699
3102
|
return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
|
|
1700
3103
|
},
|
|
1701
|
-
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
|
|
3104
|
+
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete, journalSources) {
|
|
1702
3105
|
if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
|
|
1703
3106
|
return undefined;
|
|
1704
3107
|
}
|
|
@@ -1708,15 +3111,23 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1708
3111
|
!journalBindingKeys.every((bindingKey) => typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512))
|
|
1709
3112
|
return undefined;
|
|
1710
3113
|
const keys = Array.from(new Set(journalBindingKeys));
|
|
3114
|
+
const sources = journalSources === undefined ? current.journalSources ?? [] : normalizeWorkspaceJournalSources(journalSources);
|
|
3115
|
+
if (!sources || sources.some(source => !(current.journalSources ?? []).some(retained => isDeepStrictEqual(retained, source))))
|
|
3116
|
+
return undefined;
|
|
1711
3117
|
if ((current.journalBindingsComplete === true) === complete &&
|
|
1712
|
-
isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
|
|
3118
|
+
isDeepStrictEqual(current.journalBindingKeys ?? [], keys) && isDeepStrictEqual(current.journalSources ?? [], sources)) {
|
|
1713
3119
|
return cloneWorkspaceBinding(current);
|
|
1714
3120
|
}
|
|
1715
3121
|
const next = { ...current, updatedAtMs: getNowMs() };
|
|
1716
|
-
|
|
3122
|
+
// A complete empty set stays explicit so persisted completeness survives a lossless reload.
|
|
3123
|
+
if (keys.length || complete)
|
|
1717
3124
|
next.journalBindingKeys = keys;
|
|
1718
3125
|
else
|
|
1719
3126
|
delete next.journalBindingKeys;
|
|
3127
|
+
if (sources.length)
|
|
3128
|
+
next.journalSources = sources.map(source => ({ ...source }));
|
|
3129
|
+
else
|
|
3130
|
+
delete next.journalSources;
|
|
1720
3131
|
if (complete)
|
|
1721
3132
|
next.journalBindingsComplete = true;
|
|
1722
3133
|
else
|
|
@@ -1725,6 +3136,15 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
1725
3136
|
markDirty();
|
|
1726
3137
|
return cloneWorkspaceBinding(next);
|
|
1727
3138
|
},
|
|
3139
|
+
async persistWorkspaceJournalEvidence(expected, isCurrent) {
|
|
3140
|
+
const path = getPath();
|
|
3141
|
+
const frameCurrent = () => getPath() === path && isCurrent() && isDeepStrictEqual(workspaceBindings.get(getWorkspaceBindingMapKey(expected)), expected);
|
|
3142
|
+
if (!frameCurrent())
|
|
3143
|
+
return false;
|
|
3144
|
+
const current = () => frameCurrent() && options.canPersist?.() !== false;
|
|
3145
|
+
const published = await persistSnapshot(undefined, current);
|
|
3146
|
+
return published && current();
|
|
3147
|
+
},
|
|
1728
3148
|
upsertWorkspaceRetirementIntent(intent) {
|
|
1729
3149
|
const next = normalizeWorkspaceRetirementIntent(intent);
|
|
1730
3150
|
if (!next)
|
|
@@ -2311,6 +3731,18 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
2311
3731
|
let claim = workspaceClaims.get(nextMapKey);
|
|
2312
3732
|
const replacedTargetBinding = Array.from(workspaceBindings.values()).find((existing) => existing.bindingKey !== next.bindingKey &&
|
|
2313
3733
|
targetMatches(existing.target, next.target));
|
|
3734
|
+
const retainedSources = [
|
|
3735
|
+
...(workspaceBindings.get(nextMapKey)?.journalSources ?? []),
|
|
3736
|
+
...(replacedTargetBinding?.journalSources ?? []),
|
|
3737
|
+
...(next.journalSources ?? []),
|
|
3738
|
+
];
|
|
3739
|
+
if (retainedSources.length) {
|
|
3740
|
+
const uniqueSources = Array.from(new Map(retainedSources.map(source => [JSON.stringify(source), source])).values());
|
|
3741
|
+
const journalSources = normalizeWorkspaceJournalSources(uniqueSources);
|
|
3742
|
+
if (!journalSources)
|
|
3743
|
+
return undefined;
|
|
3744
|
+
next.journalSources = journalSources;
|
|
3745
|
+
}
|
|
2314
3746
|
if (claimInstanceId && claim?.instanceId === claimInstanceId &&
|
|
2315
3747
|
replacedTargetBinding?.slot) {
|
|
2316
3748
|
claim = {
|
|
@@ -2352,7 +3784,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
2352
3784
|
...(previous.journalBindingKeys ?? []),
|
|
2353
3785
|
...(next.journalBindingKeys ?? []),
|
|
2354
3786
|
]));
|
|
2355
|
-
if (journalBindingKeys.length)
|
|
3787
|
+
if (journalBindingKeys.length || previous.journalBindingsComplete)
|
|
2356
3788
|
next.journalBindingKeys = journalBindingKeys;
|
|
2357
3789
|
else
|
|
2358
3790
|
delete next.journalBindingKeys;
|
|
@@ -2662,49 +4094,7 @@ function isTelegramTopicTargetSlotOccupied(slot, records, reservations = [], pen
|
|
|
2662
4094
|
}
|
|
2663
4095
|
return false;
|
|
2664
4096
|
}
|
|
2665
|
-
|
|
2666
|
-
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
2667
|
-
}
|
|
2668
|
-
function getGraphemeSegments(value) {
|
|
2669
|
-
const segmenter = Intl.Segmenter;
|
|
2670
|
-
if (!segmenter)
|
|
2671
|
-
return Array.from(value);
|
|
2672
|
-
return Array.from(new segmenter(undefined, { granularity: "grapheme" }).segment(value), (part) => part.segment);
|
|
2673
|
-
}
|
|
2674
|
-
export function getTelegramTopicIdentityName(threadName) {
|
|
2675
|
-
return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
|
|
2676
|
-
.join("")
|
|
2677
|
-
.trim();
|
|
2678
|
-
}
|
|
2679
|
-
const TELEGRAM_THREAD_NAME_PALETTE = {
|
|
2680
|
-
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
2681
|
-
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
2682
|
-
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
2683
|
-
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
2684
|
-
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
2685
|
-
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
2686
|
-
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
2687
|
-
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
2688
|
-
I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
|
|
2689
|
-
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
2690
|
-
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
2691
|
-
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
2692
|
-
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
2693
|
-
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
2694
|
-
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
2695
|
-
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
2696
|
-
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
2697
|
-
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
2698
|
-
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
2699
|
-
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
2700
|
-
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
2701
|
-
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
2702
|
-
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
2703
|
-
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
2704
|
-
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
2705
|
-
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
2706
|
-
};
|
|
2707
|
-
export function listOccupiedTelegramThreadIdentities(input) {
|
|
4097
|
+
function listOccupiedTelegramThreadIdentities(input) {
|
|
2708
4098
|
const occupied = new Set();
|
|
2709
4099
|
const add = (threadName) => {
|
|
2710
4100
|
if (!threadName)
|
|
@@ -2737,35 +4127,6 @@ export function listOccupiedTelegramThreadIdentities(input) {
|
|
|
2737
4127
|
}
|
|
2738
4128
|
return Array.from(occupied);
|
|
2739
4129
|
}
|
|
2740
|
-
export function chooseTelegramThreadName(input) {
|
|
2741
|
-
if (!input.slot || !/^[A-Z]$/.test(input.slot))
|
|
2742
|
-
return undefined;
|
|
2743
|
-
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
2744
|
-
if (!names || names.length === 0)
|
|
2745
|
-
return undefined;
|
|
2746
|
-
const occupied = new Set((input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)));
|
|
2747
|
-
const start = input.getRandom
|
|
2748
|
-
? Math.max(0, Math.min(names.length - 1, Math.floor(input.getRandom() * names.length)))
|
|
2749
|
-
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
2750
|
-
for (let offset = 0; offset < names.length; offset += 1) {
|
|
2751
|
-
const name = names[(start + offset) % names.length];
|
|
2752
|
-
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
2753
|
-
return name;
|
|
2754
|
-
}
|
|
2755
|
-
for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
2756
|
-
for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
|
|
2757
|
-
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
2758
|
-
return name;
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
return undefined;
|
|
2762
|
-
}
|
|
2763
|
-
function getTelegramThreadNameLeadingSlot(threadName) {
|
|
2764
|
-
if (!threadName)
|
|
2765
|
-
return undefined;
|
|
2766
|
-
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
2767
|
-
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
2768
|
-
}
|
|
2769
4130
|
function getNextTelegramThreadNamePaletteSlot(records, fallbackSlot) {
|
|
2770
4131
|
let maxCode = "A".charCodeAt(0) - 1;
|
|
2771
4132
|
for (const record of records) {
|
|
@@ -2784,79 +4145,6 @@ function getNextTelegramThreadNamePaletteSlot(records, fallbackSlot) {
|
|
|
2784
4145
|
code = "A".charCodeAt(0);
|
|
2785
4146
|
return String.fromCharCode(code);
|
|
2786
4147
|
}
|
|
2787
|
-
function getTelegramThreadNameEntropyIndex(entropy, length) {
|
|
2788
|
-
if (length <= 1)
|
|
2789
|
-
return 0;
|
|
2790
|
-
if (typeof entropy === "number" && entropy < 1_000_000_000_000)
|
|
2791
|
-
return 0;
|
|
2792
|
-
const value = entropy === undefined ? "0" : String(entropy);
|
|
2793
|
-
let hash = 2166136261;
|
|
2794
|
-
for (const char of value) {
|
|
2795
|
-
hash ^= char.codePointAt(0) ?? 0;
|
|
2796
|
-
hash = Math.imul(hash, 16777619) >>> 0;
|
|
2797
|
-
}
|
|
2798
|
-
return hash % length;
|
|
2799
|
-
}
|
|
2800
|
-
export function getTelegramTopicThreadNameValidationError(threadName, _slot) {
|
|
2801
|
-
const identity = getTelegramTopicIdentityName(threadName);
|
|
2802
|
-
const reasons = [];
|
|
2803
|
-
if (!identity)
|
|
2804
|
-
reasons.push("it is empty after trimming");
|
|
2805
|
-
if (/\s/.test(identity))
|
|
2806
|
-
reasons.push("it contains spaces");
|
|
2807
|
-
if (/[^A-Za-z]/.test(identity)) {
|
|
2808
|
-
reasons.push("it contains characters outside Latin A-Z letters");
|
|
2809
|
-
}
|
|
2810
|
-
if (!/^[A-Z]/.test(identity)) {
|
|
2811
|
-
reasons.push("it does not start with an uppercase Latin letter");
|
|
2812
|
-
}
|
|
2813
|
-
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
2814
|
-
if (genericLabels.has(identity.toLowerCase())) {
|
|
2815
|
-
reasons.push("it is a generic role label");
|
|
2816
|
-
}
|
|
2817
|
-
if (/^[A-Z]$/.test(identity))
|
|
2818
|
-
reasons.push("it is only a bare slot letter");
|
|
2819
|
-
if (reasons.length === 0)
|
|
2820
|
-
return undefined;
|
|
2821
|
-
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.`;
|
|
2822
|
-
}
|
|
2823
|
-
export function getTelegramManualThreadDisplayNameValidationError(threadName) {
|
|
2824
|
-
const trimmed = threadName.trim();
|
|
2825
|
-
const normalized = trimmed.replace(/\s+/g, " ");
|
|
2826
|
-
const reasons = [];
|
|
2827
|
-
if (!trimmed)
|
|
2828
|
-
reasons.push("it is empty after trimming");
|
|
2829
|
-
if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
|
|
2830
|
-
reasons.push("it contains characters outside printable ASCII");
|
|
2831
|
-
}
|
|
2832
|
-
if (normalized.length > 96)
|
|
2833
|
-
reasons.push("it is longer than 96 characters");
|
|
2834
|
-
if (/^[A-Z]$/.test(normalized)) {
|
|
2835
|
-
reasons.push("a bare slot letter is reserved for reset to automatic");
|
|
2836
|
-
}
|
|
2837
|
-
if (reasons.length === 0)
|
|
2838
|
-
return undefined;
|
|
2839
|
-
return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
|
|
2840
|
-
}
|
|
2841
|
-
export function isTelegramTopicThreadNameValidForSlot(threadName, slot) {
|
|
2842
|
-
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
2843
|
-
}
|
|
2844
|
-
function applyTopicNameTemplate(template, request, slot) {
|
|
2845
|
-
const threadName = request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
2846
|
-
let result = template
|
|
2847
|
-
.replaceAll("{threadName}", threadName)
|
|
2848
|
-
.replaceAll("{profileKey}", request.profileKey)
|
|
2849
|
-
.replaceAll("{instanceId}", request.instanceId);
|
|
2850
|
-
if (slot)
|
|
2851
|
-
result = result.replaceAll("{slot}", slot);
|
|
2852
|
-
return result;
|
|
2853
|
-
}
|
|
2854
|
-
export function getTelegramTopicName(request, template = "{slot}", slot) {
|
|
2855
|
-
const name = applyTopicNameTemplate(template, request, slot)
|
|
2856
|
-
.replace(/\s+/g, " ")
|
|
2857
|
-
.trim();
|
|
2858
|
-
return (name || slot || "Pi").slice(0, 128);
|
|
2859
|
-
}
|
|
2860
4148
|
function asInteger(value) {
|
|
2861
4149
|
if (typeof value === "number" && Number.isInteger(value))
|
|
2862
4150
|
return value;
|
|
@@ -3541,13 +4829,6 @@ export function isTelegramTopicModeUnavailableError(error) {
|
|
|
3541
4829
|
message.includes("threaded mode") ||
|
|
3542
4830
|
message.includes("method is available only for"));
|
|
3543
4831
|
}
|
|
3544
|
-
export function getTelegramTopicTitleForThreadName(threadName, slot, template = "{threadName}") {
|
|
3545
|
-
return getTelegramTopicName({
|
|
3546
|
-
instanceId: "",
|
|
3547
|
-
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
3548
|
-
threadName,
|
|
3549
|
-
}, template, slot);
|
|
3550
|
-
}
|
|
3551
4832
|
export function createTelegramTopicTargetRenamer(deps) {
|
|
3552
4833
|
return async (request) => {
|
|
3553
4834
|
const threadName = normalizeTelegramTopicTargetThreadName(request.threadName);
|
|
@@ -3599,6 +4880,12 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3599
4880
|
.listPendingProvisions()
|
|
3600
4881
|
.find((pending) => pending.profileKey === request.profileKey ||
|
|
3601
4882
|
pending.instanceId === request.instanceId);
|
|
4883
|
+
const pendingTarget = pendingForRequest?.target;
|
|
4884
|
+
if (pendingForRequest && request.workspaceBindingKey &&
|
|
4885
|
+
pendingForRequest.workspaceBindingKey !== request.workspaceBindingKey &&
|
|
4886
|
+
!(pendingTarget && deps.store.listWorkspaceBindings().some((binding) => binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, pendingTarget)))) {
|
|
4887
|
+
throw new Error("Telegram unfinished Thread creation does not match this session binding.");
|
|
4888
|
+
}
|
|
3602
4889
|
if (pendingForRequest?.target) {
|
|
3603
4890
|
const target = pendingForRequest.target;
|
|
3604
4891
|
const observation = deps.store.listSyncObservations().find((entry) => targetMatches(entry.target, target));
|
|
@@ -3611,8 +4898,11 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3611
4898
|
pendingForRequest = undefined;
|
|
3612
4899
|
}
|
|
3613
4900
|
}
|
|
4901
|
+
const matchesWorkspace = (target) => !request.workspaceBindingKey ||
|
|
4902
|
+
deps.store.listWorkspaceBindings().some((binding) => binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, target)) ||
|
|
4903
|
+
(!!pendingForRequest?.target && targetMatches(pendingForRequest.target, target));
|
|
3614
4904
|
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
3615
|
-
if (existing && isCurrentThreadRecord(existing)) {
|
|
4905
|
+
if (existing && isCurrentThreadRecord(existing) && matchesWorkspace(existing.target)) {
|
|
3616
4906
|
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
3617
4907
|
if (!slot) {
|
|
3618
4908
|
throw new TelegramWorkspaceSlotUnavailableError();
|
|
@@ -3691,7 +4981,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3691
4981
|
throw new Error(`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`);
|
|
3692
4982
|
}
|
|
3693
4983
|
const activeForInstance = deps.store.getActiveByInstanceId(request.instanceId);
|
|
3694
|
-
if (activeForInstance) {
|
|
4984
|
+
if (activeForInstance && matchesWorkspace(activeForInstance.target)) {
|
|
3695
4985
|
if (!activeForInstance.slot) {
|
|
3696
4986
|
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
3697
4987
|
}
|
|
@@ -3723,12 +5013,12 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3723
5013
|
const preferredNameSlot = getTelegramThreadNameLeadingSlot(candidateThreadName) ??
|
|
3724
5014
|
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
3725
5015
|
request.preferredSlot;
|
|
3726
|
-
const slot = existing
|
|
5016
|
+
const slot = (existing && matchesWorkspace(existing.target) ? existing.slot : undefined) ??
|
|
3727
5017
|
deps.store.allocateSlot(request.profileKey, isManualFollowerRequest
|
|
3728
5018
|
? request.preferredSlot
|
|
3729
5019
|
: (request.preferredSlot ??
|
|
3730
5020
|
(candidateThreadName ? undefined : identity?.slot) ??
|
|
3731
|
-
preferredNameSlot), request.workspaceBindingKey);
|
|
5021
|
+
preferredNameSlot), request.workspaceBindingKey, { excludeCurrentRecord: !!existing && !matchesWorkspace(existing.target) });
|
|
3732
5022
|
if (!slot) {
|
|
3733
5023
|
throw new TelegramWorkspaceSlotUnavailableError();
|
|
3734
5024
|
}
|
|
@@ -3752,7 +5042,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3752
5042
|
if (!projectedTitle?.trim()) {
|
|
3753
5043
|
throw new Error("Telegram Thread display identity is missing or ambiguous.");
|
|
3754
5044
|
}
|
|
3755
|
-
displayTitle =
|
|
5045
|
+
displayTitle = buildTelegramTopicName({ ...request, threadName: projectedTitle }, "{threadName}", slot);
|
|
3756
5046
|
}
|
|
3757
5047
|
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
3758
5048
|
const pendingOwner = request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
@@ -3762,6 +5052,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3762
5052
|
owner: pendingOwner,
|
|
3763
5053
|
instanceId: request.instanceId,
|
|
3764
5054
|
profileKey: request.profileKey,
|
|
5055
|
+
...(request.workspaceBindingKey ? { workspaceBindingKey: request.workspaceBindingKey } : {}),
|
|
3765
5056
|
threadName: requestThreadName,
|
|
3766
5057
|
...(displayTitle ? { displayTitle } : {}),
|
|
3767
5058
|
slot,
|
|
@@ -3776,7 +5067,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
|
|
|
3776
5067
|
assertLeaderEpoch("before-createForumTopic");
|
|
3777
5068
|
const topic = await deps.callApi("createForumTopic", {
|
|
3778
5069
|
chat_id: deps.topicChatId,
|
|
3779
|
-
name: displayTitle ??
|
|
5070
|
+
name: displayTitle ?? buildTelegramTopicName({
|
|
3780
5071
|
...request,
|
|
3781
5072
|
...(requestThreadName ? { threadName: requestThreadName } : {}),
|
|
3782
5073
|
}, deps.topicNameTemplate ??
|