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.
Files changed (43) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/PRIVACY.md +62 -12
  3. package/README.md +50 -20
  4. package/SECURITY.md +47 -6
  5. package/docs/AGENT_CLIENT_CONTRACT.md +201 -16
  6. package/docs/CAPABILITY_FEEDBACK.md +199 -0
  7. package/docs/COMPATIBILITY.md +63 -5
  8. package/docs/CROSS_SYSTEM_CONVERSATIONS.md +138 -0
  9. package/docs/GENERATED_ARTIFACTS.md +77 -0
  10. package/docs/GETTING_STARTED.md +37 -11
  11. package/docs/GETTING_STARTED.zh-CN.md +32 -12
  12. package/docs/INVOCATION_RECOVERY.md +182 -0
  13. package/docs/MIGRATION_VNEXT.md +78 -7
  14. package/docs/PROJECT_BOUNDARIES.md +4 -1
  15. package/docs/README.zh-CN.md +59 -18
  16. package/docs/RELEASE_NOTES_v0.5.0.md +79 -0
  17. package/docs/RELEASE_NOTES_v0.6.0.en.md +39 -0
  18. package/docs/RELEASE_NOTES_v0.6.0.md +39 -0
  19. package/docs/SESSION_TOOL_REUSE.md +213 -0
  20. package/docs/TASK_CONTROL.md +183 -0
  21. package/docs/UPGRADE_v0.6.0.en.md +71 -0
  22. package/docs/UPGRADE_v0.6.0.md +73 -0
  23. package/lib/artifact-store.js +56 -0
  24. package/lib/artifacts.js +132 -0
  25. package/lib/authorizations.js +23 -8
  26. package/lib/capability-feedback.js +138 -0
  27. package/lib/conversation-archive-store.js +2 -2
  28. package/lib/conversation-outbox.js +37 -7
  29. package/lib/index.js +7 -1
  30. package/lib/invocation-journal.js +223 -0
  31. package/lib/invocation-store.js +275 -0
  32. package/lib/runtime.js +1126 -174
  33. package/lib/session-scope-store.js +2 -2
  34. package/lib/session-scope.js +146 -30
  35. package/lib/session-task-store.js +275 -0
  36. package/lib/session-task.js +237 -0
  37. package/lib/session-tool-cache.js +168 -0
  38. package/lib/session-tools.js +240 -0
  39. package/lib/subject-display.js +39 -0
  40. package/lib/system-info.js +76 -0
  41. package/lib/tool-catalog.js +38 -0
  42. package/lib/transport.js +34 -0
  43. 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
+ }