File size: 11,506 Bytes
5710dd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | /**
* Minimal `/api/v1/ws` client for GLOBAL session facts β no subscriptions.
*
* The server pushes every global event (`event.session.*` /
* `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every
* established connection, so this client subscribes to nothing: it sends a
* `client_hello` with an empty subscription list (etiquette only β the
* delivery set does not depend on it) and dispatches the coarse per-session
* facts to the consumer:
*
* - `event.session.work_changed` β `{busy, main_turn_active,
* pending_interaction, last_turn_reason}` for one session;
* - `event.session.created` / `session.meta.updated` β list-level signals
* (a session appeared / retitled), forwarded for list invalidation;
* - `event.session.archived` (live or cold) / `event.workspace.*` β
* list-level signals, forwarded for list invalidation;
* - `event.di.unit_changed` β one DI unit state transition of the engine's
* scope tree (the debug-surface feed), forwarded for `['di']`
* invalidation. Global like the rest: it carries the `__global__`
* session watermark and fans out to every connection.
*
* Session/agent-grained events never arrive here (they stay subscribe-gated
* server-side); the transcript chat channel has its own socket. Global
* frames are live-only β a drop loses whatever fired meanwhile, so the
* consumer answers `onReconnected` with a REST re-seed.
*
* The bearer token is presented at the upgrade through the
* `kimi-code.bearer.<token>` subprotocol (the only credential channel a
* browser WebSocket has).
*/
import type { WsLike, WsLikeCtor } from '../channel/wsLike';
export type SessionPendingInteraction = 'none' | 'approval' | 'question';
export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed';
export interface SessionWorkFacts {
readonly busy: boolean;
readonly mainTurnActive: boolean;
readonly pendingInteraction: SessionPendingInteraction;
readonly lastTurnReason?: SessionTurnOutcome | undefined;
}
export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed';
/** Wire payload of the `event.di.unit_changed` global event. */
export interface DiUnitChangedPayload {
/** Scope path of the container owning the unit (`app` / `app/workspace:<id>` / β¦). */
readonly scope: string;
readonly token: string;
readonly state: DiUnitState;
/** Serialized sticky failure, present only on a Failed transition. */
readonly error?: string | undefined;
}
export interface GlobalEventsWsHandlers {
/** Coarse work-fact tuple for one session changed. */
onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void;
/** A session was created (list-level signal). */
onSessionCreated: (sessionId: string) => void;
/** A session's title/patch changed (list-level signal). */
onMetaUpdated: (sessionId: string) => void;
/** A session was archived, live or cold (list-level signal). The envelope
* carries the `__global__` watermark; the real session id rides in the
* payload. */
onSessionArchived?: ((sessionId: string) => void) | undefined;
/** A session was permanently deleted (list-level signal). Same envelope
* shape as `event.session.archived`: the real session id rides in the
* payload. */
onSessionDeleted?: (sessionId: string) => void;
/** A workspace was created / updated / deleted (list-level signal). */
onWorkspaceChanged?: (() => void) | undefined;
/** A DI unit of the engine's scope tree changed state (debug feed). */
onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined;
/** Socket established (initial connect and every reconnect) β the consumer
* answers with a REST re-seed, since live facts are missed while down. */
onReconnected: () => void;
}
export interface GlobalEventsWsOptions {
/** Server base URL (`http(s)://host:port`) or a full `ws(s)://β¦/api/v1/ws` URL. */
readonly url: string;
readonly token?: string | undefined;
readonly handlers: GlobalEventsWsHandlers;
/** WebSocket implementation; defaults to the global `WebSocket`. */
readonly WebSocketImpl?: WsLikeCtor;
/** Base delay (ms) for the reconnect backoff. Default `500`. */
readonly reconnectDelayMs?: number;
}
interface ServerFrame {
readonly type: string;
readonly id?: string;
readonly session_id?: string;
readonly payload?: unknown;
}
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
export class GlobalEventsWs {
private readonly wsUrl: string;
private readonly token?: string;
private readonly handlers: GlobalEventsWsHandlers;
private readonly WsCtor: WsLikeCtor;
private readonly reconnectDelayMs: number;
private ws: WsLike | undefined;
private manualClose = false;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
constructor(opts: GlobalEventsWsOptions) {
this.wsUrl = toWsUrl(opts.url);
this.token = opts.token;
this.handlers = opts.handlers;
const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined);
if (ctor === undefined) {
throw new Error('no WebSocket implementation available; pass WebSocketImpl');
}
this.WsCtor = ctor;
this.reconnectDelayMs = opts.reconnectDelayMs ?? 500;
this.connect();
}
/** Tear the socket down permanently. */
close(): void {
this.manualClose = true;
if (this.reconnectTimer !== undefined) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
const ws = this.ws;
this.ws = undefined;
ws?.close();
}
private connect(): void {
const protocols =
this.token !== undefined && this.token.length > 0
? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`]
: undefined;
let ws: WsLike;
try {
ws = new this.WsCtor(this.wsUrl, protocols);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.addEventListener('open', () => {
this.reconnectAttempt = 0;
this.send({
type: 'client_hello',
id: `kimi-inspect-global-${Date.now().toString(36)}`,
payload: { client_id: 'kimi-inspect', subscriptions: [] },
});
// Established (first connect and every reconnect alike): live facts may
// have been missed β the consumer re-seeds from REST.
this.handlers.onReconnected();
});
ws.addEventListener('message', (event: { data: unknown }) => {
this.onMessage(event.data);
});
ws.addEventListener('close', () => {
// Stale socket (a manual close already cleared `this.ws`).
if (this.ws !== ws) return;
this.ws = undefined;
if (!this.manualClose) this.scheduleReconnect();
});
ws.addEventListener('error', () => {
// The 'close' event always follows 'error'; reconnect logic lives there.
});
}
private onMessage(raw: unknown): void {
let frame: ServerFrame;
try {
frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame;
} catch {
return;
}
const sessionId = frame.session_id;
if (typeof sessionId !== 'string' || sessionId === '') return;
switch (frame.type) {
case 'event.session.work_changed': {
const facts = parseWorkFacts(frame.payload);
if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts);
return;
}
case 'event.session.created': {
this.handlers.onSessionCreated(sessionId);
return;
}
case 'event.session.archived': {
const payload = frame.payload as { sessionId?: unknown } | undefined;
const archivedId = payload?.sessionId;
if (typeof archivedId === 'string' && archivedId !== '') {
this.handlers.onSessionArchived?.(archivedId);
}
return;
}
case 'event.session.deleted': {
const payload = frame.payload as { sessionId?: unknown } | undefined;
const deletedId = payload?.sessionId;
if (typeof deletedId === 'string' && deletedId !== '') {
this.handlers.onSessionDeleted?.(deletedId);
}
return;
}
case 'event.workspace.created':
case 'event.workspace.updated':
case 'event.workspace.deleted': {
this.handlers.onWorkspaceChanged?.();
return;
}
case 'session.meta.updated': {
this.handlers.onMetaUpdated(sessionId);
return;
}
case 'event.di.unit_changed': {
const payload = parseDiUnitChangedPayload(frame.payload);
if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload);
return;
}
case 'ping': {
const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce;
this.send({ type: 'pong', payload: { nonce } });
return;
}
default:
return;
}
}
private scheduleReconnect(): void {
if (this.manualClose) return;
this.reconnectAttempt += 1;
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.connect();
}, delay);
this.reconnectTimer.unref?.();
}
private send(frame: Record<string, unknown>): void {
const ws = this.ws;
if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return;
try {
ws.send(JSON.stringify(frame));
} catch {
// best-effort; the close handler handles teardown
}
}
}
function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined {
if (typeof payload !== 'object' || payload === null) return undefined;
const p = payload as Record<string, unknown>;
if (typeof p['busy'] !== 'boolean') return undefined;
const pending = p['pending_interaction'];
const reason = p['last_turn_reason'];
return {
busy: p['busy'],
mainTurnActive: p['main_turn_active'] === true,
pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none',
lastTurnReason:
reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined,
};
}
const DI_UNIT_STATES: ReadonlySet<string> = new Set([
'Pending',
'Activating',
'Active',
'Unloading',
'Failed',
]);
function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined {
if (typeof payload !== 'object' || payload === null) return undefined;
const p = payload as Record<string, unknown>;
if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined;
const state = p['state'];
if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined;
return {
scope: p['scope'],
token: p['token'],
state: state as DiUnitState,
error: typeof p['error'] === 'string' ? p['error'] : undefined,
};
}
/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */
function toWsUrl(base: string): string {
const url = new URL(base);
if (url.protocol === 'http:') url.protocol = 'ws:';
else if (url.protocol === 'https:') url.protocol = 'wss:';
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
throw new Error(`unsupported URL scheme for WS transport: ${base}`);
}
if (!url.pathname.endsWith('/api/v1/ws')) {
url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`;
}
url.search = '';
url.hash = '';
return url.toString();
}
|