import type { WriteFileOptions } from "node:fs"; import type { Settings } from "../../config/settings"; import { type NotificationProvider, type NotificationRuntime, type ProviderResolution } from "./config"; import type { DiscordDiagnosticProvider } from "./discord-provider"; import type { SlackDiagnosticProvider } from "./slack-provider"; /** * Strip secrets from a human-facing diagnostic string. Redacts the configured * bot token (exact match) and any token-shaped substring so health/test details * can never leak a credential regardless of where the string originated. */ export declare function sanitizeDiagnostic(text: string, token?: string): string; export interface NotificationDiagnosticEvent { timestamp: string; operation: string; phase: string; outcome: string; reason: string; pid?: number; incarnation?: string; ageMs?: number; detail?: string; } /** Append a bounded, private, secret-safe daemon diagnostic event. */ export declare function writeNotificationDiagnostic(settings: Pick, event: Omit & { timestamp?: string; }): Promise; /** Identity evidence required to remove precisely the endpoint that was inspected. */ export interface NotificationEndpointFileIdentity { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string; } export interface NotificationEndpointFile { bytes: Buffer; identity: NotificationEndpointFileIdentity; } export interface NotificationExactUnlinkResult { ok: boolean; code?: string; detachedPath?: string; /** A live publisher successor retained after an exact-unlink race. */ retainedSuccessorPath?: string; /** An internal exchange placeholder whose verified cleanup failed. */ retainedPlaceholderPath?: string; /** A cleanup entry whose identity could not be verified after a race. */ retainedUnknownPath?: string; } /** Read one regular file together with the identity required for exact removal. */ export declare function readNotificationEndpointFile(file: string): Promise; /** Bump when the native exact-deletion identity contract changes. */ export declare const NATIVE_PATH_IDENTITY_CONTRACT_VERSION = 1; export declare function exactUnlinkNotificationFile(file: string, identity: NotificationEndpointFileIdentity, quarantineName: string): NotificationExactUnlinkResult; /** Minimal filesystem surface the service needs; injectable for tests. */ export interface NotificationServiceFs { readdir(dir: string): Promise; readFile(file: string, encoding: "utf8"): Promise; readEndpointFile(file: string): Promise; exactUnlink(file: string, identity: NotificationEndpointFileIdentity): Promise; /** Identity-bound direct unlink reserved for inert exchange debris. */ unlinkExact(file: string, identity: NotificationEndpointFileIdentity): Promise; unlink(file: string): Promise; writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise; stat?(file: string): Promise<{ mtimeMs: number; }>; } /** Injectable dependencies shared across service operations. */ export interface NotificationServiceDeps { fs?: NotificationServiceFs; now?: () => number; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; fetchImpl?: typeof fetch; apiBase?: string; createDiscordDiagnostic?: (config: { applicationId: string; botToken: string; }) => DiscordDiagnosticProvider; createSlackDiagnostic?: (config: { appToken: string; botToken: string; }) => SlackDiagnosticProvider; providerRuntimeStatus?: (provider: NotificationProvider) => Promise | NotificationRuntime; } export interface AdapterConfigView { botTokenMasked: string; channel: string | undefined; configured: boolean; quarantined: boolean; desiredEnabled: boolean; desiredSource: ProviderResolution["desiredSource"]; effectiveEnabled: boolean; issues: ProviderResolution["issues"]; runtime?: NotificationRuntime; } export interface NotificationStatusReport { enabled: boolean; redact: boolean; verbosity: "lean" | "verbose"; globallyConfigured: boolean; anyProviderComplete: boolean; anyProviderEffective: boolean; telegram: AdapterConfigView & { tokenFingerprint: string | undefined; }; discord: AdapterConfigView; slack: AdapterConfigView; } /** Build a secret-safe structured status snapshot from settings. */ export declare function buildNotificationStatusReport(settings: Settings): NotificationStatusReport; /** Render a status report as human-readable lines (no secrets). */ export declare function formatNotificationStatusReport(report: NotificationStatusReport): string; export interface NotificationEndpointView { sessionId: string; pid: number | undefined; stale: boolean; } export type NotificationEndpointLiveness = "live" | "dead" | "unknown"; /** * Classification used by recovery and startup takeover. A file is an endpoint * only when it has endpoint authority fields; lifecycle/audit records are never * candidates for endpoint cleanup. */ export type NotificationEndpointClassification = { kind: "endpoint"; view: NotificationEndpointView; liveness: NotificationEndpointLiveness; identity: NotificationEndpointFileIdentity; } | { kind: "non-endpoint"; } | { kind: "unreadable"; }; /** * Classify an endpoint using owner-proof semantics. An endpoint is only `dead` * with positive proof: an explicit `stale` tombstone, or a recorded pid that is * confirmed not alive. A PID-less endpoint is `unknown` (not provably dead) and * must never be treated as dead — removing it could delete a live session's * discovery file that simply omitted a pid. */ export declare function notificationEndpointLiveness(view: NotificationEndpointView, pidAlive: (pid: number) => boolean): NotificationEndpointLiveness; /** * Read and classify one endpoint candidate. The returned identity belongs to * exactly the bytes inspected and is required for any later deletion. */ export declare function classifyNotificationEndpoint(fs: Pick, file: string, pidAlive: (pid: number) => boolean): Promise; export type HealthLevel = "ok" | "warn" | "error"; export interface HealthCheck { name: string; level: HealthLevel; detail: string; } export interface DaemonHealth { present: boolean; ownerId: string | undefined; pid: number | undefined; alive: boolean; heartbeatFresh: boolean; identityMatches: boolean; stopped: boolean; heartbeatAt: number | undefined; heartbeatAgeMs: number | undefined; /** * Session endpoints the live owner reported an OPEN socket to in its latest * matching heartbeat sidecar. `undefined` means the owner never published the * field (older daemon, no stable owner tag, no matching sidecar): unknown, not zero. */ attachedEndpoints: number | undefined; generation: number | undefined; currentGeneration: number; generationRelation: DaemonGenerationRelation; } export type DaemonGenerationRelation = "pre_generation" | "older" | "current" | "newer" | "unknown"; export interface EndpointHealth { total: number; live: number; dead: number; unknown: number; unreadable: number; } export interface NotificationHealthReport { overall: HealthLevel; configured: boolean; provider?: NotificationProvider; resolution?: ProviderResolution; checks: HealthCheck[]; daemon: DaemonHealth; endpoints: EndpointHealth; reachability: { probed: boolean; ok: boolean; detail: string; }; } export interface HealthOptions { settings: Settings; stateRoot?: string; deps?: NotificationServiceDeps; provider?: NotificationProvider; /** When true, use the selected provider's REST-only diagnostic path. */ probe?: boolean; signal?: AbortSignal; } /** Structural (offline-by-default) health of the notification subsystem. */ export declare function checkNotificationHealth(opts: HealthOptions): Promise; /** Render a health report as human-readable lines (no secrets). */ export declare function formatNotificationHealthReport(report: NotificationHealthReport): string; export interface NotificationTestResult { ok: boolean; adapter?: NotificationProvider; destination?: string; detail: string; uncertain?: boolean; } export interface TestOptions { settings: Settings; deps?: NotificationServiceDeps; provider?: NotificationProvider; text?: string; signal?: AbortSignal; } /** Send a one-off test through exactly one durable, effective provider. */ export declare function sendNotificationTest(opts: TestOptions): Promise; /** Render a test result as a single human-readable line (no secrets). */ export declare function formatNotificationTestResult(result: NotificationTestResult): string; export interface RecoveredEndpoint { sessionId: string; pid: number | undefined; reason: "stale-flag" | "dead-pid"; } export type DaemonRecoveryAction = "none" | "cleared-dead-owner-lock" | "left-active" | "left-contended" | "owner-superseded" | "orphan-lock-left"; export interface NotificationRecoveryReport { endpointsScanned: number; endpointsRemoved: RecoveredEndpoint[]; endpointsKept: number; endpointsUnreadable: number; endpointsDetached?: string[]; /** Successor paths retained after an exact-unlink race, distinct from stale quarantines. */ endpointsRetainedSuccessors?: string[]; /** Internal exchange placeholders retained after verified cleanup failure. */ endpointsRetainedPlaceholders?: string[]; /** Cleanup entries retained with unverified or mismatching identity. */ endpointsRetainedUnknown?: string[]; /** Stale quarantine/staging debris removed from the endpoint and daemon dirs. */ debrisRemoved?: string[]; /** Debris candidates retained by policy (young, live writer). */ debrisKept?: number; /** Debris removals attempted without conclusive success (identity/unlink). */ debrisFailures?: number; /** At least one debris directory listing failed; the sweep was not exhaustive. */ debrisScanFailed?: boolean; daemon: { action: DaemonRecoveryAction; detail: string; ownerId: string | undefined; blockingReason?: string; markerAgeMs?: number; forceCommand?: string; pid: number | undefined; }; } export interface RecoveryOptions { settings: Settings; stateRoot?: string; deps?: NotificationServiceDeps; forceDaemonLock?: boolean; } export interface DaemonTransitionLock { pid: number; incarnation: string; createdAt: number; /** Unique fencing generation for this particular transition acquisition. */ token: string; } type TransitionMarkerFs = { readFile(file: string, encoding: "utf8"): Promise; writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise; readEndpointFile?(file: string): Promise; exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise; stat?(file: string): Promise<{ mtimeMs: number; }>; }; /** True only while the exact transition acquisition still occupies the marker path. */ export declare function daemonTransitionLockIsHeld(input: { fs: Pick; path: string; lock: DaemonTransitionLock; }): Promise; /** Removes only the caller's exact marker through the identity-bound detach primitive. */ export declare function releaseDaemonTransitionLock(input: { fs: TransitionMarkerFs; path: string; lock: DaemonTransitionLock; }): Promise; /** * Acquire the daemon lifecycle transition lock using durable owner metadata. * The full marker is published in the single O_EXCL write which reserves it; * canonical markers are detached only through their captured filesystem identity. * * Malformed and empty markers deliberately remain blocked regardless of age. They * have no owner provenance, so reclaiming them could detach a generation-6 empty * reservation while its paused legacy writer can still resume. Operators must * manually clean up such legacy debris after confirming no legacy process remains. */ export declare function acquireDaemonTransitionLock(input: { fs: TransitionMarkerFs; path: string; pid: number; pidAlive: (pid: number) => boolean; pidIncarnation: (pid: number) => string | undefined; now?: () => number; sleep?: (ms: number) => Promise; retries?: number; retryDelayMs?: number; randomToken?: () => string; }): Promise; /** * Minimum age before age-based debris removal. Quarantine artifacts are inert * the moment they are detached, but the age bound keeps the sweep clear of any * recovery pass still holding a `detachedPath` reference in its report. */ export declare const NOTIFICATION_DEBRIS_MIN_AGE_MS: number; export interface NotificationDebrisSweepReport { /** Basenames removed from the swept directory. */ removed: string[]; /** Debris candidates retained (young, live writer, or identity/unlink refusal). */ kept: number; /** * Candidates whose removal was ATTEMPTED and did not conclusively succeed * (identity mismatch, unreadable candidate, unlink error). Distinct from * `kept`-by-policy so a caller can see that a sweep was not fully effective. */ failures: number; /** The directory listing itself failed; nothing could be inspected. */ scanFailed?: boolean; } /** * Remove inert filesystem debris from a notification/state directory: * quarantine targets of already-detached markers and endpoints, exact-unlink * exchange placeholders, and leaked atomic-write staging files. * * Removal requires positive staleness proof — a dead recorded writer pid for * staging files, or an mtime older than {@link NOTIFICATION_DEBRIS_MIN_AGE_MS} * for everything else. Canonical files can never match the debris patterns, and * a young staging file with a live writer is kept. * * Age, terminal-scrub proof, and the delete identity all come from ONE * no-follow snapshot of the candidate. Judging age from a separate `stat` would * prove one pathname stale and then bind the delete to whatever occupied that * pathname afterwards, so a live successor could be removed on a predecessor's * staleness. A candidate replaced after the snapshot fails the identity match * and is retained; a symlinked, non-regular, or unreadable candidate is refused * outright. Every inconclusive attempt is counted in `failures` rather than * silently folded into `kept`. */ export declare function sweepNotificationDebris(input: { dir: string; deps?: NotificationServiceDeps; minAgeMs?: number; }): Promise; /** * Ownership-protected cleanup. Removes only DEAD-owner artifacts: * per-session endpoint files with positive proof of death (a stale tombstone or * a dead recorded pid), and a daemon lock whose recorded owner is confirmed * dead. A PID-less endpoint is treated as unknown (not dead) and kept. The * daemon lock is removed through {@link removeDeadOwnerLock}, an owner-bound * primitive that re-checks ownership under the daemon steal-mutex so it can * never race a concurrent takeover. Never removes a live owner's lock, never * deletes unreadable files, and never kills a process. */ export declare function recoverNotifications(opts: RecoveryOptions): Promise; /** Render a recovery report as human-readable lines (no secrets). */ export declare function formatNotificationRecoveryReport(report: NotificationRecoveryReport): string; export {};