@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
JavaScript
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