realtimeBroker.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import { randomUUID } from 'node:crypto'
  2. import WebSocket from 'ws'
  3. import { HttpError } from './errors.js'
  4. import { checkedChatFrame, CHAT_MAX_PAYLOAD, GroupFrameValidator } from './realtimeProtocol.js'
  5. import { RealtimeReceipts, type CommandReceipt } from './realtimeReceipts.js'
  6. import { applyWorkingDirectory, type AppliedWorkingDirectory } from './sessionWorkingDirectory.js'
  7. type Frame = Record<string, any>
  8. export interface RealtimeActivity { kind: 'command' | 'event' | 'group' | 'reset'; name: string; sessionId?: string; roomId?: string }
  9. export interface RealtimePrincipal {
  10. key: string
  11. upstreamKey: string
  12. instanceKey?: string
  13. paired: boolean
  14. nativeBot?: boolean
  15. agent?: WebSocket.ClientOptions['agent']
  16. valid(): boolean
  17. authorize?(kind: 'chat' | 'groups', method?: string): void
  18. canResume?(profile: string, sessionId: string): Promise<boolean>
  19. configuredWorkingDirectory?(profile: string): Promise<string | undefined>
  20. url(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<URL>
  21. observeCommand?(frame: string): void
  22. observeEvent?(frame: string): void
  23. }
  24. export interface StreamEntry { id: string; event: 'frame' | 'reset'; data: string; at: number; bytes: number }
  25. export interface RealtimeChannel {
  26. id: string; kind: 'chat' | 'groups'; principal: RealtimePrincipal
  27. seq: number; droppedThrough: number; entries: StreamEntry[]; routes: Set<string>
  28. listeners: Set<(entry: StreamEntry) => void>; touched: number; bytes: number
  29. group?: { epoch: string; cursor: number }; socket?: WebSocket
  30. }
  31. interface Route { profile: string; stored: string; runtime: string; active: boolean; seq: number; observers: Map<string, RealtimePrincipal>; cwd?: string; workingDirectory?: AppliedWorkingDirectory }
  32. interface Pending { resolve(frame: Frame): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
  33. interface Upstream {
  34. principal: RealtimePrincipal; socket?: WebSocket; connecting?: Promise<void>; ready?: Frame
  35. pending: Map<string, Pending>; routes: Map<string, Route>; byRuntime: Map<string, Route>
  36. tails: Map<string, Promise<unknown>>; early: Map<string, Frame[]>; earlyBytes: number
  37. retry?: ReturnType<typeof setTimeout>; attempts: number; epoch?: string; recovering: boolean
  38. lastUsed: number; ping?: ReturnType<typeof setInterval>
  39. deferred: Frame[]
  40. recovery?: Promise<void>
  41. }
  42. /** Server-owned WS transports. Downstream detach never tears down an active upstream. */
  43. export class RealtimeBroker {
  44. get idleForUpdate(): boolean {
  45. return this.commands.size === 0 && [...this.upstreams.values()].every(upstream => upstream.pending.size === 0 && [...upstream.routes.values()].every(route => !route.active))
  46. }
  47. protectedSession: (id: string) => boolean = () => false
  48. onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
  49. onNativeGlobalEvent: (owner: string, type: string) => void = () => {}
  50. onNativeCommand: (owner: string, profile: string, storedId: string, method: string, params: Frame) => void = () => {}
  51. onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string) => void = () => {}
  52. readonly epoch = randomUUID()
  53. readonly channels = new Map<string, RealtimeChannel>()
  54. private upstreams = new Map<string, Upstream>()
  55. private commands = new Map<string, Promise<CommandReceipt>>()
  56. private completed = new Map<string, { receipt: CommandReceipt; at: number; bytes: number }>()
  57. private receipts: RealtimeReceipts
  58. private timer: ReturnType<typeof setInterval>
  59. private closed = false
  60. constructor(home: string, readonly now = Date.now, readonly onActivity: (activity: RealtimeActivity) => void = () => {}) {
  61. this.receipts = new RealtimeReceipts(home)
  62. this.timer = setInterval(() => this.sweep(), 5_000)
  63. this.timer.unref()
  64. }
  65. async create(principal: RealtimePrincipal, kind: 'chat' | 'groups', group?: { epoch: string; cursor: number }): Promise<RealtimeChannel> {
  66. if (String(kind) === 'groups') throw new HttpError(410, '请使用新版聊天事件接口', 'retired_group_stream')
  67. if (this.closed) throw new HttpError(503, 'Realtime broker is stopping', 'broker_stopping')
  68. principal.authorize?.(kind)
  69. if (!principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  70. // Closed pages leave resumable channels behind. They must not consume the live-stream
  71. // quota; evict oldest detached handles under pressure, never a running upstream.
  72. let own = [...this.channels.values()].filter(c => c.principal.key === principal.key)
  73. while (own.length >= 32) {
  74. const stale = own.filter(c => !c.listeners.size).sort((a, b) => a.touched - b.touched)[0]
  75. if (!stale) break
  76. this.remove(stale); own = own.filter(c => c !== stale)
  77. }
  78. if (own.length >= 32 || this.channels.size >= 512) {
  79. throw new HttpError(429, 'Too many realtime channels', 'channel_limit')
  80. }
  81. const c: RealtimeChannel = { id: randomUUID(), kind, principal, seq: 0, droppedThrough: 0,
  82. entries: [], routes: new Set(), listeners: new Set(), touched: this.now(), bytes: 0, group }
  83. this.channels.set(c.id, c)
  84. try {
  85. {
  86. const u = this.upstream(principal)
  87. await this.connect(u)
  88. this.emit(c, 'frame', u.ready!)
  89. }
  90. return c
  91. } catch (e) { this.remove(c); throw e }
  92. }
  93. get(principal: RealtimePrincipal, id: string): RealtimeChannel {
  94. const c = this.channels.get(id)
  95. if (!c || c.principal.key !== principal.key) throw new HttpError(404, 'Channel not found', 'channel_not_found')
  96. if (!principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  97. c.touched = this.now()
  98. return c
  99. }
  100. detach(principal: RealtimePrincipal, id: string): void { this.remove(this.get(principal, id)) }
  101. release(c: RealtimeChannel): void { if (this.channels.get(c.id) === c) this.remove(c) }
  102. private remove(c: RealtimeChannel): void {
  103. this.channels.delete(c.id)
  104. c.socket?.close()
  105. for (const listener of c.listeners) {
  106. try { listener({ id: '', event: 'reset', data: '{"reason":"channel_closed"}', at: this.now(), bytes: 0 }) } catch { /* a closed consumer must not affect the upstream */ }
  107. }
  108. c.listeners.clear()
  109. }
  110. subscribe(c: RealtimeChannel, cursor: string | undefined, listener: (entry: StreamEntry) => void): () => void {
  111. if ([...this.channels.values()].filter(v => v.principal.key === c.principal.key).reduce((n, v) => n + v.listeners.size, 0) >= 8) {
  112. throw new HttpError(429, 'Too many active event streams', 'stream_limit')
  113. }
  114. this.trim(c)
  115. let seq = 0
  116. if (cursor) {
  117. const prefix = `${this.epoch}:`
  118. if (!cursor.startsWith(prefix) || !/^\d+$/.test(cursor.slice(prefix.length))) {
  119. throw new HttpError(409, 'Stream epoch changed', 'reset_required')
  120. }
  121. seq = Number(cursor.slice(prefix.length))
  122. }
  123. if (!Number.isSafeInteger(seq) || seq < c.droppedThrough || seq > c.seq) {
  124. throw new HttpError(409, 'Replay window expired', 'reset_required')
  125. }
  126. c.listeners.add(listener)
  127. for (const entry of c.entries) if (Number(entry.id.split(':').at(-1)) > seq) listener(entry)
  128. return () => { c.listeners.delete(listener); c.touched = this.now() }
  129. }
  130. receipt(p: RealtimePrincipal, id: string): CommandReceipt {
  131. if (!p.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  132. const receipt = this.completed.get(`${p.key}:${id}`)?.receipt ?? this.receipts.lookup(p.key, id)
  133. if (!receipt) throw new HttpError(404, 'Receipt not found; do not automatically resubmit an attempted command', 'receipt_not_found')
  134. return receipt
  135. }
  136. command(c: RealtimeChannel, requestId: string, input: Frame): Promise<CommandReceipt> {
  137. if (this.closed) throw new HttpError(503, 'Realtime broker is stopping', 'broker_stopping')
  138. if (c.kind !== 'chat') throw new HttpError(403, 'Group event channel is read-only', 'read_only')
  139. const normalized = checkedChatFrame(Buffer.from(JSON.stringify(input)), false, c.principal.paired)
  140. const frame = JSON.parse(normalized) as Frame
  141. if (Object.values(frame.params?.preferred_session_ids ?? {}).some(id => typeof id === 'string' && this.protectedSession(id))) {
  142. throw new HttpError(404, '会话不存在', 'session_not_found')
  143. }
  144. c.principal.authorize?.('chat', String(frame.method))
  145. delete frame.id
  146. const fingerprint = JSON.stringify(frame)
  147. if (!this.receipts.lookup(c.principal.key, requestId) && (this.commands.size >= 1024
  148. || [...this.commands.keys()].filter(k => k.startsWith(`${c.principal.key}:`)).length >= 32)) {
  149. throw new HttpError(429, 'Too many pending commands', 'command_limit')
  150. }
  151. const existing = this.receipts.reserve(c.principal.key, requestId, fingerprint)
  152. const key = `${c.principal.key}:${requestId}`
  153. if (existing) return this.commands.get(key) ?? Promise.resolve(this.completed.get(key)?.receipt ?? existing)
  154. const work = this.perform(c, frame).then(response => {
  155. const state = response.error ? 'rejected' : 'confirmed'
  156. this.receipts.finish(c.principal.key, requestId, state, response)
  157. return { requestId, state, response } as CommandReceipt
  158. }, (error: unknown) => {
  159. if (error instanceof HttpError) {
  160. const response = { error: { code: error.code, message: error.message } }
  161. this.receipts.finish(c.principal.key, requestId, 'rejected', response)
  162. return { requestId, state: 'rejected', response } as CommandReceipt
  163. }
  164. this.receipts.finish(c.principal.key, requestId, 'unknown')
  165. return { requestId, state: 'unknown' } as CommandReceipt
  166. })
  167. this.commands.set(key, work)
  168. void work.then(receipt => {
  169. this.commands.delete(key)
  170. this.completed.set(key, { receipt, at: this.now(), bytes: Buffer.byteLength(JSON.stringify(receipt)) })
  171. while (this.completed.size > 10_000 || [...this.completed.values()].reduce((n, x) => n + x.bytes, 0) > 32 * 1024 * 1024) this.completed.delete(this.completed.keys().next().value!)
  172. })
  173. return work
  174. }
  175. private async perform(c: RealtimeChannel, frame: Frame): Promise<Frame> {
  176. const u = this.upstream(c.principal)
  177. const p = frame.params as Frame
  178. if (p.session_id && this.protectedSession(String(p.session_id))) throw new HttpError(404, '会话不存在', 'session_not_found')
  179. const method = String(frame.method)
  180. const opening = method === 'session.create' || method === 'session.resume'
  181. if (opening && (c.routes.size >= 128 || u.routes.size >= 512)) throw new HttpError(429, 'Route capacity reached', 'route_limit')
  182. let route = p.session_id ? u.byRuntime.get(p.session_id) : undefined
  183. if (!opening && !method.startsWith('profiles.')) {
  184. if (!route && (method === 'approval.respond' || method === 'clarify.respond')) {
  185. // These RPCs use request_id rather than session_id; resolve only a pending interaction seen by this channel.
  186. const match = [...c.routes].map(k => u.routes.get(k)).find(r => r && this.interactions.get(`${u.principal.upstreamKey}:${p.request_id}`) === r.runtime)
  187. route = match
  188. }
  189. if (!route || !c.routes.has(this.routeKey(route.profile, route.stored))) throw new HttpError(403, 'Session is not subscribed', 'route_forbidden')
  190. if (['approval.respond', 'clarify.respond'].includes(method)
  191. && this.interactions.get(`${u.principal.upstreamKey}:${p.request_id}`) !== route.runtime) {
  192. throw new HttpError(403, 'Interaction does not belong to this session', 'interaction_forbidden')
  193. }
  194. }
  195. const routeKey = opening ? this.routeKey(String(p.profile), String(p.session_id ?? c.id))
  196. : route ? this.routeKey(route.profile, route.stored) : 'profiles'
  197. const execute = async () => {
  198. if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  199. if (method === 'session.resume') {
  200. let permitted = !c.principal.canResume || await c.principal.canResume(String(p.profile), String(p.session_id))
  201. if (c.principal.nativeBot) {
  202. await this.connect(u)
  203. const roster = await this.rpc(u, 'profiles.list', {include_sessions: false})
  204. const profiles = roster.result?.profiles
  205. const bot = Array.isArray(profiles) ? profiles.find((value: Frame) => value.name === p.profile) : undefined
  206. permitted = !roster.error && typeof p.session_id === 'string' && p.session_id.length > 0 &&
  207. bot?.ui_meta?.['hermes-bots']?.chat === p.session_id
  208. }
  209. if (!permitted) {
  210. throw new HttpError(403, '此会话属于历史记录,不能继续聊天', 'history_session_read_only')
  211. }
  212. const ownerKey = JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, p.profile, p.session_id])
  213. const owner = this.routeOwners.get(ownerKey)
  214. if (owner && owner !== c.principal.upstreamKey) throw new HttpError(409, 'Session is attached through another credential scope', 'session_scope_conflict')
  215. this.routeOwners.set(ownerKey, c.principal.upstreamKey)
  216. }
  217. await this.connect(u)
  218. await u.recovery
  219. if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  220. if (!c.principal.nativeBot && c.principal.configuredWorkingDirectory) {
  221. if (method === 'session.create') {
  222. const cwd = await c.principal.configuredWorkingDirectory(String(p.profile))
  223. // Client cwd is never authoritative, including paired mobile clients.
  224. delete p.cwd
  225. if (cwd) p.cwd = cwd
  226. } else if (route && !route.active && ['prompt.submit', 'file.attach', 'image.attach_bytes', 'pdf.attach'].includes(method)) {
  227. const cwd = await c.principal.configuredWorkingDirectory(route.profile)
  228. if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  229. route.workingDirectory = await applyWorkingDirectory(async (method, params) => {
  230. const response = await this.rpc(u, method, params)
  231. if (response.error) throw new HttpError(502, response.error.message || '无法切换到机器人工作目录', 'working_directory_rejected')
  232. return response.result
  233. }, route.runtime, cwd, route.workingDirectory, route.cwd)
  234. if (route.workingDirectory) route.cwd = route.workingDirectory.resolved
  235. }
  236. if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
  237. }
  238. const previouslyActive = route?.active ?? false
  239. if (route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
  240. const nativeOwner = this.nativeOwner(c.principal)
  241. if (nativeOwner && route && ['prompt.submit', 'session.steer'].includes(method)) {
  242. this.onNativeCommand(nativeOwner, route.profile, route.stored, method, p)
  243. }
  244. const activity: RealtimeActivity = { kind: 'command', name: method, sessionId: route?.stored }
  245. this.onActivity(activity)
  246. const response = await this.rpc(u, method, p, f => c.principal.observeEvent?.(JSON.stringify(f)), f => c.principal.observeCommand?.(JSON.stringify(f)))
  247. .finally(() => this.onActivity(activity))
  248. if (response.error && route && (method === 'prompt.submit' || method === 'session.steer')) route.active = previouslyActive
  249. if (!response.error && route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
  250. if ((opening || method === 'session.branch') && response.result) {
  251. const result = response.result as Frame
  252. const info = result.info ?? {}
  253. const runtime = String(result.session_id ?? '')
  254. const stored = String(result.stored_session_id ?? result.session_key ?? info.stored_session_id ?? p.session_id ?? runtime)
  255. if (!runtime || !stored) throw new Error('Invalid upstream session identity')
  256. const profile = String(p.profile ?? route?.profile ?? 'default')
  257. const key = this.routeKey(profile, stored)
  258. this.routeOwners.set(JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, profile, stored]), c.principal.upstreamKey)
  259. const r: Route = u.routes.get(key) ?? { profile, stored, runtime, active: false, seq: 0, observers: new Map() }
  260. u.byRuntime.delete(r.runtime)
  261. r.runtime = runtime
  262. r.active = result.running === true || info.running === true
  263. // Resume/reconnect may have restored a historical cwd or another runtime.
  264. r.workingDirectory = undefined
  265. r.cwd = typeof info.cwd === 'string' ? info.cwd : undefined
  266. u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
  267. r.observers.set(c.principal.key, c.principal)
  268. if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime)
  269. if (nativeOwner && (method === 'session.create' || method === 'session.branch')) {
  270. this.onNativeCommand(nativeOwner, profile, stored, method, p)
  271. }
  272. for (const pending of [result.pending_approval, result.pending_clarify, result.inflight?.pending_approval, result.inflight?.pending_clarify]) {
  273. if (pending?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${pending.request_id}`, runtime)
  274. }
  275. const early = u.early.get(runtime) ?? []
  276. u.early.delete(runtime)
  277. for (const e of early) { u.earlyBytes -= Buffer.byteLength(JSON.stringify(e)); this.event(u, e) }
  278. }
  279. if (method === 'session.close' && route && !response.error) {
  280. u.byRuntime.delete(route.runtime); u.routes.delete(this.routeKey(route.profile, route.stored))
  281. this.routeOwners.delete(JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, route.profile, route.stored]))
  282. }
  283. if (route && response.result && ['file.attach','image.attach_bytes','pdf.attach'].includes(method) && c.principal.key.startsWith('user:')) {
  284. this.onNativeEvent(c.principal.key.split(':')[1]!, route.profile, route.stored, { type:'attachment.staged', payload:response.result })
  285. }
  286. u.lastUsed = this.now()
  287. return this.withoutProtectedSessions(response) ?? { result: {} }
  288. }
  289. if (['session.interrupt', 'approval.respond', 'clarify.respond', 'session.steer'].includes(method)) return execute()
  290. const work = (u.tails.get(routeKey) ?? Promise.resolve()).catch(() => {}).then(execute)
  291. u.tails.set(routeKey, work)
  292. void work.finally(() => { if (u.tails.get(routeKey) === work) u.tails.delete(routeKey) }).catch(() => {})
  293. return work
  294. }
  295. private interactions = new Map<string, string>()
  296. private routeOwners = new Map<string, string>()
  297. private routeKey(profile: string, stored: string): string { return JSON.stringify([profile, stored]) }
  298. private upstream(p: RealtimePrincipal): Upstream {
  299. let u = this.upstreams.get(p.upstreamKey)
  300. if (!u) {
  301. if (this.upstreams.size >= 64) throw new HttpError(429, 'Upstream capacity reached', 'upstream_limit')
  302. u = { principal: p, pending: new Map(), routes: new Map(), byRuntime: new Map(), tails: new Map(),
  303. early: new Map(), earlyBytes: 0, attempts: 0, recovering: false, deferred: [], lastUsed: this.now() }
  304. this.upstreams.set(p.upstreamKey, u)
  305. }
  306. return u
  307. }
  308. private connect(u: Upstream): Promise<void> {
  309. if (u.connecting) return u.connecting
  310. if (u.socket?.readyState === WebSocket.OPEN && u.ready) return Promise.resolve()
  311. u.connecting = (async () => {
  312. const url = await u.principal.url('chat')
  313. if (this.closed) throw new Error('Broker closed')
  314. await new Promise<void>((resolve, reject) => {
  315. const ws = new WebSocket(url, { agent: u.principal.agent, headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: CHAT_MAX_PAYLOAD, handshakeTimeout: 15_000 })
  316. u.socket = ws
  317. const timeout = setTimeout(() => { reject(new Error('Upstream ready timeout')); ws.terminate() }, 20_000)
  318. let ready = false
  319. ws.on('message', (data, binary) => {
  320. if (u.socket !== ws || binary) return
  321. let f: Frame
  322. try { f = JSON.parse(data.toString()) } catch { ws.terminate(); return }
  323. if (f.method === 'event' && f.params?.type === 'gateway.ready') {
  324. clearTimeout(timeout); ready = true; u.ready = f; u.attempts = 0
  325. const epoch = f.params.payload?.replay_epoch
  326. const changed = u.epoch !== undefined && epoch !== u.epoch
  327. u.epoch = epoch
  328. if (u.routes.size) u.recovery = this.recover(u, changed).finally(() => { u.recovery = undefined })
  329. resolve()
  330. } else if (f.id !== undefined) {
  331. const pending = u.pending.get(String(f.id))
  332. if (pending) { clearTimeout(pending.timer); u.pending.delete(String(f.id)); pending.resolve(f) }
  333. } else if (f.method === 'event') this.event(u, f)
  334. })
  335. let alive = true
  336. ws.on('pong', () => { alive = true })
  337. u.ping = setInterval(() => { if (!alive) ws.terminate(); else if (ws.readyState === WebSocket.OPEN) { alive = false; ws.ping() } }, 25_000)
  338. u.ping.unref()
  339. ws.on('error', () => { if (!ready) reject(new Error('Upstream connection failed')) })
  340. ws.on('close', () => {
  341. clearTimeout(timeout); clearInterval(u.ping)
  342. if (u.socket !== ws) return
  343. u.socket = undefined; u.ready = undefined
  344. if (!ready) reject(new Error('Upstream disconnected'))
  345. for (const pending of u.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('Upstream disconnected; result unknown')) }
  346. u.pending.clear()
  347. if (!this.closed && this.upstreams.get(u.principal.upstreamKey) === u) {
  348. u.retry = setTimeout(() => { u.retry = undefined; void this.connect(u).catch(() => {}) }, Math.min(15_000, 500 * 2 ** Math.min(u.attempts++, 5)))
  349. u.retry.unref()
  350. }
  351. })
  352. })
  353. })().finally(() => { u.connecting = undefined })
  354. return u.connecting
  355. }
  356. private rpc(u: Upstream, method: string, params: Frame, observed?: (frame: Frame) => void, sent?: (frame: Frame) => void): Promise<Frame> {
  357. const id = randomUUID()
  358. if (u.socket?.readyState !== WebSocket.OPEN || u.socket.bufferedAmount > 8 * 1024 * 1024 || u.pending.size >= 128) throw new Error('Upstream unavailable or congested')
  359. const frame = { jsonrpc: '2.0', id, method, params }
  360. sent?.(frame)
  361. return new Promise((resolve, reject) => {
  362. const timer = setTimeout(() => { u.pending.delete(id); reject(new Error('Upstream command result unknown')) }, 120_000)
  363. timer.unref()
  364. u.pending.set(id, { timer, reject, resolve: response => { observed?.(response); resolve(response) } })
  365. u.socket!.send(JSON.stringify(frame), error => { if (error) { clearTimeout(timer); u.pending.delete(id); reject(error) } })
  366. })
  367. }
  368. private event(u: Upstream, f: Frame, replay = false): void {
  369. if (this.closed) return
  370. if (u.recovering && !replay) {
  371. if (u.deferred.length >= 512 || Buffer.byteLength(JSON.stringify([...u.deferred, f])) > 1024 * 1024) { u.deferred = []; this.reset(u, 'recovery_overflow') }
  372. else u.deferred.push(f)
  373. return
  374. }
  375. const p = f.params
  376. if (!p || typeof p.type !== 'string') return
  377. const sid = String(p.session_id ?? '')
  378. const r = u.byRuntime.get(sid)
  379. this.onActivity({ kind: 'event', name: p.type, sessionId: r?.stored })
  380. if (sid && !r) {
  381. const size = Buffer.byteLength(JSON.stringify(f))
  382. if (u.earlyBytes + size > 1024 * 1024) { u.early.clear(); u.earlyBytes = 0; this.reset(u, 'unmapped_event_overflow'); return }
  383. const list = u.early.get(sid) ?? []; list.push(f); u.early.set(sid, list); u.earlyBytes += size
  384. return
  385. }
  386. if (r) {
  387. if (Number.isSafeInteger(p.seq)) {
  388. if (p.seq <= r.seq) return
  389. if (r.seq > 0 && p.seq > r.seq + 1) this.reset(u, 'upstream_sequence_gap')
  390. r.seq = p.seq
  391. }
  392. if (p.type === 'session.info' && typeof p.payload?.cwd === 'string') {
  393. r.cwd = p.payload.cwd
  394. if (r.cwd !== r.workingDirectory?.resolved) r.workingDirectory = undefined
  395. }
  396. if (['message.start', 'approval.request', 'clarify.request'].includes(p.type)) r.active = true
  397. if (p.type === 'message.complete') r.active = false
  398. if (p.payload?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${p.payload.request_id}`, sid)
  399. while (this.interactions.size > 10_000) this.interactions.delete(this.interactions.keys().next().value!)
  400. }
  401. if (r) for (const principal of r.observers.values()) {
  402. if (principal.valid()) {
  403. principal.observeEvent?.(JSON.stringify(f))
  404. const owner = this.nativeOwner(principal)
  405. if (owner) this.onNativeEvent(owner, r.profile, r.stored, p)
  406. }
  407. }
  408. if (!r && p.type === 'sessions.changed') {
  409. const owners = new Set([...this.channels.values()].flatMap(channel => {
  410. const owner = this.nativeOwner(channel.principal)
  411. return owner && channel.principal.valid() ? [owner] : []
  412. }))
  413. for (const owner of owners) this.onNativeGlobalEvent(owner, p.type)
  414. }
  415. for (const c of this.channels.values()) {
  416. if (c.kind !== 'chat' || c.principal.upstreamKey !== u.principal.upstreamKey || !c.principal.valid()) continue
  417. if (r && !c.routes.has(this.routeKey(r.profile, r.stored))) continue
  418. if (!r && !['profiles.changed', 'sessions.changed', 'models.changed', 'pet.changed'].includes(p.type)) continue
  419. this.emit(c, 'frame', f)
  420. }
  421. if (r && (p.type === 'session.title' || p.type === 'session.title.updated')) {
  422. const owners = new Set([...r.observers.values()].flatMap(principal => {
  423. const owner = principal.valid() ? this.nativeOwner(principal) : undefined
  424. return owner ? [owner] : []
  425. }))
  426. for (const owner of owners) this.publishOwnerSessionsChanged(owner)
  427. }
  428. }
  429. publishOwnerSessionsChanged(owner: string, reason = 'session.title', profile?: string, sessionID?: string): void {
  430. if (this.closed) return
  431. this.onNativeGlobalEvent(owner, 'sessions.changed')
  432. const frame = {
  433. jsonrpc: '2.0',
  434. method: 'event',
  435. params: { type: 'sessions.changed', payload: { reason, profile, session_id: sessionID } },
  436. }
  437. for (const channel of this.channels.values()) {
  438. if (channel.kind !== 'chat' || !channel.principal.valid()
  439. || this.nativeOwner(channel.principal) !== owner) continue
  440. this.emit(channel, 'frame', frame)
  441. }
  442. }
  443. private nativeOwner(principal: RealtimePrincipal): string | undefined {
  444. const device = /^device:[^:]+(?::agent:[^:]+)?$/.test(principal.key)
  445. if (device) return principal.key
  446. const match = /^(?:user|push):([^:]+)/.exec(principal.key)
  447. return match?.[1]
  448. }
  449. private async recover(u: Upstream, changed: boolean): Promise<void> {
  450. if (u.recovering) return
  451. u.recovering = true
  452. try {
  453. for (const r of u.routes.values()) {
  454. const lastSeen = r.seq
  455. const resumed = await this.rpc(u, 'session.resume', { session_id: r.stored, profile: r.profile, close_on_disconnect: false, omit_messages: true })
  456. r.workingDirectory = undefined
  457. r.cwd = typeof resumed.result?.info?.cwd === 'string' ? resumed.result.info.cwd : undefined
  458. if (typeof resumed.result?.running === 'boolean') r.active = resumed.result.running
  459. if (!resumed.result || resumed.result.session_id !== r.runtime || changed) {
  460. if (resumed.result?.session_id) { u.byRuntime.delete(r.runtime); r.runtime = resumed.result.session_id; u.byRuntime.set(r.runtime, r) }
  461. r.seq = 0; this.reset(u, 'upstream_reset'); continue
  462. }
  463. const replay = await this.rpc(u, 'session.events.since', { session_id: r.runtime, last_seen: lastSeen })
  464. if (replay.error || replay.result?.truncated || replay.result?.epoch !== u.epoch) this.reset(u, 'upstream_replay_unavailable')
  465. else for (const p of replay.result?.events ?? []) this.event(u, { jsonrpc: '2.0', method: 'event', params: p }, true)
  466. }
  467. } catch { this.reset(u, 'upstream_recovery_failed') }
  468. finally {
  469. u.recovering = false
  470. const deferred = u.deferred; u.deferred = []
  471. for (const frame of deferred) this.event(u, frame)
  472. }
  473. }
  474. private reset(u: Upstream, reason: string): void {
  475. this.onActivity({ kind: 'reset', name: reason })
  476. for (const route of u.routes.values()) {
  477. for (const principal of route.observers.values()) {
  478. const owner = this.nativeOwner(principal)
  479. if (owner && principal.valid()) {
  480. this.onNativeEvent(owner, route.profile, route.stored, {
  481. type: 'gateway.reset', payload: { reason }, seq: route.seq,
  482. })
  483. }
  484. }
  485. }
  486. for (const c of this.channels.values()) if (c.kind === 'chat' && c.principal.upstreamKey === u.principal.upstreamKey) this.emit(c, 'reset', { reason })
  487. }
  488. publishServerIdentity(identity: import('../shared/serverIdentity.js').ServerIdentity, instanceKey: string): void {
  489. for (const channel of this.channels.values()) {
  490. if (channel.principal.valid() && channel.principal.instanceKey === instanceKey) {
  491. this.emit(channel, 'frame', { jsonrpc: '2.0', method: 'event', params: { type: 'server.identity.changed', payload: identity } })
  492. }
  493. }
  494. }
  495. private emit(c: RealtimeChannel, event: StreamEntry['event'], value: Frame): void {
  496. const publicValue = this.withoutProtectedSessions(value)
  497. if (!publicValue) return
  498. const data = JSON.stringify(publicValue)
  499. const entry: StreamEntry = { id: `${this.epoch}:${++c.seq}`, event, data, at: this.now(), bytes: Buffer.byteLength(data) }
  500. c.entries.push(entry); c.bytes += entry.bytes
  501. this.trim(c)
  502. for (const listener of [...c.listeners]) {
  503. try { listener(entry) } catch { c.listeners.delete(listener) }
  504. }
  505. }
  506. private withoutProtectedSessions(value: any): any {
  507. if (Array.isArray(value)) return value.map(v => this.withoutProtectedSessions(v)).filter(v => v !== undefined)
  508. if (!value || typeof value !== 'object') return value
  509. if (value.source === 'yaoyao_workspace' || ['id','session_id','sessionId','stored_session_id','session_key'].some(k => typeof value[k] === 'string' && this.protectedSession(value[k]))) return undefined
  510. return Object.fromEntries(Object.entries(value).map(([k,v]) => [k,this.withoutProtectedSessions(v)]).filter(([,v]) => v !== undefined))
  511. }
  512. private trim(c: RealtimeChannel): void {
  513. const own = [...this.channels.values()].filter(v => v.principal.key === c.principal.key)
  514. while (own.reduce((n, v) => n + v.bytes, 0) > 8 * 1024 * 1024 || own.reduce((n, v) => n + v.entries.length, 0) > 10_000) {
  515. const oldest = own.filter(v => v.entries.length).sort((a, b) => a.entries[0]!.at - b.entries[0]!.at)[0]
  516. if (!oldest) break
  517. this.evict(oldest)
  518. }
  519. while (c.entries[0] && c.entries[0].at < this.now() - 600_000) this.evict(c)
  520. while ([...this.channels.values()].reduce((n, v) => n + v.bytes, 0) > 128 * 1024 * 1024) {
  521. const oldest = [...this.channels.values()].filter(v => v.entries.length).sort((a, b) => a.entries[0]!.at - b.entries[0]!.at)[0]
  522. if (!oldest) break
  523. this.evict(oldest)
  524. }
  525. }
  526. private evict(c: RealtimeChannel): void {
  527. const entry = c.entries.shift()!
  528. c.bytes -= entry.bytes; c.droppedThrough = Number(entry.id.split(':').at(-1))
  529. }
  530. private sweep(): void {
  531. for (const [key, item] of this.completed) if (item.at < this.now() - 600_000) this.completed.delete(key)
  532. for (const c of this.channels.values()) {
  533. if (!c.principal.valid() || (!c.listeners.size && this.now() - c.touched > 600_000)) this.remove(c)
  534. else this.trim(c)
  535. }
  536. for (const [key, u] of this.upstreams) {
  537. if (u.pending.size || u.tails.size || [...u.routes.values()].some(r => r.active)) { u.lastUsed = this.now(); continue }
  538. if ([...this.channels.values()].some(c => c.principal.upstreamKey === key)) { u.lastUsed = this.now(); continue }
  539. if (this.now() - u.lastUsed > 300_000) {
  540. this.upstreams.delete(key); clearTimeout(u.retry); clearInterval(u.ping); u.socket?.close()
  541. for (const [route, owner] of this.routeOwners) if (owner === key) this.routeOwners.delete(route)
  542. }
  543. }
  544. }
  545. close(): void {
  546. if (this.closed) return
  547. this.closed = true; clearInterval(this.timer)
  548. for (const c of [...this.channels.values()]) this.remove(c)
  549. for (const u of this.upstreams.values()) {
  550. clearTimeout(u.retry); clearInterval(u.ping)
  551. for (const p of u.pending.values()) { clearTimeout(p.timer); p.reject(new Error('Broker stopped')) }
  552. u.pending.clear(); u.socket?.terminate()
  553. }
  554. // Pending command continuations may still update their receipts in the next microtask.
  555. void Promise.allSettled(this.commands.values()).then(() => this.receipts.close())
  556. }
  557. }