rollbridge 0.1.31 → 0.1.33

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/src/daemon.js CHANGED
@@ -23,9 +23,12 @@ const STATE_PERSIST_INTERVAL_MS = 5000
23
23
  * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
24
24
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
25
25
  * @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
26
- * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, ownerRecovery: {configDigest: string} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
26
+ * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "committed_pending" | "committed"} GenerationTransitionPhase
27
+ * @typedef {{candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, configDigest: string, error?: string, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
28
+ * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, generationTransition?: GenerationTransition, ownerRecovery: {configDigest: string} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
27
29
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
28
- * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata}} OwnerRecoverySnapshot
30
+ * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
31
+ * @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
29
32
  * @typedef {{boundaryCrossed: boolean, incumbentControl: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
30
33
  */
31
34
 
@@ -62,7 +65,9 @@ export default class RollbridgeDaemon {
62
65
  this.servicePorts = /** @type {Record<string, number>} */ ({})
63
66
  this.portReservations = /** @type {Set<number>} */ (new Set())
64
67
  this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
68
+ this.singletonReleaseIds = /** @type {Map<string, string>} */ (new Map())
65
69
  this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
70
+ this.generationTransition = /** @type {GenerationTransition | undefined} */ (undefined)
66
71
  this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
67
72
  this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
68
73
  this.controlServer = /** @type {net.Server | undefined} */ (undefined)
@@ -137,10 +142,42 @@ export default class RollbridgeDaemon {
137
142
  this.watchOwnerReplacementEvents()
138
143
 
139
144
  if (snapshot) {
140
- await this.restoreOwnerState(snapshot, {resumeDrains: false})
145
+ if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
146
+ throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
147
+ }
148
+ let recoveryConfig = this.config
149
+ let releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({})
150
+ let singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({})
151
+ let recoverySnapshot = snapshot
152
+
153
+ if ((recovery?.format ?? 0) >= 2) {
154
+ const ownerState = /** @type {PrivateOwnerState} */ (await this.guardian.ownerState())
155
+
156
+ if (!ownerState?.config || !ownerState.releaseConfigs || !ownerState.snapshot) {
157
+ throw new Error("Durable guardian state is missing exact release definitions; refusing partial owner recovery.")
158
+ }
159
+ recoveryConfig = ownerState.config
160
+ releaseConfigs = ownerState.releaseConfigs
161
+ singletonReleaseIds = ownerState.singletonReleaseIds || singletonReleaseIds
162
+ const guardianTransitionAt = Date.parse(ownerState.snapshot.generationTransition?.updatedAt || "")
163
+ const persistedTransitionAt = Date.parse(snapshot.generationTransition?.updatedAt || "")
164
+
165
+ if (Number.isFinite(guardianTransitionAt) && (!Number.isFinite(persistedTransitionAt) || guardianTransitionAt > persistedTransitionAt)) {
166
+ recoverySnapshot = ownerState.snapshot
167
+ }
168
+ }
169
+ await this.restoreOwnerState(recoverySnapshot, {config: recoveryConfig, releaseConfigs, resumeDrains: false, singletonReleaseIds})
170
+ this.persistenceEnabled = true
141
171
  await this.guardian.reconcileInventory()
172
+ if (this.generationTransition && this.generationTransition.phase !== "committed" && !this.generationTransition.error) {
173
+ try {
174
+ await this.resumeGenerationTransition()
175
+ } catch (error) {
176
+ this.logger("release generation transition recovery failed", {error: error instanceof Error ? error.message : String(error), releaseId: this.generationTransition.candidateReleaseId})
177
+ }
178
+ }
142
179
  for (const release of this.releases.values()) {
143
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
180
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
144
181
  }
145
182
  }
146
183
  else {
@@ -166,7 +203,7 @@ export default class RollbridgeDaemon {
166
203
  if (this.listenerHandoff || this.ownerRetired) return
167
204
  for (const release of this.releases.values()) {
168
205
  release.resumeDrainAfterOwnerHandoff()
169
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
206
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
170
207
  }
171
208
  })
172
209
  }
@@ -182,17 +219,24 @@ export default class RollbridgeDaemon {
182
219
  * @param {import("./config.js").RollbridgeConfig} [options.config] - Owner config for daemon-wide processes.
183
220
  * @param {Record<string, import("./config.js").RollbridgeConfig>} [options.releaseConfigs] - Exact generation configs.
184
221
  * @param {boolean} [options.resumeDrains] - Whether to resume draining generations immediately.
222
+ * @param {Record<string, string>} [options.singletonReleaseIds] - Exact release owner for each singleton registration.
223
+ * @param {boolean} [options.synchronizeLifecycleRoles] - Whether the caller owns guardian role mutation authority.
185
224
  */
186
- async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true} = {}) {
225
+ async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true, singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({}), synchronizeLifecycleRoles = true} = {}) {
187
226
  if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
188
227
  throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
189
228
  }
229
+ this.generationTransition = snapshot.generationTransition ? {...snapshot.generationTransition} : undefined
190
230
  if (snapshot.activeReleaseId === null && snapshot.releases.length === 0) return
191
231
  this.bootstrap = snapshot.bootstrap ? {...snapshot.bootstrap} : undefined
192
232
  this.ownerTransition = snapshot.ownerTransition ? {...snapshot.ownerTransition} : undefined
233
+ const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
193
234
 
194
235
  for (const releaseStatus of snapshot.releases) {
195
- if (releaseStatus.state !== "active" && releaseStatus.state !== "draining") continue
236
+ const transitionCandidate = this.generationTransition?.candidateReleaseId === releaseStatus.releaseId && this.generationTransition.phase !== "committed"
237
+ const singletonOwner = singletonOwnerReleaseIds.has(releaseStatus.releaseId)
238
+
239
+ if (releaseStatus.state !== "active" && releaseStatus.state !== "draining" && !transitionCandidate && !singletonOwner) continue
196
240
  const release = new ReleaseGroup({
197
241
  config: releaseConfigs[releaseStatus.releaseId] || config,
198
242
  logger: this.logger,
@@ -205,7 +249,7 @@ export default class RollbridgeDaemon {
205
249
  shouldStart: () => !this.stopping
206
250
  })
207
251
 
208
- await release.restore(releaseStatus)
252
+ await release.restore(releaseStatus, {synchronizeLifecycleRole: synchronizeLifecycleRoles})
209
253
  this.releases.set(release.releaseId, release)
210
254
  if (release.releaseId === snapshot.activeReleaseId) this.activeRelease = release
211
255
  }
@@ -231,16 +275,21 @@ export default class RollbridgeDaemon {
231
275
  }
232
276
  }
233
277
  for (const singletonStatus of snapshot.singletons) {
234
- const processConfig = config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
278
+ const singletonReleaseId = singletonReleaseIds[singletonStatus.id] || definitionRelease.releaseId
279
+ const singletonRelease = this.releases.get(singletonReleaseId)
280
+
281
+ if (!singletonRelease) throw new Error(`Owner recovery state contains singleton ${singletonStatus.id} for unknown release ${singletonReleaseId}.`)
282
+ const processConfig = singletonRelease.config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
235
283
 
236
- if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id}.`)
237
- const singleton = definitionRelease.buildProcess(processConfig, {guardianKey: `singleton:${definitionRelease.releaseId}:${singletonStatus.id}`})
284
+ if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id} for release ${singletonReleaseId}.`)
285
+ const singleton = singletonRelease.buildProcess(processConfig, {guardianKey: `singleton:${singletonReleaseId}:${singletonStatus.id}`})
238
286
 
239
287
  await this.recoverGuardianProcess(singleton)
240
288
  this.singletons.set(singletonStatus.id, singleton)
289
+ this.singletonReleaseIds.set(singletonStatus.id, singletonReleaseId)
241
290
  }
242
291
  for (const release of this.releases.values()) {
243
- if (resumeDrains && release.state === "draining") void this.drainAndPrune(release, release.config)
292
+ if (resumeDrains && release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
244
293
  }
245
294
  this.logger("owner state recovered", {activeReleaseId: this.activeRelease?.releaseId ?? null, releases: this.releases.size})
246
295
  }
@@ -252,6 +301,7 @@ export default class RollbridgeDaemon {
252
301
  authority: this.ownerAuthority(),
253
302
  config: this.config,
254
303
  releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
304
+ singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
255
305
  snapshot: this.status()
256
306
  })
257
307
  }
@@ -289,10 +339,10 @@ export default class RollbridgeDaemon {
289
339
  prepared = legacyBridge.prepared
290
340
  }
291
341
  const preparedStatus = await this.guardian.replacementStatus()
292
- const transfer = /** @type {{config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, snapshot: OwnerRecoverySnapshot}} */ (prepared.ownerState)
342
+ const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
293
343
 
294
344
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
295
- await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false})
345
+ await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
296
346
  for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
297
347
  this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
298
348
 
@@ -356,12 +406,16 @@ export default class RollbridgeDaemon {
356
406
  authority: this.ownerAuthority(),
357
407
  config: this.config,
358
408
  releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
409
+ singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
359
410
  snapshot: this.status()
360
411
  })
361
412
 
362
413
  if (!staged.committed) {
363
414
  if (retiredIncumbentControl) {
364
- await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
415
+ const processKey = this.guardian.processes.keys().next().value
416
+
417
+ if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
418
+ await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
365
419
  } else {
366
420
  try {
367
421
  if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
@@ -381,12 +435,13 @@ export default class RollbridgeDaemon {
381
435
  finalControlPublished = true
382
436
  this.boundControlPath = this.config.control.path
383
437
  }
438
+ await Promise.all([...this.releases.values()].map((release) => release.synchronizeLifecycleRoles()))
384
439
  if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
385
440
  this.incumbentListenerControl = incumbentControl
386
441
  retainIncumbentControl = true
387
442
  }
388
443
  for (const release of this.releases.values()) {
389
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
444
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
390
445
  }
391
446
  this.startStatePersistence()
392
447
  await this.persistState({throwOnError: true})
@@ -509,6 +564,7 @@ export default class RollbridgeDaemon {
509
564
  authority: persistedAuthority,
510
565
  config: this.config,
511
566
  releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
567
+ singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
512
568
  snapshot: persisted
513
569
  }
514
570
  const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
@@ -1022,7 +1078,7 @@ export default class RollbridgeDaemon {
1022
1078
  if (!handoff || this.ownerRetired) return
1023
1079
  for (const release of this.releases.values()) {
1024
1080
  release.resumeDrainAfterOwnerHandoff()
1025
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
1081
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
1026
1082
  }
1027
1083
  if (handoff.proxy) {
1028
1084
  await this.startProxy()
@@ -1048,6 +1104,16 @@ export default class RollbridgeDaemon {
1048
1104
  this.assertReloadCompatible(nextConfig)
1049
1105
 
1050
1106
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
1107
+ const transition = this.generationTransition
1108
+
1109
+ if (transition && transition.phase !== "committed") {
1110
+ this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1111
+ return await this.resumeGenerationTransition()
1112
+ }
1113
+ if (transition?.phase === "committed" && transition.candidateReleaseId === newReleaseId && this.activeRelease?.releaseId === newReleaseId) {
1114
+ this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1115
+ return {activeReleaseId: newReleaseId, previousReleaseId: transition.previousReleaseId}
1116
+ }
1051
1117
  const release = new ReleaseGroup({
1052
1118
  config: nextConfig,
1053
1119
  logger: this.logger,
@@ -1080,6 +1146,25 @@ export default class RollbridgeDaemon {
1080
1146
 
1081
1147
  const previousRelease = this.activeRelease
1082
1148
 
1149
+ if (nextConfig.processes.some((processConfig) => processConfig.lifecycle.activateCommand !== undefined)) {
1150
+ this.config = nextConfig
1151
+ this.releases.set(release.releaseId, release)
1152
+ const now = new Date().toISOString()
1153
+
1154
+ this.generationTransition = /** @type {GenerationTransition} */ ({
1155
+ candidateReleaseId: release.releaseId,
1156
+ candidateReleasePath: release.releasePath,
1157
+ candidateRevision: release.revision,
1158
+ configDigest: ownerConfigDigest(nextConfig),
1159
+ phase: "candidate_ready",
1160
+ previousReleaseId: previousRelease?.releaseId ?? null,
1161
+ startedAt: now,
1162
+ updatedAt: now
1163
+ })
1164
+ await this.checkpointGenerationTransition()
1165
+ return await this.resumeGenerationTransition()
1166
+ }
1167
+
1083
1168
  this.config = nextConfig
1084
1169
  this.releases.set(release.releaseId, release)
1085
1170
  release.activate()
@@ -1113,6 +1198,138 @@ export default class RollbridgeDaemon {
1113
1198
  }
1114
1199
  }
1115
1200
 
1201
+ /**
1202
+ * Continues one exact durable generation transition without an internal retry loop.
1203
+ * @returns {Promise<Record<string, JsonValue>>} Deploy result after commit.
1204
+ */
1205
+ async resumeGenerationTransition() {
1206
+ const transition = this.generationTransition
1207
+
1208
+ if (!transition) throw new Error("No release generation transition to resume")
1209
+ const release = this.releases.get(transition.candidateReleaseId)
1210
+ const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
1211
+
1212
+ if (!release) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
1213
+ if (transition.previousReleaseId && !previousRelease) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
1214
+
1215
+ if (transition.phase === "candidate_ready") {
1216
+ if (previousRelease) await this.updateGenerationTransition("retiring_previous")
1217
+ else await this.updateGenerationTransition("previous_retired")
1218
+ }
1219
+
1220
+ if (transition.phase === "retiring_previous") {
1221
+ try {
1222
+ const retirementConfig = previousRelease?.config
1223
+
1224
+ // Every entry into this journaled phase represents one explicit attempt. Reset the
1225
+ // process-local acknowledgement cache so exact resume can replay an ambiguous or
1226
+ // failed idempotent retirement once; there is no internal retry loop.
1227
+ await previousRelease?.beginRetirement(retirementConfig, {retry: true})
1228
+ } catch (error) {
1229
+ const failure = previousRelease?.retirementError ?? (error instanceof Error ? error.message : String(error))
1230
+
1231
+ await this.failGenerationTransition(failure)
1232
+ this.logger("release retirement quiescence failed", {error: failure, releaseId: previousRelease?.releaseId ?? null})
1233
+ throw error
1234
+ }
1235
+ await this.updateGenerationTransition("previous_retired")
1236
+ }
1237
+
1238
+ if (transition.phase === "previous_retired") await this.updateGenerationTransition("activating_candidate")
1239
+
1240
+ if (transition.phase === "activating_candidate") {
1241
+ try {
1242
+ await release.activateGeneration()
1243
+ } catch (error) {
1244
+ const failure = error instanceof Error ? error.message : String(error)
1245
+
1246
+ await this.failGenerationTransition(failure)
1247
+ this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
1248
+ throw error
1249
+ }
1250
+
1251
+ // Activation acknowledgement and the logical proxy commit deliberately share one
1252
+ // synchronous continuation: no awaited failure boundary may leave jobs active while
1253
+ // Rollbridge still points new traffic at the retired generation.
1254
+ release.activate()
1255
+ this.activeRelease = release
1256
+ transition.phase = "committed_pending"
1257
+ transition.error = undefined
1258
+ transition.updatedAt = new Date().toISOString()
1259
+ this.logger("traffic switched", {previousReleaseId: previousRelease?.releaseId ?? null, releaseId: release.releaseId})
1260
+ }
1261
+
1262
+ if (transition.phase === "committed_pending") {
1263
+ await this.checkpointGenerationTransition()
1264
+ this.refreshServiceDefinitions(release)
1265
+ if (previousRelease) {
1266
+ const retirementConfig = previousRelease.config
1267
+
1268
+ void this.drainAndPrune(previousRelease, retirementConfig)
1269
+ }
1270
+ try {
1271
+ await this.replaceSingletons(release)
1272
+ } catch (error) {
1273
+ await this.failGenerationTransition(error instanceof Error ? error.message : String(error))
1274
+ throw error
1275
+ }
1276
+ transition.phase = "committed"
1277
+ transition.error = undefined
1278
+ transition.updatedAt = new Date().toISOString()
1279
+ await this.checkpointGenerationTransition()
1280
+ }
1281
+
1282
+ return {activeReleaseId: release.releaseId, previousReleaseId: previousRelease?.releaseId ?? null}
1283
+ }
1284
+
1285
+ /**
1286
+ * @param {GenerationTransition} transition - Pending or committed exact transition.
1287
+ * @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
1288
+ */
1289
+ assertExactGenerationTransition(transition, candidate) {
1290
+ if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || transition.configDigest !== ownerConfigDigest(candidate.config)) {
1291
+ throw new Error(`Release generation transition for ${transition.candidateReleaseId} is unresolved at ${transition.phase}; only the exact same release, path, revision, and config authority may resume it`)
1292
+ }
1293
+ }
1294
+
1295
+ /** @param {GenerationTransitionPhase} phase - Durable phase to enter. */
1296
+ async updateGenerationTransition(phase) {
1297
+ if (!this.generationTransition) throw new Error("No release generation transition to update")
1298
+ this.generationTransition.phase = phase
1299
+ this.generationTransition.error = undefined
1300
+ this.generationTransition.updatedAt = new Date().toISOString()
1301
+ await this.checkpointGenerationTransition()
1302
+ }
1303
+
1304
+ /** @param {string} error - Exact failed phase diagnostic. */
1305
+ async failGenerationTransition(error) {
1306
+ if (!this.generationTransition) throw new Error("No release generation transition to fail")
1307
+ this.generationTransition.error = error
1308
+ this.generationTransition.updatedAt = new Date().toISOString()
1309
+ await this.checkpointGenerationTransition()
1310
+ }
1311
+
1312
+ /** Persists and publishes the exact transition boundary before another external effect. */
1313
+ async checkpointGenerationTransition() {
1314
+ // Publish private exact definitions before the public secret-safe state can expose
1315
+ // a newer transition authority. A replacement can then reconstruct every guardian
1316
+ // registration without reading commands or environment values from statePath.
1317
+ await this.publishOwnerState()
1318
+ const write = this.persistState({throwOnError: true})
1319
+
1320
+ if (write) await write
1321
+ }
1322
+
1323
+ /**
1324
+ * @param {ReleaseGroup} release - Retained release.
1325
+ * @returns {boolean} Whether its stop drain may run now.
1326
+ */
1327
+ shouldResumeDrain(release) {
1328
+ const transition = this.generationTransition
1329
+
1330
+ return !transition || transition.phase === "committed_pending" || transition.phase === "committed" || transition.previousReleaseId !== release.releaseId
1331
+ }
1332
+
1116
1333
  /**
1117
1334
  * Relinquishes only daemon authority/listeners after the guardian has committed a
1118
1335
  * prepared replacement. Guardian-owned processes and drains are never stopped.
@@ -1181,6 +1398,7 @@ export default class RollbridgeDaemon {
1181
1398
  * @returns {Promise<Record<string, JsonValue>>} The rollback result.
1182
1399
  */
1183
1400
  async rollback({releaseId} = {}) {
1401
+ this.assertNoUnresolvedGenerationTransition("rollback")
1184
1402
  const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
1185
1403
 
1186
1404
  if (!target) {
@@ -1304,6 +1522,9 @@ export default class RollbridgeDaemon {
1304
1522
  if (processConfig.policy !== "singleton") continue
1305
1523
 
1306
1524
  const previous = this.singletons.get(processConfig.id)
1525
+ const previousReleaseId = this.singletonReleaseIds.get(processConfig.id)
1526
+
1527
+ if (previous && previousReleaseId === release.releaseId && previous.status().state === "running") continue
1307
1528
 
1308
1529
  if (previous) {
1309
1530
  await previous.stop()
@@ -1313,8 +1534,10 @@ export default class RollbridgeDaemon {
1313
1534
  const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
1314
1535
 
1315
1536
  this.singletons.set(processConfig.id, singleton)
1537
+ this.singletonReleaseIds.set(processConfig.id, release.releaseId)
1316
1538
  await singleton.start("deploy")
1317
1539
  }
1540
+ this.pruneStoppedReleases()
1318
1541
  }
1319
1542
 
1320
1543
  /**
@@ -1328,6 +1551,7 @@ export default class RollbridgeDaemon {
1328
1551
  * @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
1329
1552
  */
1330
1553
  async restartProcesses({policy, processId} = {}) {
1554
+ this.assertNoUnresolvedGenerationTransition("restart")
1331
1555
  if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
1332
1556
  throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
1333
1557
  }
@@ -1341,7 +1565,7 @@ export default class RollbridgeDaemon {
1341
1565
  for (const target of targets) {
1342
1566
  this.logger("process restart requested", {processId: target.id})
1343
1567
  await target.process.stop()
1344
- await target.process.start("manual")
1568
+ await target.process.start("manual", target.process.lifecycle.activateCommand ? "active" : undefined)
1345
1569
  }
1346
1570
 
1347
1571
  return {restarted: targets.map((target) => target.id)}
@@ -1402,6 +1626,7 @@ export default class RollbridgeDaemon {
1402
1626
  * @returns {Promise<void>} Resolves when stopped.
1403
1627
  */
1404
1628
  async stopRelease(releaseId) {
1629
+ this.assertNoUnresolvedGenerationTransition("stop")
1405
1630
  const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
1406
1631
 
1407
1632
  if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
@@ -1413,6 +1638,15 @@ export default class RollbridgeDaemon {
1413
1638
  this.persistState()
1414
1639
  }
1415
1640
 
1641
+ /** @param {string} operation - Mutating control operation. */
1642
+ assertNoUnresolvedGenerationTransition(operation) {
1643
+ const transition = this.generationTransition
1644
+
1645
+ if (transition && transition.phase !== "committed") {
1646
+ throw new Error(`Cannot ${operation} while release generation transition ${transition.candidateReleaseId} is unresolved at ${transition.phase}; resume the exact deploy first`)
1647
+ }
1648
+ }
1649
+
1416
1650
  /**
1417
1651
  * Drains and stops a retired release in the background, then prunes stopped releases.
1418
1652
  * @param {ReleaseGroup} release - Release to drain and stop.
@@ -1435,9 +1669,13 @@ export default class RollbridgeDaemon {
1435
1669
 
1436
1670
  /** @returns {void} Removes stopped releases beyond the retention policy. */
1437
1671
  pruneStoppedReleases() {
1438
- const statuses = [...this.releases.values()].map((release) => release.status())
1672
+ const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
1673
+ const statuses = [...this.releases.values()]
1674
+ .filter((release) => !singletonOwnerReleaseIds.has(release.releaseId))
1675
+ .map((release) => release.status())
1439
1676
 
1440
1677
  for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
1678
+ this.releases.get(releaseId)?.releasePortReservations()
1441
1679
  this.releases.delete(releaseId)
1442
1680
  }
1443
1681
  }
@@ -1469,9 +1707,10 @@ export default class RollbridgeDaemon {
1469
1707
  ...status,
1470
1708
  events,
1471
1709
  persistedAt: new Date().toISOString(),
1710
+ ...(this.hasActivationLifecycle() ? {singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds)} : {}),
1472
1711
  ...(this.guardianIdentity ? {recovery: {
1473
1712
  configDigest: this.ownerRecoveryConfigDigest(),
1474
- format: 1,
1713
+ format: this.hasActivationLifecycle() ? 2 : 1,
1475
1714
  guardian: this.guardianIdentity,
1476
1715
  reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
1477
1716
  }} : {})
@@ -1491,6 +1730,11 @@ export default class RollbridgeDaemon {
1491
1730
  return this.pendingWrite
1492
1731
  }
1493
1732
 
1733
+ /** @returns {boolean} Whether the current authority uses explicit generation activation. */
1734
+ hasActivationLifecycle() {
1735
+ return Boolean(this.generationTransition) || this.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined) || [...this.releases.values()].some((release) => release.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined))
1736
+ }
1737
+
1494
1738
  /**
1495
1739
  * On startup, reads any state left by a previous daemon and reports managed processes whose
1496
1740
  * pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
@@ -1678,6 +1922,8 @@ export default class RollbridgeDaemon {
1678
1922
  // a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
1679
1923
  this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
1680
1924
 
1925
+ const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
1926
+
1681
1927
  return {
1682
1928
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
1683
1929
  application: this.config.application,
@@ -1685,6 +1931,7 @@ export default class RollbridgeDaemon {
1685
1931
  control: {...this.config.control},
1686
1932
  daemonPid: process.pid,
1687
1933
  daemonRuntime: this.runtime ? {...this.runtime} : undefined,
1934
+ generationTransition: this.generationTransition ? {...this.generationTransition} : undefined,
1688
1935
  ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
1689
1936
  ownerTransition: this.ownerTransition ? {...this.ownerTransition} : undefined,
1690
1937
  orphans: [...this.orphans],
@@ -1694,7 +1941,7 @@ export default class RollbridgeDaemon {
1694
1941
  upstreamHost: this.config.proxy.upstreamHost
1695
1942
  },
1696
1943
  releaseReferences: [...this.releases.values()]
1697
- .filter((release) => release.state === "active" || release.state === "draining")
1944
+ .filter((release) => release.state === "active" || release.state === "draining" || singletonOwnerReleaseIds.has(release.releaseId) || (this.generationTransition?.phase !== "committed" && this.generationTransition?.candidateReleaseId === release.releaseId))
1698
1945
  .map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
1699
1946
  releases: [...this.releases.values()].map((release) => release.status()),
1700
1947
  services: [...this.services.entries()].map(([id, processInstance]) => ({
@@ -194,9 +194,12 @@ export default class GuardianClient {
194
194
  await this.request({command: "commit-owner-replacement", replacementId})
195
195
  }
196
196
 
197
- /** @param {string} replacementId - Same-authority transaction whose incumbent listener is absent. */
198
- async commitRetiredOwnerReplacement(replacementId) {
199
- await this.request({command: "commit-retired-owner-replacement", replacementId})
197
+ /**
198
+ * @param {string} replacementId - Same-authority transaction whose incumbent listener is absent.
199
+ * @param {string} key - Exact recovered guardian process proving candidate reconstruction.
200
+ */
201
+ async commitRetiredOwnerReplacement(replacementId, key) {
202
+ await this.request({command: "commit-retired-owner-replacement", key, replacementId})
200
203
  }
201
204
 
202
205
  /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
@@ -310,7 +313,7 @@ export default class GuardianClient {
310
313
 
311
314
  this.buffer = this.buffer.slice(newline + 1)
312
315
  if (message.event) {
313
- if (message.event === "process" || message.event === "status") this.processes.get(message.key)?.onGuardianEvent(message)
316
+ if (message.event === "process" || message.event === "process-log" || message.event === "status") this.processes.get(message.key)?.onGuardianEvent(message)
314
317
  for (const handler of this.eventHandlers.get(message.event) || []) handler(message)
315
318
  const waiter = this.events.get(message.event)?.shift()
316
319
 
@@ -354,10 +357,15 @@ class GuardianProcess extends ManagedProcess {
354
357
  await this.ensureRegistered()
355
358
  }
356
359
 
357
- async start(reason = "deploy") {
360
+ /**
361
+ * @param {import("./managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
362
+ * @param {import("./managed-process.js").LifecycleRole} [lifecycleRole] - Desired role restored before running.
363
+ */
364
+ async start(reason = "deploy", lifecycleRole) {
358
365
  await this.ensureRegistered()
359
366
  await this.pendingUpdate
360
- this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, reason}))
367
+ if (lifecycleRole) this.lifecycleRole = lifecycleRole
368
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
361
369
  }
362
370
 
363
371
  /** @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition. */
@@ -383,6 +391,27 @@ class GuardianProcess extends ManagedProcess {
383
391
  await this.quiesce()
384
392
  }
385
393
 
394
+ async requiesceStrict() {
395
+ await this.ensureRegistered()
396
+ await this.pendingUpdate
397
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "requiesce", key: this.key}))
398
+ }
399
+
400
+ async activateStrict() {
401
+ await this.ensureRegistered()
402
+ await this.pendingUpdate
403
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "activate", key: this.key}))
404
+ this.lifecycleRole = "active"
405
+ }
406
+
407
+ /** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
408
+ async setLifecycleRole(role) {
409
+ await this.ensureRegistered()
410
+ await this.pendingUpdate
411
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
412
+ this.lifecycleRole = role
413
+ }
414
+
386
415
  async stop(options = {}) {
387
416
  await this.ensureRegistered()
388
417
  await this.pendingUpdate
@@ -396,6 +425,10 @@ class GuardianProcess extends ManagedProcess {
396
425
  /** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
397
426
  onGuardianEvent(event) {
398
427
  if (event.status) this.cachedStatus = asProcessStatus(event.status)
428
+ if (event.event === "process-log") {
429
+ this.emit("log", asProcessLog(event.entry))
430
+ return
431
+ }
399
432
  if (event.message === "process started") this.emit("started")
400
433
  if (event.message === "process exited") this.emit("exit", event.data)
401
434
  this.logger(typeof event.message === "string" ? event.message : "guardian process status", event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {})
@@ -430,6 +463,14 @@ function asProcessStatus(value) {
430
463
  return JSON.parse(JSON.stringify(value))
431
464
  }
432
465
 
466
+ /**
467
+ * @param {import("./json.js").JsonValue} value - Protocol value.
468
+ * @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.
469
+ */
470
+ function asProcessLog(value) {
471
+ return JSON.parse(JSON.stringify(value))
472
+ }
473
+
433
474
  /**
434
475
  * @param {Error | string} error - Error-like value.
435
476
  * @returns {string} Error message.