rollbridge 0.1.38 → 0.1.40
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/README.md +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +33 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +100 -26
- package/src/config.js +14 -6
- package/src/daemon.js +741 -153
- package/src/guardian-client.js +68 -16
- package/src/managed-process.js +55 -8
- package/src/process-guardian.js +731 -43
- package/src/release-group.js +77 -6
- package/test/config-validation.test.js +4 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1339 -62
- package/test/managed-process.test.js +136 -7
- package/test/owner-recovery.test.js +814 -52
- package/test/owner-replacement.test.js +526 -23
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +21 -0
- package/test/support/process.js +41 -0
package/README.md
CHANGED
|
@@ -227,7 +227,13 @@ then runs a private local process guardian which remains the OS supervisor for
|
|
|
227
227
|
managed processes if the control daemon exits unexpectedly. A replacement using
|
|
228
228
|
the exact same normalized config/runtime reconnects within `reconnectGraceMs`,
|
|
229
229
|
reconstructs active and draining generations and their ports, and fences
|
|
230
|
-
concurrent replacements.
|
|
230
|
+
concurrent replacements. If no replacement reconnects during that grace, the
|
|
231
|
+
guardian restarts the exact accepted daemon command and environment itself; the
|
|
232
|
+
recovery definition is kept only in the guardian's private authenticated state
|
|
233
|
+
and is refreshed atomically during a package/runtime replacement. A restart
|
|
234
|
+
attempt which cannot claim ownership and publish ready listeners within the
|
|
235
|
+
accepted startup timeout is terminated with its process group and retried with a
|
|
236
|
+
nonzero backoff. `ensure-daemon` can also prepare a requested
|
|
231
237
|
config/control-socket/package/runtime owner, prove it healthy, and atomically
|
|
232
238
|
transfer guardian authority while every retained generation keeps its exact
|
|
233
239
|
release reference and drains asynchronously. The old `statePath` is the durable
|
|
@@ -237,6 +243,15 @@ Prepared transactions fence owner mutations and compare a monotonic guardian
|
|
|
237
243
|
state revision at staging. Existing HTTP/WebSocket connections remain owned by
|
|
238
244
|
the retired listener process, while their counts transfer to the new daemon so
|
|
239
245
|
later deploys continue to honor the original drain boundary.
|
|
246
|
+
If that new daemon itself exits while a retired listener still owns connections,
|
|
247
|
+
recovery conservatively retains the last authenticated transferred count until
|
|
248
|
+
the configured drain timeout; it never guesses that the older sockets closed.
|
|
249
|
+
An intermediate guardian which supports atomic owner replacement but predates
|
|
250
|
+
daemon recovery cannot be hot-upgraded because it is the existing processes' OS
|
|
251
|
+
supervisor. The upgrade fails before handoff and requires one explicit clean
|
|
252
|
+
`shutdown` followed by `ensure-daemon`; subsequent package/runtime replacements
|
|
253
|
+
remain atomic. A genuinely pre-replacement guardian still uses the separately
|
|
254
|
+
documented one-time disruptive compatibility bridge below.
|
|
240
255
|
|
|
241
256
|
There is one explicit compatibility boundary: the first upgrade from a genuine
|
|
242
257
|
pre-owner-replacement Rollbridge guardian and daemon cannot share its listeners
|
|
@@ -425,10 +440,13 @@ If the new release fails to start or health-check, the previous release stays
|
|
|
425
440
|
active and any service started during this deploy is rolled back.
|
|
426
441
|
|
|
427
442
|
`status.releaseReferences` lists the id and path of every active or draining
|
|
428
|
-
release
|
|
443
|
+
release, plus a stopped release that still owns a persistent service definition
|
|
444
|
+
or singleton, or remains part of an unresolved generation transition. A
|
|
445
|
+
reference disappears only when that release has stopped and no longer owns
|
|
446
|
+
runtime state. With
|
|
429
447
|
`ownerRecovery`, those references and generations survive both same-authority
|
|
430
|
-
daemon recovery and guardian-fenced incompatible
|
|
431
|
-
runtime replacement through `ensure-daemon`.
|
|
448
|
+
daemon recovery and guardian-fenced incompatible
|
|
449
|
+
config/control-socket/package/runtime replacement through `ensure-daemon`.
|
|
432
450
|
|
|
433
451
|
## Commands
|
|
434
452
|
|
|
@@ -7,10 +7,21 @@
|
|
|
7
7
|
- Fail closed when an older retained guardian cannot commit that replacement
|
|
8
8
|
atomically after the incumbent control socket disappears, preserving the
|
|
9
9
|
incumbent owner, retained connections, and guardian-managed release processes.
|
|
10
|
+
- Retire the committed incumbent listener after a control-socket-absent handoff
|
|
11
|
+
only after relaying source-identified live connection counts through successive
|
|
12
|
+
owner replacements. Existing WebSocket connections can finish draining without
|
|
13
|
+
one retired listener clearing another listener's counts, and socket-path cleanup
|
|
14
|
+
remains fenced by the listener identity that was actually bound.
|
|
15
|
+
- Keep incumbent authority while it yields a fixed proxy, commit only after the
|
|
16
|
+
candidate receives complete listener state and binds successfully, and resume
|
|
17
|
+
the incumbent if that bind fails. Pending retirement survives candidate recovery,
|
|
18
|
+
concurrent replacements remain fenced, and crashed local sources publish zero
|
|
19
|
+
tombstones while stopped zero-count releases remain omitted.
|
|
10
20
|
- Explicitly classify retained guardian replacement capabilities before preparing
|
|
11
21
|
a transaction. Guardians with prepare/stage support but no retired-owner commit
|
|
12
22
|
command use the fully attested one-time disruptive legacy upgrade bridge;
|
|
13
23
|
malformed, stale, or ambiguous protocol responses continue to fail closed. The
|
|
14
24
|
partial guardian's prepared transaction remains the mutation fence through
|
|
15
25
|
candidate reconstruction and boundary revalidation, and failed preparation
|
|
16
|
-
notifies the incumbent to resume paused release drains.
|
|
26
|
+
notifies the incumbent to resume paused release drains. The bridge coordinator
|
|
27
|
+
publishes its own authenticated recovery identity before the candidate proceeds.
|
|
@@ -5,5 +5,19 @@
|
|
|
5
5
|
candidate-activate acknowledgement, and synchronous active/proxy commit with
|
|
6
6
|
exact transition recovery and resume. Exact release definitions remain private
|
|
7
7
|
to guardian recovery, post-commit singleton work is resumable, unresolved
|
|
8
|
-
control mutations are fenced,
|
|
9
|
-
|
|
8
|
+
control and terminal owner mutations are fenced, recovery uses a complete
|
|
9
|
+
monotonic journal and publishes listener/PID readiness before replaying long
|
|
10
|
+
hooks, signals shut down cleanly after replay, and an active coordinator
|
|
11
|
+
restores its role after restart. Public recovery state advances only after its
|
|
12
|
+
private guardian authority, persistent service definitions retain their exact
|
|
13
|
+
release across recovery, manual restart reaches the active handoff coordinator,
|
|
14
|
+
unresolved transitions retain both release definitions and reject
|
|
15
|
+
config-authority-changing owner replacements, and live activation-mode changes
|
|
16
|
+
require a daemon restart. Hook-free configs keep their existing deploy behavior.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Serialize active-role restoration with retirement, reject empty lifecycle
|
|
21
|
+
commands, bound guardian process-log forwarding for stalled clients, and keep
|
|
22
|
+
a claimed recovery owner alive until replacement listener retirement. Journal
|
|
23
|
+
hook-free post-switch retirement and singleton work for exact crash recovery.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
- Let an `ownerRecovery` process guardian restart the exact accepted Rollbridge daemon command and environment after the reconnection grace when no external replacement claims ownership. Active and draining managed processes keep their existing PIDs, foreground or configured daemon log destinations remain current, the guardian atomically publishes PID files only for ready owners, startup fencing lasts through listener readiness, failed or superseded process groups are terminated, failed starts use a nonzero retry backoff, diagnostics exclude private recovery values, relative control/state paths resolve from the durable config directory, ensured daemons recover from that directory rather than a removable release cwd, and atomic package/runtime replacement refreshes the guardian's private recovery definition.
|
|
2
|
+
- Require one explicit clean shutdown and restart when upgrading an intermediate persistent guardian that supports owner replacement but predates daemon recovery, because the existing OS supervisor cannot gain daemon-recovery code through an ordinary daemon-only handoff. Genuinely pre-replacement guardians retain their one-time disruptive compatibility bridge.
|
package/docs/cli.md
CHANGED
|
@@ -64,6 +64,16 @@ identity. Rollbridge does not calculate or interpret the digest.
|
|
|
64
64
|
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
65
65
|
and waits for control-socket deployments.
|
|
66
66
|
|
|
67
|
+
If an external owner retirement has already journaled a committed generation but
|
|
68
|
+
cleared its active role, only a foreground bootstrap with that exact release id,
|
|
69
|
+
path, revision, and config authority may restore it. Rollbridge waits for the
|
|
70
|
+
retiring candidate processes to stop, journals `restoring_committed`, reconnects
|
|
71
|
+
their existing guardian registrations, restarts that candidate, health-checks it,
|
|
72
|
+
and restores its generation activation before completing singletons and exposing
|
|
73
|
+
control. A later exact bootstrap resumes the journaled restart without duplicating
|
|
74
|
+
processes. A mismatched tuple or a candidate that is still retiring fails closed
|
|
75
|
+
without stopping other retained generations.
|
|
76
|
+
|
|
67
77
|
`--takeover-owner` requires the complete bootstrap tuple. It bootstraps and
|
|
68
78
|
health-checks the replacement before sending the current daemon the private
|
|
69
79
|
retirement command. The current `performOwnerRetirement` path quiesces every
|
|
@@ -89,8 +99,11 @@ rollbridge ensure-daemon [--config <path>]
|
|
|
89
99
|
```
|
|
90
100
|
|
|
91
101
|
Starts the daemon as a detached process **only if** the control socket is not
|
|
92
|
-
already accepting commands, waits until it responds
|
|
93
|
-
status JSON. Idempotent — safe to call
|
|
102
|
+
already accepting commands, waits until it responds and its guardian accepts
|
|
103
|
+
the ready owner, then prints the daemon status JSON. Idempotent — safe to call
|
|
104
|
+
before every deploy. The detached daemon uses the config file's directory as its
|
|
105
|
+
working directory, rather than the invoking release, so release retention cannot
|
|
106
|
+
remove the accepted recovery cwd.
|
|
94
107
|
|
|
95
108
|
Before starting a detached daemon, Rollbridge atomically copies its runtime code
|
|
96
109
|
and production dependency closure into a content-addressed directory outside
|
|
@@ -118,8 +131,8 @@ and authority failures do not qualify and fail before any deploy is sent.
|
|
|
118
131
|
[`logging.md`](logging.md) for the log format and rotation guidance.
|
|
119
132
|
- `--daemon-pid-path <path>` — file the detached daemon's PID is written to.
|
|
120
133
|
Default: `/tmp/rollbridge-<application>.pid`. During replacement, the file
|
|
121
|
-
continues to name the incumbent until the
|
|
122
|
-
publishes
|
|
134
|
+
continues to name the incumbent until the authenticated guardian atomically
|
|
135
|
+
publishes the ready winner's exact `daemonPid`.
|
|
123
136
|
- `--daemon-runtime-path <path>` — parent directory for content-addressed daemon
|
|
124
137
|
runtime snapshots. Default:
|
|
125
138
|
`/tmp/rollbridge-<user-id>-<application-hash>-runtime`. The directory must be owned
|
|
@@ -157,10 +170,16 @@ An unresolved failure blocks different deploys; only the exact same release,
|
|
|
157
170
|
path, revision, and config authority may explicitly resume its incomplete
|
|
158
171
|
idempotent phase. A durable `committed_pending` phase keeps exact retry from
|
|
159
172
|
reporting success until singleton replacement finishes. Stop, restart, and
|
|
160
|
-
rollback mutations are rejected while the transition is unresolved.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
rollback mutations are rejected while the transition is unresolved. Automatic
|
|
174
|
+
replay also fences `shutdown` and `retire-owner` until the replay attempt settles.
|
|
175
|
+
An owner replacement may change runtime/package identity during an unresolved
|
|
176
|
+
transition, but it cannot change config authority until that transition commits;
|
|
177
|
+
the incumbent owner remains in place when such a replacement is rejected.
|
|
178
|
+
Hook-free configs retain the existing post-activation quiet behavior and
|
|
179
|
+
retirement result. `status.releaseReferences` lists `{releaseId, releasePath}`
|
|
180
|
+
for every active or draining release and for a stopped release that still owns a
|
|
181
|
+
persistent service definition, pending singleton, or unresolved generation
|
|
182
|
+
transition; unrelated fully stopped history is excluded.
|
|
164
183
|
|
|
165
184
|
For hook-free configs, after candidate activation `Daemon.deploy()` begins old-generation retirement
|
|
166
185
|
and asynchronous drain before awaiting singleton replacement. A singleton
|
|
@@ -234,6 +253,9 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
|
|
|
234
253
|
`daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
|
|
235
254
|
its runtime `format`, package `version`, content `digest`, and absolute `path`.
|
|
236
255
|
`ensure-daemon` uses this attestation before reusing a responsive daemon.
|
|
256
|
+
With `ownerRecovery`, `ownerRecovery.ready` becomes `true` only after the
|
|
257
|
+
guardian has accepted that daemon's listener readiness and atomically published
|
|
258
|
+
its configured PID file; `ensure-daemon` does not return a pre-ready status.
|
|
237
259
|
|
|
238
260
|
A foreground known-release daemon also reports the exact CLI bootstrap identity:
|
|
239
261
|
|
|
@@ -292,7 +314,9 @@ Targeting it (by id or `--policy proxied`) is an error; use `rollbridge deploy`
|
|
|
292
314
|
for a zero-downtime replacement. `--process <id>` with an id that is not a
|
|
293
315
|
managed process (unknown, or a companion with no active release) is also an
|
|
294
316
|
error. Restarting a `service` bounces a shared broker (for example Velocious
|
|
295
|
-
Beacon), which briefly disrupts every process that depends on it.
|
|
317
|
+
Beacon), which briefly disrupts every process that depends on it. For a handoff
|
|
318
|
+
service, restart targets only the active release's instance and restores its
|
|
319
|
+
active lifecycle role before reporting success.
|
|
296
320
|
|
|
297
321
|
## `predeploy-cleanup`
|
|
298
322
|
|
package/docs/config.md
CHANGED
|
@@ -50,13 +50,13 @@ restart.
|
|
|
50
50
|
| `proxy` | object | **required** | Proxy listener and shared defaults (see below). |
|
|
51
51
|
| `processes` | array | **required** | Managed processes (see below). Exactly one must be `proxied`. |
|
|
52
52
|
| `releaseRetention` | object | — | How many stopped releases the daemon retains (see below). |
|
|
53
|
-
| `statePath` | string | unset (no persistence) | File the daemon persists its state to, enabling orphaned-process detection on the next startup (see [`statePath`](#statepath)). |
|
|
53
|
+
| `statePath` | string | unset (no persistence) | File the daemon persists its state to, enabling orphaned-process detection on the next startup; relative paths resolve from the config file directory (see [`statePath`](#statepath)). |
|
|
54
54
|
|
|
55
55
|
## `control`
|
|
56
56
|
|
|
57
57
|
| Field | Type | Default | Description |
|
|
58
58
|
| --- | --- | --- | --- |
|
|
59
|
-
| `control.path` | string | `/tmp/rollbridge-<application>.sock` | Unix domain socket the CLI uses to talk to the daemon. |
|
|
59
|
+
| `control.path` | string | `/tmp/rollbridge-<application>.sock` | Unix domain socket the CLI uses to talk to the daemon; relative paths resolve from the config file directory. |
|
|
60
60
|
| `control.mode` | octal string (e.g. `"660"`) or octal number (`0o660`) | unset | `chmod` applied to the socket after it binds, to share it with a deploy group. When unset, the daemon umask applies. |
|
|
61
61
|
| `control.owner` | non-negative integer uid or user name | unset | `chown` owner applied to the socket after it binds. |
|
|
62
62
|
| `control.group` | non-negative integer gid or group name | unset | `chown` group applied to the socket after it binds, so a shared deploy group can use it. |
|
|
@@ -126,15 +126,30 @@ ownerRecovery: {reconnectGraceMs: 30000}
|
|
|
126
126
|
The private guardian socket is derived from `statePath`; the atomic state file is
|
|
127
127
|
written mode `0600` and contains its authentication capability. The guardian
|
|
128
128
|
owns managed child processes, restart policy, lifecycle hooks, and exit events.
|
|
129
|
-
After an unexpected daemon exit, an exact config/runtime replacement
|
|
130
|
-
guardian during `reconnectGraceMs
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
After an unexpected daemon exit, an exact config/runtime replacement may claim
|
|
130
|
+
the guardian during `reconnectGraceMs`. If none does, the guardian restarts the
|
|
131
|
+
exact accepted daemon command and environment itself, retains startup fencing
|
|
132
|
+
until that daemon publishes its ready listeners and PID file, and uses a nonzero
|
|
133
|
+
retry backoff after failed starts. The replacement restores active and draining
|
|
134
|
+
releases with their allocated ports and resumes proxy/control ownership.
|
|
135
|
+
An unfailed durable generation transition resumes under the guardian's mutation
|
|
136
|
+
fence after those listeners and the recovered PID are ready, so a valid long
|
|
137
|
+
lifecycle hook does not consume the daemon startup deadline. Status remains
|
|
138
|
+
available during replay while competing mutations and terminal owner operations
|
|
139
|
+
stay fenced. `SIGINT` and `SIGTERM` wait for that startup replay before beginning
|
|
140
|
+
clean shutdown. Transition snapshots carry a monotonic journal revision; the
|
|
141
|
+
public snapshot is considered only when it is strictly newer and contains every
|
|
142
|
+
retained transition and singleton-owning release, while private guardian state
|
|
143
|
+
remains authoritative on ties and for older released snapshots without a
|
|
144
|
+
journal revision.
|
|
145
|
+
Concurrent matching starts are fenced: one claims ownership and losers attest
|
|
146
|
+
that winner. The authenticated guardian's private committed state is
|
|
147
|
+
authoritative when the public snapshot is stale or partially written; missing or
|
|
148
|
+
corrupt guardian identity, authentication, or authority still fails closed
|
|
149
|
+
without rewriting the snapshot. Owner disconnection alone never reclaims
|
|
150
|
+
accepted work or transfers workers: guardian-owned processes and their
|
|
151
|
+
generation-local connections continue during the grace, so the replacement
|
|
152
|
+
reconnects to supervision rather than duplicating execution.
|
|
138
153
|
|
|
139
154
|
For a responsive incompatible owner, `ensure-daemon` prepares the requested
|
|
140
155
|
durable runtime, restores exact active and draining generation definitions from
|
|
@@ -163,6 +178,13 @@ identity; retry config or socket changes after the protocol upgrade, when the
|
|
|
163
178
|
normal atomic handoff applies. Other guardian/auth/transport/identity failures
|
|
164
179
|
remain fail-closed.
|
|
165
180
|
|
|
181
|
+
An intermediate guardian which already supports atomic owner replacement but
|
|
182
|
+
predates guardian-owned daemon recovery cannot be upgraded through that bridge.
|
|
183
|
+
The candidate aborts before listener handoff, the incumbent resumes any paused
|
|
184
|
+
drains and remains serving, and the command requests one explicit clean
|
|
185
|
+
`shutdown` followed by `ensure-daemon` so the new guardian can become the OS
|
|
186
|
+
supervisor.
|
|
187
|
+
|
|
166
188
|
Without `ownerRecovery`, `statePath` retains the advisory orphan behavior above.
|
|
167
189
|
|
|
168
190
|
## `legacyTakeover`
|
|
@@ -293,7 +315,9 @@ release id, path, revision, and config may resume only its incomplete idempotent
|
|
|
293
315
|
phase. Once the health-ready candidate is journaled, its exact config becomes the
|
|
294
316
|
transition authority even if a later hook fails. A recorded failed hook is not
|
|
295
317
|
retried merely because daemon ownership changes. Omit `activateCommand` to retain
|
|
296
|
-
the existing activate-then-retire behavior.
|
|
318
|
+
the existing activate-then-retire behavior. Adding, removing, or moving
|
|
319
|
+
`activateCommand` changes the daemon's generation coordinator and therefore
|
|
320
|
+
requires a daemon restart before the next deploy.
|
|
297
321
|
|
|
298
322
|
The synchronous traffic assignment is persisted as `committed_pending` before
|
|
299
323
|
Rollbridge awaits singleton replacement. Exact retry or unambiguous owner recovery
|
|
@@ -469,6 +493,6 @@ Rollbridge sets these in every managed process's environment (the process's own
|
|
|
469
493
|
- `restart.maxRestarts` must be a non-negative integer (omit it for unlimited restarts); `restart.backoffFactor` must be a number ≥ 1; `restart.windowMs` and `restart.maxDelayMs` must be non-negative numbers.
|
|
470
494
|
- When `memory` is set, `memory.limitBytes` must be a positive integer, `memory.warnBytes` a non-negative integer, and `memory.checkIntervalMs` a positive number.
|
|
471
495
|
- `replicas` must be a positive integer; `replicas > 1` is allowed only on a `companion` process without a `port`. Process ids must not contain `#` (reserved for replica instance ids).
|
|
472
|
-
- `lifecycle.activateCommand`/`quietCommand`/`drainCommand`/`stopCommand` must be strings when set, and `lifecycle.drainTimeoutMs` a non-negative number; `lifecycle.drainCommand` requires a positive `lifecycle.drainTimeoutMs`. `activateCommand` is allowed on at most one handoff service, requires that service's `quietCommand`, and requires `statePath` plus `ownerRecovery`. A `lifecycle.stopCommand` may not be combined with a custom `stopSignal` (the `stopCommand` runs instead of the signal, so the signal would be ignored).
|
|
496
|
+
- `lifecycle.activateCommand`/`quietCommand`/`drainCommand`/`stopCommand` must be non-empty strings when set, and `lifecycle.drainTimeoutMs` a non-negative number; `lifecycle.drainCommand` requires a positive `lifecycle.drainTimeoutMs`. `activateCommand` is allowed on at most one handoff service, requires that service's `quietCommand`, and requires `statePath` plus `ownerRecovery`. A `lifecycle.stopCommand` may not be combined with a custom `stopSignal` (the `stopCommand` runs instead of the signal, so the signal would be ignored).
|
|
473
497
|
- `nonBlockingDrain` must be a boolean, and is allowed only on a `companion` process.
|
|
474
498
|
- `statePath` must be a string when set.
|
package/docs/logging.md
CHANGED
|
@@ -15,7 +15,7 @@ daemon was started.
|
|
|
15
15
|
|
|
16
16
|
| How the daemon runs | Destination |
|
|
17
17
|
| --- | --- |
|
|
18
|
-
| `rollbridge daemon` (foreground) | stdout — redirect it (`rollbridge daemon … >> /var/log/rollbridge/app.log 2>&1`) or let your service manager capture it. |
|
|
18
|
+
| `rollbridge daemon` (foreground) | stdout — redirect it (`rollbridge daemon … >> /var/log/rollbridge/app.log 2>&1`) or let your service manager capture it. With `ownerRecovery`, guardian restarts inherit that same accepted destination. |
|
|
19
19
|
| systemd (`examples/rollbridge.service`) | the journal — `journalctl -u rollbridge`. journald rotates on its own. |
|
|
20
20
|
| `rollbridge ensure-daemon` / `rollbridge deploy --ensure-daemon` | the **daemon log file**: `--daemon-log-path <path>`, default `/tmp/rollbridge-<application>.log`. The detached daemon's stdout and stderr are appended there. |
|
|
21
21
|
|
|
@@ -46,8 +46,9 @@ protected accordingly.
|
|
|
46
46
|
|
|
47
47
|
Without `ownerRecovery`, both in-memory views clear when the daemon restarts.
|
|
48
48
|
With it, guardian-held process output remains available after reconnection while
|
|
49
|
-
the replacement daemon begins a new event history.
|
|
50
|
-
|
|
49
|
+
the replacement daemon begins a new event history. A detached ensured daemon
|
|
50
|
+
keeps appending to its configured log file, while a recovered foreground daemon
|
|
51
|
+
inherits its original stdout/stderr destination.
|
|
51
52
|
|
|
52
53
|
## Rotation
|
|
53
54
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -138,11 +138,13 @@ end close idle WebSockets on deploy). In the documented compliant jobs topology,
|
|
|
138
138
|
jobs companions use `nonBlockingDrain: true`, so timeout expiry affects only the
|
|
139
139
|
web side and must not stop a still-draining jobs generation.
|
|
140
140
|
|
|
141
|
-
`status.releaseReferences` reports active and draining releases
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
`status.releaseReferences` reports active and draining releases, plus a stopped
|
|
142
|
+
release that still owns a persistent service definition, pending singleton, or
|
|
143
|
+
unresolved generation transition, until all runtime ownership ends; Rampway
|
|
144
|
+
still owns enforcement against on-disk cleanup. With `ownerRecovery`, references
|
|
145
|
+
reconstruct across same-authority daemon process replacement and transfer across
|
|
146
|
+
an incompatible `ensure-daemon` owner handoff. They do not transfer through the
|
|
147
|
+
separate destructive `--takeover-owner` path. If
|
|
146
148
|
`retirementError` is set,
|
|
147
149
|
inspect the quiet-hook events. Rollbridge deliberately leaves that generation
|
|
148
150
|
alive rather than signaling arbitrary PIDs or continuing its stop sequence.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -41,37 +41,71 @@ 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
|
+
.addOption(new Option("--guardian-daemon-log-path <path>").hideHelp())
|
|
45
|
+
.addOption(new Option("--guardian-daemon-pid-path <path>").hideHelp())
|
|
46
|
+
.addOption(new Option("--guardian-daemon-start-timeout-ms <ms>").hideHelp())
|
|
44
47
|
.addOption(new Option("--legacy-incumbent-pid <pid>").hideHelp())
|
|
45
48
|
.action(async (options) => {
|
|
46
49
|
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
47
50
|
const configPath = await resolveConfigPath(options.config)
|
|
48
51
|
const config = await loadConfig(configPath)
|
|
49
52
|
const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
|
|
53
|
+
const recoveryEnvironment = /** @type {Record<string, string>} */ (Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)))
|
|
54
|
+
const recoveryLogPath = options.guardianDaemonLogPath ? path.resolve(options.guardianDaemonLogPath) : undefined
|
|
55
|
+
const recoveryPidPath = options.guardianDaemonPidPath ? path.resolve(options.guardianDaemonPidPath) : undefined
|
|
56
|
+
const recoveryStartupTimeoutMs = normalizeTimeoutMs(options.guardianDaemonStartTimeoutMs)
|
|
50
57
|
const daemon = new RollbridgeDaemon({
|
|
51
58
|
bootstrap,
|
|
52
59
|
config,
|
|
53
60
|
configPath,
|
|
54
61
|
legacyIncumbentPid: positiveIntegerOrUndefined(options.legacyIncumbentPid, "legacy incumbent pid"),
|
|
62
|
+
recoveryCommand: {
|
|
63
|
+
args: [
|
|
64
|
+
path.resolve(argv[1]), "daemon", "--config", configPath,
|
|
65
|
+
...(recoveryLogPath ? ["--guardian-daemon-log-path", recoveryLogPath] : []),
|
|
66
|
+
...(recoveryPidPath ? ["--guardian-daemon-pid-path", recoveryPidPath] : []),
|
|
67
|
+
"--guardian-daemon-start-timeout-ms", String(recoveryStartupTimeoutMs)
|
|
68
|
+
],
|
|
69
|
+
cwd: process.cwd(),
|
|
70
|
+
env: recoveryEnvironment,
|
|
71
|
+
executable: process.execPath,
|
|
72
|
+
logPath: recoveryLogPath,
|
|
73
|
+
pidPath: recoveryPidPath,
|
|
74
|
+
startupTimeoutMs: recoveryStartupTimeoutMs
|
|
75
|
+
},
|
|
55
76
|
runtime
|
|
56
77
|
})
|
|
78
|
+
let startupPromise = Promise.resolve()
|
|
79
|
+
let shutdownRequested = false
|
|
80
|
+
const shutdown = async () => {
|
|
81
|
+
shutdownRequested = true
|
|
82
|
+
await startupPromise.catch(() => undefined)
|
|
83
|
+
await daemon.shutdown()
|
|
84
|
+
process.exit(0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.once("SIGINT", () => { void shutdown() })
|
|
88
|
+
process.once("SIGTERM", () => { void shutdown() })
|
|
57
89
|
|
|
58
90
|
if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
|
|
59
91
|
|
|
60
92
|
if (options.replaceOwner) {
|
|
61
93
|
if (bootstrap || options.takeoverOwner) throw new Error("Daemon --replace-owner cannot be combined with bootstrap takeover options.")
|
|
62
|
-
|
|
94
|
+
startupPromise = daemon.replaceIncompatibleOwner()
|
|
95
|
+
await startupPromise
|
|
63
96
|
}
|
|
64
97
|
|
|
65
98
|
if (!options.takeoverOwner && !options.replaceOwner) {
|
|
66
99
|
try {
|
|
67
|
-
|
|
100
|
+
startupPromise = daemon.start({exposeControl: !bootstrap})
|
|
101
|
+
await startupPromise
|
|
68
102
|
} catch (error) {
|
|
69
103
|
if (!config.ownerRecovery) throw error
|
|
70
104
|
|
|
71
105
|
const winner = await sendControlCommand({command: {command: "status"}, path: config.control.path}).catch(() => undefined)
|
|
72
106
|
const matchingWinner = winner?.application === config.application &&
|
|
73
107
|
winner.ownerRecovery && typeof winner.ownerRecovery === "object" && !Array.isArray(winner.ownerRecovery) &&
|
|
74
|
-
winner.ownerRecovery.configDigest === daemon.ownerRecoveryConfigDigest() &&
|
|
108
|
+
winner.ownerRecovery.configDigest === daemon.ownerRecoveryConfigDigest() && winner.ownerRecovery.ready === true &&
|
|
75
109
|
((!runtime && !winner.daemonRuntime) || (runtime && winner.daemonRuntime && typeof winner.daemonRuntime === "object" && !Array.isArray(winner.daemonRuntime) && winner.daemonRuntime.digest === runtime.digest))
|
|
76
110
|
|
|
77
111
|
if (!matchingWinner) throw error
|
|
@@ -79,14 +113,7 @@ export async function runCli(argv) {
|
|
|
79
113
|
return
|
|
80
114
|
}
|
|
81
115
|
}
|
|
82
|
-
|
|
83
|
-
const shutdown = async () => {
|
|
84
|
-
await daemon.shutdown()
|
|
85
|
-
process.exit(0)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
process.once("SIGINT", () => { void shutdown() })
|
|
89
|
-
process.once("SIGTERM", () => { void shutdown() })
|
|
116
|
+
if (shutdownRequested) return
|
|
90
117
|
|
|
91
118
|
if (bootstrap) {
|
|
92
119
|
try {
|
|
@@ -102,8 +129,14 @@ export async function runCli(argv) {
|
|
|
102
129
|
|
|
103
130
|
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error", ...errorLogData(failure)})
|
|
104
131
|
|
|
105
|
-
if (config.ownerRecovery && daemon.
|
|
106
|
-
|
|
132
|
+
if (config.ownerRecovery && daemon.releases.size > 0) {
|
|
133
|
+
if (daemon.activeRelease) {
|
|
134
|
+
await daemon.exposeControl()
|
|
135
|
+
await publishDaemonReadiness(daemon, recoveryPidPath)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
await daemon.abandonOwnerRecoveryAttempt()
|
|
139
|
+
process.exitCode = 1
|
|
107
140
|
return
|
|
108
141
|
}
|
|
109
142
|
|
|
@@ -119,6 +152,7 @@ export async function runCli(argv) {
|
|
|
119
152
|
return
|
|
120
153
|
}
|
|
121
154
|
}
|
|
155
|
+
if (!shutdownRequested) await publishDaemonReadiness(daemon, recoveryPidPath)
|
|
122
156
|
})
|
|
123
157
|
|
|
124
158
|
program
|
|
@@ -834,6 +868,8 @@ async function validateDaemonBootstrapOptions(options) {
|
|
|
834
868
|
*/
|
|
835
869
|
async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtimePath, timeoutMs}) {
|
|
836
870
|
const runtime = await prepareDaemonRuntime(runtimePath || defaultDaemonRuntimePath(config))
|
|
871
|
+
const resolvedConfigPath = path.resolve(configPath)
|
|
872
|
+
const resolvedLogPath = path.resolve(logPath || defaultDaemonLogPath(config))
|
|
837
873
|
const existingStatus = await daemonStatus(config)
|
|
838
874
|
const expectedConfigDigest = ownerConfigDigest(config)
|
|
839
875
|
|
|
@@ -843,26 +879,30 @@ async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtim
|
|
|
843
879
|
existingStatus.ownerRecovery.configDigest === expectedConfigDigest
|
|
844
880
|
)
|
|
845
881
|
|
|
846
|
-
if (compatibleDaemonRuntime(existingStatus, runtime) && matchingRecoveryAuthority)
|
|
882
|
+
if (compatibleDaemonRuntime(existingStatus, runtime) && matchingRecoveryAuthority) {
|
|
883
|
+
if (!config.ownerRecovery || (existingStatus.ownerRecovery && typeof existingStatus.ownerRecovery === "object" && !Array.isArray(existingStatus.ownerRecovery) && existingStatus.ownerRecovery.ready === true)) return existingStatus
|
|
884
|
+
return await waitForDaemonStatus(config, timeoutMs, {configDigest: expectedConfigDigest, runtime})
|
|
885
|
+
}
|
|
847
886
|
if (!config.ownerRecovery) assertCompatibleDaemonRuntime(existingStatus, runtime)
|
|
848
887
|
}
|
|
849
888
|
|
|
850
889
|
const persistedState = config.ownerRecovery && config.statePath ? await readState(config.statePath) : undefined
|
|
851
890
|
const persistedOwner = persistedState && typeof persistedState === "object" && !Array.isArray(persistedState) ? persistedState : undefined
|
|
852
891
|
const persistedRecovery = persistedOwner?.recovery
|
|
853
|
-
const replacement = Boolean(config.ownerRecovery && (existingStatus || (persistedRecovery && typeof persistedRecovery === "object" && !Array.isArray(persistedRecovery)
|
|
854
|
-
|
|
855
|
-
))))
|
|
856
|
-
const resolvedPidPath = pidPath || defaultDaemonPidPath(config)
|
|
892
|
+
const replacement = Boolean(config.ownerRecovery && (existingStatus || (persistedRecovery && typeof persistedRecovery === "object" && !Array.isArray(persistedRecovery))))
|
|
893
|
+
const resolvedPidPath = path.resolve(pidPath || defaultDaemonPidPath(config))
|
|
857
894
|
const legacyIncumbentPid = replacement ? await readDaemonPid(resolvedPidPath) : undefined
|
|
858
895
|
|
|
859
896
|
await fsPromises.mkdir(path.dirname(resolvedPidPath), {recursive: true})
|
|
860
897
|
const candidate = await startDaemonProcess({
|
|
861
|
-
configPath,
|
|
862
|
-
|
|
898
|
+
configPath: resolvedConfigPath,
|
|
899
|
+
cwd: path.dirname(resolvedConfigPath),
|
|
900
|
+
logPath: resolvedLogPath,
|
|
901
|
+
pidPath: resolvedPidPath,
|
|
863
902
|
replacement,
|
|
864
903
|
legacyIncumbentPid,
|
|
865
|
-
runtime
|
|
904
|
+
runtime,
|
|
905
|
+
timeoutMs
|
|
866
906
|
})
|
|
867
907
|
let startedStatus
|
|
868
908
|
|
|
@@ -880,8 +920,11 @@ async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtim
|
|
|
880
920
|
)
|
|
881
921
|
|
|
882
922
|
if (!startedPid) throw new Error("Started Rollbridge daemon did not report its exact PID")
|
|
883
|
-
await fsPromises.writeFile(resolvedPidPath, `${startedPid}\n`)
|
|
923
|
+
if (!config.ownerRecovery) await fsPromises.writeFile(resolvedPidPath, `${startedPid}\n`)
|
|
884
924
|
return startedStatus
|
|
925
|
+
} catch (error) {
|
|
926
|
+
killDetachedProcessGroup(candidate)
|
|
927
|
+
throw error
|
|
885
928
|
} finally {
|
|
886
929
|
candidate.unref()
|
|
887
930
|
}
|
|
@@ -912,13 +955,16 @@ async function daemonStatus(config) {
|
|
|
912
955
|
* Starts the foreground daemon command as a detached child.
|
|
913
956
|
* @param {object} args - Options.
|
|
914
957
|
* @param {string} args.configPath - Config path.
|
|
958
|
+
* @param {string} args.cwd - Durable daemon working directory.
|
|
915
959
|
* @param {string} args.logPath - Log file path.
|
|
960
|
+
* @param {string} args.pidPath - PID file path.
|
|
916
961
|
* @param {boolean} args.replacement - Whether to run the incompatible replacement transaction.
|
|
917
962
|
* @param {number | undefined} args.legacyIncumbentPid - Exact incumbent recorded before candidate spawn.
|
|
918
963
|
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} args.runtime - Prepared runtime.
|
|
964
|
+
* @param {number} args.timeoutMs - Guardian recovery startup timeout.
|
|
919
965
|
* @returns {Promise<import("node:child_process").ChildProcess>} Referenced child after exact spawn completion.
|
|
920
966
|
*/
|
|
921
|
-
async function startDaemonProcess({configPath, legacyIncumbentPid, logPath, replacement = false, runtime}) {
|
|
967
|
+
async function startDaemonProcess({configPath, cwd, legacyIncumbentPid, logPath, pidPath, replacement = false, runtime, timeoutMs}) {
|
|
922
968
|
await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
|
|
923
969
|
|
|
924
970
|
const stdoutFd = fs.openSync(logPath, "a")
|
|
@@ -927,9 +973,13 @@ async function startDaemonProcess({configPath, legacyIncumbentPid, logPath, repl
|
|
|
927
973
|
try {
|
|
928
974
|
const child = spawn(process.execPath, [
|
|
929
975
|
path.join(runtime.path, "bin", "rollbridge"), "daemon", "--config", configPath,
|
|
976
|
+
"--guardian-daemon-log-path", logPath,
|
|
977
|
+
"--guardian-daemon-pid-path", pidPath,
|
|
978
|
+
"--guardian-daemon-start-timeout-ms", String(timeoutMs),
|
|
930
979
|
...(replacement ? ["--replace-owner"] : []),
|
|
931
980
|
...(legacyIncumbentPid ? ["--legacy-incumbent-pid", String(legacyIncumbentPid)] : [])
|
|
932
981
|
], {
|
|
982
|
+
cwd,
|
|
933
983
|
detached: true,
|
|
934
984
|
env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtime.path, "runtime.json")},
|
|
935
985
|
stdio: ["ignore", stdoutFd, stderrFd]
|
|
@@ -1021,11 +1071,10 @@ async function waitForDaemonStatus(config, timeoutMs, expected = {}) {
|
|
|
1021
1071
|
|
|
1022
1072
|
if (status) {
|
|
1023
1073
|
const statusPid = typeof status.daemonPid === "number" ? status.daemonPid : undefined
|
|
1024
|
-
const candidateResolved = !expected.candidate || candidateExit || statusPid === expected.candidate.pid
|
|
1025
|
-
|
|
1026
1074
|
if (expected.runtime && !compatibleDaemonRuntime(status, expected.runtime)) {
|
|
1027
1075
|
if (!expected.configDigest) assertCompatibleDaemonRuntime(status, expected.runtime)
|
|
1028
|
-
} else if (
|
|
1076
|
+
} else if (!expected.configDigest || (status.ownerRecovery && typeof status.ownerRecovery === "object" && !Array.isArray(status.ownerRecovery) && status.ownerRecovery.configDigest === expected.configDigest && status.ownerRecovery.ready === true)) {
|
|
1077
|
+
if (expected.candidate && !candidateExit && statusPid !== expected.candidate.pid) killDetachedProcessGroup(expected.candidate)
|
|
1029
1078
|
return status
|
|
1030
1079
|
}
|
|
1031
1080
|
}
|
|
@@ -1053,6 +1102,31 @@ async function waitForDaemonStatus(config, timeoutMs, expected = {}) {
|
|
|
1053
1102
|
}
|
|
1054
1103
|
}
|
|
1055
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* Publishes the exact daemon PID before confirming completed startup to its guardian.
|
|
1107
|
+
* @param {RollbridgeDaemon} daemon - Started daemon.
|
|
1108
|
+
* @param {string | undefined} pidPath - Optional daemon PID file.
|
|
1109
|
+
*/
|
|
1110
|
+
async function publishDaemonReadiness(daemon, pidPath) {
|
|
1111
|
+
if (daemon.guardian) {
|
|
1112
|
+
await daemon.markOwnerReady()
|
|
1113
|
+
} else if (pidPath) {
|
|
1114
|
+
await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
|
|
1115
|
+
await fsPromises.writeFile(pidPath, `${process.pid}\n`)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** @param {import("node:child_process").ChildProcess} child - Exact detached process-group leader. */
|
|
1120
|
+
function killDetachedProcessGroup(child) {
|
|
1121
|
+
if (!child.pid) return
|
|
1122
|
+
try {
|
|
1123
|
+
process.kill(-child.pid, "SIGKILL")
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
|
|
1126
|
+
throw error
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1056
1130
|
/**
|
|
1057
1131
|
* @param {import("node:child_process").ChildProcess} candidate - Exact spawned daemon candidate.
|
|
1058
1132
|
* @param {{code: number | null, signal: DaemonExitSignal}} exit - Exact exit status.
|
package/src/config.js
CHANGED
|
@@ -67,8 +67,12 @@ export async function parseConfigFile(configPath) {
|
|
|
67
67
|
*/
|
|
68
68
|
export async function loadConfig(configPath) {
|
|
69
69
|
const {absolutePath, rawConfig} = await parseConfigFile(configPath)
|
|
70
|
+
const config = normalizeConfig(rawConfig, absolutePath)
|
|
71
|
+
const configDirectory = path.dirname(absolutePath)
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
config.control.path = path.resolve(configDirectory, config.control.path)
|
|
74
|
+
if (config.statePath) config.statePath = path.resolve(configDirectory, config.statePath)
|
|
75
|
+
return config
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/**
|
|
@@ -351,10 +355,10 @@ function normalizeLifecycle(value, key, issues) {
|
|
|
351
355
|
/** @type {LifecycleConfig} */
|
|
352
356
|
const lifecycle = {drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)}
|
|
353
357
|
|
|
354
|
-
if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues)
|
|
355
|
-
if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues)
|
|
356
|
-
if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues)
|
|
357
|
-
if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues)
|
|
358
|
+
if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues, {nonEmpty: true})
|
|
359
|
+
if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues, {nonEmpty: true})
|
|
360
|
+
if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues, {nonEmpty: true})
|
|
361
|
+
if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues, {nonEmpty: true})
|
|
358
362
|
|
|
359
363
|
if (lifecycle.drainCommand !== undefined && lifecycle.drainTimeoutMs <= 0) {
|
|
360
364
|
issues.push({fix: `Set ${key}.drainTimeoutMs to a positive number to bound ${key}.drainCommand; with 0 the drain step is skipped and the command never runs.`, message: `${key}.drainCommand requires a positive ${key}.drainTimeoutMs`})
|
|
@@ -849,7 +853,7 @@ function normalizePortRange(value, key, issues) {
|
|
|
849
853
|
* @param {JsonValue} value - Raw value.
|
|
850
854
|
* @param {string} key - Config key.
|
|
851
855
|
* @param {ConfigIssue[]} issues - Issue collector.
|
|
852
|
-
* @param {{default?: string}} [options] - Options.
|
|
856
|
+
* @param {{default?: string, nonEmpty?: boolean}} [options] - Options.
|
|
853
857
|
* @returns {string} Normalized string, or a placeholder when invalid.
|
|
854
858
|
*/
|
|
855
859
|
function normalizeString(value, key, issues, options = {}) {
|
|
@@ -867,6 +871,10 @@ function normalizeString(value, key, issues, options = {}) {
|
|
|
867
871
|
return options.default ?? ""
|
|
868
872
|
}
|
|
869
873
|
|
|
874
|
+
if (options.nonEmpty && !value.trim()) {
|
|
875
|
+
issues.push({fix: `Set ${key} to a non-empty command string.`, message: `${key} must not be empty`})
|
|
876
|
+
}
|
|
877
|
+
|
|
870
878
|
return value
|
|
871
879
|
}
|
|
872
880
|
|