@llblab/pi-kit 0.36.1 → 0.36.2
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 +5 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
- package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
- package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
- package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
- package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
- package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
- package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
- package/node_modules/@llblab/pi-telegram/package.json +2 -1
- package/package.json +4 -4
|
@@ -2026,6 +2026,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2026
2026
|
): boolean =>
|
|
2027
2027
|
pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
|
|
2028
2028
|
const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
|
|
2029
|
+
// Body-free, bounded warm deduplication: album members can expire through the same chooser.
|
|
2030
|
+
const expiredRoutingViews = new Set<string>();
|
|
2029
2031
|
const implicitThreadCreations = new Map<
|
|
2030
2032
|
string,
|
|
2031
2033
|
{
|
|
@@ -2542,6 +2544,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2542
2544
|
temporaryDeleteConfirmed = true;
|
|
2543
2545
|
return result;
|
|
2544
2546
|
} catch (error) {
|
|
2547
|
+
const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
|
|
2548
|
+
if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
|
|
2549
|
+
TelegramApi.isTelegramApiRequestRejected(error, method) &&
|
|
2550
|
+
TelegramApi.isTelegramStaleTargetHttpError(error) &&
|
|
2551
|
+
ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
|
|
2552
|
+
missingTarget?.chatId === temporaryCleanup.target?.chatId &&
|
|
2553
|
+
missingTarget?.threadId === temporaryCleanup.target?.threadId &&
|
|
2554
|
+
missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
|
|
2555
|
+
// Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
|
|
2556
|
+
// Closed/denied/foreign/forged responses remain uncertain and never license another request.
|
|
2557
|
+
assertExecutionCurrent?.();
|
|
2558
|
+
temporaryDeleteConfirmed = true;
|
|
2559
|
+
return true as TResponse;
|
|
2560
|
+
}
|
|
2545
2561
|
temporaryTransportUncertain = true;
|
|
2546
2562
|
throw error;
|
|
2547
2563
|
}
|
|
@@ -8102,7 +8118,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8102
8118
|
signal: AbortSignal,
|
|
8103
8119
|
): Promise<void> => {
|
|
8104
8120
|
const operator = deps.configStore.getAllowedUserId(),
|
|
8105
|
-
epoch = deps.getCurrentLeaderEpoch?.()
|
|
8121
|
+
epoch = deps.getCurrentLeaderEpoch?.(),
|
|
8122
|
+
generation = deps.getSessionGeneration?.(),
|
|
8123
|
+
instanceId = deps.getCurrentInstanceId?.(),
|
|
8124
|
+
scope = deps.getAdmissionScope?.();
|
|
8125
|
+
const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
|
|
8106
8126
|
const message = source.original.update.message as
|
|
8107
8127
|
Partial<TMessage> | undefined;
|
|
8108
8128
|
const runtimeCurrent = () =>
|
|
@@ -8112,6 +8132,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8112
8132
|
deps.isContextActive?.(ctx) === true &&
|
|
8113
8133
|
deps.configStore.getAllowedUserId() === operator &&
|
|
8114
8134
|
deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
8135
|
+
deps.getSessionGeneration?.() === generation &&
|
|
8136
|
+
deps.getCurrentInstanceId?.() === instanceId &&
|
|
8137
|
+
deps.getAdmissionScope?.() === scope &&
|
|
8115
8138
|
deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
8116
8139
|
source.original.routingInput?.operatorUserId === operator &&
|
|
8117
8140
|
message?.from?.id === operator &&
|
|
@@ -8142,6 +8165,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8142
8165
|
if (!result) return;
|
|
8143
8166
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
8144
8167
|
let movedIntoTab = false;
|
|
8168
|
+
let unissued = source.original.routingInput?.phase === "waiting" &&
|
|
8169
|
+
(!temporary || temporary.forwardProtocol === "one-shot-v1") &&
|
|
8170
|
+
![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input =>
|
|
8171
|
+
input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
|
|
8172
|
+
const views = new Map<string, { chatId: number; threadId?: number; messageId: number }>();
|
|
8173
|
+
const chooser = source.original.routingInput?.chooser;
|
|
8174
|
+
if (chooser) views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
|
|
8145
8175
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
8146
8176
|
if (
|
|
8147
8177
|
!Updates.collectTelegramAdmissionSourceUpdateIds(
|
|
@@ -8150,32 +8180,55 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8150
8180
|
)
|
|
8151
8181
|
continue;
|
|
8152
8182
|
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
8183
|
+
unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
|
|
8184
|
+
if (pending.chooserMessageId !== undefined)
|
|
8185
|
+
views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
|
|
8186
|
+
...pending.sourceTarget, messageId: pending.chooserMessageId,
|
|
8187
|
+
});
|
|
8153
8188
|
removePendingReroute(id);
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
)
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8189
|
+
}
|
|
8190
|
+
const notice = unissued
|
|
8191
|
+
? "<b>⌛ Routing expired; input was not delivered.</b>"
|
|
8192
|
+
: "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
|
|
8193
|
+
const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
|
|
8194
|
+
if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
|
|
8195
|
+
if (expiredRoutingViews.size >= 100) expiredRoutingViews.delete(expiredRoutingViews.values().next().value!);
|
|
8196
|
+
expiredRoutingViews.add(noticeKey);
|
|
8197
|
+
let visible = false;
|
|
8198
|
+
const assertNoticeAuthority = () => {
|
|
8199
|
+
if (!runtimeCurrent()) throw new Error("Routing expiry notice authority changed.");
|
|
8200
|
+
};
|
|
8201
|
+
for (const view of views.values()) {
|
|
8202
|
+
if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage) break;
|
|
8203
|
+
if (view.chatId !== message!.chat!.id || !Number.isSafeInteger(view.threadId) || view.threadId! <= 0) continue;
|
|
8166
8204
|
try {
|
|
8167
|
-
await
|
|
8168
|
-
|
|
8169
|
-
pending.chooserMessageId,
|
|
8170
|
-
"<b>⌛ Routing choice expired.</b>",
|
|
8171
|
-
"html",
|
|
8172
|
-
{ inline_keyboard: [] },
|
|
8173
|
-
);
|
|
8174
|
-
} catch (error) {
|
|
8175
|
-
deps.recordRuntimeEvent?.("routing", error, {
|
|
8176
|
-
phase: "routing-input-expiry-view",
|
|
8205
|
+
await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
|
|
8206
|
+
target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
|
|
8177
8207
|
});
|
|
8208
|
+
visible = true;
|
|
8209
|
+
} catch (error) {
|
|
8210
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
|
|
8211
|
+
}
|
|
8212
|
+
}
|
|
8213
|
+
// A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
|
|
8214
|
+
const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
|
|
8215
|
+
if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
|
|
8216
|
+
leader.target.chatId === message!.chat!.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId! > 0) {
|
|
8217
|
+
const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
|
|
8218
|
+
try {
|
|
8219
|
+
const assertFallback = () => {
|
|
8220
|
+
assertNoticeAuthority();
|
|
8221
|
+
const active = deps.threadStore?.getActiveByInstanceId(instanceId!);
|
|
8222
|
+
if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
|
|
8223
|
+
throw new Error("Routing expiry notice destination changed.");
|
|
8224
|
+
};
|
|
8225
|
+
assertFallback();
|
|
8226
|
+
await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
|
|
8227
|
+
} catch (error) {
|
|
8228
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
|
|
8178
8229
|
}
|
|
8230
|
+
} else if (!visible && runtimeCurrent()) {
|
|
8231
|
+
deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
|
|
8179
8232
|
}
|
|
8180
8233
|
}
|
|
8181
8234
|
// The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
|
|
@@ -8434,7 +8487,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8434
8487
|
)
|
|
8435
8488
|
continue;
|
|
8436
8489
|
try {
|
|
8437
|
-
await retireCancellationChooser(id, pending, isCurrent)
|
|
8490
|
+
if (await retireCancellationChooser(id, pending, isCurrent))
|
|
8491
|
+
await deleteCancelledOrphanTab(
|
|
8492
|
+
pending,
|
|
8493
|
+
pending.abandonment!,
|
|
8494
|
+
isCurrent,
|
|
8495
|
+
);
|
|
8438
8496
|
} catch (error) {
|
|
8439
8497
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
8440
8498
|
phase: "recovered-chooser-cleanup",
|
|
@@ -8463,6 +8521,60 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8463
8521
|
}
|
|
8464
8522
|
return true;
|
|
8465
8523
|
};
|
|
8524
|
+
/**
|
|
8525
|
+
* A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
|
|
8526
|
+
* Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
|
|
8527
|
+
* missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
|
|
8528
|
+
*/
|
|
8529
|
+
const deleteCancelledOrphanTab = async (
|
|
8530
|
+
pending: PendingUnboundReroute,
|
|
8531
|
+
cancellation: NonNullable<PendingUnboundReroute["abandonment"]>,
|
|
8532
|
+
isAuthorityCurrent: () => boolean,
|
|
8533
|
+
): Promise<void> => {
|
|
8534
|
+
const { chatId, threadId } = pending.sourceTarget;
|
|
8535
|
+
if (
|
|
8536
|
+
threadId === undefined ||
|
|
8537
|
+
pending.temporaryThread ||
|
|
8538
|
+
isTemporaryTabTarget({ chatId, threadId }) ||
|
|
8539
|
+
hasPendingRerouteForTarget({ chatId, threadId }) ||
|
|
8540
|
+
!deps.inspectTemporaryThreadSources
|
|
8541
|
+
)
|
|
8542
|
+
return;
|
|
8543
|
+
const target = { chatId, threadId };
|
|
8544
|
+
try {
|
|
8545
|
+
const own = {
|
|
8546
|
+
journalBindingKey: cancellation.journalBindingKey,
|
|
8547
|
+
updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(
|
|
8548
|
+
pending.messages,
|
|
8549
|
+
),
|
|
8550
|
+
};
|
|
8551
|
+
const assertCurrent = (): void => {
|
|
8552
|
+
if (!isAuthorityCurrent())
|
|
8553
|
+
throw new Error("Unbound tab cleanup authority changed.");
|
|
8554
|
+
};
|
|
8555
|
+
assertCurrent();
|
|
8556
|
+
if (
|
|
8557
|
+
deps.inspectTemporaryThreadSources(
|
|
8558
|
+
target,
|
|
8559
|
+
[cancellation.journalBindingKey],
|
|
8560
|
+
[own],
|
|
8561
|
+
)?.length !== 0
|
|
8562
|
+
)
|
|
8563
|
+
return;
|
|
8564
|
+
assertCurrent();
|
|
8565
|
+
await closeReroutedUnboundTopic(
|
|
8566
|
+
target,
|
|
8567
|
+
pending.chooserMessageId,
|
|
8568
|
+
assertCurrent,
|
|
8569
|
+
);
|
|
8570
|
+
} catch (error) {
|
|
8571
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
8572
|
+
phase: "unbound-cancel-tab-cleanup",
|
|
8573
|
+
chatId: target.chatId,
|
|
8574
|
+
threadId: target.threadId,
|
|
8575
|
+
});
|
|
8576
|
+
}
|
|
8577
|
+
};
|
|
8466
8578
|
const handleUnboundRerouteCancelCallback = async (
|
|
8467
8579
|
query: TCallbackQuery,
|
|
8468
8580
|
ctx: TContext,
|
|
@@ -8580,6 +8692,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8580
8692
|
await Promise.all([
|
|
8581
8693
|
deps.answerCallbackQuery(query.id, "Routing cancelled"),
|
|
8582
8694
|
deleteAllTabCopies(pending!),
|
|
8695
|
+
deleteCancelledOrphanTab(pending!, cancellation!, isAuthorityCurrent),
|
|
8583
8696
|
]);
|
|
8584
8697
|
} catch (error) {
|
|
8585
8698
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -1049,6 +1049,20 @@ export class TelegramApiStaleTargetError extends Error {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
+
/** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
|
|
1053
|
+
export class TelegramApiFollowerNotAllowedError extends Error {
|
|
1054
|
+
readonly requestTarget: { chatId: number; threadId: number } | undefined;
|
|
1055
|
+
|
|
1056
|
+
constructor(
|
|
1057
|
+
message: string,
|
|
1058
|
+
requestTarget?: { chatId: number; threadId: number },
|
|
1059
|
+
) {
|
|
1060
|
+
super(message);
|
|
1061
|
+
this.name = "TelegramApiFollowerNotAllowedError";
|
|
1062
|
+
this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1052
1066
|
export function getTelegramApiErrorRequestTarget(
|
|
1053
1067
|
error: unknown,
|
|
1054
1068
|
): { chatId: number; threadId: number } | undefined {
|
|
@@ -22,7 +22,14 @@ import type {
|
|
|
22
22
|
TelegramWorkspaceDeletionPermit,
|
|
23
23
|
TelegramWorkspaceDestructiveFence,
|
|
24
24
|
TelegramWorkspaceRetirementFence,
|
|
25
|
+
TelegramWorkspaceAdmissionLedger,
|
|
26
|
+
TelegramWorkspaceAdmissionOwner,
|
|
25
27
|
} from "./workspace-admission.ts";
|
|
28
|
+
import { isTelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
|
|
29
|
+
import type { TelegramTopicTargetStore, TelegramWorkspaceThreadBinding } from "./threads.ts";
|
|
30
|
+
import type { TelegramWorkspaceOperationRunner } from "./workspace-retirement.ts";
|
|
31
|
+
import type { TelegramWorkspaceThreadDeletionTransport } from "./telegram-api.ts";
|
|
32
|
+
import type { TelegramProcessLiveness } from "./process-identity.ts";
|
|
26
33
|
|
|
27
34
|
export interface TelegramThreadCleanupBindingSnapshot {
|
|
28
35
|
cwd: string;
|
|
@@ -56,7 +63,8 @@ export interface TelegramThreadCleanupCandidate {
|
|
|
56
63
|
sessionId?: string;
|
|
57
64
|
sessionKey?: string;
|
|
58
65
|
instanceSlot: string;
|
|
59
|
-
|
|
66
|
+
/** Null is explicit absence in reviewed work; it never denotes a reusable letter. */
|
|
67
|
+
slot: string | null;
|
|
60
68
|
target: { chatId: number; threadId: number };
|
|
61
69
|
inactiveSinceMs: number;
|
|
62
70
|
bindingUpdatedAtMs: number;
|
|
@@ -116,8 +124,7 @@ export function planTelegramInactiveThreadCleanup(input: {
|
|
|
116
124
|
!binding.cwd ||
|
|
117
125
|
!binding.workspaceKey ||
|
|
118
126
|
!binding.instanceSlot ||
|
|
119
|
-
typeof slot !== "string" ||
|
|
120
|
-
!slot ||
|
|
127
|
+
(slot !== undefined && (typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) ||
|
|
121
128
|
!validSessionIdentity(binding) ||
|
|
122
129
|
!validTarget(binding.target) ||
|
|
123
130
|
bindingKeys.has(binding.bindingKey) ||
|
|
@@ -152,8 +159,6 @@ export function planTelegramInactiveThreadCleanup(input: {
|
|
|
152
159
|
const inactiveSinceMs = binding.inactiveSinceMs;
|
|
153
160
|
const slot = binding.slot;
|
|
154
161
|
if (
|
|
155
|
-
typeof slot !== "string" ||
|
|
156
|
-
!slot ||
|
|
157
162
|
!Number.isSafeInteger(inactiveSinceMs) ||
|
|
158
163
|
inactiveSinceMs === undefined ||
|
|
159
164
|
inactiveSinceMs < 0 ||
|
|
@@ -180,7 +185,7 @@ export function planTelegramInactiveThreadCleanup(input: {
|
|
|
180
185
|
? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
|
|
181
186
|
: {}),
|
|
182
187
|
instanceSlot: binding.instanceSlot,
|
|
183
|
-
slot,
|
|
188
|
+
slot: slot ?? null,
|
|
184
189
|
target: { ...binding.target },
|
|
185
190
|
inactiveSinceMs,
|
|
186
191
|
bindingUpdatedAtMs: binding.updatedAtMs,
|
|
@@ -409,8 +414,7 @@ function validateWorkSet(
|
|
|
409
414
|
!validSessionIdentity(raw) ||
|
|
410
415
|
typeof raw.instanceSlot !== "string" ||
|
|
411
416
|
!raw.instanceSlot ||
|
|
412
|
-
typeof raw.slot !== "string" ||
|
|
413
|
-
!raw.slot ||
|
|
417
|
+
(raw.slot !== null && (typeof raw.slot !== "string" || !/^[A-Z]$/u.test(raw.slot))) ||
|
|
414
418
|
!isObject(raw.target) ||
|
|
415
419
|
!onlyKeys(raw.target, ["chatId", "threadId"]) ||
|
|
416
420
|
!validTarget(
|
|
@@ -509,7 +513,11 @@ function sameCandidates(
|
|
|
509
513
|
left: readonly TelegramThreadCleanupCandidate[],
|
|
510
514
|
right: readonly TelegramThreadCleanupCandidate[],
|
|
511
515
|
): boolean {
|
|
512
|
-
|
|
516
|
+
const keys = ["profileName", "bindingKey", "cwd", "workspaceKey", "sessionId", "sessionKey", "instanceSlot", "slot", "inactiveSinceMs", "bindingUpdatedAtMs"] as const;
|
|
517
|
+
return left.length === right.length && left.every((candidate, index) => {
|
|
518
|
+
const other = right[index]!;
|
|
519
|
+
return keys.every(key => candidate[key] === other[key]) && targetKey(candidate.target) === targetKey(other.target);
|
|
520
|
+
});
|
|
513
521
|
}
|
|
514
522
|
|
|
515
523
|
function parseTelegramThreadCleanupWorkFile(
|
|
@@ -544,6 +552,8 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
544
552
|
runtimeDir?: string;
|
|
545
553
|
profileName: string;
|
|
546
554
|
tokenSha256: string;
|
|
555
|
+
/** Exact captured ledger identity; standalone fixtures may use the plain profile identity. */
|
|
556
|
+
admissionProfileKey?: string;
|
|
547
557
|
maxWorkSets?: number;
|
|
548
558
|
maxBytes?: number;
|
|
549
559
|
getNowMs?: () => number;
|
|
@@ -680,12 +690,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
680
690
|
!entry ||
|
|
681
691
|
entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
|
|
682
692
|
permit.destructiveKind !== "manual-thread-cleanup" ||
|
|
683
|
-
permit.profileKey !== entry.profileName ||
|
|
693
|
+
permit.profileKey !== (options.admissionProfileKey ?? entry.profileName) ||
|
|
684
694
|
permit.bindingKey !== entry.bindingKey ||
|
|
685
695
|
permit.slot !== entry.slot ||
|
|
686
696
|
targetKey(permit.target) !== targetKey(entry.target) ||
|
|
687
697
|
!permit.operationId ||
|
|
688
|
-
|
|
698
|
+
permit.retirementIntentId !== workSet.operationId ||
|
|
689
699
|
!safeTime(permit.issuedAtMs)
|
|
690
700
|
)
|
|
691
701
|
throw new Error(
|
|
@@ -842,12 +852,13 @@ export async function executeTelegramInactiveThreadCleanup(input: {
|
|
|
842
852
|
|
|
843
853
|
export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
844
854
|
ledger: {
|
|
855
|
+
getProfileKey(): string;
|
|
845
856
|
read(): { fence?: TelegramWorkspaceDestructiveFence };
|
|
846
857
|
acquireThreadCleanupFence(input: {
|
|
847
858
|
operationId: string;
|
|
848
859
|
cleanupWorkSetId: string;
|
|
849
860
|
bindingKey: string;
|
|
850
|
-
slot: string;
|
|
861
|
+
slot: string | null;
|
|
851
862
|
target: { chatId: number; threadId: number };
|
|
852
863
|
leaderEpoch: number | string;
|
|
853
864
|
cleanupRequestedAtMs: number;
|
|
@@ -930,7 +941,7 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
|
930
941
|
const fence = deps.ledger.read().fence;
|
|
931
942
|
return fence?.operationId === operationId &&
|
|
932
943
|
fence.destructiveKind === "manual-thread-cleanup" &&
|
|
933
|
-
fence.profileKey ===
|
|
944
|
+
fence.profileKey === deps.ledger.getProfileKey() &&
|
|
934
945
|
fence.retirementIntentId === workSetId &&
|
|
935
946
|
fence.bindingKey === candidate.bindingKey &&
|
|
936
947
|
fence.slot === candidate.slot &&
|
|
@@ -1045,8 +1056,17 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
|
1045
1056
|
: { kind: "already-issued" };
|
|
1046
1057
|
},
|
|
1047
1058
|
async settleDeleted(fence, commit) {
|
|
1059
|
+
const assertAuthority = () => {
|
|
1060
|
+
const owner = deps.getOwner();
|
|
1061
|
+
if (deps.getLeaderEpoch() !== fence.leaderEpoch ||
|
|
1062
|
+
owner.processId !== fence.owner.processId || owner.processBirthId !== fence.owner.processBirthId)
|
|
1063
|
+
throw new Error("Telegram Thread cleanup settlement authority changed.");
|
|
1064
|
+
};
|
|
1065
|
+
assertAuthority();
|
|
1048
1066
|
const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
|
|
1049
|
-
|
|
1067
|
+
const committed = await commit();
|
|
1068
|
+
assertAuthority();
|
|
1069
|
+
if (!committed) return "commit-pending";
|
|
1050
1070
|
deps.ledger.completeThreadCleanupFence(ready);
|
|
1051
1071
|
return "completed";
|
|
1052
1072
|
},
|
|
@@ -1069,6 +1089,138 @@ export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
|
|
|
1069
1089
|
): Promise<boolean>;
|
|
1070
1090
|
}
|
|
1071
1091
|
|
|
1092
|
+
export interface TelegramInactiveThreadCleanupAuthority {
|
|
1093
|
+
profileName: string;
|
|
1094
|
+
leaderEpoch: number | string;
|
|
1095
|
+
ledger: TelegramWorkspaceAdmissionLedger;
|
|
1096
|
+
isCurrent(): boolean;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
|
|
1100
|
+
export function createTelegramInactiveThreadCleanupRuntime(deps: {
|
|
1101
|
+
store: Pick<TelegramTopicTargetStore, "load" | "listWorkspaceBindings" | "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "commitInactiveWorkspaceCleanup">;
|
|
1102
|
+
captureAuthority(): TelegramInactiveThreadCleanupAuthority | undefined;
|
|
1103
|
+
getWorkStore(authority: TelegramInactiveThreadCleanupAuthority): TelegramThreadCleanupWorkStore;
|
|
1104
|
+
getProtection(binding: TelegramWorkspaceThreadBinding): {
|
|
1105
|
+
liveOwner: TelegramThreadCleanupProtectionState;
|
|
1106
|
+
acceptedWork: TelegramThreadCleanupProtectionState;
|
|
1107
|
+
deliveryAuthority: TelegramThreadCleanupProtectionState;
|
|
1108
|
+
};
|
|
1109
|
+
getProcessLiveness(owner: TelegramWorkspaceAdmissionOwner): TelegramProcessLiveness;
|
|
1110
|
+
runExclusive<T>(operation: () => Promise<T>): Promise<T>;
|
|
1111
|
+
runWorkspaceOperation: TelegramWorkspaceOperationRunner;
|
|
1112
|
+
deleteThread: TelegramWorkspaceThreadDeletionTransport;
|
|
1113
|
+
onError?: (error: unknown) => void;
|
|
1114
|
+
}): {
|
|
1115
|
+
review(): Promise<{ count: number; operationId?: string }>;
|
|
1116
|
+
clean(operationId: string): Promise<TelegramInactiveThreadCleanupResult>;
|
|
1117
|
+
} {
|
|
1118
|
+
const capture = () => {
|
|
1119
|
+
const authority = deps.captureAuthority();
|
|
1120
|
+
if (!authority || !authority.isCurrent())
|
|
1121
|
+
throw new Error("Inactive Thread cleanup requires current leader authority.");
|
|
1122
|
+
const ledgerKey = authority.ledger.getProfileKey();
|
|
1123
|
+
const owner = authority.ledger.getOwner();
|
|
1124
|
+
const assertCurrent = () => {
|
|
1125
|
+
if (!authority.isCurrent() || authority.ledger.getProfileKey() !== ledgerKey)
|
|
1126
|
+
throw new Error("Inactive Thread cleanup authority changed.");
|
|
1127
|
+
};
|
|
1128
|
+
const evidence = () => {
|
|
1129
|
+
assertCurrent();
|
|
1130
|
+
return captureTelegramInactiveThreadCleanupEvidence({
|
|
1131
|
+
profileName: authority.profileName,
|
|
1132
|
+
listBindings: deps.store.listWorkspaceBindings,
|
|
1133
|
+
getProtection: deps.getProtection,
|
|
1134
|
+
listReservations: deps.store.listReservations,
|
|
1135
|
+
listPendingProvisions: deps.store.listPendingProvisions,
|
|
1136
|
+
listPendingCleanups: deps.store.listPendingCleanups,
|
|
1137
|
+
});
|
|
1138
|
+
};
|
|
1139
|
+
return { authority, owner, assertCurrent, evidence };
|
|
1140
|
+
};
|
|
1141
|
+
return {
|
|
1142
|
+
async review() {
|
|
1143
|
+
const scope = capture();
|
|
1144
|
+
return createTelegramInactiveThreadCleanupReviewRuntime({
|
|
1145
|
+
getProfileName: () => scope.authority.profileName,
|
|
1146
|
+
listBindings: deps.store.listWorkspaceBindings,
|
|
1147
|
+
getProtection: deps.getProtection,
|
|
1148
|
+
listReservations: deps.store.listReservations,
|
|
1149
|
+
listPendingProvisions: deps.store.listPendingProvisions,
|
|
1150
|
+
listPendingCleanups: deps.store.listPendingCleanups,
|
|
1151
|
+
getWorkStore() { scope.assertCurrent(); return deps.getWorkStore(scope.authority); },
|
|
1152
|
+
runWorkspaceOperation(metadata, operation) {
|
|
1153
|
+
return deps.runWorkspaceOperation(metadata, async () => {
|
|
1154
|
+
scope.assertCurrent();
|
|
1155
|
+
await deps.store.load();
|
|
1156
|
+
scope.assertCurrent();
|
|
1157
|
+
return operation();
|
|
1158
|
+
});
|
|
1159
|
+
},
|
|
1160
|
+
}).review();
|
|
1161
|
+
},
|
|
1162
|
+
async clean(operationId) {
|
|
1163
|
+
const scope = capture();
|
|
1164
|
+
return deps.runExclusive(async () => {
|
|
1165
|
+
scope.assertCurrent();
|
|
1166
|
+
const { authority } = scope;
|
|
1167
|
+
const work = deps.getWorkStore(authority);
|
|
1168
|
+
const exactBinding = (candidate: TelegramThreadCleanupCandidate) => {
|
|
1169
|
+
const fresh = planTelegramInactiveThreadCleanup(scope.evidence()).find(binding => binding.bindingKey === candidate.bindingKey);
|
|
1170
|
+
if (!fresh || !sameCandidates([fresh], [candidate])) return undefined;
|
|
1171
|
+
return deps.store.listWorkspaceBindings().find(binding => binding.bindingKey === fresh.bindingKey);
|
|
1172
|
+
};
|
|
1173
|
+
const loadExactBinding = async (candidate: TelegramThreadCleanupCandidate) => {
|
|
1174
|
+
scope.assertCurrent();
|
|
1175
|
+
await deps.store.load();
|
|
1176
|
+
scope.assertCurrent();
|
|
1177
|
+
return exactBinding(candidate);
|
|
1178
|
+
};
|
|
1179
|
+
const permitRuntime = createTelegramThreadCleanupPermitRuntime({
|
|
1180
|
+
ledger: authority.ledger,
|
|
1181
|
+
getLeaderEpoch: () => authority.isCurrent() ? authority.leaderEpoch : undefined,
|
|
1182
|
+
getProfileName: () => authority.profileName,
|
|
1183
|
+
getOwner: () => scope.owner,
|
|
1184
|
+
canAdoptFence: fence => authority.isCurrent() && deps.getProcessLiveness(fence.owner) === "dead",
|
|
1185
|
+
revalidateUnderFence: async candidate => !!(await loadExactBinding(candidate)),
|
|
1186
|
+
});
|
|
1187
|
+
return cleanReviewedInactiveThreads({
|
|
1188
|
+
store: work, operationId, permitRuntime,
|
|
1189
|
+
resolveFullBinding: loadExactBinding,
|
|
1190
|
+
async deleteWithPermit(permit, candidate) {
|
|
1191
|
+
try {
|
|
1192
|
+
await deps.deleteThread(() => {
|
|
1193
|
+
scope.assertCurrent();
|
|
1194
|
+
const fence = authority.ledger.read().fence;
|
|
1195
|
+
if (!fence || !isTelegramWorkspaceRetirementFence(fence) ||
|
|
1196
|
+
fence.destructiveKind !== "manual-thread-cleanup" || fence.phase !== "deletion-issued" ||
|
|
1197
|
+
fence.operationId !== permit.operationId || fence.retirementIntentId !== operationId ||
|
|
1198
|
+
fence.profileKey !== permit.profileKey || fence.bindingKey !== candidate.bindingKey ||
|
|
1199
|
+
fence.slot !== candidate.slot || fence.deletionIssuedAtMs !== permit.issuedAtMs ||
|
|
1200
|
+
fence.leaderEpoch !== authority.leaderEpoch || fence.owner.processId !== scope.owner.processId ||
|
|
1201
|
+
fence.owner.processBirthId !== scope.owner.processBirthId ||
|
|
1202
|
+
targetKey(fence.target) !== targetKey(permit.target) || !exactBinding(candidate))
|
|
1203
|
+
throw new Error("Inactive Thread cleanup deletion permit is stale.");
|
|
1204
|
+
return { ...permit.target };
|
|
1205
|
+
});
|
|
1206
|
+
scope.assertCurrent();
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
deps.onError?.(error);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
async commitBinding(candidate) {
|
|
1213
|
+
scope.assertCurrent();
|
|
1214
|
+
await deps.store.load();
|
|
1215
|
+
scope.assertCurrent();
|
|
1216
|
+
return deps.store.commitInactiveWorkspaceCleanup(candidate, authority.isCurrent);
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1072
1224
|
export function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(
|
|
1073
1225
|
deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
|
|
1074
1226
|
): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult> {
|
|
@@ -1105,9 +1257,13 @@ export async function cleanReviewedInactiveThreads<TBinding>(
|
|
|
1105
1257
|
entry,
|
|
1106
1258
|
input.operationId,
|
|
1107
1259
|
);
|
|
1108
|
-
if (ready)
|
|
1109
|
-
await input.permitRuntime.settleDeleted(ready, async () => true);
|
|
1110
|
-
|
|
1260
|
+
if (ready) {
|
|
1261
|
+
const settlement = await input.permitRuntime.settleDeleted(ready, async () => true);
|
|
1262
|
+
if (settlement === "completed") deleted += 1;
|
|
1263
|
+
else outcomeUnknown += 1;
|
|
1264
|
+
} else if (input.permitRuntime.diagnoseRecovery(entry, input.operationId) !== "none") {
|
|
1265
|
+
outcomeUnknown += 1;
|
|
1266
|
+
} else deleted += 1;
|
|
1111
1267
|
continue;
|
|
1112
1268
|
}
|
|
1113
1269
|
if (entry.state === "outcome-unknown") {
|
|
@@ -174,8 +174,12 @@ export function resolveTelegramWorkspaceDisplayNames(
|
|
|
174
174
|
: undefined;
|
|
175
175
|
if (manualName) {
|
|
176
176
|
labels.set(binding.bindingKey, boundedLabel(manualName));
|
|
177
|
+
} else if (!slot) {
|
|
178
|
+
// A displaced binding retains its generated identity, not the letter now owned elsewhere.
|
|
179
|
+
const name = binding.threadName ? labelText(binding.threadName) : undefined;
|
|
180
|
+
if (name) labels.set(binding.bindingKey, boundedLabel(name));
|
|
177
181
|
} else if (mode === "letters") {
|
|
178
|
-
|
|
182
|
+
labels.set(binding.bindingKey, slot);
|
|
179
183
|
} else if (mode === "names") {
|
|
180
184
|
const name = binding.threadName ? labelText(binding.threadName) : slot;
|
|
181
185
|
if (name) labels.set(binding.bindingKey, boundedLabel(name));
|
|
@@ -191,12 +195,11 @@ export function resolveTelegramWorkspaceDisplayNames(
|
|
|
191
195
|
bindings.filter((candidate) => candidate.cwd === binding.cwd)
|
|
192
196
|
.length > 1
|
|
193
197
|
: (liveDirectoryCounts.get(binding.cwd) ?? 0) > 1;
|
|
194
|
-
if (showSuffix && !slot) continue;
|
|
195
198
|
const suffix = !showSuffix
|
|
196
199
|
? ""
|
|
197
200
|
: mode === "directory-title"
|
|
198
201
|
? ` ${slot}`
|
|
199
|
-
: `_${slot
|
|
202
|
+
: `_${slot.toLowerCase()}`;
|
|
200
203
|
labels.set(binding.bindingKey, boundedLabel(base, suffix));
|
|
201
204
|
}
|
|
202
205
|
}
|
|
@@ -378,9 +381,9 @@ export function createTelegramThreadDisplaySettingsRuntime(deps: {
|
|
|
378
381
|
/** Caller owns triggering and reporting; no timer or background retry is created. */
|
|
379
382
|
export function createTelegramThreadDisplayReconciler(
|
|
380
383
|
deps: TelegramThreadDisplayReconcilerDeps,
|
|
381
|
-
): { reconcile(): Promise<{ changed: number }> } {
|
|
384
|
+
): { reconcile(bindingKey?: string): Promise<{ changed: number }> } {
|
|
382
385
|
let tail: Promise<unknown> = Promise.resolve();
|
|
383
|
-
const apply = async (): Promise<{ changed: number }> => {
|
|
386
|
+
const apply = async (bindingKey?: string): Promise<{ changed: number }> => {
|
|
384
387
|
const epoch = deps.getLeaderEpoch();
|
|
385
388
|
const profile = deps.getProfileKey();
|
|
386
389
|
const mode = deps.getMode();
|
|
@@ -406,6 +409,7 @@ export function createTelegramThreadDisplayReconciler(
|
|
|
406
409
|
);
|
|
407
410
|
let changed = 0;
|
|
408
411
|
for (const binding of bindings) {
|
|
412
|
+
if (bindingKey !== undefined && binding.bindingKey !== bindingKey) continue;
|
|
409
413
|
const isBindingCurrent = deps.captureBindingAuthority(binding);
|
|
410
414
|
if (!isBindingCurrent) continue;
|
|
411
415
|
const title = titles.get(binding.bindingKey);
|
|
@@ -426,7 +430,7 @@ export function createTelegramThreadDisplayReconciler(
|
|
|
426
430
|
};
|
|
427
431
|
assertBinding(binding);
|
|
428
432
|
if ((binding.displayTitle ?? binding.threadName) === title) continue;
|
|
429
|
-
await deps.callApi(
|
|
433
|
+
const acknowledged = await deps.callApi<boolean>(
|
|
430
434
|
"editForumTopic",
|
|
431
435
|
{
|
|
432
436
|
chat_id: binding.target.chatId,
|
|
@@ -436,6 +440,8 @@ export function createTelegramThreadDisplayReconciler(
|
|
|
436
440
|
{ maxAttempts: 1 },
|
|
437
441
|
);
|
|
438
442
|
assertBinding(binding);
|
|
443
|
+
if (acknowledged !== true)
|
|
444
|
+
throw new Error("Telegram Thread display update was not acknowledged.");
|
|
439
445
|
if (!deps.store.setWorkspaceDisplayTitle(binding, title)) {
|
|
440
446
|
throw new Error(
|
|
441
447
|
"Telegram Thread display binding changed before title commit.",
|
|
@@ -452,8 +458,8 @@ export function createTelegramThreadDisplayReconciler(
|
|
|
452
458
|
return { changed };
|
|
453
459
|
};
|
|
454
460
|
return {
|
|
455
|
-
reconcile() {
|
|
456
|
-
const run = tail.then(apply);
|
|
461
|
+
reconcile(bindingKey) {
|
|
462
|
+
const run = tail.then(() => apply(bindingKey));
|
|
457
463
|
tail = run.catch(() => undefined);
|
|
458
464
|
return run;
|
|
459
465
|
},
|