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.
Files changed (81) 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/lease-broker/entry.js +98 -0
  23. package/src/lease-broker/node-contracts.d.ts +63 -0
  24. package/src/lease-broker/protocol.js +236 -0
  25. package/src/lease-broker/server.js +399 -0
  26. package/src/lease-broker/state.js +425 -0
  27. package/src/lsp/tools.js +137 -27
  28. package/src/settings/index.js +2 -44
  29. package/src/settings/sections.js +2 -24
  30. package/src/shared/audit.js +6 -1
  31. package/src/todo-driver/index.js +1 -6
  32. package/src/worktree/cards.js +6 -21
  33. package/src/worktree/lane-core.js +1 -19
  34. package/src/worktree/lane-workflows.js +11 -32
  35. package/lib/client.edit-lock-maintenance.js +0 -592
  36. package/lib/client.edit-lock-panel.js +0 -405
  37. package/src/edit-lock/adapter.js +0 -57
  38. package/src/edit-lock/admin-ledger.js +0 -149
  39. package/src/edit-lock/admin-recovery.js +0 -151
  40. package/src/edit-lock/admission.js +0 -82
  41. package/src/edit-lock/binding-attestation.js +0 -29
  42. package/src/edit-lock/binding-guidance.js +0 -72
  43. package/src/edit-lock/binding-machine.js +0 -312
  44. package/src/edit-lock/call-context.js +0 -65
  45. package/src/edit-lock/derived-facts.js +0 -67
  46. package/src/edit-lock/domain.js +0 -165
  47. package/src/edit-lock/domains.js +0 -170
  48. package/src/edit-lock/host.js +0 -149
  49. package/src/edit-lock/incarnations.js +0 -150
  50. package/src/edit-lock/index.js +0 -1303
  51. package/src/edit-lock/inspect.js +0 -219
  52. package/src/edit-lock/lifecycle.js +0 -579
  53. package/src/edit-lock/limits.js +0 -61
  54. package/src/edit-lock/maintenance.js +0 -310
  55. package/src/edit-lock/manager.js +0 -875
  56. package/src/edit-lock/negotiation.js +0 -132
  57. package/src/edit-lock/operation-history.js +0 -214
  58. package/src/edit-lock/peer-client.js +0 -86
  59. package/src/edit-lock/peer-transport.js +0 -76
  60. package/src/edit-lock/peer.js +0 -87
  61. package/src/edit-lock/publisher.js +0 -191
  62. package/src/edit-lock/read-authority.js +0 -101
  63. package/src/edit-lock/recovery.js +0 -139
  64. package/src/edit-lock/remote.js +0 -362
  65. package/src/edit-lock/request-binding.js +0 -42
  66. package/src/edit-lock/request-data.js +0 -40
  67. package/src/edit-lock/reservation.js +0 -301
  68. package/src/edit-lock/reserved-runtime.js +0 -45
  69. package/src/edit-lock/resource-identity.js +0 -130
  70. package/src/edit-lock/runtime.js +0 -145
  71. package/src/edit-lock/settle.js +0 -144
  72. package/src/edit-lock/snapshot.js +0 -175
  73. package/src/edit-lock/stale-sweep.js +0 -120
  74. package/src/edit-lock/state.js +0 -462
  75. package/src/edit-lock/store.js +0 -327
  76. package/src/edit-lock/tool-scope.js +0 -29
  77. package/src/edit-lock/view.js +0 -170
  78. package/src/edit-lock/write-scopes.js +0 -48
  79. package/src/edit-lock/write-tool.js +0 -45
  80. package/src/shared/delegate-edit-lifecycle.js +0 -36
  81. 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
+ }