dsh-bailinghub 0.4.0 → 0.6.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 +108 -0
- package/PRIVACY.md +62 -12
- package/README.md +50 -20
- package/SECURITY.md +47 -6
- package/docs/AGENT_CLIENT_CONTRACT.md +201 -16
- package/docs/CAPABILITY_FEEDBACK.md +199 -0
- package/docs/COMPATIBILITY.md +63 -5
- package/docs/CROSS_SYSTEM_CONVERSATIONS.md +138 -0
- package/docs/GENERATED_ARTIFACTS.md +77 -0
- package/docs/GETTING_STARTED.md +37 -11
- package/docs/GETTING_STARTED.zh-CN.md +32 -12
- package/docs/INVOCATION_RECOVERY.md +182 -0
- package/docs/MIGRATION_VNEXT.md +78 -7
- package/docs/PROJECT_BOUNDARIES.md +4 -1
- package/docs/README.zh-CN.md +59 -18
- package/docs/RELEASE_NOTES_v0.5.0.md +79 -0
- package/docs/RELEASE_NOTES_v0.6.0.en.md +39 -0
- package/docs/RELEASE_NOTES_v0.6.0.md +39 -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/lib/artifact-store.js +56 -0
- package/lib/artifacts.js +132 -0
- package/lib/authorizations.js +23 -8
- package/lib/capability-feedback.js +138 -0
- package/lib/conversation-archive-store.js +2 -2
- package/lib/conversation-outbox.js +37 -7
- package/lib/index.js +7 -1
- package/lib/invocation-journal.js +223 -0
- package/lib/invocation-store.js +275 -0
- package/lib/runtime.js +1126 -174
- package/lib/session-scope-store.js +2 -2
- package/lib/session-scope.js +146 -30
- 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/subject-display.js +39 -0
- package/lib/system-info.js +76 -0
- package/lib/tool-catalog.js +38 -0
- package/lib/transport.js +34 -0
- package/package.json +14 -3
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { memberBinding } from './session-scope.js'
|
|
3
|
+
|
|
4
|
+
const SCHEMA = 'bailing.agent-invocations.v2'
|
|
5
|
+
const LEGACY_SCHEMA = 'bailing.agent-invocations.v1'
|
|
6
|
+
const ID = /^[a-f0-9]{64}$/
|
|
7
|
+
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
|
|
8
|
+
const STATES = new Set(['unknown', 'executed', 'business_rejected', 'awaiting_approval', 'denied', 'rejected_before_dispatch', 'reconciliation_required', 'in_progress'])
|
|
9
|
+
const IDENTITY_FIELDS = ['id', 'scopeHash', 'scopeRevision', 'target', 'runId', 'turn', 'callId', 'tool', 'capabilityRevision', 'parameterHash', 'taskBinding']
|
|
10
|
+
const FIELDS = [...IDENTITY_FIELDS, 'lastKnownState', 'retryAt', 'resultHash']
|
|
11
|
+
|
|
12
|
+
function canonical(value) {
|
|
13
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
14
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function metadataHash(value) {
|
|
19
|
+
return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function scopeFingerprint(scope) {
|
|
23
|
+
if (!scope?.locked || scope.state !== 'ready' || !scope.authorizations?.length) throw recoveryError('invocation_binding_conflict')
|
|
24
|
+
return metadataHash({ sessionId: scope.sessionId, revision: scope.revision,
|
|
25
|
+
members: scope.authorizations.map(member => ({ connectionKey: member.connectionKey,
|
|
26
|
+
...memberBinding(scope, member), agentSessionId: member.sessionId,
|
|
27
|
+
})).sort((a, b) => a.connectionKey.localeCompare(b.connectionKey)),
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function recoveryError(code, invocationId, dispatch = 'not_dispatched') {
|
|
32
|
+
const storage = ['invocation_store_unavailable', 'invocation_store_conflict'].includes(code)
|
|
33
|
+
const error = new Error(code)
|
|
34
|
+
error.publicCode = code
|
|
35
|
+
error.feedback = {
|
|
36
|
+
schema: 'bailing.agent-feedback.v1', origin: 'dsh', operation: 'resume', code,
|
|
37
|
+
category: storage ? 'storage_error' : code === 'invocation_store_unsupported' ? 'unsupported' : 'invocation_recovery_unavailable',
|
|
38
|
+
dispatch, next_action: storage ? 'restore_invocations' : 'inspect_original',
|
|
39
|
+
...(invocationId ? { invocation_id: invocationId, original_outcome: 'unverified' } : {}),
|
|
40
|
+
}
|
|
41
|
+
return error
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkedEntry(value, legacy = false) {
|
|
45
|
+
const entry = structuredClone(value)
|
|
46
|
+
if (!entry || Array.isArray(entry) || Object.keys(entry).some(key => !FIELDS.includes(key)) ||
|
|
47
|
+
!ID.test(entry.id ?? '') || !ID.test(entry.scopeHash ?? '') ||
|
|
48
|
+
!Number.isSafeInteger(entry.scopeRevision) || entry.scopeRevision < 1 || !UUID.test(entry.runId ?? '') ||
|
|
49
|
+
!Number.isSafeInteger(entry.turn) || entry.turn < 0 ||
|
|
50
|
+
typeof entry.callId !== 'string' || !entry.callId.length || entry.callId.length > 4096 ||
|
|
51
|
+
!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(entry.tool ?? '') ||
|
|
52
|
+
!ID.test(entry.capabilityRevision ?? '') || !ID.test(entry.parameterHash ?? '') ||
|
|
53
|
+
!STATES.has(entry.lastKnownState) || !Number.isSafeInteger(entry.retryAt) || entry.retryAt < 0 ||
|
|
54
|
+
!(entry.resultHash === null || ID.test(entry.resultHash ?? ''))) throw recoveryError('invocation_binding_conflict')
|
|
55
|
+
if (legacy && Object.hasOwn(entry, 'taskBinding')) throw recoveryError('invocation_binding_conflict')
|
|
56
|
+
if (entry.taskBinding !== undefined && (!entry.taskBinding ||
|
|
57
|
+
Object.keys(entry.taskBinding).sort().join(',') !== 'schema_version,scope_hash,task_id' ||
|
|
58
|
+
entry.taskBinding.schema_version !== 'bailing.agent-task-binding.v1' ||
|
|
59
|
+
!UUID.test(entry.taskBinding.task_id ?? '') || !ID.test(entry.taskBinding.scope_hash ?? ''))) throw recoveryError('invocation_binding_conflict')
|
|
60
|
+
const target = entry.target
|
|
61
|
+
if (!target || Array.isArray(target) || Object.keys(target).sort().join(',') !== 'agentSessionId,authorizationRef,clientAppId,connectionKey,hubUrl,workspace' ||
|
|
62
|
+
!/^conn_[a-f0-9]{32}$/.test(target.connectionKey ?? '') || !UUID.test(target.agentSessionId ?? '') ||
|
|
63
|
+
!/^[a-z0-9][a-z0-9_-]{1,63}$/.test(target.clientAppId ?? '') ||
|
|
64
|
+
!/^[a-z0-9][a-z0-9_-]{1,63}$/.test(target.workspace ?? '') ||
|
|
65
|
+
typeof target.hubUrl !== 'string' || target.hubUrl.length > 2048 ||
|
|
66
|
+
typeof target.authorizationRef !== 'string' || !/^auth_[a-f0-9]{24}$/.test(target.authorizationRef)) throw recoveryError('invocation_binding_conflict')
|
|
67
|
+
return entry
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sameBinding(a, b) {
|
|
71
|
+
return metadataHash(Object.fromEntries(IDENTITY_FIELDS.map(key => [key, a[key]]))) ===
|
|
72
|
+
metadataHash(Object.fromEntries(IDENTITY_FIELDS.map(key => [key, b[key]])))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Trusted host metadata only; transcript text and model-provided bindings are never consumed. */
|
|
76
|
+
export class InvocationJournal {
|
|
77
|
+
constructor(store) {
|
|
78
|
+
if (store != null && (typeof store.load !== 'function' || typeof store.save !== 'function')) throw new TypeError('An invocation store with load/save is required')
|
|
79
|
+
this.store = store
|
|
80
|
+
this.queues = new Map()
|
|
81
|
+
this.revisions = new Map()
|
|
82
|
+
this.pending = new Map()
|
|
83
|
+
this.failures = new Map()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
serial(sessionId, operation) {
|
|
87
|
+
const previous = this.queues.get(sessionId) ?? Promise.resolve()
|
|
88
|
+
const result = previous.then(operation)
|
|
89
|
+
const tail = result.catch(() => {})
|
|
90
|
+
this.queues.set(sessionId, tail)
|
|
91
|
+
void tail.finally(() => { if (this.queues.get(sessionId) === tail) this.queues.delete(sessionId) })
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
normalizeFailure(error, id) {
|
|
96
|
+
return error?.feedback ? error : recoveryError(error?.code === 'INVOCATION_STORE_CONFLICT'
|
|
97
|
+
? 'invocation_store_conflict' : 'invocation_store_unavailable', id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async read(sessionId) {
|
|
101
|
+
if (!this.store) throw recoveryError('invocation_store_unsupported')
|
|
102
|
+
try {
|
|
103
|
+
const raw = await this.store.load(sessionId)
|
|
104
|
+
const seen = this.revisions.get(sessionId) ?? 0
|
|
105
|
+
if (!raw) {
|
|
106
|
+
if (seen) throw recoveryError('invocation_binding_conflict')
|
|
107
|
+
return { schema: SCHEMA, sessionId, revision: 0, entries: [] }
|
|
108
|
+
}
|
|
109
|
+
if (![SCHEMA, LEGACY_SCHEMA].includes(raw.schema) || raw.sessionId !== sessionId || !Number.isSafeInteger(raw.revision) || raw.revision < 1 || raw.revision < seen ||
|
|
110
|
+
Object.keys(raw).some(key => !['schema', 'sessionId', 'revision', 'entries'].includes(key)) || !Array.isArray(raw.entries)) throw recoveryError('invocation_binding_conflict')
|
|
111
|
+
const entries = raw.entries.map(value => checkedEntry(value, raw.schema === LEGACY_SCHEMA))
|
|
112
|
+
if (new Set(entries.map(entry => entry.id)).size !== entries.length) throw recoveryError('invocation_binding_conflict')
|
|
113
|
+
this.revisions.set(sessionId, raw.revision)
|
|
114
|
+
return { schema: SCHEMA, sessionId, revision: raw.revision, entries }
|
|
115
|
+
} catch (error) { throw this.normalizeFailure(error) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async write(record, entries) {
|
|
119
|
+
const next = { ...record, revision: record.revision + 1, entries }
|
|
120
|
+
const saved = await this.store.save(record.sessionId, structuredClone(next), record.revision || null)
|
|
121
|
+
if (metadataHash(saved) !== metadataHash(next)) throw recoveryError('invocation_store_unavailable')
|
|
122
|
+
this.revisions.set(record.sessionId, next.revision)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
reserve(sessionId, value) {
|
|
126
|
+
if (!this.store) return Promise.resolve({ created: true, supported: false })
|
|
127
|
+
return this.serial(sessionId, async () => {
|
|
128
|
+
try {
|
|
129
|
+
const entry = checkedEntry(value)
|
|
130
|
+
if (this.failures.has(sessionId) || this.pending.get(sessionId)?.size) throw recoveryError(this.failures.get(sessionId) ?? 'invocation_store_unavailable', entry.id)
|
|
131
|
+
const record = await this.read(sessionId)
|
|
132
|
+
const existing = record.entries.find(item => item.id === entry.id)
|
|
133
|
+
if (existing) {
|
|
134
|
+
if (!sameBinding(existing, entry)) throw recoveryError('invocation_binding_conflict', entry.id)
|
|
135
|
+
return { created: false, supported: true, entry: structuredClone(existing) }
|
|
136
|
+
}
|
|
137
|
+
await this.write(record, [...record.entries, entry])
|
|
138
|
+
this.failures.delete(sessionId)
|
|
139
|
+
return { created: true, supported: true, entry: structuredClone(entry) }
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const failure = this.normalizeFailure(error, value.id)
|
|
142
|
+
this.failures.set(sessionId, failure.publicCode)
|
|
143
|
+
throw failure
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async updateOnce(sessionId, entry, previous) {
|
|
149
|
+
const record = await this.read(sessionId)
|
|
150
|
+
const index = record.entries.findIndex(item => item.id === entry.id)
|
|
151
|
+
if (index < 0 || !sameBinding(record.entries[index], entry)) throw recoveryError('invocation_binding_conflict', entry.id)
|
|
152
|
+
// A lost save acknowledgement is idempotent; a different outcome written by
|
|
153
|
+
// another process must not be overwritten by a stale in-flight response.
|
|
154
|
+
if (metadataHash(record.entries[index]) === metadataHash(entry)) return
|
|
155
|
+
if (metadataHash(record.entries[index]) !== metadataHash(previous)) throw recoveryError('invocation_store_conflict', entry.id)
|
|
156
|
+
const entries = [...record.entries]
|
|
157
|
+
entries[index] = checkedEntry(entry)
|
|
158
|
+
await this.write(record, entries)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
update(sessionId, value, previous) {
|
|
162
|
+
if (!this.store) return Promise.resolve()
|
|
163
|
+
return this.serial(sessionId, async () => {
|
|
164
|
+
const entry = checkedEntry(value)
|
|
165
|
+
const pending = this.pending.get(sessionId) ?? new Map()
|
|
166
|
+
pending.set(entry.id, { entry, previous: checkedEntry(previous) })
|
|
167
|
+
this.pending.set(sessionId, pending)
|
|
168
|
+
try {
|
|
169
|
+
await this.updateOnce(sessionId, entry, previous)
|
|
170
|
+
pending.delete(entry.id)
|
|
171
|
+
if (!pending.size) this.failures.delete(sessionId)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const failure = this.normalizeFailure(error, entry.id)
|
|
174
|
+
failure.feedback.dispatch = 'unknown'
|
|
175
|
+
this.failures.set(sessionId, failure.publicCode)
|
|
176
|
+
throw failure
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async find(sessionId, id, scopeHash) {
|
|
182
|
+
return this.serial(sessionId, async () => {
|
|
183
|
+
const record = await this.read(sessionId)
|
|
184
|
+
const entry = record.entries.find(value => value.id === id)
|
|
185
|
+
if (!entry) throw recoveryError('invocation_binding_unavailable', id)
|
|
186
|
+
if (entry.scopeHash !== scopeHash) throw recoveryError('invocation_binding_conflict', id)
|
|
187
|
+
return structuredClone(entry)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
status(sessionId, scopeHash, restore = false) {
|
|
192
|
+
if (!this.store) return Promise.resolve({ state: 'unsupported', reason: 'invocation_store_unsupported', entries: [], unsavedRecords: 0 })
|
|
193
|
+
return this.serial(sessionId, async () => {
|
|
194
|
+
try {
|
|
195
|
+
let record = await this.read(sessionId)
|
|
196
|
+
if (record.entries.some(entry => entry.scopeHash !== scopeHash)) throw recoveryError('invocation_binding_conflict')
|
|
197
|
+
if (restore) {
|
|
198
|
+
for (const { entry, previous } of this.pending.get(sessionId)?.values() ?? []) {
|
|
199
|
+
if (entry.scopeHash !== scopeHash) throw recoveryError('invocation_binding_conflict')
|
|
200
|
+
await this.updateOnce(sessionId, entry, previous)
|
|
201
|
+
this.pending.get(sessionId).delete(entry.id)
|
|
202
|
+
}
|
|
203
|
+
this.failures.delete(sessionId)
|
|
204
|
+
record = await this.read(sessionId)
|
|
205
|
+
}
|
|
206
|
+
const unsavedRecords = this.pending.get(sessionId)?.size ?? 0
|
|
207
|
+
const reason = this.failures.get(sessionId)
|
|
208
|
+
return { state: reason || unsavedRecords ? 'storage_error' : 'ready', ...(reason ? { reason } : {}),
|
|
209
|
+
revision: record.revision || null, unsavedRecords,
|
|
210
|
+
entries: record.entries.map(entry => ({ invocation_id: entry.id, authorization_ref: entry.target.authorizationRef,
|
|
211
|
+
tool: entry.tool, ...(entry.taskBinding ? { task_binding: structuredClone(entry.taskBinding) } : {}), original_run_id: entry.runId, last_known_state: entry.lastKnownState,
|
|
212
|
+
result_verified: false, retry_at: entry.retryAt,
|
|
213
|
+
})),
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const failure = this.normalizeFailure(error)
|
|
217
|
+
this.failures.set(sessionId, failure.publicCode)
|
|
218
|
+
return { state: failure.publicCode === 'invocation_binding_conflict' ? 'blocked' : 'storage_error',
|
|
219
|
+
reason: failure.publicCode, entries: [], unsavedRecords: this.pending.get(sessionId)?.size ?? 0 }
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -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-invocations.v1', 'bailing.agent-invocations.v2'])
|
|
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 = 'INVOCATION_STORE_UNAVAILABLE') {
|
|
21
|
+
const error = new Error(code === 'INVOCATION_STORE_CONFLICT'
|
|
22
|
+
? 'The invocation record changed or is being saved. Read it again before retrying.'
|
|
23
|
+
: 'The invocation store is unavailable. Recovery metadata was not safely confirmed.')
|
|
24
|
+
error.code = code
|
|
25
|
+
return error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeError(error) {
|
|
29
|
+
return ['INVOCATION_STORE_CONFLICT', 'INVOCATION_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('INVOCATION_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', 'invocation-records')
|
|
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('INVOCATION_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 createFileInvocationStore({ 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 createMemoryInvocationStore() {
|
|
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
|
+
}
|