weir-harness 1.2.2 → 2.0.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.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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 +1066 -0
- package/src/file-lease/index.js +30 -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 +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/matcher.js +27 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/lsp/tools.js +137 -27
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +29 -28
- package/src/shared/audit.js +6 -1
- 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/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
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
// The client half of the edit-lock lease protocol (design D6, D7, D8).
|
|
2
|
+
//
|
|
3
|
+
// The daemon in ../lease-broker/ is the ONLY arbiter: every verdict about who
|
|
4
|
+
// may write a file arrives over this socket, and nothing here re-derives one.
|
|
5
|
+
// What lives in this file:
|
|
6
|
+
//
|
|
7
|
+
// - one lazily established connection per client, one `hello` handshake per
|
|
8
|
+
// connection, one `reqId` per request (replies are matched by that id and by
|
|
9
|
+
// nothing else);
|
|
10
|
+
// - the in-memory lease ledger (`path -> {session, lineage, holdUntil}`). It is
|
|
11
|
+
// used for exactly two things — knowing what a turn boundary must release and
|
|
12
|
+
// what a reconnect must replay. It NEVER decides a conflict (D6): the moment
|
|
13
|
+
// this file starts reasoning about ownership it becomes a second authority,
|
|
14
|
+
// which is the failure mode this whole design exists to delete;
|
|
15
|
+
// - degrade-and-proceed (D7). An unhealthy arbiter never blocks a write; a real
|
|
16
|
+
// `held` conflict always does. The protocol's closed error vocabulary is what
|
|
17
|
+
// keeps those two apart — no heuristics, no "is it probably fine".
|
|
18
|
+
//
|
|
19
|
+
// Deliberately absent: killing or restarting the daemon. The daemon is a cache
|
|
20
|
+
// with an idle window and a stale-socket recovery of its own, and a client that
|
|
21
|
+
// permanently gives up after one transport failure would turn a transient blip
|
|
22
|
+
// into a session-long loss of arbitration. Every later call re-tries the
|
|
23
|
+
// connection (and the spawn); only the audit is rate limited.
|
|
24
|
+
//
|
|
25
|
+
// Every external dependency is injected so a unit test can drive the state
|
|
26
|
+
// machine without a socket, a clock, or a child process.
|
|
27
|
+
|
|
28
|
+
import { isAbsolute } from 'node:path'
|
|
29
|
+
import { createConnection } from 'node:net'
|
|
30
|
+
import { PROTOCOL } from '../lease-broker/protocol.js'
|
|
31
|
+
import { defaultSocketPath } from '../lease-broker/server.js'
|
|
32
|
+
import { spawnDaemon as defaultSpawnDaemon } from './spawn.js'
|
|
33
|
+
|
|
34
|
+
/** Per-request deadline. A slow arbiter is an unhealthy arbiter (D7). */
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 2_000
|
|
36
|
+
|
|
37
|
+
/** Pause between connection attempts while a spawned daemon comes up. */
|
|
38
|
+
const DEFAULT_CONNECT_RETRY_MS = 50
|
|
39
|
+
|
|
40
|
+
/** Total budget for "spawn, then wait for the socket" before degrading. */
|
|
41
|
+
const DEFAULT_CONNECT_RETRY_BUDGET_MS = 1_500
|
|
42
|
+
|
|
43
|
+
/** Retention bound in minutes, mirrored from the protocol for replay math. */
|
|
44
|
+
const HOLD_MAX_MINUTES = 60
|
|
45
|
+
|
|
46
|
+
/** Infrastructure failure: connection, handshake, timeout, write, closed. */
|
|
47
|
+
export const TRANSPORT_CODE = 'transport'
|
|
48
|
+
|
|
49
|
+
/** Real conflict: someone else holds the path. Never degraded (D7). */
|
|
50
|
+
const CONFLICT_CODE = 'held'
|
|
51
|
+
|
|
52
|
+
/** Endpoint-absent errors: nobody is listening yet, so spawning may help. */
|
|
53
|
+
const ABSENT_ENDPOINT_CODES = new Set(['ENOENT', 'ECONNREFUSED'])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Degraded-mode audit throttle: at most one record per process per window (D7).
|
|
57
|
+
* Exported because the operator-facing contract names the number.
|
|
58
|
+
*/
|
|
59
|
+
export const DEGRADED_AUDIT_WINDOW_MS = 60_000
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Error & {
|
|
63
|
+
* code: string,
|
|
64
|
+
* conflicts?: any[],
|
|
65
|
+
* paths?: string[],
|
|
66
|
+
* maxMinutes?: number,
|
|
67
|
+
* cause?: unknown,
|
|
68
|
+
* }} LeaseFailure
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} LeaseRecord
|
|
73
|
+
* @property {string} session the session that last touched the lease (release scope)
|
|
74
|
+
* @property {string} lineage the opaque `owner.id` this client acquired under
|
|
75
|
+
* @property {number | null} holdUntil retention deadline in epoch ms, `null` when unretained
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The transport contract the state machine speaks. `connect()` may resolve a
|
|
80
|
+
* connection asynchronously and must reject with the underlying error (so
|
|
81
|
+
* `ENOENT`/`ECONNREFUSED` can be recognised as "daemon not up yet").
|
|
82
|
+
*
|
|
83
|
+
* @typedef {object} LeaseConnection
|
|
84
|
+
* @property {(text: string) => void} write may throw; every throw is a transport failure
|
|
85
|
+
* @property {(handler: (chunk: string) => void) => void} onData newline-delimited frames
|
|
86
|
+
* @property {(handler: () => void) => void} onClose called once when the connection is gone
|
|
87
|
+
* @property {() => void} close idempotent
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {object} LeaseClientOptions
|
|
92
|
+
* @property {string} [socketPath] endpoint address; defaults to the daemon's own `defaultSocketPath({protocol})`
|
|
93
|
+
* @property {number} [protocol] protocol major sent in the handshake
|
|
94
|
+
* @property {number} [timeoutMs] per-request deadline
|
|
95
|
+
* @property {() => number} [now] injected clock (epoch ms)
|
|
96
|
+
* @property {(socketPath: string) => LeaseConnection | Promise<LeaseConnection>} [connect] dial the endpoint
|
|
97
|
+
* @property {(() => unknown) | null} [spawnDaemon] called at most once per connection attempt; `null` disables spawning
|
|
98
|
+
* @property {(kind: string, detail: Record<string, any>) => void} [audit] audit sink (`kind` is `'degraded'`)
|
|
99
|
+
* @property {number} [connectRetryMs] pause between connection attempts after a spawn
|
|
100
|
+
* @property {number} [connectRetryBudgetMs] total budget for connect attempts after a spawn
|
|
101
|
+
* @property {number} [degradedAuditWindowMs] throttle window for the degraded audit record
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} code
|
|
106
|
+
* @param {string} message
|
|
107
|
+
* @param {{ conflicts?: any[], paths?: string[], maxMinutes?: number, cause?: unknown }} [detail]
|
|
108
|
+
* @returns {LeaseFailure}
|
|
109
|
+
*/
|
|
110
|
+
function leaseError(code, message, detail = {}) {
|
|
111
|
+
/** @type {LeaseFailure} */
|
|
112
|
+
const error = /** @type {LeaseFailure} */ (new Error(message))
|
|
113
|
+
error.code = code
|
|
114
|
+
if (Array.isArray(detail.conflicts)) error.conflicts = detail.conflicts
|
|
115
|
+
if (Array.isArray(detail.paths)) error.paths = detail.paths
|
|
116
|
+
if (typeof detail.maxMinutes === 'number') error.maxMinutes = detail.maxMinutes
|
|
117
|
+
if (detail.cause !== undefined) error.cause = detail.cause
|
|
118
|
+
return error
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} message
|
|
123
|
+
* @param {unknown} [cause]
|
|
124
|
+
* @returns {LeaseFailure}
|
|
125
|
+
*/
|
|
126
|
+
function transportError(message, cause) {
|
|
127
|
+
return leaseError(TRANSPORT_CODE, message, { cause })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True for a real conflict — the only refusal that may stop a write.
|
|
132
|
+
* @param {unknown} error
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function isLeaseConflict(error) {
|
|
136
|
+
return !!error && typeof error === 'object' && /** @type {any} */ (error).code === CONFLICT_CODE
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {unknown} error
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function errorCode(error) {
|
|
144
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
145
|
+
return typeof code === 'string' ? code : 'internal'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {number} ms
|
|
150
|
+
* @returns {Promise<void>}
|
|
151
|
+
*/
|
|
152
|
+
function delay(ms) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
setTimeout(resolve, ms)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Default dialer: a plain newline-JSON socket from `node:net`.
|
|
160
|
+
* @param {string} socketPath
|
|
161
|
+
* @returns {Promise<LeaseConnection>}
|
|
162
|
+
*/
|
|
163
|
+
function defaultConnect(socketPath) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const socket = createConnection({ path: socketPath })
|
|
166
|
+
socket.setEncoding('utf8')
|
|
167
|
+
let connected = false
|
|
168
|
+
let settled = false
|
|
169
|
+
socket.once('connect', () => {
|
|
170
|
+
connected = true
|
|
171
|
+
settled = true
|
|
172
|
+
socket.setNoDelay(true)
|
|
173
|
+
// A lease connection is process state (design D11), not a pending task: it
|
|
174
|
+
// must never keep an otherwise-finished Node process alive. Without this
|
|
175
|
+
// an idle client pins the event loop, so a headless CLI turn that ended
|
|
176
|
+
// normally only dies when its caller times out (observed as a hung
|
|
177
|
+
// integration boot), and a graceful host shutdown would wait forever.
|
|
178
|
+
// `unref` is part of Node's net.Socket but not modelled by the hand-written
|
|
179
|
+
// node contracts this bundle compiles against (`types: []`), and the
|
|
180
|
+
// connection is an injectable stub in tests — hence the guarded call.
|
|
181
|
+
const timer = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (socket))
|
|
182
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
183
|
+
resolve(wrapSocket(socket))
|
|
184
|
+
})
|
|
185
|
+
socket.once('error', (error) => {
|
|
186
|
+
// Pre-connect: the dial failed and the caller decides whether to spawn and
|
|
187
|
+
// re-try. Post-connect: the wrapper's own listener turns it into a close.
|
|
188
|
+
if (connected || settled) return
|
|
189
|
+
settled = true
|
|
190
|
+
try {
|
|
191
|
+
socket.destroy()
|
|
192
|
+
} catch {
|
|
193
|
+
// Already gone.
|
|
194
|
+
}
|
|
195
|
+
reject(error)
|
|
196
|
+
})
|
|
197
|
+
socket.on('close', () => {
|
|
198
|
+
if (connected || settled) return
|
|
199
|
+
settled = true
|
|
200
|
+
reject(leaseError(TRANSPORT_CODE, `lease broker closed the connection during dial to ${socketPath}`))
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Narrow a Node socket to the `LeaseConnection` contract, making the close
|
|
207
|
+
* notification exactly-once (Node may emit both `error` and `close`).
|
|
208
|
+
* @param {import('node:net').Socket} socket
|
|
209
|
+
* @returns {LeaseConnection}
|
|
210
|
+
*/
|
|
211
|
+
function wrapSocket(socket) {
|
|
212
|
+
let released = false
|
|
213
|
+
/** @type {(() => void)[]} */
|
|
214
|
+
const listeners = []
|
|
215
|
+
const notify = () => {
|
|
216
|
+
if (released) return
|
|
217
|
+
released = true
|
|
218
|
+
for (const listener of listeners.splice(0)) {
|
|
219
|
+
try {
|
|
220
|
+
listener()
|
|
221
|
+
} catch {
|
|
222
|
+
// A transport listener must never break the client's own bookkeeping.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
socket.on('error', () => {
|
|
227
|
+
try {
|
|
228
|
+
socket.destroy()
|
|
229
|
+
} catch {
|
|
230
|
+
// Already gone.
|
|
231
|
+
}
|
|
232
|
+
notify()
|
|
233
|
+
})
|
|
234
|
+
socket.on('close', notify)
|
|
235
|
+
return {
|
|
236
|
+
write(text) {
|
|
237
|
+
socket.write(text)
|
|
238
|
+
},
|
|
239
|
+
onData(handler) {
|
|
240
|
+
socket.on('data', handler)
|
|
241
|
+
},
|
|
242
|
+
onClose(handler) {
|
|
243
|
+
if (released) {
|
|
244
|
+
// Registered too late: report asynchronously so the caller's state
|
|
245
|
+
// machine cannot re-enter mid-registration.
|
|
246
|
+
queueMicrotask(() => {
|
|
247
|
+
try {
|
|
248
|
+
handler()
|
|
249
|
+
} catch {
|
|
250
|
+
// Same rule as notify().
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
listeners.push(handler)
|
|
256
|
+
},
|
|
257
|
+
close() {
|
|
258
|
+
notify()
|
|
259
|
+
try {
|
|
260
|
+
socket.destroy()
|
|
261
|
+
} catch {
|
|
262
|
+
// Already gone.
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Create one lease client.
|
|
270
|
+
*
|
|
271
|
+
* A process shares ONE client (design D8/D11): one connection carries every
|
|
272
|
+
* lease of this process, so two preset mounts must never each create their own.
|
|
273
|
+
* The factory exists so tests and the wiring can build independent instances.
|
|
274
|
+
*
|
|
275
|
+
* @param {LeaseClientOptions} [options]
|
|
276
|
+
*/
|
|
277
|
+
export function createLeaseClient({
|
|
278
|
+
socketPath,
|
|
279
|
+
protocol = PROTOCOL,
|
|
280
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
281
|
+
now = Date.now,
|
|
282
|
+
connect = defaultConnect,
|
|
283
|
+
spawnDaemon = defaultSpawnDaemon,
|
|
284
|
+
audit = () => {},
|
|
285
|
+
connectRetryMs = DEFAULT_CONNECT_RETRY_MS,
|
|
286
|
+
connectRetryBudgetMs = DEFAULT_CONNECT_RETRY_BUDGET_MS,
|
|
287
|
+
degradedAuditWindowMs = DEGRADED_AUDIT_WINDOW_MS,
|
|
288
|
+
} = {}) {
|
|
289
|
+
const endpoint = typeof socketPath === 'string' && socketPath.length > 0 ? socketPath : defaultSocketPath({ protocol })
|
|
290
|
+
|
|
291
|
+
/** @type {LeaseConnection | null} */
|
|
292
|
+
let connection = null
|
|
293
|
+
let greeted = false
|
|
294
|
+
/** @type {Promise<LeaseConnection> | null} */
|
|
295
|
+
let connecting = null
|
|
296
|
+
let closed = false
|
|
297
|
+
/** @type {number | undefined} */
|
|
298
|
+
let startedAt
|
|
299
|
+
/** A connection reset means the daemon may have restarted with an empty table (D6). */
|
|
300
|
+
let replayPending = false
|
|
301
|
+
/** A new incarnation was detected; the next request (or `replayHolds`) replays first. */
|
|
302
|
+
let replayQueued = false
|
|
303
|
+
/** @type {Promise<{ replayed: number, dropped: number }> | null} */
|
|
304
|
+
let replaying = null
|
|
305
|
+
/** @type {number | null} */
|
|
306
|
+
let lastDegradedAt = null
|
|
307
|
+
/** @type {string | undefined} */
|
|
308
|
+
let lastScope
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The replay ledger. `path -> {session, lineage, holdUntil}` — nothing more.
|
|
312
|
+
* In particular there is no `owner`-decides-anything field here, by design.
|
|
313
|
+
* @type {Map<string, LeaseRecord>}
|
|
314
|
+
*/
|
|
315
|
+
const ledger = new Map()
|
|
316
|
+
/** `lineage -> label`, presentation only; the broker never compares it. @type {Map<string, string>} */
|
|
317
|
+
const labels = new Map()
|
|
318
|
+
/** @type {Set<(event: any) => void>} */
|
|
319
|
+
const eventHandlers = new Set()
|
|
320
|
+
/**
|
|
321
|
+
* @type {Map<string, {
|
|
322
|
+
* resolve: (reply: any) => void,
|
|
323
|
+
* reject: (error: any) => void,
|
|
324
|
+
* timer: ReturnType<typeof setTimeout>,
|
|
325
|
+
* }>}
|
|
326
|
+
*/
|
|
327
|
+
const waiters = new Map()
|
|
328
|
+
let requestSeq = 0
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------
|
|
331
|
+
// Transport: connection lifecycle, framing, correlation
|
|
332
|
+
// ---------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** @returns {string} a correlation token unique to this client */
|
|
335
|
+
function nextReqId() {
|
|
336
|
+
requestSeq += 1
|
|
337
|
+
return `c${requestSeq}`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {LeaseConnection} target
|
|
342
|
+
* @param {Record<string, any>} frame
|
|
343
|
+
* @returns {Promise<any>}
|
|
344
|
+
*/
|
|
345
|
+
function sendFrame(target, frame) {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
const reqId = nextReqId()
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
waiters.delete(reqId)
|
|
350
|
+
reject(transportError(`lease broker did not answer ${String(frame.op)} within ${timeoutMs}ms`))
|
|
351
|
+
}, timeoutMs)
|
|
352
|
+
waiters.set(reqId, { resolve, reject, timer })
|
|
353
|
+
try {
|
|
354
|
+
// `reqId` is written last so a caller-supplied payload cannot collide.
|
|
355
|
+
target.write(`${JSON.stringify({ ...frame, reqId })}\n`)
|
|
356
|
+
} catch (error) {
|
|
357
|
+
clearTimeout(timer)
|
|
358
|
+
waiters.delete(reqId)
|
|
359
|
+
reject(transportError(`lease broker write failed at ${endpoint}`, error))
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @param {LeaseConnection} target
|
|
366
|
+
* @param {string} op
|
|
367
|
+
* @param {Record<string, any>} [payload]
|
|
368
|
+
* @returns {Promise<any>}
|
|
369
|
+
*/
|
|
370
|
+
async function exchange(target, op, payload = {}) {
|
|
371
|
+
const reply = await sendFrame(target, { op, ...payload })
|
|
372
|
+
if (reply !== null && typeof reply === 'object' && reply.ok === true) return reply
|
|
373
|
+
const detail = reply !== null && typeof reply === 'object' && reply.error !== null && typeof reply.error === 'object' ? reply.error : {}
|
|
374
|
+
const code = typeof detail.code === 'string' ? detail.code : 'internal'
|
|
375
|
+
throw leaseError(code, `lease broker refused ${op}: ${code}`, detail)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** @param {any} frame */
|
|
379
|
+
function dispatchEvent(frame) {
|
|
380
|
+
for (const handler of [...eventHandlers]) {
|
|
381
|
+
try {
|
|
382
|
+
handler(frame)
|
|
383
|
+
} catch {
|
|
384
|
+
// A listener is a consumer; it must never be able to break the transport.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @param {string} line */
|
|
390
|
+
function handleFrame(line) {
|
|
391
|
+
/** @type {any} */
|
|
392
|
+
let frame
|
|
393
|
+
try {
|
|
394
|
+
frame = JSON.parse(line)
|
|
395
|
+
} catch {
|
|
396
|
+
// An undecodable line cannot be matched to anything; the waiter's own
|
|
397
|
+
// deadline is what reports it.
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (frame === null || typeof frame !== 'object') return
|
|
401
|
+
if (typeof frame.event === 'string') {
|
|
402
|
+
dispatchEvent(frame)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
const reqId = typeof frame.reqId === 'string' ? frame.reqId : undefined
|
|
406
|
+
if (reqId === undefined) return
|
|
407
|
+
const waiter = waiters.get(reqId)
|
|
408
|
+
// Correlation is exact: a reply nobody is waiting for is dropped. The
|
|
409
|
+
// alternative — handing it to the next waiter in line — would let one
|
|
410
|
+
// request satisfy another and silently mis-report ownership.
|
|
411
|
+
if (waiter === undefined) return
|
|
412
|
+
waiters.delete(reqId)
|
|
413
|
+
clearTimeout(waiter.timer)
|
|
414
|
+
waiter.resolve(frame)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {any} error
|
|
419
|
+
*/
|
|
420
|
+
function failWaiters(error) {
|
|
421
|
+
for (const [reqId, waiter] of [...waiters]) {
|
|
422
|
+
waiters.delete(reqId)
|
|
423
|
+
clearTimeout(waiter.timer)
|
|
424
|
+
waiter.reject(error)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** @param {LeaseConnection} target */
|
|
429
|
+
function handleDisconnect(target) {
|
|
430
|
+
if (connection !== target) return
|
|
431
|
+
connection = null
|
|
432
|
+
greeted = false
|
|
433
|
+
// D6: a reset connection means the daemon may have died and come back with
|
|
434
|
+
// an empty table, so retained leases have to be replayed on the next dial.
|
|
435
|
+
if (ledger.size > 0) replayPending = true
|
|
436
|
+
failWaiters(transportError('lease broker connection closed'))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {LeaseConnection} target
|
|
441
|
+
*/
|
|
442
|
+
function dropConnection(target) {
|
|
443
|
+
if (connection === target) {
|
|
444
|
+
connection = null
|
|
445
|
+
greeted = false
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
target.close()
|
|
449
|
+
} catch {
|
|
450
|
+
// Already gone.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** @returns {Promise<{ connection: LeaseConnection | null, error: any }>} */
|
|
455
|
+
async function attemptConnect() {
|
|
456
|
+
try {
|
|
457
|
+
return { connection: await connect(endpoint), error: null }
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return { connection: null, error }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** @param {unknown} error @returns {boolean} */
|
|
464
|
+
function isAbsentEndpoint(error) {
|
|
465
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
|
|
466
|
+
return typeof code === 'string' && ABSENT_ENDPOINT_CODES.has(code)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function spawnOnce() {
|
|
470
|
+
if (typeof spawnDaemon !== 'function') return
|
|
471
|
+
try {
|
|
472
|
+
spawnDaemon()
|
|
473
|
+
} catch {
|
|
474
|
+
// Spawning is best effort — a `null` daemon degrades, it never refuses.
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Dial the daemon, spawning it once when the endpoint is simply not there.
|
|
480
|
+
*
|
|
481
|
+
* Spawning is per attempt, not per process: after a failure this client must
|
|
482
|
+
* be able to start the daemon again on the next call, because "the daemon is
|
|
483
|
+
* gone" is a transient condition, not a decision to stop arbitrating.
|
|
484
|
+
*
|
|
485
|
+
* @returns {Promise<LeaseConnection>}
|
|
486
|
+
*/
|
|
487
|
+
async function openConnection() {
|
|
488
|
+
const first = await attemptConnect()
|
|
489
|
+
if (first.connection !== null) return first.connection
|
|
490
|
+
if (!isAbsentEndpoint(first.error)) {
|
|
491
|
+
throw transportError(`lease broker is unreachable at ${endpoint}`, first.error)
|
|
492
|
+
}
|
|
493
|
+
spawnOnce()
|
|
494
|
+
const deadline = now() + connectRetryBudgetMs
|
|
495
|
+
// A frozen or absent clock must not turn this into an endless loop: the
|
|
496
|
+
// attempt count is what actually bounds the wait.
|
|
497
|
+
const maxAttempts = Math.max(1, Math.ceil(connectRetryBudgetMs / Math.max(1, connectRetryMs))) + 2
|
|
498
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
499
|
+
if (now() >= deadline) break
|
|
500
|
+
await delay(connectRetryMs)
|
|
501
|
+
const retry = await attemptConnect()
|
|
502
|
+
if (retry.connection !== null) return retry.connection
|
|
503
|
+
if (!isAbsentEndpoint(retry.error)) {
|
|
504
|
+
throw transportError(`lease broker is unreachable at ${endpoint}`, retry.error)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
throw transportError(`lease broker did not come up at ${endpoint} within ${connectRetryBudgetMs}ms`)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Establish the connection and complete the handshake, replaying retained
|
|
512
|
+
* leases when the daemon turns out to be a different incarnation (D6).
|
|
513
|
+
* @returns {Promise<LeaseConnection>}
|
|
514
|
+
*/
|
|
515
|
+
async function establish() {
|
|
516
|
+
const fresh = await openConnection()
|
|
517
|
+
const previousStartedAt = startedAt
|
|
518
|
+
connection = fresh
|
|
519
|
+
greeted = false
|
|
520
|
+
let buffered = ''
|
|
521
|
+
fresh.onData((chunk) => {
|
|
522
|
+
buffered += chunk
|
|
523
|
+
let index = buffered.indexOf('\n')
|
|
524
|
+
while (index >= 0) {
|
|
525
|
+
const line = buffered.slice(0, index)
|
|
526
|
+
buffered = buffered.slice(index + 1)
|
|
527
|
+
if (line.trim() !== '') handleFrame(line)
|
|
528
|
+
index = buffered.indexOf('\n')
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
fresh.onClose(() => handleDisconnect(fresh))
|
|
532
|
+
/** @type {any} */
|
|
533
|
+
let hello
|
|
534
|
+
try {
|
|
535
|
+
hello = await sendFrame(fresh, { op: 'hello', protocol })
|
|
536
|
+
} catch (error) {
|
|
537
|
+
dropConnection(fresh)
|
|
538
|
+
throw transportError(`lease broker handshake failed at ${endpoint}`, error)
|
|
539
|
+
}
|
|
540
|
+
if (hello.ok !== true) {
|
|
541
|
+
// A protocol mismatch is infrastructure, not a conflict: the write goes
|
|
542
|
+
// through and the audit records why (D7). Nobody is asked to fix anything.
|
|
543
|
+
const code = typeof hello.error?.code === 'string' ? hello.error.code : 'unknown'
|
|
544
|
+
dropConnection(fresh)
|
|
545
|
+
throw transportError(`lease broker refused the handshake (${code})`)
|
|
546
|
+
}
|
|
547
|
+
greeted = true
|
|
548
|
+
const reported = typeof hello.startedAt === 'number' ? hello.startedAt : previousStartedAt
|
|
549
|
+
startedAt = reported
|
|
550
|
+
const restarted = replayPending || (previousStartedAt !== undefined && previousStartedAt !== reported)
|
|
551
|
+
replayPending = false
|
|
552
|
+
// Arm the replay instead of running it here: the connection is usable, but
|
|
553
|
+
// replaying from inside `establish` would re-enter the request path. The next
|
|
554
|
+
// request flushes it (or `replayHolds()` does, without replaying twice).
|
|
555
|
+
if (restarted && ledger.size > 0) replayQueued = true
|
|
556
|
+
return fresh
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** @returns {Promise<LeaseConnection>} */
|
|
560
|
+
function ensureConnection() {
|
|
561
|
+
if (closed) return Promise.reject(transportError('lease client is closed'))
|
|
562
|
+
if (connection !== null && greeted) return Promise.resolve(connection)
|
|
563
|
+
if (connecting !== null) return connecting
|
|
564
|
+
/** @type {Promise<LeaseConnection>} */
|
|
565
|
+
const attempt = establish().finally(() => {
|
|
566
|
+
if (connecting === attempt) connecting = null
|
|
567
|
+
})
|
|
568
|
+
connecting = attempt
|
|
569
|
+
return attempt
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Send one protocol request on a live connection.
|
|
574
|
+
* @param {string} op
|
|
575
|
+
* @param {Record<string, any>} [payload]
|
|
576
|
+
* @returns {Promise<any>}
|
|
577
|
+
*/
|
|
578
|
+
async function request(op, payload = {}) {
|
|
579
|
+
if (typeof op !== 'string' || op.length === 0) throw new TypeError('lease request requires an operation name')
|
|
580
|
+
const target = await ensureConnection()
|
|
581
|
+
if (replayQueued) {
|
|
582
|
+
// Flush before the caller's own frame so an observe-after-reconnect call
|
|
583
|
+
// reports the world the replay has already restored.
|
|
584
|
+
replayQueued = false
|
|
585
|
+
await runReplay()
|
|
586
|
+
}
|
|
587
|
+
return await exchange(target, op, payload)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------
|
|
591
|
+
// Ledger: what we hold, what a turn boundary releases, what a reconnect replays
|
|
592
|
+
// ---------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* @param {any} owner
|
|
596
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
597
|
+
*/
|
|
598
|
+
function normalizeOwner(owner) {
|
|
599
|
+
const rawId = typeof owner === 'string' ? owner : owner?.id
|
|
600
|
+
const id = typeof rawId === 'string' && rawId.length > 0 ? rawId : undefined
|
|
601
|
+
if (id === undefined) throw new TypeError('lease owner requires a non-empty id (the lineage)')
|
|
602
|
+
const session = typeof owner?.session === 'string' && owner.session.length > 0 ? owner.session : id
|
|
603
|
+
const label = typeof owner?.label === 'string' && owner.label.length > 0 ? owner.label : session
|
|
604
|
+
return { id, session, label }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Normalise and validate the path batch. A relative path is a wiring bug, and
|
|
609
|
+
* the broker would only answer `invalid-path`, which this client would have to
|
|
610
|
+
* degrade — so it is refused loudly here instead of silently losing
|
|
611
|
+
* arbitration for every write of that tool.
|
|
612
|
+
* @param {unknown} paths
|
|
613
|
+
* @returns {string[]}
|
|
614
|
+
*/
|
|
615
|
+
function normalizePaths(paths) {
|
|
616
|
+
if (!Array.isArray(paths)) throw new TypeError('lease paths must be an array')
|
|
617
|
+
/** @type {string[]} */
|
|
618
|
+
const out = []
|
|
619
|
+
for (const path of paths) {
|
|
620
|
+
if (typeof path !== 'string' || path.length === 0) throw new TypeError('lease paths must be non-empty strings')
|
|
621
|
+
if (path.includes('\u0000')) throw new TypeError('lease paths may not contain NUL bytes')
|
|
622
|
+
if (!isAbsolute(path)) throw new TypeError(`lease paths must be absolute: ${path}`)
|
|
623
|
+
if (!out.includes(path)) out.push(path)
|
|
624
|
+
}
|
|
625
|
+
return out
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** @param {unknown} scope @returns {string} */
|
|
629
|
+
function requireScope(scope) {
|
|
630
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
631
|
+
throw new TypeError('lease scope must be a non-empty string (the project root)')
|
|
632
|
+
}
|
|
633
|
+
return scope
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* @param {string} lineage
|
|
638
|
+
* @param {string} session
|
|
639
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
640
|
+
*/
|
|
641
|
+
function ownerOf(lineage, session) {
|
|
642
|
+
return { id: lineage, session, label: labels.get(lineage) ?? session }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The owner to speak as for paths this client already holds. `hold` extends
|
|
647
|
+
* ownership, so an owner can only be inferred from something already owned; a
|
|
648
|
+
* batch we hold nothing of is refused locally as `not-owned` (a closed-vocabulary
|
|
649
|
+
* code) instead of sending a frame the broker would reject anyway.
|
|
650
|
+
* @param {string[]} paths
|
|
651
|
+
* @returns {{ id: string, session: string, label: string }}
|
|
652
|
+
*/
|
|
653
|
+
function ownerForPaths(paths) {
|
|
654
|
+
for (const path of paths) {
|
|
655
|
+
const record = ledger.get(path)
|
|
656
|
+
if (record !== undefined) return ownerOf(record.lineage, record.session)
|
|
657
|
+
}
|
|
658
|
+
throw leaseError('not-owned', 'this client holds none of the requested paths, so there is no owner to extend', {
|
|
659
|
+
paths,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Record a granted batch. Only the fields the ledger exists for are kept: who
|
|
665
|
+
* to release as, and whether a retention must be replayed.
|
|
666
|
+
* @param {any[]} granted
|
|
667
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
668
|
+
* @param {string} scope
|
|
669
|
+
*/
|
|
670
|
+
function remember(granted, owner, scope) {
|
|
671
|
+
lastScope = scope
|
|
672
|
+
labels.set(owner.id, owner.label)
|
|
673
|
+
for (const lease of granted) {
|
|
674
|
+
const path = typeof lease?.path === 'string' ? lease.path : undefined
|
|
675
|
+
if (path === undefined) continue
|
|
676
|
+
ledger.set(path, {
|
|
677
|
+
session: owner.session,
|
|
678
|
+
lineage: owner.id,
|
|
679
|
+
holdUntil: typeof lease.holdUntil === 'number' ? lease.holdUntil : null,
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Record a successful retention.
|
|
686
|
+
* @param {any} reply
|
|
687
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
688
|
+
*/
|
|
689
|
+
function applyHold(reply, owner) {
|
|
690
|
+
const held = Array.isArray(reply?.held) ? reply.held : []
|
|
691
|
+
labels.set(owner.id, owner.label)
|
|
692
|
+
for (const entry of held) {
|
|
693
|
+
const path = typeof entry?.path === 'string' ? entry.path : undefined
|
|
694
|
+
if (path === undefined) continue
|
|
695
|
+
const holdUntil = typeof entry.holdUntil === 'number' ? entry.holdUntil : null
|
|
696
|
+
ledger.set(path, { session: owner.session, lineage: owner.id, holdUntil })
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ---------------------------------------------------------------------
|
|
701
|
+
// Arbitration-facing operations
|
|
702
|
+
// ---------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Acquire a batch up front (the explicit `edit_lock_acquire` form of what every
|
|
706
|
+
* managed write does implicitly).
|
|
707
|
+
* @param {any} owner
|
|
708
|
+
* @param {string[]} paths
|
|
709
|
+
* @param {string} scope
|
|
710
|
+
* @returns {Promise<any>}
|
|
711
|
+
*/
|
|
712
|
+
async function acquire(owner, paths, scope) {
|
|
713
|
+
const holder = normalizeOwner(owner)
|
|
714
|
+
const list = normalizePaths(paths)
|
|
715
|
+
const leaseScope = requireScope(scope)
|
|
716
|
+
const reply = await request('acquire', { owner: holder, scope: leaseScope, paths: list })
|
|
717
|
+
if (Array.isArray(reply.granted)) remember(reply.granted, holder, leaseScope)
|
|
718
|
+
return reply
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Attempt the batch again on a brand-new connection (the single retry D7
|
|
723
|
+
* allows). Returns `false` when the retry failed too; a real conflict is
|
|
724
|
+
* rethrown, because conflicts are never degraded.
|
|
725
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
726
|
+
* @param {string[]} paths
|
|
727
|
+
* @param {string} scope
|
|
728
|
+
* @returns {Promise<boolean>}
|
|
729
|
+
*/
|
|
730
|
+
async function retryAcquire(owner, paths, scope) {
|
|
731
|
+
const stale = connection
|
|
732
|
+
if (stale !== null) dropConnection(stale)
|
|
733
|
+
try {
|
|
734
|
+
await acquire(owner, paths, scope)
|
|
735
|
+
return true
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (isLeaseConflict(error)) throw error
|
|
738
|
+
const broken = connection
|
|
739
|
+
if (broken !== null) dropConnection(broken)
|
|
740
|
+
return false
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Acquire the batch, or run the write anyway (D7).
|
|
746
|
+
*
|
|
747
|
+
* One retry on a fresh connection at most; when that fails too, a single audit
|
|
748
|
+
* record explains the silent loss of mutual exclusion and `fn` proceeds with no
|
|
749
|
+
* lease. A real conflict is rethrown on every path — including out of the retry.
|
|
750
|
+
*
|
|
751
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
752
|
+
* @param {string[]} paths
|
|
753
|
+
* @param {string} scope
|
|
754
|
+
* @returns {Promise<void>}
|
|
755
|
+
*/
|
|
756
|
+
async function acquireOrDegrade(owner, paths, scope) {
|
|
757
|
+
try {
|
|
758
|
+
await acquire(owner, paths, scope)
|
|
759
|
+
return
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (isLeaseConflict(error)) throw error
|
|
762
|
+
if (await retryAcquire(owner, paths, scope)) return
|
|
763
|
+
auditDegraded({
|
|
764
|
+
code: errorCode(error),
|
|
765
|
+
session: owner.session,
|
|
766
|
+
lineage: owner.id,
|
|
767
|
+
scope,
|
|
768
|
+
paths: paths.length,
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Hold the leases for `owner` for the duration of `fn` (design D2): the leases
|
|
775
|
+
* are acquired once and NOT released in a `finally` — ownership lasts until the
|
|
776
|
+
* turn ends (`releaseSession`) or the process does (`releaseAll`/connection
|
|
777
|
+
* close). `fn` runs exactly once on every path that is not a real conflict.
|
|
778
|
+
*
|
|
779
|
+
* @template T
|
|
780
|
+
* @param {any} owner `{id, session, label}` (or a bare lineage id)
|
|
781
|
+
* @param {string[]} paths absolute paths
|
|
782
|
+
* @param {string} scope opaque project root, used only for `status` filtering
|
|
783
|
+
* @param {() => T | Promise<T>} fn the managed operation
|
|
784
|
+
* @returns {Promise<T>}
|
|
785
|
+
*/
|
|
786
|
+
async function withLeases(owner, paths, scope, fn) {
|
|
787
|
+
if (typeof fn !== 'function') throw new TypeError('withLeases requires a callback')
|
|
788
|
+
const holder = normalizeOwner(owner)
|
|
789
|
+
const list = normalizePaths(paths)
|
|
790
|
+
const leaseScope = requireScope(scope)
|
|
791
|
+
if (list.length > 0) await acquireOrDegrade(holder, list, leaseScope)
|
|
792
|
+
return await fn()
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Extend the leases for `paths` (only ever later, ≤ 60 minutes; the broker
|
|
797
|
+
* owns that rule). The owner is inferred from the ledger when omitted, which is
|
|
798
|
+
* the only legitimate case: `hold` applies to something already held.
|
|
799
|
+
* @param {string[]} paths
|
|
800
|
+
* @param {number} minutes
|
|
801
|
+
* @param {any} [owner]
|
|
802
|
+
* @returns {Promise<any>}
|
|
803
|
+
*/
|
|
804
|
+
async function hold(paths, minutes, owner) {
|
|
805
|
+
const list = normalizePaths(paths)
|
|
806
|
+
const holder = owner === undefined ? ownerForPaths(list) : normalizeOwner(owner)
|
|
807
|
+
const reply = await request('hold', { owner: holder, paths: list, minutes })
|
|
808
|
+
applyHold(reply, holder)
|
|
809
|
+
return reply
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Read-only projection. Filters stack; only the protocol's three filter keys
|
|
814
|
+
* are forwarded (the frame grammar rejects extras).
|
|
815
|
+
* @param {{ scope?: string, session?: string, path?: string }} [filter]
|
|
816
|
+
* @returns {Promise<any>}
|
|
817
|
+
*/
|
|
818
|
+
function status(filter = {}) {
|
|
819
|
+
/** @type {Record<string, string>} */
|
|
820
|
+
const payload = {}
|
|
821
|
+
if (typeof filter?.scope === 'string' && filter.scope.length > 0) payload.scope = filter.scope
|
|
822
|
+
if (typeof filter?.session === 'string' && filter.session.length > 0) payload.session = filter.session
|
|
823
|
+
if (typeof filter?.path === 'string' && filter.path.length > 0) payload.path = filter.path
|
|
824
|
+
return request('status', payload)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Read-only connection health for the status surface (design D13).
|
|
829
|
+
*
|
|
830
|
+
* `ok` means the handshake stands right now, so a `status` read reaches the
|
|
831
|
+
* arbiter; `degraded` means this process HAS been arbitrated before but the
|
|
832
|
+
* connection is gone at this instant (the next write would degrade and
|
|
833
|
+
* proceed, D7); `unavailable` means it was never reached in this process.
|
|
834
|
+
*
|
|
835
|
+
* Reporting only: it opens nothing, closes nothing and retries nothing, so a
|
|
836
|
+
* reader can never change an arbitration verdict by looking.
|
|
837
|
+
*
|
|
838
|
+
* @returns {'ok' | 'degraded' | 'unavailable'}
|
|
839
|
+
*/
|
|
840
|
+
function connectionState() {
|
|
841
|
+
if (closed) return 'unavailable'
|
|
842
|
+
if (connection !== null && greeted) return 'ok'
|
|
843
|
+
// `startedAt` is set by the first successful handshake and never cleared:
|
|
844
|
+
// its presence is exactly "this process once had an arbiter".
|
|
845
|
+
return startedAt === undefined ? 'unavailable' : 'degraded'
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Ask the holder of `path` to hand it over (negotiation, D4).
|
|
850
|
+
* @param {string} path
|
|
851
|
+
* @param {any} from the requesting owner
|
|
852
|
+
* @returns {Promise<any>}
|
|
853
|
+
*/
|
|
854
|
+
function requestTransfer(path, from) {
|
|
855
|
+
if (typeof path !== 'string' || path.length === 0) throw new TypeError('requestTransfer requires a path')
|
|
856
|
+
return request('request', { from: normalizeOwner(from), path })
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Answer a pending transfer request as the holder.
|
|
861
|
+
* @param {{ requestId: string, owner: any, decision: 'release' | 'keep' }} input
|
|
862
|
+
* @returns {Promise<any>}
|
|
863
|
+
*/
|
|
864
|
+
function reply(input) {
|
|
865
|
+
const requestId = input?.requestId
|
|
866
|
+
if (typeof requestId !== 'string' || requestId.length === 0) throw new TypeError('reply requires a requestId')
|
|
867
|
+
const decision = input?.decision
|
|
868
|
+
if (decision !== 'release' && decision !== 'keep') throw new TypeError("reply decision must be 'release' or 'keep'")
|
|
869
|
+
return request('reply', { owner: normalizeOwner(input?.owner), requestId, decision })
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ---------------------------------------------------------------------
|
|
873
|
+
// Release and replay
|
|
874
|
+
// ---------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Release everything this session holds EXCEPT retentions (design D10): the
|
|
878
|
+
* `turn/end` rule. A failure is swallowed — turn-boundary release is an
|
|
879
|
+
* optimisation, and the daemon's disconnect rule plus the TTL already cover it.
|
|
880
|
+
* @param {string} session
|
|
881
|
+
* @returns {Promise<{ released: string[] }>}
|
|
882
|
+
*/
|
|
883
|
+
async function releaseSession(session) {
|
|
884
|
+
return await releaseMatching((record) => record.session === session && record.holdUntil === null)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Release everything this session holds, retentions included (design D10): the
|
|
889
|
+
* `agent/disposed` rule — a disposed agent never comes back.
|
|
890
|
+
* @param {string} session
|
|
891
|
+
* @returns {Promise<{ released: string[] }>}
|
|
892
|
+
*/
|
|
893
|
+
async function releaseAll(session) {
|
|
894
|
+
return await releaseMatching((record) => record.session === session)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* @param {(record: LeaseRecord) => boolean} match
|
|
899
|
+
* @returns {Promise<{ released: string[] }>}
|
|
900
|
+
*/
|
|
901
|
+
async function releaseMatching(match) {
|
|
902
|
+
/** @type {Map<string, { session: string, paths: string[] }>} */
|
|
903
|
+
const groups = new Map()
|
|
904
|
+
for (const [path, record] of [...ledger]) {
|
|
905
|
+
if (!match(record)) continue
|
|
906
|
+
// The intent is "stop holding this", and a failed frame cannot undo the
|
|
907
|
+
// intent: the entry goes, whatever the transport does next.
|
|
908
|
+
ledger.delete(path)
|
|
909
|
+
const group = groups.get(record.lineage) ?? { session: record.session, paths: [] }
|
|
910
|
+
group.session = record.session
|
|
911
|
+
group.paths.push(path)
|
|
912
|
+
groups.set(record.lineage, group)
|
|
913
|
+
}
|
|
914
|
+
/** @type {string[]} */
|
|
915
|
+
const released = []
|
|
916
|
+
for (const [lineage, group] of groups) {
|
|
917
|
+
try {
|
|
918
|
+
const response = await request('release', { owner: ownerOf(lineage, group.session), paths: group.paths })
|
|
919
|
+
if (Array.isArray(response.released)) released.push(...response.released)
|
|
920
|
+
} catch {
|
|
921
|
+
// See above: never throw at a turn boundary, never keep state to retry.
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return { released }
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Replay every still-valid retention (`holdUntil > now`) so a daemon restart
|
|
929
|
+
* does not silently drop a hold. An entry somebody else now owns is removed
|
|
930
|
+
* without an error: the next write sees an ordinary conflict (D6).
|
|
931
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
932
|
+
*/
|
|
933
|
+
async function replayHolds() {
|
|
934
|
+
await ensureConnection()
|
|
935
|
+
// An explicit replay satisfies a queued one: never send the same retention
|
|
936
|
+
// twice just because a reconnect happened to arm it.
|
|
937
|
+
replayQueued = false
|
|
938
|
+
return await runReplay()
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
943
|
+
*/
|
|
944
|
+
function runReplay() {
|
|
945
|
+
if (replaying !== null) return replaying
|
|
946
|
+
/** @type {Promise<{ replayed: number, dropped: number }>} */
|
|
947
|
+
const attempt = replayOnce().finally(() => {
|
|
948
|
+
if (replaying === attempt) replaying = null
|
|
949
|
+
})
|
|
950
|
+
replaying = attempt
|
|
951
|
+
return attempt
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
956
|
+
*/
|
|
957
|
+
async function replayOnce() {
|
|
958
|
+
let replayed = 0
|
|
959
|
+
let dropped = 0
|
|
960
|
+
const at = now()
|
|
961
|
+
for (const [path, record] of [...ledger]) {
|
|
962
|
+
if (record.holdUntil === null || record.holdUntil <= at) continue
|
|
963
|
+
const owner = ownerOf(record.lineage, record.session)
|
|
964
|
+
const scope = lastScope
|
|
965
|
+
try {
|
|
966
|
+
// Re-acquire first: after a daemon restart the table is empty, so a bare
|
|
967
|
+
// `hold` would be `not-owned`. The pair is idempotent by protocol.
|
|
968
|
+
await acquire(owner, [path], requireScope(scope))
|
|
969
|
+
const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
|
|
970
|
+
applyHold(response, owner)
|
|
971
|
+
replayed += 1
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (errorCode(error) === TRANSPORT_CODE) {
|
|
974
|
+
// The connection died again: keep the rest for the next reconnect
|
|
975
|
+
// rather than forgetting a retention nobody has seen fail.
|
|
976
|
+
break
|
|
977
|
+
}
|
|
978
|
+
ledger.delete(path)
|
|
979
|
+
dropped += 1
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return { replayed, dropped }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Remaining retention as `hold` minutes, clamped to the protocol's closed
|
|
987
|
+
* interval `(0, 60]` and rounded UP so a replay never shortens a hold.
|
|
988
|
+
* @param {number} remainingMs
|
|
989
|
+
* @returns {number}
|
|
990
|
+
*/
|
|
991
|
+
function holdMinutes(remainingMs) {
|
|
992
|
+
const minutes = Math.ceil((remainingMs / 60_000) * 1000) / 1000
|
|
993
|
+
return Math.min(HOLD_MAX_MINUTES, Math.max(0.001, minutes))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ---------------------------------------------------------------------
|
|
997
|
+
// Degrade accounting, push events, shutdown
|
|
998
|
+
// ---------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Record one degraded write. Rate limited per process (D7) so a broken daemon
|
|
1002
|
+
* cannot flood the audit log, and never allowed to throw.
|
|
1003
|
+
* @param {Record<string, any>} detail
|
|
1004
|
+
*/
|
|
1005
|
+
function auditDegraded(detail) {
|
|
1006
|
+
const at = now()
|
|
1007
|
+
if (lastDegradedAt !== null && at - lastDegradedAt < degradedAuditWindowMs) return
|
|
1008
|
+
lastDegradedAt = at
|
|
1009
|
+
try {
|
|
1010
|
+
audit('degraded', { ...detail, at })
|
|
1011
|
+
} catch {
|
|
1012
|
+
// Audit is log-only; it must never break a write.
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Subscribe to broker-pushed `request` / `settled` events.
|
|
1018
|
+
* @param {(event: any) => void} handler
|
|
1019
|
+
* @returns {() => void} unsubscribe
|
|
1020
|
+
*/
|
|
1021
|
+
function onEvent(handler) {
|
|
1022
|
+
if (typeof handler !== 'function') throw new TypeError('onEvent requires a handler')
|
|
1023
|
+
eventHandlers.add(handler)
|
|
1024
|
+
return () => {
|
|
1025
|
+
eventHandlers.delete(handler)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Close the connection. The daemon keeps running: it is shared with every other
|
|
1031
|
+
* client on this machine, and its own idle window is what ends it.
|
|
1032
|
+
*/
|
|
1033
|
+
function close() {
|
|
1034
|
+
if (closed) return
|
|
1035
|
+
closed = true
|
|
1036
|
+
const target = connection
|
|
1037
|
+
connection = null
|
|
1038
|
+
greeted = false
|
|
1039
|
+
ledger.clear()
|
|
1040
|
+
failWaiters(transportError('lease client closed'))
|
|
1041
|
+
if (target !== null) {
|
|
1042
|
+
try {
|
|
1043
|
+
target.close()
|
|
1044
|
+
} catch {
|
|
1045
|
+
// Already gone.
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return Object.freeze({
|
|
1051
|
+
endpoint,
|
|
1052
|
+
request,
|
|
1053
|
+
acquire,
|
|
1054
|
+
withLeases,
|
|
1055
|
+
hold,
|
|
1056
|
+
status,
|
|
1057
|
+
connectionState,
|
|
1058
|
+
requestTransfer,
|
|
1059
|
+
reply,
|
|
1060
|
+
releaseSession,
|
|
1061
|
+
releaseAll,
|
|
1062
|
+
replayHolds,
|
|
1063
|
+
onEvent,
|
|
1064
|
+
close,
|
|
1065
|
+
})
|
|
1066
|
+
}
|