/** * Is the installed `omp-telegram` current, and is its daemon running it? (#961) * * Conductor already proves its *own* three install surfaces carry one identity * (#904/#915) and shows divergence without an on-demand run (#919). This is the * same check for the one plugin a documented conductor contract depends on. * * The incident that motivated it: the package floor mandates answering an * inbound message with a targetless `telegram_send`, so the reply stays in the * topic it arrived in. On 2026-08-21 that call refused three times with * `no active telegram chat — pass chat_id`, and each refusal cost an improvised * recovery — once by passing route ids by hand, which is precisely the * anti-pattern #882 names as its silent fake. The cause was that the installed * 0.12.1 predates the target ladder: its send path had two rungs, and the * published 0.12.2 has four plus per-rung diagnostics. The fix had been on npm * for days. No conductor surface said so, so three sessions did transcript * archaeology to reach a conclusion `npm view` answers in one call. * * Three properties are deliberate: * * - **Read-only, and never a remedy.** Installing stays operator-owned under the * running-conductor boundary. The deliverable is that the operator is told. * - **Offline-tolerant.** A registry that cannot be reached is `unknown`, never * a finding. A fleet with no outbound npm access must not grow a permanent * warning it can do nothing about. * - **No baked-in baseline.** A minimum version compiled into conductor goes * stale in exactly the way this module exists to catch, and re-creates #904's * divergence with one more copy of the number. Every version compared here is * read from a surface that exists at runtime. */ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; /** The plugin this checks. Named once. */ export const TELEGRAM_PACKAGE = "omp-telegram"; /** One surface's answer: a version, or why there isn't one. */ export type VersionRead = | { kind: "version"; version: string } | { kind: "absent" } | { kind: "unreadable"; problem: string }; /** What the three surfaces said. */ export interface TelegramSurfaces { /** The copy on disk, as `omp plugin list --json` reports it. */ installed: VersionRead; /** What the running daemon says it is, from its own record. */ daemon: VersionRead; /** The newest published version, or `unreadable` when the registry is not * reachable — which is a non-finding, not a problem. */ published: VersionRead; } /** * The verdict, in the vocabulary a surface renders. * * `unknown` is load-bearing: it is what an unreachable registry or an absent * daemon produces, and it must never read as "current". A check that cannot run * says so rather than certifying. */ export type TelegramFreshnessState = | "current" | "installed-behind" | "daemon-stale" | "not-installed" | "unknown"; export interface TelegramFreshness { state: TelegramFreshnessState; surfaces: TelegramSurfaces; /** One operator-facing sentence, or undefined when there is nothing to say. * Never contains a token, chat id or topic id — only versions and paths. */ detail?: string; } /** Numeric-dotted comparison, prerelease-agnostic: -1, 0 or 1. * * Deliberately not a semver dependency. The only question asked here is * "is the installed one older than the published one", and a prerelease * suffix is compared as a tiebreak string rather than by semver's precedence * rules — an installed prerelease of the newest version is not behind. */ export function compareVersions(a: string, b: string): number { const parse = (v: string): { nums: number[]; rest: string } => { const m = /^v?(\d+(?:\.\d+)*)(.*)$/.exec(v.trim()); if (m === null) return { nums: [], rest: v.trim() }; return { nums: m[1]!.split(".").map((n) => Number(n)), rest: m[2] ?? "" }; }; const pa = parse(a); const pb = parse(b); const width = Math.max(pa.nums.length, pb.nums.length); for (let i = 0; i < width; i++) { const x = pa.nums[i] ?? 0; const y = pb.nums[i] ?? 0; if (x !== y) return x < y ? -1 : 1; } if (pa.rest === pb.rest) return 0; // A release sorts above its own prereleases: "" beats "-rc.1". if (pa.rest === "") return 1; if (pb.rest === "") return -1; return pa.rest < pb.rest ? -1 : 1; } /** The plugin's version from an `omp plugin list --json` payload. * * Tolerant by design, unlike conductor's own surface read: this plugin is a * peer the fleet depends on, not the package being upgraded, so an inventory * that cannot be parsed is `unreadable` rather than a thrown error that would * take a whole `doctor` run down with it. */ export function installedFromInventory(raw: string): VersionRead { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { kind: "unreadable", problem: "omp plugin list returned invalid JSON" }; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { kind: "unreadable", problem: "omp plugin list returned an invalid inventory" }; } const npm = Reflect.get(parsed, "npm"); if (!Array.isArray(npm)) { return { kind: "unreadable", problem: "omp plugin list returned no npm inventory" }; } const entry = npm.find( (e) => e !== null && typeof e === "object" && Reflect.get(e, "name") === TELEGRAM_PACKAGE, ); if (entry === undefined) return { kind: "absent" }; const version = Reflect.get(entry, "version"); return typeof version === "string" && version.length > 0 ? { kind: "version", version } : { kind: "unreadable", problem: `${TELEGRAM_PACKAGE} is installed with no version` }; } /** Where the daemon writes its own identity. Resolved exactly the way every * other reader of that directory resolves it, so a test's override works here * without a second convention. */ export function telegramStateDir(stateDir?: string): string { const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim(); return override ? override : join(homedir(), ".omp", "agent", "telegram"); } /** What the running daemon says it is. `absent` when it is not running — which * is not a fault here: a fleet may have no daemon up, and that is `unknown` * rather than stale. */ export function daemonVersion(stateDir?: string): VersionRead { const path = join(telegramStateDir(stateDir), "daemon.json"); let raw: unknown; try { raw = JSON.parse(readFileSync(path, "utf8")); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" }; return { kind: "unreadable", problem: `cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`, }; } if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { kind: "unreadable", problem: `${path} is not a daemon record` }; } const version = Reflect.get(raw, "version"); return typeof version === "string" && version.length > 0 ? { kind: "version", version } : { kind: "unreadable", problem: `${path} records no version` }; } /** * Decide, from three reads. * * Order matters, and it is worth stating: `installed-behind` is checked before * `daemon-stale` because the remedy differs — a behind install needs an install * *and* a restart, and reporting only the restart would leave the operator one * step short. A daemon matching a behind install is therefore still reported as * behind, not as stale. */ export function classifyTelegramFreshness(surfaces: TelegramSurfaces): TelegramFreshness { const { installed, daemon, published } = surfaces; if (installed.kind === "absent") { return { state: "not-installed", surfaces, detail: `${TELEGRAM_PACKAGE} is not installed; the reply-in-topic contract the brief mandates has no transport`, }; } if (installed.kind === "unreadable") { return { state: "unknown", surfaces, detail: installed.problem }; } if (published.kind === "version" && compareVersions(installed.version, published.version) < 0) { const running = daemon.kind === "version" && daemon.version !== installed.version ? `; the daemon is on ${daemon.version}` : ""; return { state: "installed-behind", surfaces, detail: `${TELEGRAM_PACKAGE} ${installed.version} is installed, ${published.version} is published${running}` + ` — install it and restart the daemon (a publish does not bump an installed copy)`, }; } if (daemon.kind === "version" && daemon.version !== installed.version) { return { state: "daemon-stale", surfaces, detail: `the running ${TELEGRAM_PACKAGE} daemon is ${daemon.version} but ${installed.version} is installed` + " — restart it, or it keeps serving the old code", }; } // Nothing to compare against: the registry was unreachable, so "current" is // an answer this check has not earned. if (published.kind !== "version") { return { state: "unknown", surfaces, detail: published.kind === "unreadable" ? `${TELEGRAM_PACKAGE} ${installed.version} is installed; the newest published version could not be read (${published.problem})` : `${TELEGRAM_PACKAGE} ${installed.version} is installed; nothing published to compare against`, }; } return { state: "current", surfaces }; } /** What reading the surfaces needs. Injected so the whole check is testable * without npm, a daemon, or a plugin install. */ export interface TelegramFreshnessDeps { /** `omp plugin list --json`, and `npm view omp-telegram version`. */ run: (cmd: string, args: readonly string[]) => Promise<{ code: number; stdout: string }>; /** Override for the daemon record's directory, in tests. */ stateDir?: string; } /** Read all three surfaces and classify. Never throws: every failure is a * `VersionRead` that the classifier turns into `unknown`. */ export async function checkTelegramFreshness( deps: TelegramFreshnessDeps, ): Promise { const inventory = await deps .run("omp", ["plugin", "list", "--json"]) .then((r): VersionRead => r.code === 0 ? installedFromInventory(r.stdout) : { kind: "unreadable", problem: "omp plugin list failed" }, ) .catch((err: unknown): VersionRead => ({ kind: "unreadable", problem: `omp plugin list failed: ${err instanceof Error ? err.message : String(err)}`, })); const published = await deps .run("npm", ["view", TELEGRAM_PACKAGE, "version"]) .then((r): VersionRead => { const version = r.stdout.trim(); return r.code === 0 && version.length > 0 ? { kind: "version", version } : { kind: "unreadable", problem: "npm registry unreachable" }; }) .catch((): VersionRead => ({ kind: "unreadable", problem: "npm registry unreachable" })); return classifyTelegramFreshness({ installed: inventory, daemon: daemonVersion(deps.stateDir), published, }); }