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,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,
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
|
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(
|
|
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:
|
|
82
|
+
command: resolved.command,
|
|
76
83
|
installed,
|
|
77
84
|
version,
|
|
78
|
-
installCommand: displayInstallCommand(
|
|
79
|
-
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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
package/src/lsp/registry.js
CHANGED
|
@@ -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: '
|
|
159
|
-
|
|
160
|
-
darwin: {
|
|
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: '
|
|
168
|
-
|
|
169
|
-
darwin: {
|
|
170
|
-
|
|
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
|
-
/**
|
|
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
|
|
249
|
+
const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
|
|
250
|
+
const install = resolved?.install
|
|
198
251
|
if (!install) return undefined
|
|
199
|
-
if (
|
|
252
|
+
if (typeof install.command === 'string') return install // single-spec form
|
|
200
253
|
return install[platform] ?? install.default
|
|
201
254
|
}
|
|
202
255
|
|
|
203
|
-
/**
|
|
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
|
|
206
|
-
|
|
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
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
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
|
}
|