@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.
Files changed (204) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +7 -11
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +639 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +29 -3
  44. package/dist/lib/locks.js +289 -108
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +65 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +21 -37
  76. package/dist/lib/polling.js +59 -50
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +252 -127
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +1 -3
  95. package/dist/lib/sync.js +67 -43
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +148 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +31 -29
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1206 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +677 -203
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +128 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +144 -147
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +516 -301
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +448 -361
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +302 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. 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 { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
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 { TelegramWorkspaceDeletionPermit,
21
+ import type {
22
+ TelegramWorkspaceDeletionPermit,
20
23
  TelegramWorkspaceDestructiveFence,
21
- TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
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 = "clear" | "protected" | "unknown";
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 Number.isSafeInteger(target.chatId) && Number.isSafeInteger(target.threadId) &&
66
- target.threadId > 0;
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) return true;
74
- if (typeof value.sessionId !== "string" || !value.sessionId ||
75
- value.sessionId !== value.sessionId.trim() ||
76
- Buffer.byteLength(value.sessionId, "utf8") > 256 ||
77
- typeof value.sessionKey !== "string" || !/^[a-f0-9]{64}$/u.test(value.sessionKey)) {
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 createHash("sha256").update(value.sessionId).digest("hex") ===
81
- value.sessionKey;
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 (!binding.bindingKey || !binding.cwd || !binding.workspaceKey || !binding.instanceSlot ||
100
- typeof slot !== "string" || !slot || !validSessionIdentity(binding) ||
101
- !validTarget(binding.target) || bindingKeys.has(binding.bindingKey) || bindingTargets.has(key)) return [];
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<string, TelegramThreadCleanupProtectionEvidence>();
130
+ const evidenceByBinding = new Map<
131
+ string,
132
+ TelegramThreadCleanupProtectionEvidence
133
+ >();
106
134
  for (const evidence of input.protection) {
107
- if (!evidence.bindingKey || evidenceByBinding.has(evidence.bindingKey) || !validTarget(evidence.target)) return [];
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 (typeof slot !== "string" || !slot || !Number.isSafeInteger(inactiveSinceMs) ||
122
- inactiveSinceMs === undefined || inactiveSinceMs < 0 ||
123
- !Number.isSafeInteger(binding.updatedAtMs) || binding.updatedAtMs < inactiveSinceMs) continue;
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 (!evidence || targetKey(evidence.target) !== targetKey(binding.target) ||
126
- evidence.liveOwner !== "clear" || evidence.acceptedWork !== "clear" ||
127
- evidence.deliveryAuthority !== "clear" || competing.has(targetKey(binding.target))) continue;
128
- candidates.push({ profileName: input.profileName, bindingKey: binding.bindingKey,
129
- cwd: binding.cwd, workspaceKey: binding.workspaceKey,
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, slot, target: { ...binding.target }, inactiveSinceMs,
134
- bindingUpdatedAtMs: binding.updatedAtMs });
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) => left.bindingKey.localeCompare(right.bindingKey));
189
+ return candidates.sort((left, right) =>
190
+ left.bindingKey.localeCompare(right.bindingKey),
191
+ );
137
192
  }
138
193
 
139
- export function captureTelegramInactiveThreadCleanupEvidence<
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
- >(input: {
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 { target: { chatId: number; threadId: number } }[];
150
- listPendingProvisions(): readonly { target?: { chatId: number; threadId: number } }[];
151
- listPendingCleanups(): readonly { target: { chatId: number; threadId: number } }[];
152
- }): Parameters<typeof planTelegramInactiveThreadCleanup>[0] {
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, workspaceKey: binding.workspaceKey,
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 }), bindingKey: binding.bindingKey, target: { ...binding.target },
161
- ...(binding.inactiveSinceMs === undefined ? {} : { inactiveSinceMs: binding.inactiveSinceMs }),
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 { return { bindingKey: binding.bindingKey, target: { ...binding.target },
167
- ...input.getProtection(source) }; }
168
- catch { return { bindingKey: binding.bindingKey, target: { ...binding.target },
169
- liveOwner: "unknown" as const, acceptedWork: "unknown" as const,
170
- deliveryAuthority: "unknown" as const }; }
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.listReservations().map(entry => ({ ...entry.target })),
177
- provisioningTargets: input.listPendingProvisions().flatMap(entry =>
178
- entry.target ? [{ ...entry.target }] : []),
179
- cleanupTargets: input.listPendingCleanups().map(entry => ({ ...entry.target })),
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
- >(deps: {
186
- getProfileName(): string;
187
- listBindings(): readonly TBinding[];
188
- getProtection(binding: TBinding): {
189
- liveOwner: TelegramThreadCleanupProtectionState;
190
- acceptedWork: TelegramThreadCleanupProtectionState;
191
- deliveryAuthority: TelegramThreadCleanupProtectionState;
192
- };
193
- listReservations(): readonly { target: { chatId: number; threadId: number } }[];
194
- listPendingProvisions(): readonly { target?: { chatId: number; threadId: number } }[];
195
- listPendingCleanups(): readonly { target: { chatId: number; threadId: number } }[];
196
- getWorkStore(): TelegramThreadCleanupWorkStore;
197
- runWorkspaceOperation<T>(input: { operationId: string; operationKind: string;
198
- scopes: readonly [{ kind: "profile" }] }, operation: () => Promise<T>): Promise<T>;
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({ operationId: `thread-cleanup-review:${randomUUID()}`,
203
- operationKind: "thread-cleanup-review", scopes: [{ kind: "profile" }] }, async () => {
204
- const evidence = captureTelegramInactiveThreadCleanupEvidence({
205
- profileName: deps.getProfileName(), listBindings: deps.listBindings,
206
- getProtection: deps.getProtection, listReservations: deps.listReservations,
207
- listPendingProvisions: deps.listPendingProvisions,
208
- listPendingCleanups: deps.listPendingCleanups,
209
- });
210
- const candidates = planTelegramInactiveThreadCleanup(evidence);
211
- if (candidates.length === 0) return { count: 0 };
212
- const digest = createHash("sha256").update(JSON.stringify(candidates)).digest("hex").slice(0, 32);
213
- const operationId = `thread-cleanup:${digest}`;
214
- deps.getWorkStore().prepare(operationId, candidates);
215
- return { count: candidates.length, operationId };
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 = "prepared" | "outcome-unknown" | "deleted";
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 = TelegramWorkspaceDeletionPermit;
342
+ export type TelegramThreadCleanupDeletionPermit =
343
+ TelegramWorkspaceDeletionPermit;
243
344
  export type TelegramThreadCleanupFence = TelegramWorkspaceRetirementFence;
244
345
 
245
346
  export interface TelegramThreadCleanupWorkStore {
246
- prepare(operationId: string, candidates: readonly TelegramThreadCleanupCandidate[]):
247
- { prepared: boolean; workSet: TelegramThreadCleanupWorkSet };
248
- recordDeletionIssued(input: { operationId: string; bindingKey: string; bindingUpdatedAtMs: number;
249
- permit: TelegramThreadCleanupDeletionPermit }):
250
- { recorded: boolean; entry: TelegramThreadCleanupWorkEntry };
251
- confirmDeleted(input: { operationId: string; bindingKey: string }):
252
- { confirmed: boolean; entry: TelegramThreadCleanupWorkEntry };
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(value: unknown, profileName: string): TelegramThreadCleanupWorkSet {
257
- if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
258
- typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
259
- !Array.isArray(value.entries) || value.entries.length === 0) throw new Error("Telegram Thread cleanup work-set schema is invalid.");
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 (!isObject(raw) || !onlyKeys(raw, ["profileName", "bindingKey", "cwd", "workspaceKey",
263
- "sessionId", "sessionKey", "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
264
- "issuedAtMs", "permitOperationId", "permitIntentId", "permitLeaderEpoch", "deletedAtMs"]) || raw.profileName !== profileName || typeof raw.bindingKey !== "string" ||
265
- !raw.bindingKey || typeof raw.cwd !== "string" || !raw.cwd || typeof raw.workspaceKey !== "string" ||
266
- !raw.workspaceKey || !validSessionIdentity(raw) ||
267
- typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
268
- typeof raw.slot !== "string" || !raw.slot || !isObject(raw.target) ||
269
- !onlyKeys(raw.target, ["chatId", "threadId"]) || !validTarget(raw.target as unknown as { chatId: number; threadId: number }) ||
270
- !safeTime(raw.inactiveSinceMs) || !safeTime(raw.bindingUpdatedAtMs) ||
271
- (raw.bindingUpdatedAtMs as number) < (raw.inactiveSinceMs as number) || !safeTime(raw.updatedAtMs))
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 = { profileName, bindingKey: raw.bindingKey, cwd: raw.cwd, workspaceKey: raw.workspaceKey,
274
- ...(typeof raw.sessionId === "string" && typeof raw.sessionKey === "string"
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, slot: raw.slot, target: { chatId: raw.target.chatId as number,
278
- threadId: raw.target.threadId as number }, inactiveSinceMs: raw.inactiveSinceMs as number,
279
- bindingUpdatedAtMs: raw.bindingUpdatedAtMs as number, updatedAtMs: raw.updatedAtMs as number };
280
- if (base.updatedAtMs < base.bindingUpdatedAtMs) throw new Error("Telegram Thread cleanup entry clock is invalid.");
281
- const noPermit = raw.permitOperationId === undefined && raw.permitIntentId === undefined &&
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 (raw.state === "prepared" && raw.issuedAtMs === undefined && raw.deletedAtMs === undefined && noPermit)
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 = typeof raw.permitOperationId === "string" && !!raw.permitOperationId &&
286
- typeof raw.permitIntentId === "string" && !!raw.permitIntentId &&
287
- (typeof raw.permitLeaderEpoch === "string" || Number.isSafeInteger(raw.permitLeaderEpoch));
288
- if (raw.state === "outcome-unknown" && safeTime(raw.issuedAtMs) && validPermit &&
289
- raw.issuedAtMs === base.updatedAtMs && raw.deletedAtMs === undefined)
290
- return { ...base, state: "outcome-unknown" as const, issuedAtMs: raw.issuedAtMs,
291
- permitOperationId: raw.permitOperationId as string, permitIntentId: raw.permitIntentId as string,
292
- permitLeaderEpoch: raw.permitLeaderEpoch as number | string };
293
- if (raw.state === "deleted" && safeTime(raw.issuedAtMs) && validPermit && safeTime(raw.deletedAtMs) &&
294
- raw.deletedAtMs === base.updatedAtMs && raw.deletedAtMs >= raw.issuedAtMs)
295
- return { ...base, state: "deleted" as const, issuedAtMs: raw.issuedAtMs,
296
- permitOperationId: raw.permitOperationId as string, permitIntentId: raw.permitIntentId as string,
297
- permitLeaderEpoch: raw.permitLeaderEpoch as number | string, deletedAtMs: raw.deletedAtMs };
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 (entries.some(entry => entry.updatedAtMs < createdAtMs) ||
301
- new Set(entries.map(entry => entry.bindingKey)).size !== entries.length ||
302
- new Set(entries.map(entry => targetKey(entry.target))).size !== entries.length)
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(left: readonly TelegramThreadCleanupCandidate[], right: readonly TelegramThreadCleanupCandidate[]): boolean {
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(value: unknown, profileName: string, tokenSha256: string): TelegramThreadCleanupWorkFile {
312
- if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
313
- value.version !== 1 || value.profileName !== profileName || value.tokenSha256 !== tokenSha256 || !Array.isArray(value.workSets))
314
- throw new Error("Telegram Thread cleanup store identity or schema does not match.");
315
- const workSets = value.workSets.map(workSet => validateWorkSet(workSet, profileName));
316
- if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
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; runtimeDir?: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
323
- maxBytes?: number; getNowMs?: () => number;
324
- onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
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(options.path, options.runtimeDir);
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 (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
332
- !Number.isSafeInteger(maxWorkSets) || maxWorkSets <= 0 ||
333
- !Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("Telegram Thread cleanup store options are invalid.");
334
- const empty = (): TelegramThreadCleanupWorkFile => ({ version: 1, profileName: options.profileName,
335
- tokenSha256: options.tokenSha256, workSets: [] });
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 before;
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
- const opened = fstatSync(fd, { bigint: true });
352
- if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size ||
353
- opened.mtimeNs !== before.mtimeNs) throw new Error("Telegram Thread cleanup store changed during inspection.");
354
- value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
355
- } finally { closeSync(fd); }
356
- return parseTelegramThreadCleanupWorkFile(value, options.profileName, options.tokenSha256);
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) throw new Error("Telegram Thread cleanup work-set capacity reached.");
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) throw new Error("Telegram Thread cleanup byte capacity reached.");
362
- const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
363
- mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
364
- mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
365
- try {
366
- writeFileSync(temporaryPath, serialized, { mode: 0o600 });
367
- chmodSync(temporaryPath, 0o600);
368
- options.onPublicationBoundary?.("after-write-before-rename");
369
- if (!renameTelegramPathWithRetry(temporaryPath, options.path))
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>(operation: (file: TelegramThreadCleanupWorkFile) => T): T =>
376
- withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
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 (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
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(workSet => workSet.operationId === operationId);
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(({ state: _state, updatedAtMs: _updated,
385
- issuedAtMs: _issued, permitOperationId: _permitOperation,
386
- permitIntentId: _permitIntent, permitLeaderEpoch: _permitEpoch,
387
- deletedAtMs: _deleted, ...candidate }) => candidate);
388
- if (!sameCandidates(projected, candidates)) throw new Error("Telegram Thread cleanup operation conflicts.");
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)) throw new Error("Telegram Thread cleanup clock is invalid.");
393
- const workSet = validateWorkSet({ operationId, createdAtMs: atMs,
394
- entries: candidates.map(candidate => ({ ...structuredClone(candidate), state: "prepared", updatedAtMs: atMs })) },
395
- options.profileName);
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(candidate => candidate.operationId === input.operationId);
403
- const entry = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
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 (!workSet || !entry || entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
406
- permit.destructiveKind !== "manual-thread-cleanup" || permit.profileKey !== entry.profileName || permit.bindingKey !== entry.bindingKey ||
407
- permit.slot !== entry.slot || targetKey(permit.target) !== targetKey(entry.target) ||
408
- !permit.operationId || !permit.retirementIntentId || !safeTime(permit.issuedAtMs))
409
- throw new Error("Telegram Thread cleanup entry or deletion permit is stale or mismatched.");
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 = entry.permitOperationId === permit.operationId &&
695
+ const exact =
696
+ entry.permitOperationId === permit.operationId &&
412
697
  entry.permitIntentId === permit.retirementIntentId &&
413
698
  entry.permitLeaderEpoch === permit.leaderEpoch;
414
- if (!exact) throw new Error("Telegram Thread cleanup deletion permit conflicts with retained work.");
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 (!safeTime(atMs) || atMs < entry.updatedAtMs || atMs < permit.issuedAtMs)
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"; entry.issuedAtMs = atMs; entry.updatedAtMs = atMs;
421
- entry.permitOperationId = permit.operationId; entry.permitIntentId = permit.retirementIntentId;
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); return { recorded: true, entry: structuredClone(entry) };
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.find(candidate => candidate.operationId === input.operationId)
429
- ?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
430
- if (!entry) throw new Error("Telegram Thread cleanup entry is missing.");
431
- if (entry.state === "deleted") return { confirmed: false, entry: structuredClone(entry) };
432
- if (entry.state !== "outcome-unknown") throw new Error("Telegram Thread cleanup deletion confirmation is premature.");
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) throw new Error("Telegram Thread cleanup clock is invalid.");
435
- entry.state = "deleted"; entry.deletedAtMs = atMs; entry.updatedAtMs = atMs;
436
- publish(file); return { confirmed: true, entry: structuredClone(entry) };
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(withTelegramFileTransaction(
441
- publicationPaths.transactionPath,
442
- () => read().workSets,
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({ operationId: input.operationId, bindingKey: input.bindingKey });
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<Parameters<typeof planTelegramInactiveThreadCleanup>[0]>;
465
- acquireDeletionPermit(candidate: TelegramThreadCleanupCandidate): Promise<
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(permit: TelegramThreadCleanupDeletionPermit,
470
- candidate: TelegramThreadCleanupCandidate): Promise<void>;
471
- }): Promise<{ status: "deleted" | "blocked" | "outcome-unknown";
472
- entry?: TelegramThreadCleanupWorkEntry }> {
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.list().find(candidate => candidate.operationId === input.operationId);
475
- const retained = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
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") return { status: "deleted", entry: retained };
478
- if (retained.state === "outcome-unknown") return { status: "outcome-unknown", entry: retained };
479
- const planned = planTelegramInactiveThreadCleanup(await input.loadFreshEvidence());
480
- const fresh = planned.find(candidate => candidate.bindingKey === retained.bindingKey);
481
- const retainedCandidate = (({ state: _state, updatedAtMs: _updated, issuedAtMs: _issued,
482
- permitOperationId: _permitOperation, permitIntentId: _permitIntent,
483
- permitLeaderEpoch: _permitEpoch, deletedAtMs: _deleted, ...candidate }) => candidate)(retained);
484
- if (!fresh || !sameCandidates([fresh], [retainedCandidate])) return { status: "blocked" };
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({ operationId: input.operationId,
488
- bindingKey: input.bindingKey, bindingUpdatedAtMs: retained.bindingUpdatedAtMs,
489
- permit: permitResult.permit });
490
- if (!recorded.recorded) return { status: "outcome-unknown", entry: recorded.entry };
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({ operationId: input.operationId,
493
- bindingKey: input.bindingKey });
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: { operationId: string; cleanupWorkSetId: string;
502
- bindingKey: string; slot: string; target: { chatId: number; threadId: number };
503
- leaderEpoch: number | string; cleanupRequestedAtMs: number }):
504
- | { kind: "acquired"; fence: TelegramThreadCleanupFence; resumed: boolean }
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(fence: TelegramThreadCleanupFence, replacement: {
507
- owner: { processId: number; processBirthId: string }; leaderEpoch: number | string;
508
- }): TelegramThreadCleanupFence;
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
- | { kind: "issued"; fence: TelegramThreadCleanupFence; permit: TelegramThreadCleanupDeletionPermit }
869
+ | {
870
+ kind: "issued";
871
+ fence: TelegramThreadCleanupFence;
872
+ permit: TelegramThreadCleanupDeletionPermit;
873
+ }
511
874
  | { kind: "already-issued"; fence: TelegramThreadCleanupFence };
512
- confirmThreadCleanupAbsence(fence: TelegramThreadCleanupFence): TelegramThreadCleanupFence;
513
- releaseUnissuedThreadCleanupFence(fence: TelegramThreadCleanupFence): boolean;
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(candidate: TelegramThreadCleanupCandidate): Promise<boolean>;
887
+ revalidateUnderFence(
888
+ candidate: TelegramThreadCleanupCandidate,
889
+ ): Promise<boolean>;
521
890
  getNowMs?: () => number;
522
891
  }): {
523
- acquire(candidate: TelegramThreadCleanupCandidate, workSetId: string): Promise<
524
- | { kind: "issued"; fence: TelegramThreadCleanupFence; permit: TelegramThreadCleanupDeletionPermit }
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(candidate: TelegramThreadCleanupCandidate, workSetId: string):
529
- "none" | "fenced" | "deletion-issued" | "commit-ready" | "authority-blocked";
530
- findCommitReady(candidate: TelegramThreadCleanupCandidate,
531
- workSetId: string): TelegramThreadCleanupFence | undefined;
532
- settleDeleted(fence: TelegramThreadCleanupFence,
533
- commit: () => Promise<boolean>): Promise<"completed" | "commit-pending">;
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 = (candidate: TelegramThreadCleanupCandidate, workSetId: string) => {
537
- const operationId = `thread-cleanup-fence:${createHash("sha256").update(
538
- `${workSetId}\u0000${candidate.bindingKey}`, "utf8").digest("hex")}`;
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 && fence.destructiveKind === "manual-thread-cleanup" &&
541
- fence.profileKey === candidate.profileName && fence.retirementIntentId === workSetId && fence.bindingKey === candidate.bindingKey &&
542
- fence.slot === candidate.slot && targetKey(fence.target) === targetKey(candidate.target)
543
- ? fence : undefined;
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 = (candidate: TelegramThreadCleanupCandidate, workSetId: string) => {
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 (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
551
- fence.owner.processBirthId !== owner.processBirthId) {
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, { owner, leaderEpoch });
555
- if (adopted.destructiveKind !== "manual-thread-cleanup") return undefined;
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 { return undefined; }
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 = exact.owner.processId === owner.processId &&
570
- exact.owner.processBirthId === owner.processBirthId && exact.leaderEpoch === leaderEpoch;
571
- return owns || deps.canAdoptFence(exact) ? exact.phase : "authority-blocked";
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 (leaderEpoch === undefined || candidate.profileName.length === 0 ||
580
- candidate.profileName !== deps.getProfileName()) return { kind: "blocked" };
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).update("\0").update(candidate.bindingKey).digest("hex")}`;
583
- const existing = adoptExactFence(candidate, workSetId) ?? deps.ledger.read().fence;
584
- if (existing && existing.operationId === operationId &&
585
- existing.destructiveKind === "manual-thread-cleanup" &&
586
- existing.retirementIntentId === workSetId && existing.bindingKey === candidate.bindingKey &&
587
- existing.slot === candidate.slot && targetKey(existing.target) === targetKey(candidate.target) &&
588
- existing.leaderEpoch === leaderEpoch && existing.phase !== "fenced") {
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({ operationId,
594
- cleanupWorkSetId: workSetId, bindingKey: candidate.bindingKey, slot: candidate.slot,
595
- target: candidate.target, leaderEpoch, cleanupRequestedAtMs: now() });
596
- } catch { return { kind: "blocked" }; }
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 (deps.getLeaderEpoch() !== leaderEpoch || !await deps.revalidateUnderFence(candidate)) {
599
- if (acquired.fence.phase === "fenced") deps.ledger.releaseUnissuedThreadCleanupFence(acquired.fence);
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(acquired.fence);
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(candidate: TelegramThreadCleanupCandidate): Promise<TBinding | undefined>;
621
- deleteWithPermit(permit: TelegramThreadCleanupDeletionPermit,
622
- candidate: TelegramThreadCleanupCandidate): Promise<void>;
623
- commitBinding(candidate: TelegramThreadCleanupCandidate,
624
- currentBinding?: TBinding): Promise<boolean>;
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 => cleanReviewedInactiveThreads({ ...deps, 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>(input: {
641
- operationId: string;
642
- } & TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>): Promise<TelegramInactiveThreadCleanupResult> {
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("Telegram Thread cleanup confirmation identity is invalid.");
645
- const workSet = input.store.list().find(candidate => candidate.operationId === input.operationId);
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(entry, input.operationId);
653
- if (ready) await input.permitRuntime.settleDeleted(ready, async () => true);
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(entry, input.operationId);
659
- if (!ready) { outcomeUnknown += 1; continue; }
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({ store: input.store, operationId: input.operationId,
662
- bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry) }));
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) { blocked += 1; continue; }
669
- const authority = await input.permitRuntime.acquire(entry, input.operationId);
670
- if (authority.kind === "blocked") { blocked += 1; continue; }
671
- if (authority.kind === "already-issued") { outcomeUnknown += 1; continue; }
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({ operationId: input.operationId,
674
- bindingKey: entry.bindingKey, bindingUpdatedAtMs: entry.bindingUpdatedAtMs,
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(authority.fence, async () => true);
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(authority.fence, () =>
687
- commitTelegramInactiveThreadCleanup({ store: input.store, operationId: input.operationId,
688
- bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry, fullBinding) }));
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 = input.store.list().find(
696
- candidate => candidate.operationId === input.operationId)?.entries ?? [];
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 = retainedEntries.map(entry =>
699
- input.permitRuntime.diagnoseRecovery(entry, input.operationId)).find(value => value !== "none") ?? "none";
700
- const recovery = diagnosis === "commit-ready" ? "commit-ready"
701
- : diagnosis === "deletion-issued" ? "deletion-outcome-unknown" : "authority-blocked";
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 };