Files
Benjamin Taylor ecbf51028f feat(runtime): split Channel status into transport and provider legs (refs OSS-739)
`status().overall === "online"` proved only that the runtime reached the Gateway
with a valid project API key. It said nothing about whether a Slack/Teams app was
bound to the Channel, so a Channel with no provider at all reported `online` —
and every version of our onboarding guidance used that value to certify
end-to-end success.

`setup_required` had no producer. The manager set it only when the activation
engine threw a `SETUP_REQUIRED` error, and the engine stopped doing that at the
2026-07-29 realtime-boundary cutover. In published
@copilotkit/channels-intelligence@0.7.0 the string survives in exactly one file,
a shipped test. The 15 doc comments describing the state outlived the mechanism,
which is why nobody noticed for a week; they are corrected here, with a note not
to describe the state again without a path that can emit it.

The Gateway now reports per-Channel provider attachment on the control join
reply. This half consumes it:

- `connectRealtimeGateway` captures the join reply (it was discarded) and exposes
  `providerStates()`. Phoenix's `Push.resend` preserves `recHooks`, so the hook
  re-fires on every auto-rejoin and a Channel provisioned while the runtime was
  disconnected is picked up with no extra plumbing.
- The launcher and the manager's handle view delegate it as a GETTER, not a
  captured snapshot, for that same reason.
- `status()` gains `detail`, reporting `transport` and `provider` separately so a
  caller can assert the leg it cares about. `channels` keeps its shape — turning
  its values into objects would break the CLI's channels-report and the starter
  channel-host — but its values are now the fold of the two legs, which is what
  makes `overall` honest.

`unknown` is the load-bearing case. An older Gateway, a Gateway whose lookup
failed, a handle without the seam, a Channel the Gateway did not mention, an
unrecognised state, and a throwing getter all yield `unknown`, which keeps the
transport-derived status — exactly today's behaviour. Only a positively reported
absence downgrades a Channel, so no existing deployment turns amber on upgrade.
The 41 pre-existing channel-manager tests pass unchanged, which is that
guarantee.

Verified the new tests fail without the fix by mutating the fold, not just that
they pass with it.
2026-08-04 08:21:30 -05:00

156 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Channel } from "@copilotkit/channels-core";
/** Lowercase kebab-case Channel name, 3–64 characters. */
const CHANNEL_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
const RESERVED_CHANNEL_NAME = "channels";
/**
* Validate the framework Channels declared to a Channel runtime: each needs a
* `name`, names must be lowercase kebab-case Channel names, and they must be
* unique within the runtime. Fails
* loudly — a misconfigured declaration should never start silently.
*/
export function assertValidChannelNames(channels: readonly Channel[]): void {
const seen = new Set<string>();
for (const channel of channels) {
const name = channel.name;
if (!name) {
throw new Error(
"Intelligence Channel is missing a `name` — pass createChannel({ name }) for an Intelligence Channel",
);
}
if (name.length < 3 || name.length > 64 || !CHANNEL_NAME_RE.test(name)) {
throw new Error(
`Channel name "${name}" is invalid — use lowercase kebab-case, 3–64 characters`,
);
}
if (name === RESERVED_CHANNEL_NAME) {
throw new Error(`Channel name "${name}" is reserved`);
}
if (seen.has(name)) {
throw new Error(
`duplicate Channel name "${name}" — each Channel runtime entry must be unique`,
);
}
seen.add(name);
}
}
/** Runtime environment + version metadata sent to Intelligence on activation. */
export interface ChannelActivationEnv {
runtimeInstanceId?: string;
/** COPILOTKIT_RUNTIME_ENV override, else NODE_ENV, else "development". */
runtimeEnv: string;
nodeEnv?: string;
nodeVersion?: string;
runtimePackageVersion?: string;
channelsPackageVersion?: string;
}
export interface ChannelActivationMetadata extends ChannelActivationEnv {
declaredChannelNames: string[];
/** Per-Channel declarations: name + declared slash-command names. */
declaredChannels: Array<{ channelName: string; commands: string[] }>;
}
/**
* Gather the process-level runtime activation env — `COPILOTKIT_RUNTIME_ENV`
* (override) → `NODE_ENV` → "development", and the Node version. Caller
* `overrides` win and supply what only the runtime knows: package versions
* (`runtimePackageVersion`/`channelsPackageVersion`) and an activation-scoped
* `runtimeInstanceId` that is stable only across transport reconnects.
*/
export function resolveChannelActivationEnv(
overrides: Partial<ChannelActivationEnv> = {},
): ChannelActivationEnv {
// Guard against non-Node hosts (browser/edge) where `process` is absent.
const env = typeof process !== "undefined" ? process.env : undefined;
const copilotRuntimeEnv = env?.COPILOTKIT_RUNTIME_ENV?.trim() || undefined;
const nodeEnv = env?.NODE_ENV?.trim() || undefined;
// Only defined overrides may clobber defaults (Partial can carry explicit undefined).
const definedOverrides = Object.fromEntries(
Object.entries(overrides).filter(([, value]) => value !== undefined),
) as Partial<ChannelActivationEnv>;
return {
runtimeEnv: copilotRuntimeEnv ?? nodeEnv ?? "development",
nodeEnv,
nodeVersion: typeof process !== "undefined" ? process.version : undefined,
...definedOverrides,
};
}
/**
* Build the activation metadata declared to Intelligence: the resolved
* env/versions plus per-Channel declarations (name + declared command names). Pure.
*
* Assumes every Channel has a name — call {@link assertValidChannelNames} first
* (the managed launcher does). A nameless Channel is a programming error and throws
* rather than being silently filtered out of the activation set.
*
* TODO(OSS-377): add richer per-Channel capabilities once the framework Channel exposes them.
*/
export function buildChannelActivationMetadata(
channels: readonly Channel[],
env: ChannelActivationEnv,
): ChannelActivationMetadata {
const names = channels.map((c) => {
if (!c.name) {
throw new Error(
"buildChannelActivationMetadata: Channel is missing a `name` — validate with assertValidChannelNames first",
);
}
return c.name;
});
return {
...env,
declaredChannelNames: names,
declaredChannels: channels.map((c, i) => ({
channelName: names[i]!,
commands: c.commandNames,
})),
};
}
export interface ChannelsHandle {
metadata: ChannelActivationMetadata;
stop(): Promise<void>;
/**
* Optional drop breadcrumb when the managed session disconnects
* unexpectedly. Not used by `ChannelManager` for reconnect (Phoenix owns
* reconnect; status is driven by `onStateChange`). Not fired by the
* handle's own `stop()`. Present when the underlying session supports it
* (see `ConnectedRealtimeGatewaySession.onClose` in `realtime-gateway.ts`).
*/
onClose?(cb: () => void): void;
/**
* Optional seam: register a connection-health observer so a supervising
* `ChannelManager`'s `status()` can reflect real connection health
* (`online` → sendable, `reconnecting` → dropped and retrying, `gave_up` →
* dead after the bounded reconnect window). Not fired by the handle's own
* `stop()`. Present when the underlying session supports it (see
* `ConnectedRealtimeGatewaySession.onStateChange` in `realtime-gateway.ts`).
*/
onStateChange?(
cb: (
state: "online" | "reconnecting" | "gave_up",
detail?: { reason?: string; code?: string },
) => void,
): void;
/**
* Optional seam: managed provider attachment state per declared Channel, as
* reported on the newest gateway control join reply — so a supervising
* `ChannelManager` can tell "the control socket is up" from "a Slack/Teams app
* is actually bound to this Channel".
*
* A getter, not a snapshot: the gateway's join hooks re-fire on every Phoenix
* auto-rejoin, so a Channel provisioned while the runtime was disconnected is
* reflected on the next read.
*
* `undefined` means "not reported", NOT "no provider attached" — a gateway
* predating this contract and one whose lookup failed both omit it. Present
* when the underlying session supports it (see
* `ConnectedRealtimeGatewaySession.providerStates` in `realtime-gateway.ts`).
*/
providerStates?(): Readonly<Record<string, string>> | undefined;
}