UNPKG

@copilotkit/runtime

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

443 lines (441 loc) 20.7 kB
import "reflect-metadata"; import { ChannelConfigError, deriveChannelActivationConfig } from "./channel-activation-config.mjs"; import { randomUUID } from "node:crypto"; //#region src/v2/runtime/core/channel-manager.ts /** * Signals that a declared Channel cannot be activated because no managed * provider exists for it yet. The engine throws this (or any error whose * `code === "SETUP_REQUIRED"`) to move a Channel to `setup_required` rather * than `error` — a declared-but-unprovisioned Channel is a valid degraded * state, not a failure. */ var ChannelSetupRequiredError = class extends Error { constructor(message) { super(message); this.name = "ChannelSetupRequiredError"; } }; /** Non-literal specifier so the pure-ESM channels-intelligence package never * becomes a static dependency of this CJS package (mirrors the runtime's other * channels seams). */ const CHANNELS_INTELLIGENCE_SPECIFIER = "@copilotkit/channels-intelligence"; /** * Default engine: wrap the channels-intelligence Realtime Gateway launcher. * * The module is reached through an injectable importer that defaults to a * dynamic `import()` of a non-literal specifier, so the pure-ESM * `@copilotkit/channels-intelligence` never becomes a static dependency of this * CJS package (mirrors the runtime's other channels seams). The `import` * seam is a parameter purely so this function's config→opts mapping and its * module-not-found / generic-error branches are unit-testable WITHOUT the real * package installed; production always uses the default importer. * * Passes NO `org`/`channelId` — the launcher's realtime scope treats them as * optional. * * @param config - Resolved activation config for the Channel. * @param channel - The Channel to activate. * @param importChannelsIntelligence - Test seam; loads the channels-intelligence * module. Defaults to a dynamic import of the real package. * @param log - Optional diagnostic sink forwarded to the launcher/transport so * transport-level drop diagnostics are not silent in the managed path. * @returns The launcher's {@link ChannelsHandle}. */ async function defaultActivateChannel(config, channel, importChannelsIntelligence = () => import(CHANNELS_INTELLIGENCE_SPECIFIER), log) { let mod; try { mod = await importChannelsIntelligence(); } catch (err) { if (isModuleNotFound(err)) throw new Error("Managed Channels require '@copilotkit/channels-intelligence' to be installed. Add it to your app's dependencies.", { cause: err }); throw err; } return mod.startChannelsOverRealtimeGateway([channel], { wsUrl: config.wsUrl, apiKey: config.apiKey, scope: { projectId: config.projectId, channelName: config.channelName }, runtimeInstanceId: config.runtimeInstanceId, adapter: config.adapter, appApiBaseUrl: config.apiUrl, ...log ? { log } : {} }); } /** Whether `err` signals a missing managed provider rather than a hard failure. */ function isSetupRequired(err) { return err instanceof ChannelSetupRequiredError || typeof err === "object" && err !== null && err.code === "SETUP_REQUIRED"; } /** * Whether `err` is a Node/runtime module-resolution failure — i.e. the error * a dynamic `import()` throws when the target package is not installed. * Exported so the friendly-error path in {@link defaultActivateChannel} can be * unit-tested without forcing a real failing import. */ function isModuleNotFound(err) { if (typeof err !== "object" || err === null) return false; const code = err.code; return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; } /** Default deadline (ms) for a single `handle.stop()` during teardown. */ const DEFAULT_STOP_HANDLE_TIMEOUT_MS = 5e3; /** * Reject with `timeoutMessage` after `timeoutMs` if `inner` has not settled, * otherwise pass `inner` through. When `timeoutMs` is undefined, `inner` is * returned unchanged. The timer is `unref`'d so a pending deadline never keeps * the process alive, and `inner` always has a settle handler attached, so a * timed-out promise that later settles never surfaces as unhandled. */ function withTimeout(inner, timeoutMs, timeoutMessage) { if (timeoutMs === void 0) return inner; return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); timer.unref?.(); inner.then((value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); }); }); } /** * Drives managed Channel activation for an Intelligence runtime: lazily * activates each declared Channel through an engine, tracks per-Channel * lifecycle status, exposes readiness, and tears everything down. * * Activation is lazy and idempotent — constructing the manager does nothing; * {@link activate} starts it and a second call is a no-op. Activation throws * SYNCHRONOUSLY (a {@link ChannelConfigError}) only for a misconfiguration it * can detect up front — a duplicate or missing Channel name. Every OTHER * activation failure is recorded as the Channel's status (`error`, or * `setup_required` for a missing provider) and surfaced through {@link status} * and {@link ready} rather than thrown. * * Reconnection is NOT handled here — it is delegated to the Phoenix connection * layer that backs the launcher. When a managed socket drops, Phoenix's `Socket` * auto-reconnects and auto-rejoins, re-sending the channel's join declaration; * the Intelligence gateway's `join/3` re-runs `record_heartbeat` (re-registering * the runtime's listener) and its `terminate/2` releases the dead socket's * leases (verified against Intelligence #511 `sdk_channel.ex`). So the transport * self-heals under the persistent adapter and a re-activation here would be both * redundant AND broken: re-invoking the engine on an already-started `Channel` * throws in `channel.addAdapter` (started=true). The manager therefore never * re-activates on a drop. * * It DOES, however, reflect real connection health through the session's * `onStateChange` observer so {@link ChannelManager.status} stays honest rather * than reporting `online` forever after a drop: a drop moves the Channel to * `reconnecting`, a successful rejoin restores `online`, and a bounded give-up * (Phoenix would otherwise retry forever) moves it to `error`. */ var ChannelManager = class { /** @param args - See {@link ChannelManagerArgs}. */ constructor(args) { this.entries = /* @__PURE__ */ new Map(); this.activated = false; this.stopped = false; this.intelligence = args.intelligence; this.channels = args.channels; this.log = args.log; this.activateChannel = args.activateChannel ?? ((config, channel) => defaultActivateChannel(config, channel, void 0, this.log)); this.mintRuntimeInstanceId = args.mintRuntimeInstanceId ?? (() => `rti_${randomUUID().replace(/-/g, "")}`); this.stopHandleTimeoutMs = args.stopHandleTimeoutMs ?? DEFAULT_STOP_HANDLE_TIMEOUT_MS; } /** * Start activation of every declared Channel (lazy + idempotent). Mints a * distinct runtime instance id per Channel, derives its activation config, * and calls the engine. Records each Channel as `connecting`, transitioning * to `online`/`setup_required`/`error` as its activation settles. */ activate() { if (this.activated || this.stopped) return; this.assertUniqueChannelNames(); this.activated = true; for (const channel of this.channels) { if (channel.adapters.some((a) => !a.__intelligenceChannel)) { this.log?.(`channel "${channel.name}" carries a direct adapter — recording status "unmanaged" and skipping managed activation (this handler does not own its lifecycle; start it via channel.start(); exclusive per Channel: a Channel served by a direct adapter is not also managed, regardless of platform — managed+direct coexistence deferred (OSS-484); routing of direct channels deferred (OSS-486))`); this.entries.set(channel.name, { status: "unmanaged", handle: void 0, handleStopped: false, settled: Promise.resolve() }); continue; } const name = channel.name; const runtimeInstanceId = this.mintRuntimeInstanceId(); let resolveSettled; let rejectSettled; const settled = new Promise((resolve, reject) => { resolveSettled = resolve; rejectSettled = reject; }); settled.catch(() => {}); let activation; let config; try { config = deriveChannelActivationConfig({ intelligence: this.intelligence, channel, runtimeInstanceId }); activation = this.activateChannel(config, channel); } catch (err) { activation = Promise.reject(err); } const entry = { status: "connecting", handle: void 0, handleStopped: false, settled }; activation.then(async (handle) => { entry.handle = handle; if (this.stopped) { await this.stopEntry(entry); resolveSettled(); return; } entry.status = "online"; this.registerConnectionObserver(name, entry); resolveSettled(); }, async (err) => { if (this.stopped) { await this.stopEntry(entry); resolveSettled(); return; } if (isSetupRequired(err)) { entry.status = "setup_required"; this.log?.(`channel "${name}" requires setup`, err); resolveSettled(); } else { entry.status = "error"; this.log?.(`channel "${name}" failed to activate`, err); rejectSettled(err); } }).catch(() => {}); this.entries.set(name, entry); } } /** * Throw if two declared Channels share a `name`. `entries` is keyed by name, * so a duplicate would overwrite the first Channel's entry and leak its live * session. Called at the very start of {@link activate}, before any engine * call, so a misconfiguration fails loud instead of silently. * * @throws {ChannelConfigError} If any Channel is missing a name, or if any * name appears more than once. */ assertUniqueChannelNames() { const seen = /* @__PURE__ */ new Set(); for (const channel of this.channels) { const name = channel.name; if (!name) throw new ChannelConfigError("A managed Channel is missing a `name` — every declared Channel must have a unique, non-empty name (pass createChannel({ name }))."); if (seen.has(name)) throw new ChannelConfigError(`Duplicate managed Channel name "${name}" — every declared Channel must have a unique name.`); seen.add(name); } } /** * Resolve when every managed Channel has settled to `online`/`setup_required`. * * A direct-adapter (`unmanaged`) Channel has an already-resolved `settled` and * so never blocks — but its resolution implies NO health: this handler does not * own it. Truthfulness about direct Channels lives in {@link status} (they read * `unmanaged`, never `online`), not in `ready()` resolving. * * Activates lazily if not already started — so a first call rejects with the * same {@link ChannelConfigError} as the synchronous throw from * {@link activate} for an up-front misconfiguration (duplicate/missing Channel * names). Once activation has been kicked off, all OTHER failures are surfaced * here instead: this rejects with an `AggregateError` if any Channel settled * to `error` OR — when `timeoutMs` is given — did not settle in time. The * `timeoutMs` deadline is applied PER CHANNEL, so the aggregate carries each * failed Channel's real reason AND a named timeout for each Channel still * hanging: a genuine activation error is never masked by a sibling that hangs * (a pre-fix set-wide timeout discarded the real reason in that case). * * A STOPPED manager short-circuits and resolves: a Channel that settled to * `error` BEFORE {@link stop} already rejected its `settled` promise, so * awaiting it here would throw an `AggregateError` even though * {@link status}.overall is `"stopped"` — inconsistent with the case where the * Channel was still online at stop() (which resolves). A stopped manager has * nothing left to be ready for, so resolve uniformly. * * `ready()` is ONE-SHOT: it settles on the INITIAL activation outcome. Later * connection-health transitions (a live Channel dropping to `reconnecting`, or * giving up to `error`) are reported through {@link status} — where `online` * means currently-sendable — but do NOT re-arm or re-reject an already-settled * `ready()`. */ async ready(opts) { if (this.stopped) return; this.activate(); const entries = [...this.entries.entries()]; const errors = (await Promise.allSettled(entries.map(([name, e]) => withTimeout(e.settled, opts?.timeoutMs, `channel "${name}" did not settle within ${opts?.timeoutMs}ms`)))).filter((r) => r.status === "rejected").map((r) => r.reason); if (errors.length > 0) throw new AggregateError(errors, `ChannelManager.ready: ${errors.length} channel(s) failed to activate or settle in time`); } /** * Snapshot status. Every declared Channel — managed OR direct/`unmanaged` — * appears keyed by name in `channels`; a direct-adapter Channel this handler * does not own is always surfaced as `unmanaged`, never `online`. * * `overall` is folded over the MANAGED Channels only (see {@link computeOverall}), * by precedence `error` > `reconnecting` > `setup_required` > `connecting` > * `online`. `online` means every managed Channel can currently send. * `reconnecting` outranks `setup_required` because a dropped-but-retrying * Channel is an active outage, louder than a steadily-degraded unprovisioned * one. `unmanaged` Channels are EXCLUDED from that fold — they carry no health * this handler established — so a healthy managed Channel alongside an * `unmanaged` one still reports `overall: "online"` while the `unmanaged` one * stays visible per-Channel. When every declared Channel is `unmanaged`, * `overall` is `unmanaged` (NOT `online`). With no declared Channels at all, * `overall` is `online` (nothing is degraded); once every managed Channel has * been stopped, `overall` is `stopped`. */ status() { const channels = {}; for (const [name, entry] of this.entries) channels[name] = entry.status; if (this.stopped) return { overall: "stopped", channels }; if (!this.activated && this.channels.length > 0) return { overall: "connecting", channels }; return { overall: this.computeOverall(Object.values(channels)), channels }; } /** * Fold per-Channel statuses into a single overall status (see {@link status}). * * `unmanaged` Channels are folded out FIRST: they carry no lifecycle this * handler owns, so they must neither count as `online` nor mask a real managed * outage. The remaining MANAGED statuses are ranked * `error` > `reconnecting` > `setup_required` > `connecting` > `online`, so a * genuine managed failure still dominates while a healthy managed Channel * beside an `unmanaged` one reads `online`. If NO managed Channels remain (every * declared Channel is direct/`unmanaged`) the result is `unmanaged` — never the * false-healthy `online`. The empty-input case (no declared Channels at all) * stays `online` (nothing is degraded). */ computeOverall(values) { if (values.length === 0) return "online"; const managed = values.filter((v) => v !== "unmanaged"); if (managed.length === 0) return "unmanaged"; if (managed.every((v) => v === "stopped")) return "stopped"; if (managed.includes("error")) return "error"; if (managed.includes("reconnecting")) return "reconnecting"; if (managed.includes("setup_required")) return "setup_required"; if (managed.includes("connecting")) return "connecting"; return "online"; } /** * Wire the Channel's connection-health observer (if the handle exposes the * optional `onStateChange` seam) so {@link ChannelManager.status} reflects real * health instead of reporting `online` forever after a drop: * * - `reconnecting` → status `reconnecting` (dropped, Phoenix retrying); * - `online` → status `online` (rejoined, sendable again); * - `gave_up` → status `error` (dead after the bounded reconnect window). * * Makes NO re-activation — reconnection is delegated to the Phoenix connection * layer (see {@link ChannelManager}), which auto-rejoins under the persistent * adapter. A STOPPED manager (or an already-stopped entry) ignores late * connection events, so a drop that fires after {@link ChannelManager.stop} * never resurrects the Channel out of `stopped`. * * @param name - The Channel name (map key). * @param entry - The Channel's activation entry. */ registerConnectionObserver(name, entry) { entry.handle?.onStateChange?.((state) => { if (this.stopped || entry.status === "stopped") return; if (state === "reconnecting") { entry.status = "reconnecting"; this.log?.(`channel "${name}" managed session dropped; reconnecting (Phoenix auto-rejoin)`); } else if (state === "online") { entry.status = "online"; this.log?.(`channel "${name}" managed session back online`); } else { entry.status = "error"; this.log?.(`channel "${name}" managed session gave up reconnecting; marking error`); } }); } /** * Drive a single entry to its terminal `stopped` state, tearing down its * handle AT MOST ONCE. Idempotent: it always sets `status = "stopped"`, and * only calls `handle.stop()` on the first invocation that sees a live, * not-yet-stopped handle (gated by {@link ChannelEntry.handleStopped}). * * This is the ONE guarded teardown path shared by both `stop()` and the * post-settle guard in {@link activate}. Because the guard is per-entry and * idempotent, a handle assigned in the same tick as `stop()` is stopped * exactly once even when both callers reach the entry, and a late settle can * never resurrect a `stopped` entry. * * `handle.stop()` failures are logged (via {@link ChannelManager.log}) but NOT * rethrown: the real launcher's `stop()` rethrows after `session.disconnect()`, * and teardown must still complete for every other entry. The call is wrapped * in `Promise.resolve().then(...)` so a foreign/injected handle whose `stop()` * throws SYNCHRONOUSLY (before any promise is created) is caught by the same * `.catch` — otherwise the sync throw would escape, skip `resolveSettled()` in * the fulfilled-then-stopped branch of {@link activate}, and hang `settled`. * * An `unmanaged` entry (a direct-adapter Channel this handler never activated) * is left untouched: the manager owns no handle and no lifecycle for it, so * claiming to have `stopped` it would be as untruthful as calling it `online`. * The developer's `channel.start()`/stop path is unaffected by manager * teardown. * * A WEDGED `handle.stop()` (one that never settles) is bounded by * {@link ChannelManagerArgs.stopHandleTimeoutMs}: after the deadline the call * is logged and abandoned so it can't hang `stop()` — and thus SIGTERM * shutdown — forever. * * @param entry - The Channel entry to stop. */ async stopEntry(entry) { if (entry.status === "unmanaged") return; entry.status = "stopped"; if (entry.handle && !entry.handleStopped) { entry.handleStopped = true; const handle = entry.handle; await withTimeout(Promise.resolve().then(() => handle.stop()), this.stopHandleTimeoutMs, `channel handle stop() timed out after ${this.stopHandleTimeoutMs}ms during teardown`).catch((err) => this.log?.("channel handle stop() failed during teardown", err)); } } /** * Stop every activated Channel exactly once and mark all statuses `stopped`. * Idempotent — a second call is a no-op. * * Resolves promptly: {@link stopEntry} stops only the handles that already * exist and never blocks on activations that have not settled. A hung connect * (which `ready({ timeoutMs })` tolerates) has no handle to stop yet, and * awaiting it here would hang teardown — and thus SIGTERM shutdown — forever. * Any handle that arrives after this point is torn down by the post-settle * guard in {@link activate}, which routes through the same idempotent * {@link stopEntry}, so nothing leaks and nothing double-stops. * * Teardown is resilient to a throwing `handle.stop()`: `Promise.allSettled` * over the per-entry `stopEntry` calls guarantees one rejection can't abort * the rest, so every entry reaches `stopped` and `stop()` always resolves. * It is equally resilient to a WEDGED `handle.stop()` that never settles: each * is bounded by {@link ChannelManagerArgs.stopHandleTimeoutMs} inside * {@link stopEntry}, so a single hung handle can't hang SIGTERM shutdown. */ async stop() { if (this.stopped) return; this.stopped = true; const entries = [...this.entries.values()]; await Promise.allSettled(entries.map((entry) => this.stopEntry(entry))); } }; //#endregion export { ChannelManager }; //# sourceMappingURL=channel-manager.mjs.map