weir-harness 1.2.3 → 2.1.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 (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,301 +0,0 @@
1
- /// <reference path="../capabilities/store/checked-contract.d.ts" />
2
- import { mkdirSync, realpathSync, lstatSync, rmdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, fsyncSync, closeSync, constants } from 'node:fs'
3
- import { dirname, basename, join } from 'node:path'
4
- import { randomBytes } from 'node:crypto'
5
- import { createLiveness } from '../capabilities/store/liveness.js'
6
- import { parseOwnerDoc } from '../capabilities/store/lock.js'
7
- import { canonical } from './snapshot.js'
8
-
9
- /** Deterministic sibling of a canonical authority directory.
10
- * @param {string} authority */
11
- export function reservationPathFor(authority) {
12
- return join(dirname(authority), `.${basename(authority)}.publisher-reservation`)
13
- }
14
-
15
- // Lease defaults (spike 0.1, openspec edit-lock-autonomous-recovery): the lease
16
- // bounds crash-detection latency, the renewal period bounds its cost. Renewal
17
- // is a temp+rename of one small file and costs single-digit milliseconds even
18
- // under load, so a 30s/10s pair keeps detection latency human-scale at
19
- // negligible IO cost. A timeout ALONE never justifies reclamation: the
20
- // recorded owner must also be proven dead by the shared Liveness adapter.
21
- export const RESERVATION_LEASE_MS = 30_000
22
- export const RESERVATION_RENEW_MS = 10_000
23
-
24
- const OWNER_FILE = 'owner.json'
25
-
26
- /** @param {unknown} error @param {...string} codes */
27
- const hasCode = (error, ...codes) => codes.includes(/** @type {{ code?: string }} */ (error)?.code ?? '')
28
-
29
- /** Read and parse the owner document of a reservation directory.
30
- * @param {string} path @returns {ReturnType<typeof parseOwnerDoc>} */
31
- function readOwner(path) {
32
- let text
33
- try {
34
- text = readFileSync(join(path, OWNER_FILE), 'utf8')
35
- } catch (error) {
36
- if (hasCode(error, 'ENOENT')) return { kind: /** @type {const} */ ('absent') }
37
- throw error
38
- }
39
- return parseOwnerDoc(text)
40
- }
41
-
42
- /** Atomic full-content owner publication inside the reservation directory
43
- * (temp + rename; a crash leaves an orphan temp, never a torn owner.json).
44
- * @param {string} path @param {import('../capabilities/store/lock.js').OwnerDoc} doc */
45
- function writeOwner(path, doc) {
46
- const temporary = join(path, `.owner-${doc.ownerToken}.tmp`)
47
- writeFileSync(temporary, JSON.stringify(doc), { mode: 0o600 })
48
- renameSync(temporary, join(path, OWNER_FILE))
49
- }
50
-
51
- // fsync on a directory handle is a POSIX crash-durability idiom: on Windows
52
- // the handle opens but fsync fails EPERM (verified on Node v24, NTFS), so the
53
- // win32 row skips it — the owner temp+rename stands on NTFS journaling.
54
- /** @param {string} directory */
55
- function syncParent(directory) {
56
- if (process.platform !== 'darwin' && process.platform !== 'linux') return
57
- const handle = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0))
58
- try { fsyncSync(handle) } finally { closeSync(handle) }
59
- }
60
-
61
- /** True while the recorded owner document is exactly the one we observed
62
- * (same token, same lease): any renewal or replacement aborts a reclaim.
63
- * @param {import('../capabilities/store/lock.js').OwnerDoc} a @param {import('../capabilities/store/lock.js').OwnerDoc} b */
64
- const sameOwner = (a, b) => canonical(a) === canonical(b)
65
-
66
- /** Conservative cross-process reservation for one preconfigured authority
67
- * directory on a local filesystem. All cooperating hosts MUST use the same
68
- * authority directory; this is not discovery for overlapping workspace roots.
69
- *
70
- * Liveness stance (design D1): the reservation carries an owner document
71
- * (pid/host/startIdentity/lease, schema-aligned with the capability-store
72
- * OwnerDoc) that the holder renews periodically. A crash leaves the directory
73
- * in place; a newcomer finding it reclaims it ONLY when the lease has expired
74
- * AND the recorded owner is proven dead (same host, same boot, pid gone or
75
- * start-identity mismatch) — through a serialized `<reservation>.recover`
76
- * lock with a re-read before the rename, so a reservation naming a live owner
77
- * is never removed. Every other outcome (lease current, owner alive, owner
78
- * state unknown, owner document absent/corrupt) keeps the historical
79
- * behavior: the newcomer becomes a client (EEXIST).
80
- * @param {string} directory
81
- * @param {{ liveness?: import('../capabilities/store/liveness.js').Liveness,
82
- * leaseMs?: number, renewMs?: number, now?: () => number,
83
- * token?: () => string,
84
- * testing?: { checkpoint?: (point: string) => void|Promise<void> }}} [options] */
85
- export async function reservePublisher(directory, options = {}) {
86
- const authority = realpathSync.native(directory)
87
- if (!lstatSync(authority).isDirectory()) throw new Error('authority directory required')
88
- if (dirname(authority) === authority) throw new Error('filesystem root cannot be authority directory')
89
- const liveness = options.liveness ?? createLiveness()
90
- const leaseMs = options.leaseMs ?? RESERVATION_LEASE_MS
91
- const renewMs = options.renewMs ?? RESERVATION_RENEW_MS
92
- const now = options.now ?? Date.now
93
- const token = options.token ?? (() => randomBytes(12).toString('hex'))
94
- const testing = options.testing ?? {}
95
- // Keep the store directory empty for initial creation; use a deterministic
96
- // sibling reservation tied to the canonical authority path.
97
- const path = reservationPathFor(authority)
98
-
99
- /** @param {string} ownerToken @returns {Promise<import('../capabilities/store/lock.js').OwnerDoc>} */
100
- async function ownerDoc(ownerToken) {
101
- const startIdentity = await liveness.identity()
102
- const at = now()
103
- return {
104
- schemaVersion: 1, ownerToken, pid: liveness.pid, host: liveness.host,
105
- startIdentity, acquiredAt: at, leaseUntil: at + leaseMs,
106
- }
107
- }
108
-
109
- /**
110
- * Serialized reclamation of a reservation whose observed owner is expired
111
- * and proven dead. The `<reservation>.recover` directory serializes
112
- * concurrent reclaimers and stays GENUINELY exclusive for the whole
113
- * takeover → reservation-replacement → cleanup sequence:
114
- *
115
- * - An abandoned recovery lock is taken over only after re-verifying, past
116
- * the awaited death check, that the directory renamed aside is still the
117
- * exact one observed (same dev/ino, same owner document). A live
118
- * reclaimer that took over in the window changed both, so a stale
119
- * observation can never rename a LIVE recovery lock aside.
120
- * - The takeover immediately publishes OUR owner document and anchors the
121
- * lock to the created directory's dev/ino identity.
122
- * - Immediately before the reservation rename, the recovery lock must still
123
- * carry our identity AND our owner document, and the reservation must
124
- * still name the observed dead owner. Any drift loses the reclaim.
125
- * - Cleanup removes the recovery lock only while it is still the directory
126
- * we created, so a loser never deletes the winner's lock.
127
- *
128
- * Test-only checkpoints surround the decision points; they never replace
129
- * filesystem calls. @param {import('../capabilities/store/lock.js').OwnerDoc} observed */
130
- async function reclaim(observed) {
131
- const recoverPath = `${path}.recover`
132
- const recoverToken = token()
133
- let held = false
134
- /** @type {{ dev: bigint, ino: bigint }|null} */
135
- let ours = null
136
- /** The dev/ino of the recovery directory right now (null when absent).
137
- * @returns {{ dev: bigint, ino: bigint }|null} */
138
- const statRecover = () => {
139
- try {
140
- const stat = lstatSync(recoverPath, { bigint: true })
141
- return stat.isDirectory() ? { dev: stat.dev, ino: stat.ino } : null
142
- } catch (error) {
143
- if (hasCode(error, 'ENOENT')) return null
144
- throw error
145
- }
146
- }
147
- /** @param {string} message */
148
- const lost = message => Object.assign(new Error(`${message}: EEXIST`), { code: 'EEXIST' })
149
- try {
150
- mkdirSync(recoverPath, { mode: 0o700 })
151
- held = true
152
- } catch (error) {
153
- if (!hasCode(error, 'EEXIST')) throw error
154
- // A previous reclaimer holds (or abandoned) the serialization lock. It
155
- // can be retaken only when its own owner is expired and proven dead; a
156
- // crashed reclaimer that already renamed the reservation aside left NO
157
- // reservation behind, so an existing reservation naming the dead owner
158
- // means the interrupted reclaim never moved it. Anything else fails
159
- // closed: the caller becomes a client, exactly as with a live publisher.
160
- const observedLock = statRecover()
161
- const recover = readOwner(recoverPath)
162
- if (observedLock === null || recover.kind !== 'ok' || recover.value.leaseUntil > now() ||
163
- (await liveness.state(recover.value)) !== 'dead') throw error
164
- await testing.checkpoint?.('takeover-observed')
165
- // The awaited death check makes the observation above STALE: another
166
- // reclaimer may have completed its own takeover in between. Rename the
167
- // abandoned lock aside only while it is still the exact directory we
168
- // observed — same dev/ino and same owner document. Renaming a LIVE
169
- // reclaimer's lock aside on a stale observation would hand two
170
- // processes the serialization of one reclaim.
171
- const current = statRecover()
172
- const again = readOwner(recoverPath)
173
- if (current === null || current.dev !== observedLock.dev || current.ino !== observedLock.ino ||
174
- again.kind !== 'ok' || !sameOwner(again.value, recover.value)) throw error
175
- try {
176
- renameSync(recoverPath, `${recoverPath}.stale.${token()}`)
177
- } catch (renameError) {
178
- // A racer moved the observed directory aside first: the takeover is
179
- // lost and the caller becomes a client, never a second reclaimer.
180
- if (hasCode(renameError, 'ENOENT')) throw error
181
- throw renameError
182
- }
183
- mkdirSync(recoverPath, { mode: 0o700 })
184
- held = true
185
- }
186
- try {
187
- // Anchor the lock to the directory WE created, then publish our owner
188
- // document at once: from here on an identity mismatch is a lost
189
- // takeover — never a reason to proceed, and never something to clean up.
190
- const created = lstatSync(recoverPath, { bigint: true })
191
- ours = { dev: created.dev, ino: created.ino }
192
- const doc = await ownerDoc(recoverToken)
193
- writeOwner(recoverPath, doc)
194
- // Immediately before the reservation rename, prove (a) the recovery
195
- // lock is still exclusively ours — same dev/ino captured at takeover
196
- // and still carrying OUR owner document — and (b) the reservation
197
- // still names the observed dead owner. Any drift aborts the reclaim.
198
- const lock = statRecover()
199
- const mine = readOwner(recoverPath)
200
- if (lock === null || lock.dev !== ours.dev || lock.ino !== ours.ino ||
201
- mine.kind !== 'ok' || !sameOwner(mine.value, doc)) throw lost('recovery lock lost before reclaim')
202
- const again = readOwner(path)
203
- if (again.kind !== 'ok' || !sameOwner(again.value, observed)) {
204
- throw lost('publisher reservation changed before reclaim')
205
- }
206
- await testing.checkpoint?.('before-reservation-rename')
207
- renameSync(path, `${path}.stale.${recoverToken}`)
208
- mkdirSync(path, { mode: 0o700 })
209
- syncParent(dirname(path))
210
- return true
211
- } finally {
212
- if (held && ours !== null) {
213
- // Remove the serialization lock ONLY while it is still the directory
214
- // we created: after a lost takeover the same path belongs to another
215
- // reclaimer, and deleting it would break their exclusion.
216
- const lock = statRecover()
217
- if (lock !== null && lock.dev === ours.dev && lock.ino === ours.ino) {
218
- try { unlinkSync(join(recoverPath, OWNER_FILE)) } catch (error) { if (!hasCode(error, 'ENOENT')) throw error }
219
- try { rmdirSync(recoverPath) } catch (error) { if (!hasCode(error, 'ENOENT')) throw error }
220
- }
221
- }
222
- }
223
- }
224
-
225
- // The owner document is computed BEFORE the election so the mkdir → publish
226
- // window is as short as the filesystem allows. A crash inside even that
227
- // window leaves a reservation without an owner document, which every
228
- // newcomer treats like the historical stale reservation: become a client.
229
- let doc = await ownerDoc(token())
230
- try {
231
- mkdirSync(path, { mode: 0o700 })
232
- } catch (error) {
233
- if (!hasCode(error, 'EEXIST')) throw error
234
- const observed = readOwner(path)
235
- // Any deviation from "expired lease AND provably dead owner" keeps the
236
- // historical client fallback: timeout alone never justifies reclamation.
237
- if (observed.kind !== 'ok' || observed.value.leaseUntil > now() ||
238
- (await liveness.state(observed.value)) !== 'dead' || !(await reclaim(observed.value))) {
239
- throw error
240
- }
241
- }
242
- writeOwner(path, doc)
243
- syncParent(dirname(path))
244
- const identity = lstatSync(path, { bigint: true })
245
- /** The renewal may only ever write into the directory WE created: after a
246
- * legitimate replacement (reclaim) the same path belongs to another owner,
247
- * and renaming our document into it would clobber their lease. */
248
- const ours = () => {
249
- try {
250
- const current = lstatSync(path, { bigint: true })
251
- return current.isDirectory() && current.dev === identity.dev && current.ino === identity.ino
252
- } catch { return false }
253
- }
254
- let released = false
255
- /** @type {unknown} */
256
- let renewalFailure
257
- const renewal = setInterval(() => {
258
- try {
259
- if (!ours()) throw new Error('publisher reservation replaced')
260
- doc = { ...doc, leaseUntil: now() + leaseMs }
261
- writeOwner(path, doc)
262
- } catch (error) {
263
- // A renewal that cannot land must not masquerade as a live lease:
264
- // exclusivity checks fail closed from here on; a later newcomer still
265
- // needs the death proof to reclaim, so this only hurts the owner.
266
- renewalFailure = error
267
- clearInterval(renewal)
268
- }
269
- }, renewMs)
270
- renewal.unref?.()
271
- function assertExclusive() {
272
- if (released) throw new Error('publisher reservation released')
273
- if (renewalFailure) throw new Error('publisher reservation renewal failed', { cause: renewalFailure })
274
- const current = lstatSync(path, { bigint: true })
275
- if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino
276
- || realpathSync.native(path) !== path) throw new Error('publisher reservation replaced')
277
- }
278
- return Object.freeze({
279
- assertExclusive,
280
- /** The owner document as last (re)published; read-only observation. */
281
- owner: () => ({ ...doc, startIdentity: { ...doc.startIdentity } }),
282
- /** The owner must close every admission and await every native publisher
283
- * before release. A failed drain MUST leave this reservation in place. */
284
- releaseAfterQuiescence() {
285
- if (released) return
286
- clearInterval(renewal)
287
- assertExclusive()
288
- // The resolved absolute target and original inode were checked above.
289
- // Never recursive: unexpected contents retain the reservation — intact,
290
- // owner document included, so a retained reservation stays reclaimable
291
- // under the same death-proof rule as a crashed one. Only our own
292
- // document (and its transient temp) are ever removed here.
293
- const contents = readdirSync(path).filter(entry => entry !== OWNER_FILE && entry !== `.owner-${doc.ownerToken}.tmp`)
294
- if (contents.length > 0) throw new Error(`publisher reservation has unexpected contents: ${contents[0]}`)
295
- try { unlinkSync(join(path, `.owner-${doc.ownerToken}.tmp`)) } catch (error) { if (!hasCode(error, 'ENOENT')) throw error }
296
- try { unlinkSync(join(path, OWNER_FILE)) } catch (error) { if (!hasCode(error, 'ENOENT')) throw error }
297
- rmdirSync(path)
298
- released = true
299
- },
300
- })
301
- }
@@ -1,45 +0,0 @@
1
- import { reservePublisher } from './reservation.js'
2
- import { openEditLockRuntime } from './runtime.js'
3
- import { createLiveness } from '../capabilities/store/liveness.js'
4
-
5
- /** Open only after exclusive reservation. A failure to OPEN releases it (nothing
6
- * was published); a crash or failed drain after opening retains it for
7
- * operator-assisted recovery. Callers never auto-promote after a disconnect.
8
- * `mode` may be decided only after the reservation is held.
9
- * @param {Omit<Parameters<typeof openEditLockRuntime>[0], 'assertExclusive'|'mode'> & {mode: 'create'|'recover'|(() => 'create'|'recover'),
10
- * reservation?: Parameters<typeof reservePublisher>[1]}} options */
11
- export async function openReservedEditLockRuntime(options) {
12
- // One Liveness adapter per domain open (design D1/D2): the reservation
13
- // owner document, the incarnation process identity and the dead-process
14
- // settlement probe all read the same process facts.
15
- const liveness = options.reservation?.liveness ?? createLiveness()
16
- const reservation = await reservePublisher(options.directory, { ...options.reservation, liveness })
17
- let runtime
18
- try {
19
- const mode = typeof options.mode === 'function' ? options.mode() : options.mode
20
- const startIdentity = await liveness.identity()
21
- const processIdentity = { pid: liveness.pid, host: liveness.host, osStart: startIdentity.osStart, bootNonce: startIdentity.bootNonce }
22
- runtime = await openEditLockRuntime({...options, mode, assertExclusive:reservation.assertExclusive, processIdentity, liveness})
23
- } catch (error) {
24
- // The runtime never opened, so this process published nothing under the
25
- // reservation: it is provably quiescent and must not strand its own claim.
26
- // Keeping it made the next attempt in the SAME process see EEXIST, take the
27
- // client path and fail closed against a socket nobody serves.
28
- try { reservation.releaseAfterQuiescence() } catch { /* replaced: leave it for a human */ }
29
- throw error
30
- }
31
- /** @type {Promise<void> | undefined} */
32
- let closing
33
- return Object.freeze({
34
- control: runtime.control,
35
- requests: runtime.requests,
36
- automaticRecovery: runtime.automaticRecovery ?? null,
37
- close() {
38
- if (!closing) closing = (async () => {
39
- await runtime.close()
40
- reservation.releaseAfterQuiescence()
41
- })()
42
- return closing
43
- },
44
- })
45
- }
@@ -1,130 +0,0 @@
1
- import { lstatSync, readlinkSync, realpathSync, statSync } from 'node:fs'
2
- import { dirname, isAbsolute } from 'node:path'
3
-
4
- /**
5
- * @typedef {Readonly<{ kind: 'file', resourceId: string }> |
6
- * Readonly<{ kind: 'missing', ancestor: string, suffix: string }>} Observation
7
- * @typedef {{ value: Observation, witnesses: string[], links: number }} Inspection
8
- * @typedef {{ filePath: string, cwd: string, kind: Observation['kind'],
9
- * witnesses: string[], links: number }} Evidence
10
- */
11
-
12
- /**
13
- * Read-only filesystem observations, not ownership or publication authority.
14
- * Filesystem calls are not an atomic snapshot: callers must coordinate mutation.
15
- * dev/ino continuity cannot detect inode reuse or change-and-restore (ABA).
16
- *
17
- * Verified platform rows: darwin/linux (POSIX spellings) and win32
18
- * (openspec windows-platform-adaptation: drive-letter absolute spellings with
19
- * both separators normalized to '/'; NTFS dev/ino/nlink verified real on Node
20
- * v24; junctions are reparse points and surface as symbolic links). UNC and
21
- * device paths are rejected on win32 — network shares are outside the
22
- * supported envelope (edit-lock.md).
23
- * @param {{ platform?: string }} [options]
24
- */
25
- export function createResourceIdentity({ platform = process.platform } = {}) {
26
- if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
27
- throw new Error(`unsupported platform: ${platform}`)
28
- }
29
- const win = platform === 'win32'
30
- /** @type {WeakMap<object, Evidence>} */
31
- const observations = new WeakMap()
32
- /** @param {string} filePath @param {{ cwd: string }} options */
33
- function resolve(filePath, { cwd }) {
34
- if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new TypeError('expected absolute cwd')
35
- if (typeof filePath !== 'string' || filePath.length === 0) throw new TypeError('expected non-empty path')
36
- if (filePath.includes('\0') || cwd.includes('\0')) throw new TypeError('NUL in path')
37
- const joined = isAbsolute(filePath) ? filePath : `${cwd}/${filePath}`
38
- const result = inspect(win ? normalizeWin(joined) : joined, win)
39
- const observation = Object.freeze(result.value)
40
- observations.set(observation, { filePath, cwd, kind: observation.kind, witnesses: result.witnesses, links: result.links })
41
- return observation
42
- }
43
- return {
44
- resolve,
45
- /** @param {unknown} observation @returns {Observation} */
46
- revalidate(observation) {
47
- if (observation === null || typeof observation !== 'object') throw new Error('unknown observation')
48
- const original = observations.get(observation)
49
- if (!original) throw new Error('unknown observation')
50
- const fresh = resolve(original.filePath, { cwd: original.cwd })
51
- const current = observations.get(fresh)
52
- if (!current || fresh.kind !== original.kind || current.links !== original.links ||
53
- original.witnesses.some((witness, index) => witness !== current.witnesses[index])) {
54
- throw new Error('topology changed')
55
- }
56
- return fresh
57
- },
58
- }
59
- }
60
-
61
- /** Canonical spelling for the win32 walk: single forward slashes under a
62
- * drive-letter root. UNC/device roots are rejected, never reinterpreted.
63
- * @param {string} spelling */
64
- function normalizeWin(spelling) {
65
- const normalized = spelling.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
66
- if (!/^[A-Za-z]:\//.test(normalized)) throw new Error(`unsupported path root (drive-letter absolute paths only): ${spelling.slice(0, 64)}`)
67
- return normalized
68
- }
69
-
70
- /** Walk original prefixes without lexically collapsing symlink/.. or missing/..
71
- * The ancestor chain terminates at the filesystem root of the spelling —
72
- * '/' on POSIX, '<drive>:/' on win32 — detected as dirname's fixed point.
73
- * @param {string} spelling @param {boolean} win @returns {Inspection} */
74
- function inspect(spelling, win) {
75
- const parts = spelling.split('/')
76
- let ancestor = win ? /** @type {string} */ (parts[0]) : '/'
77
- const witnesses = []
78
- let links = 0
79
- for (let index = 1; index < parts.length; index++) {
80
- const prefix = parts.slice(0, index + 1).join('/') || '/'
81
- let entry
82
- try {
83
- entry = lstatSync(prefix, { bigint: true })
84
- } catch (error) {
85
- if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
86
- const suffix = parts.slice(index).join('/')
87
- if (parts.slice(index).includes('..')) throw new Error('missing path contains ..')
88
- return { value: { kind: 'missing', ancestor, suffix }, witnesses, links }
89
- }
90
- if (entry.isSymbolicLink()) links++
91
- const physical = `${ancestor}/${parts[index]}`
92
- ancestor = realpathSync.native(prefix)
93
- const node = statSync(ancestor, { bigint: true })
94
- witnesses.push(`${ancestor}:${node.dev}:${node.ino}:${entry.dev}:${entry.ino}`)
95
- if (entry.isSymbolicLink()) traceLink(physical, witnesses, 0, win)
96
- for (let parent = dirname(ancestor); ; parent = dirname(parent)) {
97
- const directory = statSync(parent, { bigint: true })
98
- witnesses.push(`${parent}:${directory.dev}:${directory.ino}`)
99
- if (parent === dirname(parent)) break
100
- }
101
- if (index < parts.length - 1) {
102
- if (!node.isDirectory()) throw new Error('non-directory ancestor')
103
- } else if (!node.isFile() || node.nlink !== 1n) {
104
- throw new Error('not editable: expected a single-link regular file')
105
- }
106
- }
107
- return { value: { kind: 'file', resourceId: ancestor }, witnesses, links }
108
- }
109
-
110
- /** Witness every target hop from an already-witnessed physical parent. Advancing
111
- * the physical cursor avoids recursively replaying the original parent spelling.
112
- * Resolve each component only AFTER tracing it: lexical .. collapse or resolving
113
- * the whole target up front would hide nested links and their topology changes.
114
- * On win32 readlink returns backslash spellings; they are normalized before the
115
- * walk so component splitting matches the POSIX row.
116
- * @param {string} path @param {string[]} witnesses @param {number} depth @param {boolean} win */
117
- function traceLink(path, witnesses, depth, win) {
118
- if (depth >= 40) throw new Error('unsupported symlink depth')
119
- const target = win ? readlinkSync(path).replace(/\\/g, '/') : readlinkSync(path)
120
- witnesses.push(`link:${path}:${target}`)
121
- let parent = isAbsolute(target) ? (win ? /** @type {string} */ (target.split('/')[0]) : '/') : dirname(path)
122
- const parts = isAbsolute(target) ? target.split('/').slice(1) : target.split('/')
123
- for (const part of parts) {
124
- const prefix = `${parent}/${part}`
125
- const node = lstatSync(prefix, { bigint: true })
126
- witnesses.push(`${prefix}:${node.dev}:${node.ino}`)
127
- if (node.isSymbolicLink()) traceLink(prefix, witnesses, depth + 1, win)
128
- parent = realpathSync.native(prefix)
129
- }
130
- }
@@ -1,145 +0,0 @@
1
- import { realpathSync, statSync, constants } from 'node:fs'
2
- import { open, rename } from 'node:fs/promises'
3
- import { join, relative, isAbsolute } from 'node:path'
4
- import { randomUUID } from 'node:crypto'
5
- import { openEditLockStore } from './store.js'
6
- import { createEditLockManager, recoverEditLockManager } from './manager.js'
7
- import { createPublisher } from './publisher.js'
8
- import { reservationPathFor } from './reservation.js'
9
-
10
- const domains = new Set()
11
-
12
- /** Durable publication of one administrative backup artifact inside the
13
- * authority directory (temp + rename + directory sync, like the snapshot).
14
- * @param {string} directory @param {string} file @param {Buffer} bytes */
15
- async function writeAuthorityFile(directory, file, bytes) {
16
- const temporary = join(directory, `.backup-${randomUUID()}.tmp`)
17
- const handle = await open(temporary, 'wx', 0o600)
18
- try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
19
- await rename(temporary, join(directory, file))
20
- // Directory fsync is POSIX-only (EPERM on win32): the file fsync above and
21
- // the atomic rename stand alone there (reservation.js SYNC_SUPPORTED stance).
22
- if (process.platform !== 'darwin' && process.platform !== 'linux') return
23
- const dir = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
24
- try { await dir.sync() } finally { await dir.close() }
25
- }
26
-
27
- /** Internal host lifecycle, not a model-facing service. Process-local exclusion
28
- * supplements, but does not replace, the caller's cross-process lifecycle lease.
29
- * The optional process identity and Liveness adapter (design D2/D3) come from
30
- * the reservation layer: the incarnation is registered in the durable image,
31
- * and a recovery open administratively settles unknown publications of
32
- * provably dead incarnation processes in the recovery commit.
33
- * @param {{directory: string, root: string, domainId: string, mode: 'create'|'recover',
34
- * fs: Parameters<typeof createPublisher>[0]['fs'],
35
- * assertExclusive: () => void,
36
- * processIdentity?: import('./incarnations.js').ProcessIdentity|null,
37
- * liveness?: import('../capabilities/store/liveness.js').Liveness|null}} options */
38
- export async function openEditLockRuntime(options) {
39
- options.assertExclusive()
40
- const root = realpathSync.native(options.root)
41
- if (!statSync(root).isDirectory()) throw new Error('management root must be directory')
42
- if (domains.has(root)) throw new Error('management domain already open')
43
- if (!isAbsolute(options.directory)) throw new Error('absolute authority directory required')
44
- const directory = realpathSync.native(options.directory)
45
- // The authority may live inside the domain, but its files and reservation
46
- // are never targets of the managed business publisher.
47
- const excluded = [directory, reservationPathFor(directory)]
48
- if (excluded.some(path => path === root || !relative(path, root).startsWith('..'))) throw new Error('authority directory must not contain the business domain')
49
- domains.add(root)
50
- /** @type {Awaited<ReturnType<typeof openEditLockStore>> | undefined} */
51
- let store
52
- try {
53
- store = await openEditLockStore({ directory, domainId: options.domainId, mode: options.mode })
54
- const openedStore = store
55
- const processIdentity = options.processIdentity ?? null
56
- const liveness = options.liveness ?? null
57
- /** @type {{records: any[], revision: number}|null} */
58
- let automaticRecovery = null
59
- // The domain root and backup writer are always supplied: the online
60
- // administrative recovery transaction (design D4) needs them even when
61
- // no Liveness adapter is present; the automatic settlement (D3) still
62
- // requires the full identity chain and stays gated inside the manager.
63
- const manager = options.mode === 'recover'
64
- ? await recoverEditLockManager({ store, managerIncarnation: randomUUID(), processIdentity, liveness,
65
- root: options.domainId,
66
- writeBackup: (file, bytes) => writeAuthorityFile(directory, file, bytes),
67
- onAutomaticRecovery: info => { automaticRecovery = info } })
68
- : createEditLockManager({ store, managerIncarnation: randomUUID(), processIdentity,
69
- root: options.domainId,
70
- writeBackup: (file, bytes) => writeAuthorityFile(directory, file, bytes) })
71
- const publisher = createPublisher({ manager, fs: options.fs, root, excluded, assertExclusive: options.assertExclusive })
72
- let closing = false
73
- /** @type {Promise<void> | undefined} */
74
- let shutdown
75
- /** @type {Set<Promise<any>>} */
76
- const pending = new Set()
77
- /** @template T @param {() => Promise<T>} action */
78
- function run(action) {
79
- if (closing) throw new Error('edit lock runtime closing')
80
- options.assertExclusive()
81
- const work = action()
82
- pending.add(work)
83
- void work.then(() => pending.delete(work), () => pending.delete(work))
84
- return work
85
- }
86
- // Preserve synchronous cancellation overlays while sealing every public
87
- // control admission. Manager FIFO drain covers asynchronous control work.
88
- const control = new Proxy({ ...manager }, {
89
- get(target, property, receiver) {
90
- const method = Reflect.get(target, property, receiver)
91
- if (typeof method !== 'function') return method
92
- return (/** @type {any[]} */ ...args) => {
93
- if (closing) throw new Error('edit lock runtime closing')
94
- options.assertExclusive()
95
- return method.apply(target, args)
96
- }
97
- },
98
- })
99
- return Object.freeze({
100
- // Trusted host control only; must never be passed to a tool's arguments.
101
- control,
102
- /** Dead-process unknowns settled in the recovery commit (design D3), or null. */
103
- automaticRecovery,
104
- requests: Object.freeze({
105
- /** @param {import('./state.js').Execution} execution @param {string[]} paths @param {string} cwd */
106
- acquireBatch(execution, paths, cwd) { return run(() => publisher.acquireBatch(execution, paths, cwd)) },
107
- /** @param {string} path @param {string} cwd */
108
- resource(path, cwd) {
109
- if (closing) throw new Error('edit lock runtime closing')
110
- return publisher.resource(path, cwd)
111
- },
112
- /** @param {string} path @param {string} cwd */
113
- releaseResource(path, cwd) {
114
- if (closing) throw new Error('edit lock runtime closing')
115
- return publisher.releaseResource(path, cwd)
116
- },
117
- /** @param {Parameters<typeof publisher.release>[0]} token
118
- * @param {Parameters<typeof publisher.release>[1]} observation */
119
- release(token, observation) { return run(() => publisher.release(token, observation)) },
120
- /** @param {Parameters<typeof publisher.prepare>[0]} execution
121
- * @param {Parameters<typeof publisher.prepare>[1]} request
122
- * @param {AbortSignal} signal */
123
- prepare(execution, request, signal) { return run(() => publisher.prepare(execution, request, signal)) },
124
- /** @param {object} submission */
125
- commit(submission) { return run(() => publisher.commit(submission)) },
126
- }),
127
- close() {
128
- if (shutdown) return shutdown
129
- closing = true
130
- shutdown = (async () => {
131
- await Promise.allSettled([...pending])
132
- await manager.drain()
133
- await manager.close()
134
- await openedStore.close()
135
- domains.delete(root)
136
- })()
137
- return shutdown
138
- },
139
- })
140
- } catch (error) {
141
- if (store) await store.close()
142
- domains.delete(root)
143
- throw error
144
- }
145
- }