@wangmo-bit/ds-link 0.0.0-stage → 0.8.18

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/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/TESTING.md +59 -0
  4. package/THIRD_PARTY_NOTICES.md +313 -0
  5. package/assets/icon.svg +7 -0
  6. package/cordis.patch.yml +3 -0
  7. package/dist/client.js +6012 -0
  8. package/package.json +102 -4
  9. package/src/core/constants.js +188 -0
  10. package/src/core/downgrade.js +80 -0
  11. package/src/core/engine.js +4669 -0
  12. package/src/core/errors.js +51 -0
  13. package/src/core/ledger.js +1174 -0
  14. package/src/core/migration.js +741 -0
  15. package/src/core/render.js +61 -0
  16. package/src/core/settings.js +147 -0
  17. package/src/core/validation.js +454 -0
  18. package/src/host/adapter.js +1152 -0
  19. package/src/host/connection.js +113 -0
  20. package/src/host/entry-0.2.0.js +13 -0
  21. package/src/host/entry-0.3.0.js +13 -0
  22. package/src/host/entry-0.4.0.js +13 -0
  23. package/src/host/entry-0.8.10.js +13 -0
  24. package/src/host/entry-0.8.11.js +13 -0
  25. package/src/host/entry-0.8.12.js +13 -0
  26. package/src/host/entry-0.8.13.js +13 -0
  27. package/src/host/entry-0.8.14.js +13 -0
  28. package/src/host/entry-0.8.15.js +13 -0
  29. package/src/host/entry-0.8.16-pre.1.js +13 -0
  30. package/src/host/entry-0.8.16-pre.2.js +13 -0
  31. package/src/host/entry-0.8.16-pre.3.js +13 -0
  32. package/src/host/entry-0.8.16-pre.4.js +13 -0
  33. package/src/host/entry-0.8.16-pre.5.js +13 -0
  34. package/src/host/entry-0.8.18.js +13 -0
  35. package/src/host/entry-0.8.5.js +13 -0
  36. package/src/host/entry-0.8.6.js +13 -0
  37. package/src/host/entry-0.8.7.js +13 -0
  38. package/src/host/entry-0.8.8.js +13 -0
  39. package/src/host/entry-0.8.9.js +13 -0
  40. package/src/host/index.js +74 -0
  41. package/src/host/plugin.js +403 -0
  42. package/src/host/prompt.js +22 -0
  43. package/src/host/tools.js +312 -0
@@ -0,0 +1,1174 @@
1
+ /**
2
+ * Private, atomic, single-writer v0.2 ledger.
3
+ *
4
+ * - directory 0700, files 0600, same-directory temp + fsync + rename + dir fsync;
5
+ * - one OS-backed SQLite EXCLUSIVE writer claim for the life of the ledger;
6
+ * - v1 ledgers are validated with the frozen v0.1 rules, backed up verbatim,
7
+ * then converted and atomically persisted as v2;
8
+ * - a persist failure poisons the ledger: reads may show the last confirmed
9
+ * memory, but no mutation is accepted until a safe reopen;
10
+ * - unknown versions and malformed records fail closed and are never rewritten.
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto'
14
+ import fs from 'node:fs/promises'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { DatabaseSync } from 'node:sqlite'
18
+
19
+ import {
20
+ CHAIN_STATES,
21
+ CREATE_PHASES,
22
+ CREATE_RENAME_STATUSES,
23
+ DISPATCH_STATUSES,
24
+ LEDGER_FILE,
25
+ LOCK_DB_FILE,
26
+ LOCK_FILE,
27
+ MESSAGE_KINDS,
28
+ REPLY_STATUSES,
29
+ STATE_VERSION,
30
+ LIMITS,
31
+ V1_BACKUP_PREFIX,
32
+ V2_BACKUP_PREFIX,
33
+ V2_ROLLBACK_BACKUP_PREFIX,
34
+ V3_ROLLBACK_BACKUP_PREFIX,
35
+ } from './constants.js'
36
+ import { fail } from './errors.js'
37
+ import { createDefaultSettings, validateStoredSettings } from './settings.js'
38
+ import { isPlainObject } from './validation.js'
39
+ import {
40
+ migrateV1State,
41
+ migrateV2State,
42
+ rollbackPreflight,
43
+ sha256,
44
+ validateMigrationMarker,
45
+ validateUpgradeMarker,
46
+ validateV1State,
47
+ v3ToV2Preflight,
48
+ V2_STATE_VERSION,
49
+ } from './migration.js'
50
+
51
+ export const SKIP = Symbol('session-link.skip-mutation')
52
+
53
+ /** @template T @param {T} value @returns {{ [SKIP]: true, value: T }} */
54
+ export function skipMutation(value) {
55
+ return { [SKIP]: true, value }
56
+ }
57
+
58
+ class SerialQueue {
59
+ #tail = Promise.resolve()
60
+
61
+ /** @template T @param {() => Promise<T> | T} task @returns {Promise<T>} */
62
+ run(task) {
63
+ const result = this.#tail.then(task, task)
64
+ this.#tail = result.then(
65
+ () => undefined,
66
+ () => undefined,
67
+ )
68
+ return result
69
+ }
70
+
71
+ async drain() {
72
+ await this.#tail
73
+ }
74
+ }
75
+
76
+ /** @returns {Record<string, any>} */
77
+ function initialState(now) {
78
+ return {
79
+ version: STATE_VERSION,
80
+ revision: 1,
81
+ settingsRevision: 1,
82
+ createdAt: now,
83
+ updatedAt: now,
84
+ settings: createDefaultSettings(),
85
+ messages: [],
86
+ chains: {},
87
+ operations: {},
88
+ }
89
+ }
90
+
91
+ /** @param {string} message */
92
+ function corrupt(message) {
93
+ return fail('storage_corrupt', message)
94
+ }
95
+
96
+ /** @param {unknown} value @param {string} label @returns {Record<string, any>} */
97
+ function record(value, label) {
98
+ if (!isPlainObject(value)) throw corrupt(`${label} is invalid`)
99
+ return value
100
+ }
101
+
102
+ /** @param {unknown} value @param {string} label */
103
+ function nonEmptyString(value, label) {
104
+ if (typeof value !== 'string' || value.length === 0) throw corrupt(`${label} is invalid`)
105
+ }
106
+
107
+ /** @param {unknown} value @param {string} label @param {number} [min] */
108
+ function safeInteger(value, label, min = 0) {
109
+ if (!Number.isSafeInteger(value) || value < min) throw corrupt(`${label} is invalid`)
110
+ }
111
+
112
+ /** @param {unknown} value @param {string} label */
113
+ function optionalTime(value, label) {
114
+ if (value === undefined || value === null) return
115
+ safeInteger(value, label)
116
+ }
117
+
118
+ /** @param {unknown} value @param {string} label */
119
+ function optionalString(value, label) {
120
+ if (value === undefined || value === null) return
121
+ nonEmptyString(value, label)
122
+ }
123
+
124
+ /** @param {string} id @param {unknown} chain */
125
+ function validateChain(id, chain) {
126
+ const value = record(chain, `ledger chain ${id}`)
127
+ if (value.id !== id) throw corrupt(`ledger chain ${id} id mismatch`)
128
+ nonEmptyString(value.rootOwner, `ledger chain ${id} rootOwner`)
129
+ safeInteger(value.createdAt, `ledger chain ${id} createdAt`)
130
+ if (!CHAIN_STATES.includes(value.state)) throw corrupt(`ledger chain ${id} state is invalid`)
131
+ // v1 chain budget fields are inert audit fields in v2 when present.
132
+ if (value.maxMessages !== undefined) safeInteger(value.maxMessages, `ledger chain ${id} maxMessages`, 1)
133
+ if (value.maxHops !== undefined) safeInteger(value.maxHops, `ledger chain ${id} maxHops`, 1)
134
+ if (value.messageCount !== undefined) safeInteger(value.messageCount, `ledger chain ${id} messageCount`)
135
+ optionalTime(value.pausedAt, `ledger chain ${id} pausedAt`)
136
+ optionalTime(value.closedAt, `ledger chain ${id} closedAt`)
137
+ optionalString(value.pausedReason, `ledger chain ${id} pausedReason`)
138
+ optionalString(value.closedBy, `ledger chain ${id} closedBy`)
139
+ }
140
+
141
+ /**
142
+ * @param {unknown} message
143
+ * @param {Set<string>} chainIds
144
+ * @param {Set<string>} messageIds
145
+ * @param {Record<string, any>} chains
146
+ */
147
+ function validateMessage(message, chainIds, messageIds, chains) {
148
+ const value = record(message, 'ledger message')
149
+ nonEmptyString(value.id, 'ledger message id')
150
+ if (messageIds.has(value.id)) throw corrupt('ledger message id is duplicated')
151
+ messageIds.add(value.id)
152
+ nonEmptyString(value.from, `ledger message ${value.id} from`)
153
+ nonEmptyString(value.to, `ledger message ${value.id} to`)
154
+ if (value.from === value.to) throw corrupt(`ledger message ${value.id} targets itself`)
155
+ if (!MESSAGE_KINDS.includes(value.kind)) throw corrupt(`ledger message ${value.id} kind is invalid`)
156
+ nonEmptyString(value.chainId, `ledger message ${value.id} chainId`)
157
+ if (!chainIds.has(value.chainId)) throw corrupt(`ledger message ${value.id} references an unknown chain`)
158
+ safeInteger(value.hop, `ledger message ${value.id} hop`)
159
+ safeInteger(value.createdAt, `ledger message ${value.id} createdAt`)
160
+ if (!DISPATCH_STATUSES.includes(value.status)) throw corrupt(`ledger message ${value.id} status is invalid`)
161
+ if (!REPLY_STATUSES.includes(value.replyStatus)) throw corrupt(`ledger message ${value.id} replyStatus is invalid`)
162
+ optionalString(value.operationId, `ledger message ${value.id} operationId`)
163
+ if (value.deliveryAttempts !== undefined) safeInteger(value.deliveryAttempts, `ledger message ${value.id} deliveryAttempts`)
164
+ if (value.durableMessageId !== undefined && value.durableMessageId !== null) {
165
+ nonEmptyString(value.durableMessageId, `ledger message ${value.id} durableMessageId`)
166
+ }
167
+ const content = record(value.content, `ledger message ${value.id} content`)
168
+ if (typeof content.text !== 'string') throw corrupt(`ledger message ${value.id} content.text is invalid`)
169
+ if (!Array.isArray(content.references)) throw corrupt(`ledger message ${value.id} references are invalid`)
170
+ for (const [index, reference] of content.references.entries()) {
171
+ const entry = record(reference, `ledger message ${value.id} reference ${index}`)
172
+ nonEmptyString(entry.sessionId, `ledger message ${value.id} reference sessionId`)
173
+ safeInteger(entry.seq, `ledger message ${value.id} reference seq`)
174
+ if (typeof entry.text !== 'string') throw corrupt(`ledger message ${value.id} reference text is invalid`)
175
+ }
176
+ if (!Array.isArray(content.paths) || content.paths.some((item) => typeof item !== 'string')) {
177
+ throw corrupt(`ledger message ${value.id} paths are invalid`)
178
+ }
179
+ if (value.error !== undefined && value.error !== null) {
180
+ const error = record(value.error, `ledger message ${value.id} error`)
181
+ nonEmptyString(error.code, `ledger message ${value.id} error.code`)
182
+ if (typeof error.message !== 'string') throw corrupt(`ledger message ${value.id} error.message is invalid`)
183
+ }
184
+ if (value.contentBytes !== undefined) safeInteger(value.contentBytes, `ledger message ${value.id} contentBytes`)
185
+ if (value.subject !== undefined && value.subject !== null && typeof value.subject !== 'string') {
186
+ throw corrupt(`ledger message ${value.id} subject is invalid`)
187
+ }
188
+ for (const [field, time] of [
189
+ ['acceptedAt', value.acceptedAt],
190
+ ['deadlineAt', value.deadlineAt],
191
+ ['timedOutAt', value.timedOutAt],
192
+ ['lateReplyAt', value.lateReplyAt],
193
+ ['heldAt', value.heldAt],
194
+ ['cancelledAt', value.cancelledAt],
195
+ ['failedAt', value.failedAt],
196
+ ['answeredAt', value.answeredAt],
197
+ ['completedAt', value.completedAt],
198
+ ['redactedAt', value.redactedAt],
199
+ ['unknownAt', value.unknownAt],
200
+ ['dispatchStartedAt', value.dispatchStartedAt],
201
+ ['releasedAt', value.releasedAt],
202
+ ['autoResumedAt', value.autoResumedAt],
203
+ ['manualConfirmedAt', value.manualConfirmedAt],
204
+ ['unconfirmedAt', value.unconfirmedAt],
205
+ ]) {
206
+ optionalTime(time, `ledger message ${value.id} ${field}`)
207
+ }
208
+ optionalString(value.heldReason, `ledger message ${value.id} heldReason`)
209
+ if (value.releaseAuthorized !== undefined && typeof value.releaseAuthorized !== 'boolean') {
210
+ throw corrupt(`ledger message ${value.id} releaseAuthorized is invalid`)
211
+ }
212
+ if (value.late !== undefined && typeof value.late !== 'boolean') throw corrupt(`ledger message ${value.id} late is invalid`)
213
+ const chain = chains[value.chainId]
214
+ if (chain === undefined) throw corrupt(`ledger message ${value.id} references an unknown chain`)
215
+ }
216
+
217
+ /** @param {unknown} operation @param {string} key */
218
+ function validateOperation(key, operation) {
219
+ const value = record(operation, `ledger operation ${key}`)
220
+ if (value.key !== undefined && value.key !== key) throw corrupt('ledger operation key mismatch')
221
+ nonEmptyString(value.from, `ledger operation ${key} from`)
222
+ nonEmptyString(value.operationId, `ledger operation ${key} operationId`)
223
+ nonEmptyString(value.fingerprint, `ledger operation ${key} fingerprint`)
224
+ if (!Array.isArray(value.targets) || value.targets.length === 0) throw corrupt(`ledger operation ${key} targets are invalid`)
225
+ if (new Set(value.targets).size !== value.targets.length) throw corrupt(`ledger operation ${key} targets are duplicated`)
226
+ for (const target of value.targets) nonEmptyString(target, `ledger operation ${key} target`)
227
+ const messageIds = record(value.messageIds, `ledger operation ${key} messageIds`)
228
+ for (const target of value.targets) nonEmptyString(messageIds[target], `ledger operation ${key} messageId`)
229
+ safeInteger(value.at, `ledger operation ${key} at`)
230
+ }
231
+
232
+ /**
233
+ * Validate a v2 state. There is intentionally no message-count or hop cap here:
234
+ * a legal large v2 ledger must load, and v1 hop limits never block continuations.
235
+ * @param {unknown} state
236
+ * @returns {Record<string, any>}
237
+ */
238
+ export function validateV2State(state) {
239
+ if (!isPlainObject(state)) throw corrupt('ledger state is not an object')
240
+ if (state.version !== V2_STATE_VERSION) throw corrupt(`ledger uses unsupported state version ${String(state.version)}`)
241
+ if (!Number.isSafeInteger(state.revision) || state.revision < 1) throw corrupt('ledger revision is invalid')
242
+ if (!Number.isSafeInteger(state.settingsRevision) || state.settingsRevision < 1) {
243
+ throw corrupt('ledger settingsRevision is invalid')
244
+ }
245
+ if (!Number.isSafeInteger(state.createdAt) || state.createdAt < 0) throw corrupt('ledger createdAt is invalid')
246
+ if (!Number.isSafeInteger(state.updatedAt) || state.updatedAt < 0) throw corrupt('ledger updatedAt is invalid')
247
+ if (!Array.isArray(state.messages)) throw corrupt('ledger messages are invalid')
248
+ if (!isPlainObject(state.chains)) throw corrupt('ledger chains are invalid')
249
+ if (!isPlainObject(state.operations)) throw corrupt('ledger operations are invalid')
250
+ state.settings = validateStoredSettings(state.settings)
251
+ if (state.migration !== undefined) validateMigrationMarker(state.migration)
252
+ const chainIds = new Set(Object.keys(state.chains))
253
+ for (const [id, chain] of Object.entries(state.chains)) validateChain(id, chain)
254
+ const messageIds = new Set()
255
+ const messagesById = new Map()
256
+ for (const message of state.messages) {
257
+ validateMessage(message, chainIds, messageIds, state.chains)
258
+ messagesById.set(message.id, message)
259
+ }
260
+ for (const message of state.messages) {
261
+ if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
262
+ throw corrupt(`ledger message ${message.id} replyTo mismatch`)
263
+ }
264
+ for (const field of ['parentMessageId', 'replyTo']) {
265
+ const parentId = message[field]
266
+ if (parentId === undefined || parentId === null) continue
267
+ const parent = messagesById.get(parentId)
268
+ if (parent === undefined) throw corrupt(`ledger message ${message.id} ${field} is dangling`)
269
+ if (parent.chainId !== message.chainId) throw corrupt(`ledger message ${message.id} ${field} crosses chains`)
270
+ if (message.hop !== parent.hop + 1) throw corrupt(`ledger message ${message.id} hop does not follow its parent`)
271
+ if (message.kind === 'reply') {
272
+ if (parent.kind !== 'request' || parent.to !== message.from) {
273
+ throw corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
274
+ }
275
+ } else if (parent.from !== message.from && parent.to !== message.from) {
276
+ throw corrupt(`ledger message ${message.id} parent does not involve the sender`)
277
+ }
278
+ }
279
+ }
280
+ for (const [key, operation] of Object.entries(state.operations)) validateOperation(key, operation)
281
+ return state
282
+ }
283
+
284
+ const RECEIPT_POLICIES = Object.freeze(['context', 'legacy-accepted'])
285
+ const RECEIPT_STATES = Object.freeze(['not_sent', 'queued', 'received', 'uncertain'])
286
+ const TRANSFER_STATES = Object.freeze(['prepared', 'moved', 'uncertain'])
287
+ const RECEIPT_KEYS = Object.freeze([
288
+ 'policy',
289
+ 'state',
290
+ 'receivedAt',
291
+ 'receivedSeq',
292
+ 'timeoutMs',
293
+ 'transfer',
294
+ ])
295
+ const TRANSFER_KEYS = Object.freeze(['state', 'at', 'error'])
296
+
297
+ /** @param {unknown} receipt @param {string} messageId */
298
+ function validateReceipt(receipt, messageId) {
299
+ const value = record(receipt, `ledger message ${messageId} receipt`)
300
+ for (const key of Object.keys(value)) {
301
+ if (!RECEIPT_KEYS.includes(key)) throw corrupt(`ledger message ${messageId} receipt has unknown field ${key}`)
302
+ }
303
+ for (const key of RECEIPT_KEYS) {
304
+ if (!(key in value)) throw corrupt(`ledger message ${messageId} receipt is missing ${key}`)
305
+ }
306
+ if (!RECEIPT_POLICIES.includes(value.policy)) {
307
+ throw corrupt(`ledger message ${messageId} receipt.policy is invalid`)
308
+ }
309
+ if (!RECEIPT_STATES.includes(value.state)) {
310
+ throw corrupt(`ledger message ${messageId} receipt.state is invalid`)
311
+ }
312
+ if (value.receivedAt !== null && (!Number.isSafeInteger(value.receivedAt) || value.receivedAt < 0)) {
313
+ throw corrupt(`ledger message ${messageId} receipt.receivedAt is invalid`)
314
+ }
315
+ if (value.receivedSeq !== null && (!Number.isSafeInteger(value.receivedSeq) || value.receivedSeq < 0)) {
316
+ throw corrupt(`ledger message ${messageId} receipt.receivedSeq is invalid`)
317
+ }
318
+ if (value.state === 'received') {
319
+ if (value.receivedAt === null || value.receivedSeq === null) {
320
+ throw corrupt(`ledger message ${messageId} received receipt lacks exact event evidence`)
321
+ }
322
+ } else if (value.receivedAt !== null || value.receivedSeq !== null) {
323
+ throw corrupt(`ledger message ${messageId} non-received receipt carries evidence`)
324
+ }
325
+ if (value.timeoutMs !== null && (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0)) {
326
+ throw corrupt(`ledger message ${messageId} receipt.timeoutMs is invalid`)
327
+ }
328
+ if (value.transfer !== null) {
329
+ const transfer = record(value.transfer, `ledger message ${messageId} receipt.transfer`)
330
+ for (const key of Object.keys(transfer)) {
331
+ if (!TRANSFER_KEYS.includes(key)) {
332
+ throw corrupt(`ledger message ${messageId} receipt.transfer has unknown field ${key}`)
333
+ }
334
+ }
335
+ if (!TRANSFER_STATES.includes(transfer.state)) {
336
+ throw corrupt(`ledger message ${messageId} receipt.transfer.state is invalid`)
337
+ }
338
+ safeInteger(transfer.at, `ledger message ${messageId} receipt.transfer.at`)
339
+ if (transfer.error !== undefined && typeof transfer.error !== 'string') {
340
+ throw corrupt(`ledger message ${messageId} receipt.transfer.error is invalid`)
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Validate the private transfer journal. Journals never reach the public view:
347
+ * they retain the original host message only for crash recovery and exact
348
+ * identity checks.
349
+ * @param {unknown} transfers
350
+ * @param {Map<string, Record<string, any>>} messagesById
351
+ */
352
+ function validateTransfers(transfers, messagesById) {
353
+ const value = record(transfers, 'ledger transfers')
354
+ for (const [messageId, rawJournal] of Object.entries(value)) {
355
+ const journal = record(rawJournal, `ledger transfer ${messageId}`)
356
+ const message = messagesById.get(messageId)
357
+ if (message === undefined) throw corrupt(`ledger transfer ${messageId} references an unknown message`)
358
+ if (journal.messageId !== messageId) throw corrupt(`ledger transfer ${messageId} id mismatch`)
359
+ nonEmptyString(journal.target, `ledger transfer ${messageId} target`)
360
+ if (journal.target !== message.to) throw corrupt(`ledger transfer ${messageId} target mismatch`)
361
+ nonEmptyString(journal.durableMessageId, `ledger transfer ${messageId} durableMessageId`)
362
+ if (journal.durableMessageId !== message.durableMessageId) {
363
+ throw corrupt(`ledger transfer ${messageId} durableMessageId mismatch`)
364
+ }
365
+ const hostMessage = record(journal.hostMessage, `ledger transfer ${messageId} hostMessage`)
366
+ if (hostMessage.id !== journal.durableMessageId) {
367
+ throw corrupt(`ledger transfer ${messageId} hostMessage id mismatch`)
368
+ }
369
+ if (!Array.isArray(hostMessage.content)) {
370
+ throw corrupt(`ledger transfer ${messageId} hostMessage content is invalid`)
371
+ }
372
+ const source = record(hostMessage.source, `ledger transfer ${messageId} hostMessage source`)
373
+ if (Object.keys(source).length !== 3) {
374
+ throw corrupt(`ledger transfer ${messageId} hostMessage source is not the standard shape`)
375
+ }
376
+ if (source.kind !== 'agent-message' || source.form !== 'relay') {
377
+ throw corrupt(`ledger transfer ${messageId} hostMessage source is invalid`)
378
+ }
379
+ if (source.senderSessionId !== message.from) {
380
+ throw corrupt(`ledger transfer ${messageId} hostMessage sender mismatch`)
381
+ }
382
+ safeInteger(journal.queuePosition, `ledger transfer ${messageId} queuePosition`)
383
+ safeInteger(journal.hostSessionSeq, `ledger transfer ${messageId} hostSessionSeq`, -1)
384
+ safeInteger(journal.at, `ledger transfer ${messageId} at`)
385
+ if (journal.attempts !== undefined) safeInteger(journal.attempts, `ledger transfer ${messageId} attempts`)
386
+ if (!TRANSFER_STATES.includes(journal.state)) {
387
+ throw corrupt(`ledger transfer ${messageId} state is invalid`)
388
+ }
389
+ if (typeof journal.digest !== 'string' || !/^[0-9a-f]{64}$/.test(journal.digest)) {
390
+ throw corrupt(`ledger transfer ${messageId} digest is invalid`)
391
+ }
392
+ if (journal.error !== undefined && typeof journal.error !== 'string') {
393
+ throw corrupt(`ledger transfer ${messageId} error is invalid`)
394
+ }
395
+ }
396
+ }
397
+
398
+ /** Exact per-record whitelist for the optional create journal region. */
399
+ const CREATE_JOURNAL_KEYS = Object.freeze([
400
+ 'key',
401
+ 'from',
402
+ 'operationId',
403
+ 'fingerprint',
404
+ 'at',
405
+ 'updatedAt',
406
+ 'phase',
407
+ 'task',
408
+ 'subject',
409
+ 'title',
410
+ 'location',
411
+ 'requestedSessionId',
412
+ 'sessionId',
413
+ 'workspaceId',
414
+ 'workspacePath',
415
+ 'renameStatus',
416
+ 'renameError',
417
+ 'sendOperationId',
418
+ 'firstMessageId',
419
+ 'error',
420
+ 'terminalAt',
421
+ 'bodyStripped',
422
+ ])
423
+
424
+ const CREATE_LOCATION_KEYS = Object.freeze([
425
+ 'kind',
426
+ 'workspaceId',
427
+ 'parentDir',
428
+ 'directoryName',
429
+ 'workspacePath',
430
+ 'workspaceTitle',
431
+ ])
432
+
433
+ const CREATE_ERROR_KEYS = Object.freeze(['code', 'message'])
434
+
435
+ /**
436
+ * Validate the optional create journal region with a strict per-record
437
+ * whitelist: an unknown field, phase, rename status, or malformed effect
438
+ * rejects the whole ledger load instead of being silently dropped. Records
439
+ * keep their private task body only until the first message is delivered;
440
+ * a stripped record carries an empty `task`/`null` `subject` and keeps its
441
+ * fingerprint, status, and association fields.
442
+ * @param {unknown} creates
443
+ */
444
+ function validateCreates(creates) {
445
+ const value = record(creates, 'ledger creates')
446
+ for (const [key, rawJournal] of Object.entries(value)) {
447
+ const journal = record(rawJournal, `ledger create ${key}`)
448
+ for (const field of Object.keys(journal)) {
449
+ if (!CREATE_JOURNAL_KEYS.includes(field)) {
450
+ throw corrupt(`ledger create ${key} has unknown field ${field}`)
451
+ }
452
+ }
453
+ for (const field of CREATE_JOURNAL_KEYS) {
454
+ if (!(field in journal)) throw corrupt(`ledger create ${key} is missing ${field}`)
455
+ }
456
+ nonEmptyString(journal.from, `ledger create ${key} from`)
457
+ nonEmptyString(journal.operationId, `ledger create ${key} operationId`)
458
+ if (journal.key !== key || key !== `${journal.from}|${journal.operationId}`) {
459
+ throw corrupt(`ledger create ${key} key mismatch`)
460
+ }
461
+ nonEmptyString(journal.fingerprint, `ledger create ${key} fingerprint`)
462
+ safeInteger(journal.at, `ledger create ${key} at`)
463
+ safeInteger(journal.updatedAt, `ledger create ${key} updatedAt`)
464
+ if (!CREATE_PHASES.includes(journal.phase)) throw corrupt(`ledger create ${key} phase is invalid`)
465
+ if (typeof journal.task !== 'string') throw corrupt(`ledger create ${key} task is invalid`)
466
+ if (journal.subject !== null && typeof journal.subject !== 'string') {
467
+ throw corrupt(`ledger create ${key} subject is invalid`)
468
+ }
469
+ if (journal.title !== null && typeof journal.title !== 'string') {
470
+ throw corrupt(`ledger create ${key} title is invalid`)
471
+ }
472
+ const location = record(journal.location, `ledger create ${key} location`)
473
+ for (const field of Object.keys(location)) {
474
+ if (!CREATE_LOCATION_KEYS.includes(field)) {
475
+ throw corrupt(`ledger create ${key} location has unknown field ${field}`)
476
+ }
477
+ }
478
+ for (const field of CREATE_LOCATION_KEYS) {
479
+ // workspacePath is additive for the existing_project mode; older 0.4
480
+ // journals (workspace/new_project) legitimately lack it.
481
+ if (field === 'workspacePath') continue
482
+ if (!(field in location)) throw corrupt(`ledger create ${key} location is missing ${field}`)
483
+ }
484
+ if (location.kind !== 'workspace' && location.kind !== 'new_project' && location.kind !== 'existing_project') {
485
+ throw corrupt(`ledger create ${key} location.kind is invalid`)
486
+ }
487
+ for (const field of ['workspaceId', 'parentDir', 'directoryName', 'workspacePath', 'workspaceTitle']) {
488
+ // workspacePath is additive; an older journal missing it entirely is
489
+ // valid, while a present value must be a string or null.
490
+ if (field === 'workspacePath' && !(field in location)) continue
491
+ if (location[field] !== null && typeof location[field] !== 'string') {
492
+ throw corrupt(`ledger create ${key} location.${field} is invalid`)
493
+ }
494
+ }
495
+ if (location.kind === 'workspace' && typeof location.workspaceId !== 'string') {
496
+ throw corrupt(`ledger create ${key} workspace location lacks workspaceId`)
497
+ }
498
+ if (location.kind === 'new_project' && (typeof location.parentDir !== 'string' || typeof location.directoryName !== 'string')) {
499
+ throw corrupt(`ledger create ${key} new_project location lacks parentDir/directoryName`)
500
+ }
501
+ if (location.kind === 'existing_project' && typeof location.workspacePath !== 'string') {
502
+ throw corrupt(`ledger create ${key} existing_project location lacks workspacePath`)
503
+ }
504
+ for (const field of ['requestedSessionId', 'sessionId', 'workspaceId', 'workspacePath', 'sendOperationId', 'firstMessageId']) {
505
+ if (journal[field] !== null && typeof journal[field] !== 'string') {
506
+ throw corrupt(`ledger create ${key} ${field} is invalid`)
507
+ }
508
+ }
509
+ if (!CREATE_RENAME_STATUSES.includes(journal.renameStatus)) {
510
+ throw corrupt(`ledger create ${key} renameStatus is invalid`)
511
+ }
512
+ if (journal.renameError !== null && typeof journal.renameError !== 'string') {
513
+ throw corrupt(`ledger create ${key} renameError is invalid`)
514
+ }
515
+ if (journal.error !== null) {
516
+ const error = record(journal.error, `ledger create ${key} error`)
517
+ for (const field of Object.keys(error)) {
518
+ if (!CREATE_ERROR_KEYS.includes(field)) throw corrupt(`ledger create ${key} error has unknown field ${field}`)
519
+ }
520
+ nonEmptyString(error.code, `ledger create ${key} error.code`)
521
+ if (typeof error.message !== 'string') throw corrupt(`ledger create ${key} error.message is invalid`)
522
+ }
523
+ if (journal.terminalAt !== null) safeInteger(journal.terminalAt, `ledger create ${key} terminalAt`)
524
+ if (typeof journal.bodyStripped !== 'boolean') throw corrupt(`ledger create ${key} bodyStripped is invalid`)
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Validate a v3 state. The v2 rules remain frozen in validateV2State; this
530
+ * validator adds the public receipt, the private upgrade marker, and the
531
+ * private transfer journal without relaxing any 0.2 invariant.
532
+ * @param {unknown} state
533
+ * @returns {Record<string, any>}
534
+ */
535
+ export function validateV3State(state) {
536
+ if (!isPlainObject(state)) throw corrupt('ledger state is not an object')
537
+ if (state.version !== STATE_VERSION) throw corrupt(`ledger uses unsupported state version ${String(state.version)}`)
538
+ if (!Number.isSafeInteger(state.revision) || state.revision < 1) throw corrupt('ledger revision is invalid')
539
+ if (!Number.isSafeInteger(state.settingsRevision) || state.settingsRevision < 1) {
540
+ throw corrupt('ledger settingsRevision is invalid')
541
+ }
542
+ if (!Number.isSafeInteger(state.createdAt) || state.createdAt < 0) throw corrupt('ledger createdAt is invalid')
543
+ if (!Number.isSafeInteger(state.updatedAt) || state.updatedAt < 0) throw corrupt('ledger updatedAt is invalid')
544
+ if (!Array.isArray(state.messages)) throw corrupt('ledger messages are invalid')
545
+ if (!isPlainObject(state.chains)) throw corrupt('ledger chains are invalid')
546
+ if (!isPlainObject(state.operations)) throw corrupt('ledger operations are invalid')
547
+ state.settings = validateStoredSettings(state.settings)
548
+ if (state.migration !== undefined) validateMigrationMarker(state.migration)
549
+ if (state.upgrade !== undefined) validateUpgradeMarker(state.upgrade)
550
+ const chainIds = new Set(Object.keys(state.chains))
551
+ for (const [id, chain] of Object.entries(state.chains)) validateChain(id, chain)
552
+ const messageIds = new Set()
553
+ const messagesById = new Map()
554
+ for (const message of state.messages) {
555
+ validateMessage(message, chainIds, messageIds, state.chains)
556
+ validateReceipt(message.receipt, message.id)
557
+ messagesById.set(message.id, message)
558
+ }
559
+ for (const message of state.messages) {
560
+ if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
561
+ throw corrupt(`ledger message ${message.id} replyTo mismatch`)
562
+ }
563
+ for (const field of ['parentMessageId', 'replyTo']) {
564
+ const parentId = message[field]
565
+ if (parentId === undefined || parentId === null) continue
566
+ const parent = messagesById.get(parentId)
567
+ if (parent === undefined) throw corrupt(`ledger message ${message.id} ${field} is dangling`)
568
+ if (parent.chainId !== message.chainId) throw corrupt(`ledger message ${message.id} ${field} crosses chains`)
569
+ if (message.hop !== parent.hop + 1) throw corrupt(`ledger message ${message.id} hop does not follow its parent`)
570
+ if (message.kind === 'reply') {
571
+ if (parent.kind !== 'request' || parent.to !== message.from) {
572
+ throw corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
573
+ }
574
+ } else if (parent.from !== message.from && parent.to !== message.from) {
575
+ throw corrupt(`ledger message ${message.id} parent does not involve the sender`)
576
+ }
577
+ }
578
+ }
579
+ for (const [key, operation] of Object.entries(state.operations)) validateOperation(key, operation)
580
+ if (state.creates !== undefined) validateCreates(state.creates)
581
+ if (state.transfers !== undefined) validateTransfers(state.transfers, messagesById)
582
+ return state
583
+ }
584
+
585
+ export class Ledger {
586
+ /**
587
+ * @param {{
588
+ * root: string,
589
+ * clock?: () => number,
590
+ * hostname?: string,
591
+ * pid?: number,
592
+ * processStart?: string,
593
+ * limits?: { maxDiskBytes?: number },
594
+ * }} options
595
+ */
596
+ constructor(options) {
597
+ if (typeof options?.root !== 'string' || options.root.length === 0) throw fail('invalid_argument', 'ledger root is required')
598
+ this.root = path.resolve(options.root)
599
+ this.statePath = path.join(this.root, LEDGER_FILE)
600
+ this.lockPath = path.join(this.root, LOCK_FILE)
601
+ this.lockDbPath = path.join(this.root, LOCK_DB_FILE)
602
+ this.clock = options.clock ?? Date.now
603
+ this.hostname = options.hostname ?? os.hostname()
604
+ this.pid = options.pid ?? process.pid
605
+ this.processStart =
606
+ options.processStart ?? new Date(Date.now() - process.uptime() * 1000).toISOString()
607
+ this.limits = {
608
+ maxDiskBytes: options.limits?.maxDiskBytes ?? LIMITS.MAX_LEDGER_BYTES,
609
+ }
610
+ if (!Number.isSafeInteger(this.limits.maxDiskBytes) || this.limits.maxDiskBytes <= 0) {
611
+ throw fail('invalid_argument', 'ledger maxDiskBytes must be a positive safe integer')
612
+ }
613
+ this.#queue = new SerialQueue()
614
+ this.#opened = false
615
+ this.#state = undefined
616
+ this.#lockNonce = undefined
617
+ this.#lockDb = undefined
618
+ this.#poisoned = undefined
619
+ }
620
+
621
+ #queue
622
+ #opened
623
+ #state
624
+ #lockNonce
625
+ #lockDb
626
+ #poisoned
627
+
628
+ /** @returns {Promise<Ledger>} */
629
+ async open() {
630
+ if (this.#opened) return this
631
+ // A failed open may have poisoned this instance mid-migration. A fresh
632
+ // open starts from the on-disk state and must re-evaluate health.
633
+ this.#poisoned = undefined
634
+ await fs.mkdir(this.root, { recursive: true, mode: 0o700 })
635
+ await fs.chmod(this.root, 0o700)
636
+ await this.#acquireLock()
637
+ try {
638
+ this.#state = await this.#load()
639
+ this.#opened = true
640
+ return this
641
+ } catch (error) {
642
+ await this.#releaseLock().catch(() => undefined)
643
+ throw error
644
+ }
645
+ }
646
+
647
+ get opened() {
648
+ return this.#opened
649
+ }
650
+
651
+ /** @returns {boolean} */
652
+ get writable() {
653
+ return this.#opened && this.#poisoned === undefined
654
+ }
655
+
656
+ /** @returns {Record<string, any> | undefined} */
657
+ get storageError() {
658
+ return this.#poisoned === undefined ? undefined : structuredClone(this.#poisoned)
659
+ }
660
+
661
+ /** @returns {Record<string, any>} detached deep copy of the current state */
662
+ read() {
663
+ if (!this.#opened) throw fail('internal', 'ledger is not open')
664
+ return structuredClone(this.#state)
665
+ }
666
+
667
+ /**
668
+ * @returns {{
669
+ * root: string, messageCount: number, chainCount: number, revision: number,
670
+ * bytes: number, maxBytes: number,
671
+ * health: 'ok'|'near_capacity'|'full'|'storage_error',
672
+ * error?: Record<string, any>,
673
+ * }}
674
+ */
675
+ stats() {
676
+ const state = this.read()
677
+ const bytes = Buffer.byteLength(JSON.stringify(state), 'utf8')
678
+ const maxBytes = this.limits.maxDiskBytes
679
+ let health = 'ok'
680
+ if (this.#poisoned !== undefined) health = 'storage_error'
681
+ else if (bytes >= maxBytes) health = 'full'
682
+ else if (bytes >= maxBytes * 0.9) health = 'near_capacity'
683
+ return {
684
+ root: this.root,
685
+ messageCount: state.messages.length,
686
+ chainCount: Object.keys(state.chains).length,
687
+ revision: state.revision,
688
+ bytes,
689
+ maxBytes,
690
+ health,
691
+ ...(this.#poisoned === undefined ? {} : { error: structuredClone(this.#poisoned) }),
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Serialize one read-modify-write transaction.
697
+ *
698
+ * A task may return {@link skipMutation} to report a value without writing.
699
+ * Otherwise revision is incremented, the full state is durably replaced, and
700
+ * only then does the new state become visible to readers.
701
+ *
702
+ * @template T
703
+ * @param {(state: Record<string, any>) => T | { [SKIP]: true, value: T }} task
704
+ * @param {{ expectedRevision?: number, delivery?: boolean }} [options]
705
+ * @returns {Promise<T>}
706
+ */
707
+ mutate(task, options = {}) {
708
+ return this.#queue.run(async () => {
709
+ if (!this.#opened) throw fail('internal', 'ledger is not open')
710
+ if (this.#poisoned !== undefined) throw this.#poisonError()
711
+ const draft = structuredClone(this.#state)
712
+ if (options.expectedRevision !== undefined && draft.revision !== options.expectedRevision) {
713
+ throw fail('revision_conflict', 'state changed since the panel snapshot; refresh and retry', {
714
+ currentRevision: draft.revision,
715
+ })
716
+ }
717
+ const result = task(draft)
718
+ if (isPlainObject(result) && result[SKIP] === true) return result.value
719
+ draft.revision = this.#state.revision + 1
720
+ draft.updatedAt = this.clock()
721
+ if (options.delivery === true) this.#assertCapacity(draft)
722
+ await this.#persist(draft)
723
+ this.#state = draft
724
+ return cloneResult(result)
725
+ })
726
+ }
727
+
728
+ async close() {
729
+ if (!this.#opened) return
730
+ await this.#queue.run(async () => {
731
+ this.#opened = false
732
+ })
733
+ await this.#releaseLock()
734
+ }
735
+
736
+ /** @returns {never} */
737
+ #poisonError() {
738
+ return fail('storage_error', 'session-link ledger is in storage_error; safe reopen required', {
739
+ ...(this.#poisoned ?? {}),
740
+ })
741
+ }
742
+
743
+ /** @param {{ stage: string, mayHaveWritten: boolean, message: string }} detail */
744
+ #poison(detail) {
745
+ if (this.#poisoned !== undefined) return
746
+ this.#poisoned = {
747
+ code: 'storage_error',
748
+ stage: detail.stage,
749
+ mayHaveWritten: detail.mayHaveWritten,
750
+ message: detail.message,
751
+ at: this.clock(),
752
+ }
753
+ }
754
+
755
+ /**
756
+ * @param {Record<string, any>} state
757
+ * @returns {void}
758
+ */
759
+ #assertCapacity(state) {
760
+ const bytes = Buffer.byteLength(JSON.stringify(state), 'utf8')
761
+ if (bytes > this.limits.maxDiskBytes) {
762
+ throw fail('storage_full', 'session-link ledger capacity is full', {
763
+ bytes,
764
+ maxBytes: this.limits.maxDiskBytes,
765
+ })
766
+ }
767
+ }
768
+
769
+ /** @returns {Promise<Record<string, any>>} */
770
+ async #load() {
771
+ let raw
772
+ try {
773
+ raw = await fs.readFile(this.statePath)
774
+ } catch (error) {
775
+ if (error?.code === 'ENOENT') return initialState(this.clock())
776
+ throw fail('storage_corrupt', `ledger cannot be read: ${errorCodeOf(error)}`)
777
+ }
778
+ let parsed
779
+ try {
780
+ parsed = JSON.parse(raw.toString('utf8'))
781
+ } catch {
782
+ throw fail('storage_corrupt', 'ledger JSON is corrupt; refusing to overwrite it')
783
+ }
784
+ if (!isPlainObject(parsed)) throw corrupt('ledger state is not an object')
785
+ if (parsed.version === 1) {
786
+ const v1 = validateV1State(parsed)
787
+ const fromStateSha256 = sha256(raw)
788
+ const backupPath = await this.#writeExactBackup(raw, fromStateSha256, V1_BACKUP_PREFIX, 'v1')
789
+ const migratedV2 = migrateV1State(v1, {
790
+ now: this.clock(),
791
+ fromStateSha256,
792
+ backupPath,
793
+ })
794
+ // Never persist a migration product that the next open would reject.
795
+ // The raw v1 file and backup remain untouched if this fails.
796
+ validateV2State(migratedV2)
797
+ // The v1 -> v2 rules stay frozen at schema 2. Take an exact private v2
798
+ // snapshot of that validated product, then run the separate v2 -> v3
799
+ // conversion so a later 0.3 change cannot pollute the historical chain.
800
+ const v2Bytes = Buffer.from(JSON.stringify(migratedV2), 'utf8')
801
+ const v2Digest = sha256(v2Bytes)
802
+ const v2BackupPath = await this.#writeExactBackup(v2Bytes, v2Digest, V2_BACKUP_PREFIX, 'v2')
803
+ const migratedV3 = migrateV2State(migratedV2, {
804
+ now: this.clock(),
805
+ fromStateSha256: v2Digest,
806
+ backupPath: v2BackupPath,
807
+ })
808
+ validateV3State(migratedV3)
809
+ await this.#persist(migratedV3)
810
+ return migratedV3
811
+ }
812
+ if (parsed.version === V2_STATE_VERSION) {
813
+ const v2 = validateV2State(parsed)
814
+ const v2Digest = sha256(raw)
815
+ const v2BackupPath = await this.#writeExactBackup(raw, v2Digest, V2_BACKUP_PREFIX, 'v2')
816
+ const migrated = migrateV2State(v2, {
817
+ now: this.clock(),
818
+ fromStateSha256: v2Digest,
819
+ backupPath: v2BackupPath,
820
+ })
821
+ validateV3State(migrated)
822
+ await this.#persist(migrated)
823
+ return migrated
824
+ }
825
+ if (parsed.version === STATE_VERSION) return validateV3State(parsed)
826
+ throw corrupt(`ledger uses unsupported state version ${String(parsed.version)}`)
827
+ }
828
+
829
+ /**
830
+ * Write exact bytes under an O_EXCL backup name. An existing file is reused
831
+ * only when its digest matches; a backup is never overwritten.
832
+ * @param {Buffer} raw
833
+ * @param {string} digest
834
+ * @param {string} prefix
835
+ * @param {string} label
836
+ * @returns {Promise<string>}
837
+ */
838
+ async #writeExactBackup(raw, digest, prefix, label) {
839
+ const backupPath = path.join(this.root, `${prefix}${digest}.json`)
840
+ try {
841
+ const handle = await fs.open(backupPath, 'wx', 0o600)
842
+ try {
843
+ await handle.writeFile(raw)
844
+ await handle.sync()
845
+ } finally {
846
+ await handle.close()
847
+ }
848
+ await this.#fsyncDir()
849
+ return backupPath
850
+ } catch (error) {
851
+ if (error?.code === 'EEXIST') {
852
+ const existing = await fs.readFile(backupPath)
853
+ if (sha256(existing) !== digest) {
854
+ throw fail('storage_error', `an existing ${label} backup does not match the ledger being migrated`, {
855
+ backupPath,
856
+ })
857
+ }
858
+ await fs.chmod(backupPath, 0o600)
859
+ return backupPath
860
+ }
861
+ await fs.unlink(backupPath).catch(() => undefined)
862
+ throw fail('storage_failure', `${label} backup cannot be written: ${errorCodeOf(error)}`)
863
+ }
864
+ }
865
+
866
+ /** @param {Record<string, any>} state @returns {Promise<void>} */
867
+ async #persist(state) {
868
+ await this.#persistRaw(JSON.stringify(state))
869
+ }
870
+
871
+ /** @param {string|Buffer} json @returns {Promise<void>} */
872
+ async #persistRaw(json) {
873
+ const temp = path.join(this.root, `.${LEDGER_FILE}.${this.pid}.${randomUUID()}.tmp`)
874
+ let handle
875
+ let stage = 'write'
876
+ try {
877
+ handle = await fs.open(temp, 'wx', 0o600)
878
+ await handle.writeFile(json)
879
+ await handle.sync()
880
+ await handle.close()
881
+ handle = undefined
882
+ stage = 'rename'
883
+ await fs.rename(temp, this.statePath)
884
+ stage = 'chmod'
885
+ await fs.chmod(this.statePath, 0o600)
886
+ stage = 'fsync'
887
+ await this.#fsyncDir()
888
+ } catch (error) {
889
+ if (handle !== undefined) await handle.close().catch(() => undefined)
890
+ await fs.unlink(temp).catch(() => undefined)
891
+ const mayHaveWritten = stage === 'rename' || stage === 'chmod' || stage === 'fsync'
892
+ this.#poison({
893
+ stage,
894
+ mayHaveWritten,
895
+ message: `ledger write failed: ${errorCodeOf(error)}`,
896
+ })
897
+ throw fail('storage_error', 'ledger write failed; safe reopen required before further writes', {
898
+ stage,
899
+ mayHaveWritten,
900
+ })
901
+ }
902
+ }
903
+
904
+ async #fsyncDir() {
905
+ let handle
906
+ try {
907
+ handle = await fs.open(this.root, 'r')
908
+ await handle.sync()
909
+ } catch (error) {
910
+ // Directory fsync is unsupported on a few filesystems; a failed metadata
911
+ // flush is still a storage failure on the supported Linux target.
912
+ if (error?.code === 'EINVAL' || error?.code === 'ENOTSUP' || error?.code === 'EISDIR') return
913
+ throw error
914
+ } finally {
915
+ await handle?.close().catch(() => undefined)
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Acquire the one-writer claim with an OS-backed SQLite EXCLUSIVE
921
+ * transaction held for the life of this Ledger. The kernel releases it on
922
+ * any process exit, so a crashed writer needs no stale-pid heuristics and
923
+ * two concurrent recoverers cannot both unlink and rebuild a lock file.
924
+ *
925
+ * `writer.lock` remains as bounded, human-readable metadata; it is written
926
+ * only while the database transaction is held and is never consulted to
927
+ * decide ownership.
928
+ * @returns {Promise<void>}
929
+ */
930
+ async #acquireLock() {
931
+ try {
932
+ const handle = await fs.open(this.lockDbPath, 'a', 0o600)
933
+ await handle.close()
934
+ await fs.chmod(this.lockDbPath, 0o600)
935
+ } catch (error) {
936
+ throw fail('storage_failure', `writer lock database cannot be created: ${errorCodeOf(error)}`)
937
+ }
938
+
939
+ let db
940
+ try {
941
+ db = new DatabaseSync(this.lockDbPath)
942
+ db.exec('PRAGMA busy_timeout = 0')
943
+ db.exec('BEGIN EXCLUSIVE')
944
+ } catch (error) {
945
+ db?.close?.()
946
+ if (isLocked(error)) {
947
+ throw fail('lock_active', 'session-link state is locked by another live writer')
948
+ }
949
+ throw fail('storage_failure', `writer lock cannot be acquired: ${errorCodeOf(error)}`)
950
+ }
951
+
952
+ const nonce = randomUUID()
953
+ const metadata = {
954
+ version: 1,
955
+ plugin: 'dsh-session-link',
956
+ hostname: this.hostname,
957
+ pid: this.pid,
958
+ processStart: this.processStart,
959
+ nonce,
960
+ createdAt: this.clock(),
961
+ mechanism: 'sqlite-exclusive',
962
+ }
963
+ try {
964
+ db.exec(`
965
+ CREATE TABLE IF NOT EXISTS writer (
966
+ id INTEGER PRIMARY KEY CHECK (id = 1),
967
+ version INTEGER NOT NULL,
968
+ plugin TEXT NOT NULL,
969
+ hostname TEXT NOT NULL,
970
+ pid INTEGER NOT NULL,
971
+ processStart TEXT NOT NULL,
972
+ nonce TEXT NOT NULL,
973
+ createdAt INTEGER NOT NULL
974
+ )
975
+ `)
976
+ db.exec('DELETE FROM writer')
977
+ db.prepare(
978
+ 'INSERT INTO writer (id, version, plugin, hostname, pid, processStart, nonce, createdAt) VALUES (1, ?, ?, ?, ?, ?, ?, ?)',
979
+ ).run(metadata.version, metadata.plugin, metadata.hostname, metadata.pid, metadata.processStart, metadata.nonce, metadata.createdAt)
980
+ await fs.writeFile(this.lockPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 })
981
+ await fs.chmod(this.lockPath, 0o600)
982
+ await this.#fsyncDir()
983
+ } catch (error) {
984
+ try {
985
+ db.exec('ROLLBACK')
986
+ } catch {}
987
+ db.close()
988
+ throw fail('storage_failure', `writer lock metadata cannot be written: ${errorCodeOf(error)}`)
989
+ }
990
+ this.#lockDb = db
991
+ this.#lockNonce = nonce
992
+ }
993
+
994
+ /** @returns {Promise<void>} */
995
+ async #releaseLock() {
996
+ if (this.#lockDb === undefined) return
997
+ const db = this.#lockDb
998
+ this.#lockDb = undefined
999
+ const nonce = this.#lockNonce
1000
+ this.#lockNonce = undefined
1001
+ try {
1002
+ db.exec('COMMIT')
1003
+ } catch {
1004
+ try {
1005
+ db.exec('ROLLBACK')
1006
+ } catch {}
1007
+ }
1008
+ try {
1009
+ db.close()
1010
+ } finally {
1011
+ let raw
1012
+ try {
1013
+ raw = await fs.readFile(this.lockPath, 'utf8')
1014
+ } catch (error) {
1015
+ if (error?.code !== 'ENOENT') throw fail('storage_failure', 'writer lock metadata cannot be read during release')
1016
+ }
1017
+ if (raw !== undefined) {
1018
+ let metadata
1019
+ try {
1020
+ metadata = JSON.parse(raw)
1021
+ } catch {
1022
+ metadata = undefined
1023
+ }
1024
+ if (metadata?.nonce === nonce) {
1025
+ await fs.unlink(this.lockPath).catch((error) => {
1026
+ if (error?.code !== 'ENOENT') throw fail('storage_failure', 'writer lock metadata cannot be removed')
1027
+ })
1028
+ await this.#fsyncDir()
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Offline rollback helper for Lead-owned scripts. The caller must ensure the
1036
+ * running service is stopped; this method still requires the writer lock and
1037
+ * re-checks the preflight inside it. It writes a v2 backup, then atomically
1038
+ * replaces ledger.json with the exact v1 backup (emergencyPaused=true).
1039
+ * It is intentionally not exposed by the running plugin/service.
1040
+ * @returns {Promise<{ restored: true, v2BackupPath: string, v1BackupPath: string }>}
1041
+ */
1042
+ async restoreV1Backup() {
1043
+ if (!this.#opened) throw fail('internal', 'ledger is not open')
1044
+ if (this.#poisoned !== undefined) throw this.#poisonError()
1045
+ const state = this.read()
1046
+ // A v3 ledger has already run the separate v2 -> v3 upgrade. Restoring the
1047
+ // old v1 bytes here would silently discard schema-3 receipts and journals;
1048
+ // the only supported v3 exit is the exact v2 backup restore below.
1049
+ if (state.version !== V2_STATE_VERSION) {
1050
+ throw fail('rollback_conflict', 'v1 restore only supports a v2 ledger; use the v2 upgrade backup for 0.3', {
1051
+ stateVersion: state.version,
1052
+ })
1053
+ }
1054
+ const marker = state.migration
1055
+ if (!isPlainObject(marker)) throw fail('invalid_argument', 'ledger has no v1 migration marker')
1056
+ const v1BackupPath = path.resolve(String(marker.backupPath))
1057
+ if (
1058
+ path.dirname(v1BackupPath) !== this.root ||
1059
+ !path.basename(v1BackupPath).startsWith(V1_BACKUP_PREFIX)
1060
+ ) {
1061
+ throw fail('storage_error', 'migration backup path is outside the state root')
1062
+ }
1063
+ let raw
1064
+ try {
1065
+ raw = await fs.readFile(v1BackupPath)
1066
+ } catch (error) {
1067
+ throw fail('storage_failure', `v1 backup cannot be read for rollback: ${errorCodeOf(error)}`)
1068
+ }
1069
+ const v1 = validateV1State(JSON.parse(raw.toString('utf8')))
1070
+ const preflight = rollbackPreflight({ currentState: state, migration: marker, backupRaw: raw })
1071
+ if (preflight.ok !== true) {
1072
+ throw fail('rollback_conflict', 'rollback preflight rejected; keep 0.2 and export data for manual merge', {
1073
+ reasons: preflight.reasons,
1074
+ })
1075
+ }
1076
+ const currentJson = JSON.stringify(state)
1077
+ const v2Digest = sha256(Buffer.from(currentJson, 'utf8'))
1078
+ const v2BackupPath = await this.#writeExactBackup(
1079
+ Buffer.from(currentJson, 'utf8'),
1080
+ v2Digest,
1081
+ V2_ROLLBACK_BACKUP_PREFIX,
1082
+ 'rollback v2',
1083
+ )
1084
+ v1.settings.emergencyPaused = true
1085
+ v1.updatedAt = this.clock()
1086
+ await this.#persist(v1)
1087
+ this.#state = v1
1088
+ return { restored: true, v2BackupPath, v1BackupPath }
1089
+ }
1090
+
1091
+ /**
1092
+ * Offline v3 -> v2 restore for Lead-owned scripts. It requires the exact v2
1093
+ * backup recorded by the v2 -> v3 upgrade and rejects any write after the
1094
+ * upgrade (revision/settingsRevision or baseline content changed). It writes
1095
+ * an exact v3 backup first, then atomically replaces ledger.json with the
1096
+ * original v2 bytes. The running service must be stopped; this method is not
1097
+ * exposed by the plugin.
1098
+ * @returns {Promise<{ restored: true, v3BackupPath: string, v2BackupPath: string }>}
1099
+ */
1100
+ async restoreV2Backup() {
1101
+ if (!this.#opened) throw fail('internal', 'ledger is not open')
1102
+ if (this.#poisoned !== undefined) throw this.#poisonError()
1103
+ const state = this.read()
1104
+ if (state.version !== STATE_VERSION) {
1105
+ throw fail('rollback_conflict', 'v2 upgrade restore only supports a v3 ledger', {
1106
+ stateVersion: state.version,
1107
+ })
1108
+ }
1109
+ const upgrade = state.upgrade
1110
+ if (!isPlainObject(upgrade)) throw fail('invalid_argument', 'ledger has no v2 upgrade marker')
1111
+ const v2BackupPath = path.resolve(String(upgrade.backupPath))
1112
+ if (
1113
+ path.dirname(v2BackupPath) !== this.root ||
1114
+ !path.basename(v2BackupPath).startsWith(V2_BACKUP_PREFIX)
1115
+ ) {
1116
+ throw fail('storage_error', 'upgrade backup path is outside the state root')
1117
+ }
1118
+ let raw
1119
+ try {
1120
+ raw = await fs.readFile(v2BackupPath)
1121
+ } catch (error) {
1122
+ throw fail('storage_failure', `v2 backup cannot be read for rollback: ${errorCodeOf(error)}`)
1123
+ }
1124
+ let v2
1125
+ try {
1126
+ v2 = validateV2State(JSON.parse(raw.toString('utf8')))
1127
+ } catch {
1128
+ throw fail('rollback_conflict', 'the recorded v2 upgrade backup is invalid; refusing to replace v3')
1129
+ }
1130
+ const preflight = v3ToV2Preflight({ currentState: state, upgrade, backupRaw: raw })
1131
+ if (preflight.ok !== true) {
1132
+ throw fail('rollback_conflict', 'v3 -> v2 preflight rejected; no post-upgrade transaction is allowed', {
1133
+ reasons: preflight.reasons,
1134
+ })
1135
+ }
1136
+ const currentJson = Buffer.from(JSON.stringify(state), 'utf8')
1137
+ const v3Digest = sha256(currentJson)
1138
+ const v3BackupPath = await this.#writeExactBackup(
1139
+ currentJson,
1140
+ v3Digest,
1141
+ V3_ROLLBACK_BACKUP_PREFIX,
1142
+ 'rollback v3',
1143
+ )
1144
+ await this.#persistRaw(raw)
1145
+ this.#state = v2
1146
+ return { restored: true, v3BackupPath, v2BackupPath }
1147
+ }
1148
+ }
1149
+
1150
+ /** @param {unknown} error @returns {boolean} */
1151
+ function isLocked(error) {
1152
+ return (
1153
+ (error && typeof error === 'object' && (error.errcode === 5 || error.errstr === 'database is locked')) ||
1154
+ (error instanceof Error && error.message.toLowerCase().includes('database is locked'))
1155
+ )
1156
+ }
1157
+
1158
+ /** @param {unknown} error @returns {string} */
1159
+ function errorCodeOf(error) {
1160
+ if (error && typeof error === 'object' && typeof error.code === 'string') return error.code
1161
+ return 'unknown'
1162
+ }
1163
+
1164
+ /** @param {unknown} value */
1165
+ function cloneResult(value) {
1166
+ if (value === undefined) return undefined
1167
+ try {
1168
+ return structuredClone(value)
1169
+ } catch {
1170
+ return value
1171
+ }
1172
+ }
1173
+
1174
+ export { rollbackPreflight } from './migration.js'