@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/lib/threads.ts
CHANGED
|
@@ -1,36 +1,94 @@
|
|
|
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
|
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import {
|
|
9
9
|
chmodSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
constants,
|
|
10
12
|
existsSync,
|
|
13
|
+
fstatSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readFile as readFileCallback,
|
|
11
18
|
readFileSync,
|
|
12
19
|
renameSync,
|
|
20
|
+
unlinkSync,
|
|
13
21
|
writeFileSync,
|
|
14
22
|
} from "node:fs";
|
|
15
23
|
import {
|
|
16
24
|
chmod,
|
|
17
25
|
mkdir,
|
|
18
|
-
readFile,
|
|
19
|
-
rename,
|
|
20
26
|
unlink,
|
|
21
27
|
writeFile,
|
|
22
28
|
} from "node:fs/promises";
|
|
23
|
-
import {
|
|
24
|
-
import { isDeepStrictEqual } from "node:util";
|
|
29
|
+
import { basename, dirname, join } from "node:path";
|
|
30
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
25
31
|
|
|
26
32
|
import {
|
|
27
33
|
isTelegramApiCommitUnknownError,
|
|
28
34
|
TelegramApiCommitUnknownError,
|
|
29
35
|
type TelegramApiCallOptions,
|
|
30
36
|
} from "./telegram-api.ts";
|
|
31
|
-
import
|
|
32
|
-
|
|
37
|
+
import {
|
|
38
|
+
areTelegramTargetsEqual as targetMatches,
|
|
39
|
+
getTelegramTargetKey as getTargetRecoveryHintKey,
|
|
40
|
+
type TelegramTarget,
|
|
41
|
+
} from "./target.ts";
|
|
42
|
+
import { isWireRecord as restoreObject, isNonEmptyWireString as restoreText } from "./wire.ts";
|
|
43
|
+
import {
|
|
44
|
+
chooseTelegramThreadName,
|
|
45
|
+
getTelegramManualThreadDisplayNameValidationError,
|
|
46
|
+
getTelegramThreadNameLeadingSlot,
|
|
47
|
+
getTelegramTopicIdentityName,
|
|
48
|
+
getTelegramTopicName as buildTelegramTopicName,
|
|
49
|
+
getTelegramTopicTitleForThreadName,
|
|
50
|
+
isTelegramTopicThreadNameValidForSlot,
|
|
51
|
+
normalizeTelegramTopicTargetThreadName,
|
|
52
|
+
} from "./thread-naming.ts";
|
|
53
|
+
export {
|
|
54
|
+
chooseTelegramThreadName,
|
|
55
|
+
createTelegramThreadName,
|
|
56
|
+
getTelegramManualThreadDisplayNameValidationError,
|
|
57
|
+
getTelegramTopicIdentityName,
|
|
58
|
+
getTelegramTopicThreadNameValidationError,
|
|
59
|
+
getTelegramTopicTitleForThreadName,
|
|
60
|
+
isTelegramTopicThreadNameValidForSlot,
|
|
61
|
+
type TelegramThreadNameInput,
|
|
62
|
+
} from "./thread-naming.ts";
|
|
63
|
+
|
|
64
|
+
// Preserve the legacy provision-request signature without a runtime wrapper.
|
|
65
|
+
export const getTelegramTopicName: (
|
|
66
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
67
|
+
template?: string,
|
|
68
|
+
slot?: string,
|
|
69
|
+
) => string = buildTelegramTopicName;
|
|
70
|
+
import {
|
|
71
|
+
TELEGRAM_WORKSPACE_KEY_MAX_LENGTH,
|
|
72
|
+
createTelegramSessionKey,
|
|
73
|
+
createTelegramWorkspaceBindingIdentityWithKey,
|
|
74
|
+
createTelegramWorkspaceDirectoryKey,
|
|
75
|
+
normalizeTelegramSessionId,
|
|
76
|
+
normalizeTelegramWorkspacePath,
|
|
77
|
+
type TelegramWorkspaceBindingIdentity,
|
|
78
|
+
} from "./workspace-identity.ts";
|
|
79
|
+
export {
|
|
80
|
+
createTelegramWorkspaceBindingIdentity,
|
|
81
|
+
createTelegramWorkspaceDirectoryKey,
|
|
82
|
+
normalizeTelegramSessionId,
|
|
83
|
+
normalizeTelegramWorkspacePath,
|
|
84
|
+
type TelegramWorkspaceBindingIdentity,
|
|
85
|
+
} from "./workspace-identity.ts";
|
|
86
|
+
import { withTelegramFileTransaction, readTelegramRuntimeState,
|
|
87
|
+
type TelegramLockRuntime, type TelegramLockContext, type TelegramRuntimeStateMutation,
|
|
88
|
+
type TelegramOwnedStatePublicationOptions, type TelegramOwnedStatePublicationResult,
|
|
89
|
+
} from "./locks.ts";
|
|
33
90
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
91
|
+
import { createTelegramRuntimeProjectionStore } from "./status.ts";
|
|
34
92
|
import {
|
|
35
93
|
planTelegramWorkspaceSlotAllocation,
|
|
36
94
|
TELEGRAM_WORKSPACE_SLOTS,
|
|
@@ -42,153 +100,6 @@ import {
|
|
|
42
100
|
resolveTelegramProfileTempFilePath,
|
|
43
101
|
} from "./paths.ts";
|
|
44
102
|
|
|
45
|
-
export interface TelegramThreadNameInput {
|
|
46
|
-
seed: string;
|
|
47
|
-
cwd?: string;
|
|
48
|
-
role?: "leader" | "follower";
|
|
49
|
-
peers?: readonly string[];
|
|
50
|
-
slot?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface TelegramWorkspaceBindingIdentity {
|
|
54
|
-
cwd: string;
|
|
55
|
-
workspaceKey: string;
|
|
56
|
-
/** Exact durable Pi session identity; absent only on legacy cwd-only bindings. */
|
|
57
|
-
sessionId?: string;
|
|
58
|
-
/** Full SHA-256 index component for session-qualified bindings. */
|
|
59
|
-
sessionKey?: string;
|
|
60
|
-
/** Immutable legacy binding-key component, not the displayed global letter. */
|
|
61
|
-
instanceSlot: string;
|
|
62
|
-
bindingKey: string;
|
|
63
|
-
/** Profile-wide letter reserved by the transient claim. */
|
|
64
|
-
slot?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
|
|
68
|
-
const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
|
|
69
|
-
|
|
70
|
-
export function normalizeTelegramSessionId(
|
|
71
|
-
sessionId: string,
|
|
72
|
-
): string | undefined {
|
|
73
|
-
if (typeof sessionId !== "string") return undefined;
|
|
74
|
-
const normalized = sessionId.trim();
|
|
75
|
-
return normalized && Buffer.byteLength(normalized, "utf8") <=
|
|
76
|
-
TELEGRAM_SESSION_ID_MAX_LENGTH
|
|
77
|
-
? normalized
|
|
78
|
-
: undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function createTelegramSessionKey(
|
|
82
|
-
sessionId: string,
|
|
83
|
-
): string | undefined {
|
|
84
|
-
const normalized = normalizeTelegramSessionId(sessionId);
|
|
85
|
-
return normalized
|
|
86
|
-
? createHash("sha256").update(normalized).digest("hex")
|
|
87
|
-
: undefined;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function normalizeTelegramWorkspacePath(
|
|
91
|
-
cwd: string,
|
|
92
|
-
): string | undefined {
|
|
93
|
-
const trimmed = cwd.trim();
|
|
94
|
-
if (!trimmed) return undefined;
|
|
95
|
-
const normalized = (trimmed.startsWith("/")
|
|
96
|
-
? posix.normalize(trimmed)
|
|
97
|
-
: resolve(trimmed).replaceAll("\\", "/"));
|
|
98
|
-
const withoutTrailingSeparators =
|
|
99
|
-
normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
|
|
100
|
-
return process.platform === "win32"
|
|
101
|
-
? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive: string) =>
|
|
102
|
-
`${drive.toLowerCase()}:`,
|
|
103
|
-
)
|
|
104
|
-
: withoutTrailingSeparators;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function createTelegramWorkspaceDirectoryKey(
|
|
108
|
-
cwd: string,
|
|
109
|
-
): string | undefined {
|
|
110
|
-
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
111
|
-
if (!normalized) return undefined;
|
|
112
|
-
const readable =
|
|
113
|
-
normalized
|
|
114
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
115
|
-
.replace(/^-+|-+$/gu, "") || "root";
|
|
116
|
-
const candidate = `--${readable}--`;
|
|
117
|
-
if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH) return candidate;
|
|
118
|
-
const digest = createHash("sha256")
|
|
119
|
-
.update(normalized)
|
|
120
|
-
.digest("hex")
|
|
121
|
-
.slice(0, 12);
|
|
122
|
-
const prefixLength =
|
|
123
|
-
TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
|
|
124
|
-
return `--${readable.slice(0, prefixLength)}-${digest}--`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function createTelegramWorkspaceInstanceSlot(
|
|
128
|
-
ordinal: number,
|
|
129
|
-
): string | undefined {
|
|
130
|
-
if (!Number.isSafeInteger(ordinal) || ordinal < 0) return undefined;
|
|
131
|
-
let value = ordinal + 1;
|
|
132
|
-
let slot = "";
|
|
133
|
-
while (value > 0) {
|
|
134
|
-
value -= 1;
|
|
135
|
-
slot = String.fromCharCode(97 + (value % 26)) + slot;
|
|
136
|
-
value = Math.floor(value / 26);
|
|
137
|
-
}
|
|
138
|
-
return slot;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function createTelegramWorkspaceBindingIdentityWithKey(
|
|
142
|
-
cwd: string,
|
|
143
|
-
workspaceKey: string,
|
|
144
|
-
ordinal: number,
|
|
145
|
-
sessionId?: string,
|
|
146
|
-
): TelegramWorkspaceBindingIdentity | undefined {
|
|
147
|
-
const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
|
|
148
|
-
if (!instanceSlot) return undefined;
|
|
149
|
-
const normalizedSessionId = sessionId === undefined
|
|
150
|
-
? undefined
|
|
151
|
-
: normalizeTelegramSessionId(sessionId);
|
|
152
|
-
const sessionKey = normalizedSessionId
|
|
153
|
-
? createTelegramSessionKey(normalizedSessionId)
|
|
154
|
-
: undefined;
|
|
155
|
-
if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
const legacyBindingKey = instanceSlot === "a"
|
|
159
|
-
? workspaceKey
|
|
160
|
-
: `${workspaceKey}${instanceSlot}`;
|
|
161
|
-
return {
|
|
162
|
-
cwd,
|
|
163
|
-
workspaceKey,
|
|
164
|
-
...(normalizedSessionId && sessionKey
|
|
165
|
-
? { sessionId: normalizedSessionId, sessionKey }
|
|
166
|
-
: {}),
|
|
167
|
-
instanceSlot,
|
|
168
|
-
bindingKey: sessionKey
|
|
169
|
-
? `${legacyBindingKey}-s-${sessionKey}`
|
|
170
|
-
: legacyBindingKey,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function createTelegramWorkspaceBindingIdentity(
|
|
175
|
-
cwd: string,
|
|
176
|
-
ordinal = 0,
|
|
177
|
-
sessionId?: string,
|
|
178
|
-
): TelegramWorkspaceBindingIdentity | undefined {
|
|
179
|
-
const normalized = normalizeTelegramWorkspacePath(cwd);
|
|
180
|
-
const workspaceKey = normalized
|
|
181
|
-
? createTelegramWorkspaceDirectoryKey(normalized)
|
|
182
|
-
: undefined;
|
|
183
|
-
if (!normalized || !workspaceKey) return undefined;
|
|
184
|
-
return createTelegramWorkspaceBindingIdentityWithKey(
|
|
185
|
-
normalized,
|
|
186
|
-
workspaceKey,
|
|
187
|
-
ordinal,
|
|
188
|
-
sessionId,
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
103
|
export type TelegramTopicTargetStatus =
|
|
193
104
|
| "active"
|
|
194
105
|
| "offline"
|
|
@@ -229,6 +140,7 @@ export interface TelegramThreadPendingProvision {
|
|
|
229
140
|
owner: "leader" | "manual-follower";
|
|
230
141
|
instanceId: string;
|
|
231
142
|
profileKey?: string;
|
|
143
|
+
workspaceBindingKey?: string;
|
|
232
144
|
status?: "in-flight" | "ambiguous";
|
|
233
145
|
threadName?: string;
|
|
234
146
|
displayTitle?: string;
|
|
@@ -242,7 +154,7 @@ export interface TelegramThreadPendingProvision {
|
|
|
242
154
|
export type TelegramThreadCleanupIntent =
|
|
243
155
|
ThreadReconciler.TelegramThreadCleanupIntent;
|
|
244
156
|
|
|
245
|
-
type TelegramProvisionRecoveryFile = Record<
|
|
157
|
+
export type TelegramProvisionRecoveryFile = Record<
|
|
246
158
|
string,
|
|
247
159
|
{
|
|
248
160
|
instanceId: string;
|
|
@@ -291,6 +203,11 @@ export interface TelegramThreadIdentityRecord {
|
|
|
291
203
|
updatedAtMs: number;
|
|
292
204
|
}
|
|
293
205
|
|
|
206
|
+
export interface TelegramWorkspaceJournalSource {
|
|
207
|
+
sessionId: string;
|
|
208
|
+
recipientBindingKey: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
294
211
|
export interface TelegramWorkspaceThreadBinding {
|
|
295
212
|
cwd: string;
|
|
296
213
|
workspaceKey: string;
|
|
@@ -310,6 +227,8 @@ export interface TelegramWorkspaceThreadBinding {
|
|
|
310
227
|
displayTitle?: string;
|
|
311
228
|
/** Historical follower-journal routing keys that may retain accepted work. */
|
|
312
229
|
journalBindingKeys?: string[];
|
|
230
|
+
/** Exact session-qualified journal addresses; retained across session re-key. */
|
|
231
|
+
journalSources?: TelegramWorkspaceJournalSource[];
|
|
313
232
|
/** True only when the historical journal-key set is proven complete. */
|
|
314
233
|
journalBindingsComplete?: true;
|
|
315
234
|
/** Sticky once this directory has multiple retained bindings. */
|
|
@@ -359,6 +278,164 @@ export interface TelegramBotStateSnapshot {
|
|
|
359
278
|
lastReconcileAction?: string;
|
|
360
279
|
}
|
|
361
280
|
|
|
281
|
+
export interface TelegramWorkspaceRelocationRequest {
|
|
282
|
+
operationId: string;
|
|
283
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
284
|
+
owner: TelegramTopicTargetRecord;
|
|
285
|
+
target: TelegramTarget & { threadId: number };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface TelegramWorkspaceRestoreRequest extends TelegramWorkspaceRelocationRequest {
|
|
289
|
+
source: { journalBindingKey: string; updateIds: number[] };
|
|
290
|
+
}
|
|
291
|
+
export interface TelegramWorkspaceRestoreExecutor { instanceId: string; leaderEpoch: string }
|
|
292
|
+
export interface TelegramWorkspaceRestoreAuthority {
|
|
293
|
+
executor: TelegramWorkspaceRestoreExecutor;
|
|
294
|
+
operatorUserId: number;
|
|
295
|
+
/** Exact profile, transport, session, source and Workspace-admission authority. */
|
|
296
|
+
isCurrent: () => boolean;
|
|
297
|
+
}
|
|
298
|
+
export type TelegramWorkspaceRestoreRecipient = {
|
|
299
|
+
kind: "leader" | "follower"; instanceId: string; sessionId: string; generation: string;
|
|
300
|
+
};
|
|
301
|
+
/** Retained queue admission is nonterminal; queue-completed requires positive journal-owner disposition. */
|
|
302
|
+
export type TelegramWorkspaceRestoreSourceSettlement = {
|
|
303
|
+
journalBindingKey: string; updateIds: number[];
|
|
304
|
+
} & ({ kind: "completed" } |
|
|
305
|
+
{ kind: "queued" | "queue-completed"; receiptId: string; queueKind: "prompt" | "control" });
|
|
306
|
+
/** Positive execution/recipient acceptance, retained before source disposition; never a source-removal ACK. */
|
|
307
|
+
export type TelegramWorkspaceRestoreSourceAcceptance = {
|
|
308
|
+
journalBindingKey: string;
|
|
309
|
+
updateId: number;
|
|
310
|
+
/** SHA-256 of the exact journal entry captured by its worker owner, not a routed message projection. */
|
|
311
|
+
sourceSha256: string;
|
|
312
|
+
recipient: TelegramWorkspaceRestoreRecipient;
|
|
313
|
+
} & ({ kind: "completed" } |
|
|
314
|
+
{ kind: "forwarded"; deliveryId: string; recipientBindingKey: string } |
|
|
315
|
+
{ kind: "queued"; receiptId: string; queueKind: "prompt" | "control"; queueOwnerSha256: string });
|
|
316
|
+
export interface TelegramWorkspaceRestoreIntent {
|
|
317
|
+
request: TelegramWorkspaceRestoreRequest;
|
|
318
|
+
operatorUserId: number;
|
|
319
|
+
executor: TelegramWorkspaceRestoreExecutor;
|
|
320
|
+
revision: number;
|
|
321
|
+
createdAtMs: number;
|
|
322
|
+
updatedAtMs: number;
|
|
323
|
+
phase: "relocated" | "recipient-issued" | "ready";
|
|
324
|
+
committedAtMs: number;
|
|
325
|
+
recipient?: TelegramWorkspaceRestoreRecipient;
|
|
326
|
+
readyRecipient?: TelegramWorkspaceRestoreRecipient;
|
|
327
|
+
routing?: { acceptances?: TelegramWorkspaceRestoreSourceAcceptance[];
|
|
328
|
+
settlements: TelegramWorkspaceRestoreSourceSettlement[]; cleanup?: "issued" | "completed" | "not-issued" };
|
|
329
|
+
}
|
|
330
|
+
/** Immutable source membership, not readiness, cancellation, or proof of complete Thread coverage. */
|
|
331
|
+
export interface TelegramTemporaryThreadInput {
|
|
332
|
+
journalBindingKey: string;
|
|
333
|
+
updateIds: number[];
|
|
334
|
+
}
|
|
335
|
+
/** A strict journal observation of committed cancellation plus matching private retention. */
|
|
336
|
+
export interface TelegramTemporaryThreadCancellationEvidence {
|
|
337
|
+
journalBindingKey: string;
|
|
338
|
+
updateId: number;
|
|
339
|
+
operatorAuthorityId: string;
|
|
340
|
+
}
|
|
341
|
+
/** A bot-created routing tab with an exact creation source; it owns no Pi binding or slot. */
|
|
342
|
+
export interface TelegramTemporaryThreadEntry {
|
|
343
|
+
source: { journalBindingKey: string; updateId: number };
|
|
344
|
+
/** Append-only known source groups. Missing legacy metadata never proves that the creation source was alone. */
|
|
345
|
+
inputs?: TelegramTemporaryThreadInput[];
|
|
346
|
+
/** Whole known groups with positively observed cancellation; never proof that deletion may be issued. */
|
|
347
|
+
cancelledInputs?: TelegramTemporaryThreadInput[];
|
|
348
|
+
/** Whole known groups whose Forward was positively completed by the journal owner; never deletion authority alone. */
|
|
349
|
+
completedInputs?: TelegramTemporaryThreadInput[];
|
|
350
|
+
/** Whole known groups whose Forward was durably issued before local handling/queueing or follower RPC; never delivery proof or a retry grant. */
|
|
351
|
+
forwardedInputs?: TelegramTemporaryThreadInput[];
|
|
352
|
+
/** Published at creation only: every subsequent Forward path must record issuance; missing legacy coverage stays unknown. */
|
|
353
|
+
forwardProtocol?: "one-shot-v1";
|
|
354
|
+
/** Published before the sole cleanup attempt; uncertainty survives executor adoption and restart. */
|
|
355
|
+
cleanupIssued?: true;
|
|
356
|
+
operatorUserId: number;
|
|
357
|
+
executor: TelegramWorkspaceRestoreExecutor;
|
|
358
|
+
/** Unique title token; the only identity an unacknowledged creation may later be matched by. */
|
|
359
|
+
token: string;
|
|
360
|
+
/** `creating` is published before the one creation request, so after a crash its outcome is unknown. */
|
|
361
|
+
phase: "creating" | "created";
|
|
362
|
+
target?: TelegramTarget & { threadId: number };
|
|
363
|
+
revision: number;
|
|
364
|
+
createdAtMs: number;
|
|
365
|
+
updatedAtMs: number;
|
|
366
|
+
}
|
|
367
|
+
/** Canonical target classification only; a bound target still needs live same-session readiness proof. */
|
|
368
|
+
export type TelegramTemporaryThreadTargetObservation =
|
|
369
|
+
| { kind: "bound"; binding: TelegramWorkspaceThreadBinding }
|
|
370
|
+
| { kind: "temporary" }
|
|
371
|
+
| { kind: "unknown" };
|
|
372
|
+
interface TelegramWorkspaceRestoreSnapshot {
|
|
373
|
+
version: 1; profileName: string; tokenSha256: string; revision: number;
|
|
374
|
+
operations: TelegramWorkspaceRestoreIntent[];
|
|
375
|
+
temporaryThreads?: TelegramTemporaryThreadEntry[];
|
|
376
|
+
}
|
|
377
|
+
/** Scope-bound Workspace operations; raw snapshot mutation is private to the store. */
|
|
378
|
+
export interface TelegramWorkspaceRestore {
|
|
379
|
+
list(): TelegramWorkspaceRestoreIntent[];
|
|
380
|
+
commit(request: TelegramWorkspaceRestoreRequest, authority: TelegramWorkspaceRestoreAuthority): Promise<TelegramWorkspaceRestoreIntent | undefined>;
|
|
381
|
+
adopt(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
382
|
+
issueRecipient(expected: TelegramWorkspaceRestoreIntent, recipient: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
|
|
383
|
+
confirmReady(expected: TelegramWorkspaceRestoreIntent, acknowledged: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
384
|
+
confirmInspectedReady(expected: TelegramWorkspaceRestoreIntent, observed: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
385
|
+
issueRouting(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
|
|
386
|
+
recordSourceAcceptance(expected: TelegramWorkspaceRestoreIntent, evidence: TelegramWorkspaceRestoreSourceAcceptance, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
387
|
+
recordSourceSettlement(expected: TelegramWorkspaceRestoreIntent, evidence: TelegramWorkspaceRestoreSourceSettlement, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
388
|
+
issueCleanup(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
|
|
389
|
+
recordCleanup(expected: TelegramWorkspaceRestoreIntent, result: { target: TelegramWorkspaceRestoreRequest["target"]; kind: "completed" | "not-issued" }, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
390
|
+
retire(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
391
|
+
/** Caller proves exact operator abandonment of every original; no dispatch grant may exist. */
|
|
392
|
+
retireAbandoned(expected: TelegramWorkspaceRestoreIntent, abandonedUpdateIds: readonly number[], authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
|
|
393
|
+
listTemporaryThreads(): TelegramTemporaryThreadEntry[];
|
|
394
|
+
/** Caller holds admission for effectful use. Fresh exact read grants no adoption, routing, disposition or deletion. */
|
|
395
|
+
inspectTemporaryThreadTarget(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadTargetObservation | undefined;
|
|
396
|
+
/** Read-only veto for an already-granted in-flight attempt; never another grant, disposable classification or deletion ACK. */
|
|
397
|
+
isTemporaryThreadCleanupCurrent(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): boolean;
|
|
398
|
+
/** Publishes `creating` before the caller's single creation request; an existing source entry is returned, never recreated. */
|
|
399
|
+
reserveTemporaryThread(source: TelegramTemporaryThreadEntry["source"], token: string, authority: TelegramWorkspaceRestoreAuthority): { reserved: boolean; entry: TelegramTemporaryThreadEntry } | undefined;
|
|
400
|
+
/** Caller proves a fresh owner-authenticated implicit Telegram creation and exact live source under profile admission.
|
|
401
|
+
* Registers that observed unbound target without issuing or fabricating a Bot API creation. */
|
|
402
|
+
registerImplicitTemporaryThread(input: TelegramTemporaryThreadInput, target: TelegramTarget & { threadId: number }, token: string,
|
|
403
|
+
authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
404
|
+
/** Records the exact acknowledged creation target; it never reopens a created entry. */
|
|
405
|
+
acknowledgeTemporaryThread(expected: TelegramTemporaryThreadEntry, target: TelegramTarget & { threadId: number }, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
406
|
+
adoptTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
407
|
+
/** Caller supplies exact live source/group authority; duplicate membership is read-only, never a new dispatch grant. */
|
|
408
|
+
recordTemporaryThreadInput(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
409
|
+
/** Records a positively completed Forward group; the caller proves every source of the group completed. */
|
|
410
|
+
/** Publishes the one-time Forward issuance fact before any RPC; a duplicate, cancelled, completed or Restore-owned group is refused. */
|
|
411
|
+
recordTemporaryThreadForwardIssued(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
|
|
412
|
+
authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
413
|
+
recordTemporaryThreadInputCompletion(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
|
|
414
|
+
authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
|
|
415
|
+
recordTemporaryThreadInputCancellation(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
|
|
416
|
+
authority: TelegramWorkspaceRestoreAuthority,
|
|
417
|
+
inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
|
|
418
|
+
/** Caller holds profile admission and proves fresh source/protection clearance; publication grants one attempt, never retry. */
|
|
419
|
+
issueTemporaryThreadCleanup(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority):
|
|
420
|
+
{ issued: true; entry: TelegramTemporaryThreadEntry } | undefined;
|
|
421
|
+
/** Caller proves source settlement or cancellation and target disposition; this releases protection only. */
|
|
422
|
+
/** `completed` names one newly completed group; every other known group must already be cancelled or completed. */
|
|
423
|
+
retireTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority,
|
|
424
|
+
completed?: TelegramTemporaryThreadInput): TelegramTemporaryThreadEntry | undefined;
|
|
425
|
+
/** New-world restart: atomically forgets this operator's Restore intents and temporary entries from previous runtime instances.
|
|
426
|
+
* Committed bindings stay; nothing is rolled back, replayed or deleted here. */
|
|
427
|
+
forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority):
|
|
428
|
+
{ operations: TelegramWorkspaceRestoreIntent[]; temporaryThreads: TelegramTemporaryThreadEntry[] } | undefined;
|
|
429
|
+
}
|
|
430
|
+
export interface TelegramWorkspaceRestoreOptions {
|
|
431
|
+
profileName: string; tokenSha256: string;
|
|
432
|
+
isCurrentScope?: () => boolean;
|
|
433
|
+
maxBytes?: number;
|
|
434
|
+
getNowMs?: () => number;
|
|
435
|
+
legacyPath?: string;
|
|
436
|
+
onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
|
|
437
|
+
}
|
|
438
|
+
|
|
362
439
|
export interface TelegramSessionReplacementIntent {
|
|
363
440
|
continuity: "workspace-thread" | "classic-chat";
|
|
364
441
|
cwd: string;
|
|
@@ -384,13 +461,11 @@ export interface TelegramTopicTargetFile {
|
|
|
384
461
|
source: "snapshot";
|
|
385
462
|
writtenAtMs: number;
|
|
386
463
|
bot: TelegramBotStateSnapshot;
|
|
387
|
-
runtime?: Record<string, unknown>;
|
|
388
|
-
liveRoster?: Record<string, unknown>;
|
|
389
|
-
diagnostics?: Record<string, unknown>;
|
|
390
464
|
threads: TelegramTopicTargetRecord[];
|
|
391
465
|
identities?: TelegramThreadIdentityRecord[];
|
|
392
466
|
workspaceBindings?: TelegramWorkspaceThreadBinding[];
|
|
393
467
|
workspaceRetirements?: TelegramWorkspaceRetirementIntent[];
|
|
468
|
+
workspaceRestore?: TelegramWorkspaceRestoreSnapshot;
|
|
394
469
|
sessionReplacement?: TelegramSessionReplacementIntent;
|
|
395
470
|
reservations?: TelegramThreadReservation[];
|
|
396
471
|
pendingProvisions?: TelegramThreadPendingProvision[];
|
|
@@ -470,6 +545,17 @@ export interface TelegramTopicTargetStore {
|
|
|
470
545
|
expected: TelegramTopicTargetRecord,
|
|
471
546
|
isCurrent: () => boolean,
|
|
472
547
|
) => Promise<boolean>;
|
|
548
|
+
/** Caller holds Workspace admission; publication uses this store's exact transport-owner fence. */
|
|
549
|
+
workspaceRestore: (options: TelegramWorkspaceRestoreOptions) => TelegramWorkspaceRestore;
|
|
550
|
+
/** Read-only precondition under caller-owned admission; not registration or transport authority. */
|
|
551
|
+
assertWorkspaceRestoreRegistration: (candidate: { target: TelegramTarget; bindingKey?: string; slot?: string }) => void;
|
|
552
|
+
/** Runs synchronous live publication under the same evidence transaction; caller retains authentication/admission. */
|
|
553
|
+
commitWorkspaceRestoreRegistration: (candidate: Parameters<TelegramTopicTargetStore["assertWorkspaceRestoreRegistration"]>[0], publish: () => void) => void;
|
|
554
|
+
/** Read-only canonical observation; the callback must finish synchronously under the snapshot transaction. */
|
|
555
|
+
withWorkspaceRestoreSnapshot: (expected: TelegramWorkspaceRestoreIntent,
|
|
556
|
+
observe: (snapshot: Readonly<Pick<TelegramTopicTargetFile, "threads" | "workspaceBindings">>) => undefined) => void;
|
|
557
|
+
/** Fresh strict disk read of acknowledged temporary-tab targets; throws rather than guessing absence. */
|
|
558
|
+
listTemporaryThreadTargets: () => TelegramTarget[];
|
|
473
559
|
list: () => TelegramTopicTargetRecord[];
|
|
474
560
|
getFollowerRecoveryHintByTarget?: (
|
|
475
561
|
target: TelegramTarget,
|
|
@@ -494,6 +580,8 @@ export interface TelegramTopicTargetStore {
|
|
|
494
580
|
liveRoster?: Record<string, unknown>;
|
|
495
581
|
diagnostics?: Record<string, unknown>;
|
|
496
582
|
}) => void;
|
|
583
|
+
/** Writes the non-canonical status projection to its own file; it never touches canonical state or grants authority. */
|
|
584
|
+
persistStatus: () => Promise<void>;
|
|
497
585
|
getByProfileKey: (
|
|
498
586
|
profileKey: string,
|
|
499
587
|
) => TelegramTopicTargetRecord | undefined;
|
|
@@ -519,11 +607,18 @@ export interface TelegramTopicTargetStore {
|
|
|
519
607
|
isCurrent: () => boolean,
|
|
520
608
|
) => Promise<boolean>;
|
|
521
609
|
listWorkspaceRetirementIntents: () => TelegramWorkspaceRetirementIntent[];
|
|
610
|
+
/** Metadata subset CAS only: callers prove exact-source/writer evidence; independent snapshot roots are untouched. */
|
|
522
611
|
commitWorkspaceJournalEvidence: (
|
|
523
612
|
expected: TelegramWorkspaceThreadBinding,
|
|
524
613
|
journalBindingKeys: readonly string[],
|
|
525
614
|
complete: boolean,
|
|
615
|
+
journalSources?: readonly TelegramWorkspaceJournalSource[],
|
|
526
616
|
) => TelegramWorkspaceThreadBinding | undefined;
|
|
617
|
+
/** Acknowledge publication of the exact current metadata frame; never roll it back after an uncertain write. */
|
|
618
|
+
persistWorkspaceJournalEvidence: (
|
|
619
|
+
expected: TelegramWorkspaceThreadBinding,
|
|
620
|
+
isCurrent: () => boolean,
|
|
621
|
+
) => Promise<boolean>;
|
|
527
622
|
upsertWorkspaceRetirementIntent: (
|
|
528
623
|
intent: TelegramWorkspaceRetirementIntent,
|
|
529
624
|
) => boolean;
|
|
@@ -645,7 +740,7 @@ export function reconcileTelegramFreshAllocationCursor(
|
|
|
645
740
|
}
|
|
646
741
|
|
|
647
742
|
export function createTelegramCleanupTargetProtection(
|
|
648
|
-
store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups">>,
|
|
743
|
+
store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "listTemporaryThreadTargets">>,
|
|
649
744
|
departingRecord?: TelegramTopicTargetRecord,
|
|
650
745
|
): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]> {
|
|
651
746
|
const records = store.list();
|
|
@@ -657,6 +752,11 @@ export function createTelegramCleanupTargetProtection(
|
|
|
657
752
|
const sameSnapshot = (left: unknown, right: unknown): boolean =>
|
|
658
753
|
isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
659
754
|
return (target, action) => {
|
|
755
|
+
// A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
|
|
756
|
+
if (store.listTemporaryThreadTargets) {
|
|
757
|
+
try { if (store.listTemporaryThreadTargets().some(candidate => targetMatches(candidate, target))) return true; }
|
|
758
|
+
catch { return true; }
|
|
759
|
+
}
|
|
660
760
|
for (const record of store.list()) {
|
|
661
761
|
if (!targetMatches(record.target, target)) continue;
|
|
662
762
|
// A persisted shutdown intent may retire only its original pre-intent
|
|
@@ -697,6 +797,7 @@ export interface TelegramTopicTargetStoreOptions {
|
|
|
697
797
|
getNowMs?: () => number;
|
|
698
798
|
canPersist?: () => boolean;
|
|
699
799
|
commitPersist?: (commit: () => void) => boolean;
|
|
800
|
+
consolidated?: Pick<TelegramConsolidatedWorkspaceStorageOptions, "captureAuthority" | "publishIfOwned">;
|
|
700
801
|
getExternalReservedSlots?: () => readonly string[];
|
|
701
802
|
}
|
|
702
803
|
|
|
@@ -831,51 +932,6 @@ interface TelegramTopicResult {
|
|
|
831
932
|
message_thread_id?: number;
|
|
832
933
|
}
|
|
833
934
|
|
|
834
|
-
function hashString(value: string): number {
|
|
835
|
-
let hash = 2166136261;
|
|
836
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
837
|
-
hash ^= value.charCodeAt(index);
|
|
838
|
-
hash = Math.imul(hash, 16777619);
|
|
839
|
-
}
|
|
840
|
-
return hash >>> 0;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function getWorkspaceHint(cwd: string | undefined): string | undefined {
|
|
844
|
-
if (!cwd) return undefined;
|
|
845
|
-
const parts = cwd.split("/").filter(Boolean);
|
|
846
|
-
const last = parts.at(-1)?.trim();
|
|
847
|
-
if (!last) return undefined;
|
|
848
|
-
return (
|
|
849
|
-
last
|
|
850
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
851
|
-
.trim()
|
|
852
|
-
.slice(0, 32) || undefined
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
export function createTelegramThreadName(
|
|
857
|
-
input: TelegramThreadNameInput,
|
|
858
|
-
): string {
|
|
859
|
-
const workspace = getWorkspaceHint(input.cwd);
|
|
860
|
-
const roleMark =
|
|
861
|
-
input.role === "leader"
|
|
862
|
-
? "Leader"
|
|
863
|
-
: input.role === "follower"
|
|
864
|
-
? "Follower"
|
|
865
|
-
: undefined;
|
|
866
|
-
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
867
|
-
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
868
|
-
const fallback = `Instance ${hashString(
|
|
869
|
-
`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`,
|
|
870
|
-
)
|
|
871
|
-
.toString(36)
|
|
872
|
-
.slice(0, 4)}`;
|
|
873
|
-
return (
|
|
874
|
-
[slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
875
|
-
fallback
|
|
876
|
-
);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
935
|
export function getTelegramStatePath(
|
|
880
936
|
agentDir = resolveAgentDir(),
|
|
881
937
|
profileName?: string,
|
|
@@ -936,7 +992,7 @@ export function setTelegramLeaderSessionHandoff(
|
|
|
936
992
|
else store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
|
|
937
993
|
}
|
|
938
994
|
|
|
939
|
-
|
|
995
|
+
function isTelegramLeaderSessionHandoffFresh(
|
|
940
996
|
handoff: TelegramLeaderSessionHandoff | undefined,
|
|
941
997
|
options: { pid?: number; nowMs?: number; ttlMs?: number } = {},
|
|
942
998
|
): handoff is TelegramLeaderSessionHandoff {
|
|
@@ -1120,6 +1176,7 @@ function normalizeRecord(
|
|
|
1120
1176
|
typeof record.updatedAtMs !== "number"
|
|
1121
1177
|
)
|
|
1122
1178
|
return undefined;
|
|
1179
|
+
const threadName = getPersistedThreadName(record);
|
|
1123
1180
|
const normalized: TelegramTopicTargetRecord = {
|
|
1124
1181
|
profileKey: getTelegramThreadOwnerKey(owner),
|
|
1125
1182
|
owner,
|
|
@@ -1127,7 +1184,7 @@ function normalizeRecord(
|
|
|
1127
1184
|
status,
|
|
1128
1185
|
createdAtMs: record.createdAtMs,
|
|
1129
1186
|
updatedAtMs: record.updatedAtMs,
|
|
1130
|
-
threadName:
|
|
1187
|
+
...(threadName !== undefined ? { threadName } : {}),
|
|
1131
1188
|
...(typeof record.manualThreadName === "string" &&
|
|
1132
1189
|
normalizeTelegramTopicTargetThreadName(record.manualThreadName)
|
|
1133
1190
|
? { manualThreadName:
|
|
@@ -1223,6 +1280,21 @@ function getWorkspaceBindingMapKey(
|
|
|
1223
1280
|
return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
|
|
1224
1281
|
}
|
|
1225
1282
|
|
|
1283
|
+
function normalizeWorkspaceJournalSources(value: unknown): TelegramWorkspaceJournalSource[] | undefined {
|
|
1284
|
+
if (!Array.isArray(value) || value.length > 256) return undefined;
|
|
1285
|
+
const sources = new Map<string, TelegramWorkspaceJournalSource>();
|
|
1286
|
+
for (const entry of value) {
|
|
1287
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
|
|
1288
|
+
const source = entry as Record<string, unknown>;
|
|
1289
|
+
if (Object.keys(source).some(key => key !== "sessionId" && key !== "recipientBindingKey") ||
|
|
1290
|
+
typeof source.sessionId !== "string" || normalizeTelegramSessionId(source.sessionId) !== source.sessionId ||
|
|
1291
|
+
typeof source.recipientBindingKey !== "string" || !source.recipientBindingKey || source.recipientBindingKey.length > 512) return undefined;
|
|
1292
|
+
const normalized = { sessionId: source.sessionId, recipientBindingKey: source.recipientBindingKey };
|
|
1293
|
+
sources.set(JSON.stringify(normalized), normalized);
|
|
1294
|
+
}
|
|
1295
|
+
return Array.from(sources.values());
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1226
1298
|
function normalizeWorkspaceBindingRecord(
|
|
1227
1299
|
value: unknown,
|
|
1228
1300
|
): TelegramWorkspaceThreadBinding | undefined {
|
|
@@ -1297,6 +1369,8 @@ function normalizeWorkspaceBindingRecord(
|
|
|
1297
1369
|
)
|
|
1298
1370
|
? Array.from(new Set(record.journalBindingKeys as string[]))
|
|
1299
1371
|
: undefined;
|
|
1372
|
+
const journalSources = record.journalSources === undefined ? undefined : normalizeWorkspaceJournalSources(record.journalSources);
|
|
1373
|
+
if (record.journalSources !== undefined && !journalSources) return undefined;
|
|
1300
1374
|
return {
|
|
1301
1375
|
cwd,
|
|
1302
1376
|
workspaceKey: record.workspaceKey,
|
|
@@ -1312,7 +1386,8 @@ function normalizeWorkspaceBindingRecord(
|
|
|
1312
1386
|
? { inactiveSinceMs: record.inactiveSinceMs }
|
|
1313
1387
|
: {}),
|
|
1314
1388
|
...(journalBindingKeys ? { journalBindingKeys } : {}),
|
|
1315
|
-
...(
|
|
1389
|
+
...(journalSources ? { journalSources } : {}),
|
|
1390
|
+
...(record.journalBindingsComplete === true && (journalBindingKeys || journalSources)
|
|
1316
1391
|
? { journalBindingsComplete: true as const }
|
|
1317
1392
|
: {}),
|
|
1318
1393
|
target: { chatId: target.chatId, threadId: target.threadId },
|
|
@@ -1326,7 +1401,9 @@ function normalizeWorkspaceBindingRecord(
|
|
|
1326
1401
|
function cloneWorkspaceBinding(
|
|
1327
1402
|
binding: TelegramWorkspaceThreadBinding,
|
|
1328
1403
|
): TelegramWorkspaceThreadBinding {
|
|
1329
|
-
return { ...binding, target: { ...binding.target }
|
|
1404
|
+
return { ...binding, target: { ...binding.target },
|
|
1405
|
+
...(binding.journalBindingKeys ? { journalBindingKeys: [...binding.journalBindingKeys] } : {}),
|
|
1406
|
+
...(binding.journalSources ? { journalSources: binding.journalSources.map(source => ({ ...source })) } : {}) };
|
|
1330
1407
|
}
|
|
1331
1408
|
|
|
1332
1409
|
function cloneSessionReplacementIntent(
|
|
@@ -1528,6 +1605,9 @@ function normalizePendingProvision(
|
|
|
1528
1605
|
...(record.status === "in-flight" || record.status === "ambiguous"
|
|
1529
1606
|
? { status: record.status }
|
|
1530
1607
|
: {}),
|
|
1608
|
+
...(typeof record.workspaceBindingKey === "string"
|
|
1609
|
+
? { workspaceBindingKey: record.workspaceBindingKey }
|
|
1610
|
+
: {}),
|
|
1531
1611
|
...(typeof record.threadName === "string"
|
|
1532
1612
|
? { threadName: record.threadName }
|
|
1533
1613
|
: {}),
|
|
@@ -1640,6 +1720,325 @@ function normalizeReservation(
|
|
|
1640
1720
|
};
|
|
1641
1721
|
}
|
|
1642
1722
|
|
|
1723
|
+
function normalizeTelegramWorkspaceRelocationRequest(value: unknown): TelegramWorkspaceRelocationRequest | undefined {
|
|
1724
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1725
|
+
const request = value as TelegramWorkspaceRelocationRequest;
|
|
1726
|
+
const binding = normalizeWorkspaceBindingRecord(request.binding);
|
|
1727
|
+
const owner = normalizeRecord(request.owner);
|
|
1728
|
+
const target = request.target;
|
|
1729
|
+
if (typeof request.operationId !== "string" || !request.operationId ||
|
|
1730
|
+
!binding?.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) || binding.inactiveSinceMs !== undefined ||
|
|
1731
|
+
!owner || owner.status !== "active" || !owner.instanceId || owner.slot !== binding.slot ||
|
|
1732
|
+
!["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
|
|
1733
|
+
(owner.owner?.kind === "leader" &&
|
|
1734
|
+
(owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
|
|
1735
|
+
!targetMatches(binding.target, owner.target) ||
|
|
1736
|
+
!Number.isSafeInteger(binding.target.chatId) || binding.target.chatId === 0 ||
|
|
1737
|
+
!Number.isSafeInteger(binding.target.threadId) || binding.target.threadId <= 0 ||
|
|
1738
|
+
!target || target.chatId !== binding.target.chatId ||
|
|
1739
|
+
!Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
|
|
1740
|
+
targetMatches(target, binding.target)) return undefined;
|
|
1741
|
+
const normalized = { operationId: request.operationId, binding, owner,
|
|
1742
|
+
target: { chatId: target.chatId, threadId: target.threadId } };
|
|
1743
|
+
return isDeepStrictEqual(value, normalized) ? normalized : undefined;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const restoreInteger = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
1747
|
+
const restoreKeys = (value: Record<string, unknown>, allowed: string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
|
|
1748
|
+
const isTelegramWorkspaceRestoreExecutor = (value: unknown): value is TelegramWorkspaceRestoreExecutor => restoreObject(value) &&
|
|
1749
|
+
restoreKeys(value, ["instanceId", "leaderEpoch"]) && restoreText(value.instanceId) && restoreText(value.leaderEpoch);
|
|
1750
|
+
export const isTelegramWorkspaceRestoreRecipient = (value: unknown): value is TelegramWorkspaceRestoreRecipient => restoreObject(value) &&
|
|
1751
|
+
restoreKeys(value, ["kind", "instanceId", "sessionId", "generation"]) &&
|
|
1752
|
+
(value.kind === "leader" || value.kind === "follower") && restoreText(value.instanceId) && restoreText(value.sessionId) && restoreText(value.generation);
|
|
1753
|
+
export function isTelegramWorkspaceRestoreRequest(value: unknown): value is TelegramWorkspaceRestoreRequest {
|
|
1754
|
+
if (!restoreObject(value)) return false;
|
|
1755
|
+
const { source, ...relocation } = value;
|
|
1756
|
+
return !!normalizeTelegramWorkspaceRelocationRequest(relocation) && restoreObject(source) &&
|
|
1757
|
+
restoreKeys(source, ["journalBindingKey", "updateIds"]) && restoreText(source.journalBindingKey) &&
|
|
1758
|
+
Array.isArray(source.updateIds) && source.updateIds.length > 0 &&
|
|
1759
|
+
source.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
|
|
1760
|
+
}
|
|
1761
|
+
function isTelegramWorkspaceRestoreSettlement(value: unknown, source: TelegramWorkspaceRestoreRequest["source"]): value is TelegramWorkspaceRestoreSourceSettlement {
|
|
1762
|
+
if (!restoreObject(value) || value.journalBindingKey !== source.journalBindingKey || !Array.isArray(value.updateIds) ||
|
|
1763
|
+
value.updateIds.length === 0 || !value.updateIds.every((id, index, ids) => restoreInteger(id) && source.updateIds.includes(id) &&
|
|
1764
|
+
(index === 0 || id > ids[index - 1]))) return false;
|
|
1765
|
+
return value.kind === "completed" ? restoreKeys(value, ["journalBindingKey", "updateIds", "kind"]) :
|
|
1766
|
+
(value.kind === "queued" || value.kind === "queue-completed") && restoreKeys(value, ["journalBindingKey", "updateIds", "kind", "receiptId", "queueKind"]) &&
|
|
1767
|
+
restoreText(value.receiptId) && (value.queueKind === "prompt" || value.queueKind === "control");
|
|
1768
|
+
}
|
|
1769
|
+
function isWorkspaceRestoreSourceAcceptance(value: unknown, operation: TelegramWorkspaceRestoreIntent): value is TelegramWorkspaceRestoreSourceAcceptance {
|
|
1770
|
+
if (!restoreObject(value) || value.journalBindingKey !== operation.request.source.journalBindingKey || !restoreInteger(value.updateId) ||
|
|
1771
|
+
!operation.request.source.updateIds.includes(value.updateId) || typeof value.sourceSha256 !== "string" ||
|
|
1772
|
+
!/^[a-f0-9]{64}$/u.test(value.sourceSha256) || !isTelegramWorkspaceRestoreRecipient(value.recipient) ||
|
|
1773
|
+
value.recipient.sessionId !== operation.request.binding.sessionId || value.recipient.kind !== operation.recipient?.kind) return false;
|
|
1774
|
+
const keys = ["journalBindingKey", "updateId", "sourceSha256", "recipient", "kind"];
|
|
1775
|
+
if (value.kind === "completed") return value.recipient.kind === "leader" && restoreKeys(value, keys);
|
|
1776
|
+
if (value.kind === "forwarded") return value.recipient.kind === "follower" &&
|
|
1777
|
+
restoreKeys(value, [...keys, "deliveryId", "recipientBindingKey"]) && typeof value.deliveryId === "string" &&
|
|
1778
|
+
/^telegram-follower-v1-[a-f0-9]{64}$/u.test(value.deliveryId) && restoreText(value.recipientBindingKey);
|
|
1779
|
+
return value.kind === "queued" && value.recipient.kind === "leader" &&
|
|
1780
|
+
restoreKeys(value, [...keys, "receiptId", "queueKind", "queueOwnerSha256"]) && restoreText(value.receiptId) &&
|
|
1781
|
+
(value.queueKind === "prompt" || value.queueKind === "control") && typeof value.queueOwnerSha256 === "string" &&
|
|
1782
|
+
/^[a-f0-9]{64}$/u.test(value.queueOwnerSha256);
|
|
1783
|
+
}
|
|
1784
|
+
/** Stable acceptance scope, not a removal ACK; adoption and mutable progress cannot change it. */
|
|
1785
|
+
export function getTelegramWorkspaceRestoreSourceCompletionSha256(operation: TelegramWorkspaceRestoreIntent,
|
|
1786
|
+
acceptance: TelegramWorkspaceRestoreSourceAcceptance): string {
|
|
1787
|
+
if (!isTelegramWorkspaceRestoreRequest(operation.request) || operation.operatorUserId !== operation.request.target.chatId ||
|
|
1788
|
+
!isWorkspaceRestoreSourceAcceptance(acceptance, operation) ||
|
|
1789
|
+
!operation.routing?.acceptances?.some(value => isDeepStrictEqual(value, acceptance))) {
|
|
1790
|
+
throw new Error("Workspace Restore retained acceptance scope is unavailable.");
|
|
1791
|
+
}
|
|
1792
|
+
const frame = { kind: "workspace-restore-source-completion-v1", request: operation.request,
|
|
1793
|
+
operatorUserId: operation.operatorUserId, acceptance };
|
|
1794
|
+
return createHash("sha256").update(JSON.stringify(frame, function (_key, value: unknown) {
|
|
1795
|
+
return restoreObject(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value;
|
|
1796
|
+
})).digest("hex");
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function workspaceRestoreAcceptancesConsistent(acceptances: readonly TelegramWorkspaceRestoreSourceAcceptance[]): boolean {
|
|
1800
|
+
return acceptances.every((value, index) => (index === 0 || value.updateId > acceptances[index - 1]!.updateId) &&
|
|
1801
|
+
acceptances.slice(0, index).every(previous => {
|
|
1802
|
+
if (value.kind === "forwarded" && previous.kind === "forwarded") return value.deliveryId !== previous.deliveryId;
|
|
1803
|
+
if (value.kind !== "queued" || previous.kind !== "queued" || value.receiptId !== previous.receiptId) return true;
|
|
1804
|
+
return value.queueKind === previous.queueKind && value.queueOwnerSha256 === previous.queueOwnerSha256 &&
|
|
1805
|
+
isDeepStrictEqual(value.recipient, previous.recipient);
|
|
1806
|
+
}));
|
|
1807
|
+
}
|
|
1808
|
+
function workspaceRestoreSettlementMatchesAcceptance(evidence: TelegramWorkspaceRestoreSourceSettlement,
|
|
1809
|
+
acceptances: readonly TelegramWorkspaceRestoreSourceAcceptance[]): boolean {
|
|
1810
|
+
return evidence.updateIds.every(id => {
|
|
1811
|
+
const accepted = acceptances.find(value => value.updateId === id);
|
|
1812
|
+
if (!accepted) return evidence.kind !== "queue-completed"; // Legacy admission is retained, never terminal receipt proof.
|
|
1813
|
+
return evidence.kind === "completed" ? accepted.kind !== "queued" : accepted.kind === "queued" &&
|
|
1814
|
+
evidence.receiptId === accepted.receiptId && evidence.queueKind === accepted.queueKind;
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
function workspaceRestoreSourcesSettled(operation: TelegramWorkspaceRestoreIntent): boolean {
|
|
1818
|
+
const settled = new Set(operation.routing?.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
|
|
1819
|
+
return operation.request.source.updateIds.every(id => settled.has(id));
|
|
1820
|
+
}
|
|
1821
|
+
function isWorkspaceRestoreIntent(value: unknown): value is TelegramWorkspaceRestoreIntent {
|
|
1822
|
+
if (!restoreObject(value) || !restoreKeys(value, ["request", "operatorUserId", "executor", "revision", "createdAtMs",
|
|
1823
|
+
"updatedAtMs", "phase", "committedAtMs", "recipient", "readyRecipient", "routing"]) || !isTelegramWorkspaceRestoreRequest(value.request) ||
|
|
1824
|
+
!restoreInteger(value.operatorUserId) || value.operatorUserId !== value.request.target.chatId ||
|
|
1825
|
+
!isTelegramWorkspaceRestoreExecutor(value.executor) || !restoreInteger(value.revision) || !restoreInteger(value.createdAtMs) ||
|
|
1826
|
+
!restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs ||
|
|
1827
|
+
!restoreInteger(value.committedAtMs)) return false;
|
|
1828
|
+
const source = value.request.source;
|
|
1829
|
+
if (value.routing !== undefined) {
|
|
1830
|
+
if (value.phase !== "ready" || !restoreObject(value.routing) || !restoreKeys(value.routing, ["acceptances", "settlements", "cleanup"]) ||
|
|
1831
|
+
!Array.isArray(value.routing.settlements) || !value.routing.settlements.every(item => isTelegramWorkspaceRestoreSettlement(item, source))) return false;
|
|
1832
|
+
if (value.routing.acceptances !== undefined) {
|
|
1833
|
+
const acceptances = value.routing.acceptances;
|
|
1834
|
+
if (!Array.isArray(acceptances) || !acceptances.length ||
|
|
1835
|
+
!acceptances.every(item => isWorkspaceRestoreSourceAcceptance(item, value as unknown as TelegramWorkspaceRestoreIntent)) ||
|
|
1836
|
+
!workspaceRestoreAcceptancesConsistent(acceptances)) return false;
|
|
1837
|
+
}
|
|
1838
|
+
const acceptances = (value.routing.acceptances ?? []) as TelegramWorkspaceRestoreSourceAcceptance[];
|
|
1839
|
+
if (!value.routing.settlements.every(item => workspaceRestoreSettlementMatchesAcceptance(item, acceptances))) return false;
|
|
1840
|
+
const ids = value.routing.settlements.flatMap(item => item.updateIds);
|
|
1841
|
+
if (new Set(ids).size !== ids.length || (value.routing.cleanup !== undefined &&
|
|
1842
|
+
(!["issued", "completed", "not-issued"].includes(value.routing.cleanup as string) ||
|
|
1843
|
+
!workspaceRestoreSourcesSettled(value as unknown as TelegramWorkspaceRestoreIntent)))) return false;
|
|
1844
|
+
}
|
|
1845
|
+
if (value.readyRecipient !== undefined && (value.phase !== "ready" || !isTelegramWorkspaceRestoreRecipient(value.readyRecipient) ||
|
|
1846
|
+
value.readyRecipient.sessionId !== value.request.binding.sessionId)) return false;
|
|
1847
|
+
if (value.phase === "relocated") return value.recipient === undefined;
|
|
1848
|
+
return (value.phase === "recipient-issued" || value.phase === "ready") && isTelegramWorkspaceRestoreRecipient(value.recipient) &&
|
|
1849
|
+
value.recipient.sessionId === value.request.binding.sessionId;
|
|
1850
|
+
}
|
|
1851
|
+
function isTemporaryThreadInput(value: unknown): value is TelegramTemporaryThreadInput {
|
|
1852
|
+
return restoreObject(value) && restoreKeys(value, ["journalBindingKey", "updateIds"]) && restoreText(value.journalBindingKey) &&
|
|
1853
|
+
Array.isArray(value.updateIds) && value.updateIds.length > 0 && value.updateIds.length <= TEMPORARY_THREAD_INPUT_CAPACITY &&
|
|
1854
|
+
value.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
|
|
1855
|
+
}
|
|
1856
|
+
export function getTelegramTemporaryThreadInputs(entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadInput[] {
|
|
1857
|
+
return structuredClone(entry.inputs ?? [{ journalBindingKey: entry.source.journalBindingKey, updateIds: [entry.source.updateId] }]);
|
|
1858
|
+
}
|
|
1859
|
+
/** Every known group is durably cancelled or Forward-completed; still not deletion authority by itself. */
|
|
1860
|
+
export function isTelegramTemporaryThreadFullyResolved(entry: TelegramTemporaryThreadEntry): boolean {
|
|
1861
|
+
const inputs = getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
1862
|
+
return resolved.length > 0 && inputs.every(input => resolved.some(other => isDeepStrictEqual(other, input)));
|
|
1863
|
+
}
|
|
1864
|
+
/** One unresolved group may release protection after completion when every other group has a durable terminal fact. */
|
|
1865
|
+
function isTemporaryThreadReleasedByCompletion(entry: TelegramTemporaryThreadEntry, completed: TelegramTemporaryThreadInput | undefined): boolean {
|
|
1866
|
+
if (!completed || !isTemporaryThreadInput(completed)) return false;
|
|
1867
|
+
const inputs = getTelegramTemporaryThreadInputs(entry), cancelled = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
1868
|
+
const same = (left: TelegramTemporaryThreadInput, right: TelegramTemporaryThreadInput): boolean => isDeepStrictEqual(left, right);
|
|
1869
|
+
return inputs.some(input => same(input, completed)) && !cancelled.some(input => same(input, completed)) &&
|
|
1870
|
+
inputs.every(input => same(input, completed) || cancelled.some(other => same(other, input)));
|
|
1871
|
+
}
|
|
1872
|
+
function isTemporaryThreadEntry(value: unknown): value is TelegramTemporaryThreadEntry {
|
|
1873
|
+
if (!restoreObject(value) || !restoreKeys(value, ["source", "inputs", "cancelledInputs", "completedInputs", "forwardedInputs", "forwardProtocol", "cleanupIssued", "operatorUserId", "executor", "token", "phase", "target", "revision",
|
|
1874
|
+
"createdAtMs", "updatedAtMs"]) || !restoreObject(value.source) || !restoreKeys(value.source, ["journalBindingKey", "updateId"]) ||
|
|
1875
|
+
!restoreText(value.source.journalBindingKey) || !restoreInteger(value.source.updateId) ||
|
|
1876
|
+
!restoreInteger(value.operatorUserId) || value.operatorUserId === 0 || !isTelegramWorkspaceRestoreExecutor(value.executor) ||
|
|
1877
|
+
typeof value.token !== "string" || !/^[a-f0-9]{32}$/u.test(value.token) || !restoreInteger(value.revision) ||
|
|
1878
|
+
!restoreInteger(value.createdAtMs) || !restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) return false;
|
|
1879
|
+
const source = value.source;
|
|
1880
|
+
if (value.forwardProtocol !== undefined && (value.forwardProtocol !== "one-shot-v1" || value.inputs === undefined)) return false;
|
|
1881
|
+
if (value.inputs !== undefined) {
|
|
1882
|
+
const inputs = value.inputs;
|
|
1883
|
+
if (!Array.isArray(inputs) || inputs.length === 0 || inputs.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1884
|
+
!inputs.every(isTemporaryThreadInput) || !isDeepStrictEqual(inputs[0],
|
|
1885
|
+
{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }) ||
|
|
1886
|
+
inputs.some((input, index) => input.journalBindingKey !== source.journalBindingKey ||
|
|
1887
|
+
inputs.slice(index + 1).some(other => other.updateIds.some(id => input.updateIds.includes(id))))) return false;
|
|
1888
|
+
}
|
|
1889
|
+
if (value.cancelledInputs !== undefined) {
|
|
1890
|
+
const cancelled = value.cancelledInputs;
|
|
1891
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1892
|
+
if (!Array.isArray(cancelled) || cancelled.length === 0 || cancelled.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1893
|
+
!cancelled.every(isTemporaryThreadInput) || cancelled.some((input, index) =>
|
|
1894
|
+
!inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
|
|
1895
|
+
}
|
|
1896
|
+
if (value.completedInputs !== undefined) {
|
|
1897
|
+
const completed = value.completedInputs;
|
|
1898
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1899
|
+
const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
|
|
1900
|
+
if (!Array.isArray(completed) || completed.length === 0 || completed.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1901
|
+
!completed.every(isTemporaryThreadInput) || completed.some((input, index) =>
|
|
1902
|
+
!inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
|
|
1903
|
+
completed.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
|
|
1904
|
+
}
|
|
1905
|
+
if (value.forwardedInputs !== undefined) {
|
|
1906
|
+
const forwarded = value.forwardedInputs;
|
|
1907
|
+
const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
|
|
1908
|
+
const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
|
|
1909
|
+
if (!Array.isArray(forwarded) || forwarded.length === 0 || forwarded.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
|
|
1910
|
+
!forwarded.every(isTemporaryThreadInput) || forwarded.some((input, index) =>
|
|
1911
|
+
!inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
|
|
1912
|
+
forwarded.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
|
|
1913
|
+
}
|
|
1914
|
+
if (value.cleanupIssued !== undefined && (value.cleanupIssued !== true ||
|
|
1915
|
+
!isTelegramTemporaryThreadFullyResolved(value as unknown as TelegramTemporaryThreadEntry))) return false;
|
|
1916
|
+
if (value.phase === "creating") return value.target === undefined && value.cancelledInputs === undefined && value.completedInputs === undefined && value.forwardedInputs === undefined && value.cleanupIssued === undefined &&
|
|
1917
|
+
(!Array.isArray(value.inputs) || value.inputs.length === 1);
|
|
1918
|
+
const target = value.target;
|
|
1919
|
+
return value.phase === "created" && restoreObject(target) && restoreKeys(target, ["chatId", "threadId"]) &&
|
|
1920
|
+
target.chatId === value.operatorUserId && Number.isSafeInteger(target.threadId) && (target.threadId as number) > 0;
|
|
1921
|
+
}
|
|
1922
|
+
/** Only an exact recorded source group may Restore into its temporary tab. */
|
|
1923
|
+
function isTemporaryThreadRestore(entry: TelegramTemporaryThreadEntry, operations: readonly TelegramWorkspaceRestoreIntent[]): boolean {
|
|
1924
|
+
return operations.some(({ request }) => !!entry.target && targetMatches(request.target, entry.target) &&
|
|
1925
|
+
![...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])].some(input => input.journalBindingKey === request.source.journalBindingKey &&
|
|
1926
|
+
input.updateIds.some(id => request.source.updateIds.includes(id))) &&
|
|
1927
|
+
(entry.inputs ? entry.inputs.some(input => isDeepStrictEqual(input, request.source)) :
|
|
1928
|
+
request.source.journalBindingKey === entry.source.journalBindingKey && request.source.updateIds.includes(entry.source.updateId)));
|
|
1929
|
+
}
|
|
1930
|
+
function conflictsWithTemporaryThread(snapshot: TelegramWorkspaceRestoreSnapshot | undefined, target: TelegramTarget | undefined): boolean {
|
|
1931
|
+
return !!target && !!snapshot?.temporaryThreads?.some(entry => !!entry.target && targetMatches(entry.target, target));
|
|
1932
|
+
}
|
|
1933
|
+
function conflictsWithWorkspaceRestoreProvision(request: TelegramWorkspaceRestoreRequest,
|
|
1934
|
+
provision: TelegramThreadPendingProvision | TelegramThreadReservation): boolean {
|
|
1935
|
+
if ("owner" in provision && !provision.workspaceBindingKey &&
|
|
1936
|
+
(provision.profileKey === request.owner.profileKey || provision.instanceId === request.owner.instanceId)) return true;
|
|
1937
|
+
return ("workspaceBindingKey" in provision && provision.workspaceBindingKey === request.binding.bindingKey) || provision.slot === request.binding.slot ||
|
|
1938
|
+
!!provision.target && (targetMatches(provision.target, request.binding.target) || targetMatches(provision.target, request.target));
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function assertWorkspaceRestoreBindingProtection(file: Pick<TelegramTopicTargetFile, "workspaceBindings" | "workspaceRestore" | "pendingProvisions" | "reservations"> & {
|
|
1942
|
+
threads?: readonly Pick<TelegramTopicTargetRecord, "target" | "slot">[];
|
|
1943
|
+
}): void {
|
|
1944
|
+
for (const { request } of file.workspaceRestore?.operations ?? []) {
|
|
1945
|
+
if (file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
|
|
1946
|
+
file.reservations?.some(value => conflictsWithWorkspaceRestoreProvision(request, value))) {
|
|
1947
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
1948
|
+
}
|
|
1949
|
+
const bindings = file.workspaceBindings ?? [];
|
|
1950
|
+
const retained = bindings.filter(value => value.bindingKey === request.binding.bindingKey);
|
|
1951
|
+
const current = retained[0];
|
|
1952
|
+
const identity = ["bindingKey", "cwd", "workspaceKey", "instanceSlot", "sessionId", "sessionKey", "slot"] as const;
|
|
1953
|
+
if (retained.length !== 1 || !current || identity.some(key => current[key] !== request.binding[key]) ||
|
|
1954
|
+
!targetMatches(current.target, request.target) || bindings.some(value => value !== current &&
|
|
1955
|
+
(value.slot === current.slot || targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target)))) {
|
|
1956
|
+
throw new Error("Protected Workspace Restore binding changed.");
|
|
1957
|
+
}
|
|
1958
|
+
const owners = (file.threads ?? []).filter(value => value.slot === current.slot ||
|
|
1959
|
+
targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target));
|
|
1960
|
+
if (owners.length > 1 || owners.some(value => value.slot !== current.slot || !targetMatches(value.target, request.target))) {
|
|
1961
|
+
throw new Error("Protected Workspace Restore owner target changed.");
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const operations = file.workspaceRestore?.operations ?? [];
|
|
1965
|
+
for (const entry of file.workspaceRestore?.temporaryThreads ?? []) {
|
|
1966
|
+
const target = entry.target;
|
|
1967
|
+
if (!target) continue;
|
|
1968
|
+
const claimed = [...(file.workspaceBindings ?? []), ...(file.threads ?? [])].some(value => targetMatches(value.target, target));
|
|
1969
|
+
if (file.pendingProvisions?.some(value => !!value.target && targetMatches(value.target, target)) ||
|
|
1970
|
+
file.reservations?.some(value => targetMatches(value.target, target)) ||
|
|
1971
|
+
(claimed && !isTemporaryThreadRestore(entry, operations))) {
|
|
1972
|
+
throw new Error("Protected temporary Thread target conflict.");
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function workspaceRestoresConflict(left: TelegramWorkspaceRestoreRequest, right: TelegramWorkspaceRestoreRequest): boolean {
|
|
1978
|
+
const targets = [left.binding.target, left.target];
|
|
1979
|
+
return left.operationId === right.operationId || left.binding.bindingKey === right.binding.bindingKey ||
|
|
1980
|
+
left.binding.slot === right.binding.slot || targets.some(target => targetMatches(target, right.binding.target) || targetMatches(target, right.target)) ||
|
|
1981
|
+
(left.source.journalBindingKey === right.source.journalBindingKey && right.source.updateIds.some(id => left.source.updateIds.includes(id)));
|
|
1982
|
+
}
|
|
1983
|
+
const WORKSPACE_RESTORE_MAX_BYTES = 1024 * 1024;
|
|
1984
|
+
const TEMPORARY_THREAD_CAPACITY = 26;
|
|
1985
|
+
const TEMPORARY_THREAD_INPUT_CAPACITY = 100;
|
|
1986
|
+
const WORKSPACE_SNAPSHOT_MAX_BYTES = 8 * WORKSPACE_RESTORE_MAX_BYTES;
|
|
1987
|
+
function parseWorkspaceRestore(value: unknown): TelegramWorkspaceRestoreSnapshot | undefined {
|
|
1988
|
+
if (value === undefined) return undefined;
|
|
1989
|
+
const temporary = restoreObject(value) ? value.temporaryThreads : undefined;
|
|
1990
|
+
if (!restoreObject(value) || !restoreKeys(value, ["version", "profileName", "tokenSha256", "revision", "operations", "temporaryThreads"]) ||
|
|
1991
|
+
value.version !== 1 || !restoreText(value.profileName) || typeof value.tokenSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.tokenSha256) ||
|
|
1992
|
+
!restoreInteger(value.revision) || !Array.isArray(value.operations) || value.operations.length > 26 ||
|
|
1993
|
+
(temporary !== undefined && (!Array.isArray(temporary) || temporary.length === 0 ||
|
|
1994
|
+
temporary.length > TEMPORARY_THREAD_CAPACITY || !temporary.every(isTemporaryThreadEntry))) ||
|
|
1995
|
+
((value.operations.length > 0 || temporary !== undefined) && value.revision === 0) || !value.operations.every(isWorkspaceRestoreIntent) ||
|
|
1996
|
+
Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_RESTORE_MAX_BYTES) throw new Error("Invalid Workspace Restore evidence.");
|
|
1997
|
+
const snapshot = value as unknown as TelegramWorkspaceRestoreSnapshot;
|
|
1998
|
+
if (snapshot.operations.some((operation, index) => snapshot.operations.slice(index + 1).some(other => workspaceRestoresConflict(operation.request, other.request))))
|
|
1999
|
+
throw new Error("Conflicting Workspace Restore evidence.");
|
|
2000
|
+
const entries = snapshot.temporaryThreads ?? [];
|
|
2001
|
+
if (entries.some((entry, index) => entries.slice(index + 1).some(other => other.token === entry.token ||
|
|
2002
|
+
getTelegramTemporaryThreadInputs(entry).some(input => getTelegramTemporaryThreadInputs(other).some(candidate =>
|
|
2003
|
+
input.journalBindingKey === candidate.journalBindingKey && input.updateIds.some(id => candidate.updateIds.includes(id)))) ||
|
|
2004
|
+
(!!entry.target && !!other.target && targetMatches(entry.target, other.target)))) ||
|
|
2005
|
+
entries.some(entry => !!entry.target && snapshot.operations.some(({ request }) => targetMatches(request.binding.target, entry.target!) ||
|
|
2006
|
+
(targetMatches(request.target, entry.target!) && !isTemporaryThreadRestore(entry, [{ request } as TelegramWorkspaceRestoreIntent])))))
|
|
2007
|
+
throw new Error("Conflicting Workspace Restore evidence.");
|
|
2008
|
+
return snapshot;
|
|
2009
|
+
}
|
|
2010
|
+
/** All Restore reads and publishers share these physical-file checks; there is no repairing read. */
|
|
2011
|
+
const readWorkspaceDescriptor = promisify(readFileCallback);
|
|
2012
|
+
function readWorkspaceSnapshot(path: string, requirePrivate = true, asynchronous = false): unknown {
|
|
2013
|
+
let before;
|
|
2014
|
+
try { before = lstatSync(path, { bigint: true }); }
|
|
2015
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
2016
|
+
const uid = process.getuid?.();
|
|
2017
|
+
const privateFile = uid === undefined || (before.uid === BigInt(uid) && (before.mode & 0o077n) === 0n);
|
|
2018
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > BigInt(WORKSPACE_SNAPSHOT_MAX_BYTES) ||
|
|
2019
|
+
(requirePrivate && !privateFile)) throw new Error("Workspace snapshot must be a bounded private regular file.");
|
|
2020
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
2021
|
+
let closeImmediately = true;
|
|
2022
|
+
try {
|
|
2023
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
2024
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs)
|
|
2025
|
+
throw new Error("Workspace snapshot changed during inspection.");
|
|
2026
|
+
const decode = (content: string): unknown => {
|
|
2027
|
+
const value: unknown = JSON.parse(content);
|
|
2028
|
+
// Legacy metadata may predate private storage; a Restore-bearing snapshot never may.
|
|
2029
|
+
if (!privateFile && restoreObject(value) && value.workspaceRestore !== undefined)
|
|
2030
|
+
throw new Error("Workspace snapshot must be a bounded private regular file.");
|
|
2031
|
+
return value;
|
|
2032
|
+
};
|
|
2033
|
+
if (asynchronous) {
|
|
2034
|
+
const reading = readWorkspaceDescriptor(fd, "utf8").then(decode);
|
|
2035
|
+
closeImmediately = false;
|
|
2036
|
+
return reading.finally(() => closeSync(fd));
|
|
2037
|
+
}
|
|
2038
|
+
return decode(readFileSync(fd, "utf8"));
|
|
2039
|
+
} finally { if (closeImmediately) closeSync(fd); }
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1643
2042
|
function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
1644
2043
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1645
2044
|
return {
|
|
@@ -1651,6 +2050,12 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1651
2050
|
};
|
|
1652
2051
|
}
|
|
1653
2052
|
const file = value as Record<string, unknown>;
|
|
2053
|
+
// The unpublished two-file draft is not migration or source-reconstruction authority.
|
|
2054
|
+
if ((file.workspaceRelocations !== undefined && (!Array.isArray(file.workspaceRelocations) || file.workspaceRelocations.length !== 0)) ||
|
|
2055
|
+
(file.workspaceRelocationRevision !== undefined && file.workspaceRelocationRevision !== 0))
|
|
2056
|
+
throw new Error("Unmigrated Workspace relocation evidence.");
|
|
2057
|
+
const workspaceRestore = parseWorkspaceRestore(file.workspaceRestore);
|
|
2058
|
+
if (file.version !== 1 && workspaceRestore) throw new Error("Unsupported Workspace Restore snapshot.");
|
|
1654
2059
|
if (file.version !== 1) {
|
|
1655
2060
|
return {
|
|
1656
2061
|
version: 1,
|
|
@@ -1682,6 +2087,8 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1682
2087
|
workspaceBindings: Array.isArray(file.workspaceBindings)
|
|
1683
2088
|
? file.workspaceBindings.flatMap((binding) => {
|
|
1684
2089
|
const normalized = normalizeWorkspaceBindingRecord(binding);
|
|
2090
|
+
if (!normalized && binding && typeof binding === "object" && "journalSources" in binding)
|
|
2091
|
+
throw new Error("Invalid Workspace session journal evidence.");
|
|
1685
2092
|
return normalized ? [normalized] : [];
|
|
1686
2093
|
})
|
|
1687
2094
|
: [],
|
|
@@ -1691,6 +2098,7 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1691
2098
|
return normalized ? [normalized] : [];
|
|
1692
2099
|
})
|
|
1693
2100
|
: [],
|
|
2101
|
+
...(workspaceRestore ? { workspaceRestore } : {}),
|
|
1694
2102
|
sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
|
|
1695
2103
|
reservations: Array.isArray(file.reservations)
|
|
1696
2104
|
? file.reservations.flatMap((reservation) => {
|
|
@@ -1719,6 +2127,92 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
|
1719
2127
|
};
|
|
1720
2128
|
}
|
|
1721
2129
|
|
|
2130
|
+
/** The unified section accepts only lossless current-format Workspace evidence, never tolerant legacy repair. */
|
|
2131
|
+
export function parseTelegramWorkspaceStateSection(value: unknown, profile: string): TelegramTopicTargetFile | undefined {
|
|
2132
|
+
if (value === undefined) return undefined;
|
|
2133
|
+
if (!restoreObject(value) || value.version !== 1 || value.source !== "snapshot" ||
|
|
2134
|
+
!Number.isSafeInteger(value.writtenAtMs) || (value.writtenAtMs as number) < 0 ||
|
|
2135
|
+
!restoreObject(value.bot) || !Array.isArray(value.threads))
|
|
2136
|
+
throw new Error("Invalid consolidated Workspace section.");
|
|
2137
|
+
if (Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_SNAPSHOT_MAX_BYTES)
|
|
2138
|
+
throw new Error("Workspace snapshot byte capacity reached.");
|
|
2139
|
+
const parsed = parseTopicTargetFile(value), rawThreads = value.threads;
|
|
2140
|
+
const wire = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
|
|
2141
|
+
wire.threads = (wire.threads as Record<string, unknown>[]).map((record, index) => {
|
|
2142
|
+
const copied = { ...record };
|
|
2143
|
+
const original = rawThreads[index];
|
|
2144
|
+
if (!restoreObject(original)) throw new Error("Invalid consolidated Workspace thread evidence.");
|
|
2145
|
+
if (!Object.hasOwn(original, "profileKey")) delete copied.profileKey;
|
|
2146
|
+
return copied;
|
|
2147
|
+
});
|
|
2148
|
+
for (const key of Object.keys(value)) {
|
|
2149
|
+
if (!Object.hasOwn(wire, key) || !isDeepStrictEqual(value[key], wire[key]))
|
|
2150
|
+
throw new Error("Consolidated Workspace evidence cannot drop or normalize unknown facts.");
|
|
2151
|
+
}
|
|
2152
|
+
if ((parsed.workspaceRestore && parsed.workspaceRestore.profileName !== profile) ||
|
|
2153
|
+
(parsed.sessionReplacement && parsed.sessionReplacement.profileName !== profile) ||
|
|
2154
|
+
parsed.threads.some(record => record.owner && "telegramProfile" in record.owner &&
|
|
2155
|
+
record.owner.telegramProfile !== undefined && record.owner.telegramProfile !== profile))
|
|
2156
|
+
throw new Error("Foreign consolidated Workspace profile evidence.");
|
|
2157
|
+
assertWorkspaceRestoreBindingProtection(parsed);
|
|
2158
|
+
return parsed;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export function resolveTelegramWorkspaceProvisionRecoveryPath(statePath: string, profileName = "default", layout?: "consolidated"): string {
|
|
2162
|
+
if (layout !== undefined && layout !== "consolidated") throw new Error("Invalid Workspace provisioning recovery layout.");
|
|
2163
|
+
return layout === "consolidated"
|
|
2164
|
+
? join(dirname(statePath), "runtime", `${basename(statePath)}.provision-recovery.${createHash("sha256").update(profileName).digest("hex").slice(0, 16)}.json`)
|
|
2165
|
+
: `${statePath}.provision-recovery.json`;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
export interface TelegramConsolidatedWorkspaceStorageOptions {
|
|
2169
|
+
getPath: () => string;
|
|
2170
|
+
getProfile: () => string | undefined;
|
|
2171
|
+
/** Exact owner/context/session grant captured before the caller's first await. */
|
|
2172
|
+
captureAuthority: () => (() => boolean) | undefined;
|
|
2173
|
+
publishIfOwned: NonNullable<TelegramLockRuntime<TelegramLockContext>["publishStateSectionIfOwned"]>;
|
|
2174
|
+
}
|
|
2175
|
+
export type TelegramWorkspaceStatePublication = <T>(
|
|
2176
|
+
mutate: (current: unknown) => TelegramRuntimeStateMutation<T>,
|
|
2177
|
+
publication?: Partial<Pick<TelegramOwnedStatePublicationOptions, "onPublicationBoundary" | "publishRename" | "isCurrent">>,
|
|
2178
|
+
) => TelegramOwnedStatePublicationResult<T>;
|
|
2179
|
+
|
|
2180
|
+
/** Prepared Workspace IO adapter; transition/CAS policy remains with the existing Threads owner. */
|
|
2181
|
+
export function createTelegramConsolidatedWorkspaceStorage(options: TelegramConsolidatedWorkspaceStorageOptions) {
|
|
2182
|
+
const { getPath, getProfile, captureAuthority, publishIfOwned } = options;
|
|
2183
|
+
const profileName = (): string => getProfile() ?? "default";
|
|
2184
|
+
const readRaw = (): unknown => {
|
|
2185
|
+
const path = getPath(), profile = profileName(), root = readTelegramRuntimeState(path);
|
|
2186
|
+
const raw = Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.workspace : undefined;
|
|
2187
|
+
parseTelegramWorkspaceStateSection(raw, profile);
|
|
2188
|
+
if (path !== getPath() || profile !== profileName()) throw new Error("Workspace storage scope changed during observation.");
|
|
2189
|
+
return raw;
|
|
2190
|
+
};
|
|
2191
|
+
return {
|
|
2192
|
+
readRaw,
|
|
2193
|
+
read: () => parseTelegramWorkspaceStateSection(readRaw(), profileName()),
|
|
2194
|
+
capturePublication(): TelegramWorkspaceStatePublication | undefined {
|
|
2195
|
+
const path = getPath(), profile = profileName(), authority = captureAuthority();
|
|
2196
|
+
if (!authority) return undefined;
|
|
2197
|
+
const isCurrent = (): boolean => path === getPath() && profile === profileName() && authority() === true;
|
|
2198
|
+
if (!isCurrent()) return undefined;
|
|
2199
|
+
return <T>(mutate: (current: unknown) => TelegramRuntimeStateMutation<T>,
|
|
2200
|
+
publication?: Partial<Pick<TelegramOwnedStatePublicationOptions, "onPublicationBoundary" | "publishRename" | "isCurrent">>) => {
|
|
2201
|
+
const sourceCurrent = publication?.isCurrent;
|
|
2202
|
+
const currentGrant = () => isCurrent() && (sourceCurrent?.() ?? true);
|
|
2203
|
+
if (!currentGrant()) return { committed: false };
|
|
2204
|
+
return publishIfOwned<T>("workspace", current => {
|
|
2205
|
+
parseTelegramWorkspaceStateSection(current, profile);
|
|
2206
|
+
const next = mutate(current);
|
|
2207
|
+
if (next.value === undefined && current !== undefined) throw new Error("Consolidated Workspace section removal is not authorized.");
|
|
2208
|
+
parseTelegramWorkspaceStateSection(next.value, profile);
|
|
2209
|
+
return next;
|
|
2210
|
+
}, { ...publication, isCurrent: currentGrant, expectedScope: { path, profile } });
|
|
2211
|
+
};
|
|
2212
|
+
},
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
1722
2216
|
function getTelegramStateSemanticSnapshot(
|
|
1723
2217
|
value: unknown,
|
|
1724
2218
|
): Record<string, unknown> | undefined {
|
|
@@ -1731,14 +2225,6 @@ function getTelegramStateSemanticSnapshot(
|
|
|
1731
2225
|
return semantic;
|
|
1732
2226
|
}
|
|
1733
2227
|
|
|
1734
|
-
function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
|
|
1735
|
-
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
function getTargetRecoveryHintKey(target: TelegramTarget): string {
|
|
1739
|
-
return `${target.chatId}:${target.threadId ?? "private"}`;
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
2228
|
function parseFollowerRecoveryHints(
|
|
1743
2229
|
value: unknown,
|
|
1744
2230
|
): Map<string, { slot?: string; threadName?: string }> {
|
|
@@ -1815,9 +2301,37 @@ function isPendingProvisionLiveOrTargeted(
|
|
|
1815
2301
|
return !!provision.target;
|
|
1816
2302
|
}
|
|
1817
2303
|
|
|
2304
|
+
/** Resolving selects a scope-bound view of the existing Workspace owner; it creates no files. */
|
|
2305
|
+
export function createTelegramWorkspaceRestoreResolver(deps: {
|
|
2306
|
+
getProfileName: () => string | undefined;
|
|
2307
|
+
getBotToken: () => string | undefined;
|
|
2308
|
+
threadStore: Pick<TelegramTopicTargetStore, "workspaceRestore">;
|
|
2309
|
+
agentDir?: string;
|
|
2310
|
+
}) {
|
|
2311
|
+
const { getProfileName, getBotToken, threadStore, agentDir } = deps;
|
|
2312
|
+
let active: { profileName: string; tokenSha256: string; store: TelegramWorkspaceRestore } | undefined;
|
|
2313
|
+
return () => {
|
|
2314
|
+
const profile = getProfileName();
|
|
2315
|
+
const profileName = profile ?? "default";
|
|
2316
|
+
const token = getBotToken();
|
|
2317
|
+
if (!token) return undefined;
|
|
2318
|
+
const tokenSha256 = createHash("sha256").update(token).digest("hex");
|
|
2319
|
+
if (active?.profileName === profileName && active.tokenSha256 === tokenSha256) return active.store;
|
|
2320
|
+
const store = threadStore.workspaceRestore({ profileName, tokenSha256,
|
|
2321
|
+
legacyPath: resolveTelegramProfileTempFilePath("workspace-restore", "json", agentDir, profile),
|
|
2322
|
+
isCurrentScope: () => (getProfileName() ?? "default") === profileName &&
|
|
2323
|
+
createHash("sha256").update(getBotToken() ?? "").digest("hex") === tokenSha256 });
|
|
2324
|
+
active = { profileName, tokenSha256, store };
|
|
2325
|
+
return store;
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
|
|
1818
2329
|
export function createTelegramTopicTargetStore(
|
|
1819
2330
|
options: TelegramTopicTargetStoreOptions,
|
|
1820
2331
|
): TelegramTopicTargetStore {
|
|
2332
|
+
if (options.consolidated && options.commitPersist) throw new Error("Workspace must select one publication backend.");
|
|
2333
|
+
const consolidated = options.consolidated ? { ...options.consolidated } : undefined;
|
|
2334
|
+
const commitPersist = options.commitPersist?.bind(options);
|
|
1821
2335
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
1822
2336
|
const captureExternalReservedSlots = (): string[] | undefined => {
|
|
1823
2337
|
try {
|
|
@@ -1836,6 +2350,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1836
2350
|
let identities = new Map<string, TelegramThreadIdentityRecord>();
|
|
1837
2351
|
let workspaceBindings = new Map<string, TelegramWorkspaceThreadBinding>();
|
|
1838
2352
|
let workspaceRetirements: TelegramWorkspaceRetirementIntent[] = [];
|
|
2353
|
+
let workspaceRestore: TelegramWorkspaceRestoreSnapshot | undefined;
|
|
1839
2354
|
let sessionReplacement: TelegramSessionReplacementIntent | undefined;
|
|
1840
2355
|
let workspaceRetirementCommitInFlight = false;
|
|
1841
2356
|
const hasWorkspaceRetirementConflict = (input: {
|
|
@@ -1866,10 +2381,14 @@ export function createTelegramTopicTargetStore(
|
|
|
1866
2381
|
>();
|
|
1867
2382
|
let loaded = false;
|
|
1868
2383
|
let loadedPath: string | undefined;
|
|
2384
|
+
let loadedProfile: string | undefined;
|
|
2385
|
+
let observedWorkspaceSemantic: Record<string, unknown> | undefined;
|
|
1869
2386
|
let dirty = false;
|
|
1870
2387
|
let mutationRevision = 0;
|
|
1871
|
-
let statusRevision = 0;
|
|
1872
2388
|
let persistQueue: Promise<void> = Promise.resolve();
|
|
2389
|
+
let statusQueue: Promise<void> = Promise.resolve();
|
|
2390
|
+
let lastStatusSemantic: Record<string, unknown> | undefined;
|
|
2391
|
+
let lastStatusPath: string | undefined;
|
|
1873
2392
|
let statusSnapshot: {
|
|
1874
2393
|
runtime?: Record<string, unknown>;
|
|
1875
2394
|
liveRoster?: Record<string, unknown>;
|
|
@@ -1963,6 +2482,11 @@ export function createTelegramTopicTargetStore(
|
|
|
1963
2482
|
|
|
1964
2483
|
const getPath = () =>
|
|
1965
2484
|
typeof options.path === "function" ? options.path() : options.path;
|
|
2485
|
+
/** The non-canonical projection sits beside canonical state (`state*.json` → `status*.json`). */
|
|
2486
|
+
const getStatusPath = (): string => {
|
|
2487
|
+
const statePath = getPath(), name = basename(statePath);
|
|
2488
|
+
return join(dirname(statePath), /^state/u.test(name) ? name.replace(/^state/u, "status") : `${name}.status`);
|
|
2489
|
+
};
|
|
1966
2490
|
const getTelegramProfile = () =>
|
|
1967
2491
|
typeof options.telegramProfile === "function"
|
|
1968
2492
|
? options.telegramProfile()
|
|
@@ -1981,11 +2505,61 @@ export function createTelegramTopicTargetStore(
|
|
|
1981
2505
|
}
|
|
1982
2506
|
return { ...owner, telegramProfile };
|
|
1983
2507
|
};
|
|
1984
|
-
const
|
|
2508
|
+
const activeProfile = () => getTelegramProfile() ?? "default";
|
|
2509
|
+
const scopeMatches = (path: string, profile: string) => getPath() === path && activeProfile() === profile;
|
|
2510
|
+
const loadedScopeMatches = (path: string) => loadedPath === path && (!consolidated || loadedProfile === activeProfile());
|
|
2511
|
+
const workspaceIO = consolidated ? createTelegramConsolidatedWorkspaceStorage({ getPath, getProfile: getTelegramProfile, ...consolidated }) : undefined;
|
|
2512
|
+
const runtimeProjection = consolidated ? createTelegramRuntimeProjectionStore({ getPath, getProfile: getTelegramProfile,
|
|
2513
|
+
captureAuthority: consolidated.captureAuthority, getNowMs,
|
|
2514
|
+
storage: {
|
|
2515
|
+
read({ path, profile }) { const root = readTelegramRuntimeState(path); return Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.runtime : undefined; },
|
|
2516
|
+
publish(expectedScope, mutate, isCurrent) {
|
|
2517
|
+
const outcome = consolidated.publishIfOwned("runtime", current => {
|
|
2518
|
+
const next = mutate(current);
|
|
2519
|
+
return { value: next.value, result: next.changed };
|
|
2520
|
+
}, { isCurrent, expectedScope });
|
|
2521
|
+
return outcome.committed && outcome.result;
|
|
2522
|
+
},
|
|
2523
|
+
},
|
|
2524
|
+
}) : undefined;
|
|
2525
|
+
let workspaceFrame: { path: string; profile: string; value: unknown } | undefined;
|
|
2526
|
+
const readStoreSnapshot = (path: string, requirePrivate = true, asynchronous = false): unknown => {
|
|
2527
|
+
if (!workspaceIO) return readWorkspaceSnapshot(path, requirePrivate, asynchronous);
|
|
2528
|
+
if (path !== getPath()) throw new Error("Workspace storage scope changed during observation.");
|
|
2529
|
+
if (workspaceFrame) {
|
|
2530
|
+
if (!scopeMatches(workspaceFrame.path, workspaceFrame.profile)) throw new Error("Workspace transaction scope changed.");
|
|
2531
|
+
return workspaceFrame.value;
|
|
2532
|
+
}
|
|
2533
|
+
const value = workspaceIO.readRaw();
|
|
2534
|
+
return asynchronous ? Promise.resolve(value) : value;
|
|
2535
|
+
};
|
|
2536
|
+
const withStoreTransaction = <T>(path: string, operation: () => T,
|
|
2537
|
+
publication?: Parameters<TelegramWorkspaceStatePublication>[1]): T => {
|
|
2538
|
+
if (!workspaceIO) return withTelegramFileTransaction(`${path}.transaction`, operation);
|
|
2539
|
+
if (workspaceFrame) throw new Error("Nested Workspace transaction is not allowed.");
|
|
2540
|
+
const profile = activeProfile(), publish = workspaceIO.capturePublication();
|
|
2541
|
+
if (!publish || path !== getPath()) throw new Error("Workspace publication authority changed.");
|
|
2542
|
+
const outcome = publish(value => {
|
|
2543
|
+
workspaceFrame = { path, profile, value };
|
|
2544
|
+
try { const result = operation(); return { value: workspaceFrame.value, result }; }
|
|
2545
|
+
finally { workspaceFrame = undefined; }
|
|
2546
|
+
}, publication);
|
|
2547
|
+
if (!outcome.committed) throw new Error("Workspace publication authority changed.");
|
|
2548
|
+
return outcome.result;
|
|
2549
|
+
};
|
|
2550
|
+
const getRecoveryPath = (path: string) => consolidated
|
|
2551
|
+
? resolveTelegramWorkspaceProvisionRecoveryPath(path, activeProfile(), "consolidated")
|
|
2552
|
+
: resolveTelegramWorkspaceProvisionRecoveryPath(path);
|
|
1985
2553
|
const readProvisionRecoveries = (
|
|
1986
|
-
path: string,
|
|
2554
|
+
path: string, strict = false,
|
|
1987
2555
|
): TelegramProvisionRecoveryFile => {
|
|
1988
2556
|
const recoveryPath = getRecoveryPath(path);
|
|
2557
|
+
if (strict) {
|
|
2558
|
+
const value = readWorkspaceSnapshot(recoveryPath);
|
|
2559
|
+
if (value === undefined) return {};
|
|
2560
|
+
if (!restoreObject(value)) throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
2561
|
+
return value as TelegramProvisionRecoveryFile;
|
|
2562
|
+
}
|
|
1989
2563
|
if (!existsSync(recoveryPath)) return {};
|
|
1990
2564
|
try {
|
|
1991
2565
|
const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown;
|
|
@@ -1996,13 +2570,39 @@ export function createTelegramTopicTargetStore(
|
|
|
1996
2570
|
return {};
|
|
1997
2571
|
}
|
|
1998
2572
|
};
|
|
2573
|
+
const assertRestoreRecoveryProtection = (path: string, operations: readonly TelegramWorkspaceRestoreIntent[],
|
|
2574
|
+
provisions: readonly TelegramThreadPendingProvision[], requireKnownTargets = false,
|
|
2575
|
+
recoveryEvidence?: TelegramProvisionRecoveryFile): void => {
|
|
2576
|
+
if (!operations.length) return;
|
|
2577
|
+
const recoveries = recoveryEvidence ?? readProvisionRecoveries(path, true);
|
|
2578
|
+
for (const provision of provisions) {
|
|
2579
|
+
let target = provision.target;
|
|
2580
|
+
if (Object.hasOwn(recoveries, provision.id)) {
|
|
2581
|
+
const recovery = recoveries[provision.id];
|
|
2582
|
+
if (!restoreObject(recovery)) throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
2583
|
+
if (recovery.instanceId === provision.instanceId && recovery.profileKey === provision.profileKey &&
|
|
2584
|
+
recovery.leaderEpoch === provision.leaderEpoch) {
|
|
2585
|
+
const observed = recovery.target;
|
|
2586
|
+
if (!restoreObject(observed) || typeof observed.chatId !== "number" || !Number.isSafeInteger(observed.chatId) ||
|
|
2587
|
+
!restoreInteger(observed.threadId) || observed.threadId === 0) throw new Error("Invalid Workspace provisioning recovery evidence.");
|
|
2588
|
+
const recovered = { chatId: observed.chatId, threadId: observed.threadId };
|
|
2589
|
+
if (target && !targetMatches(target, recovered)) throw new Error("Conflicting Workspace provisioning target evidence.");
|
|
2590
|
+
target = recovered;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
if (requireKnownTargets && !target) throw new Error("Workspace Restore target availability is unknown during unfinished creation.");
|
|
2594
|
+
if (operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, { ...provision, target })))
|
|
2595
|
+
throw new Error("Protected Workspace Restore provisioning recovery conflict.");
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
1999
2598
|
const resetForPath = (path: string) => {
|
|
2000
|
-
if (
|
|
2599
|
+
if (loadedScopeMatches(path)) return;
|
|
2001
2600
|
botState = { threadMode: "unknown" };
|
|
2002
2601
|
records = new Map();
|
|
2003
2602
|
identities = new Map();
|
|
2004
2603
|
workspaceBindings = new Map();
|
|
2005
2604
|
workspaceRetirements = [];
|
|
2605
|
+
workspaceRestore = undefined;
|
|
2006
2606
|
sessionReplacement = undefined;
|
|
2007
2607
|
workspaceRetirementCommitInFlight = false;
|
|
2008
2608
|
workspaceClaims = new Map();
|
|
@@ -2015,17 +2615,60 @@ export function createTelegramTopicTargetStore(
|
|
|
2015
2615
|
loaded = false;
|
|
2016
2616
|
dirty = false;
|
|
2017
2617
|
loadedPath = path;
|
|
2618
|
+
loadedProfile = activeProfile();
|
|
2619
|
+
observedWorkspaceSemantic = undefined;
|
|
2018
2620
|
};
|
|
2019
2621
|
|
|
2020
|
-
const
|
|
2622
|
+
const observeWorkspaceRestore = (incoming: TelegramWorkspaceRestoreSnapshot | undefined): void => {
|
|
2623
|
+
if (workspaceRestore && (!incoming || incoming.profileName !== workspaceRestore.profileName ||
|
|
2624
|
+
incoming.tokenSha256 !== workspaceRestore.tokenSha256 || incoming.revision < workspaceRestore.revision ||
|
|
2625
|
+
(incoming.revision === workspaceRestore.revision && !isDeepStrictEqual(incoming, workspaceRestore))))
|
|
2626
|
+
throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
|
|
2627
|
+
};
|
|
2628
|
+
const withRestoreRegistration = (candidate: Parameters<TelegramTopicTargetStore["assertWorkspaceRestoreRegistration"]>[0], publish?: () => void): void => {
|
|
2021
2629
|
const path = getPath();
|
|
2630
|
+
withStoreTransaction(path, () => {
|
|
2631
|
+
const file = parseTopicTargetFile(readStoreSnapshot(path, false));
|
|
2632
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
2633
|
+
const operations = file.workspaceRestore?.operations ?? [];
|
|
2634
|
+
if (operations.length) {
|
|
2635
|
+
if ((options.canPersist && !options.canPersist()) || getPath() !== path)
|
|
2636
|
+
throw new Error("Workspace Restore registration authority changed.");
|
|
2637
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
2638
|
+
assertRestoreRecoveryProtection(path, operations, file.pendingProvisions ?? []);
|
|
2639
|
+
for (const { request } of operations) {
|
|
2640
|
+
if (targetMatches(candidate.target, request.binding.target) ||
|
|
2641
|
+
((candidate.bindingKey === request.binding.bindingKey || candidate.slot === request.binding.slot ||
|
|
2642
|
+
targetMatches(candidate.target, request.target)) &&
|
|
2643
|
+
(candidate.bindingKey !== request.binding.bindingKey || candidate.slot !== request.binding.slot ||
|
|
2644
|
+
!targetMatches(candidate.target, request.target))))
|
|
2645
|
+
throw new Error("Protected Workspace Restore registration conflicts with its binding or target.");
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
if (publish && candidate.bindingKey !== undefined) {
|
|
2649
|
+
const bindings = (file.workspaceBindings ?? []).filter(binding => binding.bindingKey === candidate.bindingKey);
|
|
2650
|
+
if (bindings.length !== 1 || bindings[0]?.slot !== candidate.slot || !targetMatches(bindings[0].target, candidate.target))
|
|
2651
|
+
throw new Error("Workspace registration binding changed before publication.");
|
|
2652
|
+
}
|
|
2653
|
+
publish?.();
|
|
2654
|
+
});
|
|
2655
|
+
};
|
|
2656
|
+
const loadFromDisk = async () => {
|
|
2657
|
+
const path = getPath(), profile = activeProfile();
|
|
2022
2658
|
resetForPath(path);
|
|
2023
|
-
|
|
2659
|
+
const revision = mutationRevision;
|
|
2660
|
+
const rawFile = await readStoreSnapshot(path, !!workspaceRestore, true);
|
|
2661
|
+
// A read begun before a local mutation cannot replace the newly admitted projection.
|
|
2662
|
+
if (mutationRevision !== revision || getPath() !== path || (consolidated && !scopeMatches(path, profile))) return;
|
|
2663
|
+
if (consolidated) observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(rawFile);
|
|
2664
|
+
if (rawFile === undefined) {
|
|
2665
|
+
if (workspaceRestore?.revision) throw new Error("Workspace Restore evidence disappeared.");
|
|
2024
2666
|
botState = { threadMode: "unknown" };
|
|
2025
2667
|
records = new Map();
|
|
2026
2668
|
identities = new Map();
|
|
2027
2669
|
workspaceBindings = new Map();
|
|
2028
2670
|
workspaceRetirements = [];
|
|
2671
|
+
workspaceRestore = undefined;
|
|
2029
2672
|
sessionReplacement = undefined;
|
|
2030
2673
|
workspaceRetirementCommitInFlight = false;
|
|
2031
2674
|
reservations = [];
|
|
@@ -2036,14 +2679,14 @@ export function createTelegramTopicTargetStore(
|
|
|
2036
2679
|
loaded = true;
|
|
2037
2680
|
return;
|
|
2038
2681
|
}
|
|
2039
|
-
const revision = mutationRevision;
|
|
2040
|
-
const content = await readFile(path, "utf8");
|
|
2041
|
-
// A read begun before a local mutation must not replace the newly admitted
|
|
2042
|
-
// binding/cleanup state with its older disk snapshot.
|
|
2043
|
-
if (mutationRevision !== revision || getPath() !== path) return;
|
|
2044
|
-
const rawFile: unknown = JSON.parse(content);
|
|
2045
2682
|
const file = parseTopicTargetFile(rawFile);
|
|
2046
|
-
|
|
2683
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
2684
|
+
const recoveries = readProvisionRecoveries(path, !!file.workspaceRestore?.operations.length);
|
|
2685
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? [], false, recoveries);
|
|
2686
|
+
try { followerRecoveryHints = parseFollowerRecoveryHints(consolidated
|
|
2687
|
+
? runtimeProjection?.read()
|
|
2688
|
+
: JSON.parse(readFileSync(getStatusPath(), "utf8"))); }
|
|
2689
|
+
catch { followerRecoveryHints = new Map(); }
|
|
2047
2690
|
botState = file.bot;
|
|
2048
2691
|
const scopedRecords = file.threads.map((record) =>
|
|
2049
2692
|
cloneRecord({
|
|
@@ -2075,6 +2718,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2075
2718
|
workspaceRetirements = (file.workspaceRetirements ?? []).map(
|
|
2076
2719
|
cloneWorkspaceRetirementIntent,
|
|
2077
2720
|
);
|
|
2721
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
2078
2722
|
sessionReplacement = file.sessionReplacement
|
|
2079
2723
|
? cloneSessionReplacementIntent(file.sessionReplacement)
|
|
2080
2724
|
: undefined;
|
|
@@ -2088,9 +2732,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2088
2732
|
reservation.expiresAtMs > nowMs,
|
|
2089
2733
|
)
|
|
2090
2734
|
.map((reservation) => ({ ...reservation }));
|
|
2091
|
-
const recoveries = readProvisionRecoveries(path);
|
|
2092
2735
|
pendingProvisions = (file.pendingProvisions ?? [])
|
|
2093
|
-
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
2094
2736
|
.map((provision) => {
|
|
2095
2737
|
const recovery = recoveries[provision.id];
|
|
2096
2738
|
const recoveryMatches =
|
|
@@ -2106,7 +2748,8 @@ export function createTelegramTopicTargetStore(
|
|
|
2106
2748
|
? { target: { ...provision.target } }
|
|
2107
2749
|
: {}),
|
|
2108
2750
|
};
|
|
2109
|
-
})
|
|
2751
|
+
})
|
|
2752
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs));
|
|
2110
2753
|
pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
|
|
2111
2754
|
...intent,
|
|
2112
2755
|
target: { ...intent.target },
|
|
@@ -2120,6 +2763,10 @@ export function createTelegramTopicTargetStore(
|
|
|
2120
2763
|
};
|
|
2121
2764
|
|
|
2122
2765
|
const markDirty = (): void => {
|
|
2766
|
+
if (consolidated && !loadedScopeMatches(getPath())) {
|
|
2767
|
+
if (loadedPath !== undefined) throw new Error("Workspace projection must refresh after scope change.");
|
|
2768
|
+
loadedPath = getPath(); loadedProfile = activeProfile();
|
|
2769
|
+
}
|
|
2123
2770
|
loaded = true;
|
|
2124
2771
|
dirty = true;
|
|
2125
2772
|
mutationRevision += 1;
|
|
@@ -2142,15 +2789,24 @@ export function createTelegramTopicTargetStore(
|
|
|
2142
2789
|
} & (
|
|
2143
2790
|
| { kind: "invalidate"; lastSyncError: string }
|
|
2144
2791
|
| { kind: "detach"; owner: TelegramTopicTargetRecord }
|
|
2145
|
-
|
|
2792
|
+
| { kind: "relocate"; operationId: string; owner: TelegramTopicTargetRecord;
|
|
2793
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
2794
|
+
nextTarget: TelegramTarget & { threadId: number };
|
|
2795
|
+
restore: TelegramWorkspaceRestoreSnapshot; maxBytes: number;
|
|
2796
|
+
onPublicationBoundary?: TelegramWorkspaceRestoreOptions["onPublicationBoundary"] }
|
|
2797
|
+
), isExactJournalFrame?: () => boolean): Promise<boolean> => {
|
|
2798
|
+
const submittedPath = getPath(), submittedProfile = activeProfile();
|
|
2799
|
+
const publishWorkspace = workspaceIO?.capturePublication();
|
|
2146
2800
|
const persist = persistQueue.then(async () => {
|
|
2147
2801
|
const path = getPath();
|
|
2148
|
-
if (
|
|
2802
|
+
if (workspaceIO && (!publishWorkspace || !scopeMatches(submittedPath, submittedProfile) || (dirty && !loadedScopeMatches(path)))) return false;
|
|
2803
|
+
if (!loadedScopeMatches(path) && !dirty) resetForPath(path);
|
|
2149
2804
|
if (options.canPersist && !options.canPersist()) {
|
|
2150
2805
|
if (!transition) await loadFromDisk();
|
|
2151
2806
|
return false;
|
|
2152
2807
|
}
|
|
2153
2808
|
if (!dirty || !loaded) await loadFromDisk();
|
|
2809
|
+
if (workspaceIO && (!scopeMatches(submittedPath, submittedProfile) || !loadedScopeMatches(path))) return false;
|
|
2154
2810
|
if (transition && !transition.isCurrent()) return false;
|
|
2155
2811
|
const nowMs = getNowMs();
|
|
2156
2812
|
reservations = reservations.filter(
|
|
@@ -2168,6 +2824,47 @@ export function createTelegramTopicTargetStore(
|
|
|
2168
2824
|
const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
|
|
2169
2825
|
if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner)) return false;
|
|
2170
2826
|
}
|
|
2827
|
+
if (transition?.kind === "relocate") {
|
|
2828
|
+
const { binding, owner, nextTarget } = transition;
|
|
2829
|
+
const slots = captureExternalReservedSlots();
|
|
2830
|
+
const sourceBindings = Array.from(workspaceBindings.values())
|
|
2831
|
+
.filter((value) => targetMatches(value.target, binding.target));
|
|
2832
|
+
const sourceOwners = currentRecords.filter((value) => targetMatches(value.target, binding.target));
|
|
2833
|
+
if ((workspaceRestore?.operations ?? []).some((operation) =>
|
|
2834
|
+
workspaceRestoresConflict(operation.request, transition.restore.operations.at(-1)!.request)) ||
|
|
2835
|
+
!binding.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) ||
|
|
2836
|
+
binding.inactiveSinceMs !== undefined || owner.status !== "active" ||
|
|
2837
|
+
!owner.instanceId || owner.slot !== binding.slot ||
|
|
2838
|
+
!["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
|
|
2839
|
+
(owner.owner?.kind === "leader" &&
|
|
2840
|
+
(owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
|
|
2841
|
+
!targetMatches(owner.target, binding.target) ||
|
|
2842
|
+
sourceBindings.length !== 1 || !isDeepStrictEqual(sourceBindings[0], binding) ||
|
|
2843
|
+
sourceOwners.length !== 1 || !isDeepStrictEqual(sourceOwners[0], owner) ||
|
|
2844
|
+
!slots || slots.includes(binding.slot) || workspaceRetirementCommitInFlight ||
|
|
2845
|
+
hasWorkspaceRetirementConflict(binding) || hasWorkspaceRetirementConflict({ target: nextTarget }) ||
|
|
2846
|
+
Array.from(workspaceBindings.values()).some((value) =>
|
|
2847
|
+
value.bindingKey !== binding.bindingKey &&
|
|
2848
|
+
(value.slot === binding.slot || targetMatches(value.target, nextTarget))) ||
|
|
2849
|
+
currentRecords.some((value) => targetMatches(value.target, nextTarget) ||
|
|
2850
|
+
(!targetMatches(value.target, binding.target) && value.slot === binding.slot)) ||
|
|
2851
|
+
Array.from(workspaceClaims.values()).some((value) =>
|
|
2852
|
+
value.identity.bindingKey === binding.bindingKey || value.identity.slot === binding.slot) ||
|
|
2853
|
+
reservations.some((value) => value.slot === binding.slot ||
|
|
2854
|
+
targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)) ||
|
|
2855
|
+
pendingProvisions.some((value) => value.workspaceBindingKey === binding.bindingKey ||
|
|
2856
|
+
value.instanceId === owner.instanceId || value.profileKey === owner.profileKey ||
|
|
2857
|
+
value.slot === binding.slot || (value.target &&
|
|
2858
|
+
(targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)))) ||
|
|
2859
|
+
pendingCleanups.some((value) => targetMatches(value.target, binding.target) ||
|
|
2860
|
+
targetMatches(value.target, nextTarget)) ||
|
|
2861
|
+
syncObservations.some((value) => targetMatches(value.target, nextTarget) &&
|
|
2862
|
+
(value.syncStatus === "deleted" || value.syncStatus === "closed")) ||
|
|
2863
|
+
(sessionReplacement && ((sessionReplacement.cwd === binding.cwd &&
|
|
2864
|
+
sessionReplacement.sourceSessionId === binding.sessionId) ||
|
|
2865
|
+
targetMatches(sessionReplacement.target, binding.target) ||
|
|
2866
|
+
targetMatches(sessionReplacement.target, nextTarget)))) return false;
|
|
2867
|
+
}
|
|
2171
2868
|
records = new Map(
|
|
2172
2869
|
currentRecords.map((record) => [
|
|
2173
2870
|
getRecordOwnerKey(record),
|
|
@@ -2175,13 +2872,23 @@ export function createTelegramTopicTargetStore(
|
|
|
2175
2872
|
]),
|
|
2176
2873
|
);
|
|
2177
2874
|
const persistedRevision = mutationRevision;
|
|
2178
|
-
const
|
|
2875
|
+
const expectedRestore = structuredClone(workspaceRestore);
|
|
2876
|
+
const assertRestoreEvidenceCurrent = (raw?: unknown, fromTransaction = false): void => {
|
|
2877
|
+
if (isExactJournalFrame?.() === false) throw new Error("Telegram Workspace journal evidence publication lost its exact frame or authority.");
|
|
2878
|
+
const disk = parseTopicTargetFile(fromTransaction ? raw : readStoreSnapshot(path, transition?.kind === "relocate" || !!expectedRestore));
|
|
2879
|
+
if (!isDeepStrictEqual(disk.workspaceRestore, expectedRestore)) throw new Error("Workspace Restore evidence changed before publication.");
|
|
2880
|
+
assertWorkspaceRestoreBindingProtection(disk);
|
|
2881
|
+
assertRestoreRecoveryProtection(path, [...(disk.workspaceRestore?.operations ?? []), ...(file.workspaceRestore?.operations ?? [])],
|
|
2882
|
+
[...(disk.pendingProvisions ?? []), ...(file.pendingProvisions ?? [])]);
|
|
2883
|
+
const additions = (file.workspaceRestore?.operations ?? []).filter(operation =>
|
|
2884
|
+
!disk.workspaceRestore?.operations.some(retained => retained.request.operationId === operation.request.operationId));
|
|
2885
|
+
assertRestoreRecoveryProtection(path, additions, disk.pendingProvisions ?? [], true);
|
|
2886
|
+
};
|
|
2179
2887
|
const file = {
|
|
2180
2888
|
version: 1,
|
|
2181
2889
|
source: "snapshot",
|
|
2182
2890
|
writtenAtMs: nowMs,
|
|
2183
2891
|
bot: botState,
|
|
2184
|
-
...statusSnapshot,
|
|
2185
2892
|
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
2186
2893
|
workspaceBindings: Array.from(workspaceBindings.values()).map(
|
|
2187
2894
|
cloneWorkspaceBinding,
|
|
@@ -2189,6 +2896,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2189
2896
|
workspaceRetirements: workspaceRetirements.map(
|
|
2190
2897
|
cloneWorkspaceRetirementIntent,
|
|
2191
2898
|
),
|
|
2899
|
+
...(workspaceRestore ? { workspaceRestore: structuredClone(workspaceRestore) } : {}),
|
|
2192
2900
|
...(sessionReplacement
|
|
2193
2901
|
? { sessionReplacement: cloneSessionReplacementIntent(sessionReplacement) }
|
|
2194
2902
|
: {}),
|
|
@@ -2218,32 +2926,98 @@ export function createTelegramTopicTargetStore(
|
|
|
2218
2926
|
if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
|
|
2219
2927
|
bindings[0]!.slot !== record.slot) return false;
|
|
2220
2928
|
}
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2929
|
+
if (transition.kind === "relocate") {
|
|
2930
|
+
if (transition.restore.revision !== (workspaceRestore?.revision ?? 0) + 1 ||
|
|
2931
|
+
!isDeepStrictEqual(transition.restore.operations.slice(0, -1), workspaceRestore?.operations ?? [])) return false;
|
|
2932
|
+
file.workspaceRestore = structuredClone(transition.restore);
|
|
2933
|
+
parseWorkspaceRestore(file.workspaceRestore);
|
|
2934
|
+
if (Buffer.byteLength(JSON.stringify(file.workspaceRestore)) > transition.maxBytes)
|
|
2935
|
+
throw new Error("Workspace Restore byte capacity reached.");
|
|
2936
|
+
const binding = file.workspaceBindings.find((value) =>
|
|
2937
|
+
value.bindingKey === transition.binding.bindingKey)!;
|
|
2938
|
+
binding.target = { ...transition.nextTarget };
|
|
2939
|
+
binding.updatedAtMs = nowMs;
|
|
2940
|
+
delete binding.displayTitle;
|
|
2941
|
+
record.target = { ...transition.nextTarget };
|
|
2942
|
+
record.updatedAtMs = nowMs;
|
|
2943
|
+
record.rerouteConfirmedAtMs = nowMs;
|
|
2944
|
+
record.syncStatus = "unknown";
|
|
2945
|
+
record.lastReconcileAction = "workspace-restore";
|
|
2946
|
+
delete record.lastError;
|
|
2947
|
+
delete record.lastSyncError;
|
|
2948
|
+
delete record.lastSyncObservedAtMs;
|
|
2949
|
+
delete record.lastSyncProbeAtMs;
|
|
2950
|
+
} else {
|
|
2951
|
+
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
2952
|
+
for (const binding of file.workspaceBindings) {
|
|
2953
|
+
if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
|
|
2954
|
+
binding.inactiveSinceMs = nowMs;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (transition.kind === "invalidate") {
|
|
2958
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
2959
|
+
file.syncObservations.push({
|
|
2960
|
+
target: { ...record.target },
|
|
2961
|
+
syncStatus: "deleted",
|
|
2962
|
+
observedAtMs: nowMs,
|
|
2963
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
2964
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
2965
|
+
lastSyncError: transition.lastSyncError,
|
|
2966
|
+
lastReconcileAction: "mark-stale",
|
|
2967
|
+
});
|
|
2225
2968
|
}
|
|
2226
2969
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2970
|
+
}
|
|
2971
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
2972
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? []);
|
|
2973
|
+
if (publishWorkspace) {
|
|
2974
|
+
const wire = JSON.parse(JSON.stringify(file));
|
|
2975
|
+
const outcome = publishWorkspace(current => {
|
|
2976
|
+
if (!scopeMatches(submittedPath, submittedProfile) || mutationRevision !== persistedRevision ||
|
|
2977
|
+
transition?.isCurrent() === false || isExactJournalFrame?.() === false) return { value: current, result: false };
|
|
2978
|
+
const semanticCurrent = getTelegramStateSemanticSnapshot(current);
|
|
2979
|
+
if (!isDeepStrictEqual(semanticCurrent, observedWorkspaceSemantic))
|
|
2980
|
+
throw new Error("Workspace canonical snapshot changed before publication.");
|
|
2981
|
+
// A late whole-Workspace replacement cannot erase a concurrent canonical mutation.
|
|
2982
|
+
if (workspaceRestore && !isDeepStrictEqual(parseTopicTargetFile(current).workspaceRestore, expectedRestore))
|
|
2983
|
+
throw new Error("Workspace Restore evidence changed before publication.");
|
|
2984
|
+
assertRestoreEvidenceCurrent(current, true);
|
|
2985
|
+
if (transition?.kind === "relocate") {
|
|
2986
|
+
const slots = captureExternalReservedSlots();
|
|
2987
|
+
if (!slots || slots.includes(transition.binding.slot!) || workspaceRetirementCommitInFlight ||
|
|
2988
|
+
Array.from(workspaceClaims.values()).some(value => value.identity.bindingKey === transition.binding.bindingKey || value.identity.slot === transition.binding.slot))
|
|
2989
|
+
return { value: current, result: false };
|
|
2990
|
+
}
|
|
2991
|
+
const unchanged = !transition && isDeepStrictEqual(semanticCurrent, getTelegramStateSemanticSnapshot(wire));
|
|
2992
|
+
return { value: unchanged ? current : wire, result: true };
|
|
2993
|
+
}, { isCurrent: () => scopeMatches(submittedPath, submittedProfile) && mutationRevision === persistedRevision &&
|
|
2994
|
+
(transition?.isCurrent() ?? true) && (isExactJournalFrame?.() ?? true),
|
|
2995
|
+
onPublicationBoundary: transition?.kind === "relocate" ? boundary => {
|
|
2996
|
+
if (boundary !== "before-write") transition.onPublicationBoundary?.(boundary);
|
|
2997
|
+
} : undefined });
|
|
2998
|
+
if (!outcome.committed || !outcome.result) return false;
|
|
2999
|
+
loadedPath = path; loadedProfile = submittedProfile; loaded = true;
|
|
3000
|
+
observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(wire);
|
|
3001
|
+
if (transition) {
|
|
3002
|
+
records = new Map(file.threads.map(record => {
|
|
3003
|
+
const normalized = normalizeRecord(record)!;
|
|
3004
|
+
return [getRecordOwnerKey(normalized), cloneRecord(normalized)];
|
|
3005
|
+
}));
|
|
3006
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
3007
|
+
syncObservations = file.syncObservations;
|
|
3008
|
+
workspaceBindings = new Map(file.workspaceBindings.map(binding => [getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding)]));
|
|
3009
|
+
mutationRevision += 1;
|
|
2238
3010
|
}
|
|
3011
|
+
dirty = false;
|
|
3012
|
+
return true;
|
|
2239
3013
|
}
|
|
2240
3014
|
let persistedSemanticSnapshot: Record<string, unknown> | undefined;
|
|
2241
3015
|
try {
|
|
2242
3016
|
persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(
|
|
2243
|
-
|
|
3017
|
+
await readStoreSnapshot(path, !!workspaceRestore, true),
|
|
2244
3018
|
);
|
|
2245
3019
|
} catch {
|
|
2246
|
-
/* missing or
|
|
3020
|
+
/* Final Restore validation fences missing or unverifiable snapshots before publication. */
|
|
2247
3021
|
}
|
|
2248
3022
|
// Normalize optional fields to wire JSON; object key order is not a state change.
|
|
2249
3023
|
if (
|
|
@@ -2251,32 +3025,49 @@ export function createTelegramTopicTargetStore(
|
|
|
2251
3025
|
persistedSemanticSnapshot,
|
|
2252
3026
|
getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
|
|
2253
3027
|
) &&
|
|
2254
|
-
mutationRevision === persistedRevision
|
|
2255
|
-
statusRevision === persistedStatusRevision
|
|
3028
|
+
mutationRevision === persistedRevision
|
|
2256
3029
|
) {
|
|
2257
3030
|
loadedPath = path;
|
|
2258
3031
|
loaded = true;
|
|
2259
3032
|
dirty = false;
|
|
2260
3033
|
return true;
|
|
2261
3034
|
}
|
|
3035
|
+
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
3036
|
+
if (file.workspaceRestore && Buffer.byteLength(serialized) > WORKSPACE_SNAPSHOT_MAX_BYTES)
|
|
3037
|
+
throw new Error("Workspace snapshot byte capacity reached.");
|
|
2262
3038
|
await mkdir(dirname(path), { recursive: true });
|
|
2263
3039
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
2264
|
-
await writeFile(tempPath,
|
|
2265
|
-
encoding: "utf8",
|
|
2266
|
-
mode: 0o600,
|
|
2267
|
-
});
|
|
3040
|
+
await writeFile(tempPath, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
2268
3041
|
await chmod(tempPath, 0o600);
|
|
2269
3042
|
try {
|
|
2270
3043
|
if (transition) {
|
|
3044
|
+
if (transition.kind === "relocate") transition.onPublicationBoundary?.("after-write-before-rename");
|
|
2271
3045
|
let applied = false;
|
|
2272
3046
|
const commit = () => {
|
|
2273
|
-
if (
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
3047
|
+
if (!transition.isCurrent()) return;
|
|
3048
|
+
if (transition.kind === "relocate") {
|
|
3049
|
+
// Transient claims and external fences do not advance snapshot revisions.
|
|
3050
|
+
const slots = captureExternalReservedSlots();
|
|
3051
|
+
if (!slots || slots.includes(transition.binding.slot!) || workspaceRetirementCommitInFlight ||
|
|
3052
|
+
Array.from(workspaceClaims.values()).some((value) =>
|
|
3053
|
+
value.identity.bindingKey === transition.binding.bindingKey ||
|
|
3054
|
+
value.identity.slot === transition.binding.slot)) return;
|
|
3055
|
+
}
|
|
3056
|
+
if (getPath() !== path || mutationRevision !== persistedRevision) return;
|
|
3057
|
+
// All publishers fence the same retained operation, including after its terminal removal.
|
|
3058
|
+
assertRestoreEvidenceCurrent();
|
|
3059
|
+
if (!transition.isCurrent() || getPath() !== path || mutationRevision !== persistedRevision) return;
|
|
2277
3060
|
renameSync(tempPath, path);
|
|
2278
3061
|
loadedPath = path;
|
|
2279
|
-
|
|
3062
|
+
if (transition.kind === "relocate") {
|
|
3063
|
+
const relocated = file.threads.find((record) => targetMatches(record.target, transition.nextTarget))!;
|
|
3064
|
+
records.set(getRecordOwnerKey(transition.owner), {
|
|
3065
|
+
...relocated, profileKey: transition.owner.profileKey,
|
|
3066
|
+
});
|
|
3067
|
+
} else {
|
|
3068
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
|
|
3069
|
+
}
|
|
3070
|
+
workspaceRestore = structuredClone(file.workspaceRestore);
|
|
2280
3071
|
syncObservations = file.syncObservations;
|
|
2281
3072
|
workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
|
|
2282
3073
|
getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
|
|
@@ -2284,17 +3075,21 @@ export function createTelegramTopicTargetStore(
|
|
|
2284
3075
|
mutationRevision += 1;
|
|
2285
3076
|
dirty = false;
|
|
2286
3077
|
applied = true;
|
|
3078
|
+
if (transition.kind === "relocate") transition.onPublicationBoundary?.("after-rename");
|
|
2287
3079
|
};
|
|
2288
|
-
|
|
2289
|
-
|
|
3080
|
+
withTelegramFileTransaction(`${path}.transaction`, () => {
|
|
3081
|
+
if (commitPersist) commitPersist(commit);
|
|
3082
|
+
else if (!options.canPersist || options.canPersist()) commit();
|
|
3083
|
+
});
|
|
2290
3084
|
if (!applied) await unlink(tempPath).catch(() => undefined);
|
|
2291
3085
|
return applied;
|
|
2292
3086
|
}
|
|
2293
|
-
if (
|
|
2294
|
-
const committed =
|
|
3087
|
+
if (commitPersist) {
|
|
3088
|
+
const committed = withTelegramFileTransaction(`${path}.transaction`, () => commitPersist(() => {
|
|
3089
|
+
assertRestoreEvidenceCurrent();
|
|
2295
3090
|
renameSync(tempPath, path);
|
|
2296
3091
|
chmodSync(path, 0o600);
|
|
2297
|
-
});
|
|
3092
|
+
}));
|
|
2298
3093
|
if (!committed) {
|
|
2299
3094
|
await loadFromDisk();
|
|
2300
3095
|
throw new Error(
|
|
@@ -2302,8 +3097,11 @@ export function createTelegramTopicTargetStore(
|
|
|
2302
3097
|
);
|
|
2303
3098
|
}
|
|
2304
3099
|
} else {
|
|
2305
|
-
|
|
2306
|
-
|
|
3100
|
+
withTelegramFileTransaction(`${path}.transaction`, () => {
|
|
3101
|
+
assertRestoreEvidenceCurrent();
|
|
3102
|
+
renameSync(tempPath, path);
|
|
3103
|
+
chmodSync(path, 0o600);
|
|
3104
|
+
});
|
|
2307
3105
|
}
|
|
2308
3106
|
} catch (error) {
|
|
2309
3107
|
await unlink(tempPath).catch(() => undefined);
|
|
@@ -2320,6 +3118,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2320
3118
|
|
|
2321
3119
|
return {
|
|
2322
3120
|
async load() {
|
|
3121
|
+
if (workspaceIO && dirty && !loadedScopeMatches(getPath())) throw new Error("Workspace projection must refresh after scope change.");
|
|
2323
3122
|
if (dirty) return;
|
|
2324
3123
|
await loadFromDisk();
|
|
2325
3124
|
},
|
|
@@ -2329,7 +3128,8 @@ export function createTelegramTopicTargetStore(
|
|
|
2329
3128
|
return refresh;
|
|
2330
3129
|
},
|
|
2331
3130
|
async persist() {
|
|
2332
|
-
await persistSnapshot();
|
|
3131
|
+
const committed = await persistSnapshot();
|
|
3132
|
+
if (workspaceIO && !committed) throw new Error("Workspace snapshot lost captured publication authority.");
|
|
2333
3133
|
},
|
|
2334
3134
|
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
2335
3135
|
return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
|
|
@@ -2339,6 +3139,563 @@ export function createTelegramTopicTargetStore(
|
|
|
2339
3139
|
if (!owner) return Promise.resolve(false);
|
|
2340
3140
|
return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
|
|
2341
3141
|
},
|
|
3142
|
+
assertWorkspaceRestoreRegistration(candidate) {
|
|
3143
|
+
withRestoreRegistration(candidate);
|
|
3144
|
+
},
|
|
3145
|
+
commitWorkspaceRestoreRegistration: withRestoreRegistration,
|
|
3146
|
+
listTemporaryThreadTargets() {
|
|
3147
|
+
const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
|
|
3148
|
+
return (file.workspaceRestore?.temporaryThreads ?? []).flatMap(entry => entry.target ? [{ ...entry.target }] : []);
|
|
3149
|
+
},
|
|
3150
|
+
withWorkspaceRestoreSnapshot(expected, observe) {
|
|
3151
|
+
const path = getPath();
|
|
3152
|
+
withStoreTransaction(path, () => {
|
|
3153
|
+
const file = parseTopicTargetFile(readStoreSnapshot(path, false));
|
|
3154
|
+
observeWorkspaceRestore(file.workspaceRestore);
|
|
3155
|
+
if (getPath() !== path || !isDeepStrictEqual(file.workspaceRestore?.operations.find(
|
|
3156
|
+
intent => intent.request.operationId === expected.request.operationId), expected))
|
|
3157
|
+
throw new Error("Workspace Restore observation changed.");
|
|
3158
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
3159
|
+
assertRestoreRecoveryProtection(path, file.workspaceRestore!.operations, file.pendingProvisions ?? []);
|
|
3160
|
+
observe({ threads: file.threads, workspaceBindings: file.workspaceBindings });
|
|
3161
|
+
});
|
|
3162
|
+
},
|
|
3163
|
+
workspaceRestore(storageOptions) {
|
|
3164
|
+
const path = getPath(), profile = activeProfile();
|
|
3165
|
+
const now = storageOptions.getNowMs ?? Date.now;
|
|
3166
|
+
const { profileName, tokenSha256, isCurrentScope, legacyPath, onPublicationBoundary } = storageOptions;
|
|
3167
|
+
if (consolidated && profileName !== profile) throw new Error("Foreign Workspace Restore storage scope.");
|
|
3168
|
+
const maxBytes = Math.min(storageOptions.maxBytes ?? WORKSPACE_RESTORE_MAX_BYTES, WORKSPACE_RESTORE_MAX_BYTES);
|
|
3169
|
+
if (!restoreText(profileName) || !/^[a-f0-9]{64}$/u.test(tokenSha256) || !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
3170
|
+
throw new Error("Invalid Workspace Restore storage scope.");
|
|
3171
|
+
const empty: TelegramWorkspaceRestoreSnapshot = { version: 1, profileName, tokenSha256, revision: 0, operations: [] };
|
|
3172
|
+
let observed: TelegramWorkspaceRestoreSnapshot | undefined;
|
|
3173
|
+
const currentScope = () => getPath() === path && (!consolidated || scopeMatches(path, profile)) && (isCurrentScope?.() ?? true) && (options.canPersist?.() ?? true);
|
|
3174
|
+
const read = (): { file: Record<string, unknown>; state: TelegramWorkspaceRestoreSnapshot } => {
|
|
3175
|
+
if (legacyPath) {
|
|
3176
|
+
try { lstatSync(legacyPath); throw new Error("Unmigrated Workspace Restore file."); }
|
|
3177
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
3178
|
+
}
|
|
3179
|
+
const raw = readStoreSnapshot(path);
|
|
3180
|
+
if (raw !== undefined && (!restoreObject(raw) || raw.version !== 1)) throw new Error("Invalid Workspace snapshot.");
|
|
3181
|
+
const file = (raw ?? (consolidated ? { version: 1, source: "snapshot", writtenAtMs: 0, bot: { threadMode: "unknown" }, threads: [] }
|
|
3182
|
+
: { version: 1, records: [] })) as Record<string, unknown>;
|
|
3183
|
+
const state = parseTopicTargetFile(file).workspaceRestore;
|
|
3184
|
+
if (state && (state.profileName !== profileName || state.tokenSha256 !== tokenSha256))
|
|
3185
|
+
throw new Error("Invalid or foreign Workspace Restore evidence.");
|
|
3186
|
+
if (observed && (!state || state.revision < observed.revision ||
|
|
3187
|
+
(state.revision === observed.revision && !isDeepStrictEqual(state, observed))))
|
|
3188
|
+
throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
|
|
3189
|
+
if (state && Buffer.byteLength(JSON.stringify(state)) > maxBytes) throw new Error("Workspace Restore byte capacity reached.");
|
|
3190
|
+
observed = structuredClone(state);
|
|
3191
|
+
if (loadedScopeMatches(path)) observeWorkspaceRestore(state);
|
|
3192
|
+
return { file, state: structuredClone(state ?? empty) };
|
|
3193
|
+
};
|
|
3194
|
+
const inspectTemporaryOwnership = (before: ReturnType<typeof read>, entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadTargetObservation => {
|
|
3195
|
+
if (entry.phase !== "created" || !entry.target) return { kind: "unknown" };
|
|
3196
|
+
const file = parseTopicTargetFile(before.file);
|
|
3197
|
+
// Normalization omissions never prove an unbound target.
|
|
3198
|
+
for (const key of ["threads", "workspaceBindings", "reservations", "pendingProvisions", "pendingCleanups", "workspaceRetirements"] as const) {
|
|
3199
|
+
const raw = before.file[key];
|
|
3200
|
+
if ((key === "threads" && raw === undefined) || (raw !== undefined &&
|
|
3201
|
+
(!Array.isArray(raw) || raw.length !== (file[key]?.length ?? 0))))
|
|
3202
|
+
throw new Error("Invalid temporary Thread target evidence.");
|
|
3203
|
+
}
|
|
3204
|
+
assertWorkspaceRestoreBindingProtection(file);
|
|
3205
|
+
assertRestoreRecoveryProtection(path, before.state.operations, file.pendingProvisions ?? [], true);
|
|
3206
|
+
const target = entry.target;
|
|
3207
|
+
const bindings = (file.workspaceBindings ?? []).filter(value => targetMatches(value.target, target));
|
|
3208
|
+
if (bindings.length === 1) return { kind: "bound", binding: structuredClone(bindings[0]!) };
|
|
3209
|
+
if (bindings.length > 1 || file.threads.some(value => targetMatches(value.target, target)) ||
|
|
3210
|
+
file.reservations?.some(value => targetMatches(value.target, target)) ||
|
|
3211
|
+
file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, target)) ||
|
|
3212
|
+
file.pendingCleanups?.some(value => targetMatches(value.target, target)) ||
|
|
3213
|
+
file.workspaceRetirements?.some(value => targetMatches(value.binding.target, target)) ||
|
|
3214
|
+
before.state.operations.some(value => targetMatches(value.request.target, target))) return { kind: "unknown" };
|
|
3215
|
+
return { kind: "temporary" };
|
|
3216
|
+
};
|
|
3217
|
+
const classifyTemporaryTarget = (before: ReturnType<typeof read>, entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadTargetObservation =>
|
|
3218
|
+
entry.cleanupIssued ? { kind: "unknown" } : inspectTemporaryOwnership(before, entry);
|
|
3219
|
+
const storage = {
|
|
3220
|
+
read: () => read().state,
|
|
3221
|
+
async relocate(input: TelegramWorkspaceRestoreIntent, isCurrent: () => boolean) {
|
|
3222
|
+
if (!currentScope() || !isCurrent()) return false;
|
|
3223
|
+
const intent = structuredClone(input);
|
|
3224
|
+
if (!currentScope() || !isCurrent() || !isWorkspaceRestoreIntent(intent) || intent.phase !== "relocated" || intent.revision !== 0 ||
|
|
3225
|
+
intent.recipient !== undefined || intent.routing !== undefined) return false;
|
|
3226
|
+
const state = read().state;
|
|
3227
|
+
if (state.operations.some(operation => workspaceRestoresConflict(operation.request, intent.request))) return false;
|
|
3228
|
+
if (state.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
|
|
3229
|
+
if (state.operations.length >= 26) throw new Error("Workspace Restore operation capacity reached.");
|
|
3230
|
+
const restore = { ...state, revision: state.revision + 1, operations: [...state.operations, intent] };
|
|
3231
|
+
const request = intent.request;
|
|
3232
|
+
return persistSnapshot({ kind: "relocate", operationId: request.operationId, binding: request.binding, owner: request.owner,
|
|
3233
|
+
target: { ...request.binding.target }, nextTarget: { ...request.target }, restore, maxBytes, onPublicationBoundary,
|
|
3234
|
+
isCurrent: () => currentScope() && isCurrent() });
|
|
3235
|
+
},
|
|
3236
|
+
update(expectedInput: TelegramWorkspaceRestoreSnapshot, nextInput: TelegramWorkspaceRestoreSnapshot, isCurrent: () => boolean) {
|
|
3237
|
+
const expected = structuredClone(expectedInput), next = structuredClone(nextInput);
|
|
3238
|
+
if (!currentScope() || !isCurrent()) return false;
|
|
3239
|
+
if (expected.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
|
|
3240
|
+
if (next.profileName !== profileName || next.tokenSha256 !== tokenSha256 || next.revision !== expected.revision + 1 || !parseWorkspaceRestore(next))
|
|
3241
|
+
throw new Error("Invalid Workspace Restore publication.");
|
|
3242
|
+
if (Buffer.byteLength(JSON.stringify(next)) > maxBytes) throw new Error("Workspace Restore byte capacity reached.");
|
|
3243
|
+
if (!workspaceIO) mkdirSync(dirname(path), { recursive: true });
|
|
3244
|
+
let committedBefore: ReturnType<typeof read> | undefined;
|
|
3245
|
+
let committedAfter: Record<string, unknown> | undefined;
|
|
3246
|
+
const committed = withStoreTransaction(path, () => {
|
|
3247
|
+
const before = read();
|
|
3248
|
+
if (!isDeepStrictEqual(before.state, expected) || !currentScope() || !isCurrent()) return false;
|
|
3249
|
+
for (const entry of next.temporaryThreads ?? []) {
|
|
3250
|
+
const prior = expected.temporaryThreads?.find(value => value.token === entry.token);
|
|
3251
|
+
if (!prior && entry.phase === "created" && classifyTemporaryTarget(before, entry).kind !== "temporary") return false;
|
|
3252
|
+
if (!entry.cleanupIssued) continue;
|
|
3253
|
+
if (!prior?.cleanupIssued && (!prior || classifyTemporaryTarget(before, prior).kind !== "temporary")) return false;
|
|
3254
|
+
}
|
|
3255
|
+
assertWorkspaceRestoreBindingProtection(before.file);
|
|
3256
|
+
const provisions = parseTopicTargetFile(before.file).pendingProvisions ?? [];
|
|
3257
|
+
assertRestoreRecoveryProtection(path, before.state.operations, provisions);
|
|
3258
|
+
const after = { ...before.file, workspaceRestore: next };
|
|
3259
|
+
// New temporary targets must be checked against the file they will be published into.
|
|
3260
|
+
assertWorkspaceRestoreBindingProtection(parseTopicTargetFile(after));
|
|
3261
|
+
const content = JSON.stringify(after);
|
|
3262
|
+
if (Buffer.byteLength(content) > WORKSPACE_SNAPSHOT_MAX_BYTES) throw new Error("Workspace snapshot byte capacity reached.");
|
|
3263
|
+
if (workspaceIO) {
|
|
3264
|
+
if (!workspaceFrame || !currentScope() || !isCurrent()) return false;
|
|
3265
|
+
assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
|
|
3266
|
+
committedBefore = before;
|
|
3267
|
+
committedAfter = JSON.parse(content);
|
|
3268
|
+
workspaceFrame.value = committedAfter;
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
3272
|
+
try {
|
|
3273
|
+
writeFileSync(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
3274
|
+
onPublicationBoundary?.("after-write-before-rename");
|
|
3275
|
+
let published = false;
|
|
3276
|
+
const publish = (): void => {
|
|
3277
|
+
if (!currentScope() || !isCurrent()) return;
|
|
3278
|
+
if (!isDeepStrictEqual(read().file, before.file)) throw new Error("Workspace Restore evidence changed before publication.");
|
|
3279
|
+
assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
|
|
3280
|
+
if (!currentScope() || !isCurrent()) return;
|
|
3281
|
+
renameSync(temporary, path);
|
|
3282
|
+
observed = structuredClone(next);
|
|
3283
|
+
// A read-only Restore view must never bless a stale binding/owner projection.
|
|
3284
|
+
if (loadedPath === path && isDeepStrictEqual(workspaceRestore, before.file.workspaceRestore)) workspaceRestore = structuredClone(next);
|
|
3285
|
+
mutationRevision += 1;
|
|
3286
|
+
published = true;
|
|
3287
|
+
onPublicationBoundary?.("after-rename");
|
|
3288
|
+
};
|
|
3289
|
+
const owned = commitPersist ? commitPersist(publish) : (publish(), true);
|
|
3290
|
+
return owned && published;
|
|
3291
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
3292
|
+
}, { isCurrent: () => currentScope() && isCurrent(), onPublicationBoundary: boundary => {
|
|
3293
|
+
if (boundary !== "before-write") onPublicationBoundary?.(boundary);
|
|
3294
|
+
} });
|
|
3295
|
+
if (workspaceIO && committed && committedBefore && committedAfter) {
|
|
3296
|
+
observed = structuredClone(next);
|
|
3297
|
+
if (loadedScopeMatches(path) && isDeepStrictEqual(workspaceRestore, committedBefore.file.workspaceRestore)) workspaceRestore = structuredClone(next);
|
|
3298
|
+
if (isDeepStrictEqual(observedWorkspaceSemantic, getTelegramStateSemanticSnapshot(committedBefore.file)))
|
|
3299
|
+
observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(committedAfter);
|
|
3300
|
+
mutationRevision += 1;
|
|
3301
|
+
}
|
|
3302
|
+
return committed;
|
|
3303
|
+
},
|
|
3304
|
+
};
|
|
3305
|
+
const mutate = <T extends TelegramWorkspaceRestoreIntent | TelegramTemporaryThreadEntry>(authority: TelegramWorkspaceRestoreAuthority,
|
|
3306
|
+
change: (file: TelegramWorkspaceRestoreSnapshot, executor: TelegramWorkspaceRestoreExecutor, operatorUserId: number) => T | undefined,
|
|
3307
|
+
): T | undefined => {
|
|
3308
|
+
if (!authority.isCurrent()) return undefined;
|
|
3309
|
+
const executor = structuredClone(authority.executor);
|
|
3310
|
+
const operator = authority.operatorUserId;
|
|
3311
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator)) return undefined;
|
|
3312
|
+
const current = (): boolean => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
|
|
3313
|
+
const before = storage.read();
|
|
3314
|
+
const file = structuredClone(before);
|
|
3315
|
+
const operation = change(file, executor, operator);
|
|
3316
|
+
if (!operation || !current()) return undefined;
|
|
3317
|
+
if (isDeepStrictEqual(file, before)) return structuredClone(operation);
|
|
3318
|
+
if (file.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
|
|
3319
|
+
file.revision += 1;
|
|
3320
|
+
return storage.update(before, file, current) ? structuredClone(operation) : undefined;
|
|
3321
|
+
};
|
|
3322
|
+
const advance = (expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority,
|
|
3323
|
+
change: (operation: TelegramWorkspaceRestoreIntent) => boolean | "unchanged", adopt = false): TelegramWorkspaceRestoreIntent | undefined =>
|
|
3324
|
+
mutate(authority, (file, executor, operator) => {
|
|
3325
|
+
const operation = file.operations.find(candidate => candidate.request.operationId === expected.request.operationId);
|
|
3326
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
3327
|
+
(!adopt && !isDeepStrictEqual(operation.executor, executor))) return undefined;
|
|
3328
|
+
const changed = change(operation);
|
|
3329
|
+
if (!changed) return undefined;
|
|
3330
|
+
if (changed === "unchanged") return operation;
|
|
3331
|
+
if (operation.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore operation revision exhausted.");
|
|
3332
|
+
const at = now();
|
|
3333
|
+
if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
|
|
3334
|
+
operation.executor = executor;
|
|
3335
|
+
operation.revision += 1;
|
|
3336
|
+
operation.updatedAtMs = Math.max(at, operation.updatedAtMs);
|
|
3337
|
+
return operation;
|
|
3338
|
+
});
|
|
3339
|
+
const advanceTemporary = (expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority,
|
|
3340
|
+
change: (entry: TelegramTemporaryThreadEntry, file: TelegramWorkspaceRestoreSnapshot, executor: TelegramWorkspaceRestoreExecutor) => boolean | "unchanged",
|
|
3341
|
+
adopt = false): TelegramTemporaryThreadEntry | undefined =>
|
|
3342
|
+
mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
|
|
3343
|
+
const entry = file.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
3344
|
+
if (!entry || entry.operatorUserId !== operator || (!adopt && !isDeepStrictEqual(entry.executor, executor))) return undefined;
|
|
3345
|
+
const changed = change(entry, file, executor);
|
|
3346
|
+
if (!changed) return undefined;
|
|
3347
|
+
if (changed === "unchanged") return entry;
|
|
3348
|
+
if (entry.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore operation revision exhausted.");
|
|
3349
|
+
const at = now();
|
|
3350
|
+
if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
|
|
3351
|
+
entry.executor = executor;
|
|
3352
|
+
entry.revision += 1;
|
|
3353
|
+
entry.updatedAtMs = Math.max(at, entry.updatedAtMs);
|
|
3354
|
+
return entry;
|
|
3355
|
+
});
|
|
3356
|
+
return {
|
|
3357
|
+
list: () => structuredClone(storage.read().operations),
|
|
3358
|
+
inspectTemporaryThreadTarget(expected, authority) {
|
|
3359
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
3360
|
+
const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
|
|
3361
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
3362
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current()) return undefined;
|
|
3363
|
+
return withStoreTransaction(path, () => {
|
|
3364
|
+
if (!current()) return undefined;
|
|
3365
|
+
const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
3366
|
+
if (!entry || entry.operatorUserId !== operator || !current()) return undefined;
|
|
3367
|
+
const result = classifyTemporaryTarget(before, entry);
|
|
3368
|
+
return current() ? result : undefined;
|
|
3369
|
+
});
|
|
3370
|
+
},
|
|
3371
|
+
isTemporaryThreadCleanupCurrent(expected, authority) {
|
|
3372
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
3373
|
+
const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
|
|
3374
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
3375
|
+
if (!expected.cleanupIssued || expected.operatorUserId !== operator || !isDeepStrictEqual(expected.executor, executor) ||
|
|
3376
|
+
!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current()) return false;
|
|
3377
|
+
return withStoreTransaction(path, () => {
|
|
3378
|
+
if (!current()) return false;
|
|
3379
|
+
const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
|
|
3380
|
+
return !!entry && inspectTemporaryOwnership(before, entry).kind === "temporary" && current();
|
|
3381
|
+
});
|
|
3382
|
+
},
|
|
3383
|
+
/** The first durable state already includes both relocation and exact original references. */
|
|
3384
|
+
async commit(requestInput, authority) {
|
|
3385
|
+
if (!authority.isCurrent()) return undefined;
|
|
3386
|
+
const request = structuredClone(requestInput), executor = structuredClone(authority.executor), operatorUserId = authority.operatorUserId;
|
|
3387
|
+
const current = () => authority.isCurrent() && authority.operatorUserId === operatorUserId && isDeepStrictEqual(authority.executor, executor);
|
|
3388
|
+
if (!current() || !isTelegramWorkspaceRestoreRequest(request) || !isTelegramWorkspaceRestoreExecutor(executor) ||
|
|
3389
|
+
!restoreInteger(operatorUserId) || request.target.chatId !== operatorUserId) return undefined;
|
|
3390
|
+
const retained = (): TelegramWorkspaceRestoreIntent | undefined => {
|
|
3391
|
+
if (!current()) return undefined;
|
|
3392
|
+
const operation = storage.read().operations.find(value => value.request.operationId === request.operationId);
|
|
3393
|
+
return current() && operation?.phase === "relocated" && isDeepStrictEqual(operation.request, request) &&
|
|
3394
|
+
operation.operatorUserId === operatorUserId && isDeepStrictEqual(operation.executor, executor) ? operation : undefined;
|
|
3395
|
+
};
|
|
3396
|
+
const existing = retained();
|
|
3397
|
+
if (existing) return existing;
|
|
3398
|
+
await (dirty ? undefined : loadFromDisk());
|
|
3399
|
+
if (!current()) return undefined;
|
|
3400
|
+
const at = now();
|
|
3401
|
+
if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
|
|
3402
|
+
const intent: TelegramWorkspaceRestoreIntent = { request, executor, operatorUserId, revision: 0,
|
|
3403
|
+
createdAtMs: at, updatedAtMs: at, committedAtMs: at, phase: "relocated" };
|
|
3404
|
+
let failure: { error: unknown } | undefined;
|
|
3405
|
+
try { await storage.relocate(intent, current); } catch (error) { failure = { error }; }
|
|
3406
|
+
if (!current()) return undefined;
|
|
3407
|
+
// A lost rename reply is reconciled from the complete operation, never target equality.
|
|
3408
|
+
const committed = retained();
|
|
3409
|
+
if (!committed && failure) throw failure.error;
|
|
3410
|
+
return committed;
|
|
3411
|
+
},
|
|
3412
|
+
adopt(expected, authority) {
|
|
3413
|
+
return advance(expected, authority, () => !isDeepStrictEqual(expected.executor, authority.executor), true);
|
|
3414
|
+
},
|
|
3415
|
+
/** Caller validates the live recipient under admission. Only this fresh result grants one RPC issuance. */
|
|
3416
|
+
issueRecipient(expected, recipient, authority) {
|
|
3417
|
+
const intent = advance(expected, authority, operation => {
|
|
3418
|
+
if (operation.phase !== "relocated" || !isTelegramWorkspaceRestoreRecipient(recipient) || recipient.sessionId !== operation.request.binding.sessionId) return false;
|
|
3419
|
+
operation.phase = "recipient-issued";
|
|
3420
|
+
operation.recipient = structuredClone(recipient);
|
|
3421
|
+
return true;
|
|
3422
|
+
});
|
|
3423
|
+
return intent ? { issued: true, intent } : undefined;
|
|
3424
|
+
},
|
|
3425
|
+
/** One source-dispatch grant; uncertainty never resets it. Caller retains the original carriers. */
|
|
3426
|
+
issueRouting(expected, authority) {
|
|
3427
|
+
const intent = advance(expected, authority, operation => {
|
|
3428
|
+
if (operation.phase !== "ready" || operation.routing) return false;
|
|
3429
|
+
operation.routing = { settlements: [] };
|
|
3430
|
+
return true;
|
|
3431
|
+
});
|
|
3432
|
+
return intent ? { issued: true, intent } : undefined;
|
|
3433
|
+
},
|
|
3434
|
+
/** Caller owns the positive result and exact source hash; this grants neither source removal nor cleanup. */
|
|
3435
|
+
recordSourceAcceptance(expected, evidenceInput, authority) {
|
|
3436
|
+
if (!authority.isCurrent()) return undefined;
|
|
3437
|
+
const evidence = structuredClone(evidenceInput);
|
|
3438
|
+
return advance(expected, authority, operation => {
|
|
3439
|
+
if (!operation.routing || !isWorkspaceRestoreSourceAcceptance(evidence, operation)) return false;
|
|
3440
|
+
const existing = operation.routing.acceptances?.find(value => value.updateId === evidence.updateId);
|
|
3441
|
+
if (existing) return isDeepStrictEqual(existing, evidence) ? "unchanged" : false;
|
|
3442
|
+
if (operation.routing.cleanup !== undefined ||
|
|
3443
|
+
operation.routing.settlements.some(value => value.updateIds.includes(evidence.updateId)) ||
|
|
3444
|
+
!isDeepStrictEqual(evidence.recipient, operation.readyRecipient ?? operation.recipient)) return false;
|
|
3445
|
+
const acceptances = [...operation.routing.acceptances ?? [], evidence].sort((a, b) => a.updateId - b.updateId);
|
|
3446
|
+
if (!workspaceRestoreAcceptancesConsistent(acceptances)) return false;
|
|
3447
|
+
operation.routing.acceptances = acceptances;
|
|
3448
|
+
return true;
|
|
3449
|
+
});
|
|
3450
|
+
},
|
|
3451
|
+
/** Queue admission is nonterminal; only its exact journal-owner completion ACK may upgrade it. */
|
|
3452
|
+
recordSourceSettlement(expected, evidenceInput, authority) {
|
|
3453
|
+
const evidence = structuredClone(evidenceInput);
|
|
3454
|
+
return advance(expected, authority, operation => {
|
|
3455
|
+
if (!operation.routing || !isTelegramWorkspaceRestoreSettlement(evidence, operation.request.source) ||
|
|
3456
|
+
!workspaceRestoreSettlementMatchesAcceptance(evidence, operation.routing.acceptances ?? [])) return false;
|
|
3457
|
+
const overlaps = operation.routing.settlements.filter(value => value.updateIds.some(id => evidence.updateIds.includes(id)));
|
|
3458
|
+
if (overlaps.some(value => evidence.kind !== "queue-completed" || value.kind !== "queued" ||
|
|
3459
|
+
value.receiptId !== evidence.receiptId || value.queueKind !== evidence.queueKind)) return false;
|
|
3460
|
+
operation.routing.settlements = operation.routing.settlements.flatMap(value => {
|
|
3461
|
+
const updateIds = value.updateIds.filter(id => !evidence.updateIds.includes(id));
|
|
3462
|
+
return updateIds.length ? [{ ...value, updateIds }] : [];
|
|
3463
|
+
});
|
|
3464
|
+
operation.routing.settlements.push(evidence);
|
|
3465
|
+
return true;
|
|
3466
|
+
});
|
|
3467
|
+
},
|
|
3468
|
+
/** Existing cleanup owner retains deletion authority; this merely fences one invocation. */
|
|
3469
|
+
issueCleanup(expected, authority) {
|
|
3470
|
+
const intent = advance(expected, authority, operation => {
|
|
3471
|
+
if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || operation.routing.cleanup !== undefined) return false;
|
|
3472
|
+
operation.routing.cleanup = "issued";
|
|
3473
|
+
return true;
|
|
3474
|
+
});
|
|
3475
|
+
return intent ? { issued: true, intent } : undefined;
|
|
3476
|
+
},
|
|
3477
|
+
recordCleanup(expected, result, authority) {
|
|
3478
|
+
return advance(expected, authority, operation => {
|
|
3479
|
+
if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || !isDeepStrictEqual(result.target, operation.request.binding.target) ||
|
|
3480
|
+
(result.kind === "completed" ? operation.routing.cleanup !== "issued" :
|
|
3481
|
+
result.kind !== "not-issued" || operation.routing.cleanup !== undefined)) return false;
|
|
3482
|
+
operation.routing.cleanup = result.kind;
|
|
3483
|
+
return true;
|
|
3484
|
+
});
|
|
3485
|
+
},
|
|
3486
|
+
/** Exact terminal CAS releases Restore protection only; it cannot settle or cancel recipient queue work. */
|
|
3487
|
+
retire(expected, authority) {
|
|
3488
|
+
return mutate(authority, (file, executor, operator) => {
|
|
3489
|
+
const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
|
|
3490
|
+
const operation = file.operations[index];
|
|
3491
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
3492
|
+
!isDeepStrictEqual(operation.executor, executor) || !workspaceRestoreSourcesSettled(operation) ||
|
|
3493
|
+
!["completed", "not-issued"].includes(operation.routing?.cleanup ?? "")) return undefined;
|
|
3494
|
+
file.operations.splice(index, 1);
|
|
3495
|
+
return operation;
|
|
3496
|
+
});
|
|
3497
|
+
},
|
|
3498
|
+
listTemporaryThreads: () => structuredClone(storage.read().temporaryThreads ?? []),
|
|
3499
|
+
reserveTemporaryThread(sourceInput, token, authority) {
|
|
3500
|
+
const source = structuredClone(sourceInput);
|
|
3501
|
+
let reserved = false;
|
|
3502
|
+
const entry = mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
|
|
3503
|
+
const entries = file.temporaryThreads ?? [];
|
|
3504
|
+
const existing = entries.find(value => value.source.journalBindingKey === source.journalBindingKey &&
|
|
3505
|
+
value.source.updateId === source.updateId);
|
|
3506
|
+
// A retained entry proves an earlier attempt; it is never a license for another creation.
|
|
3507
|
+
if (existing) return existing.operatorUserId === operator ? existing : undefined;
|
|
3508
|
+
const at = now();
|
|
3509
|
+
if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
|
|
3510
|
+
const next: TelegramTemporaryThreadEntry = { source,
|
|
3511
|
+
inputs: [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }],
|
|
3512
|
+
operatorUserId: operator, executor, token, phase: "creating", forwardProtocol: "one-shot-v1",
|
|
3513
|
+
revision: 0, createdAtMs: at, updatedAtMs: at };
|
|
3514
|
+
if (!isTemporaryThreadEntry(next) || entries.some(value => value.token === token ||
|
|
3515
|
+
getTelegramTemporaryThreadInputs(value).some(input => input.journalBindingKey === source.journalBindingKey &&
|
|
3516
|
+
input.updateIds.includes(source.updateId)))) return undefined;
|
|
3517
|
+
if (entries.length >= TEMPORARY_THREAD_CAPACITY) throw new Error("Temporary Thread capacity reached.");
|
|
3518
|
+
file.temporaryThreads = [...entries, next];
|
|
3519
|
+
reserved = true;
|
|
3520
|
+
return next;
|
|
3521
|
+
});
|
|
3522
|
+
return entry ? { reserved, entry } : undefined;
|
|
3523
|
+
},
|
|
3524
|
+
registerImplicitTemporaryThread(inputValue, targetInput, token, authority) {
|
|
3525
|
+
const input = structuredClone(inputValue), target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
|
|
3526
|
+
if (!isTemporaryThreadInput(input) || input.updateIds.length !== 1 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0) return undefined;
|
|
3527
|
+
return mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
|
|
3528
|
+
const entries = file.temporaryThreads ?? [];
|
|
3529
|
+
if (target.chatId !== operator || entries.some(value => value.token === token ||
|
|
3530
|
+
(value.target && targetMatches(value.target, target)) || getTelegramTemporaryThreadInputs(value).some(other =>
|
|
3531
|
+
other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id))))) return undefined;
|
|
3532
|
+
const at = now();
|
|
3533
|
+
if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
|
|
3534
|
+
const entry: TelegramTemporaryThreadEntry = { source: { journalBindingKey: input.journalBindingKey, updateId: input.updateIds[0]! },
|
|
3535
|
+
inputs: [input], target, operatorUserId: operator, executor, token, phase: "created", forwardProtocol: "one-shot-v1",
|
|
3536
|
+
revision: 0, createdAtMs: at, updatedAtMs: at };
|
|
3537
|
+
if (!isTemporaryThreadEntry(entry)) return undefined;
|
|
3538
|
+
if (entries.length >= TEMPORARY_THREAD_CAPACITY) throw new Error("Temporary Thread capacity reached.");
|
|
3539
|
+
file.temporaryThreads = [...entries, entry];
|
|
3540
|
+
return entry;
|
|
3541
|
+
});
|
|
3542
|
+
},
|
|
3543
|
+
acknowledgeTemporaryThread(expected, targetInput, authority) {
|
|
3544
|
+
const target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
|
|
3545
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
3546
|
+
if (entry.phase !== "creating" || target.chatId !== entry.operatorUserId || !Number.isSafeInteger(target.threadId) ||
|
|
3547
|
+
target.threadId <= 0 || file.temporaryThreads!.some(value => !!value.target && targetMatches(value.target, target)) ||
|
|
3548
|
+
file.operations.some(({ request }) => targetMatches(request.target, target) || targetMatches(request.binding.target, target))) return false;
|
|
3549
|
+
entry.phase = "created";
|
|
3550
|
+
entry.target = target;
|
|
3551
|
+
return true;
|
|
3552
|
+
});
|
|
3553
|
+
},
|
|
3554
|
+
adoptTemporaryThread(expected, authority) {
|
|
3555
|
+
return advanceTemporary(expected, authority, (entry, _file, executor) => !isDeepStrictEqual(entry.executor, executor), true);
|
|
3556
|
+
},
|
|
3557
|
+
recordTemporaryThreadInput(expected, inputValue, authority) {
|
|
3558
|
+
const input = structuredClone(inputValue);
|
|
3559
|
+
if (!isTemporaryThreadInput(input)) return undefined;
|
|
3560
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
3561
|
+
if (entry.phase !== "created" || input.journalBindingKey !== entry.source.journalBindingKey ||
|
|
3562
|
+
file.operations.some(({ request }) => targetMatches(request.target, entry.target!))) return false;
|
|
3563
|
+
const inputs = getTelegramTemporaryThreadInputs(entry);
|
|
3564
|
+
if (inputs.some(value => isDeepStrictEqual(value, input))) return "unchanged";
|
|
3565
|
+
if (entry.cleanupIssued || inputs.some(value => value.updateIds.some(id => input.updateIds.includes(id))) ||
|
|
3566
|
+
file.temporaryThreads!.some(value => value !== entry && getTelegramTemporaryThreadInputs(value).some(other =>
|
|
3567
|
+
other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id))))) return false;
|
|
3568
|
+
if (inputs.length >= TEMPORARY_THREAD_INPUT_CAPACITY) throw new Error("Temporary Thread input capacity reached.");
|
|
3569
|
+
entry.inputs = [...inputs, input];
|
|
3570
|
+
return true;
|
|
3571
|
+
});
|
|
3572
|
+
},
|
|
3573
|
+
recordTemporaryThreadInputCancellation(expected, inputValue, authority, inspect) {
|
|
3574
|
+
const input = structuredClone(inputValue);
|
|
3575
|
+
if (!isTemporaryThreadInput(input) || typeof inspect !== "function") return undefined;
|
|
3576
|
+
const operatorUserId = authority.operatorUserId, executor = structuredClone(authority.executor);
|
|
3577
|
+
const owned = (): boolean => authority.isCurrent() && authority.operatorUserId === operatorUserId &&
|
|
3578
|
+
isDeepStrictEqual(authority.executor, executor);
|
|
3579
|
+
// Every publication fence observes retained proof again; neither cached cancellation nor absence suffices.
|
|
3580
|
+
const current = (): boolean => owned() && input.updateIds.every(updateId => {
|
|
3581
|
+
const evidence = inspect(updateId);
|
|
3582
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
|
|
3583
|
+
evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
3584
|
+
}) && owned();
|
|
3585
|
+
const recorded = advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
|
|
3586
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3587
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
3588
|
+
request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
|
|
3589
|
+
const cancelled = entry.cancelledInputs ?? [];
|
|
3590
|
+
if (cancelled.some(candidate => isDeepStrictEqual(candidate, input))) return "unchanged";
|
|
3591
|
+
if (entry.completedInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3592
|
+
entry.forwardedInputs?.some(candidate => isDeepStrictEqual(candidate, input))) return false;
|
|
3593
|
+
entry.cancelledInputs = [...cancelled, input];
|
|
3594
|
+
return true;
|
|
3595
|
+
});
|
|
3596
|
+
return recorded && current() ? recorded : undefined;
|
|
3597
|
+
},
|
|
3598
|
+
recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
|
|
3599
|
+
const input = structuredClone(inputValue);
|
|
3600
|
+
if (!isTemporaryThreadInput(input)) return undefined;
|
|
3601
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
3602
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3603
|
+
[...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])]
|
|
3604
|
+
.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3605
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
3606
|
+
request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
|
|
3607
|
+
entry.forwardedInputs = [...(entry.forwardedInputs ?? []), input];
|
|
3608
|
+
return true;
|
|
3609
|
+
});
|
|
3610
|
+
},
|
|
3611
|
+
recordTemporaryThreadInputCompletion(expected, inputValue, authority) {
|
|
3612
|
+
const input = structuredClone(inputValue);
|
|
3613
|
+
if (!isTemporaryThreadInput(input)) return undefined;
|
|
3614
|
+
return advanceTemporary(expected, authority, (entry, file) => {
|
|
3615
|
+
if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3616
|
+
entry.cancelledInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
|
|
3617
|
+
file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
|
|
3618
|
+
request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
|
|
3619
|
+
const completed = entry.completedInputs ?? [];
|
|
3620
|
+
if (completed.some(candidate => isDeepStrictEqual(candidate, input))) return "unchanged";
|
|
3621
|
+
entry.completedInputs = [...completed, input];
|
|
3622
|
+
return true;
|
|
3623
|
+
});
|
|
3624
|
+
},
|
|
3625
|
+
issueTemporaryThreadCleanup(expected, authority) {
|
|
3626
|
+
const entry = advanceTemporary(expected, authority, value => {
|
|
3627
|
+
if (value.phase !== "created" || value.cleanupIssued || !isTelegramTemporaryThreadFullyResolved(value)) return false;
|
|
3628
|
+
value.cleanupIssued = true;
|
|
3629
|
+
return true;
|
|
3630
|
+
});
|
|
3631
|
+
return entry ? { issued: true, entry } : undefined;
|
|
3632
|
+
},
|
|
3633
|
+
retireTemporaryThread(expected, authority, completed) {
|
|
3634
|
+
return mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
|
|
3635
|
+
const entries = file.temporaryThreads ?? [];
|
|
3636
|
+
const index = entries.findIndex(value => isDeepStrictEqual(value, expected));
|
|
3637
|
+
const entry = entries[index];
|
|
3638
|
+
if (!entry || entry.operatorUserId !== operator || !isDeepStrictEqual(entry.executor, executor) ||
|
|
3639
|
+
(getTelegramTemporaryThreadInputs(entry).length > 1 && !isTelegramTemporaryThreadFullyResolved(entry) &&
|
|
3640
|
+
!isTemporaryThreadReleasedByCompletion(entry, completed))) return undefined;
|
|
3641
|
+
const remaining = entries.filter((_value, position) => position !== index);
|
|
3642
|
+
if (remaining.length) file.temporaryThreads = remaining;
|
|
3643
|
+
else delete file.temporaryThreads;
|
|
3644
|
+
return entry;
|
|
3645
|
+
});
|
|
3646
|
+
},
|
|
3647
|
+
forgetPreviousWorld(authority) {
|
|
3648
|
+
if (!authority.isCurrent()) return undefined;
|
|
3649
|
+
const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
|
|
3650
|
+
if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator)) return undefined;
|
|
3651
|
+
const current = (): boolean => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
|
|
3652
|
+
const previous = (value: { operatorUserId: number; executor: TelegramWorkspaceRestoreExecutor }) =>
|
|
3653
|
+
value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
|
|
3654
|
+
const before = storage.read(), file = structuredClone(before);
|
|
3655
|
+
const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(previous);
|
|
3656
|
+
if (!current()) return undefined;
|
|
3657
|
+
if (!operations.length && !temporaryThreads.length) return { operations, temporaryThreads };
|
|
3658
|
+
file.operations = file.operations.filter(value => !previous(value));
|
|
3659
|
+
const remaining = (file.temporaryThreads ?? []).filter(value => !previous(value));
|
|
3660
|
+
if (remaining.length) file.temporaryThreads = remaining;
|
|
3661
|
+
else delete file.temporaryThreads;
|
|
3662
|
+
if (file.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
|
|
3663
|
+
file.revision += 1;
|
|
3664
|
+
return storage.update(before, file, current) ? structuredClone({ operations, temporaryThreads }) : undefined;
|
|
3665
|
+
},
|
|
3666
|
+
/** Ends protection without cleanup: the old Thread is kept, and no source was ever dispatched. */
|
|
3667
|
+
retireAbandoned(expected, abandonedUpdateIds, authority) {
|
|
3668
|
+
const abandoned = [...new Set(abandonedUpdateIds)].sort((left, right) => left - right);
|
|
3669
|
+
return mutate(authority, (file, executor, operator) => {
|
|
3670
|
+
const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
|
|
3671
|
+
const operation = file.operations[index];
|
|
3672
|
+
if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
|
|
3673
|
+
!isDeepStrictEqual(operation.executor, executor) || operation.phase !== "ready" || operation.routing !== undefined ||
|
|
3674
|
+
!isDeepStrictEqual(abandoned, operation.request.source.updateIds)) return undefined;
|
|
3675
|
+
file.operations.splice(index, 1);
|
|
3676
|
+
return operation;
|
|
3677
|
+
});
|
|
3678
|
+
},
|
|
3679
|
+
/** Caller proves current canonical ownership and authenticates a read-only observation, including successors. */
|
|
3680
|
+
confirmInspectedReady(expected, observed, authority) {
|
|
3681
|
+
return advance(expected, authority, operation => {
|
|
3682
|
+
if ((operation.phase !== "recipient-issued" && operation.phase !== "ready") || !isTelegramWorkspaceRestoreRecipient(observed) ||
|
|
3683
|
+
observed.sessionId !== operation.request.binding.sessionId) return false;
|
|
3684
|
+
operation.phase = "ready";
|
|
3685
|
+
operation.readyRecipient = structuredClone(observed);
|
|
3686
|
+
return true;
|
|
3687
|
+
});
|
|
3688
|
+
},
|
|
3689
|
+
/** Caller authenticates the exact-generation ACK; stored metadata alone is never readiness proof. */
|
|
3690
|
+
confirmReady(expected, acknowledged, authority) {
|
|
3691
|
+
return advance(expected, authority, operation => {
|
|
3692
|
+
if (operation.phase !== "recipient-issued" || !isDeepStrictEqual(operation.recipient, acknowledged)) return false;
|
|
3693
|
+
operation.phase = "ready";
|
|
3694
|
+
return true;
|
|
3695
|
+
});
|
|
3696
|
+
},
|
|
3697
|
+
};
|
|
3698
|
+
},
|
|
2342
3699
|
list() {
|
|
2343
3700
|
return Array.from(records.values()).map(cloneRecord);
|
|
2344
3701
|
},
|
|
@@ -2380,6 +3737,10 @@ export function createTelegramTopicTargetStore(
|
|
|
2380
3737
|
}));
|
|
2381
3738
|
},
|
|
2382
3739
|
reserveThread(reservation) {
|
|
3740
|
+
if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, reservation)) ||
|
|
3741
|
+
conflictsWithTemporaryThread(workspaceRestore, reservation.target)) {
|
|
3742
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
3743
|
+
}
|
|
2383
3744
|
const next = { ...reservation };
|
|
2384
3745
|
reservations = reservations.filter(
|
|
2385
3746
|
(existing) =>
|
|
@@ -2390,6 +3751,10 @@ export function createTelegramTopicTargetStore(
|
|
|
2390
3751
|
markDirty();
|
|
2391
3752
|
},
|
|
2392
3753
|
upsertPendingProvision(provision) {
|
|
3754
|
+
if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision)) ||
|
|
3755
|
+
conflictsWithTemporaryThread(workspaceRestore, provision.target)) {
|
|
3756
|
+
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
3757
|
+
}
|
|
2393
3758
|
const next = {
|
|
2394
3759
|
...provision,
|
|
2395
3760
|
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
@@ -2404,9 +3769,9 @@ export function createTelegramTopicTargetStore(
|
|
|
2404
3769
|
const path = getPath();
|
|
2405
3770
|
const recoveryPath = getRecoveryPath(path);
|
|
2406
3771
|
await mkdir(dirname(recoveryPath), { recursive: true });
|
|
2407
|
-
|
|
2408
|
-
const recoveries = readProvisionRecoveries(path);
|
|
2409
|
-
|
|
3772
|
+
withStoreTransaction(path, () => {
|
|
3773
|
+
const recoveries = readProvisionRecoveries(path, true);
|
|
3774
|
+
const recovery = {
|
|
2410
3775
|
instanceId: provision.instanceId,
|
|
2411
3776
|
...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
|
|
2412
3777
|
...(provision.leaderEpoch !== undefined
|
|
@@ -2414,6 +3779,11 @@ export function createTelegramTopicTargetStore(
|
|
|
2414
3779
|
: {}),
|
|
2415
3780
|
target: { ...target },
|
|
2416
3781
|
};
|
|
3782
|
+
if (Object.hasOwn(recoveries, provision.id)) {
|
|
3783
|
+
if (!isDeepStrictEqual(recoveries[provision.id], recovery)) throw new Error("Conflicting Workspace provisioning recovery evidence.");
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
recoveries[provision.id] = recovery;
|
|
2417
3787
|
const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
2418
3788
|
writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
|
|
2419
3789
|
encoding: "utf8",
|
|
@@ -2468,9 +3838,29 @@ export function createTelegramTopicTargetStore(
|
|
|
2468
3838
|
markDirty();
|
|
2469
3839
|
},
|
|
2470
3840
|
setStatusSnapshot(snapshot) {
|
|
2471
|
-
if (!loadedPath) loadedPath = getPath();
|
|
3841
|
+
if (!loadedPath) { loadedPath = getPath(); loadedProfile = activeProfile(); }
|
|
2472
3842
|
statusSnapshot = { ...snapshot };
|
|
2473
|
-
|
|
3843
|
+
},
|
|
3844
|
+
persistStatus() {
|
|
3845
|
+
if (runtimeProjection) return runtimeProjection.persist({ runtime: statusSnapshot.runtime ?? {},
|
|
3846
|
+
liveRoster: statusSnapshot.liveRoster ?? {}, diagnostics: statusSnapshot.diagnostics ?? {} }).then(() => undefined);
|
|
3847
|
+
const write = statusQueue.then(async () => {
|
|
3848
|
+
if (options.canPersist && !options.canPersist()) return;
|
|
3849
|
+
const body = { version: 1, source: "snapshot", ...statusSnapshot };
|
|
3850
|
+
// Normalize to wire JSON; object key order and omitted undefined are not changes.
|
|
3851
|
+
const semantic = JSON.parse(JSON.stringify(body)) as Record<string, unknown>;
|
|
3852
|
+
const statusPath = getStatusPath();
|
|
3853
|
+
if (isDeepStrictEqual(semantic, lastStatusSemantic) && statusPath === lastStatusPath) return;
|
|
3854
|
+
await mkdir(dirname(statusPath), { recursive: true });
|
|
3855
|
+
const tempPath = `${statusPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
3856
|
+
await writeFile(tempPath, `${JSON.stringify({ ...body, writtenAtMs: (options.getNowMs ?? Date.now)() }, null, 2)}\n`,
|
|
3857
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
3858
|
+
try { renameSync(tempPath, statusPath); chmodSync(statusPath, 0o600); }
|
|
3859
|
+
catch (error) { await unlink(tempPath).catch(() => undefined); throw error; }
|
|
3860
|
+
lastStatusSemantic = semantic; lastStatusPath = statusPath;
|
|
3861
|
+
});
|
|
3862
|
+
statusQueue = write.then(() => undefined, () => undefined);
|
|
3863
|
+
return write;
|
|
2474
3864
|
},
|
|
2475
3865
|
getByProfileKey(profileKey) {
|
|
2476
3866
|
const ownerKey = getTelegramThreadOwnerKey(
|
|
@@ -2543,7 +3933,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2543
3933
|
listWorkspaceRetirementIntents() {
|
|
2544
3934
|
return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
|
|
2545
3935
|
},
|
|
2546
|
-
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
|
|
3936
|
+
commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete, journalSources) {
|
|
2547
3937
|
if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
|
|
2548
3938
|
return undefined;
|
|
2549
3939
|
}
|
|
@@ -2554,19 +3944,34 @@ export function createTelegramTopicTargetStore(
|
|
|
2554
3944
|
typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512,
|
|
2555
3945
|
)) return undefined;
|
|
2556
3946
|
const keys = Array.from(new Set(journalBindingKeys));
|
|
3947
|
+
const sources = journalSources === undefined ? current.journalSources ?? [] : normalizeWorkspaceJournalSources(journalSources);
|
|
3948
|
+
if (!sources || sources.some(source => !(current.journalSources ?? []).some(retained => isDeepStrictEqual(retained, source)))) return undefined;
|
|
2557
3949
|
if ((current.journalBindingsComplete === true) === complete &&
|
|
2558
|
-
isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
|
|
3950
|
+
isDeepStrictEqual(current.journalBindingKeys ?? [], keys) && isDeepStrictEqual(current.journalSources ?? [], sources)) {
|
|
2559
3951
|
return cloneWorkspaceBinding(current);
|
|
2560
3952
|
}
|
|
2561
3953
|
const next = { ...current, updatedAtMs: getNowMs() };
|
|
2562
|
-
|
|
3954
|
+
// A complete empty set stays explicit so persisted completeness survives a lossless reload.
|
|
3955
|
+
if (keys.length || complete) next.journalBindingKeys = keys;
|
|
2563
3956
|
else delete next.journalBindingKeys;
|
|
3957
|
+
if (sources.length) next.journalSources = sources.map(source => ({ ...source }));
|
|
3958
|
+
else delete next.journalSources;
|
|
2564
3959
|
if (complete) next.journalBindingsComplete = true;
|
|
2565
3960
|
else delete next.journalBindingsComplete;
|
|
2566
3961
|
workspaceBindings.set(key, next);
|
|
2567
3962
|
markDirty();
|
|
2568
3963
|
return cloneWorkspaceBinding(next);
|
|
2569
3964
|
},
|
|
3965
|
+
async persistWorkspaceJournalEvidence(expected, isCurrent) {
|
|
3966
|
+
const path = getPath();
|
|
3967
|
+
const frameCurrent = () => getPath() === path && isCurrent() && isDeepStrictEqual(
|
|
3968
|
+
workspaceBindings.get(getWorkspaceBindingMapKey(expected)), expected,
|
|
3969
|
+
);
|
|
3970
|
+
if (!frameCurrent()) return false;
|
|
3971
|
+
const current = () => frameCurrent() && options.canPersist?.() !== false;
|
|
3972
|
+
const published = await persistSnapshot(undefined, current);
|
|
3973
|
+
return published && current();
|
|
3974
|
+
},
|
|
2570
3975
|
upsertWorkspaceRetirementIntent(intent) {
|
|
2571
3976
|
const next = normalizeWorkspaceRetirementIntent(intent);
|
|
2572
3977
|
if (!next) return false;
|
|
@@ -3184,6 +4589,17 @@ export function createTelegramTopicTargetStore(
|
|
|
3184
4589
|
existing.bindingKey !== next.bindingKey &&
|
|
3185
4590
|
targetMatches(existing.target, next.target),
|
|
3186
4591
|
);
|
|
4592
|
+
const retainedSources = [
|
|
4593
|
+
...(workspaceBindings.get(nextMapKey)?.journalSources ?? []),
|
|
4594
|
+
...(replacedTargetBinding?.journalSources ?? []),
|
|
4595
|
+
...(next.journalSources ?? []),
|
|
4596
|
+
];
|
|
4597
|
+
if (retainedSources.length) {
|
|
4598
|
+
const uniqueSources = Array.from(new Map(retainedSources.map(source => [JSON.stringify(source), source])).values());
|
|
4599
|
+
const journalSources = normalizeWorkspaceJournalSources(uniqueSources);
|
|
4600
|
+
if (!journalSources) return undefined;
|
|
4601
|
+
next.journalSources = journalSources;
|
|
4602
|
+
}
|
|
3187
4603
|
if (
|
|
3188
4604
|
claimInstanceId && claim?.instanceId === claimInstanceId &&
|
|
3189
4605
|
replacedTargetBinding?.slot
|
|
@@ -3229,7 +4645,7 @@ export function createTelegramTopicTargetStore(
|
|
|
3229
4645
|
...(previous.journalBindingKeys ?? []),
|
|
3230
4646
|
...(next.journalBindingKeys ?? []),
|
|
3231
4647
|
]));
|
|
3232
|
-
if (journalBindingKeys.length) next.journalBindingKeys = journalBindingKeys;
|
|
4648
|
+
if (journalBindingKeys.length || previous.journalBindingsComplete) next.journalBindingKeys = journalBindingKeys;
|
|
3233
4649
|
else delete next.journalBindingKeys;
|
|
3234
4650
|
if (previous.journalBindingsComplete) next.journalBindingsComplete = true;
|
|
3235
4651
|
else delete next.journalBindingsComplete;
|
|
@@ -3564,64 +4980,7 @@ function isTelegramTopicTargetSlotOccupied(
|
|
|
3564
4980
|
return false;
|
|
3565
4981
|
}
|
|
3566
4982
|
|
|
3567
|
-
|
|
3568
|
-
threadName: string,
|
|
3569
|
-
): string {
|
|
3570
|
-
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
function getGraphemeSegments(value: string): string[] {
|
|
3574
|
-
const segmenter = (
|
|
3575
|
-
Intl as unknown as {
|
|
3576
|
-
Segmenter?: new (
|
|
3577
|
-
locale?: string,
|
|
3578
|
-
options?: { granularity: "grapheme" },
|
|
3579
|
-
) => { segment(input: string): Iterable<{ segment: string }> };
|
|
3580
|
-
}
|
|
3581
|
-
).Segmenter;
|
|
3582
|
-
if (!segmenter) return Array.from(value);
|
|
3583
|
-
return Array.from(
|
|
3584
|
-
new segmenter(undefined, { granularity: "grapheme" }).segment(value),
|
|
3585
|
-
(part) => part.segment,
|
|
3586
|
-
);
|
|
3587
|
-
}
|
|
3588
|
-
|
|
3589
|
-
export function getTelegramTopicIdentityName(threadName: string): string {
|
|
3590
|
-
return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
|
|
3591
|
-
.join("")
|
|
3592
|
-
.trim();
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
|
|
3596
|
-
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
3597
|
-
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
3598
|
-
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
3599
|
-
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
3600
|
-
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
3601
|
-
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
3602
|
-
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
3603
|
-
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
3604
|
-
I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
|
|
3605
|
-
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
3606
|
-
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
3607
|
-
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
3608
|
-
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
3609
|
-
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
3610
|
-
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
3611
|
-
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
3612
|
-
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
3613
|
-
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
3614
|
-
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
3615
|
-
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
3616
|
-
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
3617
|
-
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
3618
|
-
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
3619
|
-
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
3620
|
-
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
3621
|
-
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
3622
|
-
};
|
|
3623
|
-
|
|
3624
|
-
export function listOccupiedTelegramThreadIdentities(input: {
|
|
4983
|
+
function listOccupiedTelegramThreadIdentities(input: {
|
|
3625
4984
|
records: readonly TelegramTopicTargetRecord[];
|
|
3626
4985
|
workspaceBindings?: readonly TelegramWorkspaceThreadBinding[];
|
|
3627
4986
|
pendingProvisions?: readonly TelegramThreadPendingProvision[];
|
|
@@ -3660,47 +5019,6 @@ export function listOccupiedTelegramThreadIdentities(input: {
|
|
|
3660
5019
|
return Array.from(occupied);
|
|
3661
5020
|
}
|
|
3662
5021
|
|
|
3663
|
-
export function chooseTelegramThreadName(input: {
|
|
3664
|
-
slot: string | undefined;
|
|
3665
|
-
entropy?: number | string;
|
|
3666
|
-
getRandom?: () => number;
|
|
3667
|
-
occupied?: readonly string[];
|
|
3668
|
-
}): string | undefined {
|
|
3669
|
-
if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
|
|
3670
|
-
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
3671
|
-
if (!names || names.length === 0) return undefined;
|
|
3672
|
-
const occupied = new Set(
|
|
3673
|
-
(input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)),
|
|
3674
|
-
);
|
|
3675
|
-
const start = input.getRandom
|
|
3676
|
-
? Math.max(
|
|
3677
|
-
0,
|
|
3678
|
-
Math.min(
|
|
3679
|
-
names.length - 1,
|
|
3680
|
-
Math.floor(input.getRandom() * names.length),
|
|
3681
|
-
),
|
|
3682
|
-
)
|
|
3683
|
-
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
3684
|
-
for (let offset = 0; offset < names.length; offset += 1) {
|
|
3685
|
-
const name = names[(start + offset) % names.length];
|
|
3686
|
-
if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
|
|
3687
|
-
}
|
|
3688
|
-
for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
3689
|
-
for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
|
|
3690
|
-
if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
|
|
3691
|
-
}
|
|
3692
|
-
}
|
|
3693
|
-
return undefined;
|
|
3694
|
-
}
|
|
3695
|
-
|
|
3696
|
-
function getTelegramThreadNameLeadingSlot(
|
|
3697
|
-
threadName: string | undefined,
|
|
3698
|
-
): string | undefined {
|
|
3699
|
-
if (!threadName) return undefined;
|
|
3700
|
-
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
3701
|
-
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
3702
|
-
}
|
|
3703
|
-
|
|
3704
5022
|
function getNextTelegramThreadNamePaletteSlot(
|
|
3705
5023
|
records: readonly TelegramTopicTargetRecord[],
|
|
3706
5024
|
fallbackSlot: string | undefined,
|
|
@@ -3719,95 +5037,6 @@ function getNextTelegramThreadNamePaletteSlot(
|
|
|
3719
5037
|
return String.fromCharCode(code);
|
|
3720
5038
|
}
|
|
3721
5039
|
|
|
3722
|
-
function getTelegramThreadNameEntropyIndex(
|
|
3723
|
-
entropy: number | string | undefined,
|
|
3724
|
-
length: number,
|
|
3725
|
-
): number {
|
|
3726
|
-
if (length <= 1) return 0;
|
|
3727
|
-
if (typeof entropy === "number" && entropy < 1_000_000_000_000) return 0;
|
|
3728
|
-
const value = entropy === undefined ? "0" : String(entropy);
|
|
3729
|
-
let hash = 2166136261;
|
|
3730
|
-
for (const char of value) {
|
|
3731
|
-
hash ^= char.codePointAt(0) ?? 0;
|
|
3732
|
-
hash = Math.imul(hash, 16777619) >>> 0;
|
|
3733
|
-
}
|
|
3734
|
-
return hash % length;
|
|
3735
|
-
}
|
|
3736
|
-
|
|
3737
|
-
export function getTelegramTopicThreadNameValidationError(
|
|
3738
|
-
threadName: string,
|
|
3739
|
-
_slot: string | undefined,
|
|
3740
|
-
): string | undefined {
|
|
3741
|
-
const identity = getTelegramTopicIdentityName(threadName);
|
|
3742
|
-
const reasons: string[] = [];
|
|
3743
|
-
if (!identity) reasons.push("it is empty after trimming");
|
|
3744
|
-
if (/\s/.test(identity)) reasons.push("it contains spaces");
|
|
3745
|
-
if (/[^A-Za-z]/.test(identity)) {
|
|
3746
|
-
reasons.push("it contains characters outside Latin A-Z letters");
|
|
3747
|
-
}
|
|
3748
|
-
if (!/^[A-Z]/.test(identity)) {
|
|
3749
|
-
reasons.push("it does not start with an uppercase Latin letter");
|
|
3750
|
-
}
|
|
3751
|
-
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
3752
|
-
if (genericLabels.has(identity.toLowerCase())) {
|
|
3753
|
-
reasons.push("it is a generic role label");
|
|
3754
|
-
}
|
|
3755
|
-
if (/^[A-Z]$/.test(identity)) reasons.push("it is only a bare slot letter");
|
|
3756
|
-
if (reasons.length === 0) return undefined;
|
|
3757
|
-
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.`;
|
|
3758
|
-
}
|
|
3759
|
-
|
|
3760
|
-
export function getTelegramManualThreadDisplayNameValidationError(
|
|
3761
|
-
threadName: string,
|
|
3762
|
-
): string | undefined {
|
|
3763
|
-
const trimmed = threadName.trim();
|
|
3764
|
-
const normalized = trimmed.replace(/\s+/g, " ");
|
|
3765
|
-
const reasons: string[] = [];
|
|
3766
|
-
if (!trimmed) reasons.push("it is empty after trimming");
|
|
3767
|
-
if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
|
|
3768
|
-
reasons.push("it contains characters outside printable ASCII");
|
|
3769
|
-
}
|
|
3770
|
-
if (normalized.length > 96) reasons.push("it is longer than 96 characters");
|
|
3771
|
-
if (/^[A-Z]$/.test(normalized)) {
|
|
3772
|
-
reasons.push("a bare slot letter is reserved for reset to automatic");
|
|
3773
|
-
}
|
|
3774
|
-
if (reasons.length === 0) return undefined;
|
|
3775
|
-
return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
|
|
3776
|
-
}
|
|
3777
|
-
|
|
3778
|
-
export function isTelegramTopicThreadNameValidForSlot(
|
|
3779
|
-
threadName: string,
|
|
3780
|
-
slot: string | undefined,
|
|
3781
|
-
): boolean {
|
|
3782
|
-
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
3783
|
-
}
|
|
3784
|
-
|
|
3785
|
-
function applyTopicNameTemplate(
|
|
3786
|
-
template: string,
|
|
3787
|
-
request: TelegramTopicTargetProvisionRequest,
|
|
3788
|
-
slot?: string,
|
|
3789
|
-
): string {
|
|
3790
|
-
const threadName =
|
|
3791
|
-
request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
3792
|
-
let result = template
|
|
3793
|
-
.replaceAll("{threadName}", threadName)
|
|
3794
|
-
.replaceAll("{profileKey}", request.profileKey)
|
|
3795
|
-
.replaceAll("{instanceId}", request.instanceId);
|
|
3796
|
-
if (slot) result = result.replaceAll("{slot}", slot);
|
|
3797
|
-
return result;
|
|
3798
|
-
}
|
|
3799
|
-
|
|
3800
|
-
export function getTelegramTopicName(
|
|
3801
|
-
request: TelegramTopicTargetProvisionRequest,
|
|
3802
|
-
template = "{slot}",
|
|
3803
|
-
slot?: string,
|
|
3804
|
-
): string {
|
|
3805
|
-
const name = applyTopicNameTemplate(template, request, slot)
|
|
3806
|
-
.replace(/\s+/g, " ")
|
|
3807
|
-
.trim();
|
|
3808
|
-
return (name || slot || "Pi").slice(0, 128);
|
|
3809
|
-
}
|
|
3810
|
-
|
|
3811
5040
|
function asInteger(value: unknown): number | undefined {
|
|
3812
5041
|
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
3813
5042
|
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
@@ -4828,22 +6057,6 @@ export function isTelegramTopicModeUnavailableError(error: unknown): boolean {
|
|
|
4828
6057
|
);
|
|
4829
6058
|
}
|
|
4830
6059
|
|
|
4831
|
-
export function getTelegramTopicTitleForThreadName(
|
|
4832
|
-
threadName: string,
|
|
4833
|
-
slot: string,
|
|
4834
|
-
template = "{threadName}",
|
|
4835
|
-
): string {
|
|
4836
|
-
return getTelegramTopicName(
|
|
4837
|
-
{
|
|
4838
|
-
instanceId: "",
|
|
4839
|
-
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
4840
|
-
threadName,
|
|
4841
|
-
},
|
|
4842
|
-
template,
|
|
4843
|
-
slot,
|
|
4844
|
-
);
|
|
4845
|
-
}
|
|
4846
|
-
|
|
4847
6060
|
export function createTelegramTopicTargetRenamer(
|
|
4848
6061
|
deps: TelegramTopicTargetRenamerDeps,
|
|
4849
6062
|
): (
|
|
@@ -4919,6 +6132,13 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
4919
6132
|
pending.profileKey === request.profileKey ||
|
|
4920
6133
|
pending.instanceId === request.instanceId,
|
|
4921
6134
|
);
|
|
6135
|
+
const pendingTarget = pendingForRequest?.target;
|
|
6136
|
+
if (pendingForRequest && request.workspaceBindingKey &&
|
|
6137
|
+
pendingForRequest.workspaceBindingKey !== request.workspaceBindingKey &&
|
|
6138
|
+
!(pendingTarget && deps.store.listWorkspaceBindings().some((binding) =>
|
|
6139
|
+
binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, pendingTarget)))) {
|
|
6140
|
+
throw new Error("Telegram unfinished Thread creation does not match this session binding.");
|
|
6141
|
+
}
|
|
4922
6142
|
if (pendingForRequest?.target) {
|
|
4923
6143
|
const target = pendingForRequest.target;
|
|
4924
6144
|
const observation = deps.store.listSyncObservations().find((entry) =>
|
|
@@ -4933,8 +6153,13 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
4933
6153
|
pendingForRequest = undefined;
|
|
4934
6154
|
}
|
|
4935
6155
|
}
|
|
6156
|
+
const matchesWorkspace = (target: TelegramTarget): boolean =>
|
|
6157
|
+
!request.workspaceBindingKey ||
|
|
6158
|
+
deps.store.listWorkspaceBindings().some((binding) =>
|
|
6159
|
+
binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, target)) ||
|
|
6160
|
+
(!!pendingForRequest?.target && targetMatches(pendingForRequest.target, target));
|
|
4936
6161
|
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
4937
|
-
if (existing && isCurrentThreadRecord(existing)) {
|
|
6162
|
+
if (existing && isCurrentThreadRecord(existing) && matchesWorkspace(existing.target)) {
|
|
4938
6163
|
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
4939
6164
|
if (!slot) {
|
|
4940
6165
|
throw new TelegramWorkspaceSlotUnavailableError();
|
|
@@ -5017,7 +6242,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5017
6242
|
const activeForInstance = deps.store.getActiveByInstanceId(
|
|
5018
6243
|
request.instanceId,
|
|
5019
6244
|
);
|
|
5020
|
-
if (activeForInstance) {
|
|
6245
|
+
if (activeForInstance && matchesWorkspace(activeForInstance.target)) {
|
|
5021
6246
|
if (!activeForInstance.slot) {
|
|
5022
6247
|
throw new Error("Telegram Workspace slot reservation is unavailable.");
|
|
5023
6248
|
}
|
|
@@ -5055,7 +6280,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5055
6280
|
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
5056
6281
|
request.preferredSlot;
|
|
5057
6282
|
const slot =
|
|
5058
|
-
existing
|
|
6283
|
+
(existing && matchesWorkspace(existing.target) ? existing.slot : undefined) ??
|
|
5059
6284
|
deps.store.allocateSlot(
|
|
5060
6285
|
request.profileKey,
|
|
5061
6286
|
isManualFollowerRequest
|
|
@@ -5064,6 +6289,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5064
6289
|
(candidateThreadName ? undefined : identity?.slot) ??
|
|
5065
6290
|
preferredNameSlot),
|
|
5066
6291
|
request.workspaceBindingKey,
|
|
6292
|
+
{ excludeCurrentRecord: !!existing && !matchesWorkspace(existing.target) },
|
|
5067
6293
|
);
|
|
5068
6294
|
if (!slot) {
|
|
5069
6295
|
throw new TelegramWorkspaceSlotUnavailableError();
|
|
@@ -5092,7 +6318,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5092
6318
|
if (!projectedTitle?.trim()) {
|
|
5093
6319
|
throw new Error("Telegram Thread display identity is missing or ambiguous.");
|
|
5094
6320
|
}
|
|
5095
|
-
displayTitle =
|
|
6321
|
+
displayTitle = buildTelegramTopicName(
|
|
5096
6322
|
{ ...request, threadName: projectedTitle },
|
|
5097
6323
|
"{threadName}",
|
|
5098
6324
|
slot,
|
|
@@ -5107,6 +6333,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5107
6333
|
owner: pendingOwner,
|
|
5108
6334
|
instanceId: request.instanceId,
|
|
5109
6335
|
profileKey: request.profileKey,
|
|
6336
|
+
...(request.workspaceBindingKey ? { workspaceBindingKey: request.workspaceBindingKey } : {}),
|
|
5110
6337
|
threadName: requestThreadName,
|
|
5111
6338
|
...(displayTitle ? { displayTitle } : {}),
|
|
5112
6339
|
slot,
|
|
@@ -5123,7 +6350,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5123
6350
|
"createForumTopic",
|
|
5124
6351
|
{
|
|
5125
6352
|
chat_id: deps.topicChatId,
|
|
5126
|
-
name: displayTitle ??
|
|
6353
|
+
name: displayTitle ?? buildTelegramTopicName(
|
|
5127
6354
|
{
|
|
5128
6355
|
...request,
|
|
5129
6356
|
...(requestThreadName ? { threadName: requestThreadName } : {}),
|