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.
Files changed (89) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,425 @@
1
+ // In-memory arbitration kernel behind `weir-editlockd`.
2
+ //
3
+ // Pure state machine: no IO, no timers, no real clock (the clock is injected),
4
+ // no DSH/cordis/@deepseek-ai imports. Everything the kernel knows lives in two
5
+ // maps and is thrown away when the process dies — that is the whole point of
6
+ // the design (design D6): `kill` equals "every lease expired at once", and no
7
+ // question about who owns what survives the process.
8
+ //
9
+ // Two states only (design D3): a path is `held` by exactly one connection, or
10
+ // it is free. A transfer request is a mailbox entry with a bounded window, not
11
+ // a third ownership state.
12
+ //
13
+ // Multiplicity rules that are easy to get wrong and are therefore stated once
14
+ // here and enforced in one place:
15
+ //
16
+ // - Identity is `owner.id` (the client's lineage). `session` and `label` are
17
+ // display/delivery hints and may change; an acquisition from the SAME
18
+ // `owner.id` on the SAME connection is the same owner, so it adopts and
19
+ // refreshes instead of conflicting. The same `owner.id` through a DIFFERENT
20
+ // connection is a different owner for arbitration purposes (spec: "Same
21
+ // owner identifier from another connection").
22
+ // - Validity is `max(expiresAt, holdUntil ?? 0)`. Once `now() >=` that instant
23
+ // the path is free for anyone, without negotiation and without notification.
24
+ // - `release` judges identity only (a lapsed row still belongs to its owner, so
25
+ // releasing it is `released`, keeping the call idempotent), while `hold`
26
+ // additionally requires the row to still be valid — you cannot extend
27
+ // ownership that already lapsed, you must acquire again first.
28
+
29
+ /**
30
+ * @typedef {{ id: string, session: string, label: string }} Owner
31
+ * @typedef {{ path: string, connectionId: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} Lease
32
+ * @typedef {{ path: string, holder: Owner, expiresAt: number, holdUntil: number | null }} Conflict
33
+ * @typedef {{ path: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} LeaseView
34
+ * @typedef {{ requestId: string, path: string, from: Owner, connectionId: string, holderConnectionId: string, holderId: string, expiresAt: number }} PendingRequest
35
+ * @typedef {{ event: 'request', requestId: string, path: string, from: Owner, expiresAt: number }} RequestEvent
36
+ * @typedef {{ event: 'settled', requestId: string, path: string, state: 'transferred' | 'declined' | 'expired' }} SettledEvent
37
+ * @typedef {{ connectionId: string, owner: Owner, scope: string, paths: string[], ttlMs?: number }} AcquireInput
38
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[] }} ReleaseInput
39
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[], minutes: number }} HoldInput
40
+ * @typedef {{ connectionId: string, from: Owner, path: string }} RequestInput
41
+ * @typedef {{ connectionId: string, owner: Owner, requestId: string, decision: 'release' | 'keep' }} ReplyInput
42
+ * @typedef {{ scope?: string, session?: string, path?: string }} StatusFilter
43
+ * @typedef {{ ok: true, granted: LeaseView[] } | { ok: false, code: 'held', conflicts: Conflict[] }} AcquireResult
44
+ * @typedef {{ ok: true, released: string[], notOwned: string[] }} ReleaseResult
45
+ * @typedef {{ ok: true, held: { path: string, expiresAt: number, holdUntil: number | null }[] } | { ok: false, code: 'limit', maxMinutes: number } | { ok: false, code: 'not-owned', paths: string[] } | { ok: false, code: 'bad-request' }} HoldResult
46
+ * @typedef {{ ok: true, leases: LeaseView[] }} StatusResult
47
+ * @typedef {{ ok: true, state: 'pending', requestId: string, holder: Owner, expiresAt: number } | { ok: false, code: 'free' | 'self' }} RequestResult
48
+ * @typedef {{ ok: true, state: 'transferred' | 'declined' } | { ok: false, code: 'expired' | 'stale' | 'not-holder' }} ReplyResult
49
+ */
50
+
51
+ /**
52
+ * @param {Lease} lease
53
+ * @returns {LeaseView}
54
+ */
55
+ function view(lease) {
56
+ return {
57
+ path: lease.path,
58
+ owner: { ...lease.owner },
59
+ scope: lease.scope,
60
+ acquiredAt: lease.acquiredAt,
61
+ expiresAt: lease.expiresAt,
62
+ holdUntil: lease.holdUntil,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Create one arbitration kernel.
68
+ *
69
+ * @param {object} [options]
70
+ * @param {() => number} [options.now] injected clock (epoch ms)
71
+ * @param {number} [options.ttlMs] default lease time-to-live
72
+ * @param {number} [options.requestWindowMs] bounded negotiation window
73
+ * @param {number} [options.holdMaxMinutes] retention bound in minutes
74
+ * @param {(connectionId: string, event: RequestEvent | SettledEvent) => void} [options.onEvent] event sink; a missing connection or a throwing sink never affects arbitration
75
+ */
76
+ export function createBroker({
77
+ now = Date.now,
78
+ ttlMs = 120_000,
79
+ requestWindowMs = 120_000,
80
+ holdMaxMinutes = 60,
81
+ onEvent = () => {},
82
+ } = {}) {
83
+ /** @type {Map<string, Lease>} */
84
+ const leases = new Map()
85
+ /** @type {Map<string, PendingRequest>} */
86
+ const pending = new Map()
87
+ /** @type {Map<string, string>} requester+path → requestId, at most one per pair */
88
+ const pendingKeys = new Map()
89
+ /** @type {Set<string>} */
90
+ const connections = new Set()
91
+ let requestSeq = 0
92
+
93
+ /** @param {Lease} lease */
94
+ function validUntil(lease) {
95
+ return Math.max(lease.expiresAt, lease.holdUntil ?? 0)
96
+ }
97
+
98
+ /** @param {Lease} lease */
99
+ function isValid(lease) {
100
+ return now() < validUntil(lease)
101
+ }
102
+
103
+ /** @param {string} path @param {string} fromId */
104
+ function requestKey(path, fromId) {
105
+ return `${fromId}\u0000${path}`
106
+ }
107
+
108
+ /** Drop a mailbox entry and its (requester, path) index slot. @param {string} requestId */
109
+ function dropRequest(requestId) {
110
+ const record = pending.get(requestId)
111
+ if (!record) return
112
+ pending.delete(requestId)
113
+ const key = requestKey(record.path, record.from.id)
114
+ if (pendingKeys.get(key) === requestId) pendingKeys.delete(key)
115
+ }
116
+
117
+ /**
118
+ * Deliver one event. Delivery is best effort by contract: an unknown
119
+ * connection is a silent drop and a throwing sink cannot break arbitration.
120
+ * @param {string} connectionId
121
+ * @param {RequestEvent | SettledEvent} event
122
+ */
123
+ function deliver(connectionId, event) {
124
+ if (!connections.has(connectionId)) return
125
+ try {
126
+ onEvent(connectionId, event)
127
+ } catch {
128
+ // The sink is a transport; it must never be able to corrupt the table.
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Announce the end of a request to the requester.
134
+ * @param {PendingRequest} record
135
+ * @param {'transferred' | 'declined' | 'expired'} state
136
+ */
137
+ function settle(record, state) {
138
+ deliver(record.connectionId, { event: 'settled', requestId: record.requestId, path: record.path, state })
139
+ }
140
+
141
+ /** @param {string[]} paths */
142
+ function normalize(paths) {
143
+ return [...new Set(paths)].sort()
144
+ }
145
+
146
+ /** Register a connection. @param {string} connectionId */
147
+ function connect(connectionId) {
148
+ connections.add(connectionId)
149
+ }
150
+
151
+ /**
152
+ * Forget a connection: every lease it holds is released immediately (no
153
+ * expiry wait), and every mailbox entry it takes part in is dropped. A
154
+ * request whose holder went away settles as `expired` for a requester that is
155
+ * still connected; a request owned by the departing connection simply dies
156
+ * with it.
157
+ * @param {string} connectionId
158
+ */
159
+ function disconnect(connectionId) {
160
+ connections.delete(connectionId)
161
+ for (const [path, lease] of [...leases]) {
162
+ if (lease.connectionId === connectionId) leases.delete(path)
163
+ }
164
+ for (const [requestId, record] of [...pending]) {
165
+ if (record.connectionId === connectionId) {
166
+ dropRequest(requestId)
167
+ continue
168
+ }
169
+ if (record.holderConnectionId === connectionId) {
170
+ dropRequest(requestId)
171
+ settle(record, 'expired')
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Acquire a whole path set atomically. Paths are evaluated in sorted order so
178
+ * two overlapping requests cannot deadlock and the reply is deterministic.
179
+ * One live foreign holder refuses the entire batch — nothing is granted.
180
+ * @param {AcquireInput} input
181
+ * @returns {AcquireResult}
182
+ */
183
+ function acquire({ connectionId, owner, scope, paths, ttlMs: requestTtlMs }) {
184
+ const at = now()
185
+ const ttl = requestTtlMs !== undefined ? requestTtlMs : ttlMs
186
+ /** @type {Conflict[]} */
187
+ const conflicts = []
188
+ /** @type {string[]} */
189
+ const fresh = []
190
+ /** @type {Lease[]} */
191
+ const adopt = []
192
+ for (const path of normalize(paths)) {
193
+ const lease = leases.get(path)
194
+ if (!lease || !isValid(lease)) {
195
+ fresh.push(path)
196
+ continue
197
+ }
198
+ if (lease.owner.id === owner.id && lease.connectionId === connectionId) {
199
+ adopt.push(lease)
200
+ continue
201
+ }
202
+ conflicts.push({ path, holder: { ...lease.owner }, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
203
+ }
204
+ if (conflicts.length > 0) return { ok: false, code: 'held', conflicts }
205
+ /** @type {LeaseView[]} */
206
+ const granted = []
207
+ for (const lease of adopt) {
208
+ // Same owner, same connection: adopt. `acquiredAt` and `holdUntil` are
209
+ // preserved (only the sliding validity moves); session/label/scope are
210
+ // refreshed because this is the latest touch of that lineage.
211
+ lease.owner = { ...owner }
212
+ lease.scope = scope
213
+ lease.expiresAt = at + ttl
214
+ granted.push(view(lease))
215
+ }
216
+ for (const path of fresh) {
217
+ /** @type {Lease} */
218
+ const lease = {
219
+ path,
220
+ connectionId,
221
+ owner: { ...owner },
222
+ scope,
223
+ acquiredAt: at,
224
+ expiresAt: at + ttl,
225
+ holdUntil: null,
226
+ }
227
+ leases.set(path, lease)
228
+ granted.push(view(lease))
229
+ }
230
+ granted.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
231
+ return { ok: true, granted }
232
+ }
233
+
234
+ /**
235
+ * Release whatever this owner still holds in the set. `notOwned` is NOT an
236
+ * error — after a broker restart every path is simply free, and the client's
237
+ * release list is its own business. Repeating a release is therefore a no-op
238
+ * that reports `notOwned`.
239
+ * @param {ReleaseInput} input
240
+ * @returns {ReleaseResult}
241
+ */
242
+ function release({ connectionId, owner, paths }) {
243
+ /** @type {string[]} */
244
+ const released = []
245
+ /** @type {string[]} */
246
+ const notOwned = []
247
+ for (const path of normalize(paths)) {
248
+ const lease = leases.get(path)
249
+ if (lease && lease.connectionId === connectionId && lease.owner.id === owner.id) {
250
+ leases.delete(path)
251
+ released.push(path)
252
+ } else {
253
+ notOwned.push(path)
254
+ }
255
+ }
256
+ return { ok: true, released, notOwned }
257
+ }
258
+
259
+ /**
260
+ * Extend validity to a wall-clock deadline. All-or-nothing: one path this
261
+ * owner does not (still) hold refuses the whole batch. The bound is a product
262
+ * constant, never a budget: reaching it has no consequence other than the
263
+ * refusal itself.
264
+ * @param {HoldInput} input
265
+ * @returns {HoldResult}
266
+ */
267
+ function hold({ connectionId, owner, paths, minutes }) {
268
+ if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return { ok: false, code: 'bad-request' }
269
+ if (minutes > holdMaxMinutes) return { ok: false, code: 'limit', maxMinutes: holdMaxMinutes }
270
+ const at = now()
271
+ const targets = normalize(paths)
272
+ /** @type {string[]} */
273
+ const missing = []
274
+ for (const path of targets) {
275
+ const lease = leases.get(path)
276
+ if (!lease || lease.connectionId !== connectionId || lease.owner.id !== owner.id || !isValid(lease)) missing.push(path)
277
+ }
278
+ if (missing.length > 0) return { ok: false, code: 'not-owned', paths: missing }
279
+ const deadline = at + minutes * 60_000
280
+ /** @type {{ path: string, expiresAt: number, holdUntil: number | null }[]} */
281
+ const held = []
282
+ for (const path of targets) {
283
+ const lease = /** @type {Lease} */ (leases.get(path))
284
+ // Only ever later: an earlier deadline never shortens an existing one.
285
+ lease.holdUntil = Math.max(lease.holdUntil ?? 0, deadline)
286
+ held.push({ path, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
287
+ }
288
+ return { ok: true, held }
289
+ }
290
+
291
+ /**
292
+ * Read-only projection. Filters stack (AND). Lapsed rows are not reported:
293
+ * they are free regardless of what is still in the table, and `sweep`
294
+ * reclaims the memory. Returned leases are copies.
295
+ * @param {StatusFilter} [filter]
296
+ * @returns {StatusResult}
297
+ */
298
+ function status(filter = {}) {
299
+ /** @type {LeaseView[]} */
300
+ const out = []
301
+ for (const lease of leases.values()) {
302
+ if (!isValid(lease)) continue
303
+ if (filter.scope !== undefined && lease.scope !== filter.scope) continue
304
+ if (filter.session !== undefined && lease.owner.session !== filter.session) continue
305
+ if (filter.path !== undefined && lease.path !== filter.path) continue
306
+ out.push(view(lease))
307
+ }
308
+ out.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
309
+ return { ok: true, leases: out }
310
+ }
311
+
312
+ /**
313
+ * Ask the holder of a path to hand it over. At most one pending request per
314
+ * (requester id, path): a repeat reuses the same `requestId` and the same
315
+ * window instead of opening a second mailbox entry — unless ownership changed
316
+ * hands in between, in which case the stale entry is replaced so the request
317
+ * reaches the actual holder.
318
+ * @param {RequestInput} input
319
+ * @returns {RequestResult}
320
+ */
321
+ function request({ connectionId, from, path }) {
322
+ const at = now()
323
+ const lease = leases.get(path)
324
+ if (!lease || !isValid(lease)) return { ok: false, code: 'free' }
325
+ if (lease.owner.id === from.id) return { ok: false, code: 'self' }
326
+ const key = requestKey(path, from.id)
327
+ const knownId = pendingKeys.get(key)
328
+ const known = knownId !== undefined ? pending.get(knownId) : undefined
329
+ if (known && known.holderId === lease.owner.id) {
330
+ return { ok: true, state: 'pending', requestId: known.requestId, holder: { ...lease.owner }, expiresAt: known.expiresAt }
331
+ }
332
+ if (known) dropRequest(known.requestId)
333
+ const requestId = `req-${++requestSeq}`
334
+ /** @type {PendingRequest} */
335
+ const record = {
336
+ requestId,
337
+ path,
338
+ from: { ...from },
339
+ connectionId,
340
+ holderConnectionId: lease.connectionId,
341
+ holderId: lease.owner.id,
342
+ expiresAt: at + requestWindowMs,
343
+ }
344
+ pending.set(requestId, record)
345
+ pendingKeys.set(key, requestId)
346
+ deliver(lease.connectionId, { event: 'request', requestId, path, from: { ...from }, expiresAt: record.expiresAt })
347
+ return { ok: true, state: 'pending', requestId, holder: { ...lease.owner }, expiresAt: record.expiresAt }
348
+ }
349
+
350
+ /**
351
+ * Answer a pending request as the holder.
352
+ *
353
+ * Precedence, in this order — the order is what makes all four documented
354
+ * outcomes reachable: unknown/elapsed request → `expired`; ownership gone or
355
+ * changed hands → `stale` (no ownership change); reply not from the holding
356
+ * connection/owner → `not-holder` and the request stays pending (a stranger
357
+ * must not be able to consume someone else's request); otherwise `keep`
358
+ * declines, `release` transfers in place.
359
+ * @param {ReplyInput} input
360
+ * @returns {ReplyResult}
361
+ */
362
+ function reply({ connectionId, owner, requestId, decision }) {
363
+ const at = now()
364
+ const record = pending.get(requestId)
365
+ if (!record) return { ok: false, code: 'expired' }
366
+ if (at >= record.expiresAt) {
367
+ dropRequest(requestId)
368
+ settle(record, 'expired')
369
+ return { ok: false, code: 'expired' }
370
+ }
371
+ const lease = leases.get(record.path)
372
+ if (!lease || !isValid(lease)) {
373
+ dropRequest(requestId)
374
+ settle(record, 'expired')
375
+ return { ok: false, code: 'stale' }
376
+ }
377
+ if (lease.owner.id !== record.holderId) {
378
+ dropRequest(requestId)
379
+ settle(record, 'expired')
380
+ return { ok: false, code: 'stale' }
381
+ }
382
+ if (lease.connectionId !== connectionId || lease.owner.id !== owner.id) return { ok: false, code: 'not-holder' }
383
+ dropRequest(requestId)
384
+ if (decision === 'keep') {
385
+ settle(record, 'declined')
386
+ return { ok: true, state: 'declined' }
387
+ }
388
+ lease.owner = { ...record.from }
389
+ lease.connectionId = record.connectionId
390
+ lease.acquiredAt = at
391
+ lease.expiresAt = at + ttlMs
392
+ lease.holdUntil = null
393
+ settle(record, 'transferred')
394
+ return { ok: true, state: 'transferred' }
395
+ }
396
+
397
+ /**
398
+ * Reclaim memory for lapsed leases and elapsed requests. Purely an
399
+ * optimisation for long-lived brokers — validity is decided by the clock, not
400
+ * by this pass — except that an elapsed request is the only moment the
401
+ * requester learns its request expired.
402
+ * @returns {{ droppedLeases: number, droppedRequests: number }}
403
+ */
404
+ function sweep() {
405
+ const at = now()
406
+ let droppedLeases = 0
407
+ let droppedRequests = 0
408
+ for (const [path, lease] of [...leases]) {
409
+ if (at >= validUntil(lease)) {
410
+ leases.delete(path)
411
+ droppedLeases += 1
412
+ }
413
+ }
414
+ for (const [requestId, record] of [...pending]) {
415
+ if (at >= record.expiresAt) {
416
+ dropRequest(requestId)
417
+ settle(record, 'expired')
418
+ droppedRequests += 1
419
+ }
420
+ }
421
+ return { droppedLeases, droppedRequests }
422
+ }
423
+
424
+ return Object.freeze({ connect, disconnect, acquire, release, hold, status, request, reply, sweep })
425
+ }
package/src/lsp/admin.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // the community catalog). Plain ESM, ctx-only; `connection`/`subprocess`
4
4
  // resolve through ctx.get so the module mounts harmlessly in compositions
5
5
  // without them (headless).
6
- import { buildRegistry, DEFAULT_SERVERS, displayInstallCommand, installSpecFor, languageIdsForFamily } from './registry.js'
6
+ import { buildRegistry, displayInstallCommand, installSpecFor, languageIdsForFamily, serverForPlatform } from './registry.js'
7
7
  import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '../process/index.js'
8
8
  import { jsonResponse, readJsonBody } from '../shared/http-json.js'
9
9
 
@@ -39,20 +39,27 @@ export async function probeVersion(subprocess, executable, args = ['--version'],
39
39
  return first ?? null
40
40
  }
41
41
 
42
- /** Per-family status rows over the community catalog. */
42
+ /**
43
+ * Per-family status rows over the community catalog. `options.platform`
44
+ * (default: the host) resolves each family's platform row first, so every
45
+ * displayed value — command, version probe, install command, hint — belongs to
46
+ * the platform being reported (D2/D5).
47
+ */
43
48
  export async function lspStatusFor(registry, subprocess, options = {}) {
44
49
  const probeTimeoutMs = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS
45
50
  const dirs = options.dirs ?? undefined
51
+ const platform = options.platform ?? process.platform
46
52
  const servers = []
47
53
  for (const [family, entry] of Object.entries(registry)) {
54
+ const resolved = serverForPlatform(entry, platform)
48
55
  let installed = false
49
56
  let version = null
50
57
  try {
51
- const executable = await extendedResolveExecutable(subprocess, entry.command, dirs)
58
+ const executable = await extendedResolveExecutable(subprocess, resolved.command, dirs)
52
59
  installed = Boolean(executable)
53
60
  if (executable) {
54
61
  try {
55
- version = await probeVersion(subprocess, executable, entry.versionArgs ?? ['--version'], probeTimeoutMs)
62
+ version = await probeVersion(subprocess, executable, resolved.versionArgs ?? ['--version'], probeTimeoutMs)
56
63
  } catch {
57
64
  version = null
58
65
  }
@@ -61,7 +68,7 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
61
68
  // unresolvable command = not installed
62
69
  }
63
70
  let installerAvailable = false
64
- const installerSpec = installSpecFor(entry)
71
+ const installerSpec = installSpecFor(resolved, platform)
65
72
  if (installerSpec) {
66
73
  try {
67
74
  installerAvailable = Boolean(await extendedResolveExecutable(subprocess, installerSpec.command, dirs))
@@ -72,11 +79,11 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
72
79
  servers.push({
73
80
  family,
74
81
  languageIds: languageIdsForFamily(family),
75
- command: entry.command,
82
+ command: resolved.command,
76
83
  installed,
77
84
  version,
78
- installCommand: displayInstallCommand(entry),
79
- installHint: entry.installHint ?? '',
85
+ installCommand: displayInstallCommand(resolved, platform),
86
+ installHint: resolved.installHint ?? '',
80
87
  installerAvailable,
81
88
  })
82
89
  }
@@ -87,13 +94,15 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
87
94
  * Run one family's install command: argv-only (no shell), output captured,
88
95
  * bounded by a timeout. Resolves `{ output, exitCode, timedOut }`; throws a
89
96
  * structured Error for unknown families, missing installers, or a missing
90
- * installer binary. `dirs` overrides the extra bin-directory scan (tests).
97
+ * installer binary. `dirs` overrides the extra bin-directory scan (tests);
98
+ * `platform` selects the family's platform row and its install spec (D2).
91
99
  */
92
- export async function runInstall(registry, subprocess, family, timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS, dirs = undefined) {
100
+ export async function runInstall(registry, subprocess, family, timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS, dirs = undefined, platform = process.platform) {
93
101
  const entry = registry[family]
94
102
  if (!entry) throw new Error(`lsp: unknown language family '${family}'`)
95
- const spec = installSpecFor(entry)
96
- if (!spec) throw new Error(`lsp: no installer for '${family}' on this platform — ${entry.installHint ?? 'see the install hint'}`)
103
+ const resolved = serverForPlatform(entry, platform)
104
+ const spec = installSpecFor(resolved, platform)
105
+ if (!spec) throw new Error(`lsp: no installer for '${family}' on this platform — ${resolved.installHint ?? 'see the install hint'}`)
97
106
  const executable = await extendedResolveExecutable(subprocess, spec.command, dirs).catch(() => undefined)
98
107
  if (!executable) throw new Error(`lsp: installer '${spec.command}' not found on PATH — install it first`)
99
108
  // npm installs pin a user-writable prefix: the resolved npm may belong to
@@ -127,7 +136,9 @@ export function registerLspAdminEndpoints(ctx, options = {}) {
127
136
  ctx.logger?.warn?.('weir-lsp-admin: connection/subprocess unavailable — LSP management endpoints not registered')
128
137
  return () => {}
129
138
  }
130
- const registryProvider = typeof options.registry === 'function' ? options.registry : () => DEFAULT_SERVERS
139
+ // The catalog is platform-scoped: an absent provider builds the registry for
140
+ // the running host (rows resolved), never the raw unresolved DEFAULT_SERVERS.
141
+ const registryProvider = typeof options.registry === 'function' ? options.registry : () => buildRegistry()
131
142
  const installTimeoutMs = options.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS
132
143
  const dirs = options.dirs ?? undefined
133
144
  const disposers = []
package/src/lsp/index.js CHANGED
@@ -81,7 +81,10 @@ function apply(ctx, config = {}) {
81
81
  manager = createLspManager({
82
82
  subprocess: ctx.subprocess,
83
83
  fs: ctx.fs,
84
- registry: buildRegistry(option('servers', config.servers)),
84
+ // `config.platform` is the test seam: the registry resolves the running
85
+ // host's rows unless a caller pins the platform (D2 — consumers below
86
+ // only ever see flat, already-resolved entries).
87
+ registry: buildRegistry(option('servers', config.servers), config.platform ?? process.platform),
85
88
  // config.extraBinDirs overrides the extended-resolution scan dirs
86
89
  // (hermetic tests; power users with exotic install locations)
87
90
  resolveExecutable: (command) => extendedResolveExecutable(ctx.subprocess, command, config.extraBinDirs),
@@ -244,7 +247,7 @@ function apply(ctx, config = {}) {
244
247
  diagnosticsWaitMs: option('diagnosticsWaitMs', config.diagnosticsWaitMs),
245
248
  }
246
249
  manager.setOptions(Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)))
247
- manager.setRegistry(buildRegistry(option('servers', config.servers)))
250
+ manager.setRegistry(buildRegistry(option('servers', config.servers), config.platform ?? process.platform))
248
251
  }
249
252
  })
250
253
  applyGate()
@@ -64,6 +64,14 @@ export const LANGUAGE_FAMILY = {
64
64
  cpp: 'cpp',
65
65
  }
66
66
 
67
+ /**
68
+ * Built-in catalog. An entry may carry `platforms: { darwin?, linux?, win32?,
69
+ * default? }`; every row has the entry shape (all fields optional) and
70
+ * overrides only the fields it declares for that platform (D1). A family whose
71
+ * data is identical on every platform declares it once as the shared base
72
+ * (npm/pipx/go/rustup); the rest keep a platform-NEUTRAL base plus one row per
73
+ * platform, so no platform ever sees another platform's installer (D5).
74
+ */
67
75
  export const DEFAULT_SERVERS = {
68
76
  typescript: {
69
77
  command: 'typescript-language-server',
@@ -155,19 +163,41 @@ export const DEFAULT_SERVERS = {
155
163
  command: 'lua-language-server',
156
164
  args: [],
157
165
  manifests: ['*.lua'],
158
- installHint: 'brew install lua-language-server (macOS); see https://luals.github.io for other platforms',
159
- install: {
160
- darwin: { command: 'brew', args: ['install', 'lua-language-server'] },
166
+ installHint: 'install LuaLS from https://luals.github.io',
167
+ platforms: {
168
+ darwin: {
169
+ installHint: 'brew install lua-language-server',
170
+ install: { command: 'brew', args: ['install', 'lua-language-server'] },
171
+ },
172
+ linux: {
173
+ // no package-managed LuaLS on Linux: hint only, pointing at the release
174
+ // page (a one-click install would be a guess, not a recipe)
175
+ installHint: 'no package-managed LuaLS on Linux — download it from https://github.com/LuaLS/lua-language-server/releases',
176
+ },
177
+ win32: {
178
+ installHint: 'winget install --id LuaLS.lua-language-server -e --accept-source-agreements --accept-package-agreements',
179
+ install: { command: 'winget', args: ['install', '--id', 'LuaLS.lua-language-server', '-e', '--accept-source-agreements', '--accept-package-agreements'] },
180
+ },
161
181
  },
162
182
  },
163
183
  cpp: {
164
184
  command: 'clangd',
165
185
  args: [],
166
186
  manifests: ['*.c', '*.cpp', 'compile_commands.json'],
167
- installHint: 'brew install llvm (macOS); sudo apt-get install clangd (Debian/Ubuntu)',
168
- install: {
169
- darwin: { command: 'brew', args: ['install', 'llvm'] },
170
- linux: { command: 'sudo', args: ['apt-get', 'install', '-y', 'clangd'] },
187
+ installHint: 'install clangd from https://clangd.llvm.org/installation',
188
+ platforms: {
189
+ darwin: {
190
+ installHint: 'brew install llvm',
191
+ install: { command: 'brew', args: ['install', 'llvm'] },
192
+ },
193
+ linux: {
194
+ installHint: 'sudo apt-get install clangd',
195
+ install: { command: 'sudo', args: ['apt-get', 'install', '-y', 'clangd'] },
196
+ },
197
+ win32: {
198
+ installHint: 'winget install --id LLVM.LLVM -e --accept-source-agreements --accept-package-agreements',
199
+ install: { command: 'winget', args: ['install', '--id', 'LLVM.LLVM', '-e', '--accept-source-agreements', '--accept-package-agreements'] },
200
+ },
171
201
  },
172
202
  },
173
203
  }
@@ -192,26 +222,62 @@ export function languageIdsForFamily(family) {
192
222
  return Object.entries(LANGUAGE_FAMILY).filter(([, value]) => value === family).map(([id]) => id)
193
223
  }
194
224
 
195
- /** Platform-resolved install spec for a registry entry, or undefined. */
225
+ /**
226
+ * Resolve one entry for a platform: `{...entry, ...platforms[platform]}` with
227
+ * the `platforms` key removed (D1/D2). An entry without a `platforms` map is
228
+ * returned unchanged — the platform is never guessed. A platform with no row
229
+ * (and no `default` row) keeps the entry's base fields, which are
230
+ * platform-neutral by contract — never another platform's recipe (D5).
231
+ */
232
+ export function serverForPlatform(entry, platform = process.platform) {
233
+ const platforms = entry?.platforms
234
+ if (!platforms || typeof platforms !== 'object' || Array.isArray(platforms)) return entry
235
+ const row = platforms[platform] ?? platforms.default
236
+ const resolved = { ...entry, ...(row && typeof row === 'object' ? row : {}) }
237
+ delete resolved.platforms
238
+ return resolved
239
+ }
240
+
241
+ /**
242
+ * Platform-resolved install spec for a registry entry, or undefined. The entry
243
+ * is resolved first when it declares `platforms`. A single spec is identified
244
+ * by `install.command` being a string (a platform map never has a `command`
245
+ * key — D7); anything else is the legacy platform map, kept so existing user
246
+ * configs keep resolving.
247
+ */
196
248
  export function installSpecFor(entry, platform = process.platform) {
197
- const install = entry?.install
249
+ const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
250
+ const install = resolved?.install
198
251
  if (!install) return undefined
199
- if (Array.isArray(install.args)) return install // single-spec form
252
+ if (typeof install.command === 'string') return install // single-spec form
200
253
  return install[platform] ?? install.default
201
254
  }
202
255
 
203
- /** Display form of the install command for a registry entry. */
256
+ /**
257
+ * Display form of the install command for a registry entry. Where the platform
258
+ * declares no install spec the fallback is the RESOLVED entry's `installHint`
259
+ * — never another platform's command.
260
+ */
204
261
  export function displayInstallCommand(entry, platform = process.platform) {
205
- const spec = installSpecFor(entry, platform)
206
- if (!spec) return entry?.installHint ?? ''
262
+ const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
263
+ const spec = installSpecFor(resolved, platform)
264
+ if (!spec) return resolved?.installHint ?? ''
207
265
  return [spec.command, ...(spec.args ?? [])].join(' ')
208
266
  }
209
267
 
210
- /** Merge user `lsp.servers` config over the defaults (per-language overlay). */
211
- export function buildRegistry(configServers) {
212
- const registry = { ...DEFAULT_SERVERS }
268
+ /**
269
+ * Merge user `lspServers` config over the platform-resolved catalog. Built-ins
270
+ * resolve first; each user override is then merged onto the resolved built-in
271
+ * and resolved again, so a user row overrides only the fields it declares and
272
+ * a user platform row wins over the built-in one (D2/D6).
273
+ */
274
+ export function buildRegistry(configServers, platform = process.platform) {
275
+ const registry = {}
276
+ for (const [language, entry] of Object.entries(DEFAULT_SERVERS)) {
277
+ registry[language] = serverForPlatform(entry, platform)
278
+ }
213
279
  for (const [language, entry] of Object.entries(configServers ?? {})) {
214
- registry[language] = { ...registry[language], ...entry }
280
+ registry[language] = serverForPlatform({ ...registry[language], ...entry }, platform)
215
281
  }
216
282
  return registry
217
283
  }