rollbridge 0.1.49 → 0.1.55

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 (39) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +5 -0
  3. package/changelog.d/20260909120000-velocious-testing.md +1 -0
  4. package/docs/cli.md +7 -1
  5. package/docs/generation-deployment-contract.md +9 -0
  6. package/eslint.config.js +8 -0
  7. package/package.json +3 -2
  8. package/src/cli.js +10 -2
  9. package/src/daemon.js +102 -12
  10. package/src/process-guardian.js +5 -1
  11. package/src/release-group.js +48 -1
  12. package/test/completion.test.js +18 -16
  13. package/test/config-examples.test.js +16 -17
  14. package/test/config-path.test.js +10 -11
  15. package/test/config-validation.test.js +163 -167
  16. package/test/control-protocol.test.js +75 -14
  17. package/test/daemon-bootstrap.test.js +104 -104
  18. package/test/daemon-runtime.test.js +17 -26
  19. package/test/doctor.test.js +51 -49
  20. package/test/event-log.test.js +13 -11
  21. package/test/guardian-client.test.js +160 -145
  22. package/test/health.test.js +6 -4
  23. package/test/logs.test.js +23 -17
  24. package/test/managed-process.test.js +96 -91
  25. package/test/owner-recovery.test.js +254 -239
  26. package/test/owner-replacement.test.js +228 -223
  27. package/test/package-metadata.test.js +48 -39
  28. package/test/port-allocator.test.js +13 -16
  29. package/test/predeploy-cleanup.test.js +12 -10
  30. package/test/process-memory.test.js +17 -15
  31. package/test/proxy.test.js +10 -8
  32. package/test/recover.test.js +30 -23
  33. package/test/release-group.test.js +16 -17
  34. package/test/release-retention.test.js +10 -8
  35. package/test/release-runtime-retention.test.js +31 -39
  36. package/test/rollbridge.test.js +388 -395
  37. package/test/shutdown-completion.test.js +51 -51
  38. package/test/state-store.test.js +10 -8
  39. package/test/system-ids.test.js +15 -13
package/AGENTS.md CHANGED
@@ -61,6 +61,11 @@ external-supervisor migration path, not that atomic handoff.
61
61
 
62
62
  ## Validation and publication
63
63
 
64
+ All tests use `@velocious/testing` declarations, hooks, and `expect` matchers.
65
+ Do not import `node:test` or `node:assert`, or add assertion compatibility wrappers.
66
+ Run every changed test file locally with `npx --no-install velocious-test test/<name>.test.js`
67
+ and require it to pass before committing or pushing; lint or CI is not a substitute.
68
+
64
69
  The project is ESM JavaScript with JSDoc type checking. Package scripts are the
65
70
  source of truth: `npm run typecheck`, `npm run lint`, `npm test`, and the combined
66
71
  `npm run all-checks`. Run focused checks for the files changed; documentation-only
package/README.md CHANGED
@@ -576,6 +576,7 @@ Inspect state:
576
576
 
577
577
  ```bash
578
578
  rollbridge status --config rollbridge.js
579
+ rollbridge status --no-logs --config rollbridge.js
579
580
  ```
580
581
 
581
582
  `status` reports each managed process's `state`, `pid`, recent `logs`, last
@@ -587,6 +588,10 @@ and why it last started (`lastStartReason`: `deploy`, `crash`, `manual`, or
587
588
  `rssBytes`, `memoryRestarts`, `lastMemoryRestartAt`, and `children` (the sampled
588
589
  process tree — each group member's `pid`, `command`, and `rssBytes`).
589
590
 
591
+ For machine lifecycle attestations that do not need captured process output,
592
+ pass `--no-logs`. It returns the same status projection while omitting only each
593
+ release, service, and singleton process's `logs` array.
594
+
590
595
  Print the recent captured stdout/stderr per process (a one-shot snapshot of the
591
596
  retained `outputLines`, not a live stream):
592
597
 
@@ -0,0 +1 @@
1
+ - Migrated all Rollbridge tests to `@velocious/testing` declarations, lifecycle APIs, and native `expect` matchers. Lint now rejects Node test and assertion imports in tests.
package/docs/cli.md CHANGED
@@ -253,7 +253,7 @@ stays safe.
253
253
  ## `status`
254
254
 
255
255
  ```
256
- rollbridge status [--config <path>]
256
+ rollbridge status [--config <path>] [--no-logs]
257
257
  ```
258
258
 
259
259
  Prints the daemon status JSON: the active release id, the proxy address, and —
@@ -264,6 +264,12 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
264
264
  `lastMemoryRestartAt`, and `children` (the process tree: each group member's
265
265
  `pid`, `command`, and `rssBytes`).
266
266
 
267
+ `--no-logs` omits only the captured `logs` array from release, service, and
268
+ singleton process statuses. Use it for bounded machine-readable lifecycle
269
+ attestations that do not need process output. The equivalent control request is
270
+ `{"command":"status","includeLogs":false}`; omitted `includeLogs` continues to
271
+ return the complete status payload.
272
+
267
273
  `daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
268
274
  its runtime `format`, package `version`, content `digest`, and absolute `path`.
269
275
  `ensure-daemon` uses this attestation before reusing a responsive daemon.
@@ -0,0 +1,9 @@
1
+ # Generation deployment contract
2
+
3
+ Rollbridge treats web authority, jobs-generation authority, durable transition state, and old-release draining as separate concerns.
4
+
5
+ A successful activation-lifecycle deployment starts and health-checks the candidate, waits for the old generation's retirement/quiescence acknowledgement, activates the candidate jobs generation, switches web authority, checkpoints `committed`, and returns. The acknowledged old generation then continues draining and stopping asynchronously; deployment completion does not wait for that later work.
6
+
7
+ Failed candidates never receive traffic. Recovery is exact-authority and journaled; no operator should edit durable state. A degraded incumbent keeps web authority while jobs remain explicitly degraded until a fresh generation commits.
8
+
9
+ Persistent guardians may retain old processes, but new release processes must use current lifecycle semantics. Lifecycle command failures must retain bounded stdout/stderr and stage metadata. Runtime content digest is authoritative; package versions are compatibility metadata rather than unique code identity.
package/eslint.config.js CHANGED
@@ -81,6 +81,14 @@ export default defineConfig([
81
81
  "no-unused-vars": ["error", {argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_"}]
82
82
  }
83
83
  },
84
+ {
85
+ files: ["test/**/*.js"],
86
+ rules: {
87
+ "no-restricted-imports": ["error", {
88
+ patterns: [{group: ["node:test", "node:test/*", "test", "node:assert", "node:assert/*", "assert", "assert/*"], message: "Use @velocious/testing declarations and expect matchers."}]
89
+ }]
90
+ }
91
+ },
84
92
  jsdoc({
85
93
  config: "flat/recommended",
86
94
  files: ["**/*.{js,mjs,cjs}", "**/bin/rollbridge"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.49",
3
+ "version": "0.1.55",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
@@ -29,7 +29,7 @@
29
29
  "all-checks": "npm run typecheck && npm run lint && npm test",
30
30
  "lint": "npm run eslint && npm run typecheck",
31
31
  "release:patch": "release-patch",
32
- "test": "node --test test/*.test.js",
32
+ "test": "velocious-test test/*.test.js",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "eslint": "eslint"
35
35
  },
@@ -44,6 +44,7 @@
44
44
  "@eslint/js": "^10.0.1",
45
45
  "@types/http-proxy": "^1.17.17",
46
46
  "@types/node": "^25.9.1",
47
+ "@velocious/testing": "^0.0.12",
47
48
  "eslint": "^10.4.0",
48
49
  "eslint-plugin-jsdoc": "^62.9.0",
49
50
  "globals": "^17.6.0",
package/src/cli.js CHANGED
@@ -41,6 +41,7 @@ export async function runCli(argv) {
41
41
  .option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
42
42
  .option("--takeover-owner", "Boot and health-check before retiring the current external owner")
43
43
  .option("--replace-owner", "Resume a prepared durable owner replacement")
44
+ .option("--reset-retired-owner", "Reset missing guardian identity from the exact committed bootstrap")
44
45
  .addOption(new Option("--guardian-daemon-log-path <path>").hideHelp())
45
46
  .addOption(new Option("--guardian-daemon-pid-path <path>").hideHelp())
46
47
  .addOption(new Option("--guardian-daemon-start-timeout-ms <ms>").hideHelp())
@@ -88,6 +89,12 @@ export async function runCli(argv) {
88
89
  process.once("SIGTERM", () => { void shutdown() })
89
90
 
90
91
  if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
92
+ if (options.resetRetiredOwner && (!bootstrap || options.takeoverOwner || options.replaceOwner)) throw new Error("Daemon --reset-retired-owner requires the complete bootstrap tuple and cannot be combined with owner takeover/replacement.")
93
+
94
+ if (options.resetRetiredOwner) {
95
+ startupPromise = daemon.resetRetiredOwnerRecovery()
96
+ await startupPromise
97
+ }
91
98
 
92
99
  if (options.replaceOwner) {
93
100
  if (bootstrap || options.takeoverOwner) throw new Error("Daemon --replace-owner cannot be combined with bootstrap takeover options.")
@@ -95,7 +102,7 @@ export async function runCli(argv) {
95
102
  await startupPromise
96
103
  }
97
104
 
98
- if (!options.takeoverOwner && !options.replaceOwner) {
105
+ if (!options.takeoverOwner && !options.replaceOwner && !options.resetRetiredOwner) {
99
106
  try {
100
107
  startupPromise = daemon.start({exposeControl: !bootstrap})
101
108
  await startupPromise
@@ -266,11 +273,12 @@ export async function runCli(argv) {
266
273
  program
267
274
  .command("status")
268
275
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
276
+ .option("--no-logs", "Omit captured stdout/stderr from process statuses")
269
277
  .action(async (options) => {
270
278
  const configPath = await resolveConfigPath(options.config)
271
279
  const config = await loadConfig(configPath)
272
280
  const response = await sendControlCommand({
273
- command: {command: "status"},
281
+ command: {command: "status", ...(options.logs ? {} : {includeLogs: false})},
274
282
  path: config.control.path
275
283
  })
276
284
 
package/src/daemon.js CHANGED
@@ -27,6 +27,10 @@ const STATE_PERSIST_INTERVAL_MS = 5000
27
27
  * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
28
  * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, degradedIncumbent?: boolean, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
29
29
  * @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, ready: boolean} | 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
30
+ * @typedef {Omit<import("./managed-process.js").ManagedProcessStatus, "logs">} ManagedProcessStatusWithoutLogs
31
+ * @typedef {Omit<import("./release-group.js").ReleaseStatus, "processes"> & {processes: ManagedProcessStatusWithoutLogs[]}} ReleaseStatusWithoutLogs
32
+ * @typedef {Omit<ProcessStatus, "process"> & {process: ManagedProcessStatusWithoutLogs}} ProcessStatusWithoutLogs
33
+ * @typedef {Omit<DaemonStatus, "releases" | "services" | "singletons"> & {releases: ReleaseStatusWithoutLogs[], services: ProcessStatusWithoutLogs[], singletons: ProcessStatusWithoutLogs[]}} DaemonStatusWithoutLogs
30
34
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
31
35
  * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
32
36
  * @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>, listenerSourceId?: string, recovery?: {command: JsonValue, reconnectGraceMs: number, startupTimeoutMs: number}, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, retiredListenerHandoff?: number, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
@@ -186,6 +190,28 @@ export default class RollbridgeDaemon {
186
190
  return true
187
191
  }
188
192
 
193
+ /** Re-establishes durable guardian ownership from an exact committed external-owner bootstrap. */
194
+ async resetRetiredOwnerRecovery() {
195
+ if (!this.statePath || !this.config.ownerRecovery || !this.bootstrap) throw new Error("Retired owner reset requires ownerRecovery, statePath, and the exact bootstrap tuple")
196
+ const state = await readState(this.statePath)
197
+ const snapshot = state && typeof state === "object" && !Array.isArray(state) ? /** @type {OwnerRecoverySnapshot} */ (state) : undefined
198
+ const transition = snapshot?.generationTransition
199
+ const persistedBootstrap = snapshot?.bootstrap
200
+ const exactBootstrap = snapshot?.activeReleaseId === this.bootstrap.releaseId &&
201
+ persistedBootstrap?.releaseId === this.bootstrap.releaseId && persistedBootstrap?.releasePath === this.bootstrap.releasePath && persistedBootstrap?.revision === this.bootstrap.revision &&
202
+ transition?.phase === "committed" && transition.candidateReleaseId === this.bootstrap.releaseId && transition.candidateReleasePath === this.bootstrap.releasePath && transition.candidateRevision === this.bootstrap.revision
203
+ if (!snapshot || snapshot.recovery?.guardian || !exactBootstrap) throw new Error("Retired owner reset requires an exact committed bootstrap with missing guardian identity")
204
+ const control = await inspectControlSocket(this.config.control.path)
205
+ if (control.alive) throw new Error(`Retired owner reset refuses responsive control owner at ${this.config.control.path}`)
206
+ this.guardianIdentity = {socketPath: `${this.statePath}.guardian.sock`, token: crypto.randomBytes(32).toString("hex")}
207
+ this.guardian = new GuardianClient(this.guardianIdentity)
208
+ await this.guardian.launch()
209
+ this.guardianIdentity.pid = this.guardian.pid
210
+ this.watchOwnerReplacementEvents()
211
+ await this.guardian.claimOwner(this.config.ownerRecovery.reconnectGraceMs ?? 30000, this.ownerAuthority())
212
+ this.logger("retired owner guardian reset prepared", {guardianPid: this.guardianIdentity.pid ?? null, releaseId: this.bootstrap.releaseId})
213
+ }
214
+
189
215
  /** Connects to the durable process guardian and reconstructs a matching persisted owner snapshot. */
190
216
  async initializeOwnerRecovery() {
191
217
  if (!this.statePath) throw new Error("ownerRecovery requires statePath")
@@ -1349,7 +1375,7 @@ export default class RollbridgeDaemon {
1349
1375
  }
1350
1376
 
1351
1377
  if (commandName === "status") {
1352
- return this.status()
1378
+ return this.status({includeLogs: statusIncludeLogs(data.includeLogs)})
1353
1379
  }
1354
1380
 
1355
1381
  if (commandName === "events") {
@@ -1942,14 +1968,10 @@ export default class RollbridgeDaemon {
1942
1968
  if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
1943
1969
  const activationConfig = previous.config.processes.find((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
1944
1970
  const coordinator = activationConfig ? previous.getProcesses(activationConfig.id)[0]?.process : undefined
1945
- if (!coordinator || coordinator.status().lifecycleRole !== "retired") throw new Error(`Previous release ${previous.releaseId} does not retain a retired generation coordinator`)
1946
- if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
1947
- await candidate.drainAndStop(candidate.config.proxy.drainTimeoutMs, candidate.config)
1948
- const candidateProcesses = candidate.status().processes
1971
+ const missingRetiredCoordinator = !coordinator && /^Process .+ is not retained for reactivation$/u.test(retirementFailure ?? "")
1949
1972
 
1950
- if (candidate.state !== "stopped" || candidateProcesses.some(({pid, state}) => pid !== undefined || (state !== "stopped" && state !== "failed"))) {
1951
- throw new Error(`Failed candidate ${candidate.releaseId} did not fully stop during retired-incumbent recovery`)
1952
- }
1973
+ if ((!coordinator && !missingRetiredCoordinator) || (coordinator && coordinator.status().lifecycleRole !== "retired")) throw new Error(`Previous release ${previous.releaseId} does not retain a retired or terminally absent generation coordinator`)
1974
+ if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
1953
1975
  if (this.activeRelease !== previous || !releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} lost live proxy/web authority during retired-incumbent recovery`)
1954
1976
  const result = /** @type {Record<string, JsonValue>} */ ({
1955
1977
  activeReleaseId: previous.releaseId,
@@ -1960,7 +1982,10 @@ export default class RollbridgeDaemon {
1960
1982
  recoveryStatus: "retired_incumbent_accepted"
1961
1983
  })
1962
1984
 
1963
- if (transition.phase === "degraded_active") return result
1985
+ if (transition.phase === "degraded_active") {
1986
+ if (candidate.state !== "stopped") void this.drainAndPrune(candidate, candidate.config)
1987
+ return result
1988
+ }
1964
1989
  const previousError = transition.error
1965
1990
  const previousPhase = transition.phase
1966
1991
  const previousJournalRevision = transition.journalRevision
@@ -1984,6 +2009,7 @@ export default class RollbridgeDaemon {
1984
2009
  }
1985
2010
  throw new Error(`retired-incumbent recovery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
1986
2011
  }
2012
+ if (candidate.state !== "stopped") void this.drainAndPrune(candidate, candidate.config)
1987
2013
  this.logger("retired incumbent accepted as jobs-degraded", result)
1988
2014
  return result
1989
2015
  }
@@ -2888,8 +2914,25 @@ export default class RollbridgeDaemon {
2888
2914
  return this.proxyPort
2889
2915
  }
2890
2916
 
2891
- /** @returns {DaemonStatus} Status payload. */
2892
- status() {
2917
+ /**
2918
+ * @overload
2919
+ * @returns {DaemonStatus} Full status payload.
2920
+ */
2921
+ /**
2922
+ * @overload
2923
+ * @param {{includeLogs: false}} options - Log-free status response options.
2924
+ * @returns {DaemonStatusWithoutLogs} Log-free status payload.
2925
+ */
2926
+ /**
2927
+ * @overload
2928
+ * @param {{includeLogs: boolean}} options - Status response options.
2929
+ * @returns {DaemonStatus | DaemonStatusWithoutLogs} Status payload.
2930
+ */
2931
+ /**
2932
+ * @param {{includeLogs?: boolean}} [options] - Status response options.
2933
+ * @returns {DaemonStatus | DaemonStatusWithoutLogs} Status payload.
2934
+ */
2935
+ status({includeLogs = true} = {}) {
2893
2936
  // Re-check liveness and prune the dead permanently, so the list self-clears as the operator
2894
2937
  // stops the leftovers (e.g. via `rollbridge recover`). Pruning (not just filtering) matters:
2895
2938
  // a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
@@ -2899,7 +2942,7 @@ export default class RollbridgeDaemon {
2899
2942
  const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
2900
2943
  const transitionReleaseIds = this.generationTransitionReleaseIds()
2901
2944
 
2902
- return {
2945
+ const status = {
2903
2946
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
2904
2947
  application: this.config.application,
2905
2948
  bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
@@ -2930,6 +2973,8 @@ export default class RollbridgeDaemon {
2930
2973
  process: processInstance.status()
2931
2974
  }))
2932
2975
  }
2976
+
2977
+ return includeLogs ? status : withoutStatusProcessLogs(status)
2933
2978
  }
2934
2979
  }
2935
2980
 
@@ -2941,6 +2986,50 @@ export function ownerConfigDigest(config) {
2941
2986
  return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex")
2942
2987
  }
2943
2988
 
2989
+ /**
2990
+ * Removes captured output from a live status response without changing any other status field.
2991
+ * @param {DaemonStatus} status - Full status payload.
2992
+ * @returns {DaemonStatusWithoutLogs} Log-free status payload.
2993
+ */
2994
+ function withoutStatusProcessLogs(status) {
2995
+ return {
2996
+ ...status,
2997
+ releases: status.releases.map((release) => ({
2998
+ ...release,
2999
+ processes: release.processes.map(withoutProcessLogs)
3000
+ })),
3001
+ services: status.services.map(({process, ...service}) => ({
3002
+ ...service,
3003
+ process: withoutProcessLogs(process)
3004
+ })),
3005
+ singletons: status.singletons.map(({process, ...singleton}) => ({
3006
+ ...singleton,
3007
+ process: withoutProcessLogs(process)
3008
+ }))
3009
+ }
3010
+ }
3011
+
3012
+ /**
3013
+ * @param {import("./managed-process.js").ManagedProcessStatus} processStatus - Full managed-process status.
3014
+ * @returns {ManagedProcessStatusWithoutLogs} Status without captured process output.
3015
+ */
3016
+ function withoutProcessLogs(processStatus) {
3017
+ const {logs: _logs, ...withoutLogs} = processStatus
3018
+
3019
+ return withoutLogs
3020
+ }
3021
+
3022
+ /**
3023
+ * @param {JsonValue} value - Optional status request field.
3024
+ * @returns {boolean} Whether captured process output is included.
3025
+ */
3026
+ function statusIncludeLogs(value) {
3027
+ if (value === undefined) return true
3028
+ if (typeof value !== "boolean") throw new Error("includeLogs must be a boolean")
3029
+
3030
+ return value
3031
+ }
3032
+
2944
3033
  /**
2945
3034
  * @param {PrivateOwnerState} transfer - Authenticated private incumbent state.
2946
3035
  * @param {GenerationTransition} transition - Unresolved generation transition.
@@ -2968,6 +3057,7 @@ function terminalRetirementFailure(failure) {
2968
3057
  return typeof failure === "string" && (
2969
3058
  /^Cannot activate .+ generation from retired$/iu.test(failure) ||
2970
3059
  /^activate command exited non-zero with status \d+$/u.test(failure) ||
3060
+ /^Process .+ is not retained for reactivation$/u.test(failure) ||
2971
3061
  /^Release .+ retirement quiescence failed: quiet command exited non-zero with status \d+$/u.test(failure)
2972
3062
  )
2973
3063
  }
@@ -631,7 +631,11 @@ async function execute(request, socket) {
631
631
  },
632
632
  shouldRestart: () => record.desired
633
633
  }
634
- const managedProcess = legacyGuardian
634
+ // Only process keys inventoried from the legacy guardian remain delegated to it.
635
+ // New release processes must be owned by the current guardian so current lifecycle
636
+ // fields (including activation timeouts) are enforced instead of downgraded by an
637
+ // older guardian protocol implementation.
638
+ const managedProcess = recoversLegacyProcess && legacyGuardian
635
639
  ? legacyGuardian.process(request.key, managedDefinition)
636
640
  : new ManagedProcess(managedDefinition)
637
641
 
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
 
3
+ import {spawn} from "node:child_process"
3
4
  import {EventEmitter} from "node:events"
4
5
  import ManagedProcess from "./managed-process.js"
5
6
  import {findAvailablePort} from "./port-allocator.js"
@@ -14,6 +15,40 @@ import {waitForHealth} from "./health.js"
14
15
  * @typedef {{count?: number, guardianKey?: string, index?: number, instanceId?: string, shouldRestart?: () => boolean}} BuildProcessOptions
15
16
  */
16
17
 
18
+
19
+ /**
20
+ * Runs a lifecycle authority command in the current daemon so nested legacy guardians
21
+ * cannot silently downgrade lifecycle timeout semantics for newly deployed processes.
22
+ * @param {{command: string, cwd: string, env: Record<string, string>, label: string, timeoutMs: number}} options - Command contract.
23
+ * @returns {Promise<void>} Completion after an acknowledged command.
24
+ */
25
+ async function runLifecycleCommand({command, cwd, env, label, timeoutMs}) {
26
+ await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
27
+ const child = spawn(command, {cwd, detached: true, env: {...process.env, ...env}, shell: true, stdio: "ignore"})
28
+ let settled = false
29
+ /** @param {Error | undefined} error - Completion failure. */
30
+ /** @param {Error | undefined} error - Completion failure. */
31
+ const finish = (error) => {
32
+ if (settled) return
33
+ settled = true
34
+ clearTimeout(timer)
35
+ if (error) reject(error)
36
+ else resolve()
37
+ }
38
+ const timer = setTimeout(() => {
39
+ if (child.pid) {
40
+ try { process.kill(-child.pid, "SIGKILL") } catch { /* The command already exited. */ }
41
+ }
42
+ finish(new Error(`${label} timed out after ${timeoutMs}ms`))
43
+ }, timeoutMs)
44
+ child.once("error", finish)
45
+ child.once("exit", (code, signal) => {
46
+ if (code === 0) finish(undefined)
47
+ else finish(new Error(`${label} exited non-zero with ${signal ? `signal ${signal}` : `status ${code ?? "unknown"}`}`))
48
+ })
49
+ }))
50
+ }
51
+
17
52
  /**
18
53
  * @param {string} id - Process id.
19
54
  * @returns {string} Environment suffix.
@@ -276,7 +311,19 @@ export default class ReleaseGroup extends EventEmitter {
276
311
  const [instance] = this.getProcesses(processConfig.id)
277
312
 
278
313
  if (!instance) throw new Error(`Generation activation process ${processConfig.id} is not running for release ${this.releaseId}`)
279
- await instance.process.activateStrict()
314
+ if (instance.process.constructor.name === ManagedProcess.name) {
315
+ await instance.process.activateStrict()
316
+ } else {
317
+ const definition = this.processDefinition(processConfig)
318
+ await runLifecycleCommand({
319
+ command: /** @type {string} */ (processConfig.lifecycle.activateCommand),
320
+ cwd: /** @type {string} */ (definition.cwd),
321
+ env: /** @type {Record<string, string>} */ (definition.env),
322
+ label: "activate command",
323
+ timeoutMs: processConfig.lifecycle.activateTimeoutMs ?? 30000
324
+ })
325
+ await instance.process.setLifecycleRole("active")
326
+ }
280
327
  }
281
328
 
282
329
  /** Restores the retained generation coordinator to its active role in place. */
@@ -1,9 +1,10 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
- import test from "node:test"
3
+ import {describe, expect, test} from "@velocious/testing"
5
4
  import {runCli} from "../src/cli.js"
6
5
 
6
+ describe("completion", () => {
7
+
7
8
  /**
8
9
  * Runs the CLI while capturing stdout, stderr, and the resulting exit code.
9
10
  * @param {string[]} argv - Process argv.
@@ -39,29 +40,30 @@ async function capture(argv) {
39
40
  test("completion bash prints a sourceable script with commands and option flags", async () => {
40
41
  const {code, output} = await capture(["node", "rollbridge", "completion", "bash"])
41
42
 
42
- assert.notEqual(code, 1)
43
- assert.match(output, /complete -F _rollbridge rollbridge/)
44
- assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
43
+ expect(code).not.toBe(1)
44
+ expect(output).toMatch(/complete -F _rollbridge rollbridge/)
45
+ expect(output).toMatch(/compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
45
46
  // A command's own options are completed after the command.
46
- assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
- assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
48
- assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
49
- assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
47
+ expect(output).toMatch(/deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
48
+ expect(output).toMatch(/recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
49
+ expect(output).toMatch(/ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
50
+ expect(output).toMatch(/restart\)\n\s+opts="[^"]*--policy[^"]*"/)
50
51
  })
51
52
 
52
53
  test("completion zsh prints a #compdef script with per-command options", async () => {
53
54
  const {output} = await capture(["node", "rollbridge", "completion", "zsh"])
54
55
 
55
- assert.match(output, /^#compdef rollbridge/)
56
- assert.match(output, /compdef _rollbridge rollbridge/)
57
- assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
58
- assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
59
- assert.match(output, /events\) compadd -- [^\n]*--limit/)
56
+ expect(output).toMatch(/^#compdef rollbridge/)
57
+ expect(output).toMatch(/compdef _rollbridge rollbridge/)
58
+ expect(output).toMatch(/commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
59
+ expect(output).toMatch(/recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
60
+ expect(output).toMatch(/events\) compadd -- [^\n]*--limit/)
60
61
  })
61
62
 
62
63
  test("completion rejects an unsupported shell with a non-zero exit code", async () => {
63
64
  const {code, errorOutput} = await capture(["node", "rollbridge", "completion", "fish"])
64
65
 
65
- assert.equal(code, 1)
66
- assert.match(errorOutput, /Unsupported shell "fish"\. Supported shells: bash, zsh\./)
66
+ expect(code).toBe(1)
67
+ expect(errorOutput).toMatch(/Unsupported shell "fish"\. Supported shells: bash, zsh\./)
68
+ })
67
69
  })
@@ -1,34 +1,32 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs/promises"
5
4
  import os from "node:os"
6
5
  import path from "node:path"
7
- import test from "node:test"
6
+ import {describe, expect, test} from "@velocious/testing"
8
7
  import {fileURLToPath} from "node:url"
9
8
  import {loadConfig} from "../src/config.js"
10
9
 
10
+ describe("config-examples", () => {
11
+
11
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
13
 
13
14
  test("TensorBuzz example config loads", async () => {
14
15
  const config = await loadConfig(path.join(repoRoot, "examples", "tensorbuzz.com.js"))
15
16
 
16
- assert.equal(config.application, "tensorbuzz")
17
- assert.equal(config.control.path, "/tmp/rollbridge-tensorbuzz.sock")
18
- assert.equal(config.proxy.host, "127.0.0.1")
19
- assert.equal(config.proxy.port, 4500)
20
- assert.equal(config.proxy.healthPath, "/ping")
21
- assert.deepEqual(
22
- config.processes.map((processConfig) => [processConfig.id, processConfig.policy]),
23
- [
17
+ expect(config.application).toBe("tensorbuzz")
18
+ expect(config.control.path).toBe("/tmp/rollbridge-tensorbuzz.sock")
19
+ expect(config.proxy.host).toBe("127.0.0.1")
20
+ expect(config.proxy.port).toBe(4500)
21
+ expect(config.proxy.healthPath).toBe("/ping")
22
+ expect(config.processes.map((processConfig) => [processConfig.id, processConfig.policy])).toEqual([
24
23
  ["beacon", "service"],
25
24
  ["background-jobs-main", "service"],
26
25
  ["background-jobs-worker", "companion"],
27
26
  ["web", "proxied"]
28
- ]
29
- )
30
- assert.equal(config.processes[2].lifecycle.reactivateCommand, "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
31
- assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
27
+ ])
28
+ expect(config.processes[2].lifecycle.reactivateCommand).toBe("appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
29
+ expect(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT).toBe("{{ports.background-jobs-main}}")
32
30
  })
33
31
 
34
32
  test("loadConfig resolves a config module that exports a function", async () => {
@@ -50,11 +48,12 @@ test("loadConfig resolves a config module that exports a function", async () =>
50
48
  try {
51
49
  const config = await loadConfig(configPath)
52
50
 
53
- assert.equal(config.application, "computed-app")
54
- assert.equal(config.proxy.port, 8190)
55
- assert.equal(config.processes[0].id, "web")
51
+ expect(config.application).toBe("computed-app")
52
+ expect(config.proxy.port).toBe(8190)
53
+ expect(config.processes[0].id).toBe("web")
56
54
  } finally {
57
55
  delete process.env.ROLLBRIDGE_TEST_APP
58
56
  await fs.rm(dir, {force: true, recursive: true})
59
57
  }
60
58
  })
59
+ })
@@ -1,13 +1,14 @@
1
1
  // @ts-check
2
2
 
3
- import assert from "node:assert/strict"
4
3
  import fs from "node:fs/promises"
5
4
  import os from "node:os"
6
5
  import path from "node:path"
7
- import test from "node:test"
6
+ import {describe, expect, test} from "@velocious/testing"
8
7
  import {loadConfig, resolveConfigPath} from "../src/config.js"
9
8
  import {runCli} from "../src/cli.js"
10
9
 
10
+ describe("config-path", () => {
11
+
11
12
  const validConfig = {
12
13
  application: "demo",
13
14
  control: {path: "/tmp/rollbridge-config-path.sock"},
@@ -34,7 +35,7 @@ test("resolveConfigPath returns an explicit path unchanged", async () => {
34
35
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
35
36
 
36
37
  try {
37
- assert.equal(await resolveConfigPath("/somewhere/custom.js", dir), "/somewhere/custom.js")
38
+ expect(await resolveConfigPath("/somewhere/custom.js", dir)).toBe("/somewhere/custom.js")
38
39
  } finally {
39
40
  await fs.rm(dir, {force: true, recursive: true})
40
41
  }
@@ -46,7 +47,7 @@ test("resolveConfigPath resolves rollbridge.js in the working directory", async
46
47
  try {
47
48
  await writeConfigModule(dir)
48
49
 
49
- assert.equal(await resolveConfigPath(undefined, dir), path.join(dir, "rollbridge.js"))
50
+ expect(await resolveConfigPath(undefined, dir)).toBe(path.join(dir, "rollbridge.js"))
50
51
  } finally {
51
52
  await fs.rm(dir, {force: true, recursive: true})
52
53
  }
@@ -56,10 +57,7 @@ test("resolveConfigPath throws an actionable error when no default config exists
56
57
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
57
58
 
58
59
  try {
59
- await assert.rejects(
60
- () => resolveConfigPath(undefined, dir),
61
- /No config file found.*rollbridge\.js/
62
- )
60
+ await expect(resolveConfigPath(undefined, dir)).rejects.toThrow(/No config file found.*rollbridge\.js/)
63
61
  } finally {
64
62
  await fs.rm(dir, {force: true, recursive: true})
65
63
  }
@@ -70,11 +68,11 @@ test("loadConfig reloads a changed CommonJS module", async () => {
70
68
  const configPath = await writeConfigModule(dir)
71
69
 
72
70
  try {
73
- assert.equal((await loadConfig(configPath)).application, "demo")
71
+ expect((await loadConfig(configPath)).application).toBe("demo")
74
72
 
75
73
  await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
76
74
 
77
- assert.equal((await loadConfig(configPath)).application, "updated")
75
+ expect((await loadConfig(configPath)).application).toBe("updated")
78
76
  } finally {
79
77
  await fs.rm(dir, {force: true, recursive: true})
80
78
  }
@@ -101,5 +99,6 @@ test("validate CLI command resolves the default config when --config is omitted"
101
99
  await fs.rm(dir, {force: true, recursive: true})
102
100
  }
103
101
 
104
- assert.match(lines.join("\n"), /rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
102
+ expect(lines.join("\n")).toMatch(/rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
103
+ })
105
104
  })