@llblab/pi-kit 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-state-flow/README.md +3 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +45 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +9 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +19 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +52 -11
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +0 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +36 -15
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -5
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -1
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +48 -5
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +9 -9
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +65 -11
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +1 -2
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +35 -15
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
- package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace
|
|
2
|
+
* Workspace slot rotation
|
|
3
3
|
* Zones: telegram, workspace identity, lifecycle
|
|
4
|
-
* Owns fail-closed protection
|
|
5
|
-
* successor
|
|
4
|
+
* Owns fail-closed protection, demand-driven pressure retirement, exact-intent admission,
|
|
5
|
+
* successor recovery, and fenced one-shot deletion before durable slot reuse.
|
|
6
6
|
*/
|
|
7
7
|
import { isDeepStrictEqual } from "node:util";
|
|
8
|
-
import {
|
|
8
|
+
import { getTelegramApiErrorRequestTarget, isTelegramApiRequestRejected, } from "./telegram-api.js";
|
|
9
|
+
import { planTelegramWorkspaceSlotAllocation, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
|
|
9
10
|
import { createTelegramWorkspaceAdmissionOperationId, isTelegramWorkspaceRetirementFence, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
|
|
10
11
|
export function createTelegramWorkspaceOperationGate() {
|
|
11
12
|
let tail = Promise.resolve();
|
|
@@ -50,9 +51,10 @@ export function captureTelegramWorkspaceJournalProtectionSources(input) {
|
|
|
50
51
|
sources.push({ kind: "unknown", scope });
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
54
|
+
const read = binding.readForProtection ?? binding.journal.read;
|
|
53
55
|
const snapshot = input.withJournalReference
|
|
54
|
-
? input.withJournalReference(binding,
|
|
55
|
-
:
|
|
56
|
+
? input.withJournalReference(binding, read)
|
|
57
|
+
: read();
|
|
56
58
|
sources.push({ kind: "available", scope, entries: snapshot.entries });
|
|
57
59
|
}
|
|
58
60
|
catch {
|
|
@@ -279,6 +281,185 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps) {
|
|
|
279
281
|
});
|
|
280
282
|
};
|
|
281
283
|
}
|
|
284
|
+
export function isCurrentTelegramWorkspaceBinding(store, expected) {
|
|
285
|
+
return store.listWorkspaceBindings().some((binding) => isDeepStrictEqual(binding, expected));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Demand-only preparation for pressure retirement. Every removed group is still
|
|
289
|
+
* owned by the journal's exact dead-owner CAS; this function never clears local
|
|
290
|
+
* queue memory or turns unknown evidence into deletion authority.
|
|
291
|
+
*/
|
|
292
|
+
export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps) {
|
|
293
|
+
const localProtection = (binding) => {
|
|
294
|
+
const active = deps.getActiveTurnTarget();
|
|
295
|
+
if (active && sameTarget(active, binding.target))
|
|
296
|
+
return "protected";
|
|
297
|
+
let unknown = false;
|
|
298
|
+
for (const item of deps.getQueuedItems()) {
|
|
299
|
+
if (item.target && sameTarget(item.target, binding.target))
|
|
300
|
+
return "protected";
|
|
301
|
+
if (!item.target && item.chatId === binding.target.chatId)
|
|
302
|
+
unknown = true;
|
|
303
|
+
}
|
|
304
|
+
return unknown ? "unknown" : "clear";
|
|
305
|
+
};
|
|
306
|
+
return async (binding, isCurrent) => {
|
|
307
|
+
if (!isCurrent() || !deps.isBindingCurrent(binding)) {
|
|
308
|
+
return { kind: "blocked", reason: "authority-changed" };
|
|
309
|
+
}
|
|
310
|
+
const initial = deps.getExternalProtection(binding);
|
|
311
|
+
if (initial.liveOwner !== "clear" || initial.deliveryAuthority !== "clear") {
|
|
312
|
+
return { kind: "blocked", reason: "live-owner" };
|
|
313
|
+
}
|
|
314
|
+
if (localProtection(binding) !== "clear") {
|
|
315
|
+
return { kind: "blocked", reason: "local-work" };
|
|
316
|
+
}
|
|
317
|
+
if (initial.acceptedWork === "clear")
|
|
318
|
+
return { kind: "not-needed" };
|
|
319
|
+
const discovery = binding.journalBindingsComplete === true
|
|
320
|
+
? undefined
|
|
321
|
+
: deps.discoverFollowerJournals?.();
|
|
322
|
+
let complete = binding.journalBindingsComplete === true || discovery?.complete === true;
|
|
323
|
+
const sources = [];
|
|
324
|
+
const recoveryKeys = new Set();
|
|
325
|
+
const capture = (scope, resolve) => {
|
|
326
|
+
try {
|
|
327
|
+
const source = resolve();
|
|
328
|
+
if (!source?.recoveryKey || !source.readForProtection) {
|
|
329
|
+
complete = false;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (recoveryKeys.has(source.recoveryKey))
|
|
333
|
+
return;
|
|
334
|
+
const snapshot = deps.withJournalReference
|
|
335
|
+
? deps.withJournalReference(source, source.readForProtection)
|
|
336
|
+
: source.readForProtection();
|
|
337
|
+
recoveryKeys.add(source.recoveryKey);
|
|
338
|
+
sources.push({ scope, binding: source, entries: snapshot.entries });
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
complete = false;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
capture("shared", deps.resolveLeaderJournal);
|
|
345
|
+
for (const key of binding.journalBindingKeys ?? []) {
|
|
346
|
+
capture("binding", deps.createFollowerJournalResolver(key));
|
|
347
|
+
}
|
|
348
|
+
for (const path of discovery?.paths ?? []) {
|
|
349
|
+
if (!deps.createJournalPathResolver) {
|
|
350
|
+
complete = false;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
capture("discovered", deps.createJournalPathResolver(path));
|
|
354
|
+
}
|
|
355
|
+
if (!complete)
|
|
356
|
+
return { kind: "blocked", reason: "incomplete-source" };
|
|
357
|
+
const plans = [];
|
|
358
|
+
for (const source of sources) {
|
|
359
|
+
const relevant = source.scope === "binding"
|
|
360
|
+
? [...source.entries]
|
|
361
|
+
: source.entries.filter((entry) => {
|
|
362
|
+
const target = getJournalUpdateTarget(entry.update);
|
|
363
|
+
return !!target && sameTarget(target, binding.target);
|
|
364
|
+
});
|
|
365
|
+
if (source.scope === "binding" && relevant.some((entry) => {
|
|
366
|
+
const target = getJournalUpdateTarget(entry.update);
|
|
367
|
+
return !target || !sameTarget(target, binding.target);
|
|
368
|
+
}))
|
|
369
|
+
return { kind: "blocked", reason: "unsupported-custody" };
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
for (const entry of relevant) {
|
|
372
|
+
if (entry.state !== "queued" ||
|
|
373
|
+
(entry.queueKind !== "prompt" && entry.queueKind !== "control") ||
|
|
374
|
+
!entry.queueReceiptId || !entry.queueOwner || entry.queueHandoff) {
|
|
375
|
+
return { kind: "blocked", reason: "unsupported-custody" };
|
|
376
|
+
}
|
|
377
|
+
if (seen.has(entry.queueReceiptId))
|
|
378
|
+
continue;
|
|
379
|
+
const receiptEntries = source.entries.filter((candidate) => candidate.queueReceiptId === entry.queueReceiptId);
|
|
380
|
+
if (!receiptEntries.length || receiptEntries.some((candidate) => {
|
|
381
|
+
const target = getJournalUpdateTarget(candidate.update);
|
|
382
|
+
return candidate.state !== "queued" ||
|
|
383
|
+
candidate.queueKind !== entry.queueKind ||
|
|
384
|
+
!candidate.queueOwner || candidate.queueHandoff !== undefined ||
|
|
385
|
+
!isDeepStrictEqual(candidate.queueOwner, entry.queueOwner) ||
|
|
386
|
+
!target || !sameTarget(target, binding.target);
|
|
387
|
+
}))
|
|
388
|
+
return { kind: "blocked", reason: "unsupported-custody" };
|
|
389
|
+
seen.add(entry.queueReceiptId);
|
|
390
|
+
plans.push({
|
|
391
|
+
source: source.binding,
|
|
392
|
+
recovery: {
|
|
393
|
+
queueKind: entry.queueKind,
|
|
394
|
+
receiptId: entry.queueReceiptId,
|
|
395
|
+
sourceUpdateIds: receiptEntries.map((candidate) => candidate.updateId).sort((a, b) => a - b),
|
|
396
|
+
deadOwner: entry.queueOwner,
|
|
397
|
+
recoveryOwner: deps.getRecoveryOwner(),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (!plans.length)
|
|
403
|
+
return { kind: "blocked", reason: "protection-retained" };
|
|
404
|
+
for (const plan of plans) {
|
|
405
|
+
let liveness = "unverifiable";
|
|
406
|
+
try {
|
|
407
|
+
liveness = deps.getQueueOwnerLiveness(plan.recovery.deadOwner);
|
|
408
|
+
}
|
|
409
|
+
catch { /* Unknown process evidence is never destructive authority. */ }
|
|
410
|
+
if (liveness === "alive")
|
|
411
|
+
return { kind: "blocked", reason: "owner-alive" };
|
|
412
|
+
if (liveness !== "dead")
|
|
413
|
+
return { kind: "blocked", reason: "owner-unverifiable" };
|
|
414
|
+
}
|
|
415
|
+
const recoveredIds = [];
|
|
416
|
+
for (const plan of plans) {
|
|
417
|
+
if (!isCurrent() || !deps.isBindingCurrent(binding)) {
|
|
418
|
+
return { kind: "blocked", reason: "authority-changed" };
|
|
419
|
+
}
|
|
420
|
+
const current = deps.getExternalProtection(binding);
|
|
421
|
+
if (current.liveOwner !== "clear" || current.deliveryAuthority !== "clear") {
|
|
422
|
+
return { kind: "blocked", reason: "live-owner" };
|
|
423
|
+
}
|
|
424
|
+
if (localProtection(binding) !== "clear") {
|
|
425
|
+
return { kind: "blocked", reason: "local-work" };
|
|
426
|
+
}
|
|
427
|
+
let result;
|
|
428
|
+
try {
|
|
429
|
+
result = deps.withJournalReference
|
|
430
|
+
? deps.withJournalReference(plan.source, () => plan.source.journal.recoverDeadQueueOwner(plan.recovery))
|
|
431
|
+
: plan.source.journal.recoverDeadQueueOwner(plan.recovery);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
try {
|
|
435
|
+
deps.onMutationError?.(error);
|
|
436
|
+
}
|
|
437
|
+
catch { /* Diagnostics are fail-open. */ }
|
|
438
|
+
return { kind: "blocked", reason: "mutation-refused" };
|
|
439
|
+
}
|
|
440
|
+
if (result.status === "owner-alive") {
|
|
441
|
+
return { kind: "blocked", reason: "owner-alive" };
|
|
442
|
+
}
|
|
443
|
+
if (result.status === "owner-unverifiable") {
|
|
444
|
+
return { kind: "blocked", reason: "owner-unverifiable" };
|
|
445
|
+
}
|
|
446
|
+
recoveredIds.push(...result.recoveredUpdateIds);
|
|
447
|
+
}
|
|
448
|
+
if (!isCurrent() || !deps.isBindingCurrent(binding)) {
|
|
449
|
+
return { kind: "blocked", reason: "authority-changed" };
|
|
450
|
+
}
|
|
451
|
+
const after = deps.getExternalProtection(binding);
|
|
452
|
+
if (after.liveOwner !== "clear" || after.acceptedWork !== "clear" ||
|
|
453
|
+
after.deliveryAuthority !== "clear") {
|
|
454
|
+
return { kind: "blocked", reason: "protection-retained" };
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
kind: "recovered",
|
|
458
|
+
receipts: plans.length,
|
|
459
|
+
updateIds: recoveredIds.sort((a, b) => a - b),
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
}
|
|
282
463
|
export async function adoptTelegramWorkspaceRetirementIntent(input) {
|
|
283
464
|
return input.runExclusive(async () => {
|
|
284
465
|
const leaderEpoch = input.getLeaderEpoch();
|
|
@@ -314,7 +495,7 @@ function isTelegramWorkspaceDeletionConfirmedAbsent(error) {
|
|
|
314
495
|
return false;
|
|
315
496
|
const status = "status" in error && typeof error.status === "number"
|
|
316
497
|
? error.status : undefined;
|
|
317
|
-
if (status !==
|
|
498
|
+
if (status !== 400)
|
|
318
499
|
return false;
|
|
319
500
|
const message = error.message.toLowerCase();
|
|
320
501
|
return message.includes("topic_id_invalid") ||
|
|
@@ -332,6 +513,47 @@ function matchesTelegramWorkspaceRetirementFence(fence, intent) {
|
|
|
332
513
|
fence.target.threadId === intent.binding.target.threadId &&
|
|
333
514
|
fence.retirementRequestedAtMs === intent.requestedAtMs);
|
|
334
515
|
}
|
|
516
|
+
async function cancelRejectedTelegramWorkspaceRetirement(input, retained) {
|
|
517
|
+
const epoch = input.getLeaderEpoch();
|
|
518
|
+
const isCurrent = () => epoch !== undefined && input.getLeaderEpoch() === epoch &&
|
|
519
|
+
input.getProfileKey() === retained.profileKey && input.isCurrent?.() !== false;
|
|
520
|
+
if (!isCurrent())
|
|
521
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
522
|
+
if (retained.phase !== "deletion-rejected" ||
|
|
523
|
+
(retained.destructiveKind ?? "pressure-retirement") !== "pressure-retirement") {
|
|
524
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
525
|
+
}
|
|
526
|
+
const owner = input.admission.getOwner();
|
|
527
|
+
const fence = retained.leaderEpoch === epoch && isDeepStrictEqual(retained.owner, owner)
|
|
528
|
+
? retained : input.admission.adoptRetirementFence(retained, { owner, leaderEpoch: epoch });
|
|
529
|
+
if (fence.phase !== "deletion-rejected")
|
|
530
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
531
|
+
const bindingRetained = () => input.store.listWorkspaceBindings().some(binding => binding.bindingKey === fence.bindingKey && binding.slot === fence.slot && sameTarget(binding.target, fence.target));
|
|
532
|
+
const intents = input.store.listWorkspaceRetirementIntents();
|
|
533
|
+
if (!bindingRetained() || intents.length > 1 ||
|
|
534
|
+
(intents[0] && !matchesTelegramWorkspaceRetirementFence(fence, intents[0]))) {
|
|
535
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
536
|
+
}
|
|
537
|
+
if (intents[0] && !input.store.removeWorkspaceRetirementIntent(intents[0])) {
|
|
538
|
+
return { kind: "retained", reason: "commit-rejected" };
|
|
539
|
+
}
|
|
540
|
+
// Publish even after a same-store retry whose failed write left only a dirty
|
|
541
|
+
// in-memory withdrawal. The rejection fence protects both commit prefixes.
|
|
542
|
+
await input.store.persist();
|
|
543
|
+
if (!isCurrent())
|
|
544
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
545
|
+
if (input.store.listWorkspaceRetirementIntents().length || !bindingRetained()) {
|
|
546
|
+
return { kind: "retained", reason: "commit-rejected" };
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
input.admission.completeRejectedRetirementFence(fence);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
if (input.admission.read().fence)
|
|
553
|
+
return { kind: "retained", reason: "fence-release-unconfirmed" };
|
|
554
|
+
}
|
|
555
|
+
return { kind: "cancelled", reason: "delete-rejected" };
|
|
556
|
+
}
|
|
335
557
|
export async function executeTelegramWorkspaceRetirement(input) {
|
|
336
558
|
return input.runExclusive(async () => {
|
|
337
559
|
const isCurrent = () => input.getLeaderEpoch() === input.intent.leaderEpoch &&
|
|
@@ -360,6 +582,9 @@ export async function executeTelegramWorkspaceRetirement(input) {
|
|
|
360
582
|
leaderEpoch: input.intent.leaderEpoch,
|
|
361
583
|
});
|
|
362
584
|
}
|
|
585
|
+
if (fence?.phase === "deletion-rejected") {
|
|
586
|
+
return cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
|
|
587
|
+
}
|
|
363
588
|
const intent = input.store.listWorkspaceRetirementIntents().find((candidate) => isDeepStrictEqual(candidate, input.intent));
|
|
364
589
|
const binding = input.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey);
|
|
365
590
|
if (!intent || !binding || !isDeepStrictEqual(binding, input.intent.binding)) {
|
|
@@ -385,7 +610,8 @@ export async function executeTelegramWorkspaceRetirement(input) {
|
|
|
385
610
|
return { kind: "retained", reason: "protection-changed" };
|
|
386
611
|
if (!fence) {
|
|
387
612
|
const acquired = input.admission.acquireRetirementFence({
|
|
388
|
-
|
|
613
|
+
// A fresh attempt after proven rejection must not revive an old permit.
|
|
614
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
389
615
|
retirementIntentId: input.intent.id,
|
|
390
616
|
bindingKey: binding.bindingKey,
|
|
391
617
|
slot: binding.slot,
|
|
@@ -423,6 +649,12 @@ export async function executeTelegramWorkspaceRetirement(input) {
|
|
|
423
649
|
}
|
|
424
650
|
catch (error) {
|
|
425
651
|
if (!isTelegramWorkspaceDeletionConfirmedAbsent(error)) {
|
|
652
|
+
const target = getTelegramApiErrorRequestTarget(error);
|
|
653
|
+
if (target && sameTarget(target, binding.target) && isTelegramApiRequestRejected(error, "deleteForumTopic")) {
|
|
654
|
+
fence = input.admission.confirmRetirementRejection(fence);
|
|
655
|
+
const cancellation = await cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
|
|
656
|
+
return cancellation.kind === "cancelled" ? { kind: "retained", reason: "delete-rejected" } : cancellation;
|
|
657
|
+
}
|
|
426
658
|
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
427
659
|
}
|
|
428
660
|
}
|
|
@@ -541,6 +773,31 @@ export async function prepareTelegramWorkspaceRetirement(deps) {
|
|
|
541
773
|
}
|
|
542
774
|
export async function runTelegramWorkspaceRetirementLifecycle(input) {
|
|
543
775
|
let intent = input.store.listWorkspaceRetirementIntents()[0];
|
|
776
|
+
const retainedFence = input.admission.read().fence;
|
|
777
|
+
if (retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
|
|
778
|
+
retainedFence.phase === "deletion-rejected") {
|
|
779
|
+
return input.runExclusive(() => cancelRejectedTelegramWorkspaceRetirement(input, retainedFence));
|
|
780
|
+
}
|
|
781
|
+
if (!intent && retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
|
|
782
|
+
(retainedFence.destructiveKind ?? "pressure-retirement") === "pressure-retirement" &&
|
|
783
|
+
retainedFence.phase === "commit-ready") {
|
|
784
|
+
return input.runExclusive(async () => {
|
|
785
|
+
const epoch = input.getLeaderEpoch();
|
|
786
|
+
if (epoch === undefined || input.getProfileKey() !== retainedFence.profileKey ||
|
|
787
|
+
input.isCurrent?.() === false)
|
|
788
|
+
return { kind: "retained", reason: "authority-changed" };
|
|
789
|
+
if (input.store.listWorkspaceRetirementIntents().length ||
|
|
790
|
+
input.store.listWorkspaceBindings().some((binding) => binding.bindingKey === retainedFence.bindingKey)) {
|
|
791
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
792
|
+
}
|
|
793
|
+
const owner = input.admission.getOwner();
|
|
794
|
+
const fence = retainedFence.leaderEpoch === epoch &&
|
|
795
|
+
isDeepStrictEqual(retainedFence.owner, owner) ? retainedFence :
|
|
796
|
+
input.admission.adoptRetirementFence(retainedFence, { owner, leaderEpoch: epoch });
|
|
797
|
+
input.admission.completeRetirementFence(fence);
|
|
798
|
+
return { kind: "retired", bindingKey: fence.bindingKey, slot: fence.slot };
|
|
799
|
+
});
|
|
800
|
+
}
|
|
544
801
|
if (intent) {
|
|
545
802
|
const adoption = await adoptTelegramWorkspaceRetirementIntent({
|
|
546
803
|
store: input.store,
|
|
@@ -585,3 +842,122 @@ export async function runTelegramWorkspaceRetirementLifecycle(input) {
|
|
|
585
842
|
confirmTargetAbsent: input.confirmTargetAbsent,
|
|
586
843
|
});
|
|
587
844
|
}
|
|
845
|
+
/** Retry allocation once, only after the failed operation released all ordinary leases. */
|
|
846
|
+
export function createTelegramWorkspaceSlotRotation(input) {
|
|
847
|
+
const requests = createTelegramWorkspaceOperationGate();
|
|
848
|
+
return async (operation) => {
|
|
849
|
+
const admission = input.getAdmission();
|
|
850
|
+
const epoch = input.getLeaderEpoch();
|
|
851
|
+
if (!admission || epoch === undefined)
|
|
852
|
+
return operation();
|
|
853
|
+
const profileKey = admission.getProfileKey();
|
|
854
|
+
const isCurrent = () => input.getLeaderEpoch() === epoch &&
|
|
855
|
+
input.getAdmission()?.getProfileKey() === profileKey;
|
|
856
|
+
return requests.runExclusive(async () => {
|
|
857
|
+
if (!isCurrent())
|
|
858
|
+
throw new Error("Telegram Workspace allocation lost leader authority.");
|
|
859
|
+
const rotate = async () => {
|
|
860
|
+
const result = await input.runExclusive(async () => {
|
|
861
|
+
if (!isCurrent())
|
|
862
|
+
throw new Error("Telegram Workspace rotation lost leader authority.");
|
|
863
|
+
await input.store.load();
|
|
864
|
+
if (!isCurrent())
|
|
865
|
+
throw new Error("Telegram Workspace rotation lost leader authority.");
|
|
866
|
+
return runTelegramWorkspaceRetirementLifecycle({
|
|
867
|
+
store: input.store, admission, getExternalProtection: input.getExternalProtection,
|
|
868
|
+
getLeaderEpoch: input.getLeaderEpoch, getProfileKey: () => profileKey, isCurrent,
|
|
869
|
+
// This entire lifecycle already owns the shared gate, without an admission lease.
|
|
870
|
+
runExclusive: async (action) => action(),
|
|
871
|
+
deleteForumTopic(permit, body) {
|
|
872
|
+
return input.deleteThread(() => {
|
|
873
|
+
const fence = admission.read().fence;
|
|
874
|
+
if (!isCurrent() || !fence || !isTelegramWorkspaceRetirementFence(fence) ||
|
|
875
|
+
(fence.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
|
|
876
|
+
(permit.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
|
|
877
|
+
fence.phase !== "deletion-issued" ||
|
|
878
|
+
!isDeepStrictEqual(fence.owner, admission.getOwner()) ||
|
|
879
|
+
fence.profileKey !== permit.profileKey || fence.leaderEpoch !== permit.leaderEpoch ||
|
|
880
|
+
fence.operationId !== permit.operationId || fence.retirementIntentId !== permit.retirementIntentId ||
|
|
881
|
+
fence.bindingKey !== permit.bindingKey || fence.slot !== permit.slot ||
|
|
882
|
+
fence.deletionIssuedAtMs !== permit.issuedAtMs ||
|
|
883
|
+
!sameTarget(fence.target, permit.target) ||
|
|
884
|
+
body.chat_id !== permit.target.chatId || body.message_thread_id !== permit.target.threadId) {
|
|
885
|
+
throw new Error("Telegram Workspace deletion permit is stale.");
|
|
886
|
+
}
|
|
887
|
+
return { ...permit.target };
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
if (result.kind !== "retired" && result.kind !== "not-needed" && result.kind !== "cancelled") {
|
|
893
|
+
throw new Error(`Telegram Workspace slots A-Z are unavailable; rotation blocked (${result.reason}).`);
|
|
894
|
+
}
|
|
895
|
+
if (result.kind === "retired") {
|
|
896
|
+
try {
|
|
897
|
+
input.recordEvent("Telegram Workspace slot rotated.", { slot: result.slot });
|
|
898
|
+
}
|
|
899
|
+
catch { /* Diagnostics cannot turn completed retirement into another attempt. */ }
|
|
900
|
+
}
|
|
901
|
+
if (!isCurrent())
|
|
902
|
+
throw new Error("Telegram Workspace rotation lost leader authority.");
|
|
903
|
+
return result;
|
|
904
|
+
};
|
|
905
|
+
const pending = await input.runExclusive(async () => {
|
|
906
|
+
await input.store.load();
|
|
907
|
+
if (!isCurrent())
|
|
908
|
+
throw new Error("Telegram Workspace allocation lost leader authority.");
|
|
909
|
+
return input.store.listWorkspaceRetirementIntents().length > 0;
|
|
910
|
+
});
|
|
911
|
+
const fence = admission.read().fence;
|
|
912
|
+
if (pending || (fence && isTelegramWorkspaceRetirementFence(fence) &&
|
|
913
|
+
(fence.destructiveKind ?? "pressure-retirement") === "pressure-retirement")) {
|
|
914
|
+
const recovered = await rotate();
|
|
915
|
+
if (recovered.kind !== "cancelled")
|
|
916
|
+
return operation();
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
return await operation();
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (!(error instanceof TelegramWorkspaceSlotUnavailableError))
|
|
923
|
+
throw error;
|
|
924
|
+
if (input.reclaimDeadOwnerQueuedWork) {
|
|
925
|
+
const candidates = await input.runExclusive(async () => {
|
|
926
|
+
await input.store.load();
|
|
927
|
+
if (!isCurrent())
|
|
928
|
+
throw new Error("Telegram Workspace reclamation lost leader authority.");
|
|
929
|
+
const snapshot = input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection);
|
|
930
|
+
const allocation = planTelegramWorkspaceSlotAllocation({ ...snapshot, nowMs: Date.now() });
|
|
931
|
+
if (allocation.kind !== "blocked" || allocation.reason !== "protected-capacity")
|
|
932
|
+
return [];
|
|
933
|
+
return input.store.listWorkspaceBindings().filter((binding) => typeof binding.inactiveSinceMs === "number" &&
|
|
934
|
+
Number.isFinite(binding.inactiveSinceMs) &&
|
|
935
|
+
binding.inactiveSinceMs >= 0).sort((left, right) => left.inactiveSinceMs - right.inactiveSinceMs ||
|
|
936
|
+
(left.slot ?? "").localeCompare(right.slot ?? ""));
|
|
937
|
+
});
|
|
938
|
+
for (const binding of candidates) {
|
|
939
|
+
const evidence = input.getExternalProtection(binding);
|
|
940
|
+
if (evidence.liveOwner !== "clear" ||
|
|
941
|
+
evidence.acceptedWork !== "protected" ||
|
|
942
|
+
evidence.deliveryAuthority !== "clear")
|
|
943
|
+
continue;
|
|
944
|
+
const reclaimed = await input.reclaimDeadOwnerQueuedWork(binding, isCurrent);
|
|
945
|
+
if (reclaimed.kind === "recovered") {
|
|
946
|
+
try {
|
|
947
|
+
input.recordEvent("Telegram Workspace dead-owner queue reclaimed.", {
|
|
948
|
+
slot: binding.slot,
|
|
949
|
+
receipts: reclaimed.receipts,
|
|
950
|
+
updateCount: reclaimed.updateIds.length,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
catch { /* Diagnostics cannot revoke journal-owned recovery. */ }
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
await rotate();
|
|
959
|
+
return operation();
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
};
|
|
963
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* a selection is a proposal, never authority to retire a binding.
|
|
7
7
|
*/
|
|
8
8
|
export declare const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
|
|
9
|
+
export declare class TelegramWorkspaceSlotUnavailableError extends Error {
|
|
10
|
+
constructor();
|
|
11
|
+
}
|
|
9
12
|
export interface TelegramWorkspaceSlotOccupancy {
|
|
10
13
|
bindingKey: string;
|
|
11
14
|
slot: string;
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* a selection is a proposal, never authority to retire a binding.
|
|
7
7
|
*/
|
|
8
8
|
export const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
|
|
9
|
+
export class TelegramWorkspaceSlotUnavailableError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Telegram Workspace slot reservation is unavailable.");
|
|
12
|
+
this.name = "TelegramWorkspaceSlotUnavailableError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
9
15
|
function isSlot(slot) {
|
|
10
16
|
return /^[a-z]$/u.test(slot);
|
|
11
17
|
}
|