@north-light/api 0.3.435 → 0.3.437

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.
@@ -0,0 +1,13 @@
1
+ import { InputError } from '../io.js';
2
+ export declare function joinLockPath(): string;
3
+ /** The live process holding the lock, or null when no join is running (no lock, or a holder that died). */
4
+ export declare function joinLockHolder(): number | null;
5
+ /** Whether a join is in progress. The joiner starts its own daemon through `spawnDaemon`, never through the implicit paths that ask this. */
6
+ export declare function joinInProgress(): boolean;
7
+ export declare function joinInProgressError(pid: number | null): InputError;
8
+ /** Resolve once no other live process holds the join lock, or after `timeoutMs`. For a start that must happen
9
+ * but must read the settings a join is about to commit (a daemon handover's successor): it waits the join out
10
+ * instead of bypassing the lock, so nothing but the join starts a daemon while the lock is held. */
11
+ export declare function awaitJoinLockRelease(timeoutMs: number, pollMs?: number): Promise<void>;
12
+ /** Run `operation` holding the join lock. Throws `join_in_progress` when a live process holds it. */
13
+ export declare function withJoinLock<T>(operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,61 @@
1
+ // The "join in progress" lock. `crtr sys join` stops the daemon, rewrites local
2
+ // identity, and starts the daemon again. Stopping the daemon kills every broker,
3
+ // so every open viewer sees a cold socket and its client would autostart a new
4
+ // daemon within seconds, which makes the join refuse (`daemon_running`). While
5
+ // this lock is held by a live process, `ensureDaemon` and `spawnDaemon` (so also
6
+ // `sys daemon start` and a handover successor) decline to start a daemon.
7
+ //
8
+ // It is the shared token-checked directory lock (`core/exclusive-lock.ts`): the
9
+ // holder's pid is in its marker's name, a dead holder is reclaimed by exactly the
10
+ // marker it observed (so a lagging reclaimer can never strip a live successor),
11
+ // and only the holder's own marker releases it. PURITY: Node built-ins, path
12
+ // builders and the pid probe only, so `daemon/manage.ts` can import it.
13
+ import { mkdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { crtrHome } from '../canvas/paths.js';
16
+ import { isPidAlive } from '../canvas/pid.js';
17
+ import { exclusiveLockOwnerPid, withExclusiveDirectoryLockAsync } from '../exclusive-lock.js';
18
+ import { InputError } from '../io.js';
19
+ /** How long a second joiner waits for the first before it refuses. Long enough to reclaim a dead holder's lock. */
20
+ const ACQUIRE_WAIT_MS = 1_000;
21
+ export function joinLockPath() {
22
+ return join(crtrHome(), 'join.lock');
23
+ }
24
+ /** The live process holding the lock, or null when no join is running (no lock, or a holder that died). */
25
+ export function joinLockHolder() {
26
+ const pid = exclusiveLockOwnerPid(joinLockPath());
27
+ return pid !== null && isPidAlive(pid) ? pid : null;
28
+ }
29
+ /** Whether a join is in progress. The joiner starts its own daemon through `spawnDaemon`, never through the implicit paths that ask this. */
30
+ export function joinInProgress() {
31
+ return joinLockHolder() !== null;
32
+ }
33
+ export function joinInProgressError(pid) {
34
+ return new InputError({
35
+ error: 'join_in_progress',
36
+ message: pid === null
37
+ ? '`crtr sys join` is running and holds the daemon down until it finishes'
38
+ : `\`crtr sys join\` (pid ${pid}) is running and holds the daemon down until it finishes`,
39
+ next: 'Wait for the join to finish; it starts the daemon again itself. If it is stuck, `crtr sys daemon start --force`.',
40
+ });
41
+ }
42
+ /** Resolve once no other live process holds the join lock, or after `timeoutMs`. For a start that must happen
43
+ * but must read the settings a join is about to commit (a daemon handover's successor): it waits the join out
44
+ * instead of bypassing the lock, so nothing but the join starts a daemon while the lock is held. */
45
+ export async function awaitJoinLockRelease(timeoutMs, pollMs = 100) {
46
+ const deadline = Date.now() + timeoutMs;
47
+ for (;;) {
48
+ const holder = joinLockHolder();
49
+ if (holder === null || holder === process.pid || Date.now() >= deadline)
50
+ return;
51
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
52
+ }
53
+ }
54
+ /** Run `operation` holding the join lock. Throws `join_in_progress` when a live process holds it. */
55
+ export async function withJoinLock(operation) {
56
+ mkdirSync(crtrHome(), { recursive: true });
57
+ return withExclusiveDirectoryLockAsync(joinLockPath(), operation, {
58
+ timeoutMs: ACQUIRE_WAIT_MS,
59
+ timeoutError: () => joinInProgressError(joinLockHolder()),
60
+ });
61
+ }
package/dist/core/io.d.ts CHANGED
@@ -57,6 +57,8 @@ interface ErrorPayloadBase {
57
57
  * of `received` on purpose: `received` names the caller's offending VALUE, and
58
58
  * reporting an HTTP status there is a false claim about what was sent. */
59
59
  http_status?: number;
60
+ /** Seconds the provider asked the caller to wait before retrying (a rate limit). */
61
+ retry_after_s?: number;
60
62
  next: string;
61
63
  }
62
64
  export type ErrorPayload = ErrorPayloadBase & ({
@@ -109,7 +111,12 @@ export declare function diag(message: string): void;
109
111
  * reconstruct it verbatim so a gate/guard error (revive reopen-gate, push
110
112
  * worktree/finalization guard, …) surfaces byte-for-byte as the leaf raised
111
113
  * it before the re-plumb, preserving its SPECIFIC code + next.
112
- * 3. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
114
+ * 3. A provider-origin refusal (`err.origin === 'provider'`) carries the
115
+ * provider's own error in `details` (`{code,message,field?,next?,received?}`)
116
+ * under the daemon's coarse `provider_*` code and the provider's HTTP
117
+ * status. The provider's code, `next`, `field` and `Retry-After` reach the
118
+ * caller unchanged; the status→code map would flatten them to `conflict`.
119
+ * 4. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
113
120
  * and a caller-supplied-or-default `next`. */
114
121
  export declare function apiErrorToCliError(err: ApiError, next?: string): CrtrError;
115
122
  /** Terminal error handler. Command-level failures (bad input, not-found,
package/dist/core/io.js CHANGED
@@ -209,6 +209,7 @@ function payloadOf(e) {
209
209
  received: d.received,
210
210
  field: d.field,
211
211
  ...(typeof d.http_status === 'number' ? { http_status: d.http_status } : {}),
212
+ ...(typeof d.retry_after_s === 'number' ? { retry_after_s: d.retry_after_s } : {}),
212
213
  };
213
214
  return result === undefined
214
215
  ? { ...base, next }
@@ -274,7 +275,12 @@ const STATUS_TO_CLI_CODE = {
274
275
  * reconstruct it verbatim so a gate/guard error (revive reopen-gate, push
275
276
  * worktree/finalization guard, …) surfaces byte-for-byte as the leaf raised
276
277
  * it before the re-plumb, preserving its SPECIFIC code + next.
277
- * 3. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
278
+ * 3. A provider-origin refusal (`err.origin === 'provider'`) carries the
279
+ * provider's own error in `details` (`{code,message,field?,next?,received?}`)
280
+ * under the daemon's coarse `provider_*` code and the provider's HTTP
281
+ * status. The provider's code, `next`, `field` and `Retry-After` reach the
282
+ * caller unchanged; the status→code map would flatten them to `conflict`.
283
+ * 4. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
278
284
  * and a caller-supplied-or-default `next`. */
279
285
  export function apiErrorToCliError(err, next) {
280
286
  // A handover is NOT a down daemon: the successor is already up (the client
@@ -297,12 +303,23 @@ export function apiErrorToCliError(err, next) {
297
303
  }
298
304
  // A responding daemon can relay an unavailable provider or storage service.
299
305
  // HTTP 503 alone does not mean that crtrd is down.
306
+ const d = err.details;
307
+ const provider = providerSupplied(err, d);
300
308
  if (err.status === 503) {
301
- return new CrtrError(err.code, err.message, ExitCode.NETWORK, {
302
- next: next ?? 'Retry the command; if it persists, inspect the unavailable service named in the error.',
303
- });
309
+ const code = provider.code ?? err.code;
310
+ const details = {
311
+ next: provider.next ?? next ?? 'Retry the command; if it persists, inspect the unavailable service named in the error.',
312
+ };
313
+ if (provider.code !== undefined) {
314
+ if (provider.field !== undefined)
315
+ details['field'] = provider.field;
316
+ if (provider.received !== undefined)
317
+ details['received'] = provider.received;
318
+ if (provider.retryAfterS !== undefined)
319
+ details['retry_after_s'] = provider.retryAfterS;
320
+ }
321
+ return new CrtrError(code, err.message, ExitCode.NETWORK, details);
304
322
  }
305
- const d = err.details;
306
323
  // A `/v1` request validator reports each bad field in `details.violations`;
307
324
  // the message alone only says the request was invalid.
308
325
  const violations = Array.isArray(d?.violations)
@@ -312,14 +329,20 @@ export function apiErrorToCliError(err, next) {
312
329
  const payload = d && typeof d.error === 'string' && typeof d.message === 'string' && typeof d.next === 'string'
313
330
  ? d
314
331
  : {
315
- error: STATUS_TO_CLI_CODE[err.status] ?? err.code,
332
+ error: provider.code ?? STATUS_TO_CLI_CODE[err.status] ?? err.code,
333
+ ...(provider.field !== undefined ? { field: provider.field } : {}),
334
+ ...(provider.received !== undefined ? { received: provider.received } : {}),
335
+ ...(provider.retryAfterS !== undefined ? { retry_after_s: provider.retryAfterS } : {}),
316
336
  message: violations.length > 0 ? `${err.message}: ${violations.join('; ')}` : err.message.trim() !== '' ? err.message : `request refused (HTTP ${err.status}, ${err.code}) with no message`,
317
- next: next ??
318
- (err.status >= 500
319
- ? 'This is a crtr bug, not a bad call. Retry; if it persists, report it (the daemon log holds the full cause).'
320
- : err.status === 404
321
- ? 'List nodes with `crtr node inspect list`.'
322
- : 'Inspect the error and adjust the call. See -h for the schema.'),
337
+ next: provider.next ??
338
+ next ??
339
+ (provider.retryAfterS !== undefined
340
+ ? `Wait ${provider.retryAfterS}s, then retry; do not retry sooner.`
341
+ : err.status >= 500
342
+ ? 'This is a crtr bug, not a bad call. Retry; if it persists, report it (the daemon log holds the full cause).'
343
+ : err.status === 404
344
+ ? 'List nodes with `crtr node inspect list`.'
345
+ : 'Inspect the error and adjust the call. See -h for the schema.'),
323
346
  };
324
347
  // Preserve the exit-code class the server leaf carried instead of collapsing
325
348
  // every non-503 to USAGE(2): a not-found (404 / `not_found`) surfaces exit 3
@@ -327,6 +350,33 @@ export function apiErrorToCliError(err, next) {
327
350
  // (memory/pkg/sys) still emit so the taxonomy stays uniform across the CLI.
328
351
  return new InputError(payload, exitForCliCode(payload.error, err.status));
329
352
  }
353
+ /** What a provider put in its own error body, as the daemon relays it: the
354
+ * provider's code (`details.code`; the envelope `code` is only the daemon's
355
+ * coarse `provider_*` bucket), plus `next`, `field`, `received`, and the
356
+ * `Retry-After` the daemon lifts into the envelope. Empty for any error that
357
+ * did not originate at a provider, so non-provider status→code mapping is
358
+ * untouched. A code that is not a snake_case identifier, or that came with no
359
+ * provider HTTP status, is ignored. */
360
+ function providerSupplied(err, d) {
361
+ if (err.origin !== 'provider' || d === undefined || d === null || typeof d !== 'object')
362
+ return {};
363
+ const out = {};
364
+ // `details.status` is the provider's own HTTP status; the runtime's transport
365
+ // diagnostics (`{status: null, code: 'timeout'}`) are not a provider's code.
366
+ if (typeof d.status === 'number' && typeof d.code === 'string' && /^[a-z][a-z0-9_]*$/.test(d.code))
367
+ out.code = d.code;
368
+ if (typeof d.next === 'string' && d.next.trim() !== '')
369
+ out.next = d.next;
370
+ if (typeof d.field === 'string' && d.field !== '')
371
+ out.field = d.field;
372
+ else if (err.param !== undefined)
373
+ out.field = err.param;
374
+ if (d.received !== undefined && d.received !== null)
375
+ out.received = d.received;
376
+ if (typeof err.retryAfterS === 'number')
377
+ out.retryAfterS = err.retryAfterS;
378
+ return out;
379
+ }
330
380
  /** The `ExitCode` class a reconstructed API error should carry, keyed off the
331
381
  * round-tripped CLI code (and the 404 status as the not-found signal — the
332
382
  * server maps NOT_FOUND's exit class to status 404, but folds AMBIGUOUS and
@@ -129,6 +129,8 @@ export function renderError(p) {
129
129
  // the value the caller supplied.
130
130
  if (p.http_status !== undefined)
131
131
  lines.push(`http status: ${p.http_status}`);
132
+ if (p.retry_after_s !== undefined)
133
+ lines.push(`retry after: ${p.retry_after_s}s`);
132
134
  if ('primaryCompleted' in p && p.primaryCompleted === true) {
133
135
  lines.push('primaryCompleted: true');
134
136
  lines.push(`Primary result (the command already completed):\n${fencedJson(p.result)}`);
@@ -43,6 +43,10 @@ export declare function execDaemon(): Promise<never>;
43
43
  export interface SpawnDaemonOptions {
44
44
  /** The phase that proves a daemon launched by this call is available. */
45
45
  targetPhase?: 'ready' | 'prepared';
46
+ /** Start even while another process's `crtr sys join` holds the daemon down. */
47
+ ignoreJoinLock?: boolean;
48
+ /** While another process's `crtr sys join` holds the daemon down, wait for it to finish, then start. For a handover successor. */
49
+ waitForJoinLock?: boolean;
46
50
  }
47
51
  export interface SpawnDaemonResult {
48
52
  /** True when a new daemon process was spawned. */
@@ -136,6 +140,14 @@ export declare function waitForDaemonExit(pid: number, timeoutMs?: number, deps?
136
140
  *
137
141
  * Returns how the daemon went down, for a truthful report. */
138
142
  export declare function stopDaemonProcess(pid: number, timeoutMs?: number, deps?: DaemonWaitDeps): Promise<'terminated' | 'killed'>;
143
+ /** Stop the recorded daemon and sweep every stray crtrd of this canvas home.
144
+ * Resolves once none remains. Shared by `crtr sys daemon stop` and `crtr sys join`. */
145
+ export declare function stopAllDaemons(): Promise<{
146
+ stopped: boolean;
147
+ pid?: number;
148
+ strays_killed: number[];
149
+ force_killed: boolean;
150
+ }>;
139
151
  /** Spawn crtrd detached. Returns immediately; the child outlives this process.
140
152
  *
141
153
  * If the daemon is already running, returns {started:false, existing_pid}.
@@ -169,7 +181,8 @@ export interface EnsureDaemonDeps {
169
181
  * rather than at the end of the startup window. */
170
182
  export declare function daemonAutostartStartupFailure(): string | undefined;
171
183
  /** Start the daemon if it is not already running. No-op if already up, if
172
- * autostart is suppressed for this invocation, while an attempt is in flight,
184
+ * autostart is suppressed for this invocation, while `crtr sys join` holds
185
+ * the join lock (see core/directory/join-lock.ts), while an attempt is in flight,
173
186
  * or while backing off from a recent failure.
174
187
  * Silently swallows spawn errors (the canvas still works without the daemon;
175
188
  * nodes just won't be auto-revived). */
@@ -19,6 +19,7 @@ import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
19
19
  import { exclusiveLockOwnerPid } from '../core/exclusive-lock.js';
20
20
  import { onDiskMigrationLockPath } from '../core/canvas/paths.js';
21
21
  import { envNoDaemonAutostart } from '../shared/env.js';
22
+ import { awaitJoinLockRelease, joinInProgress, joinInProgressError, joinLockHolder } from '../core/directory/join-lock.js';
22
23
  import { waitForDaemonAvailability } from '../api/index.js';
23
24
  import { localClient } from '../api/node-transport.js';
24
25
  // Daemon env sanitization
@@ -194,6 +195,8 @@ export async function execDaemon() {
194
195
  const launchArgs = await resolveDaemonLaunch();
195
196
  return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
196
197
  }
198
+ /** How long a handover successor waits for a running join: a registration call (30 s) plus the join's own stop and start. */
199
+ const JOIN_WAIT_WINDOW_MS = 3 * 60_000;
197
200
  // Shutdown stays short even though startup may include corpus migration.
198
201
  const DAEMON_SHUTDOWN_WINDOW_MS = 20_000;
199
202
  const DAEMON_VERIFY_POLL_MS = 10;
@@ -415,6 +418,16 @@ export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_
415
418
  }
416
419
  throw new Error(`daemon ${pid} survived both SIGTERM and SIGKILL`);
417
420
  }
421
+ /** Stop the recorded daemon and sweep every stray crtrd of this canvas home.
422
+ * Resolves once none remains. Shared by `crtr sys daemon stop` and `crtr sys join`. */
423
+ export async function stopAllDaemons() {
424
+ const pid = readPidfile();
425
+ const recorded = pid !== null && isPidAlive(pid);
426
+ const forceKilled = recorded && (await stopDaemonProcess(pid)) === 'killed';
427
+ // The recorded daemon is gone, so keepPid=null sweeps every remaining crtrd of this home.
428
+ const strays = sweepStrayDaemons(null);
429
+ return { stopped: recorded || strays.length > 0, ...(recorded ? { pid } : {}), strays_killed: strays, force_killed: forceKilled };
430
+ }
418
431
  /** The last `maxBytes` of a log from byte `start` onward, trimmed; '' when it
419
432
  * cannot be read. Bounded so a runaway log can never flood an error message. */
420
433
  function readLogTailFrom(path, start, maxBytes = 2_048) {
@@ -496,6 +509,13 @@ export async function spawnDaemon(opts = {}) {
496
509
  // is launchd-owned, the plist's ProgramArguments must point at the branded
497
510
  // binary too — this path only covers a manual `crtr sys daemon start`.
498
511
  const launchArgs = await resolveDaemonLaunch();
512
+ // The one spawn point every start reaches (autostart, `sys daemon start`, a handover successor): an
513
+ // autostart begun before a join took its lock lands here after it, and must not start a daemon.
514
+ if (opts.waitForJoinLock === true)
515
+ await awaitJoinLockRelease(JOIN_WAIT_WINDOW_MS);
516
+ const joiner = joinLockHolder();
517
+ if (joiner !== null && joiner !== process.pid && opts.ignoreJoinLock !== true)
518
+ throw joinInProgressError(joiner);
499
519
  // Route stdout+stderr to an append-mode file instead of discarding them
500
520
  // (stdio:'ignore'). This fd is RAW RESIDUE only — third-party/runtime
501
521
  // output the daemon doesn't control, plus the narrow set of authorized raw
@@ -624,7 +644,8 @@ export function daemonAutostartStartupFailure() {
624
644
  return autostartStartupFailure;
625
645
  }
626
646
  /** 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,
647
+ * autostart is suppressed for this invocation, while `crtr sys join` holds
648
+ * the join lock (see core/directory/join-lock.ts), while an attempt is in flight,
628
649
  * or while backing off from a recent failure.
629
650
  * Silently swallows spawn errors (the canvas still works without the daemon;
630
651
  * nodes just won't be auto-revived). */
@@ -634,6 +655,9 @@ export function ensureDaemon(deps = {}) {
634
655
  const now = deps.now ?? Date.now;
635
656
  if (autostartDisabled())
636
657
  return;
658
+ // `crtr sys join` holds the daemon down on purpose; a viewer's redial must not bring it back.
659
+ if (joinInProgress())
660
+ return;
637
661
  if (autostartInFlight)
638
662
  return;
639
663
  if (now() < autostartNextAttemptAt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.435",
3
+ "version": "0.3.437",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",