dsh-bailinghub 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { setTimeout as delay } from 'node:timers/promises'
7
+
8
+ const SCHEMAS = new Set(['bailing.agent-conversation-outbox.v1', 'bailing.agent-conversation-outbox.v2'])
9
+ const MAX_BYTES = 32 * 1024 * 1024
10
+ const LOCK_TIMEOUT_MS = 1_000
11
+ const SECRET_KEYS = new Set(['token', 'accesstoken', 'refreshtoken', 'password', 'clientsecret', 'authorization', 'credential', 'credentials'])
12
+
13
+ function storeError(code = 'ARCHIVE_STORE_UNAVAILABLE') {
14
+ const error = new Error(code === 'ARCHIVE_STORE_CONFLICT'
15
+ ? 'The conversation archive changed or is being saved. Read it again before retrying.'
16
+ : 'The conversation archive store is unavailable. The archive was not safely saved.')
17
+ error.code = code
18
+ return error
19
+ }
20
+
21
+ function safeError(error) {
22
+ return ['ARCHIVE_STORE_CONFLICT', 'ARCHIVE_STORE_UNAVAILABLE'].includes(error?.code)
23
+ ? error : storeError()
24
+ }
25
+
26
+ function sessionKey(sessionId) {
27
+ if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
28
+ return createHash('sha256').update(sessionId).digest('hex')
29
+ }
30
+
31
+ // The archive coordinator owns the event schema. This boundary additionally refuses
32
+ // non-JSON values and credential fields so stores cannot become credential caches.
33
+ function snapshotJson(value, seen = new Set()) {
34
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
35
+ if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
36
+ if (!value || typeof value !== 'object' || seen.has(value)) throw storeError()
37
+ const array = Array.isArray(value)
38
+ if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
39
+ seen.add(value)
40
+ try {
41
+ const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
42
+ if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
43
+ const copy = array ? [] : {}
44
+ for (const key of entries) {
45
+ const property = Object.getOwnPropertyDescriptor(value, key)
46
+ if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
47
+ SECRET_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
48
+ copy[key] = snapshotJson(property.value, seen)
49
+ }
50
+ return copy
51
+ } finally {
52
+ seen.delete(value)
53
+ }
54
+ }
55
+
56
+ function snapshotRecord(sessionId, record) {
57
+ const copy = snapshotJson(record)
58
+ if (!copy || Array.isArray(copy) || !SCHEMAS.has(copy.schema) || copy.sessionId !== sessionId ||
59
+ !Number.isSafeInteger(copy.revision) || copy.revision < 1) throw storeError()
60
+ if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
61
+ return copy
62
+ }
63
+
64
+ function nextRecord(sessionId, record, expectedRevision) {
65
+ sessionKey(sessionId)
66
+ if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
67
+ const copy = snapshotRecord(sessionId, record)
68
+ if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
69
+ return copy
70
+ }
71
+
72
+ function assertRevision(current, expectedRevision) {
73
+ if ((current?.revision ?? null) !== expectedRevision) throw storeError('ARCHIVE_STORE_CONFLICT')
74
+ }
75
+
76
+ function assertOwned(stat, kind) {
77
+ if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
78
+ (kind === 'file' && stat.nlink !== 1) ||
79
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
80
+ (process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
81
+ }
82
+
83
+ function sameFile(a, b) {
84
+ return a.dev === b.dev && a.ino === b.ino
85
+ }
86
+
87
+ function defaultDirectory() {
88
+ const configured = process.env.DSH_HOME
89
+ const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
90
+ const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
91
+ return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'conversation-outbox')
92
+ }
93
+
94
+ async function ensureDirectory(directory, create) {
95
+ if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
96
+ let stat
97
+ try {
98
+ stat = await lstat(directory)
99
+ } catch (error) {
100
+ if (!create && error.code === 'ENOENT') return false
101
+ throw error
102
+ }
103
+ assertOwned(stat, 'directory')
104
+ return true
105
+ }
106
+
107
+ async function readRecord(path, sessionId) {
108
+ let before
109
+ try {
110
+ before = await lstat(path)
111
+ } catch (error) {
112
+ if (error.code === 'ENOENT') return null
113
+ throw error
114
+ }
115
+ assertOwned(before, 'file')
116
+ if (before.size > MAX_BYTES) throw storeError()
117
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
118
+ try {
119
+ const opened = await file.stat()
120
+ assertOwned(opened, 'file')
121
+ if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
122
+ // Bound the read even if an abnormal writer grows the file after fstat.
123
+ const bytes = Buffer.alloc(MAX_BYTES + 1)
124
+ let length = 0
125
+ while (length < bytes.length) {
126
+ const result = await file.read(bytes, length, bytes.length - length, length)
127
+ if (!result.bytesRead) break
128
+ length += result.bytesRead
129
+ }
130
+ if (length > MAX_BYTES) throw storeError()
131
+ return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
132
+ } finally {
133
+ await file.close()
134
+ }
135
+ }
136
+
137
+ async function acquireLock(path) {
138
+ const deadline = performance.now() + LOCK_TIMEOUT_MS
139
+ while (true) {
140
+ let file
141
+ let stat
142
+ try {
143
+ file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
144
+ stat = await file.stat()
145
+ await file.chmod(0o600)
146
+ assertOwned(await file.stat(), 'file')
147
+ return { file, stat }
148
+ } catch (error) {
149
+ if (file) {
150
+ if (stat) await releaseLock(path, { file, stat })
151
+ else await file.close().catch(() => {})
152
+ }
153
+ if (error.code !== 'EEXIST') throw error
154
+ try {
155
+ assertOwned(await lstat(path), 'file')
156
+ } catch (inspectionError) {
157
+ if (inspectionError.code === 'ENOENT') continue
158
+ throw inspectionError
159
+ }
160
+ if (performance.now() >= deadline) throw storeError('ARCHIVE_STORE_CONFLICT')
161
+ // Never infer staleness or remove another process's lock.
162
+ await delay(20)
163
+ }
164
+ }
165
+ }
166
+
167
+ async function releaseLock(path, lock) {
168
+ try {
169
+ const current = await lstat(path)
170
+ if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
171
+ await unlink(path)
172
+ } finally {
173
+ await lock.file.close()
174
+ }
175
+ }
176
+
177
+ async function syncDirectory(directory) {
178
+ // Windows does not expose fsync on directory handles through Node.
179
+ if (process.platform === 'win32') return
180
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
181
+ try {
182
+ assertOwned(await handle.stat(), 'directory')
183
+ await handle.sync()
184
+ } finally {
185
+ await handle.close()
186
+ }
187
+ }
188
+
189
+ /** Private transcript outboxes. No user directory is touched until load/save. */
190
+ export function createFileConversationArchiveStore({ directory } = {}) {
191
+ if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
192
+ const root = directory === undefined ? defaultDirectory() : resolve(directory)
193
+ return {
194
+ async load(sessionId) {
195
+ try {
196
+ const key = sessionKey(sessionId)
197
+ if (!await ensureDirectory(root, false)) return null
198
+ return await readRecord(join(root, `${key}.json`), sessionId)
199
+ } catch (error) {
200
+ throw safeError(error)
201
+ }
202
+ },
203
+ async save(sessionId, record, expectedRevision) {
204
+ let lock
205
+ let temporaryPath
206
+ const key = sessionKey(sessionId)
207
+ const path = join(root, `${key}.json`)
208
+ const lockPath = join(root, `${key}.lock`)
209
+ try {
210
+ const copy = nextRecord(sessionId, record, expectedRevision)
211
+ await ensureDirectory(root, true)
212
+ lock = await acquireLock(lockPath)
213
+ assertRevision(await readRecord(path, sessionId), expectedRevision)
214
+ temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
215
+ const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
216
+ try {
217
+ await temporary.chmod(0o600)
218
+ assertOwned(await temporary.stat(), 'file')
219
+ await temporary.writeFile(JSON.stringify(copy), 'utf8')
220
+ await temporary.sync()
221
+ } finally {
222
+ await temporary.close()
223
+ }
224
+ await rename(temporaryPath, path)
225
+ temporaryPath = undefined
226
+ await syncDirectory(root)
227
+ return snapshotRecord(sessionId, copy)
228
+ } catch (error) {
229
+ throw safeError(error)
230
+ } finally {
231
+ try {
232
+ if (temporaryPath) await unlink(temporaryPath).catch((error) => {
233
+ if (error.code !== 'ENOENT') throw storeError()
234
+ })
235
+ } finally {
236
+ if (lock) {
237
+ try {
238
+ await releaseLock(lockPath, lock)
239
+ } catch {
240
+ throw storeError()
241
+ }
242
+ }
243
+ }
244
+ }
245
+ },
246
+ }
247
+ }
248
+
249
+ /** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
250
+ export function createMemoryConversationArchiveStore() {
251
+ const records = new Map()
252
+ return {
253
+ async load(sessionId) {
254
+ sessionKey(sessionId)
255
+ const record = records.get(sessionId)
256
+ return record ? snapshotRecord(sessionId, record) : null
257
+ },
258
+ async save(sessionId, record, expectedRevision) {
259
+ const copy = nextRecord(sessionId, record, expectedRevision)
260
+ assertRevision(records.get(sessionId), expectedRevision)
261
+ records.set(sessionId, copy)
262
+ return snapshotRecord(sessionId, copy)
263
+ },
264
+ }
265
+ }
@@ -0,0 +1,236 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { createFileConversationArchiveStore } from './conversation-archive-store.js'
3
+
4
+ const SCHEMA = 'bailing.agent-conversation-outbox.v1'
5
+ const TARGET_SCHEMA = 'bailing.agent-conversation-outbox.v2'
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
7
+ const KEY = /^conn_[0-9a-f]{32}$/
8
+ const KINDS = new Set(['turn_start', 'user_message', 'assistant_message', 'run_link', 'turn_end'])
9
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
10
+ const fail = (code = 'ARCHIVE_INVALID') => Object.assign(new Error(`Conversation archive unavailable (${code})`), { code })
11
+
12
+ function eventPayload(value) {
13
+ const copy = structuredClone(value)
14
+ if (!copy || !KINDS.has(copy.kind) || typeof copy.client_turn_id !== 'string' || !copy.client_turn_id || copy.client_turn_id.length > 256 ||
15
+ Object.keys(copy).some((key) => !['kind', 'client_turn_id', 'content', 'run_id', 'member_session_id', 'status'].includes(key))) throw fail()
16
+ const message = ['user_message', 'assistant_message'].includes(copy.kind)
17
+ // Preserve over-limit visible text locally. The SDK rejects a message above
18
+ // its remote limit; silently truncating it would fabricate a full transcript.
19
+ if (message ? typeof copy.content !== 'string' : Object.hasOwn(copy, 'content')) throw fail('ARCHIVE_MESSAGE_INVALID')
20
+ if (copy.kind === 'run_link' ? !UUID.test(copy.run_id ?? '') || !UUID.test(copy.member_session_id ?? '')
21
+ : Object.hasOwn(copy, 'run_id') || Object.hasOwn(copy, 'member_session_id')) throw fail()
22
+ if (copy.kind === 'turn_end' ? !['completed', 'failed', 'cancelled'].includes(copy.status) : Object.hasOwn(copy, 'status')) throw fail()
23
+ return copy
24
+ }
25
+
26
+ function contextSnapshot(value) {
27
+ const copy = structuredClone(value)
28
+ const crossSystem = copy?.binding && !Object.hasOwn(copy.binding, 'clientAppId')
29
+ if (typeof copy?.clientConversationId !== 'string' || !copy.clientConversationId || copy.clientConversationId.length > 256 ||
30
+ !copy.binding || typeof copy.binding.hubUrl !== 'string' ||
31
+ (!crossSystem && (typeof copy.binding.clientAppId !== 'string' || typeof copy.binding.workspace !== 'string')) ||
32
+ !Array.isArray(copy.members) || !copy.members.length || copy.members.length > 64 ||
33
+ Object.keys(copy).some((key) => !['clientConversationId', 'binding', 'members'].includes(key)) ||
34
+ Object.keys(copy.binding).some((key) => !(crossSystem ? ['hubUrl'] : ['hubUrl', 'clientAppId', 'workspace']).includes(key))) throw fail('ARCHIVE_MEMBERS_INVALID')
35
+ const keys = new Set()
36
+ const identities = new Set()
37
+ for (const member of copy.members) {
38
+ if (!KEY.test(member?.connectionKey ?? '') || keys.has(member.connectionKey) || !UUID.test(member.expectedSessionId ?? '') ||
39
+ identities.has(member.expectedSessionId.toLowerCase()) ||
40
+ (crossSystem ? member.hubUrl !== copy.binding.hubUrl || !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(member.clientAppId ?? '') || !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(member.workspace ?? '')
41
+ : member.workspace !== copy.binding.workspace) || typeof member.label !== 'string' || member.label.length > 128 ||
42
+ Object.keys(member).some((key) => !['connectionKey', 'workspace', 'expectedSessionId', 'label', ...(crossSystem ? ['hubUrl', 'clientAppId'] : [])].includes(key))) throw fail('ARCHIVE_MEMBERS_INVALID')
43
+ keys.add(member.connectionKey)
44
+ identities.add(member.expectedSessionId.toLowerCase())
45
+ }
46
+ return copy
47
+ }
48
+
49
+ function validateRecord(record, sessionId, context) {
50
+ if (!record || record.schema !== (Object.hasOwn(context.binding, 'clientAppId') ? SCHEMA : TARGET_SCHEMA) || record.sessionId !== sessionId || !Number.isSafeInteger(record.revision) || record.revision < 1 ||
51
+ !UUID.test(record.clientArchiveId ?? '') || digest(contextSnapshot(record.context)) !== digest(context) ||
52
+ !Number.isSafeInteger(record.acknowledged) || record.acknowledged < 0 || !Array.isArray(record.events) || record.acknowledged > record.events.length ||
53
+ record.conversationId !== null && !UUID.test(record.conversationId ?? '') ||
54
+ Object.keys(record).some((key) => !['schema', 'sessionId', 'revision', 'clientArchiveId', 'context', 'acknowledged', 'conversationId', 'events', 'syncState'].includes(key)) ||
55
+ !['pending', 'synced', 'unsupported'].includes(record.syncState)) throw fail('ARCHIVE_BINDING_CHANGED')
56
+ const sources = new Set()
57
+ const ids = new Set()
58
+ const turns = new Map()
59
+ for (const [index, item] of record.events.entries()) {
60
+ const { event_id: id, sequence, ...payload } = item?.event ?? {}
61
+ const checked = eventPayload(payload)
62
+ if (Object.keys(item).some((key) => !['source', 'hash', 'event'].includes(key)) || !/^[a-f0-9]{64}$/.test(item.source ?? '') || sources.has(item.source) ||
63
+ !UUID.test(id ?? '') || ids.has(id) || sequence !== index + 1 || item.hash !== digest(checked)) throw fail('ARCHIVE_INVALID')
64
+ if (checked.kind === 'run_link' && !context.members.some((member) => member.expectedSessionId === checked.member_session_id)) throw fail('ARCHIVE_MEMBERS_INVALID')
65
+ const phase = turns.get(checked.client_turn_id)
66
+ if (checked.kind === 'turn_start' ? phase !== undefined
67
+ : checked.kind === 'run_link' ? !['started', 'ended'].includes(phase) : phase !== 'started') throw fail('ARCHIVE_EVENT_ORDER')
68
+ if (checked.kind === 'turn_start') turns.set(checked.client_turn_id, 'started')
69
+ if (checked.kind === 'turn_end') turns.set(checked.client_turn_id, 'ended')
70
+ sources.add(item.source)
71
+ ids.add(id)
72
+ }
73
+ return structuredClone(record)
74
+ }
75
+
76
+ /** One durable visible transcript, independent from per-authorization business runs. */
77
+ export class ConversationOutbox {
78
+ constructor({ store = createFileConversationArchiveStore() } = {}) {
79
+ if (typeof store?.load !== 'function' || typeof store?.save !== 'function') throw fail('ARCHIVE_STORE_INVALID')
80
+ this.store = store
81
+ this.entries = new Map()
82
+ }
83
+
84
+ serialize(entry, action) {
85
+ const promise = (entry.queue ?? Promise.resolve()).then(action)
86
+ entry.queue = promise.catch(() => {})
87
+ return promise
88
+ }
89
+
90
+ async load(entry) {
91
+ if (entry.record) return entry.record
92
+ const saved = await this.store.load(entry.sessionId)
93
+ if (saved) entry.record = validateRecord(saved, entry.sessionId, entry.context)
94
+ else {
95
+ const record = {
96
+ schema: Object.hasOwn(entry.context.binding, 'clientAppId') ? SCHEMA : TARGET_SCHEMA,
97
+ sessionId: entry.sessionId, revision: 1, clientArchiveId: randomUUID(), context: entry.context,
98
+ acknowledged: 0, conversationId: null, events: [], syncState: 'pending',
99
+ }
100
+ await this.store.save(entry.sessionId, record, null)
101
+ entry.record = structuredClone(record)
102
+ }
103
+ return entry.record
104
+ }
105
+
106
+ open(sessionId, value) {
107
+ const context = contextSnapshot(value)
108
+ let entry = this.entries.get(sessionId)
109
+ if (entry && digest(entry.context) !== digest(context)) throw fail('ARCHIVE_BINDING_CHANGED')
110
+ if (!entry) {
111
+ entry = { sessionId, context, pending: [], status: 'pending' }
112
+ this.entries.set(sessionId, entry)
113
+ }
114
+ return this.serialize(entry, async () => {
115
+ try { await this.load(entry); entry.status = entry.pending.length ? 'storage_error' : entry.record.syncState; return this.status(sessionId) }
116
+ catch (error) { entry.status = 'storage_error'; throw error }
117
+ })
118
+ }
119
+
120
+ async save(entry, values) {
121
+ const previous = entry.record
122
+ const record = validateRecord({ ...previous, ...values, revision: previous.revision + 1 }, entry.sessionId, entry.context)
123
+ await this.store.save(entry.sessionId, record, previous.revision)
124
+ entry.record = record
125
+ }
126
+
127
+ append(sessionId, source, payload) {
128
+ const entry = this.entries.get(sessionId)
129
+ if (!entry) throw fail('ARCHIVE_NOT_OPEN')
130
+ // Capture before awaits; never retain mutable host messages or model chunks.
131
+ const item = { source: digest(String(source)), payload: eventPayload(payload) }
132
+ entry.pending.push(item)
133
+ return this.serialize(entry, () => this.persistPending(entry))
134
+ }
135
+
136
+ async persistPending(entry) {
137
+ try {
138
+ await this.load(entry)
139
+ const count = entry.pending.length
140
+ const events = structuredClone(entry.record.events)
141
+ for (const pending of entry.pending.slice(0, count)) {
142
+ const hash = digest(pending.payload)
143
+ const existing = events.find((event) => event.source === pending.source)
144
+ if (existing) {
145
+ if (existing.hash !== hash) throw fail('ARCHIVE_EVENT_CONFLICT')
146
+ continue
147
+ }
148
+ events.push({ source: pending.source, hash, event: { event_id: randomUUID(), sequence: events.length + 1, ...pending.payload } })
149
+ }
150
+ if (events.length !== entry.record.events.length) await this.save(entry, { events, syncState: 'pending' })
151
+ entry.pending.splice(0, count)
152
+ entry.status = entry.record.syncState
153
+ return this.status(entry.sessionId)
154
+ } catch (error) { entry.status = 'storage_error'; throw error }
155
+ }
156
+
157
+ status(sessionId) {
158
+ const entry = this.entries.get(sessionId)
159
+ if (!entry) return { state: 'inactive', pendingEvents: 0 }
160
+ return {
161
+ state: entry.status, pendingEvents: (entry.record?.events.length ?? 0) - (entry.record?.acknowledged ?? 0),
162
+ unsavedEvents: entry.pending.length, acknowledgedSequence: entry.record?.acknowledged ?? 0,
163
+ ...(entry.record?.conversationId ? { conversationId: entry.record.conversationId } : {}),
164
+ }
165
+ }
166
+
167
+ coverage(sessionId, expected) {
168
+ const events = this.entries.get(sessionId)?.record?.events ?? []
169
+ if (!Array.isArray(expected) || expected.length === 0 && events.length > 0) return { coverage: 'unverified' }
170
+ const available = new Map()
171
+ const signature = (event) => digest(['user_message', 'assistant_message'].includes(event.kind)
172
+ ? { kind: event.kind, content: event.content }
173
+ : { kind: event.kind, client_turn_id: event.client_turn_id, ...(event.status ? { status: event.status } : {}) })
174
+ for (const { event } of events) {
175
+ if (event.kind === 'run_link') continue
176
+ const key = signature(event)
177
+ available.set(key, (available.get(key) ?? 0) + 1)
178
+ }
179
+ let missing = 0
180
+ for (const event of expected) {
181
+ const key = signature(event)
182
+ const count = available.get(key) ?? 0
183
+ if (count) available.set(key, count - 1)
184
+ else missing += 1
185
+ }
186
+ return missing ? { coverage: 'incomplete', missingVisibleEvents: missing } : { coverage: 'available_host_history_checked' }
187
+ }
188
+
189
+ sync(sessionId, send) {
190
+ const entry = this.entries.get(sessionId)
191
+ if (!entry) return Promise.resolve(this.status(sessionId))
192
+ if (entry.syncing) return entry.syncing
193
+ entry.syncing = (async () => {
194
+ while (true) {
195
+ const snapshot = await this.serialize(entry, async () => {
196
+ await this.persistPending(entry)
197
+ return structuredClone(entry.record)
198
+ })
199
+ try {
200
+ const ack = await send({
201
+ clientArchiveId: snapshot.clientArchiveId, clientConversationId: snapshot.context.clientConversationId,
202
+ events: snapshot.events.slice(snapshot.acknowledged).map((item) => item.event),
203
+ }, { members: structuredClone(snapshot.context.members) })
204
+ if (ack?.schema !== 'bailing.agent-conversation-audit-ack.v1' || !UUID.test(ack.conversation_id ?? '') ||
205
+ !Number.isSafeInteger(ack.last_sequence) || ack.last_sequence < snapshot.acknowledged || ack.last_sequence > snapshot.events.length ||
206
+ snapshot.conversationId && ack.conversation_id !== snapshot.conversationId) throw fail('ARCHIVE_ACK_INVALID')
207
+ await this.serialize(entry, async () => {
208
+ await this.save(entry, {
209
+ acknowledged: ack.last_sequence, conversationId: ack.conversation_id,
210
+ syncState: ack.last_sequence === entry.record.events.length ? 'synced' : 'pending',
211
+ })
212
+ entry.status = entry.pending.length ? 'storage_error' : entry.record.syncState
213
+ })
214
+ // A final reply may arrive while an earlier batch is in flight. Drain
215
+ // newly captured events after success, without looping on failures.
216
+ await this.serialize(entry, () => this.persistPending(entry))
217
+ if (ack.last_sequence === snapshot.events.length && entry.record.events.length > snapshot.events.length) continue
218
+ } catch (error) {
219
+ await this.serialize(entry, async () => {
220
+ const state = error?.status === 404 || error?.statusCode === 404 || error?.code === 'ARCHIVE_UNSUPPORTED' ? 'unsupported' : 'pending'
221
+ try { await this.save(entry, { syncState: state }); entry.status = entry.pending.length ? 'storage_error' : state }
222
+ catch { entry.status = 'storage_error' }
223
+ })
224
+ }
225
+ return this.status(sessionId)
226
+ }
227
+ })().catch(() => { entry.status = 'storage_error'; return this.status(sessionId) }).finally(() => { entry.syncing = undefined })
228
+ return entry.syncing
229
+ }
230
+
231
+ async drain(sessionId) {
232
+ const entry = this.entries.get(sessionId)
233
+ await entry?.queue
234
+ return this.status(sessionId)
235
+ }
236
+ }
package/lib/index.js CHANGED
@@ -33,6 +33,7 @@ export function createAgentClientPlugin(options = {}) {
33
33
  config,
34
34
  getTransport,
35
35
  options.recovery,
36
+ { scopeStore: options.scopeStore, archiveStore: options.archiveStore },
36
37
  ).install()
37
38
  ctx.provide('bailingHubAgentClient', runtime)
38
39
  },
@@ -44,6 +45,8 @@ export function apply(ctx, config) {
44
45
  }
45
46
 
46
47
  export { BailingHubAgentClientRuntime } from './runtime.js'
48
+ export { createFileSessionScopeStore, createMemorySessionScopeStore } from './session-scope-store.js'
49
+ export { createFileConversationArchiveStore, createMemoryConversationArchiveStore } from './conversation-archive-store.js'
47
50
  export {
48
51
  DEFAULT_SDK_MODULE,
49
52
  REQUIRED_METHODS,