@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;" />
516 lines (514 loc) • 23.8 kB
JavaScript
require("reflect-metadata");
const require_runtime = require('../../../_virtual/_rolldown/runtime.cjs');
const require_channel_activation_config = require('./channel-activation-config.cjs');
let node_crypto = require("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";
}
};
/**
* Runtime installs this pure-ESM package as a direct dependency, but the
* specifier must stay non-literal so it never becomes a static dependency of
* the runtime's CJS build. The packed-consumer contract is enforced by
* `scripts/release/verify-runtime-package.ts`.
*/
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 Channel activation for an Intelligence runtime: lazily activates each
* declared Channel, tracks per-Channel lifecycle status, exposes readiness, and
* tears everything down. A MANAGED Channel (empty `adapters`) is activated
* through the injected engine over the Intelligence gateway; a DIRECT Channel
* (developer-supplied adapter) is started through its own transport seam
* (`channel.ɵruntime.start()`) — driven by the manager, but running only its own
* adapter transport, not the gateway path (direct Channels stay below the
* canonical/reliability layer by design — see {@link ChannelStatus} and OSS-599).
* Its very existence means Intelligence is configured, so there is no
* standalone/self-started path.
*
* 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_${(0, node_crypto.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.startDirectChannel(channel);
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 = require_channel_activation_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);
}
}
/**
* Start a DIRECT-adapter Channel through its own transport seam
* ({@link Channel.ɵruntime}`.start()`), recording a live entry so
* {@link ready}/{@link status}/{@link stop} all cover it. A direct Channel is
* driven by the manager — but only because the Intelligence runtime constructed
* this manager at all — and runs its OWN adapter transport, NOT the Intelligence
* gateway path. It is deliberately NOT wired into the gateway/canonical/
* reliability layer — that boundary is permanent, not a deferral (OSS-599).
*
* Mirrors the managed path's settle machinery so teardown resilience is shared:
* the entry's `handle` is a synthetic {@link ChannelsHandle} whose `stop()`
* calls `channel.ɵruntime.stop()`, so the SAME idempotent, bounded, resilient
* {@link stopEntry} that tears down a managed handle tears down a direct Channel
* too. The handle is assigned only AFTER `start()` resolves (exactly as the
* managed path assigns its handle only on resolve), so a `stop()` during a
* still-starting direct Channel returns promptly with nothing to stop and the
* post-settle guard tears down the late transport. A direct Channel exposes no
* managed-session drop signal, so no connection observer is wired and it never
* reaches `reconnecting`.
*
* @param channel - The direct-adapter Channel to start.
*/
startDirectChannel(channel) {
const name = channel.name;
this.log?.(`channel "${name}" carries a direct adapter — starting its own transport via channel.ɵruntime.start() (the Intelligence runtime drives its lifecycle; it runs its own adapter transport, NOT the managed gateway path — direct Channels stay below the canonical/reliability layer by design (OSS-599); managed+direct coexistence deferred (OSS-484))`);
let resolveSettled;
let rejectSettled;
const settled = new Promise((resolve, reject) => {
resolveSettled = resolve;
rejectSettled = reject;
});
settled.catch(() => {});
const directHandle = {
metadata: {},
stop: () => channel.ɵruntime.stop()
};
let activation;
try {
activation = channel.ɵruntime.start();
} catch (err) {
activation = Promise.reject(err);
}
const entry = {
status: "connecting",
handle: void 0,
handleStopped: false,
settled
};
activation.then(async () => {
entry.handle = directHandle;
if (this.stopped) {
await this.stopEntry(entry);
resolveSettled();
return;
}
entry.status = "online";
resolveSettled();
}, async (err) => {
if (this.stopped) {
entry.handle = directHandle;
await this.stopEntry(entry);
resolveSettled();
return;
}
entry.status = "error";
this.log?.(`channel "${name}" failed to start its direct transport`, 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 require_channel_activation_config.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 require_channel_activation_config.ChannelConfigError(`Duplicate managed Channel name "${name}" — every declared Channel must have a unique name.`);
seen.add(name);
}
}
/**
* Resolve when every declared Channel — managed OR direct — has settled to
* `online`/`setup_required`.
*
* A direct-adapter Channel is awaited too: its `settled` resolves once its own
* transport is up (`channel.ɵruntime.start()` settling) and rejects if that
* start fails, exactly as a managed Channel's `settled` tracks its activation.
*
* 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 — appears keyed
* by name in `channels`; a direct-adapter Channel reads `online` once its own
* transport is up, exactly like a managed one.
*
* `overall` is folded over ALL declared Channels (see {@link computeOverall}),
* by precedence `error` > `reconnecting` > `setup_required` > `connecting` >
* `online`. `online` means every 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 (only managed
* Channels ever reach `reconnecting`; a direct Channel has no managed-session
* drop signal). With no declared Channels at all, `overall` is `online` (nothing
* is degraded); once every 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}).
*
* Every declared Channel — managed OR direct — participates: a started direct
* Channel reads `online` and counts toward health exactly like a managed one,
* and its `error` is a real outage that must dominate. Statuses are ranked
* `error` > `reconnecting` > `setup_required` > `connecting` > `online`, so a
* genuine failure still dominates a healthy sibling. (Only managed Channels ever
* reach `reconnecting`; a direct Channel has no managed-session drop signal.)
* The empty-input case (no declared Channels at all) stays `online` (nothing is
* degraded).
*/
computeOverall(values) {
if (values.length === 0) return "online";
if (values.every((v) => v === "stopped")) return "stopped";
if (values.includes("error")) return "error";
if (values.includes("reconnecting")) return "reconnecting";
if (values.includes("setup_required")) return "setup_required";
if (values.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);
* - `fenced` → status `error` immediately (another activation superseded it).
*
* 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 if (state === "gave_up") {
entry.status = "error";
this.log?.(`channel "${name}" managed session gave up reconnecting; marking error`);
} else {
entry.status = "error";
this.log?.(`channel "${name}" managed session was generation-fenced by a replacement; 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}/{@link startDirectChannel}. 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. It is transport-agnostic: a
* managed entry's `handle.stop()` releases the gateway session, and a direct
* entry's synthetic handle (assigned in {@link startDirectChannel}) routes the
* same `handle.stop()` to `channel.ɵruntime.stop()` — so direct and managed
* Channels share one bounded, resilient teardown.
*
* `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 entry with no handle yet (a still-`connecting` Channel whose transport has
* not come up) is only marked `stopped`: there is nothing to tear down, and the
* post-settle guard releases the transport if it arrives after `stop()`.
*
* 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) {
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
exports.ChannelManager = ChannelManager;
//# sourceMappingURL=channel-manager.cjs.map