import type { ConfigManager } from '../config/manager.js'; import type { SecretsManager } from '../config/secrets.js'; import type { ServiceRegistry } from '../config/service-registry.js'; import { type SurfaceAdapterContext } from '../adapters/index.js'; export type ProviderRuntimeSurface = 'slack' | 'discord' | 'ntfy'; export interface ProviderRuntimeStatus { readonly surface: ProviderRuntimeSurface; readonly running: boolean; readonly configured: boolean; readonly transport: 'socket-mode' | 'gateway' | 'json-stream'; readonly lastStartedAt?: number | undefined; readonly lastStoppedAt?: number | undefined; readonly lastError?: string | undefined; readonly metadata: Record; } /** Options that apply when a surface is (re)started. */ export interface ProviderRuntimeStartOptions { /** * Wall-clock ms to replay ntfy from, the last moment the previous * responsible node was heard from. Null or absent subscribes live. */ readonly replayFromMs?: number | null | undefined; } export interface ProviderRuntimeActionResult { readonly ok: boolean; readonly surface: ProviderRuntimeSurface; readonly status: ProviderRuntimeStatus; readonly message: string; } interface ProviderRuntimeManagerDeps { readonly configManager: ConfigManager; readonly secretsManager?: Pick | undefined; readonly serviceRegistry: ServiceRegistry; readonly buildSurfaceAdapterContext: () => SurfaceAdapterContext; /** * Where a message this node read off a live stream and then failed to process * becomes something the owner hears about. * * These three surfaces need it MORE than the webhook ones, not less: Slack * Socket Mode, the Discord gateway and the ntfy JSON stream have no ack, no * cursor and no redelivery. Once the socket has handed an event over, the * catch below is the last place it exists. Losing it quietly is structural * here, so saying so is the only remedy available. */ readonly ingressAlarm?: import('./ingress-alarm.js').ChannelIngressAlarm | undefined; } export declare class ChannelProviderRuntimeManager { private readonly deps; private slackClient; private discordClient; /** * One live subscription per TOPIC, not one stream carrying all of them. * * ntfy will happily serve `topic-a,topic-b` down a single connection, and * that is what this used to do, but it makes the three topics one * indivisible consumer. Under per-surface leadership each topic is its own * surface with its own election, so this node can hold the agent topic while * a second machine holds the chat topic, and starting or stopping either has * to be possible without disturbing the other. A blanket consumer cannot * express that; a map of independent streams can. */ private readonly ntfyAborts; private socketLostHandler; private readonly state; constructor(deps: ProviderRuntimeManagerDeps); /** * Start every surface the operator switched on, and account for every one of * them out loud. * * The previous shape skipped a surface whose precondition was unmet by simply * not entering the branch, an enabled Slack with no app token, an enabled * ntfy with no topic, and returned a result array the caller discards. The * operator's config said "on", the runtime did nothing, and no line anywhere * said why. An enabled surface that does not come up is now an ERROR naming * the surface, the reason, and the setting to change. */ startConfigured(options?: ProviderRuntimeStartOptions): Promise; /** * One inbound surface the operator enabled is not up. This is the single most * expensive state the daemon can be in, the config reads correct, the * process is healthy, and messages disappear, so it is stated at ERROR with * the operator's next action in it, not left to a status endpoint nobody * queries. */ private reportInert; start(surface: ProviderRuntimeSurface, options?: ProviderRuntimeStartOptions): Promise; stop(surface: ProviderRuntimeSurface): ProviderRuntimeActionResult; /** * The ntfy server these topics live on. * * Part of a topic's surface identity: the same topic name on two different * servers is two unrelated surfaces, and a node reading a self-hosted server * must never stand down for a node reading ntfy.sh. */ ntfyBaseUrl(): string; /** Every ntfy topic this node is configured to read. */ ntfyTopics(): string[]; /** Topics with a live subscription on this node right now. */ runningNtfyTopics(): string[]; /** * Subscribe to ONE topic. Idempotent per topic. * * `replayFromMs` is the last moment the previous holder of THIS topic was * heard from. ntfy keeps no per-subscriber cursor, so a takeover that * subscribed "from now" would silently lose everything published in the gap. */ startNtfyTopic(topic: string, options?: ProviderRuntimeStartOptions): Promise; /** * Drop ONE topic's subscription. * * Synchronous underneath, aborting the controller closes the stream before * this returns, which is what lets the RESIGN that follows a handoff be an * honest claim that this node has stopped reading the topic. */ stopNtfyTopic(topic: string): ProviderRuntimeActionResult; private abortNtfyTopic; stopAll(): void; status(surface: ProviderRuntimeSurface): ProviderRuntimeStatus; private startSlack; private startDiscord; /** * Subscribe to every configured topic, each as its own stream. * * This is the path taken when leadership is switched off, the node reads * everything it is configured for. Under leadership the facade registers one * gate per topic instead, and each gate calls `startNtfyTopic` for the single * topic it won. */ private startNtfy; private handleSlackEnvelope; private handleDiscordDispatch; private handleNtfyMessage; private resolveSlackBotToken; private resolveSlackAppToken; private resolveDiscordBotToken; private resolveNtfyToken; private resolveNtfyTopics; /** * Which Slack WORKSPACE this node reads, its team id, or null when that * cannot be established. * * The identity has to be real. Contesting Slack under a fixed placeholder * would make two nodes configured for two DIFFERENT workspaces fight over * one election, and the loser's workspace would go unanswered with nothing * to say why, the exact silent starvation per-surface elections exist to * remove. * * `auth.test` is a plain authenticated REST call: it opens no socket and * consumes no events, so the identity is settled BEFORE anything is * contested and there is never a window in which this node is reading * without having won. It doubles as the credential check, a token that * cannot identify its own workspace cannot read it either. */ resolveSlackWorkspaceId(): Promise; /** * Which Discord APPLICATION this node reads, its bot user id, or null when * that cannot be established. Same argument as the Slack workspace above; * `/users/@me` opens no gateway and consumes no events. */ resolveDiscordApplicationId(): Promise; /** The identity of whichever socket surface this is, or null. */ resolveSocketSurfaceIdentity(kind: 'slack' | 'discord'): Promise; /** * Learn that a socket dropped on its own. * * Late-bound because leadership is composed after the runtime: a node that * loses its Slack connection is a node that can no longer serve the Slack * surface, and it has to stand down rather than hold a workspace it is not * reading. */ setSocketLostHandler(handler: (kind: 'slack' | 'discord', reason: string) => void): void; private isConfigured; /** * A socket dropped without being asked to. Record it and tell leadership. * * The state is marked stopped first so a `/status` read during the stand-down * says what is true: this node is not reading that surface. */ private reportSocketLost; private markStarted; private markStopped; private markError; private result; private resolveConfigSecret; } export {}; //# sourceMappingURL=provider-runtime.d.ts.map