@llblab/pi-kit 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
- package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
- package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
- package/node_modules/@llblab/pi-state-flow/README.md +203 -107
- package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
- package/node_modules/@llblab/pi-state-flow/package.json +8 -2
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +13 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -3
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/README.md +12 -8
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +12 -5
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
- package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +28 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
- package/node_modules/@llblab/pi-telegram/lib/config.ts +177 -24
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +51 -10
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +52 -18
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
- package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +279 -4
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
- package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
- package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
- package/node_modules/@llblab/skills/package.json +2 -3
- package/package.json +6 -5
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof-only candidate planning for inactive Telegram Workspace Thread cleanup
|
|
3
|
+
* Zones: telegram threads, workspace lifecycle
|
|
4
|
+
* Owns fail-closed cleanup eligibility projection without persistence or Bot API effects
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync,
|
|
8
|
+
readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
|
|
13
|
+
import type { TelegramWorkspaceDeletionPermit,
|
|
14
|
+
TelegramWorkspaceDestructiveFence,
|
|
15
|
+
TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
|
|
16
|
+
|
|
17
|
+
export interface TelegramThreadCleanupBindingSnapshot {
|
|
18
|
+
cwd: string;
|
|
19
|
+
workspaceKey: string;
|
|
20
|
+
instanceSlot: string;
|
|
21
|
+
slot?: string;
|
|
22
|
+
bindingKey: string;
|
|
23
|
+
target: { chatId: number; threadId: number };
|
|
24
|
+
inactiveSinceMs?: number;
|
|
25
|
+
updatedAtMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TelegramThreadCleanupProtectionState = "clear" | "protected" | "unknown";
|
|
29
|
+
|
|
30
|
+
export interface TelegramThreadCleanupProtectionEvidence {
|
|
31
|
+
bindingKey: string;
|
|
32
|
+
target: { chatId: number; threadId: number };
|
|
33
|
+
liveOwner: TelegramThreadCleanupProtectionState;
|
|
34
|
+
acceptedWork: TelegramThreadCleanupProtectionState;
|
|
35
|
+
deliveryAuthority: TelegramThreadCleanupProtectionState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TelegramThreadCleanupCandidate {
|
|
39
|
+
profileName: string;
|
|
40
|
+
bindingKey: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
workspaceKey: string;
|
|
43
|
+
instanceSlot: string;
|
|
44
|
+
slot: string;
|
|
45
|
+
target: { chatId: number; threadId: number };
|
|
46
|
+
inactiveSinceMs: number;
|
|
47
|
+
bindingUpdatedAtMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function targetKey(target: { chatId: number; threadId: number }): string {
|
|
51
|
+
return `${target.chatId}:${target.threadId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validTarget(target: { chatId: number; threadId: number }): boolean {
|
|
55
|
+
return Number.isSafeInteger(target.chatId) && Number.isSafeInteger(target.threadId) &&
|
|
56
|
+
target.threadId > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Returns no candidates when any identity/evidence ambiguity exists. */
|
|
60
|
+
export function planTelegramInactiveThreadCleanup(input: {
|
|
61
|
+
profileName: string;
|
|
62
|
+
bindings: readonly TelegramThreadCleanupBindingSnapshot[];
|
|
63
|
+
protection: readonly TelegramThreadCleanupProtectionEvidence[];
|
|
64
|
+
reservedTargets?: readonly { chatId: number; threadId: number }[];
|
|
65
|
+
provisioningTargets?: readonly { chatId: number; threadId: number }[];
|
|
66
|
+
cleanupTargets?: readonly { chatId: number; threadId: number }[];
|
|
67
|
+
}): TelegramThreadCleanupCandidate[] {
|
|
68
|
+
if (!input.profileName) return [];
|
|
69
|
+
const bindingKeys = new Set<string>();
|
|
70
|
+
const bindingTargets = new Set<string>();
|
|
71
|
+
for (const binding of input.bindings) {
|
|
72
|
+
const key = targetKey(binding.target);
|
|
73
|
+
const slot = binding.slot;
|
|
74
|
+
if (!binding.bindingKey || !binding.cwd || !binding.workspaceKey || !binding.instanceSlot ||
|
|
75
|
+
typeof slot !== "string" || !slot ||
|
|
76
|
+
!validTarget(binding.target) || bindingKeys.has(binding.bindingKey) || bindingTargets.has(key)) return [];
|
|
77
|
+
bindingKeys.add(binding.bindingKey);
|
|
78
|
+
bindingTargets.add(key);
|
|
79
|
+
}
|
|
80
|
+
const evidenceByBinding = new Map<string, TelegramThreadCleanupProtectionEvidence>();
|
|
81
|
+
for (const evidence of input.protection) {
|
|
82
|
+
if (!evidence.bindingKey || evidenceByBinding.has(evidence.bindingKey) || !validTarget(evidence.target)) return [];
|
|
83
|
+
evidenceByBinding.set(evidence.bindingKey, evidence);
|
|
84
|
+
}
|
|
85
|
+
const competingTargets = [
|
|
86
|
+
...(input.reservedTargets ?? []),
|
|
87
|
+
...(input.provisioningTargets ?? []),
|
|
88
|
+
...(input.cleanupTargets ?? []),
|
|
89
|
+
];
|
|
90
|
+
if (competingTargets.some(target => !validTarget(target))) return [];
|
|
91
|
+
const competing = new Set(competingTargets.map(targetKey));
|
|
92
|
+
const candidates: TelegramThreadCleanupCandidate[] = [];
|
|
93
|
+
for (const binding of input.bindings) {
|
|
94
|
+
const inactiveSinceMs = binding.inactiveSinceMs;
|
|
95
|
+
const slot = binding.slot;
|
|
96
|
+
if (typeof slot !== "string" || !slot || !Number.isSafeInteger(inactiveSinceMs) ||
|
|
97
|
+
inactiveSinceMs === undefined || inactiveSinceMs < 0 ||
|
|
98
|
+
!Number.isSafeInteger(binding.updatedAtMs) || binding.updatedAtMs < inactiveSinceMs) continue;
|
|
99
|
+
const evidence = evidenceByBinding.get(binding.bindingKey);
|
|
100
|
+
if (!evidence || targetKey(evidence.target) !== targetKey(binding.target) ||
|
|
101
|
+
evidence.liveOwner !== "clear" || evidence.acceptedWork !== "clear" ||
|
|
102
|
+
evidence.deliveryAuthority !== "clear" || competing.has(targetKey(binding.target))) continue;
|
|
103
|
+
candidates.push({ profileName: input.profileName, bindingKey: binding.bindingKey,
|
|
104
|
+
cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
|
|
105
|
+
slot, target: { ...binding.target }, inactiveSinceMs,
|
|
106
|
+
bindingUpdatedAtMs: binding.updatedAtMs });
|
|
107
|
+
}
|
|
108
|
+
return candidates.sort((left, right) => left.bindingKey.localeCompare(right.bindingKey));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function captureTelegramInactiveThreadCleanupEvidence<
|
|
112
|
+
TBinding extends TelegramThreadCleanupBindingSnapshot,
|
|
113
|
+
>(input: {
|
|
114
|
+
profileName: string;
|
|
115
|
+
listBindings(): readonly TBinding[];
|
|
116
|
+
getProtection(binding: TBinding): {
|
|
117
|
+
liveOwner: TelegramThreadCleanupProtectionState;
|
|
118
|
+
acceptedWork: TelegramThreadCleanupProtectionState;
|
|
119
|
+
deliveryAuthority: TelegramThreadCleanupProtectionState;
|
|
120
|
+
};
|
|
121
|
+
listReservations(): readonly { target: { chatId: number; threadId: number } }[];
|
|
122
|
+
listPendingProvisions(): readonly { target?: { chatId: number; threadId: number } }[];
|
|
123
|
+
listPendingCleanups(): readonly { target: { chatId: number; threadId: number } }[];
|
|
124
|
+
}): Parameters<typeof planTelegramInactiveThreadCleanup>[0] {
|
|
125
|
+
const sourceBindings = input.listBindings();
|
|
126
|
+
const bindings = sourceBindings.map(binding => ({
|
|
127
|
+
cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
|
|
128
|
+
...(binding.slot === undefined ? {} : { slot: binding.slot }), bindingKey: binding.bindingKey, target: { ...binding.target },
|
|
129
|
+
...(binding.inactiveSinceMs === undefined ? {} : { inactiveSinceMs: binding.inactiveSinceMs }),
|
|
130
|
+
updatedAtMs: binding.updatedAtMs,
|
|
131
|
+
}));
|
|
132
|
+
const protection = sourceBindings.map((source, index) => {
|
|
133
|
+
const binding = bindings[index]!;
|
|
134
|
+
try { return { bindingKey: binding.bindingKey, target: { ...binding.target },
|
|
135
|
+
...input.getProtection(source) }; }
|
|
136
|
+
catch { return { bindingKey: binding.bindingKey, target: { ...binding.target },
|
|
137
|
+
liveOwner: "unknown" as const, acceptedWork: "unknown" as const,
|
|
138
|
+
deliveryAuthority: "unknown" as const }; }
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
profileName: input.profileName,
|
|
142
|
+
bindings,
|
|
143
|
+
protection,
|
|
144
|
+
reservedTargets: input.listReservations().map(entry => ({ ...entry.target })),
|
|
145
|
+
provisioningTargets: input.listPendingProvisions().flatMap(entry =>
|
|
146
|
+
entry.target ? [{ ...entry.target }] : []),
|
|
147
|
+
cleanupTargets: input.listPendingCleanups().map(entry => ({ ...entry.target })),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createTelegramInactiveThreadCleanupReviewRuntime<
|
|
152
|
+
TBinding extends TelegramThreadCleanupBindingSnapshot,
|
|
153
|
+
>(deps: {
|
|
154
|
+
getProfileName(): string;
|
|
155
|
+
listBindings(): readonly TBinding[];
|
|
156
|
+
getProtection(binding: TBinding): {
|
|
157
|
+
liveOwner: TelegramThreadCleanupProtectionState;
|
|
158
|
+
acceptedWork: TelegramThreadCleanupProtectionState;
|
|
159
|
+
deliveryAuthority: TelegramThreadCleanupProtectionState;
|
|
160
|
+
};
|
|
161
|
+
listReservations(): readonly { target: { chatId: number; threadId: number } }[];
|
|
162
|
+
listPendingProvisions(): readonly { target?: { chatId: number; threadId: number } }[];
|
|
163
|
+
listPendingCleanups(): readonly { target: { chatId: number; threadId: number } }[];
|
|
164
|
+
getWorkStore(): TelegramThreadCleanupWorkStore;
|
|
165
|
+
runWorkspaceOperation<T>(input: { operationId: string; operationKind: string;
|
|
166
|
+
scopes: readonly [{ kind: "profile" }] }, operation: () => Promise<T>): Promise<T>;
|
|
167
|
+
}): { review(): Promise<{ count: number; operationId?: string }> } {
|
|
168
|
+
return {
|
|
169
|
+
review() {
|
|
170
|
+
return deps.runWorkspaceOperation({ operationId: `thread-cleanup-review:${randomUUID()}`,
|
|
171
|
+
operationKind: "thread-cleanup-review", scopes: [{ kind: "profile" }] }, async () => {
|
|
172
|
+
const evidence = captureTelegramInactiveThreadCleanupEvidence({
|
|
173
|
+
profileName: deps.getProfileName(), listBindings: deps.listBindings,
|
|
174
|
+
getProtection: deps.getProtection, listReservations: deps.listReservations,
|
|
175
|
+
listPendingProvisions: deps.listPendingProvisions,
|
|
176
|
+
listPendingCleanups: deps.listPendingCleanups,
|
|
177
|
+
});
|
|
178
|
+
const candidates = planTelegramInactiveThreadCleanup(evidence);
|
|
179
|
+
if (candidates.length === 0) return { count: 0 };
|
|
180
|
+
const digest = createHash("sha256").update(JSON.stringify(candidates)).digest("hex").slice(0, 32);
|
|
181
|
+
const operationId = `thread-cleanup:${digest}`;
|
|
182
|
+
deps.getWorkStore().prepare(operationId, candidates);
|
|
183
|
+
return { count: candidates.length, operationId };
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type TelegramThreadCleanupWorkState = "prepared" | "outcome-unknown" | "deleted";
|
|
190
|
+
export type TelegramThreadCleanupWorkEntry = TelegramThreadCleanupCandidate & {
|
|
191
|
+
state: TelegramThreadCleanupWorkState;
|
|
192
|
+
updatedAtMs: number;
|
|
193
|
+
issuedAtMs?: number;
|
|
194
|
+
permitOperationId?: string;
|
|
195
|
+
permitIntentId?: string;
|
|
196
|
+
permitLeaderEpoch?: number | string;
|
|
197
|
+
deletedAtMs?: number;
|
|
198
|
+
};
|
|
199
|
+
export interface TelegramThreadCleanupWorkSet {
|
|
200
|
+
operationId: string;
|
|
201
|
+
createdAtMs: number;
|
|
202
|
+
entries: TelegramThreadCleanupWorkEntry[];
|
|
203
|
+
}
|
|
204
|
+
interface TelegramThreadCleanupWorkFile {
|
|
205
|
+
version: 1;
|
|
206
|
+
profileName: string;
|
|
207
|
+
tokenSha256: string;
|
|
208
|
+
workSets: TelegramThreadCleanupWorkSet[];
|
|
209
|
+
}
|
|
210
|
+
export type TelegramThreadCleanupDeletionPermit = TelegramWorkspaceDeletionPermit;
|
|
211
|
+
export type TelegramThreadCleanupFence = TelegramWorkspaceRetirementFence;
|
|
212
|
+
|
|
213
|
+
export interface TelegramThreadCleanupWorkStore {
|
|
214
|
+
prepare(operationId: string, candidates: readonly TelegramThreadCleanupCandidate[]):
|
|
215
|
+
{ prepared: boolean; workSet: TelegramThreadCleanupWorkSet };
|
|
216
|
+
recordDeletionIssued(input: { operationId: string; bindingKey: string; bindingUpdatedAtMs: number;
|
|
217
|
+
permit: TelegramThreadCleanupDeletionPermit }):
|
|
218
|
+
{ recorded: boolean; entry: TelegramThreadCleanupWorkEntry };
|
|
219
|
+
confirmDeleted(input: { operationId: string; bindingKey: string }):
|
|
220
|
+
{ confirmed: boolean; entry: TelegramThreadCleanupWorkEntry };
|
|
221
|
+
list(): TelegramThreadCleanupWorkSet[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
225
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
226
|
+
}
|
|
227
|
+
function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
228
|
+
const allowed = new Set(keys); return Object.keys(value).every(key => allowed.has(key));
|
|
229
|
+
}
|
|
230
|
+
function safeTime(value: unknown): value is number {
|
|
231
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
232
|
+
}
|
|
233
|
+
function validateWorkSet(value: unknown, profileName: string): TelegramThreadCleanupWorkSet {
|
|
234
|
+
if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
|
|
235
|
+
typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
|
|
236
|
+
!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("Telegram Thread cleanup work-set schema is invalid.");
|
|
237
|
+
const createdAtMs = value.createdAtMs as number;
|
|
238
|
+
const entries = value.entries.map(raw => {
|
|
239
|
+
if (!isObject(raw) || !onlyKeys(raw, ["profileName", "bindingKey", "cwd", "workspaceKey",
|
|
240
|
+
"instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
|
|
241
|
+
"issuedAtMs", "permitOperationId", "permitIntentId", "permitLeaderEpoch", "deletedAtMs"]) || raw.profileName !== profileName || typeof raw.bindingKey !== "string" ||
|
|
242
|
+
!raw.bindingKey || typeof raw.cwd !== "string" || !raw.cwd || typeof raw.workspaceKey !== "string" ||
|
|
243
|
+
!raw.workspaceKey || typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
|
|
244
|
+
typeof raw.slot !== "string" || !raw.slot || !isObject(raw.target) ||
|
|
245
|
+
!onlyKeys(raw.target, ["chatId", "threadId"]) || !validTarget(raw.target as unknown as { chatId: number; threadId: number }) ||
|
|
246
|
+
!safeTime(raw.inactiveSinceMs) || !safeTime(raw.bindingUpdatedAtMs) ||
|
|
247
|
+
(raw.bindingUpdatedAtMs as number) < (raw.inactiveSinceMs as number) || !safeTime(raw.updatedAtMs))
|
|
248
|
+
throw new Error("Telegram Thread cleanup entry schema is invalid.");
|
|
249
|
+
const base = { profileName, bindingKey: raw.bindingKey, cwd: raw.cwd, workspaceKey: raw.workspaceKey,
|
|
250
|
+
instanceSlot: raw.instanceSlot, slot: raw.slot, target: { chatId: raw.target.chatId as number,
|
|
251
|
+
threadId: raw.target.threadId as number }, inactiveSinceMs: raw.inactiveSinceMs as number,
|
|
252
|
+
bindingUpdatedAtMs: raw.bindingUpdatedAtMs as number, updatedAtMs: raw.updatedAtMs as number };
|
|
253
|
+
if (base.updatedAtMs < base.bindingUpdatedAtMs) throw new Error("Telegram Thread cleanup entry clock is invalid.");
|
|
254
|
+
const noPermit = raw.permitOperationId === undefined && raw.permitIntentId === undefined &&
|
|
255
|
+
raw.permitLeaderEpoch === undefined;
|
|
256
|
+
if (raw.state === "prepared" && raw.issuedAtMs === undefined && raw.deletedAtMs === undefined && noPermit)
|
|
257
|
+
return { ...base, state: "prepared" as const };
|
|
258
|
+
const validPermit = typeof raw.permitOperationId === "string" && !!raw.permitOperationId &&
|
|
259
|
+
typeof raw.permitIntentId === "string" && !!raw.permitIntentId &&
|
|
260
|
+
(typeof raw.permitLeaderEpoch === "string" || Number.isSafeInteger(raw.permitLeaderEpoch));
|
|
261
|
+
if (raw.state === "outcome-unknown" && safeTime(raw.issuedAtMs) && validPermit &&
|
|
262
|
+
raw.issuedAtMs === base.updatedAtMs && raw.deletedAtMs === undefined)
|
|
263
|
+
return { ...base, state: "outcome-unknown" as const, issuedAtMs: raw.issuedAtMs,
|
|
264
|
+
permitOperationId: raw.permitOperationId as string, permitIntentId: raw.permitIntentId as string,
|
|
265
|
+
permitLeaderEpoch: raw.permitLeaderEpoch as number | string };
|
|
266
|
+
if (raw.state === "deleted" && safeTime(raw.issuedAtMs) && validPermit && safeTime(raw.deletedAtMs) &&
|
|
267
|
+
raw.deletedAtMs === base.updatedAtMs && raw.deletedAtMs >= raw.issuedAtMs)
|
|
268
|
+
return { ...base, state: "deleted" as const, issuedAtMs: raw.issuedAtMs,
|
|
269
|
+
permitOperationId: raw.permitOperationId as string, permitIntentId: raw.permitIntentId as string,
|
|
270
|
+
permitLeaderEpoch: raw.permitLeaderEpoch as number | string, deletedAtMs: raw.deletedAtMs };
|
|
271
|
+
throw new Error("Telegram Thread cleanup entry state is invalid.");
|
|
272
|
+
});
|
|
273
|
+
if (entries.some(entry => entry.updatedAtMs < createdAtMs) ||
|
|
274
|
+
new Set(entries.map(entry => entry.bindingKey)).size !== entries.length ||
|
|
275
|
+
new Set(entries.map(entry => targetKey(entry.target))).size !== entries.length)
|
|
276
|
+
throw new Error("Telegram Thread cleanup work-set identity is ambiguous.");
|
|
277
|
+
return { operationId: value.operationId, createdAtMs, entries };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sameCandidates(left: readonly TelegramThreadCleanupCandidate[], right: readonly TelegramThreadCleanupCandidate[]): boolean {
|
|
281
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function createTelegramThreadCleanupWorkStore(options: {
|
|
285
|
+
path: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
|
|
286
|
+
maxBytes?: number; getNowMs?: () => number;
|
|
287
|
+
onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
|
|
288
|
+
}): TelegramThreadCleanupWorkStore {
|
|
289
|
+
const maxWorkSets = options.maxWorkSets ?? 32;
|
|
290
|
+
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
291
|
+
const now = options.getNowMs ?? Date.now;
|
|
292
|
+
if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
|
|
293
|
+
!Number.isSafeInteger(maxWorkSets) || maxWorkSets <= 0 ||
|
|
294
|
+
!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("Telegram Thread cleanup store options are invalid.");
|
|
295
|
+
const empty = (): TelegramThreadCleanupWorkFile => ({ version: 1, profileName: options.profileName,
|
|
296
|
+
tokenSha256: options.tokenSha256, workSets: [] });
|
|
297
|
+
const read = (): TelegramThreadCleanupWorkFile => {
|
|
298
|
+
let before;
|
|
299
|
+
try { before = lstatSync(options.path, { bigint: true }); }
|
|
300
|
+
catch (error) {
|
|
301
|
+
if ((error as { code?: unknown }).code === "ENOENT") return empty();
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
const uid = process.getuid?.();
|
|
305
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
|
|
306
|
+
before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
|
|
307
|
+
(before.mode & 0o077n) !== 0n || before.size > BigInt(maxBytes))
|
|
308
|
+
throw new Error("Telegram Thread cleanup store is not a bounded private regular file.");
|
|
309
|
+
const fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
310
|
+
let value: unknown;
|
|
311
|
+
try {
|
|
312
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
313
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size ||
|
|
314
|
+
opened.mtimeNs !== before.mtimeNs) throw new Error("Telegram Thread cleanup store changed during inspection.");
|
|
315
|
+
value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
|
|
316
|
+
} finally { closeSync(fd); }
|
|
317
|
+
if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
|
|
318
|
+
value.version !== 1 || value.profileName !== options.profileName ||
|
|
319
|
+
value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.workSets))
|
|
320
|
+
throw new Error("Telegram Thread cleanup store identity or schema does not match.");
|
|
321
|
+
const workSets = value.workSets.map(workSet => validateWorkSet(workSet, options.profileName));
|
|
322
|
+
if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
|
|
323
|
+
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
324
|
+
return { version: 1, profileName: options.profileName, tokenSha256: options.tokenSha256, workSets };
|
|
325
|
+
};
|
|
326
|
+
const publish = (file: TelegramThreadCleanupWorkFile): void => {
|
|
327
|
+
if (file.workSets.length > maxWorkSets) throw new Error("Telegram Thread cleanup work-set capacity reached.");
|
|
328
|
+
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
329
|
+
if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Telegram Thread cleanup byte capacity reached.");
|
|
330
|
+
const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
331
|
+
mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
|
|
332
|
+
try {
|
|
333
|
+
writeFileSync(temporaryPath, serialized, { mode: 0o600 });
|
|
334
|
+
chmodSync(temporaryPath, 0o600);
|
|
335
|
+
options.onPublicationBoundary?.("after-write-before-rename");
|
|
336
|
+
if (!renameTelegramPathWithRetry(temporaryPath, options.path))
|
|
337
|
+
throw new Error("Telegram Thread cleanup staging file disappeared.");
|
|
338
|
+
chmodSync(options.path, 0o600);
|
|
339
|
+
options.onPublicationBoundary?.("after-rename");
|
|
340
|
+
} finally { try { unlinkSync(temporaryPath); } catch { /* rename consumed it */ } }
|
|
341
|
+
};
|
|
342
|
+
const mutate = <T>(operation: (file: TelegramThreadCleanupWorkFile) => T): T =>
|
|
343
|
+
withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
|
|
344
|
+
return {
|
|
345
|
+
prepare(operationId, candidates) {
|
|
346
|
+
if (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
|
|
347
|
+
throw new Error("Telegram Thread cleanup work-set is invalid.");
|
|
348
|
+
return mutate(file => {
|
|
349
|
+
const existing = file.workSets.find(workSet => workSet.operationId === operationId);
|
|
350
|
+
if (existing) {
|
|
351
|
+
const projected = existing.entries.map(({ state: _state, updatedAtMs: _updated,
|
|
352
|
+
issuedAtMs: _issued, permitOperationId: _permitOperation,
|
|
353
|
+
permitIntentId: _permitIntent, permitLeaderEpoch: _permitEpoch,
|
|
354
|
+
deletedAtMs: _deleted, ...candidate }) => candidate);
|
|
355
|
+
if (!sameCandidates(projected, candidates)) throw new Error("Telegram Thread cleanup operation conflicts.");
|
|
356
|
+
return { prepared: false, workSet: structuredClone(existing) };
|
|
357
|
+
}
|
|
358
|
+
const atMs = now();
|
|
359
|
+
if (!safeTime(atMs)) throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
360
|
+
const workSet = validateWorkSet({ operationId, createdAtMs: atMs,
|
|
361
|
+
entries: candidates.map(candidate => ({ ...structuredClone(candidate), state: "prepared", updatedAtMs: atMs })) },
|
|
362
|
+
options.profileName);
|
|
363
|
+
publish({ ...file, workSets: [...file.workSets, workSet] });
|
|
364
|
+
return { prepared: true, workSet: structuredClone(workSet) };
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
recordDeletionIssued(input) {
|
|
368
|
+
return mutate(file => {
|
|
369
|
+
const workSet = file.workSets.find(candidate => candidate.operationId === input.operationId);
|
|
370
|
+
const entry = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
|
|
371
|
+
const permit = input.permit;
|
|
372
|
+
if (!workSet || !entry || entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
|
|
373
|
+
permit.destructiveKind !== "manual-thread-cleanup" || permit.profileKey !== entry.profileName || permit.bindingKey !== entry.bindingKey ||
|
|
374
|
+
permit.slot !== entry.slot || targetKey(permit.target) !== targetKey(entry.target) ||
|
|
375
|
+
!permit.operationId || !permit.retirementIntentId || !safeTime(permit.issuedAtMs))
|
|
376
|
+
throw new Error("Telegram Thread cleanup entry or deletion permit is stale or mismatched.");
|
|
377
|
+
if (entry.state !== "prepared") {
|
|
378
|
+
const exact = entry.permitOperationId === permit.operationId &&
|
|
379
|
+
entry.permitIntentId === permit.retirementIntentId &&
|
|
380
|
+
entry.permitLeaderEpoch === permit.leaderEpoch;
|
|
381
|
+
if (!exact) throw new Error("Telegram Thread cleanup deletion permit conflicts with retained work.");
|
|
382
|
+
return { recorded: false, entry: structuredClone(entry) };
|
|
383
|
+
}
|
|
384
|
+
const atMs = now();
|
|
385
|
+
if (!safeTime(atMs) || atMs < entry.updatedAtMs || atMs < permit.issuedAtMs)
|
|
386
|
+
throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
387
|
+
entry.state = "outcome-unknown"; entry.issuedAtMs = atMs; entry.updatedAtMs = atMs;
|
|
388
|
+
entry.permitOperationId = permit.operationId; entry.permitIntentId = permit.retirementIntentId;
|
|
389
|
+
entry.permitLeaderEpoch = permit.leaderEpoch;
|
|
390
|
+
publish(file); return { recorded: true, entry: structuredClone(entry) };
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
confirmDeleted(input) {
|
|
394
|
+
return mutate(file => {
|
|
395
|
+
const entry = file.workSets.find(candidate => candidate.operationId === input.operationId)
|
|
396
|
+
?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
|
|
397
|
+
if (!entry) throw new Error("Telegram Thread cleanup entry is missing.");
|
|
398
|
+
if (entry.state === "deleted") return { confirmed: false, entry: structuredClone(entry) };
|
|
399
|
+
if (entry.state !== "outcome-unknown") throw new Error("Telegram Thread cleanup deletion confirmation is premature.");
|
|
400
|
+
const atMs = now();
|
|
401
|
+
if (!safeTime(atMs) || atMs < entry.updatedAtMs) throw new Error("Telegram Thread cleanup clock is invalid.");
|
|
402
|
+
entry.state = "deleted"; entry.deletedAtMs = atMs; entry.updatedAtMs = atMs;
|
|
403
|
+
publish(file); return { confirmed: true, entry: structuredClone(entry) };
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
list() { return structuredClone(read().workSets); },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function commitTelegramInactiveThreadCleanup(input: {
|
|
411
|
+
store: TelegramThreadCleanupWorkStore;
|
|
412
|
+
operationId: string;
|
|
413
|
+
bindingKey: string;
|
|
414
|
+
commitBinding(): Promise<boolean>;
|
|
415
|
+
}): Promise<boolean> {
|
|
416
|
+
if (!await input.commitBinding()) return false;
|
|
417
|
+
input.store.confirmDeleted({ operationId: input.operationId, bindingKey: input.bindingKey });
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function executeTelegramInactiveThreadCleanup(input: {
|
|
422
|
+
store: TelegramThreadCleanupWorkStore;
|
|
423
|
+
operationId: string;
|
|
424
|
+
bindingKey: string;
|
|
425
|
+
withWorkspaceDeletionBoundary<T>(operation: () => Promise<T>): Promise<T>;
|
|
426
|
+
loadFreshEvidence(): Promise<Parameters<typeof planTelegramInactiveThreadCleanup>[0]>;
|
|
427
|
+
acquireDeletionPermit(candidate: TelegramThreadCleanupCandidate): Promise<
|
|
428
|
+
| { kind: "issued"; permit: TelegramThreadCleanupDeletionPermit }
|
|
429
|
+
| { kind: "blocked" | "already-issued" }
|
|
430
|
+
>;
|
|
431
|
+
deleteWithPermit(permit: TelegramThreadCleanupDeletionPermit,
|
|
432
|
+
candidate: TelegramThreadCleanupCandidate): Promise<void>;
|
|
433
|
+
}): Promise<{ status: "deleted" | "blocked" | "outcome-unknown";
|
|
434
|
+
entry?: TelegramThreadCleanupWorkEntry }> {
|
|
435
|
+
return input.withWorkspaceDeletionBoundary(async () => {
|
|
436
|
+
const workSet = input.store.list().find(candidate => candidate.operationId === input.operationId);
|
|
437
|
+
const retained = workSet?.entries.find(candidate => candidate.bindingKey === input.bindingKey);
|
|
438
|
+
if (!retained) return { status: "blocked" };
|
|
439
|
+
if (retained.state === "deleted") return { status: "deleted", entry: retained };
|
|
440
|
+
if (retained.state === "outcome-unknown") return { status: "outcome-unknown", entry: retained };
|
|
441
|
+
const planned = planTelegramInactiveThreadCleanup(await input.loadFreshEvidence());
|
|
442
|
+
const fresh = planned.find(candidate => candidate.bindingKey === retained.bindingKey);
|
|
443
|
+
const retainedCandidate = (({ state: _state, updatedAtMs: _updated, issuedAtMs: _issued,
|
|
444
|
+
permitOperationId: _permitOperation, permitIntentId: _permitIntent,
|
|
445
|
+
permitLeaderEpoch: _permitEpoch, deletedAtMs: _deleted, ...candidate }) => candidate)(retained);
|
|
446
|
+
if (!fresh || !sameCandidates([fresh], [retainedCandidate])) return { status: "blocked" };
|
|
447
|
+
const permitResult = await input.acquireDeletionPermit(fresh);
|
|
448
|
+
if (permitResult.kind !== "issued") return { status: "blocked" };
|
|
449
|
+
const recorded = input.store.recordDeletionIssued({ operationId: input.operationId,
|
|
450
|
+
bindingKey: input.bindingKey, bindingUpdatedAtMs: retained.bindingUpdatedAtMs,
|
|
451
|
+
permit: permitResult.permit });
|
|
452
|
+
if (!recorded.recorded) return { status: "outcome-unknown", entry: recorded.entry };
|
|
453
|
+
await input.deleteWithPermit(permitResult.permit, fresh);
|
|
454
|
+
const confirmed = input.store.confirmDeleted({ operationId: input.operationId,
|
|
455
|
+
bindingKey: input.bindingKey });
|
|
456
|
+
return { status: "deleted", entry: confirmed.entry };
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
461
|
+
ledger: {
|
|
462
|
+
read(): { fence?: TelegramWorkspaceDestructiveFence };
|
|
463
|
+
acquireThreadCleanupFence(input: { operationId: string; cleanupWorkSetId: string;
|
|
464
|
+
bindingKey: string; slot: string; target: { chatId: number; threadId: number };
|
|
465
|
+
leaderEpoch: number | string; cleanupRequestedAtMs: number }):
|
|
466
|
+
| { kind: "acquired"; fence: TelegramThreadCleanupFence; resumed: boolean }
|
|
467
|
+
| { kind: "blocked"; reason: string };
|
|
468
|
+
adoptThreadCleanupFence(fence: TelegramThreadCleanupFence, replacement: {
|
|
469
|
+
owner: { processId: number; processBirthId: string }; leaderEpoch: number | string;
|
|
470
|
+
}): TelegramThreadCleanupFence;
|
|
471
|
+
issueThreadCleanupDeletionPermit(fence: TelegramThreadCleanupFence):
|
|
472
|
+
| { kind: "issued"; fence: TelegramThreadCleanupFence; permit: TelegramThreadCleanupDeletionPermit }
|
|
473
|
+
| { kind: "already-issued"; fence: TelegramThreadCleanupFence };
|
|
474
|
+
confirmThreadCleanupAbsence(fence: TelegramThreadCleanupFence): TelegramThreadCleanupFence;
|
|
475
|
+
releaseUnissuedThreadCleanupFence(fence: TelegramThreadCleanupFence): boolean;
|
|
476
|
+
completeThreadCleanupFence(fence: TelegramThreadCleanupFence): boolean;
|
|
477
|
+
};
|
|
478
|
+
getLeaderEpoch(): number | string | undefined;
|
|
479
|
+
getProfileName(): string;
|
|
480
|
+
getOwner(): { processId: number; processBirthId: string };
|
|
481
|
+
canAdoptFence(fence: TelegramThreadCleanupFence): boolean;
|
|
482
|
+
revalidateUnderFence(candidate: TelegramThreadCleanupCandidate): Promise<boolean>;
|
|
483
|
+
getNowMs?: () => number;
|
|
484
|
+
}): {
|
|
485
|
+
acquire(candidate: TelegramThreadCleanupCandidate, workSetId: string): Promise<
|
|
486
|
+
| { kind: "issued"; fence: TelegramThreadCleanupFence; permit: TelegramThreadCleanupDeletionPermit }
|
|
487
|
+
| { kind: "blocked" }
|
|
488
|
+
| { kind: "already-issued" }
|
|
489
|
+
>;
|
|
490
|
+
diagnoseRecovery(candidate: TelegramThreadCleanupCandidate, workSetId: string):
|
|
491
|
+
"none" | "fenced" | "deletion-issued" | "commit-ready" | "authority-blocked";
|
|
492
|
+
findCommitReady(candidate: TelegramThreadCleanupCandidate,
|
|
493
|
+
workSetId: string): TelegramThreadCleanupFence | undefined;
|
|
494
|
+
settleDeleted(fence: TelegramThreadCleanupFence,
|
|
495
|
+
commit: () => Promise<boolean>): Promise<"completed" | "commit-pending">;
|
|
496
|
+
} {
|
|
497
|
+
const now = deps.getNowMs ?? Date.now;
|
|
498
|
+
const findExactFence = (candidate: TelegramThreadCleanupCandidate, workSetId: string) => {
|
|
499
|
+
const operationId = `thread-cleanup-fence:${createHash("sha256").update(
|
|
500
|
+
`${workSetId}\u0000${candidate.bindingKey}`, "utf8").digest("hex")}`;
|
|
501
|
+
const fence = deps.ledger.read().fence;
|
|
502
|
+
return fence?.operationId === operationId && fence.destructiveKind === "manual-thread-cleanup" &&
|
|
503
|
+
fence.profileKey === candidate.profileName && fence.retirementIntentId === workSetId && fence.bindingKey === candidate.bindingKey &&
|
|
504
|
+
fence.slot === candidate.slot && targetKey(fence.target) === targetKey(candidate.target)
|
|
505
|
+
? fence : undefined;
|
|
506
|
+
};
|
|
507
|
+
const adoptExactFence = (candidate: TelegramThreadCleanupCandidate, workSetId: string) => {
|
|
508
|
+
let fence = findExactFence(candidate, workSetId);
|
|
509
|
+
const leaderEpoch = deps.getLeaderEpoch();
|
|
510
|
+
if (!fence || leaderEpoch === undefined) return undefined;
|
|
511
|
+
const owner = deps.getOwner();
|
|
512
|
+
if (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
|
|
513
|
+
fence.owner.processBirthId !== owner.processBirthId) {
|
|
514
|
+
if (!deps.canAdoptFence(fence)) return undefined;
|
|
515
|
+
try { fence = deps.ledger.adoptThreadCleanupFence(fence,
|
|
516
|
+
{ owner, leaderEpoch }); } catch { return undefined; }
|
|
517
|
+
}
|
|
518
|
+
return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
|
|
519
|
+
};
|
|
520
|
+
return {
|
|
521
|
+
diagnoseRecovery(candidate, workSetId) {
|
|
522
|
+
const fence = deps.ledger.read().fence;
|
|
523
|
+
if (!fence) return "none";
|
|
524
|
+
const exact = findExactFence(candidate, workSetId);
|
|
525
|
+
if (!exact) return "authority-blocked";
|
|
526
|
+
const owner = deps.getOwner();
|
|
527
|
+
const leaderEpoch = deps.getLeaderEpoch();
|
|
528
|
+
const owns = exact.owner.processId === owner.processId &&
|
|
529
|
+
exact.owner.processBirthId === owner.processBirthId && exact.leaderEpoch === leaderEpoch;
|
|
530
|
+
return owns || deps.canAdoptFence(exact) ? exact.phase : "authority-blocked";
|
|
531
|
+
},
|
|
532
|
+
findCommitReady(candidate, workSetId) {
|
|
533
|
+
const fence = adoptExactFence(candidate, workSetId);
|
|
534
|
+
return fence?.phase === "commit-ready" ? fence : undefined;
|
|
535
|
+
},
|
|
536
|
+
async acquire(candidate, workSetId) {
|
|
537
|
+
const leaderEpoch = deps.getLeaderEpoch();
|
|
538
|
+
if (leaderEpoch === undefined || candidate.profileName.length === 0 ||
|
|
539
|
+
candidate.profileName !== deps.getProfileName()) return { kind: "blocked" };
|
|
540
|
+
const operationId = `thread-cleanup-fence:${createHash("sha256")
|
|
541
|
+
.update(workSetId).update("\0").update(candidate.bindingKey).digest("hex")}`;
|
|
542
|
+
const existing = adoptExactFence(candidate, workSetId) ?? deps.ledger.read().fence;
|
|
543
|
+
if (existing && existing.operationId === operationId &&
|
|
544
|
+
existing.destructiveKind === "manual-thread-cleanup" &&
|
|
545
|
+
existing.retirementIntentId === workSetId && existing.bindingKey === candidate.bindingKey &&
|
|
546
|
+
existing.slot === candidate.slot && targetKey(existing.target) === targetKey(candidate.target) &&
|
|
547
|
+
existing.leaderEpoch === leaderEpoch && existing.phase !== "fenced") {
|
|
548
|
+
return { kind: "already-issued" };
|
|
549
|
+
}
|
|
550
|
+
let acquired;
|
|
551
|
+
try {
|
|
552
|
+
acquired = deps.ledger.acquireThreadCleanupFence({ operationId,
|
|
553
|
+
cleanupWorkSetId: workSetId, bindingKey: candidate.bindingKey, slot: candidate.slot,
|
|
554
|
+
target: candidate.target, leaderEpoch, cleanupRequestedAtMs: now() });
|
|
555
|
+
} catch { return { kind: "blocked" }; }
|
|
556
|
+
if (acquired.kind === "blocked") return { kind: "blocked" };
|
|
557
|
+
if (deps.getLeaderEpoch() !== leaderEpoch || !await deps.revalidateUnderFence(candidate)) {
|
|
558
|
+
if (acquired.fence.phase === "fenced") deps.ledger.releaseUnissuedThreadCleanupFence(acquired.fence);
|
|
559
|
+
return { kind: "blocked" };
|
|
560
|
+
}
|
|
561
|
+
const issued = deps.ledger.issueThreadCleanupDeletionPermit(acquired.fence);
|
|
562
|
+
return issued.kind === "issued"
|
|
563
|
+
? { kind: "issued", fence: issued.fence, permit: issued.permit }
|
|
564
|
+
: { kind: "already-issued" };
|
|
565
|
+
},
|
|
566
|
+
async settleDeleted(fence, commit) {
|
|
567
|
+
const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
|
|
568
|
+
if (!await commit()) return "commit-pending";
|
|
569
|
+
deps.ledger.completeThreadCleanupFence(ready);
|
|
570
|
+
return "completed";
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
|
|
577
|
+
store: TelegramThreadCleanupWorkStore;
|
|
578
|
+
permitRuntime: ReturnType<typeof createTelegramThreadCleanupPermitRuntime>;
|
|
579
|
+
resolveFullBinding(candidate: TelegramThreadCleanupCandidate): Promise<TBinding | undefined>;
|
|
580
|
+
deleteWithPermit(permit: TelegramThreadCleanupDeletionPermit,
|
|
581
|
+
candidate: TelegramThreadCleanupCandidate): Promise<void>;
|
|
582
|
+
commitBinding(candidate: TelegramThreadCleanupCandidate,
|
|
583
|
+
currentBinding?: TBinding): Promise<boolean>;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(
|
|
587
|
+
deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
|
|
588
|
+
): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult> {
|
|
589
|
+
return operationId => cleanReviewedInactiveThreads({ ...deps, operationId });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export interface TelegramInactiveThreadCleanupResult {
|
|
593
|
+
deleted: number;
|
|
594
|
+
outcomeUnknown: number;
|
|
595
|
+
blocked: number;
|
|
596
|
+
recovery?: "commit-ready" | "deletion-outcome-unknown" | "authority-blocked";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function cleanReviewedInactiveThreads<TBinding>(input: {
|
|
600
|
+
operationId: string;
|
|
601
|
+
} & TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>): Promise<TelegramInactiveThreadCleanupResult> {
|
|
602
|
+
if (!/^thread-cleanup:[a-f0-9]{32}$/u.test(input.operationId))
|
|
603
|
+
throw new Error("Telegram Thread cleanup confirmation identity is invalid.");
|
|
604
|
+
const workSet = input.store.list().find(candidate => candidate.operationId === input.operationId);
|
|
605
|
+
if (!workSet) throw new Error("Telegram Thread cleanup review is missing.");
|
|
606
|
+
let deleted = 0;
|
|
607
|
+
let outcomeUnknown = 0;
|
|
608
|
+
let blocked = 0;
|
|
609
|
+
for (const entry of workSet.entries) {
|
|
610
|
+
if (entry.state === "deleted") {
|
|
611
|
+
const ready = input.permitRuntime.findCommitReady(entry, input.operationId);
|
|
612
|
+
if (ready) await input.permitRuntime.settleDeleted(ready, async () => true);
|
|
613
|
+
deleted += 1;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (entry.state === "outcome-unknown") {
|
|
617
|
+
const ready = input.permitRuntime.findCommitReady(entry, input.operationId);
|
|
618
|
+
if (!ready) { outcomeUnknown += 1; continue; }
|
|
619
|
+
const settlement = await input.permitRuntime.settleDeleted(ready, () =>
|
|
620
|
+
commitTelegramInactiveThreadCleanup({ store: input.store, operationId: input.operationId,
|
|
621
|
+
bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry) }));
|
|
622
|
+
if (settlement === "completed") deleted += 1;
|
|
623
|
+
else outcomeUnknown += 1;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const fullBinding = await input.resolveFullBinding(entry);
|
|
627
|
+
if (!fullBinding) { blocked += 1; continue; }
|
|
628
|
+
const authority = await input.permitRuntime.acquire(entry, input.operationId);
|
|
629
|
+
if (authority.kind === "blocked") { blocked += 1; continue; }
|
|
630
|
+
if (authority.kind === "already-issued") { outcomeUnknown += 1; continue; }
|
|
631
|
+
try {
|
|
632
|
+
const recorded = input.store.recordDeletionIssued({ operationId: input.operationId,
|
|
633
|
+
bindingKey: entry.bindingKey, bindingUpdatedAtMs: entry.bindingUpdatedAtMs,
|
|
634
|
+
permit: authority.permit,
|
|
635
|
+
});
|
|
636
|
+
if (!recorded.recorded) {
|
|
637
|
+
if (recorded.entry.state === "deleted") {
|
|
638
|
+
const settlement = await input.permitRuntime.settleDeleted(authority.fence, async () => true);
|
|
639
|
+
if (settlement === "completed") deleted += 1;
|
|
640
|
+
else outcomeUnknown += 1;
|
|
641
|
+
} else outcomeUnknown += 1;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
await input.deleteWithPermit(authority.permit, entry);
|
|
645
|
+
const settlement = await input.permitRuntime.settleDeleted(authority.fence, () =>
|
|
646
|
+
commitTelegramInactiveThreadCleanup({ store: input.store, operationId: input.operationId,
|
|
647
|
+
bindingKey: entry.bindingKey, commitBinding: () => input.commitBinding(entry, fullBinding) }));
|
|
648
|
+
if (settlement === "completed") deleted += 1;
|
|
649
|
+
else outcomeUnknown += 1;
|
|
650
|
+
} catch {
|
|
651
|
+
outcomeUnknown += 1;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const retainedEntries = input.store.list().find(
|
|
655
|
+
candidate => candidate.operationId === input.operationId)?.entries ?? [];
|
|
656
|
+
if (outcomeUnknown > 0) {
|
|
657
|
+
const diagnosis = retainedEntries.map(entry =>
|
|
658
|
+
input.permitRuntime.diagnoseRecovery(entry, input.operationId)).find(value => value !== "none") ?? "none";
|
|
659
|
+
const recovery = diagnosis === "commit-ready" ? "commit-ready"
|
|
660
|
+
: diagnosis === "deletion-issued" ? "deletion-outcome-unknown" : "authority-blocked";
|
|
661
|
+
return { deleted, outcomeUnknown, blocked, recovery };
|
|
662
|
+
}
|
|
663
|
+
return { deleted, outcomeUnknown, blocked };
|
|
664
|
+
}
|