openVikingSessionSync.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { createHash } from 'node:crypto'
  2. import { OpenVikingError } from '@openviking/sdk'
  3. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../shared/workspace.js'
  4. import type { OpenVikingSyncStatus } from '../shared/openVikingSync.js'
  5. import type { WorkspaceStore } from './workspaceStore.js'
  6. import type { OpenVikingService } from './openVikingService.js'
  7. import { visibleMessageText } from '../shared/messageFiles.js'
  8. type Payload = { role: string; content: string; createdAt: string }
  9. type Job = { id: string; namespace: string; owner: string; agent: string; conversation: string; task: string; status: string; attempts: number; next_at: number; error: string | null; updated_at: number }
  10. const hash = (value: string) => createHash('sha256').update(value).digest('hex')
  11. const missing = (error: unknown) => error instanceof OpenVikingError && (error.statusCode === 404 || ['NOT_FOUND', 'ENOENT'].includes(error.code))
  12. class Paused extends Error {}
  13. /** A durable transcript mirror. Memory synthesis remains owned by WorkspaceMemorySynthesis. */
  14. export class OpenVikingSessionSync {
  15. private timer?: ReturnType<typeof setInterval>
  16. private active = false
  17. private closed = false
  18. private scanningNamespace?: string
  19. constructor(readonly store: WorkspaceStore, readonly service: OpenVikingService, private readonly userActive: (owner: string) => boolean = () => true) {
  20. store.db.exec(`
  21. CREATE TABLE IF NOT EXISTS openviking_sync_state(namespace TEXT PRIMARY KEY, scan_row INTEGER NOT NULL DEFAULT 0, scanned INTEGER NOT NULL DEFAULT 0, event_seq INTEGER NOT NULL);
  22. CREATE TABLE IF NOT EXISTS openviking_sync_jobs(id TEXT PRIMARY KEY, namespace TEXT NOT NULL, owner TEXT NOT NULL, agent TEXT NOT NULL, conversation TEXT NOT NULL, task TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, synced_messages INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT, updated_at INTEGER NOT NULL);
  23. CREATE INDEX IF NOT EXISTS openviking_sync_pending ON openviking_sync_jobs(namespace,status,next_at);
  24. CREATE TABLE IF NOT EXISTS openviking_sync_messages(session TEXT NOT NULL, message TEXT NOT NULL, seq INTEGER NOT NULL, ready INTEGER NOT NULL, payload TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(session,message));
  25. `)
  26. }
  27. start(): void {
  28. if (this.timer || this.closed) return
  29. this.timer = setInterval(() => { void this.tick() }, 1000)
  30. this.timer.unref()
  31. void this.tick()
  32. }
  33. close(): void { this.closed = true; clearInterval(this.timer) }
  34. status(): OpenVikingSyncStatus {
  35. const namespace = this.service.namespace
  36. const rows = this.store.db.prepare('SELECT status,COUNT(*) AS n FROM openviking_sync_jobs WHERE namespace=? GROUP BY status').all(namespace)
  37. const count = (status: string) => Number(rows.find(row => row.status === status)?.n ?? 0)
  38. const state = this.store.db.prepare('SELECT scanned FROM openviking_sync_state WHERE namespace=?').get(namespace)
  39. const error = this.store.db.prepare("SELECT error FROM openviking_sync_jobs WHERE namespace=? AND error IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get(namespace)
  40. return { enabled: this.service.enabled, backfilling: this.service.enabled && !state?.scanned, pending: count('pending'), complete: count('complete'), failed: count('failed'), ...(error?.error ? { lastError: String(error.error) } : {}) }
  41. }
  42. retry(): OpenVikingSyncStatus {
  43. this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',attempts=0,next_at=0,error=NULL WHERE namespace=? AND status='failed'").run(this.service.namespace)
  44. return this.status()
  45. }
  46. private current(namespace: string): void {
  47. if (this.closed || !this.service.enabled || this.service.namespace !== namespace) throw new Paused()
  48. }
  49. private job(owner: string, agentId: string, message: WorkspaceMessage): Job | undefined {
  50. const agent = this.store.get<WorkspaceAgent>(owner, 'agent', agentId)
  51. if (!agent || agent.temporaryGoalId || !this.store.get(owner, 'conversation', message.conversationId)) return
  52. if (message.conversationTaskId && !this.store.get(owner, 'conversation-task', message.conversationTaskId)) return
  53. const namespace = this.service.namespace, task = message.conversationTaskId ?? ''
  54. const id = `yy-${hash(JSON.stringify([namespace, owner, agentId, message.conversationId, task]))}`
  55. this.store.db.prepare('INSERT OR IGNORE INTO openviking_sync_jobs(id,namespace,owner,agent,conversation,task,updated_at) VALUES(?,?,?,?,?,?,?)')
  56. .run(id, namespace, owner, agentId, message.conversationId, task, Date.now())
  57. return this.store.db.prepare('SELECT * FROM openviking_sync_jobs WHERE id=?').get(id) as unknown as Job
  58. }
  59. private enqueue(owner: string, agentId: string, message: WorkspaceMessage, role?: string): void {
  60. if (message.visible === false) return
  61. const job = this.job(owner, agentId, message)
  62. if (!job) return
  63. const shown = this.store.messageForDisplay(owner, message)
  64. const content = shown.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(shown.content) : shown.content)
  65. const attachments = shown.attachments.map(file => ({ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size }))
  66. const text = content + (attachments.length ? `\n\n附件:${JSON.stringify(attachments)}` : '')
  67. const ready = message.role !== 'assistant' || ['complete', 'failed', 'interrupted'].includes(message.status)
  68. const payload = ready && text.trim() ? JSON.stringify({ role: role ?? message.role, content: text, createdAt: new Date(message.createdAt).toISOString() } satisfies Payload) : ''
  69. const fingerprint = hash(payload)
  70. const old = this.store.db.prepare('SELECT fingerprint,ready,seq FROM openviking_sync_messages WHERE session=? AND message=?').get(job.id, message.id)
  71. if (old?.fingerprint === fingerprint && old.ready === Number(ready) && old.seq === message.seq) return
  72. this.store.db.prepare('INSERT INTO openviking_sync_messages VALUES(?,?,?,?,?,?) ON CONFLICT(session,message) DO UPDATE SET seq=excluded.seq,ready=excluded.ready,payload=excluded.payload,fingerprint=excluded.fingerprint')
  73. .run(job.id, message.id, message.seq, Number(ready), payload, fingerprint)
  74. this.store.db.prepare("UPDATE openviking_sync_jobs SET status=CASE WHEN status='failed' THEN status ELSE 'pending' END,updated_at=? WHERE id=?").run(Date.now(), job.id)
  75. }
  76. private ingest(owner: string, message: WorkspaceMessage): void {
  77. if (owner.startsWith('_') || message.visible === false) return
  78. if (message.role === 'assistant' && message.agentId) {
  79. const run = message.runId ? this.store.get<WorkspaceRun>(owner, 'run', message.runId) : undefined
  80. const source = run && this.store.get<WorkspaceMessage>(owner, 'message', run.messageId)
  81. if (source && source.conversationId === message.conversationId && source.conversationTaskId === message.conversationTaskId) {
  82. if (source.role === 'user') this.enqueue(owner, message.agentId, source)
  83. else if (source.peerMessageId) this.enqueue(owner, message.agentId, source, 'user')
  84. }
  85. this.enqueue(owner, message.agentId, message)
  86. } else if (message.role === 'user') {
  87. const conversation = this.store.get<WorkspaceConversation>(owner, 'conversation', message.conversationId)
  88. if (conversation?.kind === 'direct' && conversation.memberIds[0]) this.enqueue(owner, conversation.memberIds[0], message)
  89. } else if (message.peerMessageId) {
  90. const peer = this.store.get<{ fromAgentId: string; toAgentId?: string; originConversationId: string; originTaskId?: string; conversationId: string; taskId?: string }>(owner, 'peer-message', message.peerMessageId)
  91. if (!peer) return
  92. const incoming = !!message.runId && message.conversationId === peer.conversationId && message.conversationTaskId === peer.taskId
  93. const outgoing = !message.runId && message.conversationId === peer.originConversationId && message.conversationTaskId === peer.originTaskId
  94. if (incoming && peer.toAgentId) this.enqueue(owner, peer.toAgentId, message, 'user')
  95. if (outgoing) this.enqueue(owner, peer.fromAgentId, message, 'assistant')
  96. }
  97. }
  98. private discover(namespace: string): boolean {
  99. const cursor = Number(this.store.db.prepare('SELECT COALESCE(MAX(seq),0) AS n FROM workspace_events').get()!.n)
  100. this.store.db.prepare('INSERT OR IGNORE INTO openviking_sync_state(namespace,event_seq) VALUES(?,?)').run(namespace, cursor)
  101. const state = this.store.db.prepare('SELECT * FROM openviking_sync_state WHERE namespace=?').get(namespace)!
  102. this.store.atomic(() => {
  103. if (!state.scanned) {
  104. const rows = this.store.db.prepare("SELECT rowid,owner,data FROM workspace_entities WHERE kind='message' AND rowid>? ORDER BY rowid LIMIT 250").all(Number(state.scan_row))
  105. for (const row of rows) this.ingest(String(row.owner), JSON.parse(String(row.data)))
  106. this.store.db.prepare('UPDATE openviking_sync_state SET scan_row=?,scanned=? WHERE namespace=?')
  107. .run(Number(rows.at(-1)?.rowid ?? state.scan_row), Number(rows.length < 250), namespace)
  108. }
  109. const events = this.store.db.prepare('SELECT * FROM workspace_events WHERE seq>? ORDER BY seq LIMIT 250').all(Number(state.event_seq))
  110. for (const event of events) {
  111. const owner = String(event.owner), data = JSON.parse(String(event.data))
  112. if (event.type === 'message.changed' || event.type === 'message.patch') {
  113. const message = this.store.get<WorkspaceMessage>(owner, 'message', data.id ?? data.messageId)
  114. if (message) this.ingest(owner, message)
  115. }
  116. if (['conversation.deleted', 'agent.deleted', 'conversation.task.deleted', 'task.deleted'].includes(String(event.type))) {
  117. this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',attempts=0,next_at=0 WHERE namespace=? AND owner=? AND (conversation=? OR agent=? OR task=?)")
  118. .run(namespace, owner, data.id, data.id, data.id)
  119. }
  120. }
  121. this.store.db.prepare('UPDATE openviking_sync_state SET event_seq=? WHERE namespace=?').run(Number(events.at(-1)?.seq ?? state.event_seq), namespace)
  122. })
  123. const next = this.store.db.prepare('SELECT * FROM openviking_sync_state WHERE namespace=?').get(namespace)!
  124. return !!next.scanned && Number(next.event_seq) >= cursor
  125. }
  126. private exists(job: Job): boolean {
  127. return !!this.store.get(job.owner, 'agent', job.agent) && !!this.store.get(job.owner, 'conversation', job.conversation)
  128. && (!job.task || !!this.store.get(job.owner, 'conversation-task', job.task))
  129. }
  130. private async sync(job: Job): Promise<void> {
  131. const check = () => this.current(job.namespace)
  132. check()
  133. if (!this.userActive(job.owner)) { this.store.db.prepare('UPDATE openviking_sync_jobs SET next_at=? WHERE id=?').run(Date.now() + 30_000, job.id); return }
  134. if (!this.exists(job)) {
  135. if (!this.store.get(job.owner, 'agent', job.agent) && this.service.binding(job.owner, job.agent)?.pendingRemoval) {
  136. await this.service.removeUser(job.owner, { id: job.agent }); check()
  137. }
  138. if (this.service.binding(job.owner, job.agent)?.status === 'active') {
  139. try { await this.service.userClient(job.owner, job.agent).deleteSession(job.id) } catch (error) { if (!missing(error)) throw error }
  140. }
  141. check()
  142. this.store.db.prepare("UPDATE openviking_sync_jobs SET status='deleted',error=NULL WHERE id=?").run(job.id)
  143. this.store.db.prepare('DELETE FROM openviking_sync_messages WHERE session=?').run(job.id)
  144. return
  145. }
  146. const agent = this.store.require<WorkspaceAgent>(job.owner, 'agent', job.agent)
  147. await this.service.ensureUser(job.owner, agent); check()
  148. if (!this.exists(job)) return
  149. const client = this.service.userClient(job.owner, job.agent)
  150. let info: Record<string, unknown>
  151. try { info = await client.getSession(job.id) } catch (error) {
  152. if (!missing(error)) throw error
  153. check()
  154. if (!this.exists(job)) return
  155. await client.createSession({ sessionId: job.id, memoryPolicy: { self: { enabled: false }, peer: { enabled: false }, working_memory: { enabled: false } } })
  156. info = await client.getSession(job.id)
  157. }
  158. check()
  159. // Older servers have no auto-commit policy; newer servers may inherit one.
  160. if (info.auto_commit_policy) { await this.service.disableSessionAutoCommit(job.owner, job.agent, job.id); check() }
  161. const uri = typeof info.uri === 'string' ? info.uri : `viking://session/${job.id}`
  162. let raw = ''
  163. try { raw = await client.read(`${uri}/messages.jsonl`) } catch (error) { if (!missing(error) || Number(info.message_count ?? 0) > 0) throw error }
  164. check()
  165. const remote: Payload[] = raw.split('\n').filter(line => line.trim()).map(line => {
  166. const row = JSON.parse(line)
  167. return { role: row.role, content: Array.isArray(row.parts) ? row.parts.filter((part: any) => part.type === 'text').map((part: any) => part.text).join('') : row.content ?? '', createdAt: new Date(row.created_at).toISOString() }
  168. })
  169. const rows = this.store.db.prepare('SELECT * FROM openviking_sync_messages WHERE session=? ORDER BY seq,message').all(job.id)
  170. const desired: Payload[] = []
  171. let unfinished = false
  172. for (const row of rows) {
  173. if (!row.ready) { unfinished = true; break }
  174. if (row.payload) desired.push(JSON.parse(String(row.payload)))
  175. }
  176. for (let i = 0; i < remote.length; i++) {
  177. if (!desired[i] || JSON.stringify(remote[i]) !== JSON.stringify(desired[i])) throw new Error('OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话')
  178. }
  179. if (!this.exists(job)) return
  180. const batch = desired.slice(remote.length, remote.length + 100)
  181. if (batch.length) { await client.batchAddMessages(job.id, batch); check() }
  182. if (!this.exists(job)) return
  183. const complete = !unfinished && remote.length + batch.length === desired.length
  184. this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,synced_messages=?,attempts=0,next_at=?,error=NULL,updated_at=? WHERE id=?')
  185. .run(complete ? 'complete' : 'pending', remote.length + batch.length, unfinished && !batch.length ? Date.now() + 5000 : 0, Date.now(), job.id)
  186. }
  187. async tick(): Promise<void> {
  188. if (this.active || this.closed) return
  189. if (!this.service.enabled) { this.scanningNamespace = undefined; return }
  190. this.active = true
  191. const namespace = this.service.namespace
  192. try {
  193. // Reconcile retained history on every service start / re-enable as well as
  194. // consuming durable events, including databases whose events were pruned.
  195. if (this.scanningNamespace !== namespace) {
  196. this.store.db.prepare('UPDATE openviking_sync_state SET scan_row=0,scanned=0 WHERE namespace=?').run(namespace)
  197. this.scanningNamespace = namespace
  198. }
  199. if (!this.discover(namespace)) return
  200. // Deletions also recover when an old database has already pruned its events.
  201. this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',next_at=0 WHERE namespace=? AND status='complete' AND (NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='agent' AND e.id=openviking_sync_jobs.agent) OR NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation' AND e.id=openviking_sync_jobs.conversation) OR (task!='' AND NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation-task' AND e.id=openviking_sync_jobs.task)))").run(namespace)
  202. const jobs = this.store.db.prepare("SELECT * FROM openviking_sync_jobs WHERE namespace=? AND status='pending' AND next_at<=? ORDER BY updated_at LIMIT 4").all(namespace, Date.now()) as unknown as Job[]
  203. for (const job of jobs) {
  204. try { await this.sync(job) } catch (error) {
  205. if (error instanceof Paused || this.closed) break
  206. const attempts = job.attempts + 1
  207. // Remote errors can contain request details; expose only a short error message.
  208. const message = error instanceof Error ? error.message.slice(0, 400) : 'OpenViking 会话同步失败'
  209. this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,attempts=?,next_at=?,error=?,updated_at=? WHERE id=?')
  210. .run(attempts >= 3 ? 'failed' : 'pending', attempts, Date.now() + 2000 * 2 ** attempts, message, Date.now(), job.id)
  211. }
  212. }
  213. } catch (error) {
  214. if (!this.closed && !(error instanceof Paused)) console.error('OpenViking 会话同步队列异常', error instanceof Error ? error.message : '未知错误')
  215. } finally { this.active = false }
  216. }
  217. }