@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;" />
739 lines (737 loc) • 32.1 kB
JavaScript
require("reflect-metadata");
const require_runtime = require('../../../_virtual/_rolldown/runtime.cjs');
const require_channel_activation_config = require('./channel-activation-config.cjs');
let rxjs = require("rxjs");
let node_crypto = require("node:crypto");
let _ag_ui_client = require("@ag-ui/client");
//#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, services) {
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;
}
if (!services) throw new Error("Managed Channels require the runtime AgentRunner and Intelligence client");
return mod.startChannelsOverRealtimeGateway([channel], {
wsUrl: config.wsUrl,
apiKey: config.apiKey,
scope: {
projectId: config.projectId,
channelName: config.channelName
},
runtimeInstanceId: config.runtimeInstanceId,
adapter: config.adapter,
...config.showToolStatus !== void 0 ? { showToolStatus: config.showToolStatus } : {},
...config.replyContinuation !== void 0 ? { replyContinuation: config.replyContinuation } : {},
appApiBaseUrl: config.apiUrl,
...log ? { log } : {},
runCanonical: (args) => runCanonicalChannelAgent(services.runner, services.intelligence, services.lockTtlSeconds ?? 20, services.lockHeartbeatIntervalSeconds ?? 15, args, services.lockKeyPrefix),
loadHistory: async ({ threadId, appUserId }) => {
const history = await services.intelligence.getThreadMessages({
threadId,
userId: appUserId
});
return Promise.all(history.messages.map((message) => toAgentMessage(message, services.intelligence)));
}
});
}
/** One outer agent that lets the standard runner own the whole local tool loop. */
var ChannelOuterAgent = class extends _ag_ui_client.AbstractAgent {
constructor(inner, canonicalThreadId, executeLoop) {
super({
threadId: inner.threadId,
initialMessages: inner.messages,
initialState: inner.state,
...inner.agentId ? { agentId: inner.agentId } : {}
});
this.inner = inner;
this.canonicalThreadId = canonicalThreadId;
this.executeLoop = executeLoop;
}
run() {
return rxjs.EMPTY;
}
async runAgent(parameters, subscriber) {
if (!parameters?.runId) throw new Error("Canonical Channel run requires a runId");
return {
result: await this.executeLoop(subscriber ?? {}, {
threadId: this.canonicalThreadId,
runId: parameters.runId
}),
newMessages: []
};
}
abortRun() {
this.inner.abortRun();
}
};
/** Drive one public Channel run through the runtime's existing AgentRunner. */
async function runCanonicalChannelAgent(runner, intelligence, lockTtlSeconds, lockHeartbeatIntervalSeconds, args, lockKeyPrefix) {
const lock = await intelligence.ɵacquireThreadLock({
threadId: args.threadId,
runId: args.runId,
userId: args.userId,
agentId: args.agentId,
ttlSeconds: lockTtlSeconds,
...lockKeyPrefix !== void 0 ? { lockKeyPrefix } : {}
});
const canonicalThreadId = lock.threadId;
const canonicalRunId = lock.runId;
let result = {
iterations: 0,
interrupted: false
};
const outer = new ChannelOuterAgent(args.agent, canonicalThreadId, async (subscriber, canonicalRun) => {
result = await args.execute(subscriber, canonicalRun);
return result;
});
let stopPromise;
let heartbeatError;
let heartbeatTimer;
const stopCanonicalRun = () => {
stopPromise ??= Promise.resolve().then(() => runner.stop({ threadId: canonicalThreadId })).catch(() => false);
};
heartbeatTimer = setInterval(() => {
intelligence.ɵrenewThreadLock({
threadId: canonicalThreadId,
runId: canonicalRunId,
ttlSeconds: lockTtlSeconds,
...lockKeyPrefix !== void 0 ? { lockKeyPrefix } : {}
}).catch((error) => {
if (heartbeatTimer === void 0) return;
clearInterval(heartbeatTimer);
heartbeatTimer = void 0;
heartbeatError = error;
try {
args.agent.abortRun();
} catch {}
stopCanonicalRun();
});
}, lockHeartbeatIntervalSeconds * 1e3);
heartbeatTimer.unref?.();
try {
await new Promise((resolve, reject) => {
let terminalError;
runner.run({
threadId: canonicalThreadId,
agent: outer,
input: {
threadId: canonicalThreadId,
runId: canonicalRunId,
messages: args.agent.messages,
state: args.agent.state,
tools: [...args.tools],
context: [...args.context],
forwardedProps: void 0
},
persistedInputMessages: args.persistedInputMessages
}).subscribe({
next: (event) => {
if (event.type !== _ag_ui_client.EventType.RUN_ERROR || terminalError) return;
const message = "message" in event && typeof event.message === "string" ? event.message : "Canonical Channel agent run failed";
terminalError = new Error(message);
terminalError.name = "ChannelCanonicalRunError";
if ("code" in event && typeof event.code === "string" && event.code.length > 0) terminalError.code = event.code;
},
error: reject,
complete: () => {
if (terminalError) reject(terminalError);
else resolve();
}
});
});
} finally {
if (heartbeatTimer !== void 0) {
clearInterval(heartbeatTimer);
heartbeatTimer = void 0;
}
await intelligence.ɵcleanupThreadLock({
threadId: canonicalThreadId,
runId: canonicalRunId
}).catch(() => void 0);
}
if (heartbeatError !== void 0) {
await stopPromise;
throw heartbeatError;
}
return result;
}
/** Convert canonical Intelligence history into AG-UI messages. */
async function toAgentMessage(message, intelligence) {
const content = await hydrateManagedContent(message.content, intelligence);
return {
id: message.id,
role: message.role,
content: content ?? "",
...message.activityType ? { activityType: message.activityType } : {},
...message.toolCalls ? { toolCalls: message.toolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.args
}
})) } : {},
...message.toolCallId ? { toolCallId: message.toolCallId } : {}
};
}
/** Resolves managed asset references only at the authorized Runtime boundary. */
async function hydrateManagedContent(content, intelligence) {
if (Array.isArray(content)) return Promise.all(content.map(async (part) => {
if (typeof part !== "object" || part === null || !("source" in part) || typeof part.source !== "object" || part.source === null || !("value" in part.source) || typeof part.source.value !== "string" || !part.source.value.startsWith("cpki-asset://")) return part;
const assetId = part.source.value.slice(13);
const asset = await intelligence.ɵgetManagedChannelAsset(assetId);
return {
...part,
source: {
type: "data",
value: Buffer.from(asset.bytes).toString("base64"),
mimeType: asset.mimeType ?? ("mimeType" in part.source && typeof part.source.mimeType === "string" ? part.source.mimeType : "application/octet-stream")
}
};
}));
if (typeof content === "object" && content !== null && "assetId" in content && typeof content.assetId === "string") {
const asset = await intelligence.ɵgetManagedChannelAsset(content.assetId);
return {
...content,
source: {
type: "data",
value: Buffer.from(asset.bytes).toString("base64"),
mimeType: asset.mimeType ?? ("mimeType" in content && typeof content.mimeType === "string" ? content.mimeType : "application/octet-stream")
}
};
}
return content;
}
/** 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;
/** Default cadence (ms) for the "still down" log while a session is dropped. */
const DEFAULT_RECONNECT_LOG_INTERVAL_MS = 3e4;
/**
* 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 control socket drops, Phoenix's
* `Socket` reconnects and rejoins with the same Runtime declaration. Active
* deliveries request fresh one-use join tokens through that control link. 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.runner = args.runner;
this.lockTtlSeconds = args.lockTtlSeconds ?? 20;
this.lockHeartbeatIntervalSeconds = args.lockHeartbeatIntervalSeconds ?? 15;
this.lockKeyPrefix = args.lockKeyPrefix;
this.channels = args.channels;
this.log = args.log;
this.activateChannel = args.activateChannel ?? ((config, channel) => defaultActivateChannel(config, channel, void 0, this.log, this.runner ? {
runner: this.runner,
intelligence: this.intelligence,
lockTtlSeconds: this.lockTtlSeconds,
lockHeartbeatIntervalSeconds: this.lockHeartbeatIntervalSeconds,
...this.lockKeyPrefix !== void 0 ? { lockKeyPrefix: this.lockKeyPrefix } : {}
} : void 0));
this.mintRuntimeInstanceId = args.mintRuntimeInstanceId ?? (() => `rti_${(0, node_crypto.randomUUID)().replace(/-/g, "")}`);
this.stopHandleTimeoutMs = args.stopHandleTimeoutMs ?? DEFAULT_STOP_HANDLE_TIMEOUT_MS;
this.reconnectLogIntervalMs = args.reconnectLogIntervalMs ?? DEFAULT_RECONNECT_LOG_INTERVAL_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).
*
* 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, detail) => {
if (this.stopped || entry.status === "stopped") return;
const cause = detail?.reason ?? detail?.code;
const because = cause !== void 0 ? ` — ${cause}` : "";
if (state === "reconnecting") {
entry.status = "reconnecting";
entry.downSince ??= Date.now();
this.log?.(`channel "${name}" managed session dropped; reconnecting (Phoenix auto-rejoin)${because}`);
this.startReconnectLog(name, entry);
} else if (state === "online") {
entry.status = "online";
this.clearReconnectLog(entry);
entry.downSince = void 0;
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 after ${this.downFor(entry)}; marking error (still retrying — a successful rejoin restores online)${because}`);
}
});
}
/** Rendered downtime for this outage episode (`"45s"`), or `"unknown"`. */
downFor(entry) {
return entry.downSince === void 0 ? "unknown" : `${Math.round((Date.now() - entry.downSince) / 1e3)}s`;
}
/**
* Repeat a "still down" line for as long as this outage lasts. Runs THROUGH
* `gave_up` on purpose: that transition is where the old behavior went quiet,
* and an operator watching a silent process cannot tell a dead bot from an
* idle one.
*/
startReconnectLog(name, entry) {
if (entry.reconnectLogTimer !== void 0) return;
const timer = setInterval(() => {
if (this.stopped || entry.status === "stopped") {
this.clearReconnectLog(entry);
return;
}
this.log?.(`channel "${name}" managed session still down after ${this.downFor(entry)}; Phoenix is retrying`);
}, this.reconnectLogIntervalMs);
timer.unref?.();
entry.reconnectLogTimer = timer;
}
/** Stop this entry's "still down" repeat, if one is running. */
clearReconnectLog(entry) {
if (entry.reconnectLogTimer !== void 0) {
clearInterval(entry.reconnectLogTimer);
entry.reconnectLogTimer = void 0;
}
}
/**
* 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";
this.clearReconnectLog(entry);
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