@llblab/pi-telegram 0.52.2 → 0.53.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/AGENTS.md +9 -7
- package/BACKLOG.md +1 -18
- package/CHANGELOG.md +11 -0
- package/README.md +7 -11
- package/api/commands.ts +3 -0
- package/dist/api/commands.d.ts +1 -1
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +15 -42
- package/dist/lib/activity.d.ts +9 -0
- package/dist/lib/activity.js +39 -10
- package/dist/lib/agent-messages.js +7 -3
- package/dist/lib/bindings.d.ts +43 -5
- package/dist/lib/bindings.js +163 -20
- package/dist/lib/bus-api.d.ts +12 -0
- package/dist/lib/bus-api.js +182 -79
- package/dist/lib/bus-follower.d.ts +111 -31
- package/dist/lib/bus-follower.js +1324 -359
- package/dist/lib/bus-leader.d.ts +41 -91
- package/dist/lib/bus-leader.js +969 -637
- package/dist/lib/bus-transport.js +16 -9
- package/dist/lib/bus.d.ts +215 -18
- package/dist/lib/bus.js +1048 -327
- package/dist/lib/channel-posts.d.ts +5 -0
- package/dist/lib/channel-posts.js +434 -261
- package/dist/lib/command-templates.d.ts +26 -5
- package/dist/lib/command-templates.js +92 -176
- package/dist/lib/commands.d.ts +168 -82
- package/dist/lib/commands.js +1409 -291
- package/dist/lib/config.d.ts +1 -8
- package/dist/lib/config.js +34 -76
- package/dist/lib/delivery.d.ts +2 -2
- package/dist/lib/delivery.js +16 -16
- package/dist/lib/extension.js +639 -171
- package/dist/lib/generative-app-worker.mjs +19 -7
- package/dist/lib/generative-apps.d.ts +0 -5
- package/dist/lib/generative-apps.js +61 -49
- package/dist/lib/inbound.d.ts +5 -0
- package/dist/lib/inbound.js +35 -59
- package/dist/lib/journal.d.ts +33 -3
- package/dist/lib/journal.js +2063 -782
- package/dist/lib/lifecycle.d.ts +2 -6
- package/dist/lib/lifecycle.js +75 -58
- package/dist/lib/locks.d.ts +29 -3
- package/dist/lib/locks.js +289 -108
- package/dist/lib/logging.js +50 -14
- package/dist/lib/media.d.ts +22 -1
- package/dist/lib/media.js +46 -28
- package/dist/lib/menu-model.d.ts +19 -5
- package/dist/lib/menu-model.js +53 -11
- package/dist/lib/menu-queue.d.ts +5 -4
- package/dist/lib/menu-queue.js +45 -63
- package/dist/lib/menu-settings.d.ts +5 -4
- package/dist/lib/menu-settings.js +114 -78
- package/dist/lib/menu-status.d.ts +1 -1
- package/dist/lib/menu-status.js +8 -25
- package/dist/lib/menu-thinking.d.ts +6 -1
- package/dist/lib/menu-thinking.js +14 -21
- package/dist/lib/menu.d.ts +8 -61
- package/dist/lib/menu.js +92 -164
- package/dist/lib/model.d.ts +1 -1
- package/dist/lib/model.js +2 -16
- package/dist/lib/outbound-attachments.d.ts +6 -27
- package/dist/lib/outbound-attachments.js +45 -38
- package/dist/lib/outbound-buttons.js +22 -16
- package/dist/lib/outbound-markup.d.ts +0 -2
- package/dist/lib/outbound-markup.js +51 -64
- package/dist/lib/outbound-voice.js +6 -3
- package/dist/lib/outbound.d.ts +6 -6
- package/dist/lib/outbound.js +61 -51
- package/dist/lib/ownership.js +6 -2
- package/dist/lib/paths.d.ts +5 -0
- package/dist/lib/paths.js +37 -21
- package/dist/lib/pi.d.ts +2 -0
- package/dist/lib/pi.js +8 -1
- package/dist/lib/polling.d.ts +21 -37
- package/dist/lib/polling.js +59 -50
- package/dist/lib/preview.js +39 -19
- package/dist/lib/process-identity.d.ts +24 -0
- package/dist/lib/process-identity.js +133 -0
- package/dist/lib/prompts.js +2 -7
- package/dist/lib/queue.d.ts +14 -16
- package/dist/lib/queue.js +249 -126
- package/dist/lib/recovery.d.ts +2 -83
- package/dist/lib/recovery.js +20 -274
- package/dist/lib/replies.d.ts +16 -24
- package/dist/lib/replies.js +99 -116
- package/dist/lib/routing.d.ts +120 -7
- package/dist/lib/routing.js +4667 -1152
- package/dist/lib/runtime.d.ts +1 -11
- package/dist/lib/sections.d.ts +4 -29
- package/dist/lib/sections.js +7 -20
- package/dist/lib/skills.js +4 -3
- package/dist/lib/status.js +86 -57
- package/dist/lib/sync.d.ts +1 -3
- package/dist/lib/sync.js +67 -43
- package/dist/lib/target.d.ts +4 -0
- package/dist/lib/target.js +18 -0
- package/dist/lib/telegram-api.d.ts +18 -13
- package/dist/lib/telegram-api.js +148 -53
- package/dist/lib/text-groups.js +4 -39
- package/dist/lib/thread-cleanup-manager.d.ts +6 -27
- package/dist/lib/thread-cleanup-manager.js +369 -186
- package/dist/lib/thread-display.d.ts +1 -2
- package/dist/lib/thread-display.js +58 -38
- package/dist/lib/thread-naming.d.ts +28 -8
- package/dist/lib/thread-naming.js +175 -66
- package/dist/lib/thread-reconciler.d.ts +42 -16
- package/dist/lib/thread-reconciler.js +90 -50
- package/dist/lib/threads.d.ts +82 -20
- package/dist/lib/threads.js +2247 -855
- package/dist/lib/time-injection.js +1 -2
- package/dist/lib/turns.js +1 -19
- package/dist/lib/updates.d.ts +169 -80
- package/dist/lib/updates.js +2418 -641
- package/dist/lib/voice.d.ts +7 -22
- package/dist/lib/voice.js +7 -22
- package/dist/lib/wire.js +1 -1
- package/dist/lib/workspace-admission.d.ts +3 -1
- package/dist/lib/workspace-admission.js +425 -211
- package/dist/lib/workspace-identity.js +8 -13
- package/dist/lib/workspace-retirement.d.ts +1 -1
- package/dist/lib/workspace-retirement.js +328 -140
- package/dist/lib/workspace-slots.js +8 -4
- package/dist/package.json +1 -1
- package/docs/architecture.md +31 -29
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +15 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +29 -1
- package/docs/ui-style.md +25 -8
- package/lib/activity-verbosity.ts +59 -82
- package/lib/activity.ts +146 -102
- package/lib/agent-messages.ts +12 -5
- package/lib/bindings.ts +389 -82
- package/lib/bus-api.ts +263 -102
- package/lib/bus-follower.ts +2440 -601
- package/lib/bus-leader.ts +2209 -1290
- package/lib/bus-transport.ts +46 -12
- package/lib/bus.ts +1673 -444
- package/lib/channel-posts.ts +848 -327
- package/lib/command-templates.ts +152 -265
- package/lib/commands.ts +2398 -555
- package/lib/config.ts +177 -170
- package/lib/delivery.ts +41 -29
- package/lib/extension.ts +1206 -433
- package/lib/generative-app-worker.mjs +19 -7
- package/lib/generative-apps.ts +324 -211
- package/lib/inbound.ts +95 -97
- package/lib/journal.ts +5311 -1763
- package/lib/keyboard.ts +1 -3
- package/lib/lifecycle.ts +128 -78
- package/lib/locks.ts +677 -203
- package/lib/logging.ts +147 -58
- package/lib/media.ts +82 -43
- package/lib/menu-model.ts +85 -18
- package/lib/menu-queue.ts +88 -129
- package/lib/menu-settings.ts +260 -155
- package/lib/menu-status.ts +20 -60
- package/lib/menu-thinking.ts +41 -56
- package/lib/menu.ts +112 -285
- package/lib/model.ts +8 -22
- package/lib/outbound-attachments.ts +156 -125
- package/lib/outbound-buttons.ts +76 -49
- package/lib/outbound-markup.ts +96 -94
- package/lib/outbound-voice.ts +33 -27
- package/lib/outbound.ts +125 -125
- package/lib/ownership.ts +19 -10
- package/lib/paths.ts +148 -49
- package/lib/pi.ts +35 -15
- package/lib/polling.ts +144 -147
- package/lib/preview.ts +93 -40
- package/lib/process-identity.ts +164 -0
- package/lib/prompts.ts +4 -11
- package/lib/queue.ts +511 -300
- package/lib/recovery.ts +50 -454
- package/lib/rendering.ts +1 -3
- package/lib/replies.ts +248 -209
- package/lib/routing.ts +8509 -2186
- package/lib/runtime.ts +1 -12
- package/lib/sections.ts +24 -89
- package/lib/setup.ts +1 -3
- package/lib/skills.ts +9 -4
- package/lib/status.ts +215 -110
- package/lib/sync.ts +448 -361
- package/lib/target.ts +22 -3
- package/lib/telegram-api.ts +302 -127
- package/lib/text-groups.ts +5 -39
- package/lib/thread-cleanup-manager.ts +833 -331
- package/lib/thread-display.ts +213 -97
- package/lib/thread-naming.ts +232 -87
- package/lib/thread-reconciler.ts +155 -72
- package/lib/threads.ts +5318 -1845
- package/lib/time-injection.ts +1 -4
- package/lib/turns.ts +15 -28
- package/lib/updates.ts +5184 -1612
- package/lib/voice.ts +7 -35
- package/lib/wire.ts +1 -1
- package/lib/workspace-admission.ts +996 -416
- package/lib/workspace-identity.ts +14 -17
- package/lib/workspace-retirement.ts +878 -376
- package/lib/workspace-slots.ts +22 -13
- package/package.json +1 -1
- package/scripts/check-downgrade.mjs +0 -472
- package/scripts/measure-workspace.mjs +0 -108
package/lib/locks.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Owns filesystem authority, atomic runtime-section publication and Telegram bridge ownership semantics
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import {
|
|
8
9
|
chmodSync,
|
|
9
10
|
closeSync,
|
|
11
|
+
constants,
|
|
10
12
|
existsSync,
|
|
11
13
|
fstatSync,
|
|
12
14
|
lstatSync,
|
|
@@ -20,16 +22,17 @@ import {
|
|
|
20
22
|
unlinkSync,
|
|
21
23
|
writeFileSync,
|
|
22
24
|
} from "node:fs";
|
|
23
|
-
import { randomUUID } from "node:crypto";
|
|
24
|
-
import { isDeepStrictEqual } from "node:util";
|
|
25
25
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
26
|
-
import {
|
|
26
|
+
import { isDeepStrictEqual } from "node:util";
|
|
27
|
+
|
|
27
28
|
import {
|
|
28
29
|
isTelegramSessionPollingJournalPath,
|
|
29
30
|
resolveTelegramOwnersPath,
|
|
30
31
|
resolveTelegramSessionPollingJournalPath,
|
|
31
32
|
resolveTelegramUpdateJournalPathForProfile,
|
|
32
33
|
} from "./paths.ts";
|
|
34
|
+
import { isProcessAlive } from "./process-identity.ts";
|
|
35
|
+
import { isWireRecord as runtimeStateRecord } from "./wire.ts";
|
|
33
36
|
|
|
34
37
|
export const TELEGRAM_LOCK_KEY = "default";
|
|
35
38
|
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
|
|
@@ -90,16 +93,23 @@ interface TelegramOwnedStateSessionPort<TContext extends TelegramLockContext> {
|
|
|
90
93
|
* Captures exact context, session generation and owned leader epoch before awaits.
|
|
91
94
|
* Session replacement, release or re-election revokes the grant; a successor never renews it.
|
|
92
95
|
*/
|
|
93
|
-
export function createTelegramOwnedStateAuthorityCapture<
|
|
96
|
+
export function createTelegramOwnedStateAuthorityCapture<
|
|
97
|
+
TContext extends TelegramLockContext,
|
|
98
|
+
>(
|
|
94
99
|
lock: Pick<TelegramLockRuntime<TContext>, "owns" | "getOwnedLeaderEpoch">,
|
|
95
100
|
session: TelegramOwnedStateSessionPort<TContext>,
|
|
96
101
|
): () => (() => boolean) | undefined {
|
|
97
102
|
return () => {
|
|
98
|
-
const ctx = session.get(),
|
|
99
|
-
|
|
103
|
+
const ctx = session.get(),
|
|
104
|
+
generation = session.getGeneration();
|
|
105
|
+
if (ctx === undefined || !session.isCurrent(ctx, generation))
|
|
106
|
+
return undefined;
|
|
100
107
|
const epoch = lock.getOwnedLeaderEpoch();
|
|
101
108
|
if (epoch === undefined) return undefined;
|
|
102
|
-
const current = (): boolean =>
|
|
109
|
+
const current = (): boolean =>
|
|
110
|
+
session.isCurrent(ctx, generation) &&
|
|
111
|
+
lock.owns(ctx) &&
|
|
112
|
+
lock.getOwnedLeaderEpoch() === epoch;
|
|
103
113
|
return current() ? current : undefined;
|
|
104
114
|
};
|
|
105
115
|
}
|
|
@@ -137,7 +147,8 @@ type TelegramLockAcquireResult =
|
|
|
137
147
|
| { ok: true; lock: TelegramLockEntry; replacedStale: boolean }
|
|
138
148
|
| { ok: false; lock: TelegramLockEntry };
|
|
139
149
|
|
|
140
|
-
export type TelegramOwnedStatePublicationResult<T> =
|
|
150
|
+
export type TelegramOwnedStatePublicationResult<T> =
|
|
151
|
+
{ committed: false } | { committed: true; result: T };
|
|
141
152
|
|
|
142
153
|
export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
143
154
|
acquire: (
|
|
@@ -153,7 +164,7 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
|
153
164
|
/** Consolidated-store publication; domain reducers retain their own exact payload/CAS rules. */
|
|
154
165
|
publishStateSectionIfOwned?: <T>(
|
|
155
166
|
section: "workspace" | "runtime",
|
|
156
|
-
mutate:
|
|
167
|
+
mutate: TelegramRuntimeStateSectionReducer<T>,
|
|
157
168
|
options: TelegramOwnedStatePublicationOptions,
|
|
158
169
|
) => TelegramOwnedStatePublicationResult<T>;
|
|
159
170
|
refresh: (ctx?: TelegramLockContext) => boolean;
|
|
@@ -161,15 +172,11 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
|
161
172
|
getJournalPath: () => string | undefined;
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
interface TelegramLockOwnershipGuard<
|
|
165
|
-
TContext extends TelegramLockContext,
|
|
166
|
-
> {
|
|
175
|
+
interface TelegramLockOwnershipGuard<TContext extends TelegramLockContext> {
|
|
167
176
|
ownsContext: (ctx: TContext) => boolean;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
|
-
interface TelegramLockContextStore<
|
|
171
|
-
TContext extends TelegramLockContext,
|
|
172
|
-
> {
|
|
179
|
+
interface TelegramLockContextStore<TContext extends TelegramLockContext> {
|
|
173
180
|
get: () => TContext | undefined;
|
|
174
181
|
}
|
|
175
182
|
|
|
@@ -178,7 +185,10 @@ interface TelegramLockRuntimeOptions {
|
|
|
178
185
|
locksPath?: string;
|
|
179
186
|
/** Consolidated version-2 state envelope; exclusive with `locksPath`. */
|
|
180
187
|
statePath?: string;
|
|
181
|
-
statePublication?: Pick<
|
|
188
|
+
statePublication?: Pick<
|
|
189
|
+
TelegramRuntimeStatePublicationOptions,
|
|
190
|
+
"onPublicationBoundary" | "publishRename"
|
|
191
|
+
>;
|
|
182
192
|
/** Read-only ownership file of releases that used another directory; a live fresh owner there blocks acquisition. */
|
|
183
193
|
legacyLocksPath?: string;
|
|
184
194
|
pid?: number;
|
|
@@ -204,26 +214,44 @@ export function createTelegramLeaderJournalPathResolver(deps: {
|
|
|
204
214
|
getProfileName: () => string | undefined;
|
|
205
215
|
}) {
|
|
206
216
|
// An existing flat root journal keeps its custody; new installations never create one.
|
|
207
|
-
const createOwn = (
|
|
217
|
+
const createOwn = (
|
|
218
|
+
profileName = deps.getProfileName(),
|
|
219
|
+
): string | undefined => {
|
|
208
220
|
const root = resolveTelegramUpdateJournalPathForProfile(profileName);
|
|
209
221
|
if (existsSync(root)) return root;
|
|
210
222
|
const sessionId = deps.getSessionId();
|
|
211
|
-
return sessionId === undefined
|
|
223
|
+
return sessionId === undefined
|
|
224
|
+
? undefined
|
|
225
|
+
: resolveTelegramSessionPollingJournalPath(
|
|
226
|
+
sessionId,
|
|
227
|
+
undefined,
|
|
228
|
+
profileName,
|
|
229
|
+
);
|
|
212
230
|
};
|
|
213
231
|
return {
|
|
214
232
|
createJournalPath: () => createOwn(),
|
|
215
233
|
resolve(profileName?: string): string {
|
|
216
234
|
const named = deps.getNamedJournalPath();
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
235
|
+
if (
|
|
236
|
+
named &&
|
|
237
|
+
(isTelegramSessionPollingJournalPath(named) ||
|
|
238
|
+
named === resolveTelegramUpdateJournalPathForProfile(profileName))
|
|
239
|
+
)
|
|
240
|
+
return named;
|
|
241
|
+
return (
|
|
242
|
+
createOwn(profileName) ??
|
|
243
|
+
resolveTelegramUpdateJournalPathForProfile(profileName)
|
|
244
|
+
);
|
|
220
245
|
},
|
|
221
246
|
};
|
|
222
247
|
}
|
|
223
248
|
|
|
224
249
|
/** A released key keeps only `{ journalPath }`: no owner, but the successor's polling custody. */
|
|
225
|
-
export function readTelegramLockJournalPath(
|
|
226
|
-
|
|
250
|
+
export function readTelegramLockJournalPath(
|
|
251
|
+
value: unknown,
|
|
252
|
+
): string | undefined {
|
|
253
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
254
|
+
return undefined;
|
|
227
255
|
const path = (value as Record<string, unknown>).journalPath;
|
|
228
256
|
return typeof path === "string" && path ? path : undefined;
|
|
229
257
|
}
|
|
@@ -271,6 +299,120 @@ interface TelegramRenameRetryOptions {
|
|
|
271
299
|
retryDelayMs?: number;
|
|
272
300
|
}
|
|
273
301
|
|
|
302
|
+
export type TelegramPrivateFileFailure = "capacity" | "unsafe" | "changed";
|
|
303
|
+
|
|
304
|
+
/** Typed private-file refusal; owners map the failure into their own error vocabulary. */
|
|
305
|
+
export class TelegramPrivateFileError extends Error {
|
|
306
|
+
readonly failure: TelegramPrivateFileFailure;
|
|
307
|
+
constructor(failure: TelegramPrivateFileFailure, message: string) {
|
|
308
|
+
super(message);
|
|
309
|
+
this.failure = failure;
|
|
310
|
+
this.name = "TelegramPrivateFileError";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read flags for private runtime files. Where the platform offers them, no-follow and non-blocking opens refuse a
|
|
316
|
+
* swapped link or FIFO at open time. Windows has neither flag and no FIFOs in the file namespace; there the
|
|
317
|
+
* lstat-before/fstat-after identity binding every strict reader performs is the guard, so a link swapped in after
|
|
318
|
+
* inspection opens a different file and is refused as changed.
|
|
319
|
+
*/
|
|
320
|
+
export const TELEGRAM_STRICT_READ_FLAGS =
|
|
321
|
+
constants.O_RDONLY |
|
|
322
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
323
|
+
(constants.O_NONBLOCK ?? 0);
|
|
324
|
+
|
|
325
|
+
/** POSIX owner and permission bits; Windows has no uid, so its ACL-protected agent directory carries privacy. */
|
|
326
|
+
export function isTelegramOwnerPrivate(stat: {
|
|
327
|
+
uid: bigint;
|
|
328
|
+
mode: bigint;
|
|
329
|
+
}): boolean {
|
|
330
|
+
const uid = process.getuid?.();
|
|
331
|
+
return (
|
|
332
|
+
uid === undefined ||
|
|
333
|
+
(stat.uid === BigInt(uid) && (stat.mode & 0o077n) === 0n)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Read one owner-private, single-link, bounded regular file without following links. Absence returns undefined;
|
|
339
|
+
* an inode or metadata change between inspection and open refuses as `changed`.
|
|
340
|
+
*/
|
|
341
|
+
export function readTelegramPrivateFile(
|
|
342
|
+
path: string,
|
|
343
|
+
maxBytes: number,
|
|
344
|
+
): string | undefined {
|
|
345
|
+
let before;
|
|
346
|
+
try {
|
|
347
|
+
before = lstatSync(path, { bigint: true });
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return undefined;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
!before.isFile() ||
|
|
354
|
+
before.isSymbolicLink() ||
|
|
355
|
+
!isTelegramOwnerPrivate(before) ||
|
|
356
|
+
before.nlink !== 1n ||
|
|
357
|
+
before.size > BigInt(maxBytes)
|
|
358
|
+
) {
|
|
359
|
+
throw new TelegramPrivateFileError(
|
|
360
|
+
before.size > BigInt(maxBytes) ? "capacity" : "unsafe",
|
|
361
|
+
"Telegram private file is not a bounded no-follow regular file.",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const fd = openSync(path, TELEGRAM_STRICT_READ_FLAGS);
|
|
365
|
+
try {
|
|
366
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
367
|
+
if (
|
|
368
|
+
!opened.isFile() ||
|
|
369
|
+
opened.dev !== before.dev ||
|
|
370
|
+
opened.ino !== before.ino ||
|
|
371
|
+
opened.uid !== before.uid ||
|
|
372
|
+
opened.nlink !== 1n ||
|
|
373
|
+
!isTelegramOwnerPrivate(opened) ||
|
|
374
|
+
opened.size !== before.size ||
|
|
375
|
+
opened.mtimeNs !== before.mtimeNs
|
|
376
|
+
) {
|
|
377
|
+
throw new TelegramPrivateFileError(
|
|
378
|
+
"changed",
|
|
379
|
+
"Telegram private file changed during inspection.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return readFileSync(fd, "utf8");
|
|
383
|
+
} finally {
|
|
384
|
+
closeSync(fd);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Atomically publish owner-private contents through a unique staging file; the rename consumes staging. */
|
|
389
|
+
export function publishTelegramPrivateFile(
|
|
390
|
+
path: string,
|
|
391
|
+
temporaryBasePath: string,
|
|
392
|
+
contents: string,
|
|
393
|
+
label: string,
|
|
394
|
+
onBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void,
|
|
395
|
+
): void {
|
|
396
|
+
const temporaryPath = `${temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
397
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
398
|
+
mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
|
|
399
|
+
try {
|
|
400
|
+
writeFileSync(temporaryPath, contents, { encoding: "utf8", mode: 0o600 });
|
|
401
|
+
chmodSync(temporaryPath, 0o600);
|
|
402
|
+
onBoundary?.("after-write-before-rename");
|
|
403
|
+
if (!renameTelegramPathWithRetry(temporaryPath, path))
|
|
404
|
+
throw new Error(`${label} staging file disappeared before publication.`);
|
|
405
|
+
chmodSync(path, 0o600);
|
|
406
|
+
onBoundary?.("after-rename");
|
|
407
|
+
} finally {
|
|
408
|
+
try {
|
|
409
|
+
unlinkSync(temporaryPath);
|
|
410
|
+
} catch {
|
|
411
|
+
/* Atomic rename consumes the temporary path. */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
274
416
|
/** Rename one Telegram runtime artifact with bounded Windows sharing retries. */
|
|
275
417
|
export function renameTelegramPathWithRetry(
|
|
276
418
|
sourcePath: string,
|
|
@@ -738,30 +880,53 @@ export function withTelegramFileTransaction<T>(
|
|
|
738
880
|
}
|
|
739
881
|
}
|
|
740
882
|
|
|
741
|
-
export type TelegramRuntimeStateSection =
|
|
742
|
-
|
|
883
|
+
export type TelegramRuntimeStateSection =
|
|
884
|
+
"transport" | "workspace" | "admission" | "runtime";
|
|
885
|
+
type TelegramRuntimeStateProfile = Partial<
|
|
886
|
+
Record<TelegramRuntimeStateSection, unknown>
|
|
887
|
+
>;
|
|
743
888
|
interface TelegramRuntimeStateFile {
|
|
744
889
|
version: 2;
|
|
745
890
|
profiles: Record<string, TelegramRuntimeStateProfile>;
|
|
746
891
|
}
|
|
747
892
|
export class TelegramRuntimeStateError extends Error {
|
|
748
893
|
readonly code: "invalid" | "authority-changed" | "publication-unknown";
|
|
749
|
-
constructor(
|
|
894
|
+
constructor(
|
|
895
|
+
code: TelegramRuntimeStateError["code"],
|
|
896
|
+
message: string,
|
|
897
|
+
options?: ErrorOptions,
|
|
898
|
+
) {
|
|
750
899
|
super(message, options);
|
|
751
900
|
this.name = "TelegramRuntimeStateError";
|
|
752
901
|
this.code = code;
|
|
753
902
|
}
|
|
754
903
|
}
|
|
755
|
-
const TELEGRAM_RUNTIME_STATE_SECTIONS: readonly TelegramRuntimeStateSection[] =
|
|
756
|
-
|
|
757
|
-
|
|
904
|
+
const TELEGRAM_RUNTIME_STATE_SECTIONS: readonly TelegramRuntimeStateSection[] =
|
|
905
|
+
["transport", "workspace", "admission", "runtime"];
|
|
906
|
+
const TELEGRAM_ACTIVE_STATE_TRANSACTIONS = Symbol.for(
|
|
907
|
+
"@llblab/pi-telegram/active-state-transactions",
|
|
908
|
+
);
|
|
909
|
+
type TelegramStateGlobal = typeof globalThis & {
|
|
910
|
+
[TELEGRAM_ACTIVE_STATE_TRANSACTIONS]?: Set<string>;
|
|
911
|
+
};
|
|
758
912
|
function requireRuntimeStatePath(path: string): void {
|
|
759
913
|
if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path)
|
|
760
|
-
throw new TelegramRuntimeStateError(
|
|
914
|
+
throw new TelegramRuntimeStateError(
|
|
915
|
+
"invalid",
|
|
916
|
+
"Telegram runtime state path must be canonical and absolute.",
|
|
917
|
+
);
|
|
761
918
|
}
|
|
762
919
|
function requireRuntimeStateProfile(profile: string): void {
|
|
763
|
-
if (
|
|
764
|
-
|
|
920
|
+
if (
|
|
921
|
+
typeof profile !== "string" ||
|
|
922
|
+
!profile.length ||
|
|
923
|
+
profile.length > 512 ||
|
|
924
|
+
/[\x00-\x1f]/u.test(profile)
|
|
925
|
+
)
|
|
926
|
+
throw new TelegramRuntimeStateError(
|
|
927
|
+
"invalid",
|
|
928
|
+
"Telegram runtime state profile is invalid.",
|
|
929
|
+
);
|
|
765
930
|
}
|
|
766
931
|
/** Private single-link regular file read with identity continuity; initial absence only is positive. */
|
|
767
932
|
function readTelegramRuntimeSource(path: string): string | undefined {
|
|
@@ -771,42 +936,94 @@ function readTelegramRuntimeSource(path: string): string | undefined {
|
|
|
771
936
|
try {
|
|
772
937
|
const stat = lstatSync(path);
|
|
773
938
|
observed = true;
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
|
|
939
|
+
if (
|
|
940
|
+
!stat.isFile() ||
|
|
941
|
+
stat.nlink !== 1 ||
|
|
942
|
+
(process.platform !== "win32" && (stat.mode & 0o077) !== 0)
|
|
943
|
+
)
|
|
944
|
+
throw new TelegramRuntimeStateError(
|
|
945
|
+
"invalid",
|
|
946
|
+
"Telegram runtime state must be a private regular file.",
|
|
947
|
+
);
|
|
777
948
|
const fd = openSync(path, "r");
|
|
778
949
|
try {
|
|
779
950
|
const opened = fstatSync(fd);
|
|
780
|
-
if (
|
|
781
|
-
|
|
782
|
-
|
|
951
|
+
if (
|
|
952
|
+
opened.dev !== stat.dev ||
|
|
953
|
+
opened.ino !== stat.ino ||
|
|
954
|
+
opened.nlink !== 1 ||
|
|
955
|
+
!opened.isFile() ||
|
|
956
|
+
(process.platform !== "win32" && (opened.mode & 0o077) !== 0)
|
|
957
|
+
)
|
|
958
|
+
throw new TelegramRuntimeStateError(
|
|
959
|
+
"invalid",
|
|
960
|
+
"Telegram runtime state identity changed before reading.",
|
|
961
|
+
);
|
|
783
962
|
source = readFileSync(fd).toString("utf8");
|
|
784
|
-
} finally {
|
|
963
|
+
} finally {
|
|
964
|
+
closeSync(fd);
|
|
965
|
+
}
|
|
785
966
|
const after = lstatSync(path);
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
967
|
+
if (
|
|
968
|
+
after.dev !== stat.dev ||
|
|
969
|
+
after.ino !== stat.ino ||
|
|
970
|
+
after.size !== stat.size ||
|
|
971
|
+
after.mtimeMs !== stat.mtimeMs ||
|
|
972
|
+
after.ctimeMs !== stat.ctimeMs
|
|
973
|
+
)
|
|
974
|
+
throw new TelegramRuntimeStateError(
|
|
975
|
+
"invalid",
|
|
976
|
+
"Telegram runtime state changed during observation.",
|
|
977
|
+
);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (!observed && (error as { code?: unknown }).code === "ENOENT")
|
|
980
|
+
return undefined;
|
|
791
981
|
throw error;
|
|
792
982
|
}
|
|
793
983
|
return source;
|
|
794
984
|
}
|
|
795
985
|
|
|
796
986
|
/** Strict, observational envelope read; section owners validate their own payloads. Legacy state is never adopted here. */
|
|
797
|
-
export function readTelegramRuntimeState(
|
|
987
|
+
export function readTelegramRuntimeState(
|
|
988
|
+
path: string,
|
|
989
|
+
): TelegramRuntimeStateFile {
|
|
798
990
|
const observed = readTelegramRuntimeSource(path);
|
|
799
991
|
if (observed === undefined) return { version: 2, profiles: {} };
|
|
800
992
|
let value: unknown;
|
|
801
|
-
try {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
993
|
+
try {
|
|
994
|
+
value = JSON.parse(observed);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
throw new TelegramRuntimeStateError(
|
|
997
|
+
"invalid",
|
|
998
|
+
"Telegram runtime state is unreadable.",
|
|
999
|
+
{ cause: error },
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (
|
|
1003
|
+
!runtimeStateRecord(value) ||
|
|
1004
|
+
value.version !== 2 ||
|
|
1005
|
+
!runtimeStateRecord(value.profiles) ||
|
|
1006
|
+
Object.keys(value).some((key) => key !== "version" && key !== "profiles")
|
|
1007
|
+
)
|
|
1008
|
+
throw new TelegramRuntimeStateError(
|
|
1009
|
+
"invalid",
|
|
1010
|
+
"Telegram runtime state envelope is unsupported or malformed.",
|
|
1011
|
+
);
|
|
806
1012
|
for (const [profile, sections] of Object.entries(value.profiles)) {
|
|
807
1013
|
requireRuntimeStateProfile(profile);
|
|
808
|
-
if (
|
|
809
|
-
|
|
1014
|
+
if (
|
|
1015
|
+
!runtimeStateRecord(sections) ||
|
|
1016
|
+
Object.keys(sections).some(
|
|
1017
|
+
(key) =>
|
|
1018
|
+
!TELEGRAM_RUNTIME_STATE_SECTIONS.includes(
|
|
1019
|
+
key as TelegramRuntimeStateSection,
|
|
1020
|
+
),
|
|
1021
|
+
)
|
|
1022
|
+
)
|
|
1023
|
+
throw new TelegramRuntimeStateError(
|
|
1024
|
+
"invalid",
|
|
1025
|
+
"Telegram runtime state sections are malformed.",
|
|
1026
|
+
);
|
|
810
1027
|
}
|
|
811
1028
|
return value as unknown as TelegramRuntimeStateFile;
|
|
812
1029
|
}
|
|
@@ -815,34 +1032,67 @@ export function readTelegramRuntimeState(path: string): TelegramRuntimeStateFile
|
|
|
815
1032
|
* caller-validated section is damaged, publish a fresh empty envelope instead of refusing. All profiles lose runtime
|
|
816
1033
|
* continuity. Filesystem access errors are not damage and still throw. Returns whether a reset was published.
|
|
817
1034
|
*/
|
|
818
|
-
export function resetDamagedTelegramRuntimeState(
|
|
819
|
-
|
|
1035
|
+
export function resetDamagedTelegramRuntimeState(
|
|
1036
|
+
path: string,
|
|
1037
|
+
validateProfile?: (
|
|
1038
|
+
profile: string,
|
|
1039
|
+
sections: Readonly<TelegramRuntimeStateProfile>,
|
|
1040
|
+
) => void,
|
|
1041
|
+
): boolean {
|
|
820
1042
|
requireRuntimeStatePath(path);
|
|
821
1043
|
const runtimeDir = join(dirname(path), "runtime");
|
|
822
1044
|
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
823
|
-
return withTelegramFileTransaction(
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1045
|
+
return withTelegramFileTransaction(
|
|
1046
|
+
join(runtimeDir, `${basename(path)}.transaction`),
|
|
1047
|
+
() => {
|
|
1048
|
+
let damaged = false;
|
|
1049
|
+
try {
|
|
1050
|
+
for (const [profile, sections] of Object.entries(
|
|
1051
|
+
readTelegramRuntimeState(path).profiles,
|
|
1052
|
+
)) {
|
|
1053
|
+
assertTelegramStateTransport(sections.transport);
|
|
1054
|
+
try {
|
|
1055
|
+
validateProfile?.(profile, sections);
|
|
1056
|
+
} catch {
|
|
1057
|
+
damaged = true;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
if (!(error instanceof TelegramRuntimeStateError)) throw error;
|
|
1062
|
+
damaged = true;
|
|
829
1063
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1064
|
+
if (!damaged) return false;
|
|
1065
|
+
const tempPath = join(
|
|
1066
|
+
runtimeDir,
|
|
1067
|
+
`${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
1068
|
+
);
|
|
1069
|
+
try {
|
|
1070
|
+
writeFileSync(
|
|
1071
|
+
tempPath,
|
|
1072
|
+
`${JSON.stringify({ version: 2, profiles: {} }, null, 2)}\n`,
|
|
1073
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
1074
|
+
);
|
|
1075
|
+
if (!renameTelegramPathWithRetry(tempPath, path))
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
"Telegram runtime state reset publication disappeared.",
|
|
1078
|
+
);
|
|
1079
|
+
} finally {
|
|
1080
|
+
try {
|
|
1081
|
+
unlinkSync(tempPath);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
if ((error as { code?: unknown }).code !== "ENOENT") throw error;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return true;
|
|
1087
|
+
},
|
|
1088
|
+
);
|
|
845
1089
|
}
|
|
1090
|
+
/** Pure section reducer: receives the stored section and its profile, returns the next section and result. */
|
|
1091
|
+
export type TelegramRuntimeStateSectionReducer<T> = (
|
|
1092
|
+
current: unknown,
|
|
1093
|
+
observed: Readonly<TelegramRuntimeStateProfile>,
|
|
1094
|
+
) => TelegramRuntimeStateMutation<T>;
|
|
1095
|
+
|
|
846
1096
|
export interface TelegramRuntimeStateMutation<T> {
|
|
847
1097
|
value: unknown;
|
|
848
1098
|
result: T;
|
|
@@ -850,7 +1100,9 @@ export interface TelegramRuntimeStateMutation<T> {
|
|
|
850
1100
|
interface TelegramRuntimeStatePublicationOptions {
|
|
851
1101
|
/** Exact domain authority, recaptured by the caller before invoking this synchronous transaction. */
|
|
852
1102
|
isCurrent: () => boolean;
|
|
853
|
-
onPublicationBoundary?: (
|
|
1103
|
+
onPublicationBoundary?: (
|
|
1104
|
+
boundary: "before-write" | "after-write-before-rename" | "after-rename",
|
|
1105
|
+
) => void;
|
|
854
1106
|
publishRename?: typeof renameSync;
|
|
855
1107
|
}
|
|
856
1108
|
export interface TelegramOwnedStatePublicationOptions extends TelegramRuntimeStatePublicationOptions {
|
|
@@ -862,62 +1114,127 @@ export interface TelegramOwnedStatePublicationOptions extends TelegramRuntimeSta
|
|
|
862
1114
|
* current sibling facts without granting writes to them. No await, nested transaction, repair or legacy import.
|
|
863
1115
|
*/
|
|
864
1116
|
export function mutateTelegramRuntimeStateSection<T>(
|
|
865
|
-
path: string,
|
|
866
|
-
|
|
1117
|
+
path: string,
|
|
1118
|
+
profile: string,
|
|
1119
|
+
section: TelegramRuntimeStateSection,
|
|
1120
|
+
mutate: TelegramRuntimeStateSectionReducer<T>,
|
|
867
1121
|
options: TelegramRuntimeStatePublicationOptions,
|
|
868
1122
|
): T {
|
|
869
1123
|
requireRuntimeStatePath(path);
|
|
870
1124
|
requireRuntimeStateProfile(profile);
|
|
871
1125
|
if (!TELEGRAM_RUNTIME_STATE_SECTIONS.includes(section))
|
|
872
|
-
throw new TelegramRuntimeStateError(
|
|
873
|
-
|
|
874
|
-
|
|
1126
|
+
throw new TelegramRuntimeStateError(
|
|
1127
|
+
"invalid",
|
|
1128
|
+
"Telegram runtime state section is invalid.",
|
|
1129
|
+
);
|
|
1130
|
+
const active = ((globalThis as TelegramStateGlobal)[
|
|
1131
|
+
TELEGRAM_ACTIVE_STATE_TRANSACTIONS
|
|
1132
|
+
] ??= new Set<string>());
|
|
1133
|
+
if (active.has(path))
|
|
1134
|
+
throw new TelegramRuntimeStateError(
|
|
1135
|
+
"invalid",
|
|
1136
|
+
"Nested Telegram runtime state transaction is not allowed.",
|
|
1137
|
+
);
|
|
875
1138
|
const assertCurrent = (): void => {
|
|
876
|
-
if (!options.isCurrent())
|
|
1139
|
+
if (!options.isCurrent())
|
|
1140
|
+
throw new TelegramRuntimeStateError(
|
|
1141
|
+
"authority-changed",
|
|
1142
|
+
"Telegram runtime state publication authority changed.",
|
|
1143
|
+
);
|
|
877
1144
|
};
|
|
878
1145
|
assertCurrent();
|
|
879
1146
|
active.add(path);
|
|
880
1147
|
try {
|
|
881
1148
|
const runtimeDir = join(dirname(path), "runtime");
|
|
882
1149
|
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
883
|
-
return withTelegramFileTransaction(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const previous = Object.hasOwn(file.profiles, profile) ? file.profiles[profile]! : {};
|
|
887
|
-
const outcome = mutate(structuredClone(previous[section]), structuredClone(previous));
|
|
888
|
-
if (!runtimeStateRecord(outcome) || !Object.hasOwn(outcome, "value") || !Object.hasOwn(outcome, "result") || "then" in outcome)
|
|
889
|
-
throw new TelegramRuntimeStateError("invalid", "Telegram runtime state mutations must return a synchronous value and result.");
|
|
890
|
-
// Normalize intentional optional properties to their wire representation before comparison/publication.
|
|
891
|
-
const next = outcome.value === undefined ? undefined : JSON.parse(JSON.stringify(outcome.value)) as unknown;
|
|
892
|
-
assertCurrent();
|
|
893
|
-
if (isDeepStrictEqual(previous[section], next)) return outcome.result;
|
|
894
|
-
const updated = { ...previous };
|
|
895
|
-
if (next === undefined) delete updated[section];
|
|
896
|
-
else updated[section] = next;
|
|
897
|
-
if (Object.keys(updated).length) Object.defineProperty(file.profiles, profile, { value: updated, enumerable: true, configurable: true, writable: true });
|
|
898
|
-
else delete file.profiles[profile];
|
|
899
|
-
const tempPath = join(runtimeDir, `${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
900
|
-
try {
|
|
901
|
-
options.onPublicationBoundary?.("before-write");
|
|
1150
|
+
return withTelegramFileTransaction(
|
|
1151
|
+
join(runtimeDir, `${basename(path)}.transaction`),
|
|
1152
|
+
() => {
|
|
902
1153
|
assertCurrent();
|
|
903
|
-
|
|
904
|
-
|
|
1154
|
+
const file = readTelegramRuntimeState(path);
|
|
1155
|
+
const previous = Object.hasOwn(file.profiles, profile)
|
|
1156
|
+
? file.profiles[profile]!
|
|
1157
|
+
: {};
|
|
1158
|
+
const outcome = mutate(
|
|
1159
|
+
structuredClone(previous[section]),
|
|
1160
|
+
structuredClone(previous),
|
|
1161
|
+
);
|
|
1162
|
+
if (
|
|
1163
|
+
!runtimeStateRecord(outcome) ||
|
|
1164
|
+
!Object.hasOwn(outcome, "value") ||
|
|
1165
|
+
!Object.hasOwn(outcome, "result") ||
|
|
1166
|
+
"then" in outcome
|
|
1167
|
+
)
|
|
1168
|
+
throw new TelegramRuntimeStateError(
|
|
1169
|
+
"invalid",
|
|
1170
|
+
"Telegram runtime state mutations must return a synchronous value and result.",
|
|
1171
|
+
);
|
|
1172
|
+
// Normalize intentional optional properties to their wire representation before comparison/publication.
|
|
1173
|
+
const next =
|
|
1174
|
+
outcome.value === undefined
|
|
1175
|
+
? undefined
|
|
1176
|
+
: (JSON.parse(JSON.stringify(outcome.value)) as unknown);
|
|
905
1177
|
assertCurrent();
|
|
1178
|
+
if (isDeepStrictEqual(previous[section], next)) return outcome.result;
|
|
1179
|
+
const updated = { ...previous };
|
|
1180
|
+
if (next === undefined) delete updated[section];
|
|
1181
|
+
else updated[section] = next;
|
|
1182
|
+
if (Object.keys(updated).length)
|
|
1183
|
+
Object.defineProperty(file.profiles, profile, {
|
|
1184
|
+
value: updated,
|
|
1185
|
+
enumerable: true,
|
|
1186
|
+
configurable: true,
|
|
1187
|
+
writable: true,
|
|
1188
|
+
});
|
|
1189
|
+
else delete file.profiles[profile];
|
|
1190
|
+
const tempPath = join(
|
|
1191
|
+
runtimeDir,
|
|
1192
|
+
`${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
1193
|
+
);
|
|
906
1194
|
try {
|
|
907
|
-
|
|
908
|
-
throw new Error("Telegram runtime state temporary publication disappeared.");
|
|
909
|
-
options.onPublicationBoundary?.("after-rename");
|
|
1195
|
+
options.onPublicationBoundary?.("before-write");
|
|
910
1196
|
assertCurrent();
|
|
911
|
-
|
|
912
|
-
|
|
1197
|
+
writeFileSync(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1198
|
+
encoding: "utf8",
|
|
1199
|
+
flag: "wx",
|
|
1200
|
+
mode: 0o600,
|
|
1201
|
+
});
|
|
1202
|
+
options.onPublicationBoundary?.("after-write-before-rename");
|
|
1203
|
+
assertCurrent();
|
|
1204
|
+
try {
|
|
1205
|
+
if (
|
|
1206
|
+
!renameTelegramPathWithRetry(tempPath, path, {
|
|
1207
|
+
rename(from, to) {
|
|
1208
|
+
assertCurrent();
|
|
1209
|
+
(options.publishRename ?? renameSync)(from, to);
|
|
1210
|
+
},
|
|
1211
|
+
})
|
|
1212
|
+
)
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
"Telegram runtime state temporary publication disappeared.",
|
|
1215
|
+
);
|
|
1216
|
+
options.onPublicationBoundary?.("after-rename");
|
|
1217
|
+
assertCurrent();
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
throw new TelegramRuntimeStateError(
|
|
1220
|
+
"publication-unknown",
|
|
1221
|
+
"Telegram runtime state publication outcome is unknown.",
|
|
1222
|
+
{ cause: error },
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
return outcome.result;
|
|
1226
|
+
} finally {
|
|
1227
|
+
try {
|
|
1228
|
+
unlinkSync(tempPath);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
if ((error as { code?: unknown }).code !== "ENOENT") throw error;
|
|
1231
|
+
}
|
|
913
1232
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
});
|
|
920
|
-
} finally { active.delete(path); }
|
|
1233
|
+
},
|
|
1234
|
+
);
|
|
1235
|
+
} finally {
|
|
1236
|
+
active.delete(path);
|
|
1237
|
+
}
|
|
921
1238
|
}
|
|
922
1239
|
|
|
923
1240
|
function withLockTransaction<T>(
|
|
@@ -1000,21 +1317,12 @@ export function parseTelegramLockEntry(
|
|
|
1000
1317
|
: undefined,
|
|
1001
1318
|
busSecret:
|
|
1002
1319
|
typeof record.busSecret === "string" ? record.busSecret : undefined,
|
|
1003
|
-
...(readTelegramLockJournalPath(record)
|
|
1320
|
+
...(readTelegramLockJournalPath(record)
|
|
1321
|
+
? { journalPath: readTelegramLockJournalPath(record) }
|
|
1322
|
+
: {}),
|
|
1004
1323
|
};
|
|
1005
1324
|
}
|
|
1006
1325
|
|
|
1007
|
-
export function isProcessAlive(pid: number): boolean {
|
|
1008
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1009
|
-
try {
|
|
1010
|
-
process.kill(pid, 0);
|
|
1011
|
-
return true;
|
|
1012
|
-
} catch (error) {
|
|
1013
|
-
// Only an absent PID proves death; permission and unexpected failures do not.
|
|
1014
|
-
return (error as { code?: string }).code !== "ESRCH";
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
1326
|
function formatTelegramLockEntry(lock: TelegramLockEntry): string {
|
|
1019
1327
|
return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
|
|
1020
1328
|
}
|
|
@@ -1135,16 +1443,45 @@ function formatLockState(state: TelegramLockState): string {
|
|
|
1135
1443
|
|
|
1136
1444
|
function assertTelegramStateTransport(value: unknown): void {
|
|
1137
1445
|
if (value === undefined) return;
|
|
1138
|
-
if (!runtimeStateRecord(value))
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1446
|
+
if (!runtimeStateRecord(value))
|
|
1447
|
+
throw new TelegramRuntimeStateError(
|
|
1448
|
+
"invalid",
|
|
1449
|
+
"Telegram state transport is malformed.",
|
|
1450
|
+
);
|
|
1451
|
+
const keys = [
|
|
1452
|
+
"pid",
|
|
1453
|
+
"cwd",
|
|
1454
|
+
"instanceId",
|
|
1455
|
+
"heartbeatMs",
|
|
1456
|
+
"leaderEpoch",
|
|
1457
|
+
"runtimeGeneration",
|
|
1458
|
+
"busSocketPath",
|
|
1459
|
+
"busSecret",
|
|
1460
|
+
"journalPath",
|
|
1461
|
+
];
|
|
1462
|
+
if (
|
|
1463
|
+
Object.keys(value).some((key) => !keys.includes(key)) ||
|
|
1464
|
+
(value.pid === undefined
|
|
1465
|
+
? Object.keys(value).length !== 1 || !readTelegramLockJournalPath(value)
|
|
1466
|
+
: !Number.isSafeInteger(value.pid) || (value.pid as number) <= 0) ||
|
|
1467
|
+
["cwd", "instanceId", "busSocketPath", "busSecret", "journalPath"].some(
|
|
1468
|
+
(key) => value[key] !== undefined && typeof value[key] !== "string",
|
|
1469
|
+
) ||
|
|
1470
|
+
["heartbeatMs", "runtimeGeneration"].some(
|
|
1471
|
+
(key) =>
|
|
1472
|
+
value[key] !== undefined &&
|
|
1473
|
+
(!Number.isSafeInteger(value[key]) || (value[key] as number) < 0),
|
|
1474
|
+
) ||
|
|
1475
|
+
(value.journalPath !== undefined && !readTelegramLockJournalPath(value)) ||
|
|
1476
|
+
(value.leaderEpoch !== undefined &&
|
|
1477
|
+
(typeof value.leaderEpoch === "string"
|
|
1478
|
+
? value.leaderEpoch.length === 0
|
|
1479
|
+
: !Number.isSafeInteger(value.leaderEpoch)))
|
|
1480
|
+
)
|
|
1481
|
+
throw new TelegramRuntimeStateError(
|
|
1482
|
+
"invalid",
|
|
1483
|
+
"Telegram state transport is malformed.",
|
|
1484
|
+
);
|
|
1148
1485
|
}
|
|
1149
1486
|
|
|
1150
1487
|
export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
@@ -1152,7 +1489,10 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1152
1489
|
): TelegramLockRuntime<TContext> {
|
|
1153
1490
|
const key = options.key ?? TELEGRAM_LOCK_KEY;
|
|
1154
1491
|
if (options.statePath && options.locksPath)
|
|
1155
|
-
throw new TelegramRuntimeStateError(
|
|
1492
|
+
throw new TelegramRuntimeStateError(
|
|
1493
|
+
"invalid",
|
|
1494
|
+
"Telegram transport must select one storage identity.",
|
|
1495
|
+
);
|
|
1156
1496
|
const statePath = options.statePath;
|
|
1157
1497
|
const locksPath = options.locksPath ?? getOwnersPath();
|
|
1158
1498
|
const pid = options.pid ?? process.pid;
|
|
@@ -1175,23 +1515,39 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1175
1515
|
if (!statePath) return readLocks(locksPath);
|
|
1176
1516
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1177
1517
|
try {
|
|
1178
|
-
const transports = Object.fromEntries(
|
|
1179
|
-
|
|
1518
|
+
const transports = Object.fromEntries(
|
|
1519
|
+
Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1520
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1521
|
+
.map(([profile, value]) => [profile, value.transport]),
|
|
1522
|
+
);
|
|
1180
1523
|
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1181
1524
|
return transports;
|
|
1182
|
-
} catch {
|
|
1525
|
+
} catch {
|
|
1526
|
+
return {};
|
|
1527
|
+
}
|
|
1183
1528
|
};
|
|
1184
|
-
const transactOwners = <T>(
|
|
1529
|
+
const transactOwners = <T>(
|
|
1530
|
+
mutate: (locks: Record<string, unknown>) => { result: T; changed: boolean },
|
|
1531
|
+
): T => {
|
|
1185
1532
|
if (!statePath) return withLockTransaction(locksPath, mutate);
|
|
1186
1533
|
const profile = resolveEffectiveKey();
|
|
1187
|
-
return mutateTelegramRuntimeStateSection(
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1534
|
+
return mutateTelegramRuntimeStateSection(
|
|
1535
|
+
statePath,
|
|
1536
|
+
profile,
|
|
1537
|
+
"transport",
|
|
1538
|
+
(current) => {
|
|
1539
|
+
assertTelegramStateTransport(current);
|
|
1540
|
+
const locks = { [profile]: current };
|
|
1541
|
+
const outcome = mutate(locks);
|
|
1542
|
+
const value = outcome.changed ? locks[profile] : current;
|
|
1543
|
+
assertTelegramStateTransport(value);
|
|
1544
|
+
return { value, result: outcome.result };
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
...options.statePublication,
|
|
1548
|
+
isCurrent: () => resolveEffectiveKey() === profile,
|
|
1549
|
+
},
|
|
1550
|
+
);
|
|
1195
1551
|
};
|
|
1196
1552
|
const readLock = () => {
|
|
1197
1553
|
const effectiveKey = resolveEffectiveKey();
|
|
@@ -1199,16 +1555,37 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1199
1555
|
};
|
|
1200
1556
|
// A live older-release leader polls the same bot from its own directory. Only identity is exposed: its bus
|
|
1201
1557
|
// endpoint, secret and epoch belong to another protocol and must never become a follower target.
|
|
1202
|
-
const readLegacyOwner = (
|
|
1203
|
-
|
|
1558
|
+
const readLegacyOwner = (
|
|
1559
|
+
effectiveKey: string,
|
|
1560
|
+
): TelegramLockEntry | undefined => {
|
|
1561
|
+
if (
|
|
1562
|
+
!options.legacyLocksPath ||
|
|
1563
|
+
options.legacyLocksPath === (statePath ?? locksPath)
|
|
1564
|
+
)
|
|
1565
|
+
return undefined;
|
|
1204
1566
|
try {
|
|
1205
|
-
const records = statePath
|
|
1206
|
-
|
|
1567
|
+
const records = statePath
|
|
1568
|
+
? readLocksForTransaction(options.legacyLocksPath)
|
|
1569
|
+
: readLocks(options.legacyLocksPath);
|
|
1570
|
+
if (statePath)
|
|
1571
|
+
for (const value of Object.values(records))
|
|
1572
|
+
assertTelegramStateTransport(value);
|
|
1207
1573
|
const raw = records[effectiveKey];
|
|
1208
1574
|
const legacy = parseTelegramLockEntry(raw);
|
|
1209
|
-
if (
|
|
1210
|
-
|
|
1211
|
-
|
|
1575
|
+
if (
|
|
1576
|
+
!legacy ||
|
|
1577
|
+
getLockState(legacy, -1, isAlive, stateOptions()).kind !==
|
|
1578
|
+
"active-elsewhere"
|
|
1579
|
+
)
|
|
1580
|
+
return undefined;
|
|
1581
|
+
return {
|
|
1582
|
+
pid: legacy.pid,
|
|
1583
|
+
...(legacy.cwd ? { cwd: legacy.cwd } : {}),
|
|
1584
|
+
...(legacy.instanceId ? { instanceId: legacy.instanceId } : {}),
|
|
1585
|
+
...(legacy.heartbeatMs !== undefined
|
|
1586
|
+
? { heartbeatMs: legacy.heartbeatMs }
|
|
1587
|
+
: {}),
|
|
1588
|
+
};
|
|
1212
1589
|
} catch (error) {
|
|
1213
1590
|
if (statePath) throw error;
|
|
1214
1591
|
return undefined;
|
|
@@ -1240,7 +1617,11 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1240
1617
|
transactOwners<TelegramLockAcquireResult>((locks) => {
|
|
1241
1618
|
const effectiveKey = resolveEffectiveKey();
|
|
1242
1619
|
const legacy = readLegacyOwner(effectiveKey);
|
|
1243
|
-
if (legacy)
|
|
1620
|
+
if (legacy)
|
|
1621
|
+
return {
|
|
1622
|
+
result: { ok: false, lock: legacy } as const,
|
|
1623
|
+
changed: false,
|
|
1624
|
+
};
|
|
1244
1625
|
const current = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1245
1626
|
const state = getLockState(current, pid, isAlive, stateOptions());
|
|
1246
1627
|
const expectedOwned = adoptCompatibleOwnedLock(
|
|
@@ -1302,7 +1683,9 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1302
1683
|
changed: false,
|
|
1303
1684
|
};
|
|
1304
1685
|
}
|
|
1305
|
-
const journalPath =
|
|
1686
|
+
const journalPath =
|
|
1687
|
+
readTelegramLockJournalPath(locks[effectiveKey]) ??
|
|
1688
|
+
options.createJournalPath?.(ctx);
|
|
1306
1689
|
const lock = createLockEntry(pid, ctx, {
|
|
1307
1690
|
journalPath,
|
|
1308
1691
|
instanceId: options.instanceId,
|
|
@@ -1353,7 +1736,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1353
1736
|
});
|
|
1354
1737
|
},
|
|
1355
1738
|
getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
|
|
1356
|
-
getJournalPath: () =>
|
|
1739
|
+
getJournalPath: () =>
|
|
1740
|
+
readTelegramLockJournalPath(readOwners()[resolveEffectiveKey()]),
|
|
1357
1741
|
getStatusLabel: () =>
|
|
1358
1742
|
formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
|
|
1359
1743
|
getOwnedLeaderEpoch: () => {
|
|
@@ -1373,7 +1757,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1373
1757
|
);
|
|
1374
1758
|
},
|
|
1375
1759
|
commitIfOwned: (commit) =>
|
|
1376
|
-
!deliveryRevoked &&
|
|
1760
|
+
!deliveryRevoked &&
|
|
1761
|
+
transactOwners((locks) => {
|
|
1377
1762
|
const effectiveKey = resolveEffectiveKey();
|
|
1378
1763
|
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1379
1764
|
const exactOwner =
|
|
@@ -1387,35 +1772,83 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1387
1772
|
}
|
|
1388
1773
|
commit();
|
|
1389
1774
|
// External-file commits may retain an independent effect; never acknowledge under a changed unified owner.
|
|
1390
|
-
const current =
|
|
1775
|
+
const current =
|
|
1776
|
+
!statePath ||
|
|
1777
|
+
(!deliveryRevoked &&
|
|
1778
|
+
hasSameLockOwner(
|
|
1779
|
+
parseTelegramLockEntry(readOwners()[effectiveKey]),
|
|
1780
|
+
ownedLock,
|
|
1781
|
+
));
|
|
1391
1782
|
return { result: current, changed: false };
|
|
1392
1783
|
}),
|
|
1393
1784
|
publishStateSectionIfOwned<T>(
|
|
1394
1785
|
section: "workspace" | "runtime",
|
|
1395
|
-
mutate:
|
|
1786
|
+
mutate: TelegramRuntimeStateSectionReducer<T>,
|
|
1396
1787
|
publication: TelegramOwnedStatePublicationOptions,
|
|
1397
1788
|
): TelegramOwnedStatePublicationResult<T> {
|
|
1398
1789
|
if (section !== "workspace" && section !== "runtime")
|
|
1399
|
-
throw new TelegramRuntimeStateError(
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1790
|
+
throw new TelegramRuntimeStateError(
|
|
1791
|
+
"invalid",
|
|
1792
|
+
"Telegram owner section publication is restricted to Workspace and runtime observations.",
|
|
1793
|
+
);
|
|
1794
|
+
if (!statePath)
|
|
1795
|
+
throw new TelegramRuntimeStateError(
|
|
1796
|
+
"invalid",
|
|
1797
|
+
"Consolidated transport storage is not selected.",
|
|
1798
|
+
);
|
|
1799
|
+
const profile = resolveEffectiveKey(),
|
|
1800
|
+
expected = ownedLock ? { ...ownedLock } : undefined;
|
|
1801
|
+
if (
|
|
1802
|
+
publication.expectedScope &&
|
|
1803
|
+
(publication.expectedScope.path !== statePath ||
|
|
1804
|
+
publication.expectedScope.profile !== profile)
|
|
1805
|
+
)
|
|
1403
1806
|
return { committed: false };
|
|
1404
|
-
if (
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1807
|
+
if (
|
|
1808
|
+
deliveryRevoked ||
|
|
1809
|
+
ownedLockKey !== profile ||
|
|
1810
|
+
!expected ||
|
|
1811
|
+
!publication.isCurrent()
|
|
1812
|
+
)
|
|
1813
|
+
return { committed: false };
|
|
1814
|
+
const isCurrent = (): boolean =>
|
|
1815
|
+
!deliveryRevoked &&
|
|
1816
|
+
resolveEffectiveKey() === profile &&
|
|
1817
|
+
ownedLockKey === profile &&
|
|
1818
|
+
hasSameLockOwner(ownedLock, expected) &&
|
|
1819
|
+
publication.isCurrent() &&
|
|
1820
|
+
hasSameLockOwner(
|
|
1821
|
+
parseTelegramLockEntry(readOwners()[profile]),
|
|
1822
|
+
expected,
|
|
1823
|
+
);
|
|
1408
1824
|
if (!isCurrent()) return { committed: false };
|
|
1409
|
-
return mutateTelegramRuntimeStateSection<
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1825
|
+
return mutateTelegramRuntimeStateSection<
|
|
1826
|
+
TelegramOwnedStatePublicationResult<T>
|
|
1827
|
+
>(
|
|
1828
|
+
statePath,
|
|
1829
|
+
profile,
|
|
1830
|
+
section,
|
|
1831
|
+
(current, observed) => {
|
|
1832
|
+
assertTelegramStateTransport(observed.transport);
|
|
1833
|
+
if (
|
|
1834
|
+
!hasSameLockOwner(
|
|
1835
|
+
parseTelegramLockEntry(observed.transport),
|
|
1836
|
+
expected,
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
return { value: current, result: { committed: false } as const };
|
|
1840
|
+
const outcome = mutate(current, observed);
|
|
1841
|
+
return {
|
|
1842
|
+
value: outcome.value,
|
|
1843
|
+
result: { committed: true, result: outcome.result } as const,
|
|
1844
|
+
};
|
|
1845
|
+
},
|
|
1846
|
+
{ ...publication, isCurrent },
|
|
1847
|
+
);
|
|
1416
1848
|
},
|
|
1417
1849
|
refresh: (ctx) =>
|
|
1418
|
-
!deliveryRevoked &&
|
|
1850
|
+
!deliveryRevoked &&
|
|
1851
|
+
transactOwners((locks) => {
|
|
1419
1852
|
const effectiveKey = resolveEffectiveKey();
|
|
1420
1853
|
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1421
1854
|
const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
|
|
@@ -1564,8 +1997,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1564
1997
|
let suspendedGeneration: number | undefined;
|
|
1565
1998
|
let suspensionsInFlight = 0;
|
|
1566
1999
|
let startupsInFlight = 0;
|
|
1567
|
-
const ownershipCheckMs =
|
|
1568
|
-
deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
|
|
2000
|
+
const ownershipCheckMs = deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
|
|
1569
2001
|
const ownershipRefreshMs =
|
|
1570
2002
|
deps.ownershipRefreshMs ?? TELEGRAM_OWNERSHIP_REFRESH_MS;
|
|
1571
2003
|
const stopOwnershipWatcher = () => {
|
|
@@ -1593,7 +2025,11 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1593
2025
|
}
|
|
1594
2026
|
await deps.stopPolling();
|
|
1595
2027
|
// Unsettled starts, overlapping stops or stale completion cannot certify quiescence.
|
|
1596
|
-
if (
|
|
2028
|
+
if (
|
|
2029
|
+
generation === pollingGeneration &&
|
|
2030
|
+
suspensionsInFlight === 1 &&
|
|
2031
|
+
startupsInFlight === 0
|
|
2032
|
+
) {
|
|
1597
2033
|
suspendedGeneration = generation;
|
|
1598
2034
|
}
|
|
1599
2035
|
} finally {
|
|
@@ -1693,7 +2129,10 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1693
2129
|
const stop = async (): Promise<string> => {
|
|
1694
2130
|
const generation = pollingGeneration + 1;
|
|
1695
2131
|
await suspendPolling();
|
|
1696
|
-
if (generation !== pollingGeneration)
|
|
2132
|
+
if (generation !== pollingGeneration)
|
|
2133
|
+
throw new Error(
|
|
2134
|
+
"Telegram disconnect was superseded by a new connection.",
|
|
2135
|
+
);
|
|
1697
2136
|
const state = deps.lock.release();
|
|
1698
2137
|
deps.onTransportAvailabilityChanged?.();
|
|
1699
2138
|
if (state.kind === "active-elsewhere") {
|
|
@@ -1723,15 +2162,24 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1723
2162
|
};
|
|
1724
2163
|
if (deps.isContextCurrent?.(ctx) === false) return cancelled;
|
|
1725
2164
|
const generation = ++pollingGeneration;
|
|
1726
|
-
const isCurrent = () =>
|
|
2165
|
+
const isCurrent = () =>
|
|
2166
|
+
generation === pollingGeneration &&
|
|
1727
2167
|
(deps.isContextCurrent?.(ctx) ?? true);
|
|
1728
2168
|
if (ownershipStop) await ownershipStop;
|
|
1729
2169
|
if (!isCurrent()) return cancelled;
|
|
1730
2170
|
if (!options.election && deps.resetDamagedState) {
|
|
1731
2171
|
try {
|
|
1732
|
-
if (deps.resetDamagedState())
|
|
1733
|
-
|
|
1734
|
-
|
|
2172
|
+
if (deps.resetDamagedState())
|
|
2173
|
+
deps.recordRuntimeEvent?.(
|
|
2174
|
+
"lock",
|
|
2175
|
+
new Error(
|
|
2176
|
+
"Damaged Telegram runtime state was reset; previous runtime continuity was discarded.",
|
|
2177
|
+
),
|
|
2178
|
+
{ phase: "state-reset" },
|
|
2179
|
+
);
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
deps.recordRuntimeEvent?.("lock", error, { phase: "state-reset" });
|
|
2182
|
+
}
|
|
1735
2183
|
}
|
|
1736
2184
|
let acquired = deps.lock.acquire(ctx, {
|
|
1737
2185
|
force: options.force,
|
|
@@ -1822,7 +2270,10 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1822
2270
|
return {
|
|
1823
2271
|
isCurrent,
|
|
1824
2272
|
async stop() {
|
|
1825
|
-
if (!isCurrent())
|
|
2273
|
+
if (!isCurrent())
|
|
2274
|
+
throw new Error(
|
|
2275
|
+
"Telegram disconnect was superseded by a new connection.",
|
|
2276
|
+
);
|
|
1826
2277
|
return stop();
|
|
1827
2278
|
},
|
|
1828
2279
|
};
|
|
@@ -1830,12 +2281,20 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1830
2281
|
suspend: suspendPolling,
|
|
1831
2282
|
captureTransportAuthority(ctx) {
|
|
1832
2283
|
const generation = pollingGeneration;
|
|
1833
|
-
const current = () =>
|
|
1834
|
-
|
|
2284
|
+
const current = () =>
|
|
2285
|
+
generation === pollingGeneration &&
|
|
2286
|
+
activeContext === ctx &&
|
|
2287
|
+
!ownershipStop &&
|
|
2288
|
+
(deps.isContextCurrent?.(ctx) ?? true) &&
|
|
2289
|
+
deps.lock.owns(snapshotLockContext(ctx));
|
|
1835
2290
|
return current() ? current : undefined;
|
|
1836
2291
|
},
|
|
1837
|
-
isSuspended: () =>
|
|
1838
|
-
|
|
2292
|
+
isSuspended: () =>
|
|
2293
|
+
suspendedGeneration === pollingGeneration &&
|
|
2294
|
+
suspensionsInFlight === 0 &&
|
|
2295
|
+
startupsInFlight === 0 &&
|
|
2296
|
+
!sessionAutoStartRun &&
|
|
2297
|
+
!ownershipStop,
|
|
1839
2298
|
onPersistentConflict: async (ctx, count) => {
|
|
1840
2299
|
if (activeContext === undefined || ownershipStop) return;
|
|
1841
2300
|
if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
|
|
@@ -1858,15 +2317,23 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1858
2317
|
}
|
|
1859
2318
|
ownershipStop = Promise.resolve()
|
|
1860
2319
|
.then(() => deps.stopPolling())
|
|
1861
|
-
.catch((error) => {
|
|
2320
|
+
.catch((error) => {
|
|
2321
|
+
cleanupErrors.push(String(error));
|
|
2322
|
+
})
|
|
1862
2323
|
.finally(() => {
|
|
1863
2324
|
ownershipStop = undefined;
|
|
1864
|
-
deps.recordRuntimeEvent?.(
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2325
|
+
deps.recordRuntimeEvent?.(
|
|
2326
|
+
"polling",
|
|
2327
|
+
ownership === "lost"
|
|
2328
|
+
? "Telegram transport stopped: local ownership lost; check for another Pi instance."
|
|
2329
|
+
: "Telegram transport stopped: competing getUpdates client or ownership mismatch.",
|
|
2330
|
+
{
|
|
2331
|
+
phase: "persistent-conflict",
|
|
2332
|
+
count,
|
|
2333
|
+
ownership,
|
|
2334
|
+
...(cleanupErrors.length ? { cleanupErrors } : {}),
|
|
2335
|
+
},
|
|
2336
|
+
);
|
|
1870
2337
|
deps.updateStatus(ctx);
|
|
1871
2338
|
});
|
|
1872
2339
|
deps.onTransportAvailabilityChanged?.();
|
|
@@ -1895,7 +2362,8 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1895
2362
|
}
|
|
1896
2363
|
if (deps.isContextCurrent?.(ctx) === false) return;
|
|
1897
2364
|
const generation = ++pollingGeneration;
|
|
1898
|
-
const isCurrent = () =>
|
|
2365
|
+
const isCurrent = () =>
|
|
2366
|
+
generation === pollingGeneration &&
|
|
1899
2367
|
(deps.isContextCurrent?.(ctx) ?? true);
|
|
1900
2368
|
const startedAtMs = Date.now();
|
|
1901
2369
|
deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
|
|
@@ -1906,15 +2374,21 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1906
2374
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1907
2375
|
if (ownershipStop) await ownershipStop;
|
|
1908
2376
|
if (!isCurrent()) return;
|
|
1909
|
-
if (
|
|
2377
|
+
if (
|
|
2378
|
+
canRestoreRememberedFollower &&
|
|
2379
|
+
state?.kind === "active-elsewhere"
|
|
2380
|
+
) {
|
|
1910
2381
|
const restored = await deps.restoreFollowerWithOwner?.(
|
|
1911
2382
|
ctx,
|
|
1912
2383
|
state.lock,
|
|
1913
2384
|
);
|
|
1914
2385
|
if (!isCurrent()) return;
|
|
1915
2386
|
if (!restored) {
|
|
1916
|
-
deps.recordRuntimeEvent?.(
|
|
1917
|
-
|
|
2387
|
+
deps.recordRuntimeEvent?.(
|
|
2388
|
+
"lock",
|
|
2389
|
+
"Telegram follower auto-connect did not restore this session.",
|
|
2390
|
+
{ phase: "follower-auto-connect-unavailable" },
|
|
2391
|
+
);
|
|
1918
2392
|
return;
|
|
1919
2393
|
}
|
|
1920
2394
|
deps.onTransportAvailabilityChanged?.();
|