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.
- package/cordis.patch.yml +7 -18
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- 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
|
-
}
|
package/src/edit-lock/runtime.js
DELETED
|
@@ -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
|
-
}
|