import { type UpdateFetchLike, type UpdateFileIo } from '../runtime/self-update.js'; import { type DaemonReceiptStore } from './receipts.js'; export interface AutoUpdateServiceActions { /** Whether the daemon currently runs under the platform service manager. */ isSupervised(): boolean; /** Install + enable the service unit (adoption of an unsupervised daemon). */ adoptIntoService(): void; /** Enqueue a non-blocking service restart. */ restartService(): void; } export interface DaemonUpdateInstallLocation { readonly execPath: string; readonly platform: NodeJS.Platform; readonly arch: string; readonly io?: UpdateFileIo | undefined; } /** * One installed file an update replaces (and a rollback restores). * `assetName` is null when this platform/arch publishes no release asset for * it, such a file can still be rolled back, it just cannot be downloaded. */ export interface DaemonInstalledFile { readonly label: string; readonly path: string; readonly executable: boolean; readonly assetName: string | null; } /** * The set of files a daemon update owns: the daemon binary, plus the * sqlite-vec addon when it is installed beside it, the addon is compiled * against the same build and ships in the same release, so an update refreshes * it in the same verified pass and never leaves a mismatched pair installed. * * THE TERMINAL APP BINARY IS NOT IN THIS SET, deliberately. It used to be: * both binaries were built and released from the terminal app's repository, so * one release carried both and refreshing the pair together was the only way to * keep them matched. The daemon is now its own product with its own repository * and its own release line, and a daemon-repository release publishes no * `goodvibes--` asset at all. A daemon that still claimed the app * binary would look for an asset that does not exist, and, worse, if one ever * did appear under that name, would overwrite a terminal app that updates * itself from a different repository on a different version line. Each product * updates its own binary now; `goodvibes` sitting in the same directory is a * neighbour, not cargo. * * One source of truth, shared by the update swap and the crash-loop rollback, * so the files a bad update replaced are exactly the files a rollback * restores. (A rollback therefore also leaves the app binary alone: it restores * only what the update it is undoing actually replaced.) */ export declare function resolveDaemonInstalledFiles(location: DaemonUpdateInstallLocation): DaemonInstalledFile[]; export interface DaemonAutoUpdaterOptions { readonly currentVersion: string; readonly execPath: string; readonly platform: NodeJS.Platform; readonly arch: string; /** GitHub releases/latest URL used for tag resolution. */ readonly releasesLatestUrl: string; /** `${releasesLatestUrl%/latest}/download/` builder override for tests. */ readonly downloadBaseUrl?: ((tag: string) => string) | undefined; /** Hourly by default. */ readonly checkIntervalMs?: number | undefined; /** Delay before the FIRST check after start. Default 30s (boot settle). */ readonly firstCheckDelayMs?: number | undefined; /** How often to re-try a verified-but-deferred swap while the daemon is busy. */ readonly busyRetryMs?: number | undefined; /** The daemon's real activity signal: true only when NO work is in flight. */ readonly isIdle: () => boolean; readonly serviceActions: AutoUpdateServiceActions; readonly receipts: DaemonReceiptStore; readonly fetchImpl?: UpdateFetchLike | undefined; readonly io?: UpdateFileIo | undefined; /** * The daemon's own orderly stop, run BEFORE the process hands over to the * restarted instance, so shutdown hooks fire on an update restart instead of * being skipped by a bare exit. Absent = nothing to wind down. */ readonly stopGracefully?: (() => Promise | void) | undefined; /** Exits the current process after an unsupervised daemon is adopted. */ readonly exitProcess?: ((code: number) => void) | undefined; /** * The version a crash-loop rollback rejected, or null when none stands. * Consulted on every check: a release that already failed to start on this * host is not installed a second time just because it is still the latest * one. Read fresh each time (it lives in the lifecycle marker on disk), so a * rejection recorded by the boot before this one is seen. */ readonly rejectedVersion?: (() => string | null) | undefined; /** * Put one line in front of the owner over a channel that still works, the * daemon's existing owner-alert path. Absent = no channel to alert on (an * embedded daemon, a test), in which case the ERROR log line is the record. */ readonly alertOwner?: ((text: string) => void) | undefined; /** * Consecutive failed checks before the owner is told. Default 3, one flaky * network hour is not news; three in a row means the daemon has stopped * being able to update itself. */ readonly alertAfterFailedChecks?: number | undefined; /** Quiet window after an update alert, so a persistent failure is one message, not one per hour. Default 12h. */ readonly alertWindowMs?: number | undefined; readonly now?: (() => number) | undefined; readonly setTimer?: ((fn: () => void, ms: number) => ReturnType) | undefined; readonly clearTimer?: ((timer: ReturnType) => void) | undefined; } /** Default consecutive failed checks before the owner hears about it. */ export declare const DEFAULT_UPDATE_ALERT_AFTER_FAILED_CHECKS = 3; /** Default quiet window between update alerts about the same ongoing failure. */ export declare const DEFAULT_UPDATE_ALERT_WINDOW_MS: number; /** The live state of one daemon's self-update loop. */ export interface DaemonUpdateLoopSnapshot { /** The running artifact's version, as the loop compares it. */ readonly currentVersion: string; /** Where release tags are resolved from. */ readonly releasesUrl: string; /** The steady-state cadence between checks. */ readonly checkIntervalMs: number; /** The delay before the first check after a boot. */ readonly firstCheckDelayMs: number; /** Consecutive checks that threw. Zero once one completes. */ readonly failedCheckCount: number; /** What the most recent failing check said, or null when none is failing. */ readonly lastCheckFailure: string | null; /** A downloaded-and-verified release waiting for an idle moment, or null. */ readonly pendingVersion: string | null; } export declare class DaemonAutoUpdater { private readonly options; private readonly loop; /** A downloaded-and-verified update waiting for an idle moment. */ private pendingSwap; /** Consecutive checks that threw. Reset by any check that completes. */ private consecutiveFailures; /** When the owner was last told the daemon cannot update itself, or null. */ private failureAlertedAt; /** The last error text told to the owner, so recovery can name what stopped. */ private lastFailureDetail; /** Rejected releases already reported, so the skip is stated once per release, not hourly. */ private readonly reportedRejections; constructor(options: DaemonAutoUpdaterOptions); private now; /** * Put a line in front of the owner, and state it at ERROR either way. An * update path that has stopped working is exactly the class of failure that * spent three days as WARN lines in a debug log while three releases shipped * and the installed daemon stayed where it was. */ private alertOwner; /** * A check that threw. Counted rather than announced: one bad hour is a flaky * network. Once the count reaches the threshold the owner is told once, and * not again until the quiet window has passed. */ private recordCheckFailed; /** A check that completed. Says so if the owner had been told it was failing. */ private recordCheckSucceeded; /** The most recent failure detail, exposed for the /status surface and tests. */ get lastCheckFailure(): string | null; /** * What the loop knows right now, as one readable record. * * Everything here was already tracked and already decided the loop's * behaviour; none of it was answerable from outside the process. "Is this * daemon updating itself, and if not why not" was a question only the log * could answer, and only to someone with shell access to the host, which is * how three releases shipped past a daemon whose checks had been failing for * days with nobody able to see it from any surface. */ snapshot(): DaemonUpdateLoopSnapshot; /** Consecutive failed checks, exposed for the /status surface and tests. */ get failedCheckCount(): number; /** The delay before the first check, so callers can log the schedule they got. */ get firstCheckDelayMs(): number; /** The steady-state cadence, so callers can log the schedule they got. */ get checkIntervalMs(): number; /** Begin the loop. The first check runs after a short boot-settle delay. */ start(): void; stop(): void; /** One loop iteration; exposed for tests driving mocked time. */ tick(): Promise; private checkAndApply; /** The version a crash-loop rollback rejected, normalized, or null. Never throws into the loop. */ private rejectedVersion; /** * Say, once per rejected release, to the owner, that the newest release is * being held back because it would not start here. Once per release, not once * per check: this repeats hourly until a fixed release ships, and an alert * that fires hourly is an alert nobody reads. The daemon resumes updating on * its own the moment a NEWER tag appears; no owner action is required. */ private reportRejectedRelease; /** The update targets, or null when this platform/arch publishes no assets. */ private resolveTargets; private restartIntoNewBinary; /** * The daemon's own orderly stop before handing over. A hook that throws must * never strand the process on the old binary, so a failure is logged and the * handover continues. */ private stopGracefully; } /** `https://github.com/o/r/releases/latest` -> `https://github.com/o/r/releases/download/`. */ export declare function defaultDownloadBaseUrl(releasesLatestUrl: string, tag: string): string; //# sourceMappingURL=auto-updater.d.ts.map