@llblab/pi-kit 0.30.1 → 0.31.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.
Files changed (35) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +20 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +12 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +52 -3
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -0
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +35 -0
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +81 -3
  11. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -6
  12. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +0 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +13 -2
  14. package/node_modules/@llblab/pi-telegram/dist/lib/generative-app-worker.mjs +5 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +8 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +60 -35
  17. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +9 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +118 -57
  19. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +25 -5
  20. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -3
  21. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  22. package/node_modules/@llblab/pi-telegram/docs/architecture.md +13 -13
  23. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -16
  24. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +67 -4
  25. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +8 -0
  26. package/node_modules/@llblab/pi-telegram/lib/bus.ts +124 -3
  27. package/node_modules/@llblab/pi-telegram/lib/config.ts +0 -8
  28. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -2
  29. package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +5 -0
  30. package/node_modules/@llblab/pi-telegram/lib/journal.ts +76 -37
  31. package/node_modules/@llblab/pi-telegram/lib/locks.ts +148 -58
  32. package/node_modules/@llblab/pi-telegram/lib/status.ts +24 -5
  33. package/node_modules/@llblab/pi-telegram/lib/sync.ts +5 -5
  34. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  35. package/package.json +2 -2
@@ -385,6 +385,8 @@ export function createTelegramManualFollowerProfileKeyResolver(input: {
385
385
 
386
386
  export interface TelegramBusFollowerElection {
387
387
  expectedOwner?: TelegramLockEntry;
388
+ /** The expected owner is bus-proven unresponsive; the lock CAS still requires it to be the current owner. */
389
+ unresponsive?: boolean;
388
390
  }
389
391
 
390
392
  export type TelegramBusFollowerPromotionHandler<TContext> = (
@@ -568,6 +570,10 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
568
570
  scheduleRetry?: (retry: () => void, delayMs: number) => void;
569
571
  getActiveContext?: () => TContext | undefined;
570
572
  promotionGraceMs?: number;
573
+ /** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
574
+ proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
575
+ /** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
576
+ isThreadModeDisabled?: () => boolean | Promise<boolean>;
571
577
  recordRuntimeEvent: (
572
578
  category: string,
573
579
  error: unknown,
@@ -755,7 +761,7 @@ export function createTelegramBusFollowerRuntimeAssembly<
755
761
  ...ports.recovery,
756
762
  registrationState: ports.registrationState,
757
763
  recordRuntimeEvent: ports.recordRuntimeEvent,
758
- getRegistrationRuntime: () => registration,
764
+ getRegistrationRuntime: () => baseRegistration,
759
765
  });
760
766
  registration = createTelegramBusFollowerRegistrationRuntime({
761
767
  ...ports.registration,
@@ -768,6 +774,15 @@ export function createTelegramBusFollowerRuntimeAssembly<
768
774
  const baseRegistration = registration;
769
775
  registration = {
770
776
  ...baseRegistration,
777
+ // Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
778
+ stop() {
779
+ recovery.halt();
780
+ baseRegistration.stop();
781
+ },
782
+ registerWithLeader(ctx, leader, options) {
783
+ recovery.resume();
784
+ return baseRegistration.registerWithLeader(ctx, leader, options);
785
+ },
771
786
  async setContext(ctx) {
772
787
  const sessionGeneration = ports.registration.getSessionGeneration?.();
773
788
  if (ports.registration.isContextActive?.(ctx) === false) return;
@@ -2202,9 +2217,18 @@ export function createTelegramBusFollowerRegistrationState(
2202
2217
  };
2203
2218
  }
2204
2219
 
2220
+ /** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
2221
+ export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((
2222
+ error: unknown,
2223
+ ctx: TContext,
2224
+ ) => Promise<void>) & { halt: () => void; resume: () => void };
2225
+
2205
2226
  export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2206
2227
  deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
2207
- ): (error: unknown, ctx: TContext) => Promise<void> {
2228
+ ): TelegramBusFollowerHeartbeatRecoveryHandler<TContext> {
2229
+ // Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
2230
+ // switch to classic mode halts recovery, so it never re-registers behind a disconnect.
2231
+ let halted = false;
2208
2232
  const promotionGraceMs =
2209
2233
  deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
2210
2234
  const sleep =
@@ -2246,6 +2270,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2246
2270
  binding?.target ? { target: binding.target } : undefined,
2247
2271
  );
2248
2272
  if (!restored) return false;
2273
+ if (halted) {
2274
+ deps.getRegistrationRuntime().stop();
2275
+ return false;
2276
+ }
2249
2277
  deps.setLifecyclePhase(undefined);
2250
2278
  safeUpdateStatus(ctx);
2251
2279
  deps.recordRuntimeEvent(
@@ -2273,6 +2301,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2273
2301
  binding: TelegramBusFollowerPromotedBinding,
2274
2302
  ) => {
2275
2303
  const retry = () => {
2304
+ if (halted) return;
2276
2305
  const activeCtx = deps.getActiveContext
2277
2306
  ? deps.getActiveContext()
2278
2307
  : fallbackCtx;
@@ -2372,11 +2401,31 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2372
2401
  ctx: TContext,
2373
2402
  carriedBinding?: TelegramBusFollowerPromotedBinding,
2374
2403
  ): Promise<void> => {
2375
- if (promotionPending) return;
2404
+ if (promotionPending || halted) return;
2376
2405
  promotionPending = true;
2377
2406
  deps.registrationState.beginRecovery();
2378
2407
  const initialBinding = carriedBinding ?? snapshotBinding();
2379
2408
  try {
2409
+ let threadModeDisabled = false;
2410
+ try {
2411
+ threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
2412
+ } catch (stateError) {
2413
+ deps.recordRuntimeEvent("bus", stateError, {
2414
+ phase: "follower-thread-mode-read",
2415
+ });
2416
+ }
2417
+ if (threadModeDisabled) {
2418
+ halted = true;
2419
+ deps.getRegistrationRuntime().stop();
2420
+ deps.setLifecyclePhase(undefined);
2421
+ safeUpdateStatus(ctx);
2422
+ deps.recordRuntimeEvent(
2423
+ "bus",
2424
+ "Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.",
2425
+ { phase: "follower-thread-mode-disabled" },
2426
+ );
2427
+ return;
2428
+ }
2380
2429
  const state = deps.getLeaderState();
2381
2430
  if (state.kind === "active-elsewhere") {
2382
2431
  clearRegisteredState(ctx);
@@ -2410,6 +2459,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2410
2459
  ) {
2411
2460
  return;
2412
2461
  }
2462
+ if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
2463
+ await promoteToLeader(error, ctx, initialBinding, {
2464
+ expectedOwner: graceState.lock,
2465
+ unresponsive: true,
2466
+ });
2467
+ return;
2468
+ }
2413
2469
  deps.setLifecyclePhase(undefined);
2414
2470
  safeUpdateStatus(ctx);
2415
2471
  deps.recordRuntimeEvent(
@@ -2445,7 +2501,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2445
2501
  promotionPending = false;
2446
2502
  }
2447
2503
  };
2448
- return recover;
2504
+ return Object.assign(recover, {
2505
+ halt() {
2506
+ halted = true;
2507
+ },
2508
+ resume() {
2509
+ halted = false;
2510
+ },
2511
+ });
2449
2512
  }
2450
2513
 
2451
2514
  export function createTelegramBusFollowerRegistrationRuntime<
@@ -3371,6 +3371,14 @@ export function createTelegramBusLeaderEnvelopeHandler(
3371
3371
  return createUnauthorizedBusAck(envelope.requestId);
3372
3372
  }
3373
3373
  switch (envelope.kind) {
3374
+ case "bus.probe":
3375
+ // Liveness only: no registry, routing, journal or Bot API effect.
3376
+ return {
3377
+ kind: "bus.ack" as const,
3378
+ requestId: envelope.requestId,
3379
+ ok: true,
3380
+ protocol: deps.protocolIdentity,
3381
+ };
3374
3382
  case "follower.register":
3375
3383
  case "follower.restoreWorkspace": {
3376
3384
  const compatibility = getTelegramBusProtocolCompatibility({
@@ -45,7 +45,12 @@ import {
45
45
  type TelegramBusTransportRetryPolicy,
46
46
  } from "./bus-transport.ts";
47
47
  import type { TelegramThreadDisplayMode } from "./config.ts";
48
- import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS } from "./locks.ts";
48
+ import {
49
+ isSameTelegramLockOwner,
50
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
51
+ type TelegramLockEntry,
52
+ type TelegramLockState,
53
+ } from "./locks.ts";
49
54
  import { resolveAgentDir } from "./paths.ts";
50
55
  import { getTelegramProcessBirthIdentity } from "./process-identity.ts";
51
56
  import {
@@ -131,7 +136,8 @@ export function createTelegramBusAuthSecret(): string {
131
136
  return randomBytes(32).toString("base64url");
132
137
  }
133
138
 
134
- const TELEGRAM_BUS_PROTOCOL_VERSION = 2 as const;
139
+ // v3: journal source serialization left the config transaction; mixed v2/v3 peers would not exclude each other.
140
+ const TELEGRAM_BUS_PROTOCOL_VERSION = 3 as const;
135
141
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
136
142
  "durable-follower-admission-v1" as const;
137
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
@@ -1222,6 +1228,11 @@ export type TelegramBusEnvelope = (
1222
1228
  args: unknown[];
1223
1229
  sentAtMs: number;
1224
1230
  }
1231
+ | {
1232
+ /** Side-effect-free leader liveness probe; any acknowledgement proves a responsive event loop. */
1233
+ kind: "bus.probe";
1234
+ requestId: string;
1235
+ }
1225
1236
  | {
1226
1237
  kind: "bus.ack";
1227
1238
  requestId: string;
@@ -1269,7 +1280,8 @@ export function getTelegramBusEnvelopeTrafficClass(
1269
1280
  ): TelegramBusEnvelopeTrafficClass {
1270
1281
  if (
1271
1282
  envelope.kind === "follower.register" ||
1272
- envelope.kind === "follower.restoreWorkspace"
1283
+ envelope.kind === "follower.restoreWorkspace" ||
1284
+ envelope.kind === "bus.probe"
1273
1285
  )
1274
1286
  return "bootstrap";
1275
1287
  if (envelope.kind === "bus.ack") return "response";
@@ -1579,6 +1591,9 @@ export function parseTelegramBusEnvelope(
1579
1591
  case "follower.callApi":
1580
1592
  envelope = parseCallApiEnvelope(value, requestId);
1581
1593
  break;
1594
+ case "bus.probe":
1595
+ envelope = { kind, requestId };
1596
+ break;
1582
1597
  case "bus.ack":
1583
1598
  envelope = parseAckEnvelope(value, requestId);
1584
1599
  break;
@@ -1590,6 +1605,112 @@ export function parseTelegramBusEnvelope(
1590
1605
  return envelope;
1591
1606
  }
1592
1607
 
1608
+ /**
1609
+ * One leader liveness observation. `silent` (connected or connecting, no answer within the window) and
1610
+ * `unreachable` (an endpoint that refuses connections: nobody listens) can support a takeover. A missing endpoint is
1611
+ * `unknown`: a live classic-mode leader has no socket at all. Any bytes or an orderly close prove the peer's event
1612
+ * loop handled the connection, so even an older leader rejecting the unknown kind is `responsive`.
1613
+ */
1614
+ export type TelegramBusLeaderProbeResult =
1615
+ | "responsive"
1616
+ | "silent"
1617
+ | "unreachable"
1618
+ | "unknown";
1619
+
1620
+ export function probeTelegramBusLeader(input: {
1621
+ socketPath: TelegramBusSocketPathSource;
1622
+ secret?: string;
1623
+ timeoutMs?: number;
1624
+ }): Promise<TelegramBusLeaderProbeResult> {
1625
+ const timeoutMs = input.timeoutMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
1626
+ return new Promise((resolve) => {
1627
+ let settled = false;
1628
+ let socket: Socket | undefined;
1629
+ const finish = (result: TelegramBusLeaderProbeResult) => {
1630
+ if (settled) return;
1631
+ settled = true;
1632
+ clearTimeout(timer);
1633
+ socket?.destroy();
1634
+ resolve(result);
1635
+ };
1636
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
1637
+ let connected = false;
1638
+ try {
1639
+ socket = createConnection(resolveTelegramBusSocketPath(input.socketPath));
1640
+ } catch {
1641
+ finish("unknown");
1642
+ return;
1643
+ }
1644
+ socket.once("connect", () => {
1645
+ connected = true;
1646
+ socket!.write(
1647
+ encodeTelegramBusEnvelope({
1648
+ kind: "bus.probe",
1649
+ requestId: `probe:${randomBytes(8).toString("hex")}`,
1650
+ ...(input.secret ? { auth: input.secret } : {}),
1651
+ }),
1652
+ );
1653
+ });
1654
+ socket.once("data", () => finish("responsive"));
1655
+ socket.once("end", () => finish("responsive"));
1656
+ socket.once("error", (error) => {
1657
+ const code = (error as NodeJS.ErrnoException).code;
1658
+ finish(
1659
+ !connected && code === "ECONNREFUSED" ? "unreachable" : "unknown",
1660
+ );
1661
+ });
1662
+ });
1663
+ }
1664
+
1665
+ /**
1666
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
1667
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
1668
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
1669
+ */
1670
+ export async function proveTelegramBusLeaderUnresponsive(input: {
1671
+ probe: () => Promise<TelegramBusLeaderProbeResult>;
1672
+ isSameOwner: () => boolean;
1673
+ sleep: (ms: number) => Promise<void>;
1674
+ windowMs?: number;
1675
+ }): Promise<boolean> {
1676
+ const first = await input.probe();
1677
+ if (first === "silent") return input.isSameOwner();
1678
+ if (first !== "unreachable") return false;
1679
+ await input.sleep(input.windowMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS);
1680
+ if (!input.isSameOwner()) return false;
1681
+ const second = await input.probe();
1682
+ return (second === "silent" || second === "unreachable") && input.isSameOwner();
1683
+ }
1684
+
1685
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
1686
+ export function createTelegramBusLeaderUnresponsivenessProof(deps: {
1687
+ getLeaderState: () => TelegramLockState;
1688
+ getLeaderSocketPath: () => string;
1689
+ probe?: typeof probeTelegramBusLeader;
1690
+ sleep?: (ms: number) => Promise<void>;
1691
+ }): (owner: TelegramLockEntry) => Promise<boolean> {
1692
+ const probe = deps.probe ?? probeTelegramBusLeader;
1693
+ const sleep =
1694
+ deps.sleep ??
1695
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1696
+ return (owner) =>
1697
+ proveTelegramBusLeaderUnresponsive({
1698
+ probe: () =>
1699
+ probe({
1700
+ socketPath: owner.busSocketPath ?? deps.getLeaderSocketPath(),
1701
+ secret: owner.busSecret,
1702
+ }),
1703
+ isSameOwner: () => {
1704
+ const state = deps.getLeaderState();
1705
+ return (
1706
+ state.kind === "active-elsewhere" &&
1707
+ isSameTelegramLockOwner(state.lock, owner)
1708
+ );
1709
+ },
1710
+ sleep,
1711
+ });
1712
+ }
1713
+
1593
1714
  interface TelegramBusLocalServer {
1594
1715
  start: () => Promise<void>;
1595
1716
  stop: () => Promise<void>;
@@ -289,12 +289,6 @@ export interface TelegramConfigStore {
289
289
  assertExecutionCurrent?: () => void,
290
290
  commitIfOwned?: (commit: () => void) => boolean,
291
291
  ) => Promise<boolean>;
292
- /** Lock-only serialization for trusted synchronous source operations, not authorization.
293
- * Does not read/adopt config. Acquire required Workspace admission first; never
294
- * acquire owners or nest config admission here. Do not pass async callbacks:
295
- * returned promises are not protected after their synchronous prefix.
296
- */
297
- withSourceSerialization: <T>(operation: () => T) => T;
298
292
  /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
299
293
  withPairingAdmission: <T>(
300
294
  profileName: string,
@@ -779,8 +773,6 @@ export function createTelegramConfigStore(
779
773
  nextConfig.allowedUserId = userId;
780
774
  setEffectiveConfig(nextConfig);
781
775
  },
782
- withSourceSerialization: (operation) =>
783
- withTelegramFileTransaction(`${configPath}.transaction`, operation),
784
776
  withPairingAdmission: (profileName, tokenSha256, publish) =>
785
777
  withPersistedPairingProfile(
786
778
  profileName,
@@ -181,13 +181,15 @@ export default function (pi: Pi.ExtensionAPI) {
181
181
  },
182
182
  getProfileName: configStore.getActiveProfileName,
183
183
  });
184
+ const withTelegramJournalSourceSerialization =
185
+ Journal.createTelegramJournalSourceSerialization();
184
186
  const telegramJournalBindingRuntime =
185
187
  Journal.createTelegramUpdateJournalBindingRuntime({
186
188
  base: {
187
189
  getProfileName: configStore.getActiveProfileName,
188
190
  getBotToken: configStore.getBotToken,
189
191
  getBotId: getTelegramBotId,
190
- withSourceSerialization: configStore.withSourceSerialization,
192
+ withSourceSerialization: withTelegramJournalSourceSerialization,
191
193
  onRecovery(event) {
192
194
  recordRuntimeEvent(
193
195
  "recovery",
@@ -259,6 +261,11 @@ export default function (pi: Pi.ExtensionAPI) {
259
261
  return telegramLeaderJournalPath.createJournalPath();
260
262
  },
261
263
  });
264
+ const proveTelegramLeaderUnresponsive =
265
+ Bus.createTelegramBusLeaderUnresponsivenessProof({
266
+ getLeaderState: lockRuntime.getState,
267
+ getLeaderSocketPath: getTelegramBusSocketPath,
268
+ });
262
269
  const telegramSessionContextStore =
263
270
  Lifecycle.createTelegramSessionContextStore<Pi.ExtensionContext>({
264
271
  getIdentity(ctx) {
@@ -1368,7 +1375,7 @@ export default function (pi: Pi.ExtensionAPI) {
1368
1375
  botToken,
1369
1376
  botId: getTelegramBotId(),
1370
1377
  });
1371
- return configStore.withSourceSerialization(function () {
1378
+ return withTelegramJournalSourceSerialization(function () {
1372
1379
  return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1373
1380
  directory: Paths.resolveTelegramTempDir(),
1374
1381
  profile,
@@ -1988,6 +1995,11 @@ export default function (pi: Pi.ExtensionAPI) {
1988
1995
  },
1989
1996
  recovery: {
1990
1997
  getLeaderState: lockRuntime.getState,
1998
+ proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
1999
+ async isThreadModeDisabled() {
2000
+ await threadStore.refresh?.();
2001
+ return threadStore.getBotState().threadMode === "disabled";
2002
+ },
1991
2003
  setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
1992
2004
  updateStatus,
1993
2005
  promoteToLeader: promoteTelegramBusFollowerToLeader,
@@ -2347,6 +2359,7 @@ export default function (pi: Pi.ExtensionAPI) {
2347
2359
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
2348
2360
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
2349
2361
  lock: lockRuntime,
2362
+ proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
2350
2363
  resetDamagedState() {
2351
2364
  return Locks.resetDamagedTelegramRuntimeState(
2352
2365
  Paths.resolveTelegramStatePath(),
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
22
22
  });
23
23
 
24
24
  function runBoundedProcess(input) {
25
+ // After an abort the parent may terminate this worker before execFile's own abort kill runs, which
26
+ // would orphan a freshly spawned child; never start a process once cancelled.
27
+ if (controller.signal.aborted) {
28
+ return Promise.reject(new Error("Generative App method was cancelled."));
29
+ }
25
30
  if (!input || typeof input !== "object") {
26
31
  throw new Error("Generative App run input must be an object.");
27
32
  }
@@ -46,6 +46,7 @@ import {
46
46
  import {
47
47
  decodeTelegramSessionDirectoryName,
48
48
  getTelegramProfilePathSuffix,
49
+ resolveTelegramRuntimeDir,
49
50
  TELEGRAM_DEFAULT_PROFILE_NAME,
50
51
  } from "./paths.ts";
51
52
  import {
@@ -4889,6 +4890,21 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
4889
4890
  return runtime;
4890
4891
  }
4891
4892
 
4893
+ /**
4894
+ * Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
4895
+ * in the runtime service directory, so journal work never touches `telegram.json` or its
4896
+ * transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
4897
+ * owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
4898
+ * synchronous prefix.
4899
+ */
4900
+ export function createTelegramJournalSourceSerialization(
4901
+ getTransactionPath: () => string = () =>
4902
+ join(resolveTelegramRuntimeDir(), "journals.transaction"),
4903
+ ): <T>(operation: () => T) => T {
4904
+ return (operation) =>
4905
+ withTelegramFileTransaction(getTransactionPath(), operation);
4906
+ }
4907
+
4892
4908
  export function createTelegramUpdateJournalStore(
4893
4909
  options: TelegramUpdateJournalStoreOptions,
4894
4910
  ): TelegramUpdateJournalStore {
@@ -5591,36 +5607,40 @@ function createJournalStoreCore(
5591
5607
  }
5592
5608
  };
5593
5609
 
5610
+ const acquireSource = (): JournalSourceAcquisition | undefined =>
5611
+ sourceAccess
5612
+ ? acquireTelegramUpdateJournalFamily(
5613
+ {
5614
+ ...sourceAccess,
5615
+ path,
5616
+ profile,
5617
+ botIdentity: expectedIdentity,
5618
+ },
5619
+ version,
5620
+ )
5621
+ : undefined;
5622
+ const readAcquiredSource = (
5623
+ source: JournalSourceAcquisition,
5624
+ ): ReadTelegramUpdateJournalResult => {
5625
+ const file =
5626
+ source.evidence.kind === "present" ? source.evidence.file : emptyFile();
5627
+ return {
5628
+ file,
5629
+ exists: source.evidence.kind === "present",
5630
+ serializedBytes: assertCapacity(file),
5631
+ source,
5632
+ };
5633
+ };
5634
+
5594
5635
  const runJournalTransaction = <T>(
5595
5636
  operation: (read: typeof readCurrent) => T,
5596
5637
  ): T => {
5597
5638
  try {
5598
5639
  // Acquire before the transaction helper can create parents or staging names.
5599
5640
  // The config continuation excludes participating writers through consumption.
5600
- const source = sourceAccess
5601
- ? acquireTelegramUpdateJournalFamily(
5602
- {
5603
- ...sourceAccess,
5604
- path,
5605
- profile,
5606
- botIdentity: expectedIdentity,
5607
- },
5608
- version,
5609
- )
5610
- : undefined;
5611
- const readSource = (): ReadTelegramUpdateJournalResult => {
5612
- if (!source) return readCurrent();
5613
- const file =
5614
- source.evidence.kind === "present"
5615
- ? source.evidence.file
5616
- : emptyFile();
5617
- return {
5618
- file,
5619
- exists: source.evidence.kind === "present",
5620
- serializedBytes: assertCapacity(file),
5621
- source,
5622
- };
5623
- };
5641
+ const source = acquireSource();
5642
+ const readSource = (): ReadTelegramUpdateJournalResult =>
5643
+ source ? readAcquiredSource(source) : readCurrent();
5624
5644
  return withTelegramFileTransaction(`${path}.transaction`, () =>
5625
5645
  operation(readSource),
5626
5646
  );
@@ -7024,16 +7044,30 @@ function createJournalStoreCore(
7024
7044
  assertCurrent();
7025
7045
  return { issued, entries: result };
7026
7046
  });
7047
+ const projectRead = (current: ReadTelegramUpdateJournalResult) => ({
7048
+ ...cloneFile(current.file),
7049
+ exists: current.exists,
7050
+ serializedBytes: current.serializedBytes,
7051
+ });
7027
7052
  const journal: TelegramUpdateJournalStore = {
7028
7053
  read() {
7029
- return runMutation((readCurrent) => {
7030
- const current = readCurrent();
7031
- return {
7032
- ...cloneFile(current.file),
7033
- exists: current.exists,
7034
- serializedBytes: current.serializedBytes,
7035
- };
7036
- });
7054
+ // Atomic publication makes a repair-free read safe without serialization, so idle
7055
+ // status and polling cursor reads create no guards. Any read that needs reconciliation,
7056
+ // or races a compaction, falls back to the serialized path that owns repair.
7057
+ const attempt = (): ReadTelegramUpdateJournalResult | undefined => {
7058
+ try {
7059
+ const source = acquireSource();
7060
+ return source ? readAcquiredSource(source) : readCurrentStrict(false);
7061
+ } catch {
7062
+ return undefined;
7063
+ }
7064
+ };
7065
+ const current = options.withWriterAdmission
7066
+ ? options.withWriterAdmission(attempt)
7067
+ : attempt();
7068
+ return current
7069
+ ? projectRead(current)
7070
+ : runMutation((readCurrent) => projectRead(readCurrent()));
7037
7071
  },
7038
7072
  appendBatch(updates, requestedAcceptedThroughUpdateId) {
7039
7073
  const canonicalUpdates = updates.map((update) =>
@@ -7199,10 +7233,17 @@ function createJournalStoreCore(
7199
7233
  serializedBytes: published.serializedBytes,
7200
7234
  };
7201
7235
  });
7236
+ // Sender admission is the outer config authority; journal serialization nests inside it.
7237
+ const serializedAppend = (preApprovalExcluded?: boolean) =>
7238
+ withSourceSerialization
7239
+ ? withSourceSerialization(() =>
7240
+ appendWithEvidence(preApprovalExcluded),
7241
+ )
7242
+ : appendWithEvidence(preApprovalExcluded);
7202
7243
  const append = () => {
7203
7244
  if (withPairedAdmission) {
7204
7245
  const result = withPairedAdmission(normalizedUpdates, () =>
7205
- appendWithEvidence(),
7246
+ serializedAppend(),
7206
7247
  );
7207
7248
  if (!result.admitted)
7208
7249
  throw createJournalError(
@@ -7213,10 +7254,8 @@ function createJournalStoreCore(
7213
7254
  return result.value;
7214
7255
  }
7215
7256
  if (withPairingAdmission)
7216
- return withPairingAdmission(appendWithEvidence);
7217
- return withSourceSerialization
7218
- ? withSourceSerialization(() => appendWithEvidence())
7219
- : appendWithEvidence();
7257
+ return withPairingAdmission(serializedAppend);
7258
+ return serializedAppend();
7220
7259
  };
7221
7260
  const admittedAppend = () => {
7222
7261
  if (!workspaceAdmission) return append();