dsh-bailinghub 0.5.0 → 0.7.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 +79 -0
- package/PRIVACY.md +16 -5
- package/README.md +21 -3
- package/SECURITY.md +10 -2
- package/docs/AGENT_CLIENT_CONTRACT.md +27 -7
- package/docs/CAPABILITY_FEEDBACK.md +199 -0
- package/docs/COMPATIBILITY.md +19 -6
- package/docs/CROSS_SYSTEM_CONVERSATIONS.md +4 -2
- package/docs/GENERATED_ARTIFACTS.md +77 -0
- package/docs/GETTING_STARTED.md +5 -5
- package/docs/GETTING_STARTED.zh-CN.md +5 -5
- package/docs/INVOCATION_RECOVERY.md +182 -0
- package/docs/MIGRATION_VNEXT.md +8 -0
- package/docs/README.zh-CN.md +37 -3
- package/docs/RELEASE_NOTES_v0.6.0.en.md +39 -0
- package/docs/RELEASE_NOTES_v0.6.0.md +39 -0
- package/docs/RELEASE_NOTES_v0.7.0.en.md +15 -0
- package/docs/RELEASE_NOTES_v0.7.0.md +15 -0
- package/docs/SESSION_TOOL_REUSE.md +213 -0
- package/docs/TASK_CONTROL.md +183 -0
- package/docs/UPGRADE_v0.6.0.en.md +71 -0
- package/docs/UPGRADE_v0.6.0.md +73 -0
- package/docs/UPGRADE_v0.7.0.en.md +15 -0
- package/docs/UPGRADE_v0.7.0.md +15 -0
- package/docs/USAGE_SERVICE.md +96 -0
- package/lib/artifact-store.js +56 -0
- package/lib/artifacts.js +132 -0
- package/lib/capability-feedback.js +138 -0
- package/lib/conversation-outbox.js +24 -0
- package/lib/index.js +16 -1
- package/lib/invocation-journal.js +228 -0
- package/lib/invocation-store.js +275 -0
- package/lib/model-gateway-transport.js +246 -0
- package/lib/runtime.js +951 -146
- package/lib/session-scope.js +47 -5
- package/lib/session-task-store.js +275 -0
- package/lib/session-task.js +237 -0
- package/lib/session-tool-cache.js +168 -0
- package/lib/session-tools.js +240 -0
- package/lib/session-usage-store.js +280 -0
- package/lib/tool-catalog.js +38 -0
- package/lib/transport.js +19 -0
- package/lib/usage-llm-stream.js +157 -0
- package/lib/usage-model-transport.js +206 -0
- package/lib/usage-provider-stream.js +82 -0
- package/package.json +18 -3
package/lib/session-scope.js
CHANGED
|
@@ -105,9 +105,10 @@ export class SessionScopeCoordinator {
|
|
|
105
105
|
// A restored business snapshot is not a current authorization proof.
|
|
106
106
|
// This in-memory fence never rewrites its identities or CAS revision.
|
|
107
107
|
entry.retryRequired = Boolean(entry.record?.authorizations.length)
|
|
108
|
-
} catch {
|
|
108
|
+
} catch (error) {
|
|
109
109
|
entry.blocked = true
|
|
110
110
|
entry.loadFailed = true
|
|
111
|
+
entry.failureCode = error?.code === 'INVALID_SCOPE_SNAPSHOT' ? error.code : 'SCOPE_STORE_UNAVAILABLE'
|
|
111
112
|
}
|
|
112
113
|
entry.loaded = true
|
|
113
114
|
})()
|
|
@@ -161,8 +162,12 @@ export class SessionScopeCoordinator {
|
|
|
161
162
|
try {
|
|
162
163
|
const record = this.validate(await this.store.load(entry.sessionId), entry.sessionId)
|
|
163
164
|
if (JSON.stringify(record) !== JSON.stringify(original)) throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
164
|
-
} catch {
|
|
165
|
-
if (entry.record === original && entry.generation === generation)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (entry.record === original && entry.generation === generation) {
|
|
167
|
+
entry.blocked = true
|
|
168
|
+
entry.failureCode = ['SESSION_SCOPE_CONFLICT', 'SCOPE_STORE_CONFLICT', 'INVALID_SCOPE_SNAPSHOT'].includes(error?.code)
|
|
169
|
+
? error.code : 'SCOPE_STORE_UNAVAILABLE'
|
|
170
|
+
}
|
|
166
171
|
}
|
|
167
172
|
}
|
|
168
173
|
|
|
@@ -360,6 +365,40 @@ export class SessionScopeCoordinator {
|
|
|
360
365
|
|
|
361
366
|
snapshot(sessionId) { return structuredClone(this.entry(sessionId).record) }
|
|
362
367
|
|
|
368
|
+
// Read-only host projection: drafts are valid selections, not dispatch grants.
|
|
369
|
+
// Retain object identity and generation across every await, including A -> B -> A.
|
|
370
|
+
async verifiedSelection(sessionId) {
|
|
371
|
+
const entry = await this.load(this.entry(sessionId))
|
|
372
|
+
const record = entry.record
|
|
373
|
+
const generation = entry.generation
|
|
374
|
+
const settleValidation = async () => {
|
|
375
|
+
// Another read can revalidate the same original group while this reader
|
|
376
|
+
// waits for local I/O. Join that proof; never mistake its temporary closed
|
|
377
|
+
// gate for a changed identity, nor ignore its eventual revocation result.
|
|
378
|
+
while (entry.record === record && entry.generation === generation && this.isValidating(entry)) await entry.validation.promise
|
|
379
|
+
}
|
|
380
|
+
const assertCurrent = () => {
|
|
381
|
+
if (entry.record !== record || entry.generation !== generation) throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
382
|
+
if (entry.pending || entry.blocked || entry.retryRequired || this.isValidating(entry) || record?.state === 'needs_selection') {
|
|
383
|
+
throw scopeError(entry.failureCode ?? 'SESSION_SCOPE_UNAVAILABLE')
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
await this.get(sessionId)
|
|
387
|
+
await settleValidation()
|
|
388
|
+
// An identity failure increments generation; preserve its authoritative code.
|
|
389
|
+
if (entry.record === record && entry.generation !== generation && (entry.blocked || entry.retryRequired) && !entry.pending) {
|
|
390
|
+
throw scopeError(entry.failureCode ?? 'SESSION_SCOPE_UNAVAILABLE')
|
|
391
|
+
}
|
|
392
|
+
assertCurrent()
|
|
393
|
+
return { record: structuredClone(record), assertCurrent, check: async () => {
|
|
394
|
+
await settleValidation()
|
|
395
|
+
assertCurrent()
|
|
396
|
+
if (record) await this.checkStored(entry)
|
|
397
|
+
await settleValidation()
|
|
398
|
+
assertCurrent()
|
|
399
|
+
} }
|
|
400
|
+
}
|
|
401
|
+
|
|
363
402
|
subjectDisplay(sessionId, connectionKey) {
|
|
364
403
|
return structuredClone(this.entry(sessionId).subjectDisplays?.get(connectionKey) ?? projectSubjectDisplay(null))
|
|
365
404
|
}
|
|
@@ -386,8 +425,11 @@ export class SessionScopeCoordinator {
|
|
|
386
425
|
invalidate(sessionId, error) {
|
|
387
426
|
const entry = this.entry(sessionId)
|
|
388
427
|
entry.generation += 1
|
|
389
|
-
|
|
390
|
-
|
|
428
|
+
const definitive = !error || definitiveAuthorizationFailure(error)
|
|
429
|
+
entry.failureCode = definitive ? error?.code === 'CROSS_HUB_SCOPE_UNSUPPORTED' ? error.code : 'AUTHORIZATION_CHANGED'
|
|
430
|
+
: error?.name === 'AgentAuthHttpError' && (error.statusCode === 429 || error.statusCode >= 500) ? 'agent_transport_unavailable'
|
|
431
|
+
: error?.publicCode ?? error?.code ?? 'AUTHORIZATION_UNVERIFIED'
|
|
432
|
+
if (definitive) entry.blocked = true
|
|
391
433
|
else entry.retryRequired = true
|
|
392
434
|
}
|
|
393
435
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
import { isProxy } from 'node:util/types'
|
|
8
|
+
|
|
9
|
+
const SCHEMAS = new Set(['bailing.agent-session-task.v1'])
|
|
10
|
+
const RECORD_KEYS = new Set(['schema', 'sessionId', 'revision', 'entries'])
|
|
11
|
+
const MAX_BYTES = 8 * 1024 * 1024
|
|
12
|
+
const LOCK_TIMEOUT_MS = 1_000
|
|
13
|
+
const PRIVATE_KEYS = new Set([
|
|
14
|
+
'token', 'tokens', 'accesstoken', 'refreshtoken', 'password', 'clientsecret',
|
|
15
|
+
'authorization', 'credential', 'credentials', 'secret', 'apikey',
|
|
16
|
+
'args', 'arguments', 'content', 'contents', 'body', 'prompt', 'messages',
|
|
17
|
+
])
|
|
18
|
+
const MAX_DEPTH = 128
|
|
19
|
+
|
|
20
|
+
function storeError(code = 'TASK_STORE_UNAVAILABLE') {
|
|
21
|
+
const error = new Error(code === 'TASK_STORE_CONFLICT'
|
|
22
|
+
? 'The task binding changed or is being saved. Read it again before retrying.'
|
|
23
|
+
: 'The task binding store is unavailable. Recovery metadata was not safely confirmed.')
|
|
24
|
+
error.code = code
|
|
25
|
+
return error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeError(error) {
|
|
29
|
+
return ['TASK_STORE_CONFLICT', 'TASK_STORE_UNAVAILABLE'].includes(error?.code)
|
|
30
|
+
? error : storeError()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sessionKey(sessionId) {
|
|
34
|
+
if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
|
|
35
|
+
return createHash('sha256').update(sessionId).digest('hex')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The coordinator validates each original execution binding. This boundary accepts
|
|
39
|
+
// only JSON metadata and refuses credential or business payload fields. Tool names
|
|
40
|
+
// are values, so a tool named "content" or "arguments" remains valid metadata.
|
|
41
|
+
function snapshotJson(value, seen = new Set(), depth = 0) {
|
|
42
|
+
if (depth > MAX_DEPTH) throw storeError()
|
|
43
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
44
|
+
if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
|
|
45
|
+
if (!value || typeof value !== 'object' || seen.has(value) || isProxy(value)) throw storeError()
|
|
46
|
+
const array = Array.isArray(value)
|
|
47
|
+
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
|
|
48
|
+
seen.add(value)
|
|
49
|
+
try {
|
|
50
|
+
const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
|
|
51
|
+
if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
|
|
52
|
+
const copy = array ? [] : {}
|
|
53
|
+
for (const key of entries) {
|
|
54
|
+
const property = Object.getOwnPropertyDescriptor(value, key)
|
|
55
|
+
if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
|
|
56
|
+
PRIVATE_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
|
|
57
|
+
copy[key] = snapshotJson(property.value, seen, depth + 1)
|
|
58
|
+
}
|
|
59
|
+
return copy
|
|
60
|
+
} finally {
|
|
61
|
+
seen.delete(value)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshotRecord(sessionId, record) {
|
|
66
|
+
const copy = snapshotJson(record)
|
|
67
|
+
if (!copy || Array.isArray(copy) || !SCHEMAS.has(copy.schema) || copy.sessionId !== sessionId ||
|
|
68
|
+
!Number.isSafeInteger(copy.revision) || copy.revision < 1 || !Array.isArray(copy.entries) ||
|
|
69
|
+
Object.keys(copy).some((key) => !RECORD_KEYS.has(key))) throw storeError()
|
|
70
|
+
if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
|
|
71
|
+
return copy
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nextRecord(sessionId, record, expectedRevision) {
|
|
75
|
+
sessionKey(sessionId)
|
|
76
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
|
|
77
|
+
const copy = snapshotRecord(sessionId, record)
|
|
78
|
+
if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
|
|
79
|
+
return copy
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertRevision(current, expectedRevision) {
|
|
83
|
+
if ((current?.revision ?? null) !== expectedRevision) throw storeError('TASK_STORE_CONFLICT')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertOwned(stat, kind) {
|
|
87
|
+
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
|
|
88
|
+
(kind === 'file' && stat.nlink !== 1) ||
|
|
89
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
90
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sameFile(a, b) {
|
|
94
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultDirectory() {
|
|
98
|
+
const configured = process.env.DSH_HOME
|
|
99
|
+
const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
|
|
100
|
+
const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
|
|
101
|
+
return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'session-tasks')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function ensureDirectory(directory, create) {
|
|
105
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
106
|
+
let stat
|
|
107
|
+
try {
|
|
108
|
+
stat = await lstat(directory)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (!create && error.code === 'ENOENT') return false
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
assertOwned(stat, 'directory')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readRecord(path, sessionId) {
|
|
118
|
+
let before
|
|
119
|
+
try {
|
|
120
|
+
before = await lstat(path)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code === 'ENOENT') return null
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
assertOwned(before, 'file')
|
|
126
|
+
if (before.size > MAX_BYTES) throw storeError()
|
|
127
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
128
|
+
try {
|
|
129
|
+
const opened = await file.stat()
|
|
130
|
+
assertOwned(opened, 'file')
|
|
131
|
+
if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
|
|
132
|
+
// Bound the read even if an abnormal writer grows the file after fstat.
|
|
133
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1)
|
|
134
|
+
let length = 0
|
|
135
|
+
while (length < bytes.length) {
|
|
136
|
+
const result = await file.read(bytes, length, bytes.length - length, length)
|
|
137
|
+
if (!result.bytesRead) break
|
|
138
|
+
length += result.bytesRead
|
|
139
|
+
}
|
|
140
|
+
if (length > MAX_BYTES) throw storeError()
|
|
141
|
+
return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
|
|
142
|
+
} finally {
|
|
143
|
+
await file.close()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function acquireLock(path) {
|
|
148
|
+
const deadline = performance.now() + LOCK_TIMEOUT_MS
|
|
149
|
+
while (true) {
|
|
150
|
+
let file
|
|
151
|
+
let stat
|
|
152
|
+
try {
|
|
153
|
+
file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
154
|
+
stat = await file.stat()
|
|
155
|
+
await file.chmod(0o600)
|
|
156
|
+
assertOwned(await file.stat(), 'file')
|
|
157
|
+
return { file, stat }
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (file) {
|
|
160
|
+
if (stat) await releaseLock(path, { file, stat })
|
|
161
|
+
else await file.close().catch(() => {})
|
|
162
|
+
}
|
|
163
|
+
if (error.code !== 'EEXIST') throw error
|
|
164
|
+
try {
|
|
165
|
+
assertOwned(await lstat(path), 'file')
|
|
166
|
+
} catch (inspectionError) {
|
|
167
|
+
if (inspectionError.code === 'ENOENT') continue
|
|
168
|
+
throw inspectionError
|
|
169
|
+
}
|
|
170
|
+
if (performance.now() >= deadline) throw storeError('TASK_STORE_CONFLICT')
|
|
171
|
+
// Never infer staleness or remove another process's lock.
|
|
172
|
+
await delay(20)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function releaseLock(path, lock) {
|
|
178
|
+
try {
|
|
179
|
+
const current = await lstat(path)
|
|
180
|
+
if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
|
|
181
|
+
await unlink(path)
|
|
182
|
+
} finally {
|
|
183
|
+
await lock.file.close()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function syncDirectory(directory) {
|
|
188
|
+
// Windows does not expose fsync on directory handles through Node.
|
|
189
|
+
if (process.platform === 'win32') return
|
|
190
|
+
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
191
|
+
try {
|
|
192
|
+
assertOwned(await handle.stat(), 'directory')
|
|
193
|
+
await handle.sync()
|
|
194
|
+
} finally {
|
|
195
|
+
await handle.close()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Durable execution metadata only. No user directory is touched until load/save. */
|
|
200
|
+
export function createFileSessionTaskStore({ directory } = {}) {
|
|
201
|
+
if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
|
|
202
|
+
const root = directory === undefined ? defaultDirectory() : resolve(directory)
|
|
203
|
+
return {
|
|
204
|
+
async load(sessionId) {
|
|
205
|
+
try {
|
|
206
|
+
const key = sessionKey(sessionId)
|
|
207
|
+
if (!await ensureDirectory(root, false)) return null
|
|
208
|
+
return await readRecord(join(root, `${key}.json`), sessionId)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw safeError(error)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
async save(sessionId, record, expectedRevision) {
|
|
214
|
+
let lock
|
|
215
|
+
let temporaryPath
|
|
216
|
+
const key = sessionKey(sessionId)
|
|
217
|
+
const path = join(root, `${key}.json`)
|
|
218
|
+
const lockPath = join(root, `${key}.lock`)
|
|
219
|
+
try {
|
|
220
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
221
|
+
await ensureDirectory(root, true)
|
|
222
|
+
lock = await acquireLock(lockPath)
|
|
223
|
+
assertRevision(await readRecord(path, sessionId), expectedRevision)
|
|
224
|
+
temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
|
|
225
|
+
const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
226
|
+
try {
|
|
227
|
+
await temporary.chmod(0o600)
|
|
228
|
+
assertOwned(await temporary.stat(), 'file')
|
|
229
|
+
await temporary.writeFile(JSON.stringify(copy), 'utf8')
|
|
230
|
+
await temporary.sync()
|
|
231
|
+
} finally {
|
|
232
|
+
await temporary.close()
|
|
233
|
+
}
|
|
234
|
+
await rename(temporaryPath, path)
|
|
235
|
+
temporaryPath = undefined
|
|
236
|
+
await syncDirectory(root)
|
|
237
|
+
return snapshotRecord(sessionId, copy)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw safeError(error)
|
|
240
|
+
} finally {
|
|
241
|
+
try {
|
|
242
|
+
if (temporaryPath) await unlink(temporaryPath).catch((error) => {
|
|
243
|
+
if (error.code !== 'ENOENT') throw storeError()
|
|
244
|
+
})
|
|
245
|
+
} finally {
|
|
246
|
+
if (lock) {
|
|
247
|
+
try {
|
|
248
|
+
await releaseLock(lockPath, lock)
|
|
249
|
+
} catch {
|
|
250
|
+
throw storeError()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
|
|
260
|
+
export function createMemorySessionTaskStore() {
|
|
261
|
+
const records = new Map()
|
|
262
|
+
return {
|
|
263
|
+
async load(sessionId) {
|
|
264
|
+
sessionKey(sessionId)
|
|
265
|
+
const record = records.get(sessionId)
|
|
266
|
+
return record ? snapshotRecord(sessionId, record) : null
|
|
267
|
+
},
|
|
268
|
+
async save(sessionId, record, expectedRevision) {
|
|
269
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
270
|
+
assertRevision(records.get(sessionId), expectedRevision)
|
|
271
|
+
records.set(sessionId, copy)
|
|
272
|
+
return snapshotRecord(sessionId, copy)
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { memberBinding } from './session-scope.js'
|
|
2
|
+
import { metadataHash, scopeFingerprint } from './invocation-journal.js'
|
|
3
|
+
|
|
4
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
|
|
5
|
+
const HASH = /^[a-f0-9]{64}$/
|
|
6
|
+
const SCHEMA = 'bailing.agent-session-task.v1'
|
|
7
|
+
const TASK_STATES = new Set(['active', 'paused', 'blocked', 'cancelled'])
|
|
8
|
+
const copy = value => structuredClone(value)
|
|
9
|
+
|
|
10
|
+
export function taskError(code) {
|
|
11
|
+
return Object.assign(new Error('The original task binding cannot currently permit this operation.'), {
|
|
12
|
+
publicCode: code,
|
|
13
|
+
feedback: { schema: 'bailing.agent-feedback.v1', origin: 'dsh', operation: 'task_control', code,
|
|
14
|
+
category: code.startsWith('TASK_STORE_') ? 'storage_error' : code === 'TASK_UNSUPPORTED' ? 'unsupported' : 'task_control',
|
|
15
|
+
dispatch: 'not_dispatched', next_action: code.startsWith('TASK_STORE_') ? 'restore_task' : code === 'TASK_UNSUPPORTED' ? 'check_compatibility' : 'inspect_task' },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Host-only immutable association. It cannot create, control, or enlarge a task. */
|
|
20
|
+
export class SessionTaskCoordinator {
|
|
21
|
+
constructor({ store, scopes, getTransport, conversationId }) {
|
|
22
|
+
if (store != null && (typeof store.load !== 'function' || typeof store.save !== 'function')) throw new TypeError('A task store with load/save is required')
|
|
23
|
+
Object.assign(this, { store, scopes, getTransport, conversationId })
|
|
24
|
+
this.entries = new Map()
|
|
25
|
+
this.queues = new Map()
|
|
26
|
+
this.supportedBindings = new Set()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
entry(id) {
|
|
30
|
+
if (!this.entries.has(id)) this.entries.set(id, { record: null, pending: null, snapshot: null, failure: null })
|
|
31
|
+
return this.entries.get(id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
serial(id, operation) {
|
|
35
|
+
const result = (this.queues.get(id) ?? Promise.resolve()).then(operation)
|
|
36
|
+
this.queues.set(id, result.catch(() => {}))
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
validate(raw, id) {
|
|
41
|
+
if (!raw || raw.schema !== SCHEMA || raw.sessionId !== id || raw.revision !== 1 ||
|
|
42
|
+
Object.keys(raw).sort().join(',') !== 'entries,revision,schema,sessionId' || !Array.isArray(raw.entries) || raw.entries.length !== 1) throw taskError('TASK_RECORD_INVALID')
|
|
43
|
+
const value = raw.entries[0]
|
|
44
|
+
if (!value || Object.keys(value).sort().join(',') !== 'members,scopeHash,taskBinding' || !HASH.test(value.scopeHash ?? '') ||
|
|
45
|
+
value.taskBinding?.schema_version !== 'bailing.agent-task-binding.v1' || !UUID.test(value.taskBinding.task_id ?? '') ||
|
|
46
|
+
!HASH.test(value.taskBinding.scope_hash ?? '') || Object.keys(value.taskBinding).sort().join(',') !== 'schema_version,scope_hash,task_id' ||
|
|
47
|
+
!Array.isArray(value.members) || !value.members.length || value.members.length > 64 ||
|
|
48
|
+
value.members.some(member => !member || Object.keys(member).sort().join(',') !== 'allowed_tools,client_app_id,client_conversation_id,connectionKey,session_id,workspace' ||
|
|
49
|
+
!/^conn_[a-f0-9]{32}$/.test(member.connectionKey ?? '') || !UUID.test(member.session_id ?? '') ||
|
|
50
|
+
typeof member.client_app_id !== 'string' || typeof member.workspace !== 'string' || member.client_conversation_id !== this.conversationId(id) ||
|
|
51
|
+
!Array.isArray(member.allowed_tools) || member.allowed_tools.some(tool => typeof tool !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(tool)))) throw taskError('TASK_RECORD_INVALID')
|
|
52
|
+
return copy(raw)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async read(id) {
|
|
56
|
+
const entry = this.entry(id)
|
|
57
|
+
if (!this.store) return null
|
|
58
|
+
let raw
|
|
59
|
+
try { raw = await this.store.load(id) } catch (error) { throw taskError(error?.code === 'TASK_STORE_CONFLICT' ? error.code : 'TASK_STORE_UNAVAILABLE') }
|
|
60
|
+
const record = raw === null ? null : this.validate(raw, id)
|
|
61
|
+
if (entry.record && metadataHash(entry.record) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
|
|
62
|
+
if (record && entry.pending && metadataHash(record) !== metadataHash(entry.pending)) throw taskError('TASK_BINDING_CONFLICT')
|
|
63
|
+
entry.record = record
|
|
64
|
+
return record
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async verify(id, taskId, selection) {
|
|
68
|
+
if (!selection) {
|
|
69
|
+
const scopeView = await this.scopes.get(id)
|
|
70
|
+
if (!taskId && ['chat', 'unselected'].includes(scopeView.mode)) return { supported: undefined, mode: 'optional', scope: this.scopes.snapshot(id) }
|
|
71
|
+
await this.scopes.assertUsable(id)
|
|
72
|
+
} else selection.assertCurrent()
|
|
73
|
+
const scope = selection?.record ?? this.scopes.snapshot(id)
|
|
74
|
+
const transport = await this.getTransport()
|
|
75
|
+
if (typeof transport.getTaskControlCapabilities !== 'function') {
|
|
76
|
+
if (taskId) throw taskError('TASK_UNSUPPORTED')
|
|
77
|
+
return { supported: false, mode: 'optional', scope }
|
|
78
|
+
}
|
|
79
|
+
const members = []
|
|
80
|
+
let snapshot
|
|
81
|
+
let required = false
|
|
82
|
+
let supported = true
|
|
83
|
+
for (const member of scope.authorizations) {
|
|
84
|
+
selection?.assertCurrent()
|
|
85
|
+
const expectedBinding = { ...memberBinding(scope, member), sessionId: member.sessionId }
|
|
86
|
+
const options = { connectionKey: member.connectionKey, workspace: member.workspace, expectedBinding,
|
|
87
|
+
clientConversationId: this.conversationId(id) }
|
|
88
|
+
// Only protocol support is stable for this exact original binding. The
|
|
89
|
+
// unbound/optional path must still read current enrollment on every call.
|
|
90
|
+
const protocolKey = metadataHash({ connectionKey: member.connectionKey, expectedBinding })
|
|
91
|
+
const supportedProtocol = Boolean(taskId && this.supportedBindings.has(protocolKey))
|
|
92
|
+
let caps
|
|
93
|
+
try { if (!supportedProtocol) caps = await transport.getTaskControlCapabilities(options) }
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!taskId && error.publicCode === 'TASK_UNSUPPORTED') caps = { supported: false, mode: 'optional' }
|
|
96
|
+
else throw error
|
|
97
|
+
}
|
|
98
|
+
if (!supportedProtocol) {
|
|
99
|
+
if (!caps || typeof caps.supported !== 'boolean' || !['optional', 'required'].includes(caps.mode)) throw taskError('TASK_UNAVAILABLE')
|
|
100
|
+
required ||= caps.mode === 'required'
|
|
101
|
+
supported &&= caps.supported
|
|
102
|
+
}
|
|
103
|
+
if (!taskId) continue
|
|
104
|
+
if ((!supportedProtocol && (!caps.supported || caps.inspect_invocation !== true)) || typeof transport.getTask !== 'function' ||
|
|
105
|
+
typeof transport.inspectInvocation !== 'function') throw taskError('TASK_UNSUPPORTED')
|
|
106
|
+
this.supportedBindings.add(protocolKey)
|
|
107
|
+
const task = await transport.getTask(taskId, options)
|
|
108
|
+
const own = task?.member
|
|
109
|
+
if (task?.schema_version !== 'bailing.agent-task.v1' || task.task_id !== taskId || !HASH.test(task.scope_hash ?? '') ||
|
|
110
|
+
task.member_count !== scope.authorizations.length || !TASK_STATES.has(task.state) || !Number.isSafeInteger(task.revision) ||
|
|
111
|
+
task.metering !== 'write_invocation' || task.snapshot_is_dispatch_permission !== false ||
|
|
112
|
+
!own || own.session_id !== member.sessionId || own.client_app_id !== expectedBinding.clientAppId ||
|
|
113
|
+
own.workspace !== member.workspace || own.client_conversation_id !== options.clientConversationId ||
|
|
114
|
+
!Array.isArray(own.allowed_tools) || new Set(own.allowed_tools).size !== own.allowed_tools.length) throw taskError('TASK_MEMBER_MISMATCH')
|
|
115
|
+
if (snapshot && (task.scope_hash !== snapshot.scope_hash || metadataHash(task.policy) !== metadataHash(snapshot.policy))) throw taskError('TASK_BINDING_CONFLICT')
|
|
116
|
+
members.push({ connectionKey: member.connectionKey, session_id: own.session_id, client_app_id: own.client_app_id,
|
|
117
|
+
workspace: own.workspace, client_conversation_id: own.client_conversation_id, allowed_tools: [...own.allowed_tools].sort() })
|
|
118
|
+
if (!snapshot || task.ledger_sequence >= snapshot.ledger_sequence) snapshot = task
|
|
119
|
+
}
|
|
120
|
+
if (selection) await selection.check()
|
|
121
|
+
else await this.scopes.assertUsable(id)
|
|
122
|
+
return { supported, mode: taskId ? undefined : required ? 'required' : 'optional', scope, snapshot, members: members.sort((a, b) => a.connectionKey.localeCompare(b.connectionKey)) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
coordinates(id) {
|
|
126
|
+
return this.serial(id, async () => {
|
|
127
|
+
const selection = await this.scopes.verifiedSelection(id)
|
|
128
|
+
const scope = selection.record
|
|
129
|
+
if (!scope?.authorizations.length) return { state: 'inactive', availability: 'not_checked',
|
|
130
|
+
reason: scope ? 'SESSION_SCOPE_CHAT_ONLY' : 'SESSION_SCOPE_UNSELECTED' }
|
|
131
|
+
if (new Set(scope.authorizations.map(member => member.sessionId.toLowerCase())).size !== scope.authorizations.length) throw taskError('TASK_MEMBER_MISMATCH')
|
|
132
|
+
const entry = this.entry(id)
|
|
133
|
+
if (entry.failure?.startsWith('TASK_STORE_')) throw taskError(entry.failure)
|
|
134
|
+
const record = await this.read(id)
|
|
135
|
+
if (entry.pending || (!record && entry.intendedId)) throw taskError(entry.failure ?? 'TASK_REQUIRED')
|
|
136
|
+
const verified = await this.verify(id, record?.entries[0].taskBinding.task_id, selection)
|
|
137
|
+
if (!verified.supported) throw taskError('TASK_UNSUPPORTED')
|
|
138
|
+
if (record) {
|
|
139
|
+
const expected = { scopeHash: scopeFingerprint(scope), members: verified.members,
|
|
140
|
+
taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: verified.snapshot.task_id, scope_hash: verified.snapshot.scope_hash } }
|
|
141
|
+
if (metadataHash(record.entries[0]) !== metadataHash(expected)) throw taskError('TASK_BINDING_CONFLICT')
|
|
142
|
+
}
|
|
143
|
+
// Catch a concurrent durable binding change; never write or repair sidecars here.
|
|
144
|
+
if (metadataHash(await this.read(id)) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
|
|
145
|
+
await selection.check()
|
|
146
|
+
return { state: 'ready', availability: 'supported', sessionId: id,
|
|
147
|
+
clientConversationId: this.conversationId(id), scopeRevision: scope.revision, scopeLocked: scope.locked,
|
|
148
|
+
members: scope.authorizations.map(member => ({ connectionKey: member.connectionKey,
|
|
149
|
+
agentSessionId: member.sessionId, clientAppId: memberBinding(scope, member).clientAppId, workspace: member.workspace })) }
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async save(id, record) {
|
|
154
|
+
try {
|
|
155
|
+
const saved = await this.store.save(id, copy(record), null)
|
|
156
|
+
if (metadataHash(saved) !== metadataHash(record)) throw taskError('TASK_STORE_UNAVAILABLE')
|
|
157
|
+
} catch (error) { throw taskError(error?.code === 'TASK_STORE_CONFLICT' ? error.code : 'TASK_STORE_UNAVAILABLE') }
|
|
158
|
+
this.entry(id).record = copy(record)
|
|
159
|
+
this.entry(id).pending = null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
set(id, request) {
|
|
163
|
+
return this.serial(id, async () => {
|
|
164
|
+
const entry = this.entry(id)
|
|
165
|
+
try {
|
|
166
|
+
if (!request || Object.keys(request).join(',') !== 'taskId' || !UUID.test(request.taskId ?? '')) throw taskError('TASK_RECORD_INVALID')
|
|
167
|
+
if (entry.intendedId && entry.intendedId !== request.taskId) throw taskError('TASK_BINDING_CONFLICT')
|
|
168
|
+
entry.intendedId = request.taskId
|
|
169
|
+
if (!this.store) throw taskError('TASK_STORE_UNAVAILABLE')
|
|
170
|
+
if (entry.failure?.startsWith('TASK_STORE_')) throw taskError(entry.failure)
|
|
171
|
+
await this.scopes.begin(id)
|
|
172
|
+
const current = await this.read(id)
|
|
173
|
+
if ((current ?? entry.pending)?.entries[0].taskBinding.task_id !== undefined &&
|
|
174
|
+
(current ?? entry.pending).entries[0].taskBinding.task_id !== request.taskId) throw taskError('TASK_BINDING_CONFLICT')
|
|
175
|
+
const verified = await this.verify(id, request.taskId)
|
|
176
|
+
const record = { schema: SCHEMA, sessionId: id, revision: 1, entries: [{ scopeHash: scopeFingerprint(verified.scope),
|
|
177
|
+
taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: request.taskId, scope_hash: verified.snapshot.scope_hash }, members: verified.members }] }
|
|
178
|
+
if (current && metadataHash(current) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
|
|
179
|
+
if (!current) { entry.pending = record; await this.save(id, record) }
|
|
180
|
+
entry.snapshot = verified.snapshot
|
|
181
|
+
entry.supported = true
|
|
182
|
+
entry.failure = null
|
|
183
|
+
return this.view(id)
|
|
184
|
+
} catch (error) { this.fail(id, error); throw error }
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fail(id, error) {
|
|
189
|
+
const entry = this.entry(id)
|
|
190
|
+
// Persistence failure remains primary until explicit restore succeeds.
|
|
191
|
+
if (!entry.failure?.startsWith('TASK_STORE_')) entry.failure = error.publicCode ?? error.code ?? 'TASK_UNAVAILABLE'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
refresh(id, restore = false) {
|
|
195
|
+
return this.serial(id, async () => {
|
|
196
|
+
const entry = this.entry(id)
|
|
197
|
+
try {
|
|
198
|
+
if (!restore && entry.failure?.startsWith('TASK_STORE_')) return this.view(id)
|
|
199
|
+
let record = await this.read(id)
|
|
200
|
+
let verified
|
|
201
|
+
if (restore && entry.pending && !record) { verified = await this.verify(id, entry.pending.entries[0].taskBinding.task_id); await this.save(id, entry.pending); record = entry.record }
|
|
202
|
+
if (!record && entry.intendedId) throw taskError(entry.failure ?? 'TASK_REQUIRED')
|
|
203
|
+
verified ??= await this.verify(id, record?.entries[0].taskBinding.task_id)
|
|
204
|
+
entry.supported = verified.supported
|
|
205
|
+
entry.mode = verified.mode
|
|
206
|
+
if (record) {
|
|
207
|
+
const expected = { scopeHash: scopeFingerprint(verified.scope), members: verified.members,
|
|
208
|
+
taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: verified.snapshot.task_id, scope_hash: verified.snapshot.scope_hash } }
|
|
209
|
+
if (metadataHash(record.entries[0]) !== metadataHash(expected)) throw taskError('TASK_BINDING_CONFLICT')
|
|
210
|
+
entry.snapshot = verified.snapshot
|
|
211
|
+
entry.pending = null
|
|
212
|
+
}
|
|
213
|
+
entry.failure = !record && verified.mode === 'required' ? 'TASK_REQUIRED' : null
|
|
214
|
+
} catch (error) { this.fail(id, error) }
|
|
215
|
+
return this.view(id)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
view(id) {
|
|
220
|
+
const entry = this.entry(id)
|
|
221
|
+
const failure = entry.failure
|
|
222
|
+
const snapshot = entry.snapshot
|
|
223
|
+
return { state: failure ? failure.startsWith('TASK_STORE_') ? 'storage_error' : 'blocked' : snapshot?.state ?? 'inactive',
|
|
224
|
+
availability: failure === 'TASK_UNSUPPORTED' ? 'unsupported' : failure && ['TASK_UNAVAILABLE', 'agent_transport_unavailable', 'agent_request_timeout'].includes(failure) ? 'unavailable' : entry.supported === true ? 'supported' : entry.supported === false ? 'unsupported' : 'unavailable',
|
|
225
|
+
...(failure ? { reason: failure } : {}), task_binding: copy(entry.record?.entries[0].taskBinding ?? null),
|
|
226
|
+
...(snapshot ? { task_state: snapshot.state, revision: snapshot.revision, ledger_sequence: snapshot.ledger_sequence,
|
|
227
|
+
policy: copy(snapshot.policy), counters: copy(snapshot.counters), metering: 'write_invocation' } : {}),
|
|
228
|
+
snapshot_is_dispatch_permission: false, object_metering: 'unsupported', unsavedRecords: entry.pending ? 1 : 0 }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async binding(id, { dispatch = false } = {}) {
|
|
232
|
+
const view = await this.refresh(id)
|
|
233
|
+
if (view.reason) throw taskError(view.reason)
|
|
234
|
+
if (dispatch && view.task_binding && view.task_state !== 'active') throw taskError({ paused: 'TASK_PAUSED', cancelled: 'TASK_CANCELLED', blocked: 'TASK_SCOPE_BLOCKED' }[view.task_state])
|
|
235
|
+
return view.task_binding
|
|
236
|
+
}
|
|
237
|
+
}
|