rollbridge 0.1.28 → 0.1.30

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 (37) hide show
  1. package/AGENTS.md +14 -0
  2. package/README.md +69 -14
  3. package/TODO.md +5 -2
  4. package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
  5. package/changelog.d/20260828-durable-owner-recovery.md +7 -0
  6. package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
  7. package/docs/cli.md +43 -21
  8. package/docs/config.md +71 -18
  9. package/docs/logging.md +8 -3
  10. package/docs/tensorbuzz-runbook.md +7 -6
  11. package/docs/troubleshooting.md +28 -12
  12. package/docs/velocious.md +11 -4
  13. package/docs/workers.md +8 -2
  14. package/examples/tensorbuzz.com.js +12 -4
  15. package/package.json +1 -1
  16. package/src/cli.js +209 -36
  17. package/src/config.js +8 -2
  18. package/src/control-client.js +118 -1
  19. package/src/daemon.js +939 -53
  20. package/src/guardian-client.js +434 -0
  21. package/src/managed-process.js +45 -15
  22. package/src/process-guardian.js +601 -0
  23. package/src/release-group.js +190 -15
  24. package/src/state-store.js +1 -1
  25. package/test/config-validation.test.js +22 -0
  26. package/test/fixtures/pre-split3-daemon-runner.js +30 -0
  27. package/test/fixtures/pre-split3-daemon.js +1336 -0
  28. package/test/fixtures/pre-split3-guardian-client.js +293 -0
  29. package/test/fixtures/pre-split3-process-guardian.js +292 -0
  30. package/test/fixtures/service-app.js +32 -2
  31. package/test/guardian-client.test.js +304 -0
  32. package/test/owner-recovery.test.js +950 -0
  33. package/test/owner-replacement.test.js +772 -0
  34. package/test/release-runtime-retention.test.js +1 -1
  35. package/test/rollbridge.test.js +178 -5
  36. package/test/shutdown-completion.test.js +1 -1
  37. package/test/state-store.test.js +12 -0
@@ -0,0 +1,601 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+ import net from "node:net"
5
+ import crypto from "node:crypto"
6
+ import {isDeepStrictEqual} from "node:util"
7
+ import GuardianClient from "./guardian-client.js"
8
+ import ManagedProcess from "./managed-process.js"
9
+
10
+ /**
11
+ * @typedef {object} GuardianRequest
12
+ * @property {string} command - Operation name.
13
+ * @property {ConstructorParameters<typeof ManagedProcess>[0]} [definition] - Managed process definition.
14
+ * @property {number} [graceMs] - Owner reconnection grace.
15
+ * @property {number} id - Request id.
16
+ * @property {string} [key] - Stable process key.
17
+ * @property {{timeoutMs?: number}} [options] - Stop options.
18
+ * @property {string} [previousProvenance] - Expected current provenance.
19
+ * @property {string} [provenance] - New or registered provenance.
20
+ * @property {import("./json.js").JsonValue} [ownerState] - Private owner transfer state.
21
+ * @property {string} [replacementId] - Prepared replacement transaction id.
22
+ * @property {string} [mutationId] - Owner mutation lease id.
23
+ * @property {string} [operation] - Owner mutation diagnostic name.
24
+ * @property {import("./json.js").JsonValue} [authority] - Expected current authority.
25
+ * @property {import("./json.js").JsonValue} [nextAuthority] - Requested replacement authority.
26
+ * @property {import("./managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
27
+ * @property {string} token - Authentication token.
28
+ */
29
+
30
+ const [socketPath] = process.argv.slice(2)
31
+
32
+ if (!socketPath || !process.send) throw new Error("process-guardian requires socket path and a private bootstrap channel")
33
+
34
+ const bootstrap = await new Promise((resolve, reject) => {
35
+ process.once("disconnect", () => reject(new Error("Guardian bootstrap channel closed before authentication capability arrived")))
36
+ process.once("message", (message) => {
37
+ if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
38
+ reject(new Error("Guardian bootstrap authentication capability is invalid"))
39
+ return
40
+ }
41
+ resolve(message)
42
+ })
43
+ })
44
+ const token = bootstrap.token
45
+ const legacyGuardian = bootstrap.legacyGuardian ? new GuardianClient(bootstrap.legacyGuardian) : undefined
46
+
47
+ if (legacyGuardian) await legacyGuardian.connect()
48
+
49
+ /** @type {Map<string, {desired: boolean, process: ManagedProcess, provenance: string}>} */
50
+ const processes = new Map()
51
+ /** @type {Set<net.Socket>} */
52
+ const clients = new Set()
53
+ /** @type {net.Socket | undefined} */
54
+ let ownerClient
55
+ /** @type {net.Socket | undefined} */
56
+ let replacementClient
57
+ /** @type {string | undefined} */
58
+ let replacementId
59
+ /** @type {string | undefined} */
60
+ let committedReplacementId
61
+ /** @type {import("./json.js").JsonValue | undefined} */
62
+ let replacementAuthority
63
+ /** @type {import("./json.js").JsonValue | undefined} */
64
+ let replacementOwnerState
65
+ /** @type {import("./json.js").JsonValue | undefined} */
66
+ let ownerState = bootstrap.ownerState
67
+ let ownerRevision = ownerState === undefined ? 0 : 1
68
+ /** @type {number | undefined} */
69
+ let replacementRevision
70
+ const legacyKeys = legacyGuardian ? legacyOwnerKeys(ownerState) : new Set()
71
+ /** @type {net.Socket | undefined} */
72
+ let ownerMutationClient
73
+ /** @type {string | undefined} */
74
+ let ownerMutationId
75
+ /** @type {net.Socket | undefined} */
76
+ let retiringClient
77
+ /** @type {string | undefined} */
78
+ let retiringReplacementId
79
+ let shuttingDown = false
80
+ /** @type {net.Socket | undefined} */
81
+ let shutdownClient
82
+ let shutdownFinalizing = false
83
+ /** @type {Promise<void> | undefined} */
84
+ let serverClosed
85
+ /** @type {{reject: (error: Error) => void, resolve: (value: {claimed: boolean}) => void, socket: net.Socket, timer: ReturnType<typeof setTimeout>}[]} */
86
+ const claimWaiters = []
87
+
88
+ const server = net.createServer((socket) => {
89
+ clients.add(socket)
90
+ socket.setEncoding("utf8")
91
+ // An abruptly killed daemon can reset its private guardian connection. Keep the
92
+ // durable guardian alive; the close handler below releases only that owner claim.
93
+ socket.on("error", () => socket.destroy())
94
+ let buffer = ""
95
+
96
+ socket.once("close", () => {
97
+ clients.delete(socket)
98
+ const waiterIndex = claimWaiters.findIndex((waiter) => waiter.socket === socket)
99
+
100
+ if (waiterIndex >= 0) {
101
+ const [waiter] = claimWaiters.splice(waiterIndex, 1)
102
+ clearTimeout(waiter.timer)
103
+ waiter.reject(new Error("Owner claimant disconnected"))
104
+ }
105
+ if (ownerClient === socket) {
106
+ ownerClient = undefined
107
+ if (replacementClient && replacementOwnerState) commitReplacement()
108
+ else {
109
+ if (replacementClient) abortReplacement("Committed owner disconnected before replacement candidate was ready")
110
+ if (!shuttingDown) grantNextOwner()
111
+ }
112
+ }
113
+ if (ownerMutationClient === socket) {
114
+ ownerMutationClient = undefined
115
+ ownerMutationId = undefined
116
+ }
117
+ if (retiringClient === socket) finalizeReplacementRetirement()
118
+ if (replacementClient === socket) {
119
+ replacementClient = undefined
120
+ replacementId = undefined
121
+ replacementAuthority = undefined
122
+ replacementOwnerState = undefined
123
+ if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
124
+ }
125
+ if (shutdownClient === socket) void finishShutdown()
126
+ })
127
+ socket.on("data", (chunk) => {
128
+ buffer += chunk
129
+ let newline = buffer.indexOf("\n")
130
+
131
+ while (newline >= 0) {
132
+ const line = buffer.slice(0, newline)
133
+
134
+ buffer = buffer.slice(newline + 1)
135
+ void handleLine(socket, line)
136
+ newline = buffer.indexOf("\n")
137
+ }
138
+ })
139
+ })
140
+
141
+ server.on("error", (error) => {
142
+ if (process.send) process.send({error: error.message})
143
+ else throw error
144
+ })
145
+
146
+ server.listen(socketPath, async () => {
147
+ await fs.chmod(socketPath, 0o600)
148
+ if (process.send) {
149
+ process.send({ready: true})
150
+ process.disconnect?.()
151
+ }
152
+ })
153
+
154
+ /**
155
+ * @param {net.Socket} socket - Client.
156
+ * @param {string} line - JSON request.
157
+ * @returns {Promise<void>} Request completion.
158
+ */
159
+ async function handleLine(socket, line) {
160
+ /** @type {GuardianRequest | undefined} */
161
+ let request
162
+
163
+ try {
164
+ request = /** @type {GuardianRequest} */ (JSON.parse(line))
165
+ if (request.token !== token) throw new Error("Guardian authentication failed")
166
+ const result = await execute(request, socket)
167
+
168
+ socket.write(`${JSON.stringify({id: request.id, result})}\n`)
169
+ } catch (error) {
170
+ if (!socket.destroyed) socket.write(`${JSON.stringify({error: error instanceof Error ? error.message : String(error), id: request?.id})}\n`)
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @param {GuardianRequest} request - Authenticated request.
176
+ * @param {net.Socket} socket - Requesting client.
177
+ * @returns {Promise<import("./json.js").JsonValue>} Command result.
178
+ */
179
+ async function execute(request, socket) {
180
+ if (shuttingDown) throw new Error("Process guardian is shutting down")
181
+
182
+ if (request.command === "claim-owner") {
183
+ if (!ownerClient) {
184
+ if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
185
+ throw new Error("Owner recovery authority does not match the guardian's committed authority")
186
+ }
187
+ ownerClient = socket
188
+ return {claimed: true}
189
+ }
190
+ if (ownerClient === socket) return {claimed: true}
191
+
192
+ return await new Promise((resolve, reject) => {
193
+ const timer = setTimeout(() => {
194
+ const index = claimWaiters.findIndex((waiter) => waiter.socket === socket)
195
+ if (index >= 0) claimWaiters.splice(index, 1)
196
+ reject(new Error("Durable owner is already claimed by another matching daemon"))
197
+ }, request.graceMs ?? 30000)
198
+
199
+ claimWaiters.push({reject, resolve, socket, timer})
200
+ })
201
+ }
202
+
203
+ if (request.command === "retire-owner") {
204
+ requireOwner(socket, request.command)
205
+ if (replacementClient) throw new Error("Committed owner cannot retire while an owner replacement is prepared")
206
+ for (const entry of processes.values()) entry.desired = false
207
+ void Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
208
+ ownerClient = undefined
209
+ ownerMutationClient = undefined
210
+ ownerMutationId = undefined
211
+ ownerRevision += 1
212
+ grantNextOwner()
213
+ return {retired: true}
214
+ }
215
+
216
+ if (request.command === "abandon-legacy-upgrade") {
217
+ if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
218
+ if (ownerClient || committedReplacementId) throw new Error("Committed guardian authority cannot abandon its legacy backend")
219
+ if (replacementClient && replacementClient !== socket) throw new Error("Only the prepared replacement can abandon the legacy upgrade")
220
+ if (replacementClient) abortReplacement("Legacy guardian upgrade candidate abandoned before the disruptive boundary")
221
+ legacyGuardian.disconnect()
222
+ shuttingDown = true
223
+ beginSuccessfulShutdown(socket)
224
+ return {abandoned: true}
225
+ }
226
+
227
+ if (request.command === "publish-owner-state") {
228
+ requireOwner(socket, request.command)
229
+ if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
230
+ ownerState = request.ownerState
231
+ ownerRevision += 1
232
+ committedReplacementId = undefined
233
+ return {published: true}
234
+ }
235
+
236
+ if (request.command === "owner-state") {
237
+ requireOwner(socket, request.command)
238
+ if (!ownerState) throw new Error("Committed owner has not published transferable state")
239
+ return {ownerState}
240
+ }
241
+
242
+ if (request.command === "replacement-status") {
243
+ return {committedReplacementId: committedReplacementId ?? null, ownerClaimed: Boolean(ownerClient), retirementPending: Boolean(retiringClient)}
244
+ }
245
+
246
+ if (request.command === "begin-owner-mutation") {
247
+ requireOwner(socket, request.command)
248
+ if (replacementClient) throw new Error(`Owner mutation ${request.operation || "operation"} is fenced while an owner replacement is prepared`)
249
+ if (ownerMutationClient) throw new Error("Another owner mutation is already in progress")
250
+ ownerMutationClient = socket
251
+ ownerMutationId = crypto.randomUUID()
252
+ return {mutationId: ownerMutationId}
253
+ }
254
+
255
+ if (request.command === "end-owner-mutation") {
256
+ requireOwner(socket, request.command)
257
+ if (ownerMutationClient !== socket || request.mutationId !== ownerMutationId) throw new Error("Owner mutation lease does not match the active mutation")
258
+ ownerMutationClient = undefined
259
+ ownerMutationId = undefined
260
+ return {ended: true}
261
+ }
262
+
263
+ if (request.command === "prepare-owner-replacement") {
264
+ if (socket === ownerClient) throw new Error("Committed owner cannot prepare itself as a replacement")
265
+ if (replacementClient && replacementClient !== socket) throw new Error("Another owner replacement candidate is already prepared")
266
+ if (ownerMutationClient) throw new Error("Owner replacement cannot prepare while an owner mutation is in progress")
267
+ if (!ownerState) throw new Error("Committed owner has not published transferable state")
268
+ const currentAuthority = ownerAuthority(ownerState)
269
+ const resumesCommittedAuthority = !ownerClient && isDeepStrictEqual(currentAuthority, request.nextAuthority)
270
+
271
+ if (!resumesCommittedAuthority && !isDeepStrictEqual(currentAuthority, request.authority)) {
272
+ throw new Error("Owner replacement authority fence does not match the guardian's committed authority")
273
+ }
274
+ if (request.nextAuthority === undefined) throw new Error("Owner replacement requires the requested authority")
275
+ if (!replacementId) replacementId = crypto.randomUUID()
276
+ replacementClient = socket
277
+ replacementAuthority = request.nextAuthority
278
+ replacementRevision = ownerRevision
279
+ if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-prepared", replacementId})}\n`)
280
+ return {ownerState, replacementId}
281
+ }
282
+
283
+ if (request.command === "stage-owner-replacement") {
284
+ requireReplacement(socket, request)
285
+ if (replacementRevision !== ownerRevision) throw new Error("Guardian owner state changed after prepare; abort the stale candidate before preparing again")
286
+ if (request.ownerState === undefined || !isDeepStrictEqual(ownerAuthority(request.ownerState), replacementAuthority)) {
287
+ throw new Error("Prepared replacement state does not match the requested authority")
288
+ }
289
+ replacementOwnerState = request.ownerState
290
+ if (!ownerClient) commitReplacement()
291
+ return {committed: ownerClient === socket}
292
+ }
293
+
294
+ if (request.command === "abort-owner-replacement") {
295
+ requireReplacement(socket, request)
296
+ abortReplacement("Replacement candidate aborted the prepared transaction")
297
+ return {aborted: true}
298
+ }
299
+
300
+ if (request.command === "commit-owner-replacement") {
301
+ requireOwner(socket, request.command)
302
+ if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
303
+ commitReplacement()
304
+ return {committed: true}
305
+ }
306
+
307
+ if (request.command === "validate-owner-replacement") {
308
+ requireOwner(socket, request.command)
309
+ if (!replacementClient || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
310
+ return {valid: true}
311
+ }
312
+
313
+ if (request.command === "finalize-owner-replacement") {
314
+ if (retiringClient !== socket || request.replacementId !== retiringReplacementId) throw new Error("Owner replacement retirement finalization requires the committed incumbent transaction")
315
+ finalizeReplacementRetirement()
316
+ return {finalized: true}
317
+ }
318
+
319
+ if (request.command === "shutdown") {
320
+ requireOwner(socket, request.command)
321
+ shuttingDown = true
322
+ try {
323
+ const results = await Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
324
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
325
+
326
+ if (errors.length > 0) throw new AggregateError(errors, `Guardian failed to stop ${errors.length} owned process${errors.length === 1 ? "" : "es"}: ${errors.map((error) => errorMessage(error instanceof Error ? error : String(error))).join("; ")}`)
327
+ if (legacyGuardian) await legacyGuardian.shutdown()
328
+ beginSuccessfulShutdown(socket)
329
+ return {stopped: true}
330
+ } catch (error) {
331
+ shutdownClient = undefined
332
+ shuttingDown = false
333
+ throw error
334
+ }
335
+ }
336
+
337
+ if (request.command === "inventory") {
338
+ return [...processes.entries()].map(([key, entry]) => ({key, provenance: entry.provenance, status: entry.process.status()}))
339
+ }
340
+
341
+ if (request.command === "remove") {
342
+ requireOwner(socket, request.command)
343
+ if (!request.key || !request.provenance) throw new Error("Guardian remove requires key and provenance")
344
+ const existing = processes.get(request.key)
345
+
346
+ if (!existing) throw new Error(`Guardian process ${request.key} is not registered`)
347
+ if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
348
+ existing.desired = false
349
+ await existing.process.stop()
350
+ processes.delete(request.key)
351
+ ownerRevision += 1
352
+ return {removed: true}
353
+ }
354
+
355
+ if (request.command === "register") {
356
+ if (!request.key || !request.definition || !request.provenance) throw new Error("Guardian register requires key, definition, and provenance")
357
+ const existing = processes.get(request.key)
358
+
359
+ if (existing) {
360
+ if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
361
+ return existing.process.status()
362
+ }
363
+
364
+ const recoversLegacyProcess = Boolean(legacyGuardian && legacyKeys.has(request.key))
365
+
366
+ if (!recoversLegacyProcess) requireOwner(socket, request.command)
367
+
368
+ const record = /** @type {{desired: boolean, process?: ManagedProcess, provenance: string}} */ ({desired: true, provenance: request.provenance})
369
+ const definition = request.definition
370
+ const managedDefinition = {
371
+ ...definition,
372
+ logger: (/** @type {string} */ message, /** @type {Record<string, import("./json.js").JsonValue>} */ data = {}) => {
373
+ ownerRevision += 1
374
+ broadcast({event: "process", key: request.key, message, data, status: managedProcess.status()})
375
+ },
376
+ shouldRestart: () => record.desired
377
+ }
378
+ const managedProcess = legacyGuardian
379
+ ? legacyGuardian.process(request.key, managedDefinition)
380
+ : new ManagedProcess(managedDefinition)
381
+
382
+ if (recoversLegacyProcess && "recover" in managedProcess && typeof managedProcess.recover === "function") await managedProcess.recover()
383
+
384
+ record.process = managedProcess
385
+ processes.set(request.key, /** @type {{desired: boolean, process: ManagedProcess, provenance: string}} */ (record))
386
+ if (!recoversLegacyProcess) ownerRevision += 1
387
+ return managedProcess.status()
388
+ }
389
+
390
+ if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
391
+ const record = processes.get(request.key)
392
+
393
+ if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
394
+
395
+ if (request.command !== "status") requireOwner(socket, request.command)
396
+
397
+ if (request.command === "start") {
398
+ record.desired = true
399
+ await record.process.start(request.reason)
400
+ } else if (request.command === "quiesce") {
401
+ record.desired = false
402
+ await record.process.quiesceStrict()
403
+ } else if (request.command === "stop") {
404
+ record.desired = false
405
+ await record.process.stop(request.options)
406
+ } else if (request.command === "update") {
407
+ if (!request.definition || !request.provenance) throw new Error("Guardian update requires definition and provenance")
408
+ if (record.provenance !== request.previousProvenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
409
+ record.process.updateDefinition({
410
+ ...request.definition,
411
+ lifecycle: request.definition.lifecycle || {drainTimeoutMs: 0},
412
+ logger: record.process.logger,
413
+ memory: request.definition.memory,
414
+ restart: request.definition.restart || {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0},
415
+ shouldRestart: () => record.desired,
416
+ stopSignal: request.definition.stopSignal || "SIGTERM"
417
+ })
418
+ record.provenance = request.provenance
419
+ } else if (request.command === "status") {
420
+ return record.process.status()
421
+ } else {
422
+ throw new Error(`Unknown guardian command: ${request.command}`)
423
+ }
424
+
425
+ ownerRevision += 1
426
+
427
+ const status = record.process.status()
428
+
429
+ broadcast({event: "status", key: request.key, status})
430
+ return status
431
+ }
432
+
433
+ /**
434
+ * @param {net.Socket} socket - Requesting client.
435
+ * @param {string} command - Command name.
436
+ */
437
+ function requireOwner(socket, command) {
438
+ if (ownerClient !== socket) throw new Error(`Guardian ${command} requires the committed owner`)
439
+ }
440
+
441
+ /** @param {string} reason - Abort diagnostic. */
442
+ function abortReplacement(reason) {
443
+ if (replacementClient && !replacementClient.destroyed) replacementClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
444
+ replacementClient = undefined
445
+ replacementId = undefined
446
+ replacementAuthority = undefined
447
+ replacementOwnerState = undefined
448
+ replacementRevision = undefined
449
+ }
450
+
451
+ /** Atomically promotes the prepared client and its complete transferable state. */
452
+ function commitReplacement() {
453
+ if (!replacementClient || !replacementId || !replacementOwnerState) throw new Error("Owner replacement candidate is not ready")
454
+ const previousOwner = ownerClient
455
+ const committedClient = replacementClient
456
+ const committedId = replacementId
457
+
458
+ ownerClient = committedClient
459
+ ownerState = replacementOwnerState
460
+ ownerRevision += 1
461
+ committedReplacementId = committedId
462
+ replacementClient = undefined
463
+ replacementId = undefined
464
+ replacementAuthority = undefined
465
+ replacementOwnerState = undefined
466
+ replacementRevision = undefined
467
+ if (previousOwner && !previousOwner.destroyed) {
468
+ retiringClient = previousOwner
469
+ retiringReplacementId = committedId
470
+ } else {
471
+ publishReplacementCommitted(committedClient, committedId)
472
+ }
473
+ }
474
+
475
+ /** Completes publication only after the incumbent has retired its listeners. */
476
+ function finalizeReplacementRetirement() {
477
+ const committedClient = ownerClient
478
+ const committedId = retiringReplacementId
479
+ const previousOwner = retiringClient
480
+
481
+ retiringClient = undefined
482
+ retiringReplacementId = undefined
483
+ if (!committedClient || !committedId) return
484
+ publishReplacementCommitted(committedClient, committedId)
485
+ if (previousOwner && !previousOwner.destroyed) previousOwner.write(`${JSON.stringify({event: "replacement-retired", replacementId: committedId})}\n`)
486
+ }
487
+
488
+ /**
489
+ * @param {net.Socket} committedClient - New owner channel.
490
+ * @param {string} committedId - Replacement transaction id.
491
+ */
492
+ function publishReplacementCommitted(committedClient, committedId) {
493
+ if (!committedClient.destroyed) committedClient.write(`${JSON.stringify({event: "replacement-committed", replacementId: committedId})}\n`)
494
+ }
495
+
496
+ /**
497
+ * @param {net.Socket} socket - Requesting client.
498
+ * @param {GuardianRequest} request - Request.
499
+ */
500
+ function requireReplacement(socket, request) {
501
+ if (replacementClient !== socket || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
502
+ }
503
+
504
+ /**
505
+ * @param {import("./json.js").JsonValue} state - Transfer state.
506
+ * @returns {import("./json.js").JsonValue} Embedded authority fence.
507
+ */
508
+ function ownerAuthority(state) {
509
+ if (!state || typeof state !== "object" || Array.isArray(state) || !("authority" in state)) throw new Error("Guardian owner state is missing its authority fence")
510
+ return state.authority
511
+ }
512
+
513
+ /**
514
+ * Stops accepting connections and closes every authority channel except the response caller.
515
+ * @param {net.Socket} caller - Shutdown requester retained until it receives the response.
516
+ */
517
+ function beginSuccessfulShutdown(caller) {
518
+ shutdownClient = caller
519
+ serverClosed = new Promise((resolve, reject) => {
520
+ server.close((error) => {
521
+ if (error) reject(error)
522
+ else resolve(undefined)
523
+ })
524
+ })
525
+
526
+ for (const client of clients) {
527
+ if (client !== caller) client.destroy()
528
+ }
529
+ if (caller.destroyed) void finishShutdown()
530
+ }
531
+
532
+ /** Completes shutdown after the caller has received success and closed its side. */
533
+ async function finishShutdown() {
534
+ if (shutdownFinalizing) return
535
+ shutdownFinalizing = true
536
+ for (const client of clients) client.destroy()
537
+ await serverClosed
538
+ await fs.rm(socketPath, {force: true})
539
+ }
540
+
541
+ /** @param {Record<string, import("./json.js").JsonValue>} event - Event payload. */
542
+ function broadcast(event) {
543
+ const line = `${JSON.stringify(event)}\n`
544
+
545
+ for (const client of clients) if (!client.destroyed) client.write(line)
546
+ }
547
+
548
+ /** @returns {void} Grants the next queued owner claim. */
549
+ function grantNextOwner() {
550
+ const next = claimWaiters.shift()
551
+
552
+ if (!next) return
553
+ clearTimeout(next.timer)
554
+ ownerClient = next.socket
555
+ next.resolve({claimed: true})
556
+ for (const waiter of claimWaiters.splice(0)) {
557
+ clearTimeout(waiter.timer)
558
+ waiter.reject(new Error("Durable owner was claimed by another matching daemon"))
559
+ }
560
+ }
561
+
562
+ /**
563
+ * @param {Error | string} error - Error-like value.
564
+ * @returns {string} Error message.
565
+ */
566
+ function errorMessage(error) {
567
+ return error instanceof Error ? error.message : String(error)
568
+ }
569
+
570
+ /**
571
+ * Derives only exact process keys present in a committed pre-split durable snapshot.
572
+ * @param {import("./json.js").JsonValue | undefined} state - Seeded owner state.
573
+ * @returns {Set<string>} Exact legacy registrations eligible for recovery.
574
+ */
575
+ function legacyOwnerKeys(state) {
576
+ const keys = new Set()
577
+
578
+ if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) return keys
579
+ const snapshot = state.snapshot
580
+
581
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return keys
582
+ if (Array.isArray(snapshot.releases)) {
583
+ for (const release of snapshot.releases) {
584
+ if (!release || typeof release !== "object" || Array.isArray(release) || typeof release.releaseId !== "string" || !Array.isArray(release.processes)) continue
585
+ for (const processStatus of release.processes) {
586
+ if (processStatus && typeof processStatus === "object" && !Array.isArray(processStatus) && typeof processStatus.id === "string") keys.add(`release:${release.releaseId}:${processStatus.id}`)
587
+ }
588
+ }
589
+ }
590
+ if (Array.isArray(snapshot.services)) {
591
+ for (const service of snapshot.services) {
592
+ if (service && typeof service === "object" && !Array.isArray(service) && typeof service.id === "string") keys.add(`service:${service.id}`)
593
+ }
594
+ }
595
+ if (typeof snapshot.activeReleaseId === "string" && Array.isArray(snapshot.singletons)) {
596
+ for (const singleton of snapshot.singletons) {
597
+ if (singleton && typeof singleton === "object" && !Array.isArray(singleton) && typeof singleton.id === "string") keys.add(`singleton:${snapshot.activeReleaseId}:${singleton.id}`)
598
+ }
599
+ }
600
+ return keys
601
+ }