@llblab/pi-telegram 0.52.2 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -7
- package/BACKLOG.md +1 -18
- package/CHANGELOG.md +15 -0
- package/README.md +7 -11
- package/api/commands.ts +3 -0
- package/dist/api/commands.d.ts +1 -1
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +15 -42
- package/dist/lib/activity.d.ts +9 -0
- package/dist/lib/activity.js +39 -10
- package/dist/lib/agent-messages.js +7 -3
- package/dist/lib/bindings.d.ts +43 -5
- package/dist/lib/bindings.js +163 -20
- package/dist/lib/bus-api.d.ts +12 -0
- package/dist/lib/bus-api.js +182 -79
- package/dist/lib/bus-follower.d.ts +111 -31
- package/dist/lib/bus-follower.js +1324 -359
- package/dist/lib/bus-leader.d.ts +41 -91
- package/dist/lib/bus-leader.js +969 -637
- package/dist/lib/bus-transport.js +16 -9
- package/dist/lib/bus.d.ts +215 -18
- package/dist/lib/bus.js +1048 -327
- package/dist/lib/channel-posts.d.ts +5 -0
- package/dist/lib/channel-posts.js +434 -261
- package/dist/lib/command-templates.d.ts +26 -5
- package/dist/lib/command-templates.js +92 -176
- package/dist/lib/commands.d.ts +168 -82
- package/dist/lib/commands.js +1409 -291
- package/dist/lib/config.d.ts +1 -8
- package/dist/lib/config.js +34 -76
- package/dist/lib/delivery.d.ts +2 -2
- package/dist/lib/delivery.js +16 -16
- package/dist/lib/extension.js +639 -171
- package/dist/lib/generative-app-worker.mjs +19 -7
- package/dist/lib/generative-apps.d.ts +0 -5
- package/dist/lib/generative-apps.js +61 -49
- package/dist/lib/inbound.d.ts +5 -0
- package/dist/lib/inbound.js +35 -59
- package/dist/lib/journal.d.ts +33 -3
- package/dist/lib/journal.js +2063 -782
- package/dist/lib/lifecycle.d.ts +2 -6
- package/dist/lib/lifecycle.js +75 -58
- package/dist/lib/locks.d.ts +29 -3
- package/dist/lib/locks.js +289 -108
- package/dist/lib/logging.js +50 -14
- package/dist/lib/media.d.ts +22 -1
- package/dist/lib/media.js +46 -28
- package/dist/lib/menu-model.d.ts +19 -5
- package/dist/lib/menu-model.js +53 -11
- package/dist/lib/menu-queue.d.ts +5 -4
- package/dist/lib/menu-queue.js +45 -63
- package/dist/lib/menu-settings.d.ts +5 -4
- package/dist/lib/menu-settings.js +114 -78
- package/dist/lib/menu-status.d.ts +1 -1
- package/dist/lib/menu-status.js +8 -25
- package/dist/lib/menu-thinking.d.ts +6 -1
- package/dist/lib/menu-thinking.js +14 -21
- package/dist/lib/menu.d.ts +8 -61
- package/dist/lib/menu.js +92 -164
- package/dist/lib/model.d.ts +1 -1
- package/dist/lib/model.js +2 -16
- package/dist/lib/outbound-attachments.d.ts +6 -27
- package/dist/lib/outbound-attachments.js +45 -38
- package/dist/lib/outbound-buttons.js +22 -16
- package/dist/lib/outbound-markup.d.ts +0 -2
- package/dist/lib/outbound-markup.js +51 -64
- package/dist/lib/outbound-voice.js +6 -3
- package/dist/lib/outbound.d.ts +6 -6
- package/dist/lib/outbound.js +65 -51
- package/dist/lib/ownership.js +6 -2
- package/dist/lib/paths.d.ts +5 -0
- package/dist/lib/paths.js +37 -21
- package/dist/lib/pi.d.ts +2 -0
- package/dist/lib/pi.js +8 -1
- package/dist/lib/polling.d.ts +21 -37
- package/dist/lib/polling.js +59 -50
- package/dist/lib/preview.js +39 -19
- package/dist/lib/process-identity.d.ts +24 -0
- package/dist/lib/process-identity.js +133 -0
- package/dist/lib/prompts.js +2 -7
- package/dist/lib/queue.d.ts +14 -16
- package/dist/lib/queue.js +252 -127
- package/dist/lib/recovery.d.ts +2 -83
- package/dist/lib/recovery.js +20 -274
- package/dist/lib/replies.d.ts +16 -24
- package/dist/lib/replies.js +99 -116
- package/dist/lib/routing.d.ts +120 -7
- package/dist/lib/routing.js +4667 -1152
- package/dist/lib/runtime.d.ts +1 -11
- package/dist/lib/sections.d.ts +4 -29
- package/dist/lib/sections.js +7 -20
- package/dist/lib/skills.js +4 -3
- package/dist/lib/status.js +86 -57
- package/dist/lib/sync.d.ts +1 -3
- package/dist/lib/sync.js +67 -43
- package/dist/lib/target.d.ts +4 -0
- package/dist/lib/target.js +18 -0
- package/dist/lib/telegram-api.d.ts +18 -13
- package/dist/lib/telegram-api.js +148 -53
- package/dist/lib/text-groups.js +4 -39
- package/dist/lib/thread-cleanup-manager.d.ts +6 -27
- package/dist/lib/thread-cleanup-manager.js +369 -186
- package/dist/lib/thread-display.d.ts +1 -2
- package/dist/lib/thread-display.js +58 -38
- package/dist/lib/thread-naming.d.ts +28 -8
- package/dist/lib/thread-naming.js +175 -66
- package/dist/lib/thread-reconciler.d.ts +42 -16
- package/dist/lib/thread-reconciler.js +90 -50
- package/dist/lib/threads.d.ts +82 -20
- package/dist/lib/threads.js +2247 -855
- package/dist/lib/time-injection.js +1 -2
- package/dist/lib/turns.js +1 -19
- package/dist/lib/updates.d.ts +169 -80
- package/dist/lib/updates.js +2418 -641
- package/dist/lib/voice.d.ts +7 -22
- package/dist/lib/voice.js +7 -22
- package/dist/lib/wire.js +1 -1
- package/dist/lib/workspace-admission.d.ts +3 -1
- package/dist/lib/workspace-admission.js +425 -211
- package/dist/lib/workspace-identity.js +8 -13
- package/dist/lib/workspace-retirement.d.ts +1 -1
- package/dist/lib/workspace-retirement.js +328 -140
- package/dist/lib/workspace-slots.js +8 -4
- package/dist/package.json +1 -1
- package/docs/architecture.md +31 -29
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +15 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +29 -1
- package/docs/ui-style.md +25 -8
- package/lib/activity-verbosity.ts +59 -82
- package/lib/activity.ts +146 -102
- package/lib/agent-messages.ts +12 -5
- package/lib/bindings.ts +389 -82
- package/lib/bus-api.ts +263 -102
- package/lib/bus-follower.ts +2440 -601
- package/lib/bus-leader.ts +2209 -1290
- package/lib/bus-transport.ts +46 -12
- package/lib/bus.ts +1673 -444
- package/lib/channel-posts.ts +848 -327
- package/lib/command-templates.ts +152 -265
- package/lib/commands.ts +2398 -555
- package/lib/config.ts +177 -170
- package/lib/delivery.ts +41 -29
- package/lib/extension.ts +1206 -433
- package/lib/generative-app-worker.mjs +19 -7
- package/lib/generative-apps.ts +324 -211
- package/lib/inbound.ts +95 -97
- package/lib/journal.ts +5311 -1763
- package/lib/keyboard.ts +1 -3
- package/lib/lifecycle.ts +128 -78
- package/lib/locks.ts +677 -203
- package/lib/logging.ts +147 -58
- package/lib/media.ts +82 -43
- package/lib/menu-model.ts +85 -18
- package/lib/menu-queue.ts +88 -129
- package/lib/menu-settings.ts +260 -155
- package/lib/menu-status.ts +20 -60
- package/lib/menu-thinking.ts +41 -56
- package/lib/menu.ts +112 -285
- package/lib/model.ts +8 -22
- package/lib/outbound-attachments.ts +156 -125
- package/lib/outbound-buttons.ts +76 -49
- package/lib/outbound-markup.ts +96 -94
- package/lib/outbound-voice.ts +33 -27
- package/lib/outbound.ts +128 -125
- package/lib/ownership.ts +19 -10
- package/lib/paths.ts +148 -49
- package/lib/pi.ts +35 -15
- package/lib/polling.ts +144 -147
- package/lib/preview.ts +93 -40
- package/lib/process-identity.ts +164 -0
- package/lib/prompts.ts +4 -11
- package/lib/queue.ts +516 -301
- package/lib/recovery.ts +50 -454
- package/lib/rendering.ts +1 -3
- package/lib/replies.ts +248 -209
- package/lib/routing.ts +8509 -2186
- package/lib/runtime.ts +1 -12
- package/lib/sections.ts +24 -89
- package/lib/setup.ts +1 -3
- package/lib/skills.ts +9 -4
- package/lib/status.ts +215 -110
- package/lib/sync.ts +448 -361
- package/lib/target.ts +22 -3
- package/lib/telegram-api.ts +302 -127
- package/lib/text-groups.ts +5 -39
- package/lib/thread-cleanup-manager.ts +833 -331
- package/lib/thread-display.ts +213 -97
- package/lib/thread-naming.ts +232 -87
- package/lib/thread-reconciler.ts +155 -72
- package/lib/threads.ts +5318 -1845
- package/lib/time-injection.ts +1 -4
- package/lib/turns.ts +15 -28
- package/lib/updates.ts +5184 -1612
- package/lib/voice.ts +7 -35
- package/lib/wire.ts +1 -1
- package/lib/workspace-admission.ts +996 -416
- package/lib/workspace-identity.ts +14 -17
- package/lib/workspace-retirement.ts +878 -376
- package/lib/workspace-slots.ts +22 -13
- package/package.json +1 -1
- package/scripts/check-downgrade.mjs +0 -472
- package/scripts/measure-workspace.mjs +0 -108
|
@@ -4,21 +4,25 @@
|
|
|
4
4
|
* Owns fail-closed cleanup eligibility projection without persistence or Bot API effects
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync,
|
|
8
|
-
readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { dirname } from "node:path";
|
|
10
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
8
|
|
|
12
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
publishTelegramPrivateFile,
|
|
11
|
+
readTelegramPrivateFile,
|
|
12
|
+
TelegramPrivateFileError,
|
|
13
|
+
withTelegramFileTransaction,
|
|
14
|
+
} from "./locks.ts";
|
|
13
15
|
import { getTelegramJournalPublicationPaths } from "./paths.ts";
|
|
14
16
|
import {
|
|
15
17
|
isWireRecord as isObject,
|
|
16
18
|
hasOnlyWireKeys as onlyKeys,
|
|
17
19
|
isNonNegativeWireInteger as safeTime,
|
|
18
20
|
} from "./wire.ts";
|
|
19
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
TelegramWorkspaceDeletionPermit,
|
|
20
23
|
TelegramWorkspaceDestructiveFence,
|
|
21
|
-
TelegramWorkspaceRetirementFence
|
|
24
|
+
TelegramWorkspaceRetirementFence,
|
|
25
|
+
} from "./workspace-admission.ts";
|
|
22
26
|
|
|
23
27
|
export interface TelegramThreadCleanupBindingSnapshot {
|
|
24
28
|
cwd: string;
|
|
@@ -33,7 +37,8 @@ export interface TelegramThreadCleanupBindingSnapshot {
|
|
|
33
37
|
updatedAtMs: number;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
export type TelegramThreadCleanupProtectionState =
|
|
40
|
+
export type TelegramThreadCleanupProtectionState =
|
|
41
|
+
"clear" | "protected" | "unknown";
|
|
37
42
|
|
|
38
43
|
export interface TelegramThreadCleanupProtectionEvidence {
|
|
39
44
|
bindingKey: string;
|
|
@@ -62,23 +67,33 @@ function targetKey(target: { chatId: number; threadId: number }): string {
|
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
function validTarget(target: { chatId: number; threadId: number }): boolean {
|
|
65
|
-
return
|
|
66
|
-
target.
|
|
70
|
+
return (
|
|
71
|
+
Number.isSafeInteger(target.chatId) &&
|
|
72
|
+
Number.isSafeInteger(target.threadId) &&
|
|
73
|
+
target.threadId > 0
|
|
74
|
+
);
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
function validSessionIdentity(value: {
|
|
70
78
|
sessionId?: unknown;
|
|
71
79
|
sessionKey?: unknown;
|
|
72
80
|
}): boolean {
|
|
73
|
-
if (value.sessionId === undefined && value.sessionKey === undefined)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
if (value.sessionId === undefined && value.sessionKey === undefined)
|
|
82
|
+
return true;
|
|
83
|
+
if (
|
|
84
|
+
typeof value.sessionId !== "string" ||
|
|
85
|
+
!value.sessionId ||
|
|
86
|
+
value.sessionId !== value.sessionId.trim() ||
|
|
87
|
+
Buffer.byteLength(value.sessionId, "utf8") > 256 ||
|
|
88
|
+
typeof value.sessionKey !== "string" ||
|
|
89
|
+
!/^[a-f0-9]{64}$/u.test(value.sessionKey)
|
|
90
|
+
) {
|
|
78
91
|
return false;
|
|
79
92
|
}
|
|
80
|
-
return
|
|
81
|
-
value.
|
|
93
|
+
return (
|
|
94
|
+
createHash("sha256").update(value.sessionId).digest("hex") ===
|
|
95
|
+
value.sessionKey
|
|
96
|
+
);
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
/** Returns no candidates when any identity/evidence ambiguity exists. */
|
|
@@ -96,15 +111,33 @@ export function planTelegramInactiveThreadCleanup(input: {
|
|
|
96
111
|
for (const binding of input.bindings) {
|
|
97
112
|
const key = targetKey(binding.target);
|
|
98
113
|
const slot = binding.slot;
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
if (
|
|
115
|
+
!binding.bindingKey ||
|
|
116
|
+
!binding.cwd ||
|
|
117
|
+
!binding.workspaceKey ||
|
|
118
|
+
!binding.instanceSlot ||
|
|
119
|
+
typeof slot !== "string" ||
|
|
120
|
+
!slot ||
|
|
121
|
+
!validSessionIdentity(binding) ||
|
|
122
|
+
!validTarget(binding.target) ||
|
|
123
|
+
bindingKeys.has(binding.bindingKey) ||
|
|
124
|
+
bindingTargets.has(key)
|
|
125
|
+
)
|
|
126
|
+
return [];
|
|
102
127
|
bindingKeys.add(binding.bindingKey);
|
|
103
128
|
bindingTargets.add(key);
|
|
104
129
|
}
|
|
105
|
-
const evidenceByBinding = new Map<
|
|
130
|
+
const evidenceByBinding = new Map<
|
|
131
|
+
string,
|
|
132
|
+
TelegramThreadCleanupProtectionEvidence
|
|
133
|
+
>();
|
|
106
134
|
for (const evidence of input.protection) {
|
|
107
|
-
if (
|
|
135
|
+
if (
|
|
136
|
+
!evidence.bindingKey ||
|
|
137
|
+
evidenceByBinding.has(evidence.bindingKey) ||
|
|
138
|
+
!validTarget(evidence.target)
|
|
139
|
+
)
|
|
140
|
+
return [];
|
|
108
141
|
evidenceByBinding.set(evidence.bindingKey, evidence);
|
|
109
142
|
}
|
|
110
143
|
const competingTargets = [
|
|
@@ -112,113 +145,180 @@ export function planTelegramInactiveThreadCleanup(input: {
|
|
|
112
145
|
...(input.provisioningTargets ?? []),
|
|
113
146
|
...(input.cleanupTargets ?? []),
|
|
114
147
|
];
|
|
115
|
-
if (competingTargets.some(target => !validTarget(target))) return [];
|
|
148
|
+
if (competingTargets.some((target) => !validTarget(target))) return [];
|
|
116
149
|
const competing = new Set(competingTargets.map(targetKey));
|
|
117
150
|
const candidates: TelegramThreadCleanupCandidate[] = [];
|
|
118
151
|
for (const binding of input.bindings) {
|
|
119
152
|
const inactiveSinceMs = binding.inactiveSinceMs;
|
|
120
153
|
const slot = binding.slot;
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
if (
|
|
155
|
+
typeof slot !== "string" ||
|
|
156
|
+
!slot ||
|
|
157
|
+
!Number.isSafeInteger(inactiveSinceMs) ||
|
|
158
|
+
inactiveSinceMs === undefined ||
|
|
159
|
+
inactiveSinceMs < 0 ||
|
|
160
|
+
!Number.isSafeInteger(binding.updatedAtMs) ||
|
|
161
|
+
binding.updatedAtMs < inactiveSinceMs
|
|
162
|
+
)
|
|
163
|
+
continue;
|
|
124
164
|
const evidence = evidenceByBinding.get(binding.bindingKey);
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
if (
|
|
166
|
+
!evidence ||
|
|
167
|
+
targetKey(evidence.target) !== targetKey(binding.target) ||
|
|
168
|
+
evidence.liveOwner !== "clear" ||
|
|
169
|
+
evidence.acceptedWork !== "clear" ||
|
|
170
|
+
evidence.deliveryAuthority !== "clear" ||
|
|
171
|
+
competing.has(targetKey(binding.target))
|
|
172
|
+
)
|
|
173
|
+
continue;
|
|
174
|
+
candidates.push({
|
|
175
|
+
profileName: input.profileName,
|
|
176
|
+
bindingKey: binding.bindingKey,
|
|
177
|
+
cwd: binding.cwd,
|
|
178
|
+
workspaceKey: binding.workspaceKey,
|
|
130
179
|
...(binding.sessionId && binding.sessionKey
|
|
131
180
|
? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
|
|
132
181
|
: {}),
|
|
133
|
-
instanceSlot: binding.instanceSlot,
|
|
134
|
-
|
|
182
|
+
instanceSlot: binding.instanceSlot,
|
|
183
|
+
slot,
|
|
184
|
+
target: { ...binding.target },
|
|
185
|
+
inactiveSinceMs,
|
|
186
|
+
bindingUpdatedAtMs: binding.updatedAtMs,
|
|
187
|
+
});
|
|
135
188
|
}
|
|
136
|
-
return candidates.sort((left, right) =>
|
|
189
|
+
return candidates.sort((left, right) =>
|
|
190
|
+
left.bindingKey.localeCompare(right.bindingKey),
|
|
191
|
+
);
|
|
137
192
|
}
|
|
138
193
|
|
|
139
|
-
|
|
194
|
+
/** Read ports for the bindings and target claims that cleanup evidence is captured from. */
|
|
195
|
+
export interface TelegramInactiveThreadCleanupEvidencePorts<
|
|
140
196
|
TBinding extends TelegramThreadCleanupBindingSnapshot,
|
|
141
|
-
>
|
|
142
|
-
profileName: string;
|
|
197
|
+
> {
|
|
143
198
|
listBindings(): readonly TBinding[];
|
|
144
199
|
getProtection(binding: TBinding): {
|
|
145
200
|
liveOwner: TelegramThreadCleanupProtectionState;
|
|
146
201
|
acceptedWork: TelegramThreadCleanupProtectionState;
|
|
147
202
|
deliveryAuthority: TelegramThreadCleanupProtectionState;
|
|
148
203
|
};
|
|
149
|
-
listReservations(): readonly {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
listReservations(): readonly {
|
|
205
|
+
target: { chatId: number; threadId: number };
|
|
206
|
+
}[];
|
|
207
|
+
listPendingProvisions(): readonly {
|
|
208
|
+
target?: { chatId: number; threadId: number };
|
|
209
|
+
}[];
|
|
210
|
+
listPendingCleanups(): readonly {
|
|
211
|
+
target: { chatId: number; threadId: number };
|
|
212
|
+
}[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function captureTelegramInactiveThreadCleanupEvidence<
|
|
216
|
+
TBinding extends TelegramThreadCleanupBindingSnapshot,
|
|
217
|
+
>(
|
|
218
|
+
input: TelegramInactiveThreadCleanupEvidencePorts<TBinding> & {
|
|
219
|
+
profileName: string;
|
|
220
|
+
},
|
|
221
|
+
): Parameters<typeof planTelegramInactiveThreadCleanup>[0] {
|
|
153
222
|
const sourceBindings = input.listBindings();
|
|
154
|
-
const bindings = sourceBindings.map(binding => ({
|
|
155
|
-
cwd: binding.cwd,
|
|
223
|
+
const bindings = sourceBindings.map((binding) => ({
|
|
224
|
+
cwd: binding.cwd,
|
|
225
|
+
workspaceKey: binding.workspaceKey,
|
|
156
226
|
...(binding.sessionId && binding.sessionKey
|
|
157
227
|
? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
|
|
158
228
|
: {}),
|
|
159
229
|
instanceSlot: binding.instanceSlot,
|
|
160
|
-
...(binding.slot === undefined ? {} : { slot: binding.slot }),
|
|
161
|
-
|
|
230
|
+
...(binding.slot === undefined ? {} : { slot: binding.slot }),
|
|
231
|
+
bindingKey: binding.bindingKey,
|
|
232
|
+
target: { ...binding.target },
|
|
233
|
+
...(binding.inactiveSinceMs === undefined
|
|
234
|
+
? {}
|
|
235
|
+
: { inactiveSinceMs: binding.inactiveSinceMs }),
|
|
162
236
|
updatedAtMs: binding.updatedAtMs,
|
|
163
237
|
}));
|
|
164
238
|
const protection = sourceBindings.map((source, index) => {
|
|
165
239
|
const binding = bindings[index]!;
|
|
166
|
-
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
240
|
+
try {
|
|
241
|
+
return {
|
|
242
|
+
bindingKey: binding.bindingKey,
|
|
243
|
+
target: { ...binding.target },
|
|
244
|
+
...input.getProtection(source),
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
return {
|
|
248
|
+
bindingKey: binding.bindingKey,
|
|
249
|
+
target: { ...binding.target },
|
|
250
|
+
liveOwner: "unknown" as const,
|
|
251
|
+
acceptedWork: "unknown" as const,
|
|
252
|
+
deliveryAuthority: "unknown" as const,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
171
255
|
});
|
|
172
256
|
return {
|
|
173
257
|
profileName: input.profileName,
|
|
174
258
|
bindings,
|
|
175
259
|
protection,
|
|
176
|
-
reservedTargets: input
|
|
177
|
-
|
|
178
|
-
entry
|
|
179
|
-
|
|
260
|
+
reservedTargets: input
|
|
261
|
+
.listReservations()
|
|
262
|
+
.map((entry) => ({ ...entry.target })),
|
|
263
|
+
provisioningTargets: input
|
|
264
|
+
.listPendingProvisions()
|
|
265
|
+
.flatMap((entry) => (entry.target ? [{ ...entry.target }] : [])),
|
|
266
|
+
cleanupTargets: input
|
|
267
|
+
.listPendingCleanups()
|
|
268
|
+
.map((entry) => ({ ...entry.target })),
|
|
180
269
|
};
|
|
181
270
|
}
|
|
182
271
|
|
|
183
272
|
export function createTelegramInactiveThreadCleanupReviewRuntime<
|
|
184
273
|
TBinding extends TelegramThreadCleanupBindingSnapshot,
|
|
185
|
-
>(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}): { review(): Promise<{ count: number; operationId?: string }> } {
|
|
274
|
+
>(
|
|
275
|
+
deps: TelegramInactiveThreadCleanupEvidencePorts<TBinding> & {
|
|
276
|
+
getProfileName(): string;
|
|
277
|
+
getWorkStore(): TelegramThreadCleanupWorkStore;
|
|
278
|
+
runWorkspaceOperation<T>(
|
|
279
|
+
input: {
|
|
280
|
+
operationId: string;
|
|
281
|
+
operationKind: string;
|
|
282
|
+
scopes: readonly [{ kind: "profile" }];
|
|
283
|
+
},
|
|
284
|
+
operation: () => Promise<T>,
|
|
285
|
+
): Promise<T>;
|
|
286
|
+
},
|
|
287
|
+
): { review(): Promise<{ count: number; operationId?: string }> } {
|
|
200
288
|
return {
|
|
201
289
|
review() {
|
|
202
|
-
return deps.runWorkspaceOperation(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
290
|
+
return deps.runWorkspaceOperation(
|
|
291
|
+
{
|
|
292
|
+
operationId: `thread-cleanup-review:${randomUUID()}`,
|
|
293
|
+
operationKind: "thread-cleanup-review",
|
|
294
|
+
scopes: [{ kind: "profile" }],
|
|
295
|
+
},
|
|
296
|
+
async () => {
|
|
297
|
+
const evidence = captureTelegramInactiveThreadCleanupEvidence({
|
|
298
|
+
profileName: deps.getProfileName(),
|
|
299
|
+
listBindings: deps.listBindings,
|
|
300
|
+
getProtection: deps.getProtection,
|
|
301
|
+
listReservations: deps.listReservations,
|
|
302
|
+
listPendingProvisions: deps.listPendingProvisions,
|
|
303
|
+
listPendingCleanups: deps.listPendingCleanups,
|
|
304
|
+
});
|
|
305
|
+
const candidates = planTelegramInactiveThreadCleanup(evidence);
|
|
306
|
+
if (candidates.length === 0) return { count: 0 };
|
|
307
|
+
const digest = createHash("sha256")
|
|
308
|
+
.update(JSON.stringify(candidates))
|
|
309
|
+
.digest("hex")
|
|
310
|
+
.slice(0, 32);
|
|
311
|
+
const operationId = `thread-cleanup:${digest}`;
|
|
312
|
+
deps.getWorkStore().prepare(operationId, candidates);
|
|
313
|
+
return { count: candidates.length, operationId };
|
|
314
|
+
},
|
|
315
|
+
);
|
|
217
316
|
},
|
|
218
317
|
};
|
|
219
318
|
}
|
|
220
319
|
|
|
221
|
-
export type TelegramThreadCleanupWorkState =
|
|
320
|
+
export type TelegramThreadCleanupWorkState =
|
|
321
|
+
"prepared" | "outcome-unknown" | "deleted";
|
|
222
322
|
export type TelegramThreadCleanupWorkEntry = TelegramThreadCleanupCandidate & {
|
|
223
323
|
state: TelegramThreadCleanupWorkState;
|
|
224
324
|
updatedAtMs: number;
|
|
@@ -239,208 +339,418 @@ interface TelegramThreadCleanupWorkFile {
|
|
|
239
339
|
tokenSha256: string;
|
|
240
340
|
workSets: TelegramThreadCleanupWorkSet[];
|
|
241
341
|
}
|
|
242
|
-
export type TelegramThreadCleanupDeletionPermit =
|
|
342
|
+
export type TelegramThreadCleanupDeletionPermit =
|
|
343
|
+
TelegramWorkspaceDeletionPermit;
|
|
243
344
|
export type TelegramThreadCleanupFence = TelegramWorkspaceRetirementFence;
|
|
244
345
|
|
|
245
346
|
export interface TelegramThreadCleanupWorkStore {
|
|
246
|
-
prepare(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
347
|
+
prepare(
|
|
348
|
+
operationId: string,
|
|
349
|
+
candidates: readonly TelegramThreadCleanupCandidate[],
|
|
350
|
+
): { prepared: boolean; workSet: TelegramThreadCleanupWorkSet };
|
|
351
|
+
recordDeletionIssued(input: {
|
|
352
|
+
operationId: string;
|
|
353
|
+
bindingKey: string;
|
|
354
|
+
bindingUpdatedAtMs: number;
|
|
355
|
+
permit: TelegramThreadCleanupDeletionPermit;
|
|
356
|
+
}): { recorded: boolean; entry: TelegramThreadCleanupWorkEntry };
|
|
357
|
+
confirmDeleted(input: { operationId: string; bindingKey: string }): {
|
|
358
|
+
confirmed: boolean;
|
|
359
|
+
entry: TelegramThreadCleanupWorkEntry;
|
|
360
|
+
};
|
|
253
361
|
list(): TelegramThreadCleanupWorkSet[];
|
|
254
362
|
}
|
|
255
363
|
|
|
256
|
-
function validateWorkSet(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
364
|
+
function validateWorkSet(
|
|
365
|
+
value: unknown,
|
|
366
|
+
profileName: string,
|
|
367
|
+
): TelegramThreadCleanupWorkSet {
|
|
368
|
+
if (
|
|
369
|
+
!isObject(value) ||
|
|
370
|
+
!onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
|
|
371
|
+
typeof value.operationId !== "string" ||
|
|
372
|
+
!value.operationId ||
|
|
373
|
+
!safeTime(value.createdAtMs) ||
|
|
374
|
+
!Array.isArray(value.entries) ||
|
|
375
|
+
value.entries.length === 0
|
|
376
|
+
)
|
|
377
|
+
throw new Error("Telegram Thread cleanup work-set schema is invalid.");
|
|
260
378
|
const createdAtMs = value.createdAtMs as number;
|
|
261
|
-
const entries = value.entries.map(raw => {
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
379
|
+
const entries = value.entries.map((raw) => {
|
|
380
|
+
if (
|
|
381
|
+
!isObject(raw) ||
|
|
382
|
+
!onlyKeys(raw, [
|
|
383
|
+
"profileName",
|
|
384
|
+
"bindingKey",
|
|
385
|
+
"cwd",
|
|
386
|
+
"workspaceKey",
|
|
387
|
+
"sessionId",
|
|
388
|
+
"sessionKey",
|
|
389
|
+
"instanceSlot",
|
|
390
|
+
"slot",
|
|
391
|
+
"target",
|
|
392
|
+
"inactiveSinceMs",
|
|
393
|
+
"bindingUpdatedAtMs",
|
|
394
|
+
"state",
|
|
395
|
+
"updatedAtMs",
|
|
396
|
+
"issuedAtMs",
|
|
397
|
+
"permitOperationId",
|
|
398
|
+
"permitIntentId",
|
|
399
|
+
"permitLeaderEpoch",
|
|
400
|
+
"deletedAtMs",
|
|
401
|
+
]) ||
|
|
402
|
+
raw.profileName !== profileName ||
|
|
403
|
+
typeof raw.bindingKey !== "string" ||
|
|
404
|
+
!raw.bindingKey ||
|
|
405
|
+
typeof raw.cwd !== "string" ||
|
|
406
|
+
!raw.cwd ||
|
|
407
|
+
typeof raw.workspaceKey !== "string" ||
|
|
408
|
+
!raw.workspaceKey ||
|
|
409
|
+
!validSessionIdentity(raw) ||
|
|
410
|
+
typeof raw.instanceSlot !== "string" ||
|
|
411
|
+
!raw.instanceSlot ||
|
|
412
|
+
typeof raw.slot !== "string" ||
|
|
413
|
+
!raw.slot ||
|
|
414
|
+
!isObject(raw.target) ||
|
|
415
|
+
!onlyKeys(raw.target, ["chatId", "threadId"]) ||
|
|
416
|
+
!validTarget(
|
|
417
|
+
raw.target as unknown as { chatId: number; threadId: number },
|
|
418
|
+
) ||
|
|
419
|
+
!safeTime(raw.inactiveSinceMs) ||
|
|
420
|
+
!safeTime(raw.bindingUpdatedAtMs) ||
|
|
421
|
+
(raw.bindingUpdatedAtMs as number) < (raw.inactiveSinceMs as number) ||
|
|
422
|
+
!safeTime(raw.updatedAtMs)
|
|
423
|
+
)
|
|
272
424
|
throw new Error("Telegram Thread cleanup entry schema is invalid.");
|
|
273
|
-
const base = {
|
|
274
|
-
|
|
425
|
+
const base = {
|
|
426
|
+
profileName,
|
|
427
|
+
bindingKey: raw.bindingKey,
|
|
428
|
+
cwd: raw.cwd,
|
|
429
|
+
workspaceKey: raw.workspaceKey,
|
|
430
|
+
...(typeof raw.sessionId === "string" &&
|
|
431
|
+
typeof raw.sessionKey === "string"
|
|
275
432
|
? { sessionId: raw.sessionId, sessionKey: raw.sessionKey }
|
|
276
433
|
: {}),
|
|
277
|
-
instanceSlot: raw.instanceSlot,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
434
|
+
instanceSlot: raw.instanceSlot,
|
|
435
|
+
slot: raw.slot,
|
|
436
|
+
target: {
|
|
437
|
+
chatId: raw.target.chatId as number,
|
|
438
|
+
threadId: raw.target.threadId as number,
|
|
439
|
+
},
|
|
440
|
+
inactiveSinceMs: raw.inactiveSinceMs as number,
|
|
441
|
+
bindingUpdatedAtMs: raw.bindingUpdatedAtMs as number,
|
|
442
|
+
updatedAtMs: raw.updatedAtMs as number,
|
|
443
|
+
};
|
|
444
|
+
if (base.updatedAtMs < base.bindingUpdatedAtMs)
|
|
445
|
+
throw new Error("Telegram Thread cleanup entry clock is invalid.");
|
|
446
|
+
const noPermit =
|
|
447
|
+
raw.permitOperationId === undefined &&
|
|
448
|
+
raw.permitIntentId === undefined &&
|
|
282
449
|
raw.permitLeaderEpoch === undefined;
|
|
283
|
-
if (
|
|
450
|
+
if (
|
|
451
|
+
raw.state === "prepared" &&
|
|
452
|
+
raw.issuedAtMs === undefined &&
|
|
453
|
+
raw.deletedAtMs === undefined &&
|
|
454
|
+
noPermit
|
|
455
|
+
)
|
|
284
456
|
return { ...base, state: "prepared" as const };
|
|
285
|
-
const validPermit =
|
|
286
|
-
typeof raw.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
457
|
+
const validPermit =
|
|
458
|
+
typeof raw.permitOperationId === "string" &&
|
|
459
|
+
!!raw.permitOperationId &&
|
|
460
|
+
typeof raw.permitIntentId === "string" &&
|
|
461
|
+
!!raw.permitIntentId &&
|
|
462
|
+
(typeof raw.permitLeaderEpoch === "string" ||
|
|
463
|
+
Number.isSafeInteger(raw.permitLeaderEpoch));
|
|
464
|
+
if (
|
|
465
|
+
raw.state === "outcome-unknown" &&
|
|
466
|
+
safeTime(raw.issuedAtMs) &&
|
|
467
|
+
validPermit &&
|
|
468
|
+
raw.issuedAtMs === base.updatedAtMs &&
|
|
469
|
+
raw.deletedAtMs === undefined
|
|
470
|
+
)
|
|
471
|
+
return {
|
|
472
|
+
...base,
|
|
473
|
+
state: "outcome-unknown" as const,
|
|
474
|
+
issuedAtMs: raw.issuedAtMs,
|
|
475
|
+
permitOperationId: raw.permitOperationId as string,
|
|
476
|
+
permitIntentId: raw.permitIntentId as string,
|
|
477
|
+
permitLeaderEpoch: raw.permitLeaderEpoch as number | string,
|
|
478
|
+
};
|
|
479
|
+
if (
|
|
480
|
+
raw.state === "deleted" &&
|
|
481
|
+
safeTime(raw.issuedAtMs) &&
|
|
482
|
+
validPermit &&
|
|
483
|
+
safeTime(raw.deletedAtMs) &&
|
|
484
|
+
raw.deletedAtMs === base.updatedAtMs &&
|
|
485
|
+
raw.deletedAtMs >= raw.issuedAtMs
|
|
486
|
+
)
|
|
487
|
+
return {
|
|
488
|
+
...base,
|
|
489
|
+
state: "deleted" as const,
|
|
490
|
+
issuedAtMs: raw.issuedAtMs,
|
|
491
|
+
permitOperationId: raw.permitOperationId as string,
|
|
492
|
+
permitIntentId: raw.permitIntentId as string,
|
|
493
|
+
permitLeaderEpoch: raw.permitLeaderEpoch as number | string,
|
|
494
|
+
deletedAtMs: raw.deletedAtMs,
|
|
495
|
+
};
|
|
298
496
|
throw new Error("Telegram Thread cleanup entry state is invalid.");
|
|
299
497
|
});
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
498
|
+
if (
|
|
499
|
+
entries.some((entry) => entry.updatedAtMs < createdAtMs) ||
|
|
500
|
+
new Set(entries.map((entry) => entry.bindingKey)).size !== entries.length ||
|
|
501
|
+
new Set(entries.map((entry) => targetKey(entry.target))).size !==
|
|
502
|
+
entries.length
|
|
503
|
+
)
|
|
303
504
|
throw new Error("Telegram Thread cleanup work-set identity is ambiguous.");
|
|
304
505
|
return { operationId: value.operationId, createdAtMs, entries };
|
|
305
506
|
}
|
|
306
507
|
|
|
307
|
-
function sameCandidates(
|
|
508
|
+
function sameCandidates(
|
|
509
|
+
left: readonly TelegramThreadCleanupCandidate[],
|
|
510
|
+
right: readonly TelegramThreadCleanupCandidate[],
|
|
511
|
+
): boolean {
|
|
308
512
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
309
513
|
}
|
|
310
514
|
|
|
311
|
-
function parseTelegramThreadCleanupWorkFile(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
515
|
+
function parseTelegramThreadCleanupWorkFile(
|
|
516
|
+
value: unknown,
|
|
517
|
+
profileName: string,
|
|
518
|
+
tokenSha256: string,
|
|
519
|
+
): TelegramThreadCleanupWorkFile {
|
|
520
|
+
if (
|
|
521
|
+
!isObject(value) ||
|
|
522
|
+
!onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
|
|
523
|
+
value.version !== 1 ||
|
|
524
|
+
value.profileName !== profileName ||
|
|
525
|
+
value.tokenSha256 !== tokenSha256 ||
|
|
526
|
+
!Array.isArray(value.workSets)
|
|
527
|
+
)
|
|
528
|
+
throw new Error(
|
|
529
|
+
"Telegram Thread cleanup store identity or schema does not match.",
|
|
530
|
+
);
|
|
531
|
+
const workSets = value.workSets.map((workSet) =>
|
|
532
|
+
validateWorkSet(workSet, profileName),
|
|
533
|
+
);
|
|
534
|
+
if (
|
|
535
|
+
new Set(workSets.map((workSet) => workSet.operationId)).size !==
|
|
536
|
+
workSets.length
|
|
537
|
+
)
|
|
317
538
|
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
318
539
|
return { version: 1, profileName, tokenSha256, workSets };
|
|
319
540
|
}
|
|
320
541
|
|
|
321
542
|
export function createTelegramThreadCleanupWorkStore(options: {
|
|
322
|
-
path: string;
|
|
323
|
-
|
|
324
|
-
|
|
543
|
+
path: string;
|
|
544
|
+
runtimeDir?: string;
|
|
545
|
+
profileName: string;
|
|
546
|
+
tokenSha256: string;
|
|
547
|
+
maxWorkSets?: number;
|
|
548
|
+
maxBytes?: number;
|
|
549
|
+
getNowMs?: () => number;
|
|
550
|
+
onPublicationBoundary?: (
|
|
551
|
+
boundary: "after-write-before-rename" | "after-rename",
|
|
552
|
+
) => void;
|
|
325
553
|
}): TelegramThreadCleanupWorkStore {
|
|
326
554
|
options = { ...options };
|
|
327
|
-
const publicationPaths = getTelegramJournalPublicationPaths(
|
|
555
|
+
const publicationPaths = getTelegramJournalPublicationPaths(
|
|
556
|
+
options.path,
|
|
557
|
+
options.runtimeDir,
|
|
558
|
+
);
|
|
328
559
|
const maxWorkSets = options.maxWorkSets ?? 32;
|
|
329
560
|
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
330
561
|
const now = options.getNowMs ?? Date.now;
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
562
|
+
if (
|
|
563
|
+
!options.path ||
|
|
564
|
+
!options.profileName ||
|
|
565
|
+
!/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
|
|
566
|
+
!Number.isSafeInteger(maxWorkSets) ||
|
|
567
|
+
maxWorkSets <= 0 ||
|
|
568
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
569
|
+
maxBytes <= 0
|
|
570
|
+
)
|
|
571
|
+
throw new Error("Telegram Thread cleanup store options are invalid.");
|
|
572
|
+
const empty = (): TelegramThreadCleanupWorkFile => ({
|
|
573
|
+
version: 1,
|
|
574
|
+
profileName: options.profileName,
|
|
575
|
+
tokenSha256: options.tokenSha256,
|
|
576
|
+
workSets: [],
|
|
577
|
+
});
|
|
336
578
|
const read = (): TelegramThreadCleanupWorkFile => {
|
|
337
|
-
let
|
|
338
|
-
try { before = lstatSync(options.path, { bigint: true }); }
|
|
339
|
-
catch (error) {
|
|
340
|
-
if ((error as { code?: unknown }).code === "ENOENT") return empty();
|
|
341
|
-
throw error;
|
|
342
|
-
}
|
|
343
|
-
const uid = process.getuid?.();
|
|
344
|
-
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
|
|
345
|
-
before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
|
|
346
|
-
(before.mode & 0o077n) !== 0n || before.size > BigInt(maxBytes))
|
|
347
|
-
throw new Error("Telegram Thread cleanup store is not a bounded private regular file.");
|
|
348
|
-
const fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
349
|
-
let value: unknown;
|
|
579
|
+
let text: string | undefined;
|
|
350
580
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
581
|
+
text = readTelegramPrivateFile(options.path, maxBytes);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (!(error instanceof TelegramPrivateFileError)) throw error;
|
|
584
|
+
throw new Error(
|
|
585
|
+
error.failure === "changed"
|
|
586
|
+
? "Telegram Thread cleanup store changed during inspection."
|
|
587
|
+
: "Telegram Thread cleanup store is not a bounded private regular file.",
|
|
588
|
+
{ cause: error },
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return text === undefined
|
|
592
|
+
? empty()
|
|
593
|
+
: parseTelegramThreadCleanupWorkFile(
|
|
594
|
+
JSON.parse(text) as unknown,
|
|
595
|
+
options.profileName,
|
|
596
|
+
options.tokenSha256,
|
|
597
|
+
);
|
|
357
598
|
};
|
|
358
599
|
const publish = (file: TelegramThreadCleanupWorkFile): void => {
|
|
359
|
-
if (file.workSets.length > maxWorkSets)
|
|
600
|
+
if (file.workSets.length > maxWorkSets)
|
|
601
|
+
throw new Error("Telegram Thread cleanup work-set capacity reached.");
|
|
360
602
|
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
361
|
-
if (Buffer.byteLength(serialized) > maxBytes)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
options.onPublicationBoundary
|
|
369
|
-
|
|
370
|
-
throw new Error("Telegram Thread cleanup staging file disappeared.");
|
|
371
|
-
chmodSync(options.path, 0o600);
|
|
372
|
-
options.onPublicationBoundary?.("after-rename");
|
|
373
|
-
} finally { try { unlinkSync(temporaryPath); } catch { /* rename consumed it */ } }
|
|
603
|
+
if (Buffer.byteLength(serialized) > maxBytes)
|
|
604
|
+
throw new Error("Telegram Thread cleanup byte capacity reached.");
|
|
605
|
+
publishTelegramPrivateFile(
|
|
606
|
+
options.path,
|
|
607
|
+
publicationPaths.temporaryBasePath,
|
|
608
|
+
serialized,
|
|
609
|
+
"Telegram Thread cleanup",
|
|
610
|
+
options.onPublicationBoundary,
|
|
611
|
+
);
|
|
374
612
|
};
|
|
375
|
-
const mutate = <T>(
|
|
376
|
-
|
|
613
|
+
const mutate = <T>(
|
|
614
|
+
operation: (file: TelegramThreadCleanupWorkFile) => T,
|
|
615
|
+
): T =>
|
|
616
|
+
withTelegramFileTransaction(publicationPaths.transactionPath, () =>
|
|
617
|
+
operation(read()),
|
|
618
|
+
);
|
|
377
619
|
return {
|
|
378
620
|
prepare(operationId, candidates) {
|
|
379
|
-
if (
|
|
621
|
+
if (
|
|
622
|
+
!operationId ||
|
|
623
|
+
candidates.length === 0 ||
|
|
624
|
+
candidates.some(
|
|
625
|
+
(candidate) => candidate.profileName !== options.profileName,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
380
628
|
throw new Error("Telegram Thread cleanup work-set is invalid.");
|
|
381
|
-
return mutate(file => {
|
|
382
|
-
const existing = file.workSets.find(
|
|
629
|
+
return mutate((file) => {
|
|
630
|
+
const existing = file.workSets.find(
|
|
631
|
+
(workSet) => workSet.operationId === operationId,
|
|
632
|
+
);
|
|
383
633
|
if (existing) {
|
|
384
|
-
const projected = existing.entries.map(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
634
|
+
const projected = existing.entries.map(
|
|
635
|
+
({
|
|
636
|
+
state: _state,
|
|
637
|
+
updatedAtMs: _updated,
|
|
638
|
+
issuedAtMs: _issued,
|
|
639
|
+
permitOperationId: _permitOperation,
|
|
640
|
+
permitIntentId: _permitIntent,
|
|
641
|
+
permitLeaderEpoch: _permitEpoch,
|
|
642
|
+
deletedAtMs: _deleted,
|
|
643
|
+
...candidate
|
|
644
|
+
}) => candidate,
|
|
645
|
+
);
|
|
646
|
+
if (!sameCandidates(projected, candidates))
|
|
647
|
+
throw new Error("Telegram Thread cleanup operation conflicts.");
|
|
389
648
|
return { prepared: false, workSet: structuredClone(existing) };
|
|
390
649
|
}
|
|
391
650
|
const atMs = now();
|
|
392
|
-
if (!safeTime(atMs))
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
651
|
+
if (!safeTime(atMs))
|
|
652
|
+
throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
653
|
+
const workSet = validateWorkSet(
|
|
654
|
+
{
|
|
655
|
+
operationId,
|
|
656
|
+
createdAtMs: atMs,
|
|
657
|
+
entries: candidates.map((candidate) => ({
|
|
658
|
+
...structuredClone(candidate),
|
|
659
|
+
state: "prepared",
|
|
660
|
+
updatedAtMs: atMs,
|
|
661
|
+
})),
|
|
662
|
+
},
|
|
663
|
+
options.profileName,
|
|
664
|
+
);
|
|
396
665
|
publish({ ...file, workSets: [...file.workSets, workSet] });
|
|
397
666
|
return { prepared: true, workSet: structuredClone(workSet) };
|
|
398
667
|
});
|
|
399
668
|
},
|
|
400
669
|
recordDeletionIssued(input) {
|
|
401
|
-
return mutate(file => {
|
|
402
|
-
const workSet = file.workSets.find(
|
|
403
|
-
|
|
670
|
+
return mutate((file) => {
|
|
671
|
+
const workSet = file.workSets.find(
|
|
672
|
+
(candidate) => candidate.operationId === input.operationId,
|
|
673
|
+
);
|
|
674
|
+
const entry = workSet?.entries.find(
|
|
675
|
+
(candidate) => candidate.bindingKey === input.bindingKey,
|
|
676
|
+
);
|
|
404
677
|
const permit = input.permit;
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
678
|
+
if (
|
|
679
|
+
!workSet ||
|
|
680
|
+
!entry ||
|
|
681
|
+
entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
|
|
682
|
+
permit.destructiveKind !== "manual-thread-cleanup" ||
|
|
683
|
+
permit.profileKey !== entry.profileName ||
|
|
684
|
+
permit.bindingKey !== entry.bindingKey ||
|
|
685
|
+
permit.slot !== entry.slot ||
|
|
686
|
+
targetKey(permit.target) !== targetKey(entry.target) ||
|
|
687
|
+
!permit.operationId ||
|
|
688
|
+
!permit.retirementIntentId ||
|
|
689
|
+
!safeTime(permit.issuedAtMs)
|
|
690
|
+
)
|
|
691
|
+
throw new Error(
|
|
692
|
+
"Telegram Thread cleanup entry or deletion permit is stale or mismatched.",
|
|
693
|
+
);
|
|
410
694
|
if (entry.state !== "prepared") {
|
|
411
|
-
const exact =
|
|
695
|
+
const exact =
|
|
696
|
+
entry.permitOperationId === permit.operationId &&
|
|
412
697
|
entry.permitIntentId === permit.retirementIntentId &&
|
|
413
698
|
entry.permitLeaderEpoch === permit.leaderEpoch;
|
|
414
|
-
if (!exact)
|
|
699
|
+
if (!exact)
|
|
700
|
+
throw new Error(
|
|
701
|
+
"Telegram Thread cleanup deletion permit conflicts with retained work.",
|
|
702
|
+
);
|
|
415
703
|
return { recorded: false, entry: structuredClone(entry) };
|
|
416
704
|
}
|
|
417
705
|
const atMs = now();
|
|
418
|
-
if (
|
|
706
|
+
if (
|
|
707
|
+
!safeTime(atMs) ||
|
|
708
|
+
atMs < entry.updatedAtMs ||
|
|
709
|
+
atMs < permit.issuedAtMs
|
|
710
|
+
)
|
|
419
711
|
throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
420
|
-
entry.state = "outcome-unknown";
|
|
421
|
-
entry.
|
|
712
|
+
entry.state = "outcome-unknown";
|
|
713
|
+
entry.issuedAtMs = atMs;
|
|
714
|
+
entry.updatedAtMs = atMs;
|
|
715
|
+
entry.permitOperationId = permit.operationId;
|
|
716
|
+
entry.permitIntentId = permit.retirementIntentId;
|
|
422
717
|
entry.permitLeaderEpoch = permit.leaderEpoch;
|
|
423
|
-
publish(file);
|
|
718
|
+
publish(file);
|
|
719
|
+
return { recorded: true, entry: structuredClone(entry) };
|
|
424
720
|
});
|
|
425
721
|
},
|
|
426
722
|
confirmDeleted(input) {
|
|
427
|
-
return mutate(file => {
|
|
428
|
-
const entry = file.workSets
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
723
|
+
return mutate((file) => {
|
|
724
|
+
const entry = file.workSets
|
|
725
|
+
.find((candidate) => candidate.operationId === input.operationId)
|
|
726
|
+
?.entries.find(
|
|
727
|
+
(candidate) => candidate.bindingKey === input.bindingKey,
|
|
728
|
+
);
|
|
729
|
+
if (!entry)
|
|
730
|
+
throw new Error("Telegram Thread cleanup entry is missing.");
|
|
731
|
+
if (entry.state === "deleted")
|
|
732
|
+
return { confirmed: false, entry: structuredClone(entry) };
|
|
733
|
+
if (entry.state !== "outcome-unknown")
|
|
734
|
+
throw new Error(
|
|
735
|
+
"Telegram Thread cleanup deletion confirmation is premature.",
|
|
736
|
+
);
|
|
433
737
|
const atMs = now();
|
|
434
|
-
if (!safeTime(atMs) || atMs < entry.updatedAtMs)
|
|
435
|
-
|
|
436
|
-
|
|
738
|
+
if (!safeTime(atMs) || atMs < entry.updatedAtMs)
|
|
739
|
+
throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
740
|
+
entry.state = "deleted";
|
|
741
|
+
entry.deletedAtMs = atMs;
|
|
742
|
+
entry.updatedAtMs = atMs;
|
|
743
|
+
publish(file);
|
|
744
|
+
return { confirmed: true, entry: structuredClone(entry) };
|
|
437
745
|
});
|
|
438
746
|
},
|
|
439
747
|
list() {
|
|
440
|
-
return structuredClone(
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
748
|
+
return structuredClone(
|
|
749
|
+
withTelegramFileTransaction(
|
|
750
|
+
publicationPaths.transactionPath,
|
|
751
|
+
() => read().workSets,
|
|
752
|
+
),
|
|
753
|
+
);
|
|
444
754
|
},
|
|
445
755
|
};
|
|
446
756
|
}
|
|
@@ -451,8 +761,11 @@ export async function commitTelegramInactiveThreadCleanup(input: {
|
|
|
451
761
|
bindingKey: string;
|
|
452
762
|
commitBinding(): Promise<boolean>;
|
|
453
763
|
}): Promise<boolean> {
|
|
454
|
-
if (!await input.commitBinding()) return false;
|
|
455
|
-
input.store.confirmDeleted({
|
|
764
|
+
if (!(await input.commitBinding())) return false;
|
|
765
|
+
input.store.confirmDeleted({
|
|
766
|
+
operationId: input.operationId,
|
|
767
|
+
bindingKey: input.bindingKey,
|
|
768
|
+
});
|
|
456
769
|
return true;
|
|
457
770
|
}
|
|
458
771
|
|
|
@@ -461,36 +774,68 @@ export async function executeTelegramInactiveThreadCleanup(input: {
|
|
|
461
774
|
operationId: string;
|
|
462
775
|
bindingKey: string;
|
|
463
776
|
withWorkspaceDeletionBoundary<T>(operation: () => Promise<T>): Promise<T>;
|
|
464
|
-
loadFreshEvidence(): Promise<
|
|
465
|
-
|
|
777
|
+
loadFreshEvidence(): Promise<
|
|
778
|
+
Parameters<typeof planTelegramInactiveThreadCleanup>[0]
|
|
779
|
+
>;
|
|
780
|
+
acquireDeletionPermit(
|
|
781
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
782
|
+
): Promise<
|
|
466
783
|
| { kind: "issued"; permit: TelegramThreadCleanupDeletionPermit }
|
|
467
784
|
| { kind: "blocked" | "already-issued" }
|
|
468
785
|
>;
|
|
469
|
-
deleteWithPermit(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
786
|
+
deleteWithPermit(
|
|
787
|
+
permit: TelegramThreadCleanupDeletionPermit,
|
|
788
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
789
|
+
): Promise<void>;
|
|
790
|
+
}): Promise<{
|
|
791
|
+
status: "deleted" | "blocked" | "outcome-unknown";
|
|
792
|
+
entry?: TelegramThreadCleanupWorkEntry;
|
|
793
|
+
}> {
|
|
473
794
|
return input.withWorkspaceDeletionBoundary(async () => {
|
|
474
|
-
const workSet = input.store
|
|
475
|
-
|
|
795
|
+
const workSet = input.store
|
|
796
|
+
.list()
|
|
797
|
+
.find((candidate) => candidate.operationId === input.operationId);
|
|
798
|
+
const retained = workSet?.entries.find(
|
|
799
|
+
(candidate) => candidate.bindingKey === input.bindingKey,
|
|
800
|
+
);
|
|
476
801
|
if (!retained) return { status: "blocked" };
|
|
477
|
-
if (retained.state === "deleted")
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
802
|
+
if (retained.state === "deleted")
|
|
803
|
+
return { status: "deleted", entry: retained };
|
|
804
|
+
if (retained.state === "outcome-unknown")
|
|
805
|
+
return { status: "outcome-unknown", entry: retained };
|
|
806
|
+
const planned = planTelegramInactiveThreadCleanup(
|
|
807
|
+
await input.loadFreshEvidence(),
|
|
808
|
+
);
|
|
809
|
+
const fresh = planned.find(
|
|
810
|
+
(candidate) => candidate.bindingKey === retained.bindingKey,
|
|
811
|
+
);
|
|
812
|
+
const retainedCandidate = (({
|
|
813
|
+
state: _state,
|
|
814
|
+
updatedAtMs: _updated,
|
|
815
|
+
issuedAtMs: _issued,
|
|
816
|
+
permitOperationId: _permitOperation,
|
|
817
|
+
permitIntentId: _permitIntent,
|
|
818
|
+
permitLeaderEpoch: _permitEpoch,
|
|
819
|
+
deletedAtMs: _deleted,
|
|
820
|
+
...candidate
|
|
821
|
+
}) => candidate)(retained);
|
|
822
|
+
if (!fresh || !sameCandidates([fresh], [retainedCandidate]))
|
|
823
|
+
return { status: "blocked" };
|
|
485
824
|
const permitResult = await input.acquireDeletionPermit(fresh);
|
|
486
825
|
if (permitResult.kind !== "issued") return { status: "blocked" };
|
|
487
|
-
const recorded = input.store.recordDeletionIssued({
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
826
|
+
const recorded = input.store.recordDeletionIssued({
|
|
827
|
+
operationId: input.operationId,
|
|
828
|
+
bindingKey: input.bindingKey,
|
|
829
|
+
bindingUpdatedAtMs: retained.bindingUpdatedAtMs,
|
|
830
|
+
permit: permitResult.permit,
|
|
831
|
+
});
|
|
832
|
+
if (!recorded.recorded)
|
|
833
|
+
return { status: "outcome-unknown", entry: recorded.entry };
|
|
491
834
|
await input.deleteWithPermit(permitResult.permit, fresh);
|
|
492
|
-
const confirmed = input.store.confirmDeleted({
|
|
493
|
-
|
|
835
|
+
const confirmed = input.store.confirmDeleted({
|
|
836
|
+
operationId: input.operationId,
|
|
837
|
+
bindingKey: input.bindingKey,
|
|
838
|
+
});
|
|
494
839
|
return { status: "deleted", entry: confirmed.entry };
|
|
495
840
|
});
|
|
496
841
|
}
|
|
@@ -498,63 +843,126 @@ export async function executeTelegramInactiveThreadCleanup(input: {
|
|
|
498
843
|
export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
499
844
|
ledger: {
|
|
500
845
|
read(): { fence?: TelegramWorkspaceDestructiveFence };
|
|
501
|
-
acquireThreadCleanupFence(input: {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
846
|
+
acquireThreadCleanupFence(input: {
|
|
847
|
+
operationId: string;
|
|
848
|
+
cleanupWorkSetId: string;
|
|
849
|
+
bindingKey: string;
|
|
850
|
+
slot: string;
|
|
851
|
+
target: { chatId: number; threadId: number };
|
|
852
|
+
leaderEpoch: number | string;
|
|
853
|
+
cleanupRequestedAtMs: number;
|
|
854
|
+
}):
|
|
855
|
+
| {
|
|
856
|
+
kind: "acquired";
|
|
857
|
+
fence: TelegramThreadCleanupFence;
|
|
858
|
+
resumed: boolean;
|
|
859
|
+
}
|
|
505
860
|
| { kind: "blocked"; reason: string };
|
|
506
|
-
adoptThreadCleanupFence(
|
|
507
|
-
|
|
508
|
-
|
|
861
|
+
adoptThreadCleanupFence(
|
|
862
|
+
fence: TelegramThreadCleanupFence,
|
|
863
|
+
replacement: {
|
|
864
|
+
owner: { processId: number; processBirthId: string };
|
|
865
|
+
leaderEpoch: number | string;
|
|
866
|
+
},
|
|
867
|
+
): TelegramThreadCleanupFence;
|
|
509
868
|
issueThreadCleanupDeletionPermit(fence: TelegramThreadCleanupFence):
|
|
510
|
-
| {
|
|
869
|
+
| {
|
|
870
|
+
kind: "issued";
|
|
871
|
+
fence: TelegramThreadCleanupFence;
|
|
872
|
+
permit: TelegramThreadCleanupDeletionPermit;
|
|
873
|
+
}
|
|
511
874
|
| { kind: "already-issued"; fence: TelegramThreadCleanupFence };
|
|
512
|
-
confirmThreadCleanupAbsence(
|
|
513
|
-
|
|
875
|
+
confirmThreadCleanupAbsence(
|
|
876
|
+
fence: TelegramThreadCleanupFence,
|
|
877
|
+
): TelegramThreadCleanupFence;
|
|
878
|
+
releaseUnissuedThreadCleanupFence(
|
|
879
|
+
fence: TelegramThreadCleanupFence,
|
|
880
|
+
): boolean;
|
|
514
881
|
completeThreadCleanupFence(fence: TelegramThreadCleanupFence): boolean;
|
|
515
882
|
};
|
|
516
883
|
getLeaderEpoch(): number | string | undefined;
|
|
517
884
|
getProfileName(): string;
|
|
518
885
|
getOwner(): { processId: number; processBirthId: string };
|
|
519
886
|
canAdoptFence(fence: TelegramThreadCleanupFence): boolean;
|
|
520
|
-
revalidateUnderFence(
|
|
887
|
+
revalidateUnderFence(
|
|
888
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
889
|
+
): Promise<boolean>;
|
|
521
890
|
getNowMs?: () => number;
|
|
522
891
|
}): {
|
|
523
|
-
acquire(
|
|
524
|
-
|
|
892
|
+
acquire(
|
|
893
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
894
|
+
workSetId: string,
|
|
895
|
+
): Promise<
|
|
896
|
+
| {
|
|
897
|
+
kind: "issued";
|
|
898
|
+
fence: TelegramThreadCleanupFence;
|
|
899
|
+
permit: TelegramThreadCleanupDeletionPermit;
|
|
900
|
+
}
|
|
525
901
|
| { kind: "blocked" }
|
|
526
902
|
| { kind: "already-issued" }
|
|
527
903
|
>;
|
|
528
|
-
diagnoseRecovery(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
904
|
+
diagnoseRecovery(
|
|
905
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
906
|
+
workSetId: string,
|
|
907
|
+
):
|
|
908
|
+
| "none"
|
|
909
|
+
| "fenced"
|
|
910
|
+
| "deletion-issued"
|
|
911
|
+
| "commit-ready"
|
|
912
|
+
| "authority-blocked";
|
|
913
|
+
findCommitReady(
|
|
914
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
915
|
+
workSetId: string,
|
|
916
|
+
): TelegramThreadCleanupFence | undefined;
|
|
917
|
+
settleDeleted(
|
|
918
|
+
fence: TelegramThreadCleanupFence,
|
|
919
|
+
commit: () => Promise<boolean>,
|
|
920
|
+
): Promise<"completed" | "commit-pending">;
|
|
534
921
|
} {
|
|
535
922
|
const now = deps.getNowMs ?? Date.now;
|
|
536
|
-
const findExactFence = (
|
|
537
|
-
|
|
538
|
-
|
|
923
|
+
const findExactFence = (
|
|
924
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
925
|
+
workSetId: string,
|
|
926
|
+
) => {
|
|
927
|
+
const operationId = `thread-cleanup-fence:${createHash("sha256")
|
|
928
|
+
.update(`${workSetId}\u0000${candidate.bindingKey}`, "utf8")
|
|
929
|
+
.digest("hex")}`;
|
|
539
930
|
const fence = deps.ledger.read().fence;
|
|
540
|
-
return fence?.operationId === operationId &&
|
|
541
|
-
fence.
|
|
542
|
-
fence.
|
|
543
|
-
|
|
931
|
+
return fence?.operationId === operationId &&
|
|
932
|
+
fence.destructiveKind === "manual-thread-cleanup" &&
|
|
933
|
+
fence.profileKey === candidate.profileName &&
|
|
934
|
+
fence.retirementIntentId === workSetId &&
|
|
935
|
+
fence.bindingKey === candidate.bindingKey &&
|
|
936
|
+
fence.slot === candidate.slot &&
|
|
937
|
+
targetKey(fence.target) === targetKey(candidate.target)
|
|
938
|
+
? fence
|
|
939
|
+
: undefined;
|
|
544
940
|
};
|
|
545
|
-
const adoptExactFence = (
|
|
941
|
+
const adoptExactFence = (
|
|
942
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
943
|
+
workSetId: string,
|
|
944
|
+
) => {
|
|
546
945
|
let fence = findExactFence(candidate, workSetId);
|
|
547
946
|
const leaderEpoch = deps.getLeaderEpoch();
|
|
548
947
|
if (!fence || leaderEpoch === undefined) return undefined;
|
|
549
948
|
const owner = deps.getOwner();
|
|
550
|
-
if (
|
|
551
|
-
|
|
949
|
+
if (
|
|
950
|
+
fence.leaderEpoch !== leaderEpoch ||
|
|
951
|
+
fence.owner.processId !== owner.processId ||
|
|
952
|
+
fence.owner.processBirthId !== owner.processBirthId
|
|
953
|
+
) {
|
|
552
954
|
if (!deps.canAdoptFence(fence)) return undefined;
|
|
553
955
|
try {
|
|
554
|
-
const adopted = deps.ledger.adoptThreadCleanupFence(fence, {
|
|
555
|
-
|
|
956
|
+
const adopted = deps.ledger.adoptThreadCleanupFence(fence, {
|
|
957
|
+
owner,
|
|
958
|
+
leaderEpoch,
|
|
959
|
+
});
|
|
960
|
+
if (adopted.destructiveKind !== "manual-thread-cleanup")
|
|
961
|
+
return undefined;
|
|
556
962
|
fence = adopted;
|
|
557
|
-
} catch {
|
|
963
|
+
} catch {
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
558
966
|
}
|
|
559
967
|
return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
|
|
560
968
|
};
|
|
@@ -566,9 +974,13 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
|
566
974
|
if (!exact) return "authority-blocked";
|
|
567
975
|
const owner = deps.getOwner();
|
|
568
976
|
const leaderEpoch = deps.getLeaderEpoch();
|
|
569
|
-
const owns =
|
|
570
|
-
exact.owner.
|
|
571
|
-
|
|
977
|
+
const owns =
|
|
978
|
+
exact.owner.processId === owner.processId &&
|
|
979
|
+
exact.owner.processBirthId === owner.processBirthId &&
|
|
980
|
+
exact.leaderEpoch === leaderEpoch;
|
|
981
|
+
return owns || deps.canAdoptFence(exact)
|
|
982
|
+
? exact.phase
|
|
983
|
+
: "authority-blocked";
|
|
572
984
|
},
|
|
573
985
|
findCommitReady(candidate, workSetId) {
|
|
574
986
|
const fence = adoptExactFence(candidate, workSetId);
|
|
@@ -576,58 +988,92 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
|
576
988
|
},
|
|
577
989
|
async acquire(candidate, workSetId) {
|
|
578
990
|
const leaderEpoch = deps.getLeaderEpoch();
|
|
579
|
-
if (
|
|
580
|
-
|
|
991
|
+
if (
|
|
992
|
+
leaderEpoch === undefined ||
|
|
993
|
+
candidate.profileName.length === 0 ||
|
|
994
|
+
candidate.profileName !== deps.getProfileName()
|
|
995
|
+
)
|
|
996
|
+
return { kind: "blocked" };
|
|
581
997
|
const operationId = `thread-cleanup-fence:${createHash("sha256")
|
|
582
|
-
.update(workSetId)
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
998
|
+
.update(workSetId)
|
|
999
|
+
.update("\0")
|
|
1000
|
+
.update(candidate.bindingKey)
|
|
1001
|
+
.digest("hex")}`;
|
|
1002
|
+
const existing =
|
|
1003
|
+
adoptExactFence(candidate, workSetId) ?? deps.ledger.read().fence;
|
|
1004
|
+
if (
|
|
1005
|
+
existing &&
|
|
1006
|
+
existing.operationId === operationId &&
|
|
1007
|
+
existing.destructiveKind === "manual-thread-cleanup" &&
|
|
1008
|
+
existing.retirementIntentId === workSetId &&
|
|
1009
|
+
existing.bindingKey === candidate.bindingKey &&
|
|
1010
|
+
existing.slot === candidate.slot &&
|
|
1011
|
+
targetKey(existing.target) === targetKey(candidate.target) &&
|
|
1012
|
+
existing.leaderEpoch === leaderEpoch &&
|
|
1013
|
+
existing.phase !== "fenced"
|
|
1014
|
+
) {
|
|
589
1015
|
return { kind: "already-issued" };
|
|
590
1016
|
}
|
|
591
1017
|
let acquired;
|
|
592
1018
|
try {
|
|
593
|
-
acquired = deps.ledger.acquireThreadCleanupFence({
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1019
|
+
acquired = deps.ledger.acquireThreadCleanupFence({
|
|
1020
|
+
operationId,
|
|
1021
|
+
cleanupWorkSetId: workSetId,
|
|
1022
|
+
bindingKey: candidate.bindingKey,
|
|
1023
|
+
slot: candidate.slot,
|
|
1024
|
+
target: candidate.target,
|
|
1025
|
+
leaderEpoch,
|
|
1026
|
+
cleanupRequestedAtMs: now(),
|
|
1027
|
+
});
|
|
1028
|
+
} catch {
|
|
1029
|
+
return { kind: "blocked" };
|
|
1030
|
+
}
|
|
597
1031
|
if (acquired.kind === "blocked") return { kind: "blocked" };
|
|
598
|
-
if (
|
|
599
|
-
|
|
1032
|
+
if (
|
|
1033
|
+
deps.getLeaderEpoch() !== leaderEpoch ||
|
|
1034
|
+
!(await deps.revalidateUnderFence(candidate))
|
|
1035
|
+
) {
|
|
1036
|
+
if (acquired.fence.phase === "fenced")
|
|
1037
|
+
deps.ledger.releaseUnissuedThreadCleanupFence(acquired.fence);
|
|
600
1038
|
return { kind: "blocked" };
|
|
601
1039
|
}
|
|
602
|
-
const issued = deps.ledger.issueThreadCleanupDeletionPermit(
|
|
1040
|
+
const issued = deps.ledger.issueThreadCleanupDeletionPermit(
|
|
1041
|
+
acquired.fence,
|
|
1042
|
+
);
|
|
603
1043
|
return issued.kind === "issued"
|
|
604
1044
|
? { kind: "issued", fence: issued.fence, permit: issued.permit }
|
|
605
1045
|
: { kind: "already-issued" };
|
|
606
1046
|
},
|
|
607
1047
|
async settleDeleted(fence, commit) {
|
|
608
1048
|
const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
|
|
609
|
-
if (!await commit()) return "commit-pending";
|
|
1049
|
+
if (!(await commit())) return "commit-pending";
|
|
610
1050
|
deps.ledger.completeThreadCleanupFence(ready);
|
|
611
1051
|
return "completed";
|
|
612
1052
|
},
|
|
613
1053
|
};
|
|
614
1054
|
}
|
|
615
1055
|
|
|
616
|
-
|
|
617
1056
|
export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
|
|
618
1057
|
store: TelegramThreadCleanupWorkStore;
|
|
619
1058
|
permitRuntime: ReturnType<typeof createTelegramThreadCleanupPermitRuntime>;
|
|
620
|
-
resolveFullBinding(
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1059
|
+
resolveFullBinding(
|
|
1060
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
1061
|
+
): Promise<TBinding | undefined>;
|
|
1062
|
+
deleteWithPermit(
|
|
1063
|
+
permit: TelegramThreadCleanupDeletionPermit,
|
|
1064
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
1065
|
+
): Promise<void>;
|
|
1066
|
+
commitBinding(
|
|
1067
|
+
candidate: TelegramThreadCleanupCandidate,
|
|
1068
|
+
currentBinding?: TBinding,
|
|
1069
|
+
): Promise<boolean>;
|
|
625
1070
|
}
|
|
626
1071
|
|
|
627
1072
|
export function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(
|
|
628
1073
|
deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
|
|
629
1074
|
): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult> {
|
|
630
|
-
return operationId =>
|
|
1075
|
+
return (operationId) =>
|
|
1076
|
+
cleanReviewedInactiveThreads({ ...deps, operationId });
|
|
631
1077
|
}
|
|
632
1078
|
|
|
633
1079
|
export interface TelegramInactiveThreadCleanupResult {
|
|
@@ -637,68 +1083,124 @@ export interface TelegramInactiveThreadCleanupResult {
|
|
|
637
1083
|
recovery?: "commit-ready" | "deletion-outcome-unknown" | "authority-blocked";
|
|
638
1084
|
}
|
|
639
1085
|
|
|
640
|
-
export async function cleanReviewedInactiveThreads<TBinding>(
|
|
641
|
-
|
|
642
|
-
|
|
1086
|
+
export async function cleanReviewedInactiveThreads<TBinding>(
|
|
1087
|
+
input: {
|
|
1088
|
+
operationId: string;
|
|
1089
|
+
} & TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
|
|
1090
|
+
): Promise<TelegramInactiveThreadCleanupResult> {
|
|
643
1091
|
if (!/^thread-cleanup:[a-f0-9]{32}$/u.test(input.operationId))
|
|
644
|
-
throw new Error(
|
|
645
|
-
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
"Telegram Thread cleanup confirmation identity is invalid.",
|
|
1094
|
+
);
|
|
1095
|
+
const workSet = input.store
|
|
1096
|
+
.list()
|
|
1097
|
+
.find((candidate) => candidate.operationId === input.operationId);
|
|
646
1098
|
if (!workSet) throw new Error("Telegram Thread cleanup review is missing.");
|
|
647
1099
|
let deleted = 0;
|
|
648
1100
|
let outcomeUnknown = 0;
|
|
649
1101
|
let blocked = 0;
|
|
650
1102
|
for (const entry of workSet.entries) {
|
|
651
1103
|
if (entry.state === "deleted") {
|
|
652
|
-
const ready = input.permitRuntime.findCommitReady(
|
|
653
|
-
|
|
1104
|
+
const ready = input.permitRuntime.findCommitReady(
|
|
1105
|
+
entry,
|
|
1106
|
+
input.operationId,
|
|
1107
|
+
);
|
|
1108
|
+
if (ready)
|
|
1109
|
+
await input.permitRuntime.settleDeleted(ready, async () => true);
|
|
654
1110
|
deleted += 1;
|
|
655
1111
|
continue;
|
|
656
1112
|
}
|
|
657
1113
|
if (entry.state === "outcome-unknown") {
|
|
658
|
-
const ready = input.permitRuntime.findCommitReady(
|
|
659
|
-
|
|
1114
|
+
const ready = input.permitRuntime.findCommitReady(
|
|
1115
|
+
entry,
|
|
1116
|
+
input.operationId,
|
|
1117
|
+
);
|
|
1118
|
+
if (!ready) {
|
|
1119
|
+
outcomeUnknown += 1;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
660
1122
|
const settlement = await input.permitRuntime.settleDeleted(ready, () =>
|
|
661
|
-
commitTelegramInactiveThreadCleanup({
|
|
662
|
-
|
|
1123
|
+
commitTelegramInactiveThreadCleanup({
|
|
1124
|
+
store: input.store,
|
|
1125
|
+
operationId: input.operationId,
|
|
1126
|
+
bindingKey: entry.bindingKey,
|
|
1127
|
+
commitBinding: () => input.commitBinding(entry),
|
|
1128
|
+
}),
|
|
1129
|
+
);
|
|
663
1130
|
if (settlement === "completed") deleted += 1;
|
|
664
1131
|
else outcomeUnknown += 1;
|
|
665
1132
|
continue;
|
|
666
1133
|
}
|
|
667
1134
|
const fullBinding = await input.resolveFullBinding(entry);
|
|
668
|
-
if (!fullBinding) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1135
|
+
if (!fullBinding) {
|
|
1136
|
+
blocked += 1;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const authority = await input.permitRuntime.acquire(
|
|
1140
|
+
entry,
|
|
1141
|
+
input.operationId,
|
|
1142
|
+
);
|
|
1143
|
+
if (authority.kind === "blocked") {
|
|
1144
|
+
blocked += 1;
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (authority.kind === "already-issued") {
|
|
1148
|
+
outcomeUnknown += 1;
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
672
1151
|
try {
|
|
673
|
-
const recorded = input.store.recordDeletionIssued({
|
|
674
|
-
|
|
1152
|
+
const recorded = input.store.recordDeletionIssued({
|
|
1153
|
+
operationId: input.operationId,
|
|
1154
|
+
bindingKey: entry.bindingKey,
|
|
1155
|
+
bindingUpdatedAtMs: entry.bindingUpdatedAtMs,
|
|
675
1156
|
permit: authority.permit,
|
|
676
1157
|
});
|
|
677
1158
|
if (!recorded.recorded) {
|
|
678
1159
|
if (recorded.entry.state === "deleted") {
|
|
679
|
-
const settlement = await input.permitRuntime.settleDeleted(
|
|
1160
|
+
const settlement = await input.permitRuntime.settleDeleted(
|
|
1161
|
+
authority.fence,
|
|
1162
|
+
async () => true,
|
|
1163
|
+
);
|
|
680
1164
|
if (settlement === "completed") deleted += 1;
|
|
681
1165
|
else outcomeUnknown += 1;
|
|
682
1166
|
} else outcomeUnknown += 1;
|
|
683
1167
|
continue;
|
|
684
1168
|
}
|
|
685
1169
|
await input.deleteWithPermit(authority.permit, entry);
|
|
686
|
-
const settlement = await input.permitRuntime.settleDeleted(
|
|
687
|
-
|
|
688
|
-
|
|
1170
|
+
const settlement = await input.permitRuntime.settleDeleted(
|
|
1171
|
+
authority.fence,
|
|
1172
|
+
() =>
|
|
1173
|
+
commitTelegramInactiveThreadCleanup({
|
|
1174
|
+
store: input.store,
|
|
1175
|
+
operationId: input.operationId,
|
|
1176
|
+
bindingKey: entry.bindingKey,
|
|
1177
|
+
commitBinding: () => input.commitBinding(entry, fullBinding),
|
|
1178
|
+
}),
|
|
1179
|
+
);
|
|
689
1180
|
if (settlement === "completed") deleted += 1;
|
|
690
1181
|
else outcomeUnknown += 1;
|
|
691
1182
|
} catch {
|
|
692
1183
|
outcomeUnknown += 1;
|
|
693
1184
|
}
|
|
694
1185
|
}
|
|
695
|
-
const retainedEntries =
|
|
696
|
-
|
|
1186
|
+
const retainedEntries =
|
|
1187
|
+
input.store
|
|
1188
|
+
.list()
|
|
1189
|
+
.find((candidate) => candidate.operationId === input.operationId)
|
|
1190
|
+
?.entries ?? [];
|
|
697
1191
|
if (outcomeUnknown > 0) {
|
|
698
|
-
const diagnosis =
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1192
|
+
const diagnosis =
|
|
1193
|
+
retainedEntries
|
|
1194
|
+
.map((entry) =>
|
|
1195
|
+
input.permitRuntime.diagnoseRecovery(entry, input.operationId),
|
|
1196
|
+
)
|
|
1197
|
+
.find((value) => value !== "none") ?? "none";
|
|
1198
|
+
const recovery =
|
|
1199
|
+
diagnosis === "commit-ready"
|
|
1200
|
+
? "commit-ready"
|
|
1201
|
+
: diagnosis === "deletion-issued"
|
|
1202
|
+
? "deletion-outcome-unknown"
|
|
1203
|
+
: "authority-blocked";
|
|
702
1204
|
return { deleted, outcomeUnknown, blocked, recovery };
|
|
703
1205
|
}
|
|
704
1206
|
return { deleted, outcomeUnknown, blocked };
|