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.
- package/CHANGELOG.md +80 -1
- package/PRIVACY.md +140 -7
- package/README.md +139 -198
- package/SECURITY.md +128 -3
- package/docs/AGENT_CLIENT_CONTRACT.md +433 -23
- package/docs/COMPATIBILITY.md +96 -4
- package/docs/CROSS_SYSTEM_CONVERSATIONS.md +136 -0
- package/docs/GETTING_STARTED.md +120 -60
- package/docs/GETTING_STARTED.zh-CN.md +101 -51
- package/docs/MIGRATION_VNEXT.md +142 -17
- package/docs/PROJECT_BOUNDARIES.md +6 -2
- package/docs/README.zh-CN.md +104 -173
- package/docs/RELEASE_NOTES_v0.5.0.md +79 -0
- package/lib/authorizations.js +76 -0
- package/lib/conversation-archive-store.js +265 -0
- package/lib/conversation-outbox.js +236 -0
- package/lib/index.js +3 -0
- package/lib/runtime.js +830 -49
- package/lib/session-scope-store.js +265 -0
- package/lib/session-scope.js +393 -0
- package/lib/subject-display.js +39 -0
- package/lib/system-info.js +76 -0
- package/lib/transport.js +26 -1
- package/package.json +4 -2
|
@@ -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,
|