@llblab/pi-telegram 0.36.7 → 0.36.8

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 CHANGED
@@ -81,7 +81,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
81
81
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
82
82
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
83
83
  - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner recovery groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof returns all sources to `pending`; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
84
- - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity.
84
+ - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
85
85
  - An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
86
86
  - The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
87
87
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.36.8: Durable Journal Recovery
6
+
7
+ - `Durable Journal Recovery`: Restricts age cleanup to UUID-prefixed downloads, repairs missing or revisionless snapshots from validated segment evidence, and otherwise quarantines the snapshot plus segments before publishing a fresh journal, keeping `/telegram-connect` operational with informational recovery evidence instead of manual JSON repair.
8
+
5
9
  ## 0.36.7: Context-Aware Proactive Controls
6
10
 
7
11
  - `Proactive Prompt Buttons`: Plans valid top-level `telegram_button` comments before local/autonomous assistant segments enter Rich or HTML proactive delivery, preserving the ordinary callback store and renderer path instead of silently hiding correct controls.
package/README.md CHANGED
@@ -57,7 +57,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
57
57
 
58
58
  The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
59
59
 
60
- After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
60
+ After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
61
61
 
62
62
  ### 4. Pair your Telegram account
63
63
 
@@ -163,7 +163,7 @@ Telegram private-chat Threaded Mode is the public switch for multi-instance Tele
163
163
 
164
164
  Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
165
165
 
166
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
166
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
167
167
 
168
168
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
169
169
 
@@ -205,9 +205,9 @@ All inbound updates are gated by the configured authorized user id.
205
205
 
206
206
  Here, **durable** means recovery across ordinary process exit, crash, kill, and replacement after a successful atomic rename is visible to the filesystem. It does not promise survival across host, kernel, filesystem, storage-device, or power failure: journal and offset publication do not call `fsync`/`fdatasync`, and parent directories are not flushed. A host-level failure may therefore lose a recently acknowledged rename despite correct process-level ordering. Operators requiring that stronger boundary must place the agent directory on storage with an independently managed durability/backup policy; `0.28.0` must not be described as power-loss durable.
207
207
 
208
- The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
208
+ The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. Compatibility recovery for the former broad temp-cleanup bug rebuilds a missing snapshot when its complete revision-1 segment chain removes known base authority before any upsert and reconstructs to an empty journal, and repairs a revisionless snapshot when the first surviving segment supplies its exact positive predecessor revision and the reconstructed tail validates. If repair fails, the transaction-locked loader atomically moves the snapshot when present plus its segment directory under `tmp/telegram/recovery/`, publishes a fresh empty private journal, records an informational recovery event with the quarantine path, and continues startup; no uncertain files are silently deleted. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
209
209
 
210
- Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and authority files are never automatically deleted, reset, or quarantined.
210
+ Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and valid authority files are never automatically deleted, reset, or quarantined. Only a missing-snapshot history that cannot be reconstructed safely uses the explicit evidence-preserving quarantine-and-reset compatibility fallback above.
211
211
 
212
212
  `pending` entries remain immediately executable while raw interception, routing, or grouping is incomplete. Every execution failure becomes `retry-wait` with durable attempt count, next eligible time, failure class, bounded summary, and latest failure time. The `failed` state remains schema-compatible only for legacy candidate journals and is converted to automatic retry during lifecycle startup. `queued` entries carry exact prompt/control receipts plus the acquiring Pi runtime instance, OS pid/birth identity, session generation, acquisition id, and acquisition time. Queueing alone is never completion.
213
213
 
package/index.ts CHANGED
@@ -132,6 +132,22 @@ export default function (pi: Pi.ExtensionAPI) {
132
132
  getProfileName: configStore.getActiveProfileName,
133
133
  getBotToken: configStore.getBotToken,
134
134
  getBotId: getTelegramBotId,
135
+ onRecovery(event) {
136
+ recordRuntimeEvent(
137
+ "recovery",
138
+ event.kind === "repaired"
139
+ ? "Telegram update journal was repaired automatically."
140
+ : "Telegram update journal was reset after its damaged files were quarantined.",
141
+ {
142
+ phase: "journal-auto-recovery",
143
+ recoveryKind: event.kind,
144
+ journalPath: event.path,
145
+ revision: event.revision,
146
+ quarantinePath: event.quarantinePath,
147
+ reason: event.reason,
148
+ },
149
+ );
150
+ },
135
151
  getQueueRuntimeIdentity() {
136
152
  return {
137
153
  instanceId: telegramInstanceId,
package/lib/journal.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  unlinkSync,
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
- import { dirname, join } from "node:path";
20
+ import { basename, dirname, join } from "node:path";
21
21
  import { isDeepStrictEqual } from "node:util";
22
22
 
23
23
  import {
@@ -349,6 +349,14 @@ export type TelegramUpdateJournalPublicationBoundary =
349
349
  | "before-write"
350
350
  | "after-write-before-rename";
351
351
 
352
+ export interface TelegramUpdateJournalRecoveryEvent {
353
+ kind: "repaired" | "reset";
354
+ path: string;
355
+ revision?: number;
356
+ quarantinePath?: string;
357
+ reason: string;
358
+ }
359
+
352
360
  export interface TelegramUpdateJournalStoreOptions {
353
361
  path: string;
354
362
  profileName?: string;
@@ -356,6 +364,7 @@ export interface TelegramUpdateJournalStoreOptions {
356
364
  maxEntries?: number;
357
365
  maxBytes?: number;
358
366
  getNowMs?: () => number;
367
+ onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
359
368
  queueRuntimeIdentity?: TelegramUpdateJournalQueueRuntimeIdentity;
360
369
  getQueueProcessLiveness?: (
361
370
  owner: TelegramUpdateJournalQueueProcessIdentity,
@@ -1472,6 +1481,7 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
1472
1481
  getBotId: () => number | undefined;
1473
1482
  getJournalPath: (profileName?: string) => string;
1474
1483
  getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
1484
+ onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
1475
1485
  }
1476
1486
 
1477
1487
  export function createTelegramUpdateJournalRuntimeBindingResolver(
@@ -1506,6 +1516,7 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
1506
1516
  ...(deps.getQueueRuntimeIdentity
1507
1517
  ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
1508
1518
  : {}),
1519
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
1509
1520
  }),
1510
1521
  };
1511
1522
  };
@@ -1546,6 +1557,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
1546
1557
  ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
1547
1558
  ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
1548
1559
  : {}),
1560
+ ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
1549
1561
  getJournalPath(profileName) {
1550
1562
  return deps.getFollowerJournalPath(bindingKey, profileName);
1551
1563
  },
@@ -1601,6 +1613,13 @@ export function createTelegramUpdateJournalStore(
1601
1613
  );
1602
1614
  const getNowMs = options.getNowMs ?? Date.now;
1603
1615
  const onPublicationBoundary = options.onPublicationBoundary;
1616
+ const notifyRecovery = (event: TelegramUpdateJournalRecoveryEvent): void => {
1617
+ try {
1618
+ options.onRecovery?.(event);
1619
+ } catch {
1620
+ // Recovery diagnostics must not break recovered journal authority.
1621
+ }
1622
+ };
1604
1623
  const getQueueProcessLiveness =
1605
1624
  options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
1606
1625
 
@@ -1742,8 +1761,9 @@ export function createTelegramUpdateJournalStore(
1742
1761
  entries: [],
1743
1762
  });
1744
1763
 
1745
- const readCurrent = (): ReadTelegramUpdateJournalResult => {
1764
+ const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
1746
1765
  let source: string;
1766
+ let recoveringMissingSnapshot = false;
1747
1767
  try {
1748
1768
  const size = statSync(path).size;
1749
1769
  if (size > maxBytes) {
@@ -1775,15 +1795,14 @@ export function createTelegramUpdateJournalStore(
1775
1795
  );
1776
1796
  }
1777
1797
  if (orphanedSegmentNames.length > 0) {
1778
- throw createJournalError(
1779
- "invalid",
1780
- path,
1781
- `is missing while ${segmentDirectory} retains revision segments`,
1782
- );
1798
+ recoveringMissingSnapshot = true;
1799
+ source = serializeJournalFile(emptyFile());
1800
+ } else {
1801
+ return { file: emptyFile(), exists: false, serializedBytes: 0 };
1783
1802
  }
1784
- return { file: emptyFile(), exists: false, serializedBytes: 0 };
1803
+ } else {
1804
+ throw createJournalError("io", path, "could not be read", error);
1785
1805
  }
1786
- throw createJournalError("io", path, "could not be read", error);
1787
1806
  }
1788
1807
  let parsed: unknown;
1789
1808
  try {
@@ -1808,6 +1827,9 @@ export function createTelegramUpdateJournalStore(
1808
1827
  segmentNames.sort();
1809
1828
  let revision = file.revision ?? 0;
1810
1829
  let unappliedSegmentBytes = 0;
1830
+ let orphanRecoverySawUpsert = false;
1831
+ let orphanRecoverySawBaseRemoval = false;
1832
+ let orphanRecoveryUnsafe = false;
1811
1833
  for (const name of segmentNames) {
1812
1834
  const nameRevision = Number(name.slice(0, 16));
1813
1835
  if (nameRevision <= revision) continue;
@@ -1871,11 +1893,16 @@ export function createTelegramUpdateJournalStore(
1871
1893
  file.entries.map((entry) => [entry.updateId, entry]),
1872
1894
  );
1873
1895
  for (const updateId of segment.removedUpdateIds) {
1896
+ if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
1897
+ orphanRecoverySawBaseRemoval = true;
1898
+ if (orphanRecoverySawUpsert) orphanRecoveryUnsafe = true;
1899
+ }
1874
1900
  entriesById.delete(updateId);
1875
1901
  }
1876
1902
  for (const entry of segment.upsertedEntries) {
1877
1903
  entriesById.set(entry.updateId, entry);
1878
1904
  }
1905
+ if (segment.upsertedEntries.length > 0) orphanRecoverySawUpsert = true;
1879
1906
  file = parseJournalFile(
1880
1907
  {
1881
1908
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
@@ -1935,10 +1962,166 @@ export function createTelegramUpdateJournalStore(
1935
1962
  }
1936
1963
  return { file, exists: true, serializedBytes: reboundBytes };
1937
1964
  }
1965
+ if (recoveringMissingSnapshot) {
1966
+ if (
1967
+ orphanRecoveryUnsafe ||
1968
+ !orphanRecoverySawBaseRemoval ||
1969
+ file.entries.length > 0
1970
+ ) {
1971
+ throw createJournalError(
1972
+ "invalid",
1973
+ path,
1974
+ `is missing while ${segmentDirectory} retains revision segments`,
1975
+ );
1976
+ }
1977
+ const recovered = serializeJournalFile(file);
1978
+ const recoveredBytes = assertCapacity(file, recovered);
1979
+ writeJournalFile(path, recovered, onPublicationBoundary);
1980
+ notifyRecovery({
1981
+ kind: "repaired",
1982
+ path,
1983
+ revision: file.revision,
1984
+ reason: "Recovered a missing snapshot from a complete empty segment history.",
1985
+ });
1986
+ return { file, exists: true, serializedBytes: recoveredBytes };
1987
+ }
1938
1988
  const serializedBytes = assertCapacity(file);
1939
1989
  return { file, exists: true, serializedBytes };
1940
1990
  };
1941
1991
 
1992
+ const readCurrent = (): ReadTelegramUpdateJournalResult => {
1993
+ try {
1994
+ return readCurrentStrict();
1995
+ } catch (error) {
1996
+ if (
1997
+ !(error instanceof TelegramUpdateJournalError) ||
1998
+ error.code !== "invalid"
1999
+ ) {
2000
+ throw error;
2001
+ }
2002
+ let snapshotExists = false;
2003
+ try {
2004
+ statSync(path);
2005
+ snapshotExists = true;
2006
+ } catch (snapshotError) {
2007
+ if ((snapshotError as { code?: unknown })?.code !== "ENOENT") throw error;
2008
+ }
2009
+ const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
2010
+ let segmentNames: string[];
2011
+ try {
2012
+ segmentNames = readdirSync(segmentDirectory)
2013
+ .filter((name) => /^\d{16}\.json$/u.test(name))
2014
+ .sort();
2015
+ } catch {
2016
+ throw error;
2017
+ }
2018
+ if (segmentNames.length === 0) throw error;
2019
+
2020
+ if (snapshotExists) {
2021
+ try {
2022
+ const snapshot = parseJournalFile(
2023
+ JSON.parse(readFileSync(path, "utf8")) as unknown,
2024
+ path,
2025
+ );
2026
+ const firstSegmentPath = join(segmentDirectory, segmentNames[0]);
2027
+ const firstSegment = parseJournalSegment(
2028
+ JSON.parse(readFileSync(firstSegmentPath, "utf8")) as unknown,
2029
+ firstSegmentPath,
2030
+ );
2031
+ if (
2032
+ snapshot.revision === undefined &&
2033
+ firstSegment.previousRevision > 0 &&
2034
+ snapshot.profile === firstSegment.profile &&
2035
+ identitiesMatch(snapshot.botIdentity, firstSegment.botIdentity)
2036
+ ) {
2037
+ writeJournalFile(
2038
+ path,
2039
+ serializeJournalFile({
2040
+ ...snapshot,
2041
+ revision: firstSegment.previousRevision,
2042
+ }),
2043
+ onPublicationBoundary,
2044
+ );
2045
+ const repaired = readCurrentStrict();
2046
+ notifyRecovery({
2047
+ kind: "repaired",
2048
+ path,
2049
+ revision: repaired.file.revision,
2050
+ reason: `Recovered a revisionless snapshot from segment revision ${firstSegment.revision}.`,
2051
+ });
2052
+ return repaired;
2053
+ }
2054
+ } catch {
2055
+ // Fall through to evidence-preserving quarantine and reset.
2056
+ }
2057
+ }
2058
+
2059
+ const recoveryDirectory = join(
2060
+ dirname(path),
2061
+ "recovery",
2062
+ `${getNowMs()}-${process.pid}-${randomUUID()}`,
2063
+ );
2064
+ mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
2065
+ const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
2066
+ const segmentQuarantinePath = join(
2067
+ recoveryDirectory,
2068
+ basename(segmentDirectory),
2069
+ );
2070
+ if (
2071
+ snapshotExists &&
2072
+ !renameTelegramPathWithRetry(path, snapshotQuarantinePath)
2073
+ ) {
2074
+ throw createJournalError(
2075
+ "io",
2076
+ path,
2077
+ "could not quarantine an unrecoverable journal snapshot",
2078
+ error,
2079
+ );
2080
+ }
2081
+ if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
2082
+ if (snapshotExists) {
2083
+ renameTelegramPathWithRetry(snapshotQuarantinePath, path);
2084
+ }
2085
+ throw createJournalError(
2086
+ "io",
2087
+ segmentDirectory,
2088
+ "could not quarantine an unrecoverable journal segment history",
2089
+ error,
2090
+ );
2091
+ }
2092
+ const reset = emptyFile();
2093
+ const serialized = serializeJournalFile(reset);
2094
+ const serializedBytes = assertCapacity(reset, serialized);
2095
+ try {
2096
+ writeJournalFile(path, serialized, onPublicationBoundary);
2097
+ } catch (publicationError) {
2098
+ const segmentsRestored = renameTelegramPathWithRetry(
2099
+ segmentQuarantinePath,
2100
+ segmentDirectory,
2101
+ );
2102
+ const snapshotRestored =
2103
+ !snapshotExists ||
2104
+ renameTelegramPathWithRetry(snapshotQuarantinePath, path);
2105
+ if (!segmentsRestored || !snapshotRestored) {
2106
+ throw createJournalError(
2107
+ "io",
2108
+ path,
2109
+ "reset publication failed after journal evidence was quarantined",
2110
+ publicationError,
2111
+ );
2112
+ }
2113
+ throw publicationError;
2114
+ }
2115
+ notifyRecovery({
2116
+ kind: "reset",
2117
+ path,
2118
+ quarantinePath: recoveryDirectory,
2119
+ reason: error.message,
2120
+ });
2121
+ return { file: reset, exists: true, serializedBytes };
2122
+ }
2123
+ };
2124
+
1942
2125
  const runMutation = <T>(operation: () => T): T => {
1943
2126
  try {
1944
2127
  return withTelegramFileTransaction(`${path}.transaction`, operation);
@@ -37,6 +37,8 @@ function getTelegramApiTempDir(): string {
37
37
  return resolveTelegramTempDir();
38
38
  }
39
39
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
+ const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN =
41
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
40
42
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
41
43
  process.env,
42
44
  ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
@@ -1116,7 +1118,9 @@ export async function cleanupTelegramTempFiles(
1116
1118
  return 0;
1117
1119
  }
1118
1120
  for (const entry of entries) {
1119
- if (!entry.isFile()) continue;
1121
+ if (!entry.isFile() || !TELEGRAM_TEMP_SCRATCH_FILE_PATTERN.test(entry.name)) {
1122
+ continue;
1123
+ }
1120
1124
  const path = join(tempDir, entry.name);
1121
1125
  try {
1122
1126
  const stats = await stat(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.36.7",
3
+ "version": "0.36.8",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"