@llblab/pi-telegram 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +0 -1
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1608 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +116 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1006 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +4 -2
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +2 -2
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +7 -3
- package/lib/bus-leader.ts +44 -19
- package/lib/bus.ts +4 -1
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +19 -0
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +21 -5
- package/lib/outbound-markup.ts +16 -7
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-display.ts +96 -18
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace retirement preparation
|
|
3
|
+
* Zones: telegram, workspace identity, lifecycle
|
|
4
|
+
* Owns fail-closed protection composition, pressure selection, exact-intent admission,
|
|
5
|
+
* successor adoption, and gated mocked deletion with durable retirement commit.
|
|
6
|
+
*/
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
import { planTelegramWorkspaceSlotAllocation, } from "./workspace-slots.js";
|
|
9
|
+
import { createTelegramWorkspaceAdmissionOperationId, isTelegramWorkspaceRetirementFence, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
|
|
10
|
+
export function createTelegramWorkspaceOperationGate() {
|
|
11
|
+
let tail = Promise.resolve();
|
|
12
|
+
return {
|
|
13
|
+
runExclusive(operation) {
|
|
14
|
+
const run = tail.then(operation);
|
|
15
|
+
tail = run.then(() => undefined, () => undefined);
|
|
16
|
+
return run;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createTelegramWorkspaceOperationRuntime(input = {}) {
|
|
21
|
+
const gate = createTelegramWorkspaceOperationGate();
|
|
22
|
+
const run = (metadata, operation) => {
|
|
23
|
+
const gated = () => gate.runExclusive(operation);
|
|
24
|
+
if (!input.getWorkspaceAdmission)
|
|
25
|
+
return gated();
|
|
26
|
+
const admission = input.getWorkspaceAdmission();
|
|
27
|
+
if (!admission) {
|
|
28
|
+
throw new Error("Telegram Workspace admission authority is unavailable.");
|
|
29
|
+
}
|
|
30
|
+
return runWithTelegramWorkspaceAdmissionsAsync({
|
|
31
|
+
ledger: admission,
|
|
32
|
+
...metadata,
|
|
33
|
+
operation: gated,
|
|
34
|
+
onReleaseError(error) {
|
|
35
|
+
input.onReleaseError?.(error, metadata.operationKind);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
return { run, runExclusive: gate.runExclusive };
|
|
40
|
+
}
|
|
41
|
+
export function captureTelegramWorkspaceJournalProtectionSources(input) {
|
|
42
|
+
const sources = [];
|
|
43
|
+
let complete = input.binding.journalBindingsComplete === true ||
|
|
44
|
+
input.discovery?.complete === true;
|
|
45
|
+
const capture = (scope, resolve) => {
|
|
46
|
+
try {
|
|
47
|
+
const binding = resolve();
|
|
48
|
+
if (!binding) {
|
|
49
|
+
complete = false;
|
|
50
|
+
sources.push({ kind: "unknown", scope });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const snapshot = input.withJournalReference
|
|
54
|
+
? input.withJournalReference(binding, binding.journal.read)
|
|
55
|
+
: binding.journal.read();
|
|
56
|
+
sources.push({ kind: "available", scope, entries: snapshot.entries });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
complete = false;
|
|
60
|
+
sources.push({ kind: "unknown", scope });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
capture({ kind: "shared" }, input.resolveLeader);
|
|
64
|
+
for (const journalBindingKey of input.binding.journalBindingKeys ?? []) {
|
|
65
|
+
capture({
|
|
66
|
+
kind: "binding",
|
|
67
|
+
bindingKey: input.binding.bindingKey,
|
|
68
|
+
journalBindingKey,
|
|
69
|
+
}, input.createFollowerResolver(journalBindingKey));
|
|
70
|
+
}
|
|
71
|
+
for (const path of input.discovery?.paths ?? []) {
|
|
72
|
+
capture({ kind: "discovered", path }, input.discovery.createResolver(path));
|
|
73
|
+
}
|
|
74
|
+
return { sources, complete };
|
|
75
|
+
}
|
|
76
|
+
function getJournalUpdateTarget(update) {
|
|
77
|
+
if (!update || typeof update !== "object" || Array.isArray(update))
|
|
78
|
+
return undefined;
|
|
79
|
+
const record = update;
|
|
80
|
+
if (record.message_reaction !== undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
const direct = record.message ?? record.edited_message ?? record.guest_message;
|
|
83
|
+
const callback = record.callback_query;
|
|
84
|
+
const message = direct ?? (callback && typeof callback === "object" && !Array.isArray(callback)
|
|
85
|
+
? callback.message
|
|
86
|
+
: undefined);
|
|
87
|
+
if (!message || typeof message !== "object" || Array.isArray(message))
|
|
88
|
+
return undefined;
|
|
89
|
+
const messageRecord = message;
|
|
90
|
+
const chat = messageRecord.chat;
|
|
91
|
+
if (!chat || typeof chat !== "object" || Array.isArray(chat))
|
|
92
|
+
return undefined;
|
|
93
|
+
const chatId = chat.id;
|
|
94
|
+
if (typeof chatId !== "number")
|
|
95
|
+
return undefined;
|
|
96
|
+
const threadId = messageRecord.message_thread_id;
|
|
97
|
+
return {
|
|
98
|
+
chatId,
|
|
99
|
+
...(typeof threadId === "number" ? { threadId } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function sameTarget(left, right) {
|
|
103
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
104
|
+
}
|
|
105
|
+
export function resolveTelegramWorkspaceAcceptedWorkProtection(input) {
|
|
106
|
+
if (input.localAcceptedTargets.some((target) => sameTarget(target, input.binding.target))) {
|
|
107
|
+
return "protected";
|
|
108
|
+
}
|
|
109
|
+
let unknown = !input.sourcesComplete;
|
|
110
|
+
for (const source of input.journalSources) {
|
|
111
|
+
const relevant = source.scope.kind !== "binding" ||
|
|
112
|
+
source.scope.bindingKey === input.binding.bindingKey;
|
|
113
|
+
if (!relevant)
|
|
114
|
+
continue;
|
|
115
|
+
if (source.kind === "unknown") {
|
|
116
|
+
unknown = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (source.scope.kind === "binding" && source.entries.length > 0) {
|
|
120
|
+
return "protected";
|
|
121
|
+
}
|
|
122
|
+
for (const entry of source.entries) {
|
|
123
|
+
const target = getJournalUpdateTarget(entry.update);
|
|
124
|
+
if (!target) {
|
|
125
|
+
unknown = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (sameTarget(target, input.binding.target))
|
|
129
|
+
return "protected";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return unknown ? "unknown" : "clear";
|
|
133
|
+
}
|
|
134
|
+
export async function pruneTelegramWorkspaceJournalEvidence(input) {
|
|
135
|
+
const operation = async () => {
|
|
136
|
+
const leaderEpoch = input.getLeaderEpoch();
|
|
137
|
+
const profileKey = input.getProfileKey();
|
|
138
|
+
const isCurrent = () => leaderEpoch !== undefined &&
|
|
139
|
+
input.getLeaderEpoch() === leaderEpoch &&
|
|
140
|
+
input.getProfileKey() === profileKey &&
|
|
141
|
+
input.isCurrent?.() !== false;
|
|
142
|
+
if (!isCurrent()) {
|
|
143
|
+
throw new Error("Telegram Workspace journal pruning requires current leader authority.");
|
|
144
|
+
}
|
|
145
|
+
const shared = input.capture.sources.filter((source) => source.scope.kind === "shared");
|
|
146
|
+
const bindingSources = input.capture.sources.filter((source) => source.scope.kind === "binding" &&
|
|
147
|
+
source.scope.bindingKey === input.binding.bindingKey);
|
|
148
|
+
const sourceByKey = new Map(bindingSources.flatMap((source) => source.scope.kind === "binding"
|
|
149
|
+
? [[source.scope.journalBindingKey, source]]
|
|
150
|
+
: []));
|
|
151
|
+
if (!input.capture.complete ||
|
|
152
|
+
shared.length !== 1 ||
|
|
153
|
+
shared[0]?.kind !== "available" ||
|
|
154
|
+
bindingSources.length !==
|
|
155
|
+
(input.binding.journalBindingKeys ?? []).length ||
|
|
156
|
+
sourceByKey.size !== (input.binding.journalBindingKeys ?? []).length ||
|
|
157
|
+
Array.from(sourceByKey.values()).some((source) => source.kind !== "available")) {
|
|
158
|
+
return { kind: "blocked", reason: "incomplete-evidence" };
|
|
159
|
+
}
|
|
160
|
+
const emptyKeys = (input.binding.journalBindingKeys ?? []).filter((key) => {
|
|
161
|
+
const source = sourceByKey.get(key);
|
|
162
|
+
return source?.kind === "available" && source.entries.length === 0;
|
|
163
|
+
});
|
|
164
|
+
if (emptyKeys.some((key) => input.getJournalWriterProtection(key) !== "clear")) {
|
|
165
|
+
return { kind: "blocked", reason: "writer-not-quiescent" };
|
|
166
|
+
}
|
|
167
|
+
const retainedKeys = (input.binding.journalBindingKeys ?? []).filter((key) => !emptyKeys.includes(key));
|
|
168
|
+
if (!isCurrent()) {
|
|
169
|
+
throw new Error("Telegram Workspace journal pruning lost leader authority.");
|
|
170
|
+
}
|
|
171
|
+
const binding = input.store.commitWorkspaceJournalEvidence(input.binding, retainedKeys, input.binding.journalBindingsComplete === true);
|
|
172
|
+
if (!binding)
|
|
173
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
174
|
+
const removedKeys = (input.binding.journalBindingKeys ?? []).filter((key) => !retainedKeys.includes(key));
|
|
175
|
+
if (removedKeys.length > 0)
|
|
176
|
+
await input.store.persist();
|
|
177
|
+
if (!isCurrent()) {
|
|
178
|
+
throw new Error("Telegram Workspace journal pruning lost leader authority.");
|
|
179
|
+
}
|
|
180
|
+
return { kind: "committed", binding, removedKeys };
|
|
181
|
+
};
|
|
182
|
+
return runWithTelegramWorkspaceAdmissionsAsync({
|
|
183
|
+
ledger: input.admission,
|
|
184
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
185
|
+
operationKind: "workspace.prune-journal-evidence",
|
|
186
|
+
scopes: [{ kind: "target", target: input.binding.target }],
|
|
187
|
+
operation,
|
|
188
|
+
onReleaseError(error) {
|
|
189
|
+
input.onAdmissionReleaseError?.(error);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export function captureTelegramWorkspaceExternalProtection(input) {
|
|
194
|
+
let liveOwner = "unknown";
|
|
195
|
+
let acceptedWork = "unknown";
|
|
196
|
+
let deliveryAuthority = "unknown";
|
|
197
|
+
try {
|
|
198
|
+
liveOwner = input.getLiveOwnerProtection(input.binding);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Unavailable registry/process evidence must not clear a binding.
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const journals = input.captureJournalSources(input.binding);
|
|
205
|
+
const local = input.getLocalAcceptedTargets(input.binding);
|
|
206
|
+
acceptedWork = resolveTelegramWorkspaceAcceptedWorkProtection({
|
|
207
|
+
binding: input.binding,
|
|
208
|
+
localAcceptedTargets: local.targets,
|
|
209
|
+
journalSources: journals.sources,
|
|
210
|
+
sourcesComplete: journals.complete && local.complete,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Unavailable queue or journal evidence must not clear accepted work.
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
deliveryAuthority = input.getDeliveryAuthorityProtection?.(input.binding) ?? "unknown";
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Unavailable delivery evidence must not clear a binding.
|
|
221
|
+
}
|
|
222
|
+
return { liveOwner, acceptedWork, deliveryAuthority };
|
|
223
|
+
}
|
|
224
|
+
export function createTelegramWorkspaceExternalProtectionCapture(deps) {
|
|
225
|
+
return function (binding) {
|
|
226
|
+
return captureTelegramWorkspaceExternalProtection({
|
|
227
|
+
binding,
|
|
228
|
+
getLiveOwnerProtection(candidate) {
|
|
229
|
+
if (deps.listFollowers().some((follower) => !!follower.target && sameTarget(follower.target, candidate.target)))
|
|
230
|
+
return "protected";
|
|
231
|
+
if (!deps.getJournalWriterProtection)
|
|
232
|
+
return "unknown";
|
|
233
|
+
let unknown = candidate.journalBindingsComplete !== true;
|
|
234
|
+
for (const journalBindingKey of candidate.journalBindingKeys ?? []) {
|
|
235
|
+
const protection = deps.getJournalWriterProtection(journalBindingKey);
|
|
236
|
+
if (protection === "protected")
|
|
237
|
+
return "protected";
|
|
238
|
+
if (protection === "unknown")
|
|
239
|
+
unknown = true;
|
|
240
|
+
}
|
|
241
|
+
return unknown ? "unknown" : "clear";
|
|
242
|
+
},
|
|
243
|
+
getLocalAcceptedTargets(candidate) {
|
|
244
|
+
const items = deps.getQueuedItems();
|
|
245
|
+
const targets = [];
|
|
246
|
+
const activeTarget = deps.getActiveTurnTarget();
|
|
247
|
+
if (activeTarget)
|
|
248
|
+
targets.push(activeTarget);
|
|
249
|
+
let complete = true;
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
if (item.target)
|
|
252
|
+
targets.push(item.target);
|
|
253
|
+
else if (item.chatId === candidate.target.chatId)
|
|
254
|
+
complete = false;
|
|
255
|
+
}
|
|
256
|
+
return { targets, complete };
|
|
257
|
+
},
|
|
258
|
+
captureJournalSources(candidate) {
|
|
259
|
+
const discovery = candidate.journalBindingsComplete === true
|
|
260
|
+
? undefined
|
|
261
|
+
: deps.discoverFollowerJournals?.();
|
|
262
|
+
return captureTelegramWorkspaceJournalProtectionSources({
|
|
263
|
+
binding: candidate,
|
|
264
|
+
resolveLeader: deps.resolveLeaderJournal,
|
|
265
|
+
createFollowerResolver: deps.createFollowerJournalResolver,
|
|
266
|
+
...(deps.withJournalReference
|
|
267
|
+
? { withJournalReference: deps.withJournalReference } : {}),
|
|
268
|
+
...(discovery && deps.createJournalPathResolver
|
|
269
|
+
? { discovery: {
|
|
270
|
+
...discovery,
|
|
271
|
+
createResolver: deps.createJournalPathResolver,
|
|
272
|
+
} }
|
|
273
|
+
: {}),
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
...(deps.getDeliveryAuthorityProtection
|
|
277
|
+
? { getDeliveryAuthorityProtection: deps.getDeliveryAuthorityProtection }
|
|
278
|
+
: {}),
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export async function adoptTelegramWorkspaceRetirementIntent(input) {
|
|
283
|
+
return input.runExclusive(async () => {
|
|
284
|
+
const leaderEpoch = input.getLeaderEpoch();
|
|
285
|
+
const profileKey = input.getProfileKey();
|
|
286
|
+
const isCurrent = () => leaderEpoch !== undefined && input.getLeaderEpoch() === leaderEpoch &&
|
|
287
|
+
input.getProfileKey() === profileKey && input.isCurrent?.() !== false;
|
|
288
|
+
if (!isCurrent())
|
|
289
|
+
throw new Error("Telegram Workspace retirement adoption requires current leader authority.");
|
|
290
|
+
const intents = input.store.listWorkspaceRetirementIntents();
|
|
291
|
+
if (intents.length !== 1 || !isDeepStrictEqual(intents[0], input.intent)) {
|
|
292
|
+
return { kind: "blocked", reason: "intent-conflict" };
|
|
293
|
+
}
|
|
294
|
+
if (input.intent.profileKey !== profileKey) {
|
|
295
|
+
return { kind: "blocked", reason: "profile-changed" };
|
|
296
|
+
}
|
|
297
|
+
const binding = input.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey);
|
|
298
|
+
if (!binding || !isDeepStrictEqual(binding, input.intent.binding)) {
|
|
299
|
+
return { kind: "blocked", reason: "binding-changed" };
|
|
300
|
+
}
|
|
301
|
+
const eligible = input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection, { expectedRetirement: input.intent }).bindings.find((candidate) => candidate.bindingKey === binding.bindingKey)?.protection === "eligible";
|
|
302
|
+
if (!eligible)
|
|
303
|
+
return { kind: "blocked", reason: "protection-changed" };
|
|
304
|
+
const replacement = { ...input.intent, leaderEpoch: leaderEpoch };
|
|
305
|
+
if (!isCurrent())
|
|
306
|
+
throw new Error("Telegram Workspace retirement adoption lost leader authority.");
|
|
307
|
+
if (!await input.store.replaceWorkspaceRetirementIntent(input.intent, replacement, isCurrent))
|
|
308
|
+
return { kind: "blocked", reason: "commit-rejected" };
|
|
309
|
+
return { kind: "adopted", intent: replacement };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function isTelegramWorkspaceDeletionConfirmedAbsent(error) {
|
|
313
|
+
if (!(error instanceof Error))
|
|
314
|
+
return false;
|
|
315
|
+
const status = "status" in error && typeof error.status === "number"
|
|
316
|
+
? error.status : undefined;
|
|
317
|
+
if (status !== undefined && status !== 400)
|
|
318
|
+
return false;
|
|
319
|
+
const message = error.message.toLowerCase();
|
|
320
|
+
return message.includes("topic_id_invalid") ||
|
|
321
|
+
message.includes("message thread not found") ||
|
|
322
|
+
message.includes("thread not found") ||
|
|
323
|
+
message.includes("topic not found") ||
|
|
324
|
+
message.includes("topic deleted");
|
|
325
|
+
}
|
|
326
|
+
function matchesTelegramWorkspaceRetirementFence(fence, intent) {
|
|
327
|
+
return (fence.retirementIntentId === intent.id &&
|
|
328
|
+
fence.profileKey === intent.profileKey &&
|
|
329
|
+
fence.bindingKey === intent.binding.bindingKey &&
|
|
330
|
+
fence.slot === intent.binding.slot &&
|
|
331
|
+
fence.target.chatId === intent.binding.target.chatId &&
|
|
332
|
+
fence.target.threadId === intent.binding.target.threadId &&
|
|
333
|
+
fence.retirementRequestedAtMs === intent.requestedAtMs);
|
|
334
|
+
}
|
|
335
|
+
export async function executeTelegramWorkspaceRetirement(input) {
|
|
336
|
+
return input.runExclusive(async () => {
|
|
337
|
+
const isCurrent = () => input.getLeaderEpoch() === input.intent.leaderEpoch &&
|
|
338
|
+
input.getProfileKey() === input.intent.profileKey &&
|
|
339
|
+
input.isCurrent?.() !== false;
|
|
340
|
+
if (!isCurrent())
|
|
341
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
342
|
+
if (!input.intent.binding.slot || !/^[A-Z]$/u.test(input.intent.binding.slot)) {
|
|
343
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
344
|
+
}
|
|
345
|
+
const owner = input.admission.getOwner();
|
|
346
|
+
const storedFence = input.admission.read().fence;
|
|
347
|
+
if (storedFence && !isTelegramWorkspaceRetirementFence(storedFence)) {
|
|
348
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
349
|
+
}
|
|
350
|
+
let fence = storedFence;
|
|
351
|
+
if (fence && !matchesTelegramWorkspaceRetirementFence(fence, input.intent)) {
|
|
352
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
353
|
+
}
|
|
354
|
+
if (fence &&
|
|
355
|
+
(fence.leaderEpoch !== input.intent.leaderEpoch ||
|
|
356
|
+
fence.owner.processId !== owner.processId ||
|
|
357
|
+
fence.owner.processBirthId !== owner.processBirthId)) {
|
|
358
|
+
fence = input.admission.adoptRetirementFence(fence, {
|
|
359
|
+
owner,
|
|
360
|
+
leaderEpoch: input.intent.leaderEpoch,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const intent = input.store.listWorkspaceRetirementIntents().find((candidate) => isDeepStrictEqual(candidate, input.intent));
|
|
364
|
+
const binding = input.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey);
|
|
365
|
+
if (!intent || !binding || !isDeepStrictEqual(binding, input.intent.binding)) {
|
|
366
|
+
if (!intent && !binding && fence?.phase === "commit-ready") {
|
|
367
|
+
try {
|
|
368
|
+
input.admission.completeRetirementFence(fence);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
if (input.admission.read().fence) {
|
|
372
|
+
return { kind: "retained", reason: "fence-release-unconfirmed" };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
kind: "retired",
|
|
377
|
+
bindingKey: input.intent.binding.bindingKey,
|
|
378
|
+
slot: input.intent.binding.slot,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
382
|
+
}
|
|
383
|
+
const eligible = () => input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection, { expectedRetirement: input.intent }).bindings.find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey)?.protection === "eligible";
|
|
384
|
+
if (!eligible())
|
|
385
|
+
return { kind: "retained", reason: "protection-changed" };
|
|
386
|
+
if (!fence) {
|
|
387
|
+
const acquired = input.admission.acquireRetirementFence({
|
|
388
|
+
operationId: `workspace-retirement:${input.intent.id}`,
|
|
389
|
+
retirementIntentId: input.intent.id,
|
|
390
|
+
bindingKey: binding.bindingKey,
|
|
391
|
+
slot: binding.slot,
|
|
392
|
+
target: binding.target,
|
|
393
|
+
leaderEpoch: input.intent.leaderEpoch,
|
|
394
|
+
retirementRequestedAtMs: input.intent.requestedAtMs,
|
|
395
|
+
});
|
|
396
|
+
if (acquired.kind === "blocked") {
|
|
397
|
+
return {
|
|
398
|
+
kind: "retained",
|
|
399
|
+
reason: acquired.reason === "admission-active"
|
|
400
|
+
? "admission-active"
|
|
401
|
+
: "fence-conflict",
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
fence = acquired.fence;
|
|
405
|
+
}
|
|
406
|
+
if (!isCurrent())
|
|
407
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
408
|
+
if (fence.phase === "fenced" && !eligible()) {
|
|
409
|
+
input.admission.releaseUnissuedRetirementFence(fence);
|
|
410
|
+
return { kind: "retained", reason: "protection-changed" };
|
|
411
|
+
}
|
|
412
|
+
if (fence.phase === "fenced") {
|
|
413
|
+
const issued = input.admission.issueDeletionPermit(fence);
|
|
414
|
+
if (issued.kind !== "issued") {
|
|
415
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
416
|
+
}
|
|
417
|
+
fence = issued.fence;
|
|
418
|
+
try {
|
|
419
|
+
await input.deleteForumTopic(issued.permit, {
|
|
420
|
+
chat_id: binding.target.chatId,
|
|
421
|
+
message_thread_id: binding.target.threadId,
|
|
422
|
+
}, { maxAttempts: 1 });
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (!isTelegramWorkspaceDeletionConfirmedAbsent(error)) {
|
|
426
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
fence = input.admission.confirmRetirementAbsence(fence);
|
|
430
|
+
}
|
|
431
|
+
else if (fence.phase === "deletion-issued") {
|
|
432
|
+
const absence = await input.confirmTargetAbsent?.(binding.target);
|
|
433
|
+
if (absence !== "absent") {
|
|
434
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
435
|
+
}
|
|
436
|
+
fence = input.admission.confirmRetirementAbsence(fence);
|
|
437
|
+
}
|
|
438
|
+
if (!isCurrent())
|
|
439
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
440
|
+
if (!eligible())
|
|
441
|
+
return { kind: "retained", reason: "protection-changed" };
|
|
442
|
+
if (!await input.store.commitWorkspaceRetirement(input.intent, isCurrent)) {
|
|
443
|
+
return { kind: "retained", reason: "commit-rejected" };
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
input.admission.completeRetirementFence(fence);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
if (input.admission.read().fence) {
|
|
450
|
+
return { kind: "retained", reason: "fence-release-unconfirmed" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
kind: "retired",
|
|
455
|
+
bindingKey: binding.bindingKey,
|
|
456
|
+
slot: binding.slot,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
function findEligibleCandidate(occupancy, reservedSlots, nowMs) {
|
|
461
|
+
const allocation = planTelegramWorkspaceSlotAllocation({
|
|
462
|
+
bindings: occupancy,
|
|
463
|
+
reservedSlots,
|
|
464
|
+
nowMs,
|
|
465
|
+
});
|
|
466
|
+
if (allocation.kind === "free") {
|
|
467
|
+
return { kind: "not-needed", reason: "free-capacity" };
|
|
468
|
+
}
|
|
469
|
+
if (allocation.kind === "blocked")
|
|
470
|
+
return allocation;
|
|
471
|
+
return { kind: "candidate", candidate: allocation.candidate };
|
|
472
|
+
}
|
|
473
|
+
export async function prepareTelegramWorkspaceRetirement(deps) {
|
|
474
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
475
|
+
const leaderEpoch = deps.getLeaderEpoch();
|
|
476
|
+
const profileKey = deps.getProfileKey();
|
|
477
|
+
const isCurrent = () => leaderEpoch !== undefined &&
|
|
478
|
+
deps.getLeaderEpoch() === leaderEpoch &&
|
|
479
|
+
deps.getProfileKey() === profileKey &&
|
|
480
|
+
deps.isCurrent?.() !== false;
|
|
481
|
+
if (!isCurrent())
|
|
482
|
+
throw new Error("Telegram Workspace retirement requires current leader authority.");
|
|
483
|
+
const existing = deps.store.listWorkspaceRetirementIntents();
|
|
484
|
+
if (existing.length > 1) {
|
|
485
|
+
return { kind: "blocked", reason: "existing-intent-conflict" };
|
|
486
|
+
}
|
|
487
|
+
if (existing.length === 1) {
|
|
488
|
+
const intent = existing[0];
|
|
489
|
+
const binding = deps.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === intent.binding.bindingKey);
|
|
490
|
+
const snapshot = deps.store.captureWorkspaceSlotOccupancy(deps.getExternalProtection, { expectedRetirement: intent });
|
|
491
|
+
const candidate = snapshot.bindings.find((entry) => entry.bindingKey === intent.binding.bindingKey);
|
|
492
|
+
if (intent.profileKey !== profileKey ||
|
|
493
|
+
intent.leaderEpoch !== leaderEpoch ||
|
|
494
|
+
!binding || !isDeepStrictEqual(binding, intent.binding) ||
|
|
495
|
+
candidate?.protection !== "eligible") {
|
|
496
|
+
return { kind: "blocked", reason: "stale-intent" };
|
|
497
|
+
}
|
|
498
|
+
if (!isCurrent())
|
|
499
|
+
throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
500
|
+
await deps.store.persist();
|
|
501
|
+
if (!isCurrent())
|
|
502
|
+
throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
503
|
+
return { kind: "ready", intent };
|
|
504
|
+
}
|
|
505
|
+
const nowMs = getNowMs();
|
|
506
|
+
const snapshot = deps.store.captureWorkspaceSlotOccupancy(deps.getExternalProtection);
|
|
507
|
+
const selection = findEligibleCandidate(snapshot.bindings, snapshot.reservedSlots, nowMs);
|
|
508
|
+
if (selection.kind !== "candidate")
|
|
509
|
+
return selection;
|
|
510
|
+
const selected = selection.candidate;
|
|
511
|
+
const binding = deps.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === selected.bindingKey);
|
|
512
|
+
if (!binding?.slot ||
|
|
513
|
+
binding.slot.toLowerCase() !== selected.slot ||
|
|
514
|
+
binding.inactiveSinceMs !== selected.inactiveSinceMs) {
|
|
515
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
516
|
+
}
|
|
517
|
+
const intent = {
|
|
518
|
+
id: `workspace-retirement:pressure:${binding.bindingKey}:${binding.slot}:${binding.inactiveSinceMs}`,
|
|
519
|
+
reason: "pressure",
|
|
520
|
+
profileKey,
|
|
521
|
+
binding,
|
|
522
|
+
leaderEpoch: leaderEpoch,
|
|
523
|
+
requestedAtMs: nowMs,
|
|
524
|
+
};
|
|
525
|
+
if (!isCurrent())
|
|
526
|
+
throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
527
|
+
if (!deps.store.upsertWorkspaceRetirementIntent(intent)) {
|
|
528
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
529
|
+
}
|
|
530
|
+
const rechecked = deps.store.captureWorkspaceSlotOccupancy(deps.getExternalProtection, { expectedRetirement: intent }).bindings.find((candidate) => candidate.bindingKey === binding.bindingKey);
|
|
531
|
+
if (rechecked?.protection !== "eligible" || !isCurrent()) {
|
|
532
|
+
deps.store.removeWorkspaceRetirementIntent(intent);
|
|
533
|
+
if (!isCurrent())
|
|
534
|
+
throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
535
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
536
|
+
}
|
|
537
|
+
await deps.store.persist();
|
|
538
|
+
if (!isCurrent())
|
|
539
|
+
throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
540
|
+
return { kind: "ready", intent };
|
|
541
|
+
}
|
|
542
|
+
export async function runTelegramWorkspaceRetirementLifecycle(input) {
|
|
543
|
+
let intent = input.store.listWorkspaceRetirementIntents()[0];
|
|
544
|
+
if (intent) {
|
|
545
|
+
const adoption = await adoptTelegramWorkspaceRetirementIntent({
|
|
546
|
+
store: input.store,
|
|
547
|
+
intent,
|
|
548
|
+
getExternalProtection: input.getExternalProtection,
|
|
549
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
550
|
+
getProfileKey: input.getProfileKey,
|
|
551
|
+
isCurrent: input.isCurrent,
|
|
552
|
+
runExclusive: input.runExclusive,
|
|
553
|
+
});
|
|
554
|
+
if (adoption.kind !== "adopted") {
|
|
555
|
+
return { kind: "blocked", stage: "adoption", reason: adoption.reason };
|
|
556
|
+
}
|
|
557
|
+
intent = adoption.intent;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const preparation = await prepareTelegramWorkspaceRetirement({
|
|
561
|
+
store: input.store,
|
|
562
|
+
getExternalProtection: input.getExternalProtection,
|
|
563
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
564
|
+
getProfileKey: input.getProfileKey,
|
|
565
|
+
isCurrent: input.isCurrent,
|
|
566
|
+
getNowMs: input.getNowMs,
|
|
567
|
+
});
|
|
568
|
+
if (preparation.kind === "not-needed")
|
|
569
|
+
return preparation;
|
|
570
|
+
if (preparation.kind === "blocked") {
|
|
571
|
+
return { kind: "blocked", stage: "preparation", reason: preparation.reason };
|
|
572
|
+
}
|
|
573
|
+
intent = preparation.intent;
|
|
574
|
+
}
|
|
575
|
+
return executeTelegramWorkspaceRetirement({
|
|
576
|
+
store: input.store,
|
|
577
|
+
intent,
|
|
578
|
+
getExternalProtection: input.getExternalProtection,
|
|
579
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
580
|
+
getProfileKey: input.getProfileKey,
|
|
581
|
+
isCurrent: input.isCurrent,
|
|
582
|
+
runExclusive: input.runExclusive,
|
|
583
|
+
admission: input.admission,
|
|
584
|
+
deleteForumTopic: input.deleteForumTopic,
|
|
585
|
+
confirmTargetAbsent: input.confirmTargetAbsent,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace slot allocation policy
|
|
3
|
+
* Zones: telegram, workspace identity
|
|
4
|
+
* Owns bounded profile-wide letter selection and inactivity ordering.
|
|
5
|
+
* Excludes liveness discovery, persistence, routing, and Telegram deletion;
|
|
6
|
+
* a selection is a proposal, never authority to retire a binding.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
|
|
9
|
+
export interface TelegramWorkspaceSlotOccupancy {
|
|
10
|
+
bindingKey: string;
|
|
11
|
+
slot: string;
|
|
12
|
+
inactiveSinceMs?: number;
|
|
13
|
+
protection: "eligible" | "protected" | "unknown";
|
|
14
|
+
}
|
|
15
|
+
export type TelegramWorkspaceSlotAllocation = {
|
|
16
|
+
kind: "free";
|
|
17
|
+
slot: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "reclaim";
|
|
20
|
+
candidate: TelegramWorkspaceSlotOccupancy;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "blocked";
|
|
23
|
+
reason: "invalid-state" | "protected-capacity";
|
|
24
|
+
};
|
|
25
|
+
/** Caller must recheck exact ownership and protected work before retirement. */
|
|
26
|
+
export declare function planTelegramWorkspaceSlotAllocation(input: {
|
|
27
|
+
bindings: readonly TelegramWorkspaceSlotOccupancy[];
|
|
28
|
+
reservedSlots: readonly string[];
|
|
29
|
+
nowMs: number;
|
|
30
|
+
}): TelegramWorkspaceSlotAllocation;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace slot allocation policy
|
|
3
|
+
* Zones: telegram, workspace identity
|
|
4
|
+
* Owns bounded profile-wide letter selection and inactivity ordering.
|
|
5
|
+
* Excludes liveness discovery, persistence, routing, and Telegram deletion;
|
|
6
|
+
* a selection is a proposal, never authority to retire a binding.
|
|
7
|
+
*/
|
|
8
|
+
export const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
|
|
9
|
+
function isSlot(slot) {
|
|
10
|
+
return /^[a-z]$/u.test(slot);
|
|
11
|
+
}
|
|
12
|
+
function isValidSnapshot(bindings, reservedSlots, nowMs) {
|
|
13
|
+
if (!Number.isFinite(nowMs) || nowMs < 0)
|
|
14
|
+
return false;
|
|
15
|
+
const slots = new Set();
|
|
16
|
+
const keys = new Set();
|
|
17
|
+
for (const binding of bindings) {
|
|
18
|
+
if (!isSlot(binding.slot) || !binding.bindingKey ||
|
|
19
|
+
slots.has(binding.slot) || keys.has(binding.bindingKey))
|
|
20
|
+
return false;
|
|
21
|
+
slots.add(binding.slot);
|
|
22
|
+
keys.add(binding.bindingKey);
|
|
23
|
+
}
|
|
24
|
+
return reservedSlots.every(isSlot);
|
|
25
|
+
}
|
|
26
|
+
function eligibleByInactivity(bindings, reservedSlots, nowMs) {
|
|
27
|
+
const reserved = new Set(reservedSlots);
|
|
28
|
+
return bindings.filter((binding) => binding.protection === "eligible" &&
|
|
29
|
+
!reserved.has(binding.slot) &&
|
|
30
|
+
typeof binding.inactiveSinceMs === "number" &&
|
|
31
|
+
Number.isFinite(binding.inactiveSinceMs) &&
|
|
32
|
+
binding.inactiveSinceMs >= 0 &&
|
|
33
|
+
binding.inactiveSinceMs <= nowMs).sort((left, right) => left.inactiveSinceMs - right.inactiveSinceMs ||
|
|
34
|
+
left.slot.charCodeAt(0) - right.slot.charCodeAt(0));
|
|
35
|
+
}
|
|
36
|
+
/** Caller must recheck exact ownership and protected work before retirement. */
|
|
37
|
+
export function planTelegramWorkspaceSlotAllocation(input) {
|
|
38
|
+
const { bindings, reservedSlots, nowMs } = input;
|
|
39
|
+
if (!isValidSnapshot(bindings, reservedSlots, nowMs)) {
|
|
40
|
+
return { kind: "blocked", reason: "invalid-state" };
|
|
41
|
+
}
|
|
42
|
+
const occupied = new Set([
|
|
43
|
+
...bindings.map((binding) => binding.slot),
|
|
44
|
+
...reservedSlots,
|
|
45
|
+
]);
|
|
46
|
+
for (const slot of TELEGRAM_WORKSPACE_SLOTS) {
|
|
47
|
+
if (!occupied.has(slot))
|
|
48
|
+
return { kind: "free", slot };
|
|
49
|
+
}
|
|
50
|
+
const candidate = eligibleByInactivity(bindings, reservedSlots, nowMs)[0];
|
|
51
|
+
return candidate
|
|
52
|
+
? { kind: "reclaim", candidate: { ...candidate } }
|
|
53
|
+
: { kind: "blocked", reason: "protected-capacity" };
|
|
54
|
+
}
|