workspaceSync.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import type Koa from 'koa'
  2. import type { LocalAuthStore } from './localAuth.js'
  3. import type { WorkspaceStore } from './workspaceStore.js'
  4. import type { WorkspaceRuntime } from './workspaceRuntime.js'
  5. import { readServerIdentity } from './serverIdentity.js'
  6. import type { WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceRun } from '../shared/workspace.js'
  7. /** Bot transcripts are a local projection. Snapshot and cursor are read in the same synchronous turn. */
  8. export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime, owner: string,
  9. conversationId: string, taskId?: string, limit = 50) {
  10. const conversation = store.require<WorkspaceConversation>(owner, 'conversation', conversationId)
  11. const task = store.resolveTask(owner, conversationId, taskId)
  12. const activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
  13. const page = store.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, limit + 1, false, task?.id)
  14. return {
  15. conversation: store.conversationSummary(owner, conversation), task: task ?? null,
  16. tasks: conversation.kind === 'group' ? store.tasks(owner, conversationId) : [],
  17. assignments: task ? runtime.tasks.assignments(owner, task.id) : [],
  18. messages: page.slice(-limit), hasOlder: page.length > limit,
  19. hiddenMessageIds: store.hiddenMessageIds(owner, conversationId, task?.id),
  20. run: activeRunId ? store.require<WorkspaceRun>(owner, 'run', activeRunId) : null,
  21. interactions: store.list<WorkspaceInteraction>(owner, 'interaction')
  22. .filter(value => value.conversationId === conversationId && value.conversationTaskId === task?.id && !value.resolved),
  23. context: store.get<Record<string, unknown>>(owner, 'context', task?.id ?? conversationId) ?? null,
  24. cursor: store.cursor(owner),
  25. }
  26. }
  27. /** Durable replay followed by live updates. A slow reader reconnects from its last applied cursor. */
  28. export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: LocalAuthStore): void {
  29. const owner = auth.require(ctx).id, version = auth.pushAuthorizationVersion(owner)
  30. const cookie = ctx.get('cookie')
  31. const patches = store.messagePatchesEnabled && ctx.query.format === 'patch-v1'
  32. const res = ctx.res
  33. const raw = ctx.get('last-event-id') || String(ctx.query.after ?? '0')
  34. let cursor = /^\d+$/.test(raw) ? Number(raw) : 0
  35. let seen = cursor, closed = false, lastTextAt = 0
  36. let flushTimer: ReturnType<typeof setTimeout> | undefined
  37. const pending = new Map<string, WorkspaceEvent>()
  38. const knownMessages = new Set<string>()
  39. const valid = () => {
  40. // Koa's localUser is a request snapshot; a long-lived stream must check the
  41. // captured session itself so logout and expiry revoke this connection.
  42. try { return auth.currentFromCookieHeader(cookie)?.id === owner && auth.isUserActive(owner) && auth.pushAuthorizationVersion(owner) === version }
  43. catch { return false }
  44. }
  45. const close = () => {
  46. if (closed) return
  47. closed = true; clearInterval(heartbeat); clearTimeout(flushTimer); pending.clear()
  48. store.changes.off(owner, deliver); store.changes.off('workspace.close', close)
  49. if (!res.writableEnded) res.end()
  50. }
  51. const write = (text: string) => {
  52. if (closed || res.destroyed || res.writableEnded || !valid()) { close(); return }
  53. if (res.writableLength + Buffer.byteLength(text) > 4 * 1024 * 1024) { close(); return }
  54. res.write(text)
  55. }
  56. const emit = (event: WorkspaceEvent) => {
  57. if (event.seq <= cursor) return
  58. write(`id: ${event.seq}\nevent: workspace\ndata: ${JSON.stringify(event)}\n\n`)
  59. cursor = event.seq
  60. if (event.type.startsWith('message.')) lastTextAt = Date.now()
  61. }
  62. const flush = () => {
  63. clearTimeout(flushTimer); flushTimer = undefined
  64. for (const event of [...pending.values()].sort((a, b) => a.seq - b.seq)) emit(event)
  65. pending.clear()
  66. }
  67. const deliver = (event: WorkspaceEvent) => {
  68. if (event.seq <= seen) return
  69. seen = event.seq
  70. const wire = store.wireEvent(event), id = (event.data as { id?: string }).id
  71. if (patches) {
  72. // A new live subscription may not have this background task in its snapshot.
  73. // The first live update is a full committed baseline; replay stays revision-checked.
  74. emit(wire.type === 'message.patch' && event.type === 'message.changed' && id && !knownMessages.has(id) ? event : wire)
  75. if (id && event.type.startsWith('message.')) {
  76. knownMessages.delete(id); knownMessages.add(id)
  77. while (knownMessages.size > 64) knownMessages.delete(knownMessages.values().next().value!)
  78. }
  79. } else if (wire.type === 'message.patch' && event.type === 'message.changed' && id) {
  80. pending.set(id, structuredClone(event))
  81. if (flushTimer === undefined) flushTimer = setTimeout(flush, Math.max(0, 100 - (Date.now() - lastTextAt)))
  82. } else {
  83. // Flush older text before non-text/terminal events so event order never reverses.
  84. flush(); emit(event)
  85. }
  86. }
  87. const heartbeat = setInterval(() => { flush(); write(': heartbeat\n\n') }, 15_000)
  88. heartbeat.unref()
  89. ctx.respond = false
  90. res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' })
  91. res.flushHeaders()
  92. res.once('close', close); res.once('error', close)
  93. store.changes.on(owner, deliver); store.changes.once('workspace.close', close)
  94. const latest = store.cursor(owner)
  95. if (!Number.isSafeInteger(cursor) || cursor > latest || (!patches && store.hasPatchEvents(owner, cursor, latest))) {
  96. write('event: reset\ndata: {}\n\n'); close(); return
  97. }
  98. while (!closed && cursor < latest) {
  99. const page = store.events(owner, cursor)
  100. if (!page.length) break
  101. for (const event of page) deliver(event)
  102. }
  103. flush()
  104. write(`event: ready\ndata: ${JSON.stringify({ cursor, format: patches ? 'patch-v1' : 'full-v1', serverIdentity: readServerIdentity(store) })}\n\n`)
  105. }