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();
}