@crouter/api 0.3.387 → 0.3.388

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 (130) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/core/asset-root.d.ts +7 -0
  5. package/dist/core/asset-root.js +18 -0
  6. package/dist/core/canvas/boot-id.d.ts +6 -0
  7. package/dist/core/canvas/boot-id.js +26 -0
  8. package/dist/core/canvas/paths.d.ts +72 -0
  9. package/dist/core/canvas/paths.js +163 -0
  10. package/dist/core/canvas/pid.d.ts +391 -0
  11. package/dist/core/canvas/pid.js +948 -0
  12. package/dist/core/command-plugins/bundle.d.ts +149 -0
  13. package/dist/core/command-plugins/bundle.js +588 -0
  14. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  15. package/dist/core/command-plugins/endpoint.js +51 -0
  16. package/dist/core/config.d.ts +233 -0
  17. package/dist/core/config.js +1120 -0
  18. package/dist/core/env-name.d.ts +6 -0
  19. package/dist/core/env-name.js +9 -0
  20. package/dist/core/errors.d.ts +38 -0
  21. package/dist/core/errors.js +90 -0
  22. package/dist/core/events/emit.d.ts +6 -0
  23. package/dist/core/events/emit.js +42 -0
  24. package/dist/core/events/envelope.d.ts +2 -0
  25. package/dist/core/events/envelope.js +84 -0
  26. package/dist/core/events/errors.d.ts +4 -0
  27. package/dist/core/events/errors.js +69 -0
  28. package/dist/core/events/operation-id.d.ts +4 -0
  29. package/dist/core/events/operation-id.js +24 -0
  30. package/dist/core/events/serialize.d.ts +4 -0
  31. package/dist/core/events/serialize.js +199 -0
  32. package/dist/core/events/source.d.ts +16 -0
  33. package/dist/core/events/source.js +31 -0
  34. package/dist/core/events/types.d.ts +68 -0
  35. package/dist/core/events/types.js +11 -0
  36. package/dist/core/exclusive-lock.d.ts +34 -0
  37. package/dist/core/exclusive-lock.js +197 -0
  38. package/dist/core/fs-utils.d.ts +44 -0
  39. package/dist/core/fs-utils.js +208 -0
  40. package/dist/core/help.d.ts +309 -0
  41. package/dist/core/help.js +406 -0
  42. package/dist/core/human/page-catalog.d.ts +57 -0
  43. package/dist/core/human/page-catalog.js +172 -0
  44. package/dist/core/installed-plugins.d.ts +2 -0
  45. package/dist/core/installed-plugins.js +79 -0
  46. package/dist/core/io.d.ts +122 -0
  47. package/dist/core/io.js +373 -0
  48. package/dist/core/keybindings/attach-control.d.ts +49 -0
  49. package/dist/core/keybindings/attach-control.js +42 -0
  50. package/dist/core/keybindings/catalog.d.ts +18 -0
  51. package/dist/core/keybindings/catalog.js +257 -0
  52. package/dist/core/keybindings/types.d.ts +42 -0
  53. package/dist/core/keybindings/types.js +1 -0
  54. package/dist/core/layout.d.ts +26 -0
  55. package/dist/core/layout.js +94 -0
  56. package/dist/core/locked-file.d.ts +27 -0
  57. package/dist/core/locked-file.js +118 -0
  58. package/dist/core/log.d.ts +9 -0
  59. package/dist/core/log.js +89 -0
  60. package/dist/core/manifest.d.ts +5 -0
  61. package/dist/core/manifest.js +15 -0
  62. package/dist/core/plugin-env.d.ts +8 -0
  63. package/dist/core/plugin-env.js +31 -0
  64. package/dist/core/plugin-extensions.d.ts +29 -0
  65. package/dist/core/plugin-extensions.js +191 -0
  66. package/dist/core/plugin-swap-lock.d.ts +9 -0
  67. package/dist/core/plugin-swap-lock.js +31 -0
  68. package/dist/core/preview-result-path.d.ts +4 -0
  69. package/dist/core/preview-result-path.js +26 -0
  70. package/dist/core/profiles/env-store.d.ts +22 -0
  71. package/dist/core/profiles/env-store.js +163 -0
  72. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  73. package/dist/core/profiles/fuzzy-match.js +92 -0
  74. package/dist/core/profiles/manifest.d.ts +120 -0
  75. package/dist/core/profiles/manifest.js +529 -0
  76. package/dist/core/rate-limit-scope.d.ts +25 -0
  77. package/dist/core/rate-limit-scope.js +64 -0
  78. package/dist/core/render.d.ts +12 -0
  79. package/dist/core/render.js +138 -0
  80. package/dist/core/resolver.d.ts +14 -0
  81. package/dist/core/resolver.js +111 -0
  82. package/dist/core/runtime/branded-host.d.ts +25 -0
  83. package/dist/core/runtime/branded-host.js +264 -0
  84. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  85. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  86. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  87. package/dist/core/runtime/broker/signal-stream.js +149 -0
  88. package/dist/core/scope.d.ts +32 -0
  89. package/dist/core/scope.js +184 -0
  90. package/dist/core/scoped-state/db.d.ts +17 -0
  91. package/dist/core/scoped-state/db.js +247 -0
  92. package/dist/core/scoped-state/migrate.d.ts +8 -0
  93. package/dist/core/scoped-state/migrate.js +187 -0
  94. package/dist/core/scoped-state/paths.d.ts +9 -0
  95. package/dist/core/scoped-state/paths.js +27 -0
  96. package/dist/core/scoped-state/profiles.d.ts +27 -0
  97. package/dist/core/scoped-state/profiles.js +93 -0
  98. package/dist/core/scoped-state/providers.d.ts +24 -0
  99. package/dist/core/scoped-state/providers.js +19 -0
  100. package/dist/core/scoped-state/schema.d.ts +6 -0
  101. package/dist/core/scoped-state/schema.js +43 -0
  102. package/dist/core/scoped-state/settings.d.ts +28 -0
  103. package/dist/core/scoped-state/settings.js +83 -0
  104. package/dist/core/spaces/open-beneath.d.ts +71 -0
  105. package/dist/core/spaces/open-beneath.js +581 -0
  106. package/dist/core/sqlite-statements.d.ts +4 -0
  107. package/dist/core/sqlite-statements.js +17 -0
  108. package/dist/core/subscription-state.d.ts +121 -0
  109. package/dist/core/subscription-state.js +287 -0
  110. package/dist/core/user-settings.d.ts +377 -0
  111. package/dist/core/user-settings.js +458 -0
  112. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  113. package/dist/daemon/broker-signals/bus.js +87 -0
  114. package/dist/daemon/manage.d.ts +176 -0
  115. package/dist/daemon/manage.js +664 -0
  116. package/dist/daemon/pidfile.d.ts +8 -0
  117. package/dist/daemon/pidfile.js +37 -0
  118. package/dist/daemon/startup-policy.d.ts +1 -0
  119. package/dist/daemon/startup-policy.js +1 -0
  120. package/dist/native/linux.d.ts +29 -0
  121. package/dist/native/linux.js +20 -0
  122. package/dist/shared/env.d.ts +116 -0
  123. package/dist/shared/env.js +271 -0
  124. package/dist/shared/inbox-entry-body.d.ts +22 -0
  125. package/dist/shared/inbox-entry-body.js +116 -0
  126. package/dist/shared/working-activity.d.ts +9 -0
  127. package/dist/shared/working-activity.js +27 -0
  128. package/dist/types.d.ts +562 -0
  129. package/dist/types.js +186 -0
  130. package/package.json +1 -1
@@ -0,0 +1,664 @@
1
+ // Daemon management helpers — importable without the full command tree.
2
+ //
3
+ // spawnDaemon() is the low-level spawn call shared by `crtr sys daemon start` and
4
+ // ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
5
+ // door helper called by the canvas runtime before spawning child nodes.
6
+ import { spawn, execFileSync } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import { existsSync, lstatSync, mkdirSync, openSync, closeSync, fstatSync, readSync } from 'node:fs';
11
+ import { crtrHome } from '../core/canvas/paths.js';
12
+ import { listProcessTable } from '../core/canvas/pid.js';
13
+ import { bindDaemonEventSource, eventSource } from '../core/events/source.js';
14
+ import { hostExecPath } from '../core/runtime/branded-host.js';
15
+ // Pulled from the lean db-free pidfile module (NOT crtrd.js, whose module graph
16
+ // reaches openDb) so the CLI daemon front door stays canvas.db-free (plan B-0).
17
+ import { isDaemonRunning, readPidfile, isPidAlive } from './pidfile.js';
18
+ import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
19
+ import { exclusiveLockOwnerPid } from '../core/exclusive-lock.js';
20
+ import { onDiskMigrationLockPath } from '../core/canvas/paths.js';
21
+ import { envNoDaemonAutostart } from '../shared/env.js';
22
+ import { waitForDaemonAvailability } from '../api/index.js';
23
+ import { localClient } from '../api/node-transport.js';
24
+ // Daemon env sanitization
25
+ /** Env keys that must NEVER reach the daemon process. Restarting crtrd is
26
+ * overwhelmingly done from inside an agent node's own bash tool (`crtr sys
27
+ * daemon stop && start`), which inherits that node's FULL env — its identity
28
+ * (`CRTR_NODE_ID`/`CRTR_KIND`/…, the `nodeEnv()` shape in `core/runtime/
29
+ * nodes.ts`), its front-door recursion-guard flag, and any pi-engine
30
+ * resolution seam a prior test/dev session left exported. The daemon is a
31
+ * singleton supervisor, never "a node" itself, so none of this belongs in its
32
+ * env regardless of whether today's code happens to read it — and at least one
33
+ * of these IS actively read: `host.ts`'s broker-engine resolution falls back to
34
+ * `envBrokerEngine()`'s raw `CRTR_BROKER_ENGINE` read verbatim (nodeEnv() never
35
+ * sets that key, so nothing overrides it per child launch), so a daemon that inherits a
36
+ * stale/dev override throws inside `headlessBrokerHost.launch()` on EVERY
37
+ * relaunch it ever attempts, for its whole lifetime — the 2026-07-06 diagnosis
38
+ * root cause behind 19 nodes killed with "failed to relaunch and is now dead". */
39
+ export const DAEMON_ENV_STRIP_KEYS = [
40
+ // Node identity (nodeEnv() shape) — meaningless for a process supervising
41
+ // many nodes rather than being one.
42
+ 'CRTR_NODE_ID',
43
+ 'CRTR_KIND',
44
+ 'CRTR_MODE',
45
+ 'CRTR_LIFECYCLE',
46
+ 'CRTR_NODE_CWD',
47
+ 'CRTR_CONTEXT_DIR',
48
+ 'CRTR_CYCLES',
49
+ 'CRTR_PROFILE_ID',
50
+ 'CRTR_PARENT_NODE_ID',
51
+ // Recursion-guard flag — only meaningful inside a pi engine process.
52
+ 'CRTR_FRONT_DOOR',
53
+ // Ambient tmux identity of whatever shell first autostarted the daemon. The
54
+ // daemon is a headless supervisor with no pane of its own, but `spawnNode`
55
+ // reads `currentTmux()` for a --root's foreground viewer placement; a stale
56
+ // inherited $TMUX makes that resolve to an arbitrary "current" pane, so a
57
+ // front-door root (created through crtrd) opens a SECOND viewer window and
58
+ // focuses it — stealing focus from the front door's own inline attach in the
59
+ // user's pane.
60
+ // Strip them so the daemon is truly paneless and leaves root-viewer placement
61
+ // to the front door. Explicit `-t <target>` tmux ops (focus/window mgmt) are
62
+ // unaffected — the tmux CLI reaches the server via its default socket.
63
+ 'TMUX',
64
+ 'TMUX_PANE',
65
+ // Engine-resolution seams NOT overridden per child launch — the proven and
66
+ // suspected poison vectors.
67
+ 'CRTR_BROKER_ENGINE',
68
+ 'CRTR_PI_BINARY',
69
+ // Generic Node.js env poisoning (a stray dev/debug flag from the restarting
70
+ // shell).
71
+ 'NODE_OPTIONS',
72
+ ];
73
+ /** A copy of `process.env` with every `DAEMON_ENV_STRIP_KEYS` entry removed.
74
+ * Deliberate global config the user actually wants the daemon to see —
75
+ * `CRTR_HOME`, `CRTR_SUBTREE`, `CRTR_LOG`, `CRTR_DEBUG`, etc. — passes through
76
+ * untouched; only the node-identity/engine-poisoning surface is stripped. */
77
+ export function sanitizedDaemonEnv() {
78
+ const env = { ...process.env };
79
+ for (const key of DAEMON_ENV_STRIP_KEYS)
80
+ delete env[key];
81
+ return env;
82
+ }
83
+ // Entry point resolution
84
+ /** Locate the package or generation root from a regular build, the standalone
85
+ * attach bundle, or a tsx source checkout. */
86
+ function generationRoot() {
87
+ for (let current = dirname(fileURLToPath(import.meta.url));; current = dirname(current)) {
88
+ if (existsSync(join(current, 'bin', 'runtime-generation-manifest.mjs')))
89
+ return current;
90
+ const parent = dirname(current);
91
+ if (parent === current)
92
+ throw new Error(`cannot locate the crouter generation root from ${import.meta.url}`);
93
+ }
94
+ }
95
+ /** Resolve crtrd's launch argv through the same selector as `bin/crtrd`. The
96
+ * selector may choose an ABI-compatible sibling of `runtime/selected`, but
97
+ * never the caller's pinned generation. With no selection, a source checkout
98
+ * launches through tsx; every other selection failure remains fatal. */
99
+ async function resolveDaemonLaunch() {
100
+ const root = generationRoot();
101
+ const manifestUrl = pathToFileURL(join(root, 'bin', 'runtime-generation-manifest.mjs')).href;
102
+ const { runtimeHome } = (await import(manifestUrl));
103
+ try {
104
+ lstatSync(join(runtimeHome(), 'selected'));
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'ENOENT')
108
+ throw error;
109
+ const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm')).href;
110
+ // `crtr-src` maps the package's `#` subpath imports to src/*.ts; without it
111
+ // a source-checkout launch would silently resolve them into a stale dist/.
112
+ return ['--conditions', 'crtr-src', '--import', tsxLoader, join(root, 'src', 'daemon', 'crtrd-cli.ts')];
113
+ }
114
+ const selectorUrl = pathToFileURL(join(root, 'bin', 'runtime-selector.mjs')).href;
115
+ const { tryResolveRuntime } = (await import(selectorUrl));
116
+ const result = tryResolveRuntime('daemon');
117
+ if (!result.ok) {
118
+ throw new Error(`cannot resolve the selected runtime generation's daemon entry: ${result.error}`);
119
+ }
120
+ return [result.runtime.entryPath];
121
+ }
122
+ // Stray-daemon sweep
123
+ /** A crtrd declares its owning canvas in argv, so ownership is an exact match
124
+ * on this process's canvas home. Untagged daemons retain their own
125
+ * supervision and are never counted or killed here. */
126
+ export function daemonCommandDeclaresCanvasHome(command, home) {
127
+ return command.includes(` --canvas-home ${home} `) || command.endsWith(` --canvas-home ${home}`);
128
+ }
129
+ /** Every crtrd process on this host that declares ownership of this process's
130
+ * canvas home, by pid. The `daemon/crtrd` argv segment distinguishes daemons
131
+ * from brokers. `ps` is best-effort: failures return no matches, and the
132
+ * current process is always excluded. */
133
+ export function findDaemonPids() {
134
+ const processes = listProcessTable();
135
+ if (processes === null)
136
+ return [];
137
+ const home = crtrHome();
138
+ const pids = [];
139
+ for (const { pid, command } of processes) {
140
+ if (!command.includes('daemon/crtrd'))
141
+ continue;
142
+ if (!daemonCommandDeclaresCanvasHome(command, home))
143
+ continue; // another install/canvas owns it
144
+ if (pid !== process.pid)
145
+ pids.push(pid);
146
+ }
147
+ return pids;
148
+ }
149
+ /** SIGTERM (then SIGKILL after a short grace) every crtrd daemon process EXCEPT
150
+ * `keepPid`. This is the backstop the pidfile alone cannot provide: a daemon
151
+ * that survived a prior stop (ignored SIGTERM, or released its ownership lock
152
+ * without exiting) keeps running a supervise loop — reviving brokers and
153
+ * contending for canvas state — while never appearing in the pidfile again, so
154
+ * `stop`'s single recorded-pid signal can never reach it. Returns the pids it
155
+ * reaped. Best-effort throughout: a pid that dies between enumeration and signal
156
+ * just no-ops. */
157
+ export function sweepStrayDaemons(keepPid) {
158
+ const strays = findDaemonPids().filter((pid) => pid !== keepPid);
159
+ for (const pid of strays) {
160
+ try {
161
+ process.kill(pid, 'SIGTERM');
162
+ }
163
+ catch { /* already gone */ }
164
+ }
165
+ if (strays.length === 0)
166
+ return strays;
167
+ // Give SIGTERM handlers a beat, then SIGKILL any that ignored it — a stray
168
+ // daemon that hung its shutdown is exactly the failure mode this exists for.
169
+ const deadline = Date.now() + 2_000;
170
+ while (Date.now() < deadline) {
171
+ if (!strays.some((pid) => isPidAlive(pid)))
172
+ break;
173
+ execFileSync('sleep', ['0.1']);
174
+ }
175
+ for (const pid of strays) {
176
+ if (isPidAlive(pid)) {
177
+ try {
178
+ process.kill(pid, 'SIGKILL');
179
+ }
180
+ catch { /* already gone */ }
181
+ }
182
+ }
183
+ return strays;
184
+ }
185
+ // daemon launch — low-level spawn/exec
186
+ /** Replace PID 1 with the selected daemon generation. Docker keeps a container
187
+ * alive only while PID 1 lives, so a handover there must preserve its pid. */
188
+ export async function execDaemon() {
189
+ const execve = process.execve;
190
+ if (execve === undefined) {
191
+ throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);
192
+ }
193
+ const executable = hostExecPath();
194
+ const launchArgs = await resolveDaemonLaunch();
195
+ return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
196
+ }
197
+ // Shutdown stays short even though startup may include corpus migration.
198
+ const DAEMON_SHUTDOWN_WINDOW_MS = 20_000;
199
+ const DAEMON_VERIFY_POLL_MS = 10;
200
+ /** A status/start readiness probe must not hang behind a daemon that accepts a
201
+ * Unix connection but never answers it. */
202
+ const DAEMON_HEALTH_PROBE_TIMEOUT_MS = 1_000;
203
+ /** Make one `/healthz` observation without client-side recovery. The caller owns the availability window, so this probe cannot extend it. */
204
+ export async function probeDaemonHealth(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
205
+ const probeTimeoutMs = Math.min(timeoutMs, DAEMON_HEALTH_PROBE_TIMEOUT_MS);
206
+ return localClient({ autostart: false, timeoutMs: probeTimeoutMs }).probeHealthz(probeTimeoutMs);
207
+ }
208
+ export function probeErrorMessage(error) {
209
+ const message = error instanceof Error ? error.message : String(error);
210
+ if (typeof error !== 'object' || error === null)
211
+ return message;
212
+ const direct = error.code;
213
+ const cause = error.cause;
214
+ const code = typeof direct === 'string' ? direct : cause?.code;
215
+ return typeof code === 'string' && !message.includes(code) ? `${message}: ${code}` : message;
216
+ }
217
+ /** True only when one mandatory Unix API socket probe answers the existing ready health contract. */
218
+ export async function probeDaemonServing(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
219
+ const health = await probeDaemonHealth(timeoutMs);
220
+ if (health.startup_blocked !== undefined) {
221
+ const pid = readPidfile() ?? process.pid;
222
+ throw new DaemonStartupBlockedError(pid, health.startup_blocked);
223
+ }
224
+ if (!health.ok)
225
+ throw new Error('crtrd health check reported not ready');
226
+ }
227
+ /** True only when one mandatory Unix API socket probe answers `/healthz`.
228
+ * A pidfile identifies an owner; it is never readiness. */
229
+ export async function isDaemonServing() {
230
+ try {
231
+ await probeDaemonServing();
232
+ return true;
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ }
238
+ function sleepMs(ms) {
239
+ return new Promise((resolve) => {
240
+ setTimeout(resolve, ms);
241
+ });
242
+ }
243
+ /** The spawned daemon process died before it ever became ready — a startup
244
+ * failure, not a progressing migration (a live daemon never raises this).
245
+ * `stderrTail` is what that run wrote to crtrd.err, so the CLI can say why. */
246
+ export class DaemonExitedBeforeReadyError extends Error {
247
+ stderrTail;
248
+ constructor(message, stderrTail) {
249
+ super(stderrTail === '' ? message : `${message}\n${stderrTail}`);
250
+ this.name = 'DaemonExitedBeforeReadyError';
251
+ this.stderrTail = stderrTail;
252
+ }
253
+ }
254
+ /** A live daemon has bound its repair socket but cannot serve normal work
255
+ * until its corpus migration succeeds. */
256
+ export class DaemonStartupBlockedError extends Error {
257
+ constructor(pid, message) {
258
+ super(`daemon ${pid} is running but normal startup is blocked: ${message}`);
259
+ this.name = 'DaemonStartupBlockedError';
260
+ }
261
+ }
262
+ /** A preparation request cannot succeed unless the live daemon explicitly reports prepared. */
263
+ export class DaemonNotPreparedError extends Error {
264
+ constructor(pid, startupPhase) {
265
+ super(startupPhase === undefined
266
+ ? `daemon ${pid} is serving but did not report the prepared startup phase`
267
+ : `daemon ${pid} reported startup phase ${startupPhase}, not prepared`);
268
+ this.name = 'DaemonNotPreparedError';
269
+ }
270
+ }
271
+ /** Tiny bounded post-spawn guard: wait for the daemon pidfile and live pid to
272
+ * appear before reporting success. This catches the "started:true but not yet
273
+ * plausibly alive" race without turning startup into a retry loop.
274
+ *
275
+ * One deadline, owner-first polling. A signal or non-zero child exit fails
276
+ * immediately. A clean code-0 exit is treated only as a possible ownership-
277
+ * claim loser (never readiness on its own): polling continues on the SAME
278
+ * deadline for a different live pidfile owner — the winner may publish its
279
+ * pidfile deliberately late — and fails at that deadline if none appears. No
280
+ * deadline extension and no retry spawn.
281
+ *
282
+ * Returns null when the spawned pid owns the pidfile; returns a different live
283
+ * daemon pid when startup lost the race to an already-running daemon. */
284
+ export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_STARTUP_WINDOW_MS, deps = {}, targetPhase = 'ready') {
285
+ const read = deps.readPidfile ?? readPidfile;
286
+ const alive = deps.isPidAlive ?? isPidAlive;
287
+ const exited = deps.childExited;
288
+ const serving = deps.isDaemonServing;
289
+ const probeServing = deps.probeDaemonServing ?? (async (remainingMs) => {
290
+ if (serving !== undefined) {
291
+ if (await serving())
292
+ return;
293
+ throw new Error('crtrd health check reported not ready');
294
+ }
295
+ await probeDaemonServing(remainingMs);
296
+ });
297
+ const probeHealth = deps.probeDaemonHealth ?? probeDaemonHealth;
298
+ const sleep = deps.sleepMs ?? sleepMs;
299
+ const now = deps.now ?? Date.now;
300
+ let owner = null;
301
+ let terminalError = null;
302
+ await waitForDaemonAvailability({
303
+ windowMs: timeoutMs,
304
+ pollIntervalMs: DAEMON_VERIFY_POLL_MS,
305
+ now,
306
+ sleep,
307
+ retry: (error) => error !== terminalError,
308
+ probe: async (remainingMs) => {
309
+ const candidate = read();
310
+ if (candidate === null || !alive(candidate)) {
311
+ const exitState = exited?.();
312
+ if (exitState !== null && exitState !== undefined) {
313
+ if (exitState.signal !== null)
314
+ terminalError = new DaemonExitedBeforeReadyError(`daemon ${pid} exited before becoming ready (by ${exitState.signal})`, deps.childStderrTail?.() ?? '');
315
+ else if (exitState.code !== 0)
316
+ terminalError = new DaemonExitedBeforeReadyError(`daemon ${pid} exited before becoming ready (with exit code ${exitState.code ?? '?'})`, deps.childStderrTail?.() ?? '');
317
+ if (terminalError !== null)
318
+ throw terminalError;
319
+ }
320
+ throw new Error(`daemon ${pid} has no live pidfile owner`);
321
+ }
322
+ if (targetPhase === 'ready') {
323
+ try {
324
+ await probeServing(remainingMs);
325
+ }
326
+ catch (error) {
327
+ if (error instanceof DaemonStartupBlockedError)
328
+ terminalError = error;
329
+ throw error;
330
+ }
331
+ owner = candidate;
332
+ return;
333
+ }
334
+ const health = await probeHealth(remainingMs);
335
+ if (health.startup_phase === 'prepared') {
336
+ owner = candidate;
337
+ return;
338
+ }
339
+ if (health.startup_phase === 'blocked') {
340
+ terminalError = new DaemonStartupBlockedError(candidate, health.startup_blocked ?? 'on-disk migration is blocked');
341
+ throw terminalError;
342
+ }
343
+ if (health.startup_phase === 'ready') {
344
+ terminalError = new DaemonNotPreparedError(candidate, health.startup_phase);
345
+ throw terminalError;
346
+ }
347
+ throw new Error(`daemon ${candidate} is ${health.startup_phase ?? 'not reporting a startup phase'}; waiting for prepared`);
348
+ },
349
+ });
350
+ return owner === pid ? null : owner;
351
+ }
352
+ /** Wait until a signaled daemon has exited and relinquished its pidfile. This
353
+ * makes a following start safe to claim the singleton rather than racing its
354
+ * previous owner. */
355
+ export async function waitForDaemonExit(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
356
+ const read = deps.readPidfile ?? readPidfile;
357
+ const alive = deps.isPidAlive ?? isPidAlive;
358
+ const sleep = deps.sleepMs ?? sleepMs;
359
+ const now = deps.now ?? Date.now;
360
+ const deadline = now() + timeoutMs;
361
+ while (now() <= deadline) {
362
+ if (!alive(pid) && read() !== pid)
363
+ return;
364
+ await sleep(DAEMON_VERIFY_POLL_MS);
365
+ }
366
+ throw new Error(`daemon ${pid} did not exit and release its pidfile within ${timeoutMs}ms`);
367
+ }
368
+ /** How long a SIGKILL'd daemon gets to actually leave the process table before
369
+ * `stopDaemonProcess` gives up. A killed process is reaped by the kernel, so
370
+ * this only absorbs scheduling latency (or an uninterruptible wait). */
371
+ const DAEMON_KILL_WINDOW_MS = 2_000;
372
+ /** How long a failed-to-start daemon gets to go down before the reap gives up.
373
+ * Short on purpose: nothing is waiting on this process any more. */
374
+ const DAEMON_REAP_WINDOW_MS = 5_000;
375
+ /** Stop the RECORDED daemon: SIGTERM, and if it does not exit within the wait
376
+ * window, escalate to SIGKILL exactly as `sweepStrayDaemons` does for a stray.
377
+ *
378
+ * The escalation is the point. Without it — SIGTERM, wait, throw on timeout,
379
+ * never reaching the stray sweep — the one daemon `stop` is actually aimed at
380
+ * would be the only crtrd process exempt from the SIGKILL backstop, and a
381
+ * daemon whose shutdown hangs (or outruns the window) becomes permanently
382
+ * unstoppable from the CLI: every retry re-sends a SIGTERM it is already
383
+ * ignoring, and `start` keeps refusing as "already running".
384
+ *
385
+ * A SIGKILL'd daemon runs no exit handler, so it leaves its pidfile behind
386
+ * pointing at a dead pid. That is a stale record, not an owner: `readPidfile`
387
+ * callers gate on `isPidAlive`, and the next `start` overwrites it. So this
388
+ * waits on process death alone after a kill, never on pidfile release.
389
+ *
390
+ * Returns how the daemon went down, for a truthful report. */
391
+ export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
392
+ const alive = deps.isPidAlive ?? isPidAlive;
393
+ const sleep = deps.sleepMs ?? sleepMs;
394
+ const now = deps.now ?? Date.now;
395
+ try {
396
+ process.kill(pid, 'SIGTERM');
397
+ }
398
+ catch { /* already gone */ }
399
+ try {
400
+ await waitForDaemonExit(pid, timeoutMs, deps);
401
+ return 'terminated';
402
+ }
403
+ catch {
404
+ // Ignored or hung SIGTERM — the exact failure the stray sweep already kills for.
405
+ }
406
+ try {
407
+ process.kill(pid, 'SIGKILL');
408
+ }
409
+ catch { /* already gone */ }
410
+ const deadline = now() + DAEMON_KILL_WINDOW_MS;
411
+ while (now() <= deadline) {
412
+ if (!alive(pid))
413
+ return 'killed';
414
+ await sleep(DAEMON_VERIFY_POLL_MS);
415
+ }
416
+ throw new Error(`daemon ${pid} survived both SIGTERM and SIGKILL`);
417
+ }
418
+ /** The last `maxBytes` of a log from byte `start` onward, trimmed; '' when it
419
+ * cannot be read. Bounded so a runaway log can never flood an error message. */
420
+ function readLogTailFrom(path, start, maxBytes = 2_048) {
421
+ let fd;
422
+ try {
423
+ fd = openSync(path, 'r');
424
+ const { size } = fstatSync(fd);
425
+ const from = Math.max(start, size - maxBytes);
426
+ const length = size - from;
427
+ if (length <= 0)
428
+ return '';
429
+ const buffer = Buffer.alloc(length);
430
+ readSync(fd, buffer, 0, length, from);
431
+ return buffer.toString('utf8').trim();
432
+ }
433
+ catch {
434
+ return '';
435
+ }
436
+ finally {
437
+ if (fd !== undefined) {
438
+ try {
439
+ closeSync(fd);
440
+ }
441
+ catch { /* best-effort */ }
442
+ }
443
+ }
444
+ }
445
+ /** Spawn crtrd detached. Returns immediately; the child outlives this process.
446
+ *
447
+ * If the daemon is already running, returns {started:false, existing_pid}.
448
+ * If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
449
+ export async function spawnDaemon(opts = {}) {
450
+ if (process.platform === 'linux' && existsSync('/run/crtr/launcher.sock') && !isPidAlive(readPidfile() ?? -1)) {
451
+ throw new Error('crtrd is managed by the root launcher; it cannot self-daemonize');
452
+ }
453
+ const targetPhase = opts.targetPhase ?? 'ready';
454
+ const recordedPid = readPidfile();
455
+ if (recordedPid !== null && isPidAlive(recordedPid)) {
456
+ try {
457
+ const health = await probeDaemonHealth();
458
+ if (targetPhase === 'prepared') {
459
+ if (health.startup_phase === 'prepared') {
460
+ return { started: false, existing_pid: recordedPid, startup_phase: health.startup_phase };
461
+ }
462
+ throw new DaemonNotPreparedError(recordedPid, health.startup_phase);
463
+ }
464
+ if (health.ok) {
465
+ return {
466
+ started: false,
467
+ existing_pid: recordedPid,
468
+ ...(health.startup_phase === undefined ? {} : { startup_phase: health.startup_phase }),
469
+ };
470
+ }
471
+ return {
472
+ started: false,
473
+ existing_pid: recordedPid,
474
+ running: true,
475
+ serving: false,
476
+ probe_error: health.startup_blocked ?? 'crtrd health check reported not ready',
477
+ ...(health.startup_phase === undefined ? {} : { startup_phase: health.startup_phase }),
478
+ };
479
+ }
480
+ catch (error) {
481
+ if (error instanceof DaemonNotPreparedError)
482
+ throw error;
483
+ return {
484
+ started: false,
485
+ existing_pid: recordedPid,
486
+ running: true,
487
+ serving: false,
488
+ probe_error: probeErrorMessage(error),
489
+ };
490
+ }
491
+ }
492
+ // Ensure the canvas home directory exists so the daemon can write its pidfile.
493
+ mkdirSync(crtrHome(), { recursive: true });
494
+ // Launch from the crouter-branded host binary so the daemon shows "crouter"
495
+ // in macOS Full Disk Access, not "node" (see branded-host). NOTE: when crtrd
496
+ // is launchd-owned, the plist's ProgramArguments must point at the branded
497
+ // binary too — this path only covers a manual `crtr sys daemon start`.
498
+ const launchArgs = await resolveDaemonLaunch();
499
+ // Route stdout+stderr to an append-mode file instead of discarding them
500
+ // (stdio:'ignore'). This fd is RAW RESIDUE only — third-party/runtime
501
+ // output the daemon doesn't control, plus the narrow set of authorized raw
502
+ // `[crtrd] …` bootstrap/fatal lines that by construction predate or escape
503
+ // canonical event emission (the pre-bind losing-claim line and a cleanup-
504
+ // fatal write in crtrd.ts). Without this fd all of it is discarded for any
505
+ // manually-started daemon, the common case (only a launchd-owned daemon has a
506
+ // logging plist). Every other daemon diagnostic — including per-node
507
+ // supervise/relaunch failures — is a canonical `emitEvent` write, not raw
508
+ // stderr; this file is not where those live. Mirrors host.ts's broker.log
509
+ // pattern: one fd, both streams, append so a restart keeps history.
510
+ const errLogPath = join(crtrHome(), 'crtrd.err');
511
+ const errFd = openSync(errLogPath, 'a');
512
+ // crtrd.err is append-only across restarts; remember where THIS run starts so
513
+ // a failure can quote only what this run wrote.
514
+ const errLogStart = (() => { try {
515
+ return fstatSync(errFd).size;
516
+ }
517
+ catch {
518
+ return 0;
519
+ } })();
520
+ if (eventSource() === undefined)
521
+ bindDaemonEventSource();
522
+ // `--canvas-home` declares this daemon's canvas ownership in argv so
523
+ // findDaemonPids()/sweepStrayDaemons (above) can match its own daemon
524
+ // exactly instead of scanning the whole machine. crtrd-cli.ts's argv
525
+ // parsing only looks for `--tcp`, so this extra pair of tokens is inert
526
+ // there — it exists purely for `ps` visibility.
527
+ const child = spawn(hostExecPath(), [...launchArgs, '--canvas-home', crtrHome(), ...(targetPhase === 'prepared' ? ['--prepare'] : [])], {
528
+ detached: true,
529
+ stdio: ['ignore', errFd, errFd],
530
+ env: sanitizedDaemonEnv(),
531
+ });
532
+ // The child holds its own dup of the fd; release the parent's copy so the
533
+ // launching process never leaks it.
534
+ closeSync(errFd);
535
+ const pid = child.pid;
536
+ if (pid === undefined) {
537
+ throw new Error('daemon spawn did not return a pid');
538
+ }
539
+ let exitState = null;
540
+ child.once('exit', (code, signal) => {
541
+ exitState = { code, signal };
542
+ });
543
+ child.unref();
544
+ // Reap on failure. verifyDaemonStartup gives up at its deadline but the child
545
+ // it was watching is still running — and a daemon that failed to become ready
546
+ // is typically parked deep in startup (blocked on the migration lock, whose
547
+ // own window is DAEMON_STARTUP_WINDOW_MS), so it lingers long after the
548
+ // spawner walked away. Leaving it is what let repeated autostarts ACCUMULATE
549
+ // instead of merely repeating: each attempt added a resident process that
550
+ // outlived the attempt, and the machine went down under the pile rather than
551
+ // under any single failure.
552
+ let existingPid;
553
+ try {
554
+ existingPid = await verifyDaemonStartup(pid, DAEMON_STARTUP_WINDOW_MS, {
555
+ childExited: () => exitState,
556
+ childStderrTail: () => readLogTailFrom(errLogPath, errLogStart),
557
+ }, targetPhase);
558
+ }
559
+ catch (error) {
560
+ // Reap ONLY a child that is not doing the work. A child holding the on-disk
561
+ // migration lock is mid-corpus-rewrite: the reap escalates to SIGKILL, and
562
+ // killing a migration partway through is far worse than the resident
563
+ // process the reap exists to avoid. Startup verification giving up is a
564
+ // statement about our patience, not proof the child is stuck — a large
565
+ // corpus can legitimately outlast the window, and that daemon should be
566
+ // allowed to finish and serve.
567
+ const childOwnsMigrationLock = exclusiveLockOwnerPid(onDiskMigrationLockPath()) === pid;
568
+ if (exitState === null && !childOwnsMigrationLock && !(error instanceof DaemonStartupBlockedError)) {
569
+ try {
570
+ await stopDaemonProcess(pid, DAEMON_REAP_WINDOW_MS);
571
+ }
572
+ catch { /* best effort */ }
573
+ }
574
+ throw error;
575
+ }
576
+ if (existingPid !== null) {
577
+ return { started: false, existing_pid: existingPid, startup_phase: targetPhase };
578
+ }
579
+ return { started: true, pid, startup_phase: targetPhase };
580
+ }
581
+ // ensureDaemon — fire-and-forget front-door helper
582
+ /** Whether autostart is suppressed for this invocation. `--no-autostart` or
583
+ * `CRTR_NO_DAEMON_AUTOSTART=1` forces fail-loud on a cold socket (spec §7.1) —
584
+ * for environments that manage crtrd externally and want the diagnostic, and
585
+ * for test lanes, which point `CRTR_HOME` at a throwaway directory: an
586
+ * autostarted daemon there is detached and resident, so it outlives both the
587
+ * test process and the deleted temp home with nothing left to stop it.
588
+ *
589
+ * This is the ONE gate. `ensureDaemon` below is the only path that spawns a
590
+ * daemon implicitly, so the check belongs here rather than at each caller —
591
+ * `cliClient()` reads it too, but the in-process spawn/recycle/promote callers
592
+ * reach `ensureDaemon` without going through the CLI client at all. An
593
+ * explicit `crtr sys daemon start` calls `spawnDaemon` directly and is
594
+ * deliberately unaffected. */
595
+ export function autostartDisabled() {
596
+ return envNoDaemonAutostart() || process.argv.includes('--no-autostart');
597
+ }
598
+ // Autostart rate limiting. `ensureDaemon` is the one implicit spawn path and is
599
+ // reached on every cold socket — including each redial of a long-lived attach
600
+ // client. Unguarded that is an open loop: a daemon that cannot start is retried
601
+ // forever, by every client, with nothing tracking that the last attempt failed
602
+ // for a reason the next one will hit too.
603
+ const AUTOSTART_BACKOFF_BASE_MS = 5_000;
604
+ const AUTOSTART_BACKOFF_MAX_MS = 60_000;
605
+ let autostartInFlight = false;
606
+ let autostartStartupFailure;
607
+ let autostartFailures = 0;
608
+ let autostartNextAttemptAt = 0;
609
+ function autostartBackoffMs(failures) {
610
+ return Math.min(AUTOSTART_BACKOFF_BASE_MS * 2 ** (failures - 1), AUTOSTART_BACKOFF_MAX_MS);
611
+ }
612
+ /** Reset in-process spawn backoff after a daemon is observed alive. */
613
+ export function resetDaemonAutostart() {
614
+ autostartStartupFailure = undefined;
615
+ autostartFailures = 0;
616
+ autostartNextAttemptAt = 0;
617
+ }
618
+ /** Why the daemon this process autostarted died before becoming ready, or
619
+ * undefined while it is starting, running, or exited cleanly (a clean exit may
620
+ * be a lost singleton race, so only a non-zero/signal exit counts). A client
621
+ * waiting for that daemon polls this so a startup failure ends the wait now
622
+ * rather than at the end of the startup window. */
623
+ export function daemonAutostartStartupFailure() {
624
+ return autostartStartupFailure;
625
+ }
626
+ /** Start the daemon if it is not already running. No-op if already up, if
627
+ * autostart is suppressed for this invocation, while an attempt is in flight,
628
+ * or while backing off from a recent failure.
629
+ * Silently swallows spawn errors (the canvas still works without the daemon;
630
+ * nodes just won't be auto-revived). */
631
+ export function ensureDaemon(deps = {}) {
632
+ const running = deps.isDaemonRunning ?? isDaemonRunning;
633
+ const spawn = deps.spawnDaemon ?? spawnDaemon;
634
+ const now = deps.now ?? Date.now;
635
+ if (autostartDisabled())
636
+ return;
637
+ if (autostartInFlight)
638
+ return;
639
+ if (now() < autostartNextAttemptAt)
640
+ return;
641
+ if (running()) {
642
+ resetDaemonAutostart();
643
+ return;
644
+ }
645
+ // A failure belongs to the attempt that produced it: this attempt starts with
646
+ // none, so a client waiting on it is never ended by the previous child's death.
647
+ autostartStartupFailure = undefined;
648
+ autostartInFlight = true;
649
+ void spawn().then(() => {
650
+ autostartStartupFailure = undefined;
651
+ autostartFailures = 0;
652
+ autostartNextAttemptAt = 0;
653
+ }, (error) => {
654
+ if (error instanceof DaemonExitedBeforeReadyError) {
655
+ autostartStartupFailure = error.message;
656
+ }
657
+ // Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
658
+ // pre-build) must not break the calling command.
659
+ autostartFailures += 1;
660
+ autostartNextAttemptAt = now() + autostartBackoffMs(autostartFailures);
661
+ }).finally(() => {
662
+ autostartInFlight = false;
663
+ });
664
+ }
@@ -0,0 +1,8 @@
1
+ import { isPidAlive } from '../core/canvas/pid.js';
2
+ export { isPidAlive };
3
+ /** Absolute path to crtrd's pidfile. `CRTR_PIDFILE` overrides for tests. */
4
+ export declare function pidfilePath(): string;
5
+ /** Read the pid stored in the pidfile, or null if absent / malformed. */
6
+ export declare function readPidfile(): number | null;
7
+ /** True when a crtrd process is already running (pidfile exists + pid alive). */
8
+ export declare function isDaemonRunning(): boolean;