weir-harness 1.2.3 → 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/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/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- 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,236 @@
|
|
|
1
|
+
// Wire protocol for `weir-editlockd`: constants plus the frame validator.
|
|
2
|
+
//
|
|
3
|
+
// Pure module — no IO, no clock, no DSH/cordis/@deepseek-ai imports. The same
|
|
4
|
+
// file is the single source of truth for what crosses the socket, and
|
|
5
|
+
// docs/features/edit-lock-protocol.md is kept in lockstep with it by
|
|
6
|
+
// test/lease-broker-protocol-doc.test.js (op names and error codes must match
|
|
7
|
+
// in BOTH directions: a doc-only or an implementation-only entry fails).
|
|
8
|
+
//
|
|
9
|
+
// Field discipline is exact: a frame carrying any field outside the per-op
|
|
10
|
+
// allowlist is rejected as `bad-request`. That keeps the accepted grammar
|
|
11
|
+
// readable from this file alone and prevents "tolerated extras" from drifting
|
|
12
|
+
// into a de-facto second protocol.
|
|
13
|
+
|
|
14
|
+
/** Protocol major version. Bump only for breaking wire changes; the value is
|
|
15
|
+
* part of the socket address so majors never arbitrate against each other. */
|
|
16
|
+
export const PROTOCOL = 1
|
|
17
|
+
|
|
18
|
+
/** Request operations accepted after the handshake (`hello` is the handshake). */
|
|
19
|
+
export const OPS = Object.freeze(['hello', 'acquire', 'release', 'hold', 'status', 'request', 'reply'])
|
|
20
|
+
|
|
21
|
+
/** Unsolicited events pushed by the broker to one connection. */
|
|
22
|
+
export const EVENTS = Object.freeze(['request', 'settled'])
|
|
23
|
+
|
|
24
|
+
/** Closed error vocabulary. No code may describe an operator action: every
|
|
25
|
+
* refusal either resolves by negotiation or by validity elapsing. */
|
|
26
|
+
export const ERROR_CODES = Object.freeze([
|
|
27
|
+
'protocol-mismatch',
|
|
28
|
+
'bad-request',
|
|
29
|
+
'invalid-path',
|
|
30
|
+
'held',
|
|
31
|
+
'not-owned',
|
|
32
|
+
'free',
|
|
33
|
+
'self',
|
|
34
|
+
'limit',
|
|
35
|
+
'expired',
|
|
36
|
+
'stale',
|
|
37
|
+
'not-holder',
|
|
38
|
+
'internal',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/** Reply decisions on a pending transfer request. */
|
|
42
|
+
export const DECISIONS = Object.freeze(['release', 'keep'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {{ id: string, session: string, label: string }} Owner
|
|
46
|
+
* @typedef {{ op: 'hello', protocol: number, reqId?: string }} HelloFrame
|
|
47
|
+
* @typedef {{ op: 'acquire', owner: Owner, scope: string, paths: string[], ttlMs?: number, reqId?: string }} AcquireFrame
|
|
48
|
+
* @typedef {{ op: 'release', owner: Owner, paths: string[], reqId?: string }} ReleaseFrame
|
|
49
|
+
* @typedef {{ op: 'hold', owner: Owner, paths: string[], minutes: number, reqId?: string }} HoldFrame
|
|
50
|
+
* @typedef {{ op: 'status', scope?: string, session?: string, path?: string, reqId?: string }} StatusFrame
|
|
51
|
+
* @typedef {{ op: 'request', from: Owner, path: string, reqId?: string }} RequestFrame
|
|
52
|
+
* @typedef {{ op: 'reply', owner: Owner, requestId: string, decision: 'release' | 'keep', reqId?: string }} ReplyFrame
|
|
53
|
+
* @typedef {HelloFrame | AcquireFrame | ReleaseFrame | HoldFrame | StatusFrame | RequestFrame | ReplyFrame} Frame
|
|
54
|
+
* @typedef {'hello' | 'acquire' | 'release' | 'hold' | 'status' | 'request' | 'reply'} Op
|
|
55
|
+
* @typedef {{ ok: true, op: Op, value: Frame } | { ok: false, code: 'bad-request' | 'invalid-path' }} ValidationResult
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const OP_SET = new Set(OPS)
|
|
59
|
+
|
|
60
|
+
/** Per-op field allowlist: the accepted grammar, exactly. `reqId` is the
|
|
61
|
+
* client's correlation token and is legal on every frame. */
|
|
62
|
+
const FIELDS = Object.freeze({
|
|
63
|
+
hello: ['op', 'protocol', 'reqId'],
|
|
64
|
+
acquire: ['op', 'owner', 'scope', 'paths', 'ttlMs', 'reqId'],
|
|
65
|
+
release: ['op', 'owner', 'paths', 'reqId'],
|
|
66
|
+
hold: ['op', 'owner', 'paths', 'minutes', 'reqId'],
|
|
67
|
+
status: ['op', 'scope', 'session', 'path', 'reqId'],
|
|
68
|
+
request: ['op', 'from', 'path', 'reqId'],
|
|
69
|
+
reply: ['op', 'owner', 'requestId', 'decision', 'reqId'],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} value
|
|
74
|
+
* @returns {value is Record<string, any>} true for a non-array object
|
|
75
|
+
*/
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Non-empty string without NUL.
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {value is string}
|
|
84
|
+
*/
|
|
85
|
+
function isText(value) {
|
|
86
|
+
return typeof value === 'string' && value.length > 0 && !value.includes('\u0000')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every key of `record` is listed in `allowed` (no extras, no unknowns).
|
|
91
|
+
* @param {Record<string, any>} record
|
|
92
|
+
* @param {readonly string[]} allowed
|
|
93
|
+
*/
|
|
94
|
+
function onlyFields(record, allowed) {
|
|
95
|
+
for (const key of Object.keys(record)) if (!allowed.includes(key)) return false
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `owner` is exactly `{id, session, label}` with three non-empty strings — the
|
|
100
|
+
* broker never interprets them (D8). @param {unknown} value */
|
|
101
|
+
function isOwner(value) {
|
|
102
|
+
if (!isRecord(value)) return false
|
|
103
|
+
if (!onlyFields(value, ['id', 'session', 'label'])) return false
|
|
104
|
+
return isText(value.id) && isText(value.session) && isText(value.label)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Absolute POSIX path, or a win32 drive/UNC path. @param {unknown} value */
|
|
108
|
+
function isAbsolutePath(value) {
|
|
109
|
+
if (!isText(value)) return false
|
|
110
|
+
if (value.startsWith('/')) return true
|
|
111
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) return true
|
|
112
|
+
return value.startsWith('\\\\')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Non-empty array of absolute paths. @param {unknown} value */
|
|
116
|
+
function isPathList(value) {
|
|
117
|
+
if (!Array.isArray(value) || value.length === 0) return false
|
|
118
|
+
return value.every((path) => isAbsolutePath(path))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Finite positive number (ttl overrides and retention minutes). @param {unknown} value */
|
|
122
|
+
function isPositiveNumber(value) {
|
|
123
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @returns {ValidationResult} */
|
|
127
|
+
function badRequest() {
|
|
128
|
+
return /** @type {ValidationResult} */ ({ ok: false, code: 'bad-request' })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @type {Record<string, (frame: Record<string, any>) => ValidationResult>} */
|
|
132
|
+
const BUILDERS = {
|
|
133
|
+
/** @returns {ValidationResult} */
|
|
134
|
+
hello(frame) {
|
|
135
|
+
if (!Number.isInteger(frame.protocol) || frame.protocol < 0) return badRequest()
|
|
136
|
+
/** @type {HelloFrame} */
|
|
137
|
+
const value = { op: 'hello', protocol: frame.protocol }
|
|
138
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
139
|
+
return { ok: true, op: 'hello', value }
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/** @returns {ValidationResult} */
|
|
143
|
+
acquire(frame) {
|
|
144
|
+
if (!isOwner(frame.owner)) return badRequest()
|
|
145
|
+
if (!isText(frame.scope)) return badRequest()
|
|
146
|
+
if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
|
|
147
|
+
if (frame.ttlMs !== undefined && !isPositiveNumber(frame.ttlMs)) return badRequest()
|
|
148
|
+
/** @type {AcquireFrame} */
|
|
149
|
+
const value = { op: 'acquire', owner: { ...frame.owner }, scope: frame.scope, paths: [...frame.paths] }
|
|
150
|
+
if (frame.ttlMs !== undefined) value.ttlMs = frame.ttlMs
|
|
151
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
152
|
+
return { ok: true, op: 'acquire', value }
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** @returns {ValidationResult} */
|
|
156
|
+
release(frame) {
|
|
157
|
+
if (!isOwner(frame.owner)) return badRequest()
|
|
158
|
+
if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
|
|
159
|
+
/** @type {ReleaseFrame} */
|
|
160
|
+
const value = { op: 'release', owner: { ...frame.owner }, paths: [...frame.paths] }
|
|
161
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
162
|
+
return { ok: true, op: 'release', value }
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/** @returns {ValidationResult} */
|
|
166
|
+
hold(frame) {
|
|
167
|
+
if (!isOwner(frame.owner)) return badRequest()
|
|
168
|
+
if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
|
|
169
|
+
if (!isPositiveNumber(frame.minutes)) return badRequest()
|
|
170
|
+
/** @type {HoldFrame} */
|
|
171
|
+
const value = { op: 'hold', owner: { ...frame.owner }, paths: [...frame.paths], minutes: frame.minutes }
|
|
172
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
173
|
+
return { ok: true, op: 'hold', value }
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/** @returns {ValidationResult} */
|
|
177
|
+
status(frame) {
|
|
178
|
+
/** @type {StatusFrame} */
|
|
179
|
+
const value = { op: 'status' }
|
|
180
|
+
if (frame.scope !== undefined) {
|
|
181
|
+
if (!isText(frame.scope)) return badRequest()
|
|
182
|
+
value.scope = frame.scope
|
|
183
|
+
}
|
|
184
|
+
if (frame.session !== undefined) {
|
|
185
|
+
if (!isText(frame.session)) return badRequest()
|
|
186
|
+
value.session = frame.session
|
|
187
|
+
}
|
|
188
|
+
if (frame.path !== undefined) {
|
|
189
|
+
if (!isAbsolutePath(frame.path)) return { ok: false, code: 'invalid-path' }
|
|
190
|
+
value.path = frame.path
|
|
191
|
+
}
|
|
192
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
193
|
+
return { ok: true, op: 'status', value }
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** @returns {ValidationResult} */
|
|
197
|
+
request(frame) {
|
|
198
|
+
if (!isOwner(frame.from)) return badRequest()
|
|
199
|
+
if (!isAbsolutePath(frame.path)) return { ok: false, code: 'invalid-path' }
|
|
200
|
+
/** @type {RequestFrame} */
|
|
201
|
+
const value = { op: 'request', from: { ...frame.from }, path: frame.path }
|
|
202
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
203
|
+
return { ok: true, op: 'request', value }
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/** @returns {ValidationResult} */
|
|
207
|
+
reply(frame) {
|
|
208
|
+
if (!isOwner(frame.owner)) return badRequest()
|
|
209
|
+
if (!isText(frame.requestId)) return badRequest()
|
|
210
|
+
if (!DECISIONS.includes(frame.decision)) return badRequest()
|
|
211
|
+
/** @type {ReplyFrame} */
|
|
212
|
+
const value = { op: 'reply', owner: { ...frame.owner }, requestId: frame.requestId, decision: frame.decision }
|
|
213
|
+
if (frame.reqId !== undefined) value.reqId = frame.reqId
|
|
214
|
+
return { ok: true, op: 'reply', value }
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate one decoded frame against the per-op grammar.
|
|
220
|
+
*
|
|
221
|
+
* Returns `{ok:true, op, value}` with the normalized frame (copies of owner and
|
|
222
|
+
* paths, `reqId` carried through when present) or `{ok:false, code}` where code
|
|
223
|
+
* is `bad-request` for shape/identity/value problems and `invalid-path` for a
|
|
224
|
+
* non-absolute path, a NUL byte or an empty path.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} frame decoded JSON value
|
|
227
|
+
* @returns {ValidationResult}
|
|
228
|
+
*/
|
|
229
|
+
export function validateFrame(frame) {
|
|
230
|
+
if (!isRecord(frame)) return { ok: false, code: 'bad-request' }
|
|
231
|
+
const op = frame.op
|
|
232
|
+
if (typeof op !== 'string' || !OP_SET.has(op)) return { ok: false, code: 'bad-request' }
|
|
233
|
+
if (!onlyFields(frame, FIELDS[op])) return { ok: false, code: 'bad-request' }
|
|
234
|
+
if (frame.reqId !== undefined && !isText(frame.reqId)) return { ok: false, code: 'bad-request' }
|
|
235
|
+
return BUILDERS[op](frame)
|
|
236
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Socket transport for the lease broker daemon: framing, handshake, process
|
|
2
|
+
// lifetime. No business rules live here — every verdict comes from the kernel
|
|
3
|
+
// in ./state.js, and this file only decodes frames, routes them, and writes
|
|
4
|
+
// replies and events back.
|
|
5
|
+
//
|
|
6
|
+
// Wire shape: newline-delimited JSON, one frame per line, in both directions.
|
|
7
|
+
// Requests carry `op`; replies carry `ok` plus either the result fields or
|
|
8
|
+
// `error: {code, ...details}` and always echo the client's `reqId` when it sent
|
|
9
|
+
// one. Unsolicited events carry `event` ('request' | 'settled').
|
|
10
|
+
//
|
|
11
|
+
// Process lifetime (design D1/D8): the daemon is a cache, so it is allowed to
|
|
12
|
+
// die. It exits after `idleMs` with no connected client, and stays alive as
|
|
13
|
+
// long as any client is connected. A new connection cancels the countdown.
|
|
14
|
+
|
|
15
|
+
import { createConnection, createServer } from 'node:net'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { unlinkSync } from 'node:fs'
|
|
18
|
+
import { homedir as osHomedir, tmpdir as osTmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import process from 'node:process'
|
|
21
|
+
import { PROTOCOL, validateFrame } from './protocol.js'
|
|
22
|
+
|
|
23
|
+
/** Idle window before a client-less daemon exits. */
|
|
24
|
+
export const DEFAULT_IDLE_MS = 60_000
|
|
25
|
+
/** Period of the memory-reclaiming sweep. */
|
|
26
|
+
export const DEFAULT_SWEEP_MS = 30_000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default endpoint address for this protocol major.
|
|
30
|
+
*
|
|
31
|
+
* POSIX: `<tmpdir>/weir-editlockd-<uid>-v<protocol>.sock` — per user, so two
|
|
32
|
+
* users on one machine never share an arbiter.
|
|
33
|
+
* win32: `\\.\pipe\weir-editlockd-<sha256(homedir)[0..12]>-v<protocol>` — the
|
|
34
|
+
* pipe namespace is machine-global, so the home directory is folded into the
|
|
35
|
+
* name instead of the uid.
|
|
36
|
+
*
|
|
37
|
+
* The protocol major is part of the address: two majors never arbitrate against
|
|
38
|
+
* each other, and the older one simply idles out.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {string} [options.platform] default `process.platform`
|
|
42
|
+
* @param {number} [options.protocol] default `PROTOCOL`
|
|
43
|
+
* @param {string} [options.tmpdir] default `os.tmpdir()`
|
|
44
|
+
* @param {string} [options.homedir] default `os.homedir()`
|
|
45
|
+
* @param {number | string} [options.uid] default `process.getuid()`
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function defaultSocketPath({ platform = process.platform, protocol = PROTOCOL, tmpdir, homedir, uid } = {}) {
|
|
49
|
+
if (platform === 'win32') {
|
|
50
|
+
const home = homedir ?? osHomedir()
|
|
51
|
+
const digest = createHash('sha256').update(home).digest('hex').slice(0, 12)
|
|
52
|
+
return `\\\\.\\pipe\\weir-editlockd-${digest}-v${protocol}`
|
|
53
|
+
}
|
|
54
|
+
const dir = tmpdir ?? osTmpdir()
|
|
55
|
+
const user = uid ?? (typeof process.getuid === 'function' ? process.getuid() : 'unknown')
|
|
56
|
+
return join(dir, `weir-editlockd-${user}-v${protocol}.sock`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} LeaseBroker
|
|
61
|
+
* @property {(connectionId: string) => void} connect
|
|
62
|
+
* @property {(connectionId: string) => void} disconnect
|
|
63
|
+
* @property {(input: any) => any} acquire
|
|
64
|
+
* @property {(input: any) => any} release
|
|
65
|
+
* @property {(input: any) => any} hold
|
|
66
|
+
* @property {(filter?: any) => any} status
|
|
67
|
+
* @property {(input: any) => any} request
|
|
68
|
+
* @property {(input: any) => any} reply
|
|
69
|
+
* @property {() => any} sweep
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** Transport-level dispatch: request op → kernel call. Deliberately thin and
|
|
73
|
+
* untyped — the kernel is the only place that decides anything. */
|
|
74
|
+
const HANDLERS = {
|
|
75
|
+
acquire: (broker, connectionId, value) =>
|
|
76
|
+
broker.acquire({ connectionId, owner: value.owner, scope: value.scope, paths: value.paths, ttlMs: value.ttlMs }),
|
|
77
|
+
release: (broker, connectionId, value) => broker.release({ connectionId, owner: value.owner, paths: value.paths }),
|
|
78
|
+
hold: (broker, connectionId, value) =>
|
|
79
|
+
broker.hold({ connectionId, owner: value.owner, paths: value.paths, minutes: value.minutes }),
|
|
80
|
+
status: (broker, connectionId, value) => broker.status({ scope: value.scope, session: value.session, path: value.path }),
|
|
81
|
+
request: (broker, connectionId, value) => broker.request({ connectionId, from: value.from, path: value.path }),
|
|
82
|
+
reply: (broker, connectionId, value) =>
|
|
83
|
+
broker.reply({ connectionId, owner: value.owner, requestId: value.requestId, decision: value.decision }),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map a kernel verdict onto the wire: `{ok:true, ...fields}` or
|
|
88
|
+
* `{ok:false, error:{code, ...details}}`, plus the echoed `reqId`.
|
|
89
|
+
* @param {any} result
|
|
90
|
+
* @param {string | undefined} reqId
|
|
91
|
+
*/
|
|
92
|
+
function toWire(result, reqId) {
|
|
93
|
+
/** @type {Record<string, any>} */
|
|
94
|
+
const out = {}
|
|
95
|
+
if (reqId) out.reqId = reqId
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
out.ok = true
|
|
98
|
+
for (const [key, value] of Object.entries(result)) if (key !== 'ok') out[key] = value
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
/** @type {Record<string, any>} */
|
|
102
|
+
const error = { code: result.code }
|
|
103
|
+
for (const [key, value] of Object.entries(result)) if (key !== 'ok' && key !== 'code') error[key] = value
|
|
104
|
+
out.ok = false
|
|
105
|
+
out.error = error
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {any} error */
|
|
110
|
+
function errorCode(error) {
|
|
111
|
+
return error && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start listening.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} options
|
|
118
|
+
* @param {string} options.socketPath endpoint address (`defaultSocketPath()` by default at the entry)
|
|
119
|
+
* @param {LeaseBroker} options.broker arbitration kernel from `createBroker()`
|
|
120
|
+
* @param {number} [options.protocol] this instance's protocol major
|
|
121
|
+
* @param {string} [options.daemonVersion] reported by the handshake
|
|
122
|
+
* @param {number} [options.idleMs] exit after this long with no connected client
|
|
123
|
+
* @param {number} [options.sweepMs] period of `broker.sweep()`
|
|
124
|
+
* @returns {Promise<{ address: string, close: () => Promise<void>, send: (connectionId: string, event: any) => boolean }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function startServer({
|
|
127
|
+
socketPath,
|
|
128
|
+
broker,
|
|
129
|
+
protocol = PROTOCOL,
|
|
130
|
+
daemonVersion = '0.0.0',
|
|
131
|
+
idleMs = DEFAULT_IDLE_MS,
|
|
132
|
+
sweepMs = DEFAULT_SWEEP_MS,
|
|
133
|
+
}) {
|
|
134
|
+
const startedAt = Date.now()
|
|
135
|
+
/** @type {Map<string, import('node:net').Socket>} */
|
|
136
|
+
const clients = new Map()
|
|
137
|
+
let nextConnectionId = 0
|
|
138
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
139
|
+
let idleTimer = null
|
|
140
|
+
let closed = false
|
|
141
|
+
/** @type {((error: any) => void) | null} */
|
|
142
|
+
let bindFailure = null
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {import('node:net').Socket} socket
|
|
146
|
+
* @param {any} payload
|
|
147
|
+
*/
|
|
148
|
+
function writeFrame(socket, payload) {
|
|
149
|
+
if (!socket || socket.destroyed) return false
|
|
150
|
+
try {
|
|
151
|
+
socket.write(`${JSON.stringify(payload)}\n`)
|
|
152
|
+
return true
|
|
153
|
+
} catch {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Push one event to one connection; unknown connections are a silent drop. */
|
|
159
|
+
function send(connectionId, event) {
|
|
160
|
+
const socket = clients.get(connectionId)
|
|
161
|
+
if (!socket) return false
|
|
162
|
+
return writeFrame(socket, event)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cancelIdle() {
|
|
166
|
+
if (idleTimer === null) return
|
|
167
|
+
clearTimeout(idleTimer)
|
|
168
|
+
idleTimer = null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scheduleIdle() {
|
|
172
|
+
if (closed || idleTimer !== null || clients.size > 0) return
|
|
173
|
+
idleTimer = setTimeout(() => {
|
|
174
|
+
idleTimer = null
|
|
175
|
+
void close()
|
|
176
|
+
}, idleMs > 0 ? idleMs : 0)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function close() {
|
|
180
|
+
if (closed) return
|
|
181
|
+
closed = true
|
|
182
|
+
cancelIdle()
|
|
183
|
+
if (sweepTimer !== null) {
|
|
184
|
+
clearInterval(sweepTimer)
|
|
185
|
+
sweepTimer = null
|
|
186
|
+
}
|
|
187
|
+
for (const socket of clients.values()) {
|
|
188
|
+
try {
|
|
189
|
+
socket.destroy()
|
|
190
|
+
} catch {
|
|
191
|
+
// Already gone; nothing to release here — the kernel drop happens in
|
|
192
|
+
// the connection's own cleanup.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
clients.clear()
|
|
196
|
+
await new Promise((resolve) => {
|
|
197
|
+
try {
|
|
198
|
+
server.close(() => resolve(undefined))
|
|
199
|
+
} catch {
|
|
200
|
+
resolve(undefined)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Handle one decoded line for one connection.
|
|
207
|
+
* @param {import('node:net').Socket} socket
|
|
208
|
+
* @param {any} state
|
|
209
|
+
* @param {string} line
|
|
210
|
+
*/
|
|
211
|
+
function handleLine(socket, state, line) {
|
|
212
|
+
let frame
|
|
213
|
+
try {
|
|
214
|
+
frame = JSON.parse(line)
|
|
215
|
+
} catch {
|
|
216
|
+
writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, undefined))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const rawReqId = frame && typeof frame.reqId === 'string' && frame.reqId.length > 0 ? frame.reqId : undefined
|
|
220
|
+
const parsed = validateFrame(frame)
|
|
221
|
+
if (!state.greeted) {
|
|
222
|
+
// The first frame must be a hello. Anything else — including a frame that
|
|
223
|
+
// would have failed validation for a different reason — is bad-request:
|
|
224
|
+
// no other op has a defined meaning before the handshake.
|
|
225
|
+
if (!parsed.ok || parsed.op !== 'hello') {
|
|
226
|
+
writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, rawReqId))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const hello = /** @type {import('./protocol.js').HelloFrame} */ (parsed.value)
|
|
230
|
+
if (hello.protocol !== protocol) {
|
|
231
|
+
// Different major: answer, then drop the connection. No compatibility
|
|
232
|
+
// negotiation exists by design (D8) — the client starts/uses its own
|
|
233
|
+
// endpoint for its own major.
|
|
234
|
+
writeFrame(socket, toWire({ ok: false, code: 'protocol-mismatch', supported: [protocol] }, hello.reqId))
|
|
235
|
+
state.closing = true
|
|
236
|
+
socket.end()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
state.greeted = true
|
|
240
|
+
writeFrame(socket, toWire({ ok: true, protocol, daemonVersion, startedAt }, hello.reqId))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (!parsed.ok) {
|
|
244
|
+
writeFrame(socket, toWire({ ok: false, code: parsed.code }, rawReqId))
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (parsed.op === 'hello') {
|
|
248
|
+
// Already greeted: a second handshake is a malformed frame.
|
|
249
|
+
writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, rawReqId))
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
const handler = HANDLERS[/** @type {keyof typeof HANDLERS} */ (parsed.op)]
|
|
253
|
+
let result
|
|
254
|
+
try {
|
|
255
|
+
result = handler(broker, state.connectionId, parsed.value)
|
|
256
|
+
} catch {
|
|
257
|
+
// Unexpected kernel fault: report `internal` rather than killing the
|
|
258
|
+
// connection (the closed vocabulary exists for exactly this case).
|
|
259
|
+
result = { ok: false, code: 'internal' }
|
|
260
|
+
}
|
|
261
|
+
writeFrame(socket, toWire(result, /** @type {any} */ (parsed.value).reqId))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wire one accepted socket: framing, handshake state, cleanup.
|
|
266
|
+
* @param {import('node:net').Socket} socket
|
|
267
|
+
*/
|
|
268
|
+
function handleConnection(socket) {
|
|
269
|
+
const connectionId = `c${++nextConnectionId}`
|
|
270
|
+
clients.set(connectionId, socket)
|
|
271
|
+
cancelIdle()
|
|
272
|
+
broker.connect(connectionId)
|
|
273
|
+
socket.setEncoding('utf8')
|
|
274
|
+
const state = { connectionId, greeted: false, closing: false }
|
|
275
|
+
let buffered = ''
|
|
276
|
+
let cleaned = false
|
|
277
|
+
|
|
278
|
+
function cleanup() {
|
|
279
|
+
if (cleaned) return
|
|
280
|
+
cleaned = true
|
|
281
|
+
clients.delete(connectionId)
|
|
282
|
+
try {
|
|
283
|
+
broker.disconnect(connectionId)
|
|
284
|
+
} catch {
|
|
285
|
+
// The kernel is pure; a fault here must not keep the daemon alive.
|
|
286
|
+
}
|
|
287
|
+
scheduleIdle()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
socket.on('data', (chunk) => {
|
|
291
|
+
if (state.closing) return
|
|
292
|
+
buffered += chunk
|
|
293
|
+
let index = buffered.indexOf('\n')
|
|
294
|
+
while (index >= 0) {
|
|
295
|
+
const line = buffered.slice(0, index)
|
|
296
|
+
buffered = buffered.slice(index + 1)
|
|
297
|
+
if (line.trim() !== '') handleLine(socket, state, line)
|
|
298
|
+
if (state.closing) {
|
|
299
|
+
buffered = ''
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
index = buffered.indexOf('\n')
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
socket.on('error', cleanup)
|
|
306
|
+
socket.on('close', cleanup)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const server = createServer({ allowHalfOpen: false }, (socket) => handleConnection(socket))
|
|
310
|
+
// One permanent error listener: while binding it rejects the listen promise;
|
|
311
|
+
// afterwards it keeps a per-connection fault from crashing the daemon.
|
|
312
|
+
server.on('error', (error) => {
|
|
313
|
+
if (bindFailure === null) return
|
|
314
|
+
const fail = bindFailure
|
|
315
|
+
bindFailure = null
|
|
316
|
+
fail(error)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
320
|
+
let sweepTimer = sweepMs > 0
|
|
321
|
+
? setInterval(() => {
|
|
322
|
+
try {
|
|
323
|
+
broker.sweep()
|
|
324
|
+
} catch {
|
|
325
|
+
// Memory reclamation is not a correctness source; never die for it.
|
|
326
|
+
}
|
|
327
|
+
}, sweepMs)
|
|
328
|
+
: null
|
|
329
|
+
|
|
330
|
+
/** @returns {Promise<void>} */
|
|
331
|
+
function bind() {
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
bindFailure = reject
|
|
334
|
+
server.listen({ path: socketPath }, () => {
|
|
335
|
+
bindFailure = null
|
|
336
|
+
resolve(undefined)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* POSIX leaves the socket file behind when the daemon dies. `EADDRINUSE` on a
|
|
343
|
+
* path nobody listens on means exactly that, so probe it: a live peer is a
|
|
344
|
+
* hard error (never silently take over an arbiter), a refused connect means
|
|
345
|
+
* the file is stale and may be unlinked before a single retry.
|
|
346
|
+
* @returns {Promise<void>}
|
|
347
|
+
*/
|
|
348
|
+
function recoverStaleSocket() {
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
const probe = createConnection({ path: socketPath })
|
|
351
|
+
let settled = false
|
|
352
|
+
/** @param {() => void} finish */
|
|
353
|
+
const once = (finish) => {
|
|
354
|
+
if (settled) return
|
|
355
|
+
settled = true
|
|
356
|
+
try {
|
|
357
|
+
probe.destroy()
|
|
358
|
+
} catch {
|
|
359
|
+
// Probe already gone.
|
|
360
|
+
}
|
|
361
|
+
finish()
|
|
362
|
+
}
|
|
363
|
+
probe.once('connect', () =>
|
|
364
|
+
once(() => reject(new Error(`another lease broker is already listening on ${socketPath}`))),
|
|
365
|
+
)
|
|
366
|
+
probe.once('error', (error) => {
|
|
367
|
+
const code = errorCode(error)
|
|
368
|
+
if (code !== 'ECONNREFUSED' && code !== 'ENOENT') {
|
|
369
|
+
once(() => reject(error))
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
once(() => {
|
|
373
|
+
try {
|
|
374
|
+
unlinkSync(socketPath)
|
|
375
|
+
resolve(undefined)
|
|
376
|
+
} catch (unlinkError) {
|
|
377
|
+
// `ENOENT` here means a concurrent instance cleaned up first.
|
|
378
|
+
if (errorCode(unlinkError) === 'ENOENT') resolve(undefined)
|
|
379
|
+
else reject(unlinkError)
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await bind()
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (errorCode(error) !== 'EADDRINUSE') throw error
|
|
390
|
+
await recoverStaleSocket()
|
|
391
|
+
await bind()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A broker nobody ever connects to is just as useless as one that lost its
|
|
395
|
+
// last client: both idle out on the same clock.
|
|
396
|
+
scheduleIdle()
|
|
397
|
+
|
|
398
|
+
return { address: socketPath, close, send }
|
|
399
|
+
}
|