@llblab/pi-kit 0.5.2 → 0.7.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 +8 -0
- package/README.md +5 -5
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
- package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
- package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
- package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
- package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
- package/node_modules/@llblab/pi-state-flow/README.md +203 -107
- package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
- package/node_modules/@llblab/pi-state-flow/package.json +8 -2
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -4
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -5
- package/node_modules/@llblab/pi-telegram/README.md +13 -9
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -7
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -5
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
- package/node_modules/@llblab/pi-telegram/index.ts +4 -1415
- package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +37 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
- package/node_modules/@llblab/pi-telegram/lib/config.ts +178 -25
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +154 -15
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +74 -40
- package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -22
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +47 -39
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
- package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +315 -7
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
- package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
- package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
- package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
- package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
- package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
- package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
- package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
- package/node_modules/@llblab/skills/package.json +2 -3
- package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
- package/package.json +7 -6
|
@@ -0,0 +1,968 @@
|
|
|
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
|
+
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
|
|
10
|
+
import type { TelegramTarget } from "./target.ts";
|
|
11
|
+
import type {
|
|
12
|
+
TelegramTopicTargetStore,
|
|
13
|
+
TelegramWorkspaceExternalProtectionEvidence,
|
|
14
|
+
TelegramWorkspaceRetirementIntent,
|
|
15
|
+
TelegramWorkspaceProtectionState,
|
|
16
|
+
TelegramWorkspaceThreadBinding,
|
|
17
|
+
} from "./threads.ts";
|
|
18
|
+
import {
|
|
19
|
+
planTelegramWorkspaceSlotAllocation,
|
|
20
|
+
type TelegramWorkspaceSlotOccupancy,
|
|
21
|
+
} from "./workspace-slots.ts";
|
|
22
|
+
import {
|
|
23
|
+
createTelegramWorkspaceAdmissionOperationId,
|
|
24
|
+
isTelegramWorkspaceRetirementFence,
|
|
25
|
+
runWithTelegramWorkspaceAdmissionsAsync,
|
|
26
|
+
type TelegramWorkspaceAdmissionLedger,
|
|
27
|
+
type TelegramWorkspaceAdmissionScope,
|
|
28
|
+
type TelegramWorkspaceDeletionPermit,
|
|
29
|
+
type TelegramWorkspaceRetirementFence,
|
|
30
|
+
} from "./workspace-admission.ts";
|
|
31
|
+
|
|
32
|
+
export interface TelegramWorkspaceOperationGate {
|
|
33
|
+
runExclusive: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTelegramWorkspaceOperationGate(): TelegramWorkspaceOperationGate {
|
|
37
|
+
let tail: Promise<void> = Promise.resolve();
|
|
38
|
+
return {
|
|
39
|
+
runExclusive<T>(operation: () => Promise<T>): Promise<T> {
|
|
40
|
+
const run = tail.then(operation);
|
|
41
|
+
tail = run.then(() => undefined, () => undefined);
|
|
42
|
+
return run;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TelegramWorkspaceOperationRunner = <T>(
|
|
48
|
+
input: {
|
|
49
|
+
operationId: string;
|
|
50
|
+
operationKind: string;
|
|
51
|
+
scopes: readonly TelegramWorkspaceAdmissionScope[];
|
|
52
|
+
},
|
|
53
|
+
operation: () => Promise<T>,
|
|
54
|
+
) => Promise<T>;
|
|
55
|
+
|
|
56
|
+
export interface TelegramWorkspaceOperationRuntime
|
|
57
|
+
extends TelegramWorkspaceOperationGate {
|
|
58
|
+
run: TelegramWorkspaceOperationRunner;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createTelegramWorkspaceOperationRuntime(input: {
|
|
62
|
+
getWorkspaceAdmission?: () => Pick<
|
|
63
|
+
TelegramWorkspaceAdmissionLedger,
|
|
64
|
+
"acquireAdmission" | "releaseAdmission"
|
|
65
|
+
> | undefined;
|
|
66
|
+
onReleaseError?: (error: unknown, operationKind: string) => void;
|
|
67
|
+
} = {}): TelegramWorkspaceOperationRuntime {
|
|
68
|
+
const gate = createTelegramWorkspaceOperationGate();
|
|
69
|
+
const run: TelegramWorkspaceOperationRunner = (metadata, operation) => {
|
|
70
|
+
const gated = () => gate.runExclusive(operation);
|
|
71
|
+
if (!input.getWorkspaceAdmission) return gated();
|
|
72
|
+
const admission = input.getWorkspaceAdmission();
|
|
73
|
+
if (!admission) {
|
|
74
|
+
throw new Error("Telegram Workspace admission authority is unavailable.");
|
|
75
|
+
}
|
|
76
|
+
return runWithTelegramWorkspaceAdmissionsAsync({
|
|
77
|
+
ledger: admission,
|
|
78
|
+
...metadata,
|
|
79
|
+
operation: gated,
|
|
80
|
+
onReleaseError(error) {
|
|
81
|
+
input.onReleaseError?.(error, metadata.operationKind);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
return { run, runExclusive: gate.runExclusive };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TelegramWorkspaceJournalProtectionCapture {
|
|
89
|
+
sources: TelegramWorkspaceJournalProtectionSource[];
|
|
90
|
+
complete: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface TelegramWorkspaceJournalReader {
|
|
94
|
+
recoveryKey?: string;
|
|
95
|
+
journal: { read: () => { entries: readonly { update: unknown }[] } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function captureTelegramWorkspaceJournalProtectionSources(input: {
|
|
99
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
100
|
+
resolveLeader: () => TelegramWorkspaceJournalReader | undefined;
|
|
101
|
+
createFollowerResolver: (
|
|
102
|
+
journalBindingKey: string,
|
|
103
|
+
) => () => TelegramWorkspaceJournalReader | undefined;
|
|
104
|
+
withJournalReference?: <T>(binding: TelegramWorkspaceJournalReader,
|
|
105
|
+
operation: () => T) => T;
|
|
106
|
+
discovery?: {
|
|
107
|
+
paths: readonly string[];
|
|
108
|
+
complete: boolean;
|
|
109
|
+
createResolver: (path: string) => () => TelegramWorkspaceJournalReader | undefined;
|
|
110
|
+
};
|
|
111
|
+
}): TelegramWorkspaceJournalProtectionCapture {
|
|
112
|
+
const sources: TelegramWorkspaceJournalProtectionSource[] = [];
|
|
113
|
+
let complete = input.binding.journalBindingsComplete === true ||
|
|
114
|
+
input.discovery?.complete === true;
|
|
115
|
+
const capture = (
|
|
116
|
+
scope: TelegramWorkspaceJournalProtectionSource["scope"],
|
|
117
|
+
resolve: () => TelegramWorkspaceJournalReader | undefined,
|
|
118
|
+
): void => {
|
|
119
|
+
try {
|
|
120
|
+
const binding = resolve();
|
|
121
|
+
if (!binding) {
|
|
122
|
+
complete = false;
|
|
123
|
+
sources.push({ kind: "unknown", scope });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const snapshot = input.withJournalReference
|
|
127
|
+
? input.withJournalReference(binding, binding.journal.read)
|
|
128
|
+
: binding.journal.read();
|
|
129
|
+
sources.push({ kind: "available", scope, entries: snapshot.entries });
|
|
130
|
+
} catch {
|
|
131
|
+
complete = false;
|
|
132
|
+
sources.push({ kind: "unknown", scope });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
capture({ kind: "shared" }, input.resolveLeader);
|
|
136
|
+
for (const journalBindingKey of input.binding.journalBindingKeys ?? []) {
|
|
137
|
+
capture(
|
|
138
|
+
{
|
|
139
|
+
kind: "binding",
|
|
140
|
+
bindingKey: input.binding.bindingKey,
|
|
141
|
+
journalBindingKey,
|
|
142
|
+
},
|
|
143
|
+
input.createFollowerResolver(journalBindingKey),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
for (const path of input.discovery?.paths ?? []) {
|
|
147
|
+
capture({ kind: "discovered", path }, input.discovery!.createResolver(path));
|
|
148
|
+
}
|
|
149
|
+
return { sources, complete };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type TelegramWorkspaceJournalProtectionSource =
|
|
153
|
+
| {
|
|
154
|
+
kind: "available";
|
|
155
|
+
scope:
|
|
156
|
+
| { kind: "shared" }
|
|
157
|
+
| { kind: "binding"; bindingKey: string; journalBindingKey: string }
|
|
158
|
+
| { kind: "discovered"; path: string };
|
|
159
|
+
entries: readonly { update: unknown }[];
|
|
160
|
+
}
|
|
161
|
+
| {
|
|
162
|
+
kind: "unknown";
|
|
163
|
+
scope:
|
|
164
|
+
| { kind: "shared" }
|
|
165
|
+
| { kind: "binding"; bindingKey: string; journalBindingKey: string }
|
|
166
|
+
| { kind: "discovered"; path: string };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function getJournalUpdateTarget(update: unknown):
|
|
170
|
+
| { chatId: number; threadId?: number }
|
|
171
|
+
| undefined {
|
|
172
|
+
if (!update || typeof update !== "object" || Array.isArray(update)) return undefined;
|
|
173
|
+
const record = update as Record<string, unknown>;
|
|
174
|
+
if (record.message_reaction !== undefined) return undefined;
|
|
175
|
+
const direct = record.message ?? record.edited_message ?? record.guest_message;
|
|
176
|
+
const callback = record.callback_query;
|
|
177
|
+
const message = direct ?? (
|
|
178
|
+
callback && typeof callback === "object" && !Array.isArray(callback)
|
|
179
|
+
? (callback as Record<string, unknown>).message
|
|
180
|
+
: undefined
|
|
181
|
+
);
|
|
182
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return undefined;
|
|
183
|
+
const messageRecord = message as Record<string, unknown>;
|
|
184
|
+
const chat = messageRecord.chat;
|
|
185
|
+
if (!chat || typeof chat !== "object" || Array.isArray(chat)) return undefined;
|
|
186
|
+
const chatId = (chat as Record<string, unknown>).id;
|
|
187
|
+
if (typeof chatId !== "number") return undefined;
|
|
188
|
+
const threadId = messageRecord.message_thread_id;
|
|
189
|
+
return {
|
|
190
|
+
chatId,
|
|
191
|
+
...(typeof threadId === "number" ? { threadId } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sameTarget(
|
|
196
|
+
left: { chatId: number; threadId?: number },
|
|
197
|
+
right: { chatId: number; threadId?: number },
|
|
198
|
+
): boolean {
|
|
199
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function resolveTelegramWorkspaceAcceptedWorkProtection(input: {
|
|
203
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
204
|
+
localAcceptedTargets: readonly { chatId: number; threadId?: number }[];
|
|
205
|
+
journalSources: readonly TelegramWorkspaceJournalProtectionSource[];
|
|
206
|
+
sourcesComplete: boolean;
|
|
207
|
+
}): TelegramWorkspaceProtectionState {
|
|
208
|
+
if (input.localAcceptedTargets.some((target) => sameTarget(target, input.binding.target))) {
|
|
209
|
+
return "protected";
|
|
210
|
+
}
|
|
211
|
+
let unknown = !input.sourcesComplete;
|
|
212
|
+
for (const source of input.journalSources) {
|
|
213
|
+
const relevant = source.scope.kind !== "binding" ||
|
|
214
|
+
source.scope.bindingKey === input.binding.bindingKey;
|
|
215
|
+
if (!relevant) continue;
|
|
216
|
+
if (source.kind === "unknown") {
|
|
217
|
+
unknown = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (source.scope.kind === "binding" && source.entries.length > 0) {
|
|
221
|
+
return "protected";
|
|
222
|
+
}
|
|
223
|
+
for (const entry of source.entries) {
|
|
224
|
+
const target = getJournalUpdateTarget(entry.update);
|
|
225
|
+
if (!target) {
|
|
226
|
+
unknown = true;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (sameTarget(target, input.binding.target)) return "protected";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return unknown ? "unknown" : "clear";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type TelegramWorkspaceJournalPruneResult =
|
|
236
|
+
| {
|
|
237
|
+
kind: "committed";
|
|
238
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
239
|
+
removedKeys: string[];
|
|
240
|
+
}
|
|
241
|
+
| {
|
|
242
|
+
kind: "blocked";
|
|
243
|
+
reason: "incomplete-evidence" | "writer-not-quiescent" | "state-changed";
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export async function pruneTelegramWorkspaceJournalEvidence(input: {
|
|
247
|
+
store: Pick<
|
|
248
|
+
TelegramTopicTargetStore,
|
|
249
|
+
"commitWorkspaceJournalEvidence" | "persist"
|
|
250
|
+
>;
|
|
251
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
252
|
+
capture: TelegramWorkspaceJournalProtectionCapture;
|
|
253
|
+
getJournalWriterProtection: (
|
|
254
|
+
journalBindingKey: string,
|
|
255
|
+
) => TelegramWorkspaceProtectionState;
|
|
256
|
+
getLeaderEpoch: () => number | string | undefined;
|
|
257
|
+
getProfileKey: () => string;
|
|
258
|
+
admission: Pick<
|
|
259
|
+
TelegramWorkspaceAdmissionLedger,
|
|
260
|
+
"acquireAdmission" | "releaseAdmission"
|
|
261
|
+
>;
|
|
262
|
+
isCurrent?: () => boolean;
|
|
263
|
+
onAdmissionReleaseError?: (error: unknown) => void;
|
|
264
|
+
}): Promise<TelegramWorkspaceJournalPruneResult> {
|
|
265
|
+
const operation = async (): Promise<TelegramWorkspaceJournalPruneResult> => {
|
|
266
|
+
const leaderEpoch = input.getLeaderEpoch();
|
|
267
|
+
const profileKey = input.getProfileKey();
|
|
268
|
+
const isCurrent = () =>
|
|
269
|
+
leaderEpoch !== undefined &&
|
|
270
|
+
input.getLeaderEpoch() === leaderEpoch &&
|
|
271
|
+
input.getProfileKey() === profileKey &&
|
|
272
|
+
input.isCurrent?.() !== false;
|
|
273
|
+
if (!isCurrent()) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Telegram Workspace journal pruning requires current leader authority.",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const shared = input.capture.sources.filter(
|
|
279
|
+
(source) => source.scope.kind === "shared",
|
|
280
|
+
);
|
|
281
|
+
const bindingSources = input.capture.sources.filter(
|
|
282
|
+
(source) =>
|
|
283
|
+
source.scope.kind === "binding" &&
|
|
284
|
+
source.scope.bindingKey === input.binding.bindingKey,
|
|
285
|
+
);
|
|
286
|
+
const sourceByKey = new Map(
|
|
287
|
+
bindingSources.flatMap((source) =>
|
|
288
|
+
source.scope.kind === "binding"
|
|
289
|
+
? [[source.scope.journalBindingKey, source] as const]
|
|
290
|
+
: [],
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
if (
|
|
294
|
+
!input.capture.complete ||
|
|
295
|
+
shared.length !== 1 ||
|
|
296
|
+
shared[0]?.kind !== "available" ||
|
|
297
|
+
bindingSources.length !==
|
|
298
|
+
(input.binding.journalBindingKeys ?? []).length ||
|
|
299
|
+
sourceByKey.size !== (input.binding.journalBindingKeys ?? []).length ||
|
|
300
|
+
Array.from(sourceByKey.values()).some(
|
|
301
|
+
(source) => source.kind !== "available",
|
|
302
|
+
)
|
|
303
|
+
) {
|
|
304
|
+
return { kind: "blocked", reason: "incomplete-evidence" };
|
|
305
|
+
}
|
|
306
|
+
const emptyKeys = (input.binding.journalBindingKeys ?? []).filter((key) => {
|
|
307
|
+
const source = sourceByKey.get(key);
|
|
308
|
+
return source?.kind === "available" && source.entries.length === 0;
|
|
309
|
+
});
|
|
310
|
+
if (
|
|
311
|
+
emptyKeys.some(
|
|
312
|
+
(key) => input.getJournalWriterProtection(key) !== "clear",
|
|
313
|
+
)
|
|
314
|
+
) {
|
|
315
|
+
return { kind: "blocked", reason: "writer-not-quiescent" };
|
|
316
|
+
}
|
|
317
|
+
const retainedKeys = (input.binding.journalBindingKeys ?? []).filter(
|
|
318
|
+
(key) => !emptyKeys.includes(key),
|
|
319
|
+
);
|
|
320
|
+
if (!isCurrent()) {
|
|
321
|
+
throw new Error("Telegram Workspace journal pruning lost leader authority.");
|
|
322
|
+
}
|
|
323
|
+
const binding = input.store.commitWorkspaceJournalEvidence(
|
|
324
|
+
input.binding,
|
|
325
|
+
retainedKeys,
|
|
326
|
+
input.binding.journalBindingsComplete === true,
|
|
327
|
+
);
|
|
328
|
+
if (!binding) return { kind: "blocked", reason: "state-changed" };
|
|
329
|
+
const removedKeys = (input.binding.journalBindingKeys ?? []).filter(
|
|
330
|
+
(key) => !retainedKeys.includes(key),
|
|
331
|
+
);
|
|
332
|
+
if (removedKeys.length > 0) await input.store.persist();
|
|
333
|
+
if (!isCurrent()) {
|
|
334
|
+
throw new Error("Telegram Workspace journal pruning lost leader authority.");
|
|
335
|
+
}
|
|
336
|
+
return { kind: "committed", binding, removedKeys };
|
|
337
|
+
};
|
|
338
|
+
return runWithTelegramWorkspaceAdmissionsAsync({
|
|
339
|
+
ledger: input.admission,
|
|
340
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
341
|
+
operationKind: "workspace.prune-journal-evidence",
|
|
342
|
+
scopes: [{ kind: "target", target: input.binding.target }],
|
|
343
|
+
operation,
|
|
344
|
+
onReleaseError(error) {
|
|
345
|
+
input.onAdmissionReleaseError?.(error);
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function captureTelegramWorkspaceExternalProtection(input: {
|
|
351
|
+
binding: TelegramWorkspaceThreadBinding;
|
|
352
|
+
getLiveOwnerProtection: (
|
|
353
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
354
|
+
) => TelegramWorkspaceProtectionState;
|
|
355
|
+
getLocalAcceptedTargets: (
|
|
356
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
357
|
+
) => { targets: readonly TelegramTarget[]; complete: boolean };
|
|
358
|
+
captureJournalSources: (
|
|
359
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
360
|
+
) => TelegramWorkspaceJournalProtectionCapture;
|
|
361
|
+
getDeliveryAuthorityProtection?: (
|
|
362
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
363
|
+
) => TelegramWorkspaceProtectionState;
|
|
364
|
+
}): TelegramWorkspaceExternalProtectionEvidence {
|
|
365
|
+
let liveOwner: TelegramWorkspaceProtectionState = "unknown";
|
|
366
|
+
let acceptedWork: TelegramWorkspaceProtectionState = "unknown";
|
|
367
|
+
let deliveryAuthority: TelegramWorkspaceProtectionState = "unknown";
|
|
368
|
+
try {
|
|
369
|
+
liveOwner = input.getLiveOwnerProtection(input.binding);
|
|
370
|
+
} catch {
|
|
371
|
+
// Unavailable registry/process evidence must not clear a binding.
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const journals = input.captureJournalSources(input.binding);
|
|
375
|
+
const local = input.getLocalAcceptedTargets(input.binding);
|
|
376
|
+
acceptedWork = resolveTelegramWorkspaceAcceptedWorkProtection({
|
|
377
|
+
binding: input.binding,
|
|
378
|
+
localAcceptedTargets: local.targets,
|
|
379
|
+
journalSources: journals.sources,
|
|
380
|
+
sourcesComplete: journals.complete && local.complete,
|
|
381
|
+
});
|
|
382
|
+
} catch {
|
|
383
|
+
// Unavailable queue or journal evidence must not clear accepted work.
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
deliveryAuthority = input.getDeliveryAuthorityProtection?.(input.binding) ?? "unknown";
|
|
387
|
+
} catch {
|
|
388
|
+
// Unavailable delivery evidence must not clear a binding.
|
|
389
|
+
}
|
|
390
|
+
return { liveOwner, acceptedWork, deliveryAuthority };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function createTelegramWorkspaceExternalProtectionCapture(deps: {
|
|
394
|
+
listFollowers: () => readonly { target?: TelegramTarget }[];
|
|
395
|
+
getActiveTurnTarget: () => TelegramTarget | undefined;
|
|
396
|
+
getQueuedItems: () => readonly { chatId: number; target?: TelegramTarget }[];
|
|
397
|
+
resolveLeaderJournal: () => TelegramWorkspaceJournalReader | undefined;
|
|
398
|
+
createFollowerJournalResolver: (
|
|
399
|
+
journalBindingKey: string,
|
|
400
|
+
) => () => TelegramWorkspaceJournalReader | undefined;
|
|
401
|
+
discoverFollowerJournals?: () => { paths: readonly string[]; complete: boolean };
|
|
402
|
+
createJournalPathResolver?: (
|
|
403
|
+
path: string,
|
|
404
|
+
) => () => TelegramWorkspaceJournalReader | undefined;
|
|
405
|
+
withJournalReference?: <T>(binding: TelegramWorkspaceJournalReader,
|
|
406
|
+
operation: () => T) => T;
|
|
407
|
+
getJournalWriterProtection?: (
|
|
408
|
+
journalBindingKey: string,
|
|
409
|
+
) => TelegramWorkspaceProtectionState;
|
|
410
|
+
getDeliveryAuthorityProtection?: (
|
|
411
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
412
|
+
) => TelegramWorkspaceProtectionState;
|
|
413
|
+
}): (
|
|
414
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
415
|
+
) => TelegramWorkspaceExternalProtectionEvidence {
|
|
416
|
+
return function (binding) {
|
|
417
|
+
return captureTelegramWorkspaceExternalProtection({
|
|
418
|
+
binding,
|
|
419
|
+
getLiveOwnerProtection(candidate) {
|
|
420
|
+
if (deps.listFollowers().some((follower) =>
|
|
421
|
+
!!follower.target && sameTarget(follower.target, candidate.target),
|
|
422
|
+
)) return "protected";
|
|
423
|
+
if (!deps.getJournalWriterProtection) return "unknown";
|
|
424
|
+
let unknown = candidate.journalBindingsComplete !== true;
|
|
425
|
+
for (const journalBindingKey of candidate.journalBindingKeys ?? []) {
|
|
426
|
+
const protection = deps.getJournalWriterProtection(journalBindingKey);
|
|
427
|
+
if (protection === "protected") return "protected";
|
|
428
|
+
if (protection === "unknown") unknown = true;
|
|
429
|
+
}
|
|
430
|
+
return unknown ? "unknown" : "clear";
|
|
431
|
+
},
|
|
432
|
+
getLocalAcceptedTargets(candidate) {
|
|
433
|
+
const items = deps.getQueuedItems();
|
|
434
|
+
const targets: TelegramTarget[] = [];
|
|
435
|
+
const activeTarget = deps.getActiveTurnTarget();
|
|
436
|
+
if (activeTarget) targets.push(activeTarget);
|
|
437
|
+
let complete = true;
|
|
438
|
+
for (const item of items) {
|
|
439
|
+
if (item.target) targets.push(item.target);
|
|
440
|
+
else if (item.chatId === candidate.target.chatId) complete = false;
|
|
441
|
+
}
|
|
442
|
+
return { targets, complete };
|
|
443
|
+
},
|
|
444
|
+
captureJournalSources(candidate) {
|
|
445
|
+
const discovery = candidate.journalBindingsComplete === true
|
|
446
|
+
? undefined
|
|
447
|
+
: deps.discoverFollowerJournals?.();
|
|
448
|
+
return captureTelegramWorkspaceJournalProtectionSources({
|
|
449
|
+
binding: candidate,
|
|
450
|
+
resolveLeader: deps.resolveLeaderJournal,
|
|
451
|
+
createFollowerResolver: deps.createFollowerJournalResolver,
|
|
452
|
+
...(deps.withJournalReference
|
|
453
|
+
? { withJournalReference: deps.withJournalReference } : {}),
|
|
454
|
+
...(discovery && deps.createJournalPathResolver
|
|
455
|
+
? { discovery: {
|
|
456
|
+
...discovery,
|
|
457
|
+
createResolver: deps.createJournalPathResolver,
|
|
458
|
+
} }
|
|
459
|
+
: {}),
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
...(deps.getDeliveryAuthorityProtection
|
|
463
|
+
? { getDeliveryAuthorityProtection: deps.getDeliveryAuthorityProtection }
|
|
464
|
+
: {}),
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export type TelegramWorkspaceRetirementAdoption =
|
|
470
|
+
| { kind: "adopted"; intent: TelegramWorkspaceRetirementIntent }
|
|
471
|
+
| {
|
|
472
|
+
kind: "blocked";
|
|
473
|
+
reason:
|
|
474
|
+
| "intent-conflict"
|
|
475
|
+
| "profile-changed"
|
|
476
|
+
| "binding-changed"
|
|
477
|
+
| "protection-changed"
|
|
478
|
+
| "commit-rejected";
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export async function adoptTelegramWorkspaceRetirementIntent(input: {
|
|
482
|
+
store: Pick<
|
|
483
|
+
TelegramTopicTargetStore,
|
|
484
|
+
| "captureWorkspaceSlotOccupancy"
|
|
485
|
+
| "listWorkspaceBindings"
|
|
486
|
+
| "listWorkspaceRetirementIntents"
|
|
487
|
+
| "replaceWorkspaceRetirementIntent"
|
|
488
|
+
>;
|
|
489
|
+
intent: TelegramWorkspaceRetirementIntent;
|
|
490
|
+
getExternalProtection: (
|
|
491
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
492
|
+
) => TelegramWorkspaceExternalProtectionEvidence;
|
|
493
|
+
getLeaderEpoch: () => number | string | undefined;
|
|
494
|
+
getProfileKey: () => string;
|
|
495
|
+
isCurrent?: () => boolean;
|
|
496
|
+
runExclusive: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
497
|
+
}): Promise<TelegramWorkspaceRetirementAdoption> {
|
|
498
|
+
return input.runExclusive(async () => {
|
|
499
|
+
const leaderEpoch = input.getLeaderEpoch();
|
|
500
|
+
const profileKey = input.getProfileKey();
|
|
501
|
+
const isCurrent = () =>
|
|
502
|
+
leaderEpoch !== undefined && input.getLeaderEpoch() === leaderEpoch &&
|
|
503
|
+
input.getProfileKey() === profileKey && input.isCurrent?.() !== false;
|
|
504
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement adoption requires current leader authority.");
|
|
505
|
+
const intents = input.store.listWorkspaceRetirementIntents();
|
|
506
|
+
if (intents.length !== 1 || !isDeepStrictEqual(intents[0], input.intent)) {
|
|
507
|
+
return { kind: "blocked", reason: "intent-conflict" };
|
|
508
|
+
}
|
|
509
|
+
if (input.intent.profileKey !== profileKey) {
|
|
510
|
+
return { kind: "blocked", reason: "profile-changed" };
|
|
511
|
+
}
|
|
512
|
+
const binding = input.store.listWorkspaceBindings().find((candidate) =>
|
|
513
|
+
candidate.bindingKey === input.intent.binding.bindingKey,
|
|
514
|
+
);
|
|
515
|
+
if (!binding || !isDeepStrictEqual(binding, input.intent.binding)) {
|
|
516
|
+
return { kind: "blocked", reason: "binding-changed" };
|
|
517
|
+
}
|
|
518
|
+
const eligible = input.store.captureWorkspaceSlotOccupancy(
|
|
519
|
+
input.getExternalProtection,
|
|
520
|
+
{ expectedRetirement: input.intent },
|
|
521
|
+
).bindings.find((candidate) =>
|
|
522
|
+
candidate.bindingKey === binding.bindingKey,
|
|
523
|
+
)?.protection === "eligible";
|
|
524
|
+
if (!eligible) return { kind: "blocked", reason: "protection-changed" };
|
|
525
|
+
const replacement = { ...input.intent, leaderEpoch: leaderEpoch! };
|
|
526
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement adoption lost leader authority.");
|
|
527
|
+
if (!await input.store.replaceWorkspaceRetirementIntent(
|
|
528
|
+
input.intent,
|
|
529
|
+
replacement,
|
|
530
|
+
isCurrent,
|
|
531
|
+
)) return { kind: "blocked", reason: "commit-rejected" };
|
|
532
|
+
return { kind: "adopted", intent: replacement };
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isTelegramWorkspaceDeletionConfirmedAbsent(error: unknown): boolean {
|
|
537
|
+
if (!(error instanceof Error)) return false;
|
|
538
|
+
const status = "status" in error && typeof error.status === "number"
|
|
539
|
+
? error.status : undefined;
|
|
540
|
+
if (status !== undefined && status !== 400) return false;
|
|
541
|
+
const message = error.message.toLowerCase();
|
|
542
|
+
return message.includes("topic_id_invalid") ||
|
|
543
|
+
message.includes("message thread not found") ||
|
|
544
|
+
message.includes("thread not found") ||
|
|
545
|
+
message.includes("topic not found") ||
|
|
546
|
+
message.includes("topic deleted");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export type TelegramWorkspaceRetirementExecution =
|
|
550
|
+
| { kind: "retired"; bindingKey: string; slot: string }
|
|
551
|
+
| {
|
|
552
|
+
kind: "retained";
|
|
553
|
+
reason:
|
|
554
|
+
| "stale-intent"
|
|
555
|
+
| "protection-changed"
|
|
556
|
+
| "admission-active"
|
|
557
|
+
| "fence-conflict"
|
|
558
|
+
| "delete-unconfirmed"
|
|
559
|
+
| "authority-changed"
|
|
560
|
+
| "commit-rejected"
|
|
561
|
+
| "fence-release-unconfirmed";
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
export type TelegramWorkspaceRetirementAbsence =
|
|
565
|
+
| "absent"
|
|
566
|
+
| "present"
|
|
567
|
+
| "unknown";
|
|
568
|
+
|
|
569
|
+
function matchesTelegramWorkspaceRetirementFence(
|
|
570
|
+
fence: TelegramWorkspaceRetirementFence,
|
|
571
|
+
intent: TelegramWorkspaceRetirementIntent,
|
|
572
|
+
): boolean {
|
|
573
|
+
return (
|
|
574
|
+
fence.retirementIntentId === intent.id &&
|
|
575
|
+
fence.profileKey === intent.profileKey &&
|
|
576
|
+
fence.bindingKey === intent.binding.bindingKey &&
|
|
577
|
+
fence.slot === intent.binding.slot &&
|
|
578
|
+
fence.target.chatId === intent.binding.target.chatId &&
|
|
579
|
+
fence.target.threadId === intent.binding.target.threadId &&
|
|
580
|
+
fence.retirementRequestedAtMs === intent.requestedAtMs
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function executeTelegramWorkspaceRetirement(input: {
|
|
585
|
+
store: Pick<
|
|
586
|
+
TelegramTopicTargetStore,
|
|
587
|
+
| "captureWorkspaceSlotOccupancy"
|
|
588
|
+
| "listWorkspaceBindings"
|
|
589
|
+
| "listWorkspaceRetirementIntents"
|
|
590
|
+
| "commitWorkspaceRetirement"
|
|
591
|
+
>;
|
|
592
|
+
admission: Pick<
|
|
593
|
+
TelegramWorkspaceAdmissionLedger,
|
|
594
|
+
| "getOwner"
|
|
595
|
+
| "read"
|
|
596
|
+
| "acquireRetirementFence"
|
|
597
|
+
| "adoptRetirementFence"
|
|
598
|
+
| "issueDeletionPermit"
|
|
599
|
+
| "confirmRetirementAbsence"
|
|
600
|
+
| "releaseUnissuedRetirementFence"
|
|
601
|
+
| "completeRetirementFence"
|
|
602
|
+
>;
|
|
603
|
+
intent: TelegramWorkspaceRetirementIntent;
|
|
604
|
+
getExternalProtection: (
|
|
605
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
606
|
+
) => TelegramWorkspaceExternalProtectionEvidence;
|
|
607
|
+
getLeaderEpoch: () => number | string | undefined;
|
|
608
|
+
getProfileKey: () => string;
|
|
609
|
+
isCurrent?: () => boolean;
|
|
610
|
+
runExclusive: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
611
|
+
deleteForumTopic: (
|
|
612
|
+
permit: TelegramWorkspaceDeletionPermit,
|
|
613
|
+
body: { chat_id: number; message_thread_id: number },
|
|
614
|
+
options: { maxAttempts: 1 },
|
|
615
|
+
) => Promise<unknown>;
|
|
616
|
+
confirmTargetAbsent?: (
|
|
617
|
+
target: TelegramTarget & { threadId: number },
|
|
618
|
+
) => Promise<TelegramWorkspaceRetirementAbsence>;
|
|
619
|
+
}): Promise<TelegramWorkspaceRetirementExecution> {
|
|
620
|
+
return input.runExclusive(async () => {
|
|
621
|
+
const isCurrent = () =>
|
|
622
|
+
input.getLeaderEpoch() === input.intent.leaderEpoch &&
|
|
623
|
+
input.getProfileKey() === input.intent.profileKey &&
|
|
624
|
+
input.isCurrent?.() !== false;
|
|
625
|
+
if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
|
|
626
|
+
if (!input.intent.binding.slot || !/^[A-Z]$/u.test(input.intent.binding.slot)) {
|
|
627
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
628
|
+
}
|
|
629
|
+
const owner = input.admission.getOwner();
|
|
630
|
+
const storedFence = input.admission.read().fence;
|
|
631
|
+
if (storedFence && !isTelegramWorkspaceRetirementFence(storedFence)) {
|
|
632
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
633
|
+
}
|
|
634
|
+
let fence: TelegramWorkspaceRetirementFence | undefined = storedFence;
|
|
635
|
+
if (fence && !matchesTelegramWorkspaceRetirementFence(fence, input.intent)) {
|
|
636
|
+
return { kind: "retained", reason: "fence-conflict" };
|
|
637
|
+
}
|
|
638
|
+
if (
|
|
639
|
+
fence &&
|
|
640
|
+
(fence.leaderEpoch !== input.intent.leaderEpoch ||
|
|
641
|
+
fence.owner.processId !== owner.processId ||
|
|
642
|
+
fence.owner.processBirthId !== owner.processBirthId)
|
|
643
|
+
) {
|
|
644
|
+
fence = input.admission.adoptRetirementFence(fence, {
|
|
645
|
+
owner,
|
|
646
|
+
leaderEpoch: input.intent.leaderEpoch,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
const intent = input.store.listWorkspaceRetirementIntents().find((candidate) =>
|
|
650
|
+
isDeepStrictEqual(candidate, input.intent),
|
|
651
|
+
);
|
|
652
|
+
const binding = input.store.listWorkspaceBindings().find((candidate) =>
|
|
653
|
+
candidate.bindingKey === input.intent.binding.bindingKey,
|
|
654
|
+
);
|
|
655
|
+
if (!intent || !binding || !isDeepStrictEqual(binding, input.intent.binding)) {
|
|
656
|
+
if (!intent && !binding && fence?.phase === "commit-ready") {
|
|
657
|
+
try {
|
|
658
|
+
input.admission.completeRetirementFence(fence);
|
|
659
|
+
} catch {
|
|
660
|
+
if (input.admission.read().fence) {
|
|
661
|
+
return { kind: "retained", reason: "fence-release-unconfirmed" };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
kind: "retired",
|
|
666
|
+
bindingKey: input.intent.binding.bindingKey,
|
|
667
|
+
slot: input.intent.binding.slot,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return { kind: "retained", reason: "stale-intent" };
|
|
671
|
+
}
|
|
672
|
+
const eligible = () => input.store.captureWorkspaceSlotOccupancy(
|
|
673
|
+
input.getExternalProtection,
|
|
674
|
+
{ expectedRetirement: input.intent },
|
|
675
|
+
).bindings.find((candidate) =>
|
|
676
|
+
candidate.bindingKey === input.intent.binding.bindingKey,
|
|
677
|
+
)?.protection === "eligible";
|
|
678
|
+
if (!eligible()) return { kind: "retained", reason: "protection-changed" };
|
|
679
|
+
if (!fence) {
|
|
680
|
+
const acquired = input.admission.acquireRetirementFence({
|
|
681
|
+
operationId: `workspace-retirement:${input.intent.id}`,
|
|
682
|
+
retirementIntentId: input.intent.id,
|
|
683
|
+
bindingKey: binding.bindingKey,
|
|
684
|
+
slot: binding.slot!,
|
|
685
|
+
target: binding.target,
|
|
686
|
+
leaderEpoch: input.intent.leaderEpoch,
|
|
687
|
+
retirementRequestedAtMs: input.intent.requestedAtMs,
|
|
688
|
+
});
|
|
689
|
+
if (acquired.kind === "blocked") {
|
|
690
|
+
return {
|
|
691
|
+
kind: "retained",
|
|
692
|
+
reason:
|
|
693
|
+
acquired.reason === "admission-active"
|
|
694
|
+
? "admission-active"
|
|
695
|
+
: "fence-conflict",
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
fence = acquired.fence;
|
|
699
|
+
}
|
|
700
|
+
if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
|
|
701
|
+
if (fence.phase === "fenced" && !eligible()) {
|
|
702
|
+
input.admission.releaseUnissuedRetirementFence(fence);
|
|
703
|
+
return { kind: "retained", reason: "protection-changed" };
|
|
704
|
+
}
|
|
705
|
+
if (fence.phase === "fenced") {
|
|
706
|
+
const issued = input.admission.issueDeletionPermit(fence);
|
|
707
|
+
if (issued.kind !== "issued") {
|
|
708
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
709
|
+
}
|
|
710
|
+
fence = issued.fence;
|
|
711
|
+
try {
|
|
712
|
+
await input.deleteForumTopic(
|
|
713
|
+
issued.permit,
|
|
714
|
+
{
|
|
715
|
+
chat_id: binding.target.chatId,
|
|
716
|
+
message_thread_id: binding.target.threadId,
|
|
717
|
+
},
|
|
718
|
+
{ maxAttempts: 1 },
|
|
719
|
+
);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (!isTelegramWorkspaceDeletionConfirmedAbsent(error)) {
|
|
722
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
fence = input.admission.confirmRetirementAbsence(fence);
|
|
726
|
+
} else if (fence.phase === "deletion-issued") {
|
|
727
|
+
const absence = await input.confirmTargetAbsent?.(binding.target);
|
|
728
|
+
if (absence !== "absent") {
|
|
729
|
+
return { kind: "retained", reason: "delete-unconfirmed" };
|
|
730
|
+
}
|
|
731
|
+
fence = input.admission.confirmRetirementAbsence(fence);
|
|
732
|
+
}
|
|
733
|
+
if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
|
|
734
|
+
if (!eligible()) return { kind: "retained", reason: "protection-changed" };
|
|
735
|
+
if (!await input.store.commitWorkspaceRetirement(input.intent, isCurrent)) {
|
|
736
|
+
return { kind: "retained", reason: "commit-rejected" };
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
input.admission.completeRetirementFence(fence);
|
|
740
|
+
} catch {
|
|
741
|
+
if (input.admission.read().fence) {
|
|
742
|
+
return { kind: "retained", reason: "fence-release-unconfirmed" };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
kind: "retired",
|
|
747
|
+
bindingKey: binding.bindingKey,
|
|
748
|
+
slot: binding.slot!,
|
|
749
|
+
};
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export type TelegramWorkspaceRetirementPreparation =
|
|
754
|
+
| { kind: "ready"; intent: TelegramWorkspaceRetirementIntent }
|
|
755
|
+
| { kind: "not-needed"; reason: "free-capacity" }
|
|
756
|
+
| {
|
|
757
|
+
kind: "blocked";
|
|
758
|
+
reason:
|
|
759
|
+
| "invalid-state"
|
|
760
|
+
| "protected-capacity"
|
|
761
|
+
| "state-changed"
|
|
762
|
+
| "existing-intent-conflict"
|
|
763
|
+
| "stale-intent";
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
export interface TelegramWorkspaceRetirementPreparationDeps {
|
|
767
|
+
store: Pick<
|
|
768
|
+
TelegramTopicTargetStore,
|
|
769
|
+
| "captureWorkspaceSlotOccupancy"
|
|
770
|
+
| "listWorkspaceBindings"
|
|
771
|
+
| "listWorkspaceRetirementIntents"
|
|
772
|
+
| "upsertWorkspaceRetirementIntent"
|
|
773
|
+
| "removeWorkspaceRetirementIntent"
|
|
774
|
+
| "persist"
|
|
775
|
+
>;
|
|
776
|
+
getExternalProtection: (
|
|
777
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
778
|
+
) => TelegramWorkspaceExternalProtectionEvidence;
|
|
779
|
+
getLeaderEpoch: () => number | string | undefined;
|
|
780
|
+
getProfileKey: () => string;
|
|
781
|
+
isCurrent?: () => boolean;
|
|
782
|
+
getNowMs?: () => number;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function findEligibleCandidate(
|
|
786
|
+
occupancy: readonly TelegramWorkspaceSlotOccupancy[],
|
|
787
|
+
reservedSlots: readonly string[],
|
|
788
|
+
nowMs: number,
|
|
789
|
+
):
|
|
790
|
+
| { kind: "candidate"; candidate: TelegramWorkspaceSlotOccupancy }
|
|
791
|
+
| Exclude<TelegramWorkspaceRetirementPreparation, { kind: "ready" }> {
|
|
792
|
+
const allocation = planTelegramWorkspaceSlotAllocation({
|
|
793
|
+
bindings: occupancy,
|
|
794
|
+
reservedSlots,
|
|
795
|
+
nowMs,
|
|
796
|
+
});
|
|
797
|
+
if (allocation.kind === "free") {
|
|
798
|
+
return { kind: "not-needed", reason: "free-capacity" };
|
|
799
|
+
}
|
|
800
|
+
if (allocation.kind === "blocked") return allocation;
|
|
801
|
+
return { kind: "candidate", candidate: allocation.candidate };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export async function prepareTelegramWorkspaceRetirement(
|
|
805
|
+
deps: TelegramWorkspaceRetirementPreparationDeps,
|
|
806
|
+
): Promise<TelegramWorkspaceRetirementPreparation> {
|
|
807
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
808
|
+
const leaderEpoch = deps.getLeaderEpoch();
|
|
809
|
+
const profileKey = deps.getProfileKey();
|
|
810
|
+
const isCurrent = () =>
|
|
811
|
+
leaderEpoch !== undefined &&
|
|
812
|
+
deps.getLeaderEpoch() === leaderEpoch &&
|
|
813
|
+
deps.getProfileKey() === profileKey &&
|
|
814
|
+
deps.isCurrent?.() !== false;
|
|
815
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement requires current leader authority.");
|
|
816
|
+
|
|
817
|
+
const existing = deps.store.listWorkspaceRetirementIntents();
|
|
818
|
+
if (existing.length > 1) {
|
|
819
|
+
return { kind: "blocked", reason: "existing-intent-conflict" };
|
|
820
|
+
}
|
|
821
|
+
if (existing.length === 1) {
|
|
822
|
+
const intent = existing[0]!;
|
|
823
|
+
const binding = deps.store.listWorkspaceBindings().find((candidate) =>
|
|
824
|
+
candidate.bindingKey === intent.binding.bindingKey,
|
|
825
|
+
);
|
|
826
|
+
const snapshot = deps.store.captureWorkspaceSlotOccupancy(
|
|
827
|
+
deps.getExternalProtection,
|
|
828
|
+
{ expectedRetirement: intent },
|
|
829
|
+
);
|
|
830
|
+
const candidate = snapshot.bindings.find((entry) =>
|
|
831
|
+
entry.bindingKey === intent.binding.bindingKey,
|
|
832
|
+
);
|
|
833
|
+
if (
|
|
834
|
+
intent.profileKey !== profileKey ||
|
|
835
|
+
intent.leaderEpoch !== leaderEpoch ||
|
|
836
|
+
!binding || !isDeepStrictEqual(binding, intent.binding) ||
|
|
837
|
+
candidate?.protection !== "eligible"
|
|
838
|
+
) {
|
|
839
|
+
return { kind: "blocked", reason: "stale-intent" };
|
|
840
|
+
}
|
|
841
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
842
|
+
await deps.store.persist();
|
|
843
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
844
|
+
return { kind: "ready", intent };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const nowMs = getNowMs();
|
|
848
|
+
const snapshot = deps.store.captureWorkspaceSlotOccupancy(
|
|
849
|
+
deps.getExternalProtection,
|
|
850
|
+
);
|
|
851
|
+
const selection = findEligibleCandidate(
|
|
852
|
+
snapshot.bindings,
|
|
853
|
+
snapshot.reservedSlots,
|
|
854
|
+
nowMs,
|
|
855
|
+
);
|
|
856
|
+
if (selection.kind !== "candidate") return selection;
|
|
857
|
+
const selected = selection.candidate;
|
|
858
|
+
const binding = deps.store.listWorkspaceBindings().find((candidate) =>
|
|
859
|
+
candidate.bindingKey === selected.bindingKey,
|
|
860
|
+
);
|
|
861
|
+
if (
|
|
862
|
+
!binding?.slot ||
|
|
863
|
+
binding.slot.toLowerCase() !== selected.slot ||
|
|
864
|
+
binding.inactiveSinceMs !== selected.inactiveSinceMs
|
|
865
|
+
) {
|
|
866
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
867
|
+
}
|
|
868
|
+
const intent: TelegramWorkspaceRetirementIntent = {
|
|
869
|
+
id: `workspace-retirement:pressure:${binding.bindingKey}:${binding.slot}:${binding.inactiveSinceMs}`,
|
|
870
|
+
reason: "pressure",
|
|
871
|
+
profileKey,
|
|
872
|
+
binding,
|
|
873
|
+
leaderEpoch: leaderEpoch!,
|
|
874
|
+
requestedAtMs: nowMs,
|
|
875
|
+
};
|
|
876
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
877
|
+
if (!deps.store.upsertWorkspaceRetirementIntent(intent)) {
|
|
878
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
879
|
+
}
|
|
880
|
+
const rechecked = deps.store.captureWorkspaceSlotOccupancy(
|
|
881
|
+
deps.getExternalProtection,
|
|
882
|
+
{ expectedRetirement: intent },
|
|
883
|
+
).bindings.find((candidate) => candidate.bindingKey === binding.bindingKey);
|
|
884
|
+
if (rechecked?.protection !== "eligible" || !isCurrent()) {
|
|
885
|
+
deps.store.removeWorkspaceRetirementIntent(intent);
|
|
886
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
887
|
+
return { kind: "blocked", reason: "state-changed" };
|
|
888
|
+
}
|
|
889
|
+
await deps.store.persist();
|
|
890
|
+
if (!isCurrent()) throw new Error("Telegram Workspace retirement lost leader authority.");
|
|
891
|
+
return { kind: "ready", intent };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export type TelegramWorkspaceRetirementLifecycleResult =
|
|
895
|
+
| TelegramWorkspaceRetirementExecution
|
|
896
|
+
| Extract<TelegramWorkspaceRetirementPreparation, { kind: "not-needed" }>
|
|
897
|
+
| {
|
|
898
|
+
kind: "blocked";
|
|
899
|
+
stage: "preparation" | "adoption";
|
|
900
|
+
reason: string;
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
export async function runTelegramWorkspaceRetirementLifecycle(input: {
|
|
904
|
+
store: TelegramWorkspaceRetirementPreparationDeps["store"] & Pick<
|
|
905
|
+
TelegramTopicTargetStore,
|
|
906
|
+
"replaceWorkspaceRetirementIntent" | "commitWorkspaceRetirement"
|
|
907
|
+
>;
|
|
908
|
+
getExternalProtection: (
|
|
909
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
910
|
+
) => TelegramWorkspaceExternalProtectionEvidence;
|
|
911
|
+
getLeaderEpoch: () => number | string | undefined;
|
|
912
|
+
getProfileKey: () => string;
|
|
913
|
+
isCurrent?: () => boolean;
|
|
914
|
+
getNowMs?: () => number;
|
|
915
|
+
runExclusive: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
916
|
+
admission: Parameters<typeof executeTelegramWorkspaceRetirement>[0]["admission"];
|
|
917
|
+
deleteForumTopic: Parameters<
|
|
918
|
+
typeof executeTelegramWorkspaceRetirement
|
|
919
|
+
>[0]["deleteForumTopic"];
|
|
920
|
+
confirmTargetAbsent?: Parameters<
|
|
921
|
+
typeof executeTelegramWorkspaceRetirement
|
|
922
|
+
>[0]["confirmTargetAbsent"];
|
|
923
|
+
}): Promise<TelegramWorkspaceRetirementLifecycleResult> {
|
|
924
|
+
let intent = input.store.listWorkspaceRetirementIntents()[0];
|
|
925
|
+
if (intent) {
|
|
926
|
+
const adoption = await adoptTelegramWorkspaceRetirementIntent({
|
|
927
|
+
store: input.store,
|
|
928
|
+
intent,
|
|
929
|
+
getExternalProtection: input.getExternalProtection,
|
|
930
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
931
|
+
getProfileKey: input.getProfileKey,
|
|
932
|
+
isCurrent: input.isCurrent,
|
|
933
|
+
runExclusive: input.runExclusive,
|
|
934
|
+
});
|
|
935
|
+
if (adoption.kind !== "adopted") {
|
|
936
|
+
return { kind: "blocked", stage: "adoption", reason: adoption.reason };
|
|
937
|
+
}
|
|
938
|
+
intent = adoption.intent;
|
|
939
|
+
} else {
|
|
940
|
+
const preparation = await prepareTelegramWorkspaceRetirement(
|
|
941
|
+
{
|
|
942
|
+
store: input.store,
|
|
943
|
+
getExternalProtection: input.getExternalProtection,
|
|
944
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
945
|
+
getProfileKey: input.getProfileKey,
|
|
946
|
+
isCurrent: input.isCurrent,
|
|
947
|
+
getNowMs: input.getNowMs,
|
|
948
|
+
},
|
|
949
|
+
);
|
|
950
|
+
if (preparation.kind === "not-needed") return preparation;
|
|
951
|
+
if (preparation.kind === "blocked") {
|
|
952
|
+
return { kind: "blocked", stage: "preparation", reason: preparation.reason };
|
|
953
|
+
}
|
|
954
|
+
intent = preparation.intent;
|
|
955
|
+
}
|
|
956
|
+
return executeTelegramWorkspaceRetirement({
|
|
957
|
+
store: input.store,
|
|
958
|
+
intent,
|
|
959
|
+
getExternalProtection: input.getExternalProtection,
|
|
960
|
+
getLeaderEpoch: input.getLeaderEpoch,
|
|
961
|
+
getProfileKey: input.getProfileKey,
|
|
962
|
+
isCurrent: input.isCurrent,
|
|
963
|
+
runExclusive: input.runExclusive,
|
|
964
|
+
admission: input.admission,
|
|
965
|
+
deleteForumTopic: input.deleteForumTopic,
|
|
966
|
+
confirmTargetAbsent: input.confirmTargetAbsent,
|
|
967
|
+
});
|
|
968
|
+
}
|