@crouter/api 0.3.386 → 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
@@ -8,6 +8,7 @@ import { join } from 'node:path';
8
8
  import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
9
9
  import { localClient } from '../../node-transport.js';
10
10
  import { ApiError } from '../../errors.js';
11
+ import { DaemonExitedBeforeReadyError, daemonAutostartStartupFailure, ensureDaemon, resetDaemonAutostart, } from '../../../daemon/manage.js';
11
12
  function startDelayedHealthzServer(socketPath, delayMs) {
12
13
  const server = createServer((_req, res) => {
13
14
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -177,3 +178,99 @@ test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated
177
178
  throw new Error('custom hook blew up');
178
179
  }), undefined);
179
180
  });
181
+ test('a daemon this client started that fails terminally ends the cold-start wait at once with its reason', async () => {
182
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-startup-failed-'));
183
+ const socketPath = join(dir, 'crtrd.sock'); // nothing ever listens
184
+ let failure;
185
+ const client = localClient({
186
+ socketPath,
187
+ autostart: true,
188
+ onColdSocket: async () => { setTimeout(() => { failure = 'daemon 7 exited before becoming ready (with exit code 1)\n[crtrd] claim lost: own_identity_indeterminate'; }, 150); },
189
+ coldStartPollWindowMs: 60_000,
190
+ coldStartFailure: () => failure,
191
+ coldStartDiagnostic: () => 'crtrd.log (tail): must not be appended to a reason that already explains itself',
192
+ });
193
+ const started = Date.now();
194
+ try {
195
+ await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError
196
+ && error.code === 'daemon_unavailable'
197
+ && error.message.includes('crtrd failed to start')
198
+ && error.message.includes('own_identity_indeterminate')
199
+ && !error.message.includes('crtrd.log (tail)'));
200
+ assert.ok(Date.now() - started < 5_000, 'must not poll out the 60s window');
201
+ }
202
+ finally {
203
+ rmSync(dir, { recursive: true, force: true });
204
+ }
205
+ });
206
+ test('a first spawn that failed does not end the wait of a later healthy spawn: failure → backoff → new in-flight attempt', async () => {
207
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-stale-failure-'));
208
+ const socketPath = join(dir, 'crtrd.sock');
209
+ const savedEnv = process.env['CRTR_NO_DAEMON_AUTOSTART'];
210
+ delete process.env['CRTR_NO_DAEMON_AUTOSTART'];
211
+ resetDaemonAutostart();
212
+ let now = 1_000_000;
213
+ let spawns = 0;
214
+ let server;
215
+ let timer;
216
+ const deps = {
217
+ isDaemonRunning: () => false,
218
+ now: () => now,
219
+ spawnDaemon: () => {
220
+ spawns += 1;
221
+ if (spawns === 1)
222
+ return Promise.reject(new DaemonExitedBeforeReadyError('daemon 1 exited before becoming ready (with exit code 1)', 'FIRST SPAWN EXITED'));
223
+ // Attempt 2: stays in flight, and its daemon starts listening a moment later.
224
+ return new Promise((resolve) => {
225
+ server = createServer((_q, r) => { r.writeHead(200, { 'content-type': 'application/json' }); r.end('{"ok":true}'); });
226
+ timer = setTimeout(() => { server?.listen(socketPath); resolve({}); }, 400);
227
+ });
228
+ },
229
+ };
230
+ try {
231
+ ensureDaemon(deps); // attempt 1 — fails terminally
232
+ await new Promise((resolve) => setImmediate(resolve));
233
+ assert.match(daemonAutostartStartupFailure() ?? '', /FIRST SPAWN EXITED/);
234
+ now += 120_000; // backoff elapsed
235
+ const client = localClient({
236
+ socketPath,
237
+ autostart: true,
238
+ onColdSocket: async () => { ensureDaemon(deps); }, // attempt 2 starts as this client goes cold
239
+ coldStartPollWindowMs: 5_000,
240
+ coldStartFailure: daemonAutostartStartupFailure,
241
+ });
242
+ assert.ok((await client.healthz()) !== undefined);
243
+ assert.equal(spawns, 2);
244
+ }
245
+ finally {
246
+ if (timer !== undefined)
247
+ clearTimeout(timer);
248
+ server?.close();
249
+ resetDaemonAutostart();
250
+ if (savedEnv === undefined)
251
+ delete process.env['CRTR_NO_DAEMON_AUTOSTART'];
252
+ else
253
+ process.env['CRTR_NO_DAEMON_AUTOSTART'] = savedEnv;
254
+ rmSync(dir, { recursive: true, force: true });
255
+ }
256
+ });
257
+ test('with no reported startup failure the cold-start wait keeps its full window', async () => {
258
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-startup-slow-'));
259
+ const socketPath = join(dir, 'crtrd.sock');
260
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 400);
261
+ try {
262
+ const client = localClient({
263
+ socketPath,
264
+ autostart: true,
265
+ onColdSocket: async () => { },
266
+ coldStartPollWindowMs: 5_000,
267
+ coldStartFailure: () => undefined, // a migration still making progress reports nothing
268
+ });
269
+ assert.ok((await client.healthz()) !== undefined);
270
+ }
271
+ finally {
272
+ cancel();
273
+ server.close();
274
+ rmSync(dir, { recursive: true, force: true });
275
+ }
276
+ });
@@ -58,6 +58,10 @@ export interface CrtrClientOptions {
58
58
  * return synchronously and cheaply — it runs on the failure path, not the
59
59
  * happy path. A thrown/undefined result is treated as "no diagnostic". */
60
60
  coldStartDiagnostic?: () => string | undefined;
61
+ /** Polled during the cold-start wait: a string means the daemon this client
62
+ * started has terminally failed (it exited before becoming ready), so the
63
+ * wait ends immediately with that text instead of polling out the window. */
64
+ coldStartFailure?: () => string | undefined;
61
65
  /** Strict wall-clock window (ms) for local API availability after a cold
62
66
  * socket or interrupted response. Each probe is capped to the remaining
63
67
  * budget. Defaults to `HEALTHZ_POLL_WINDOW_MS`. */
@@ -94,7 +98,10 @@ export declare class CrtrClient {
94
98
  private readonly localSocketTransport;
95
99
  private readonly onColdSocket?;
96
100
  private readonly coldStartDiagnostic?;
101
+ private readonly coldStartFailure?;
97
102
  private readonly coldStartPollWindowMs;
103
+ /** Set when the last availability wait ended on a daemon startup failure, whose message already says why. */
104
+ private startupFailureReported;
98
105
  /** Guards against invoking the daemon-start hook more than once per client. */
99
106
  private coldStartAttempted;
100
107
  constructor(opts: CrtrClientOptions);
@@ -48,7 +48,10 @@ export class CrtrClient {
48
48
  localSocketTransport;
49
49
  onColdSocket;
50
50
  coldStartDiagnostic;
51
+ coldStartFailure;
51
52
  coldStartPollWindowMs;
53
+ /** Set when the last availability wait ended on a daemon startup failure, whose message already says why. */
54
+ startupFailureReported = false;
52
55
  /** Guards against invoking the daemon-start hook more than once per client. */
53
56
  coldStartAttempted = false;
54
57
  constructor(opts) {
@@ -65,6 +68,8 @@ export class CrtrClient {
65
68
  this.onColdSocket = opts.onColdSocket;
66
69
  if (opts.coldStartDiagnostic !== undefined)
67
70
  this.coldStartDiagnostic = opts.coldStartDiagnostic;
71
+ if (opts.coldStartFailure !== undefined)
72
+ this.coldStartFailure = opts.coldStartFailure;
68
73
  this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
69
74
  }
70
75
  // Health / status
@@ -1074,27 +1079,41 @@ export class CrtrClient {
1074
1079
  const remainingBudgetMs = deadlineAt === undefined ? undefined : deadlineAt - Date.now();
1075
1080
  if (remainingBudgetMs !== undefined && remainingBudgetMs <= 0)
1076
1081
  throw budgetExhausted();
1077
- await waitForDaemonAvailability({
1078
- windowMs: Math.min(this.coldStartPollWindowMs, remainingBudgetMs ?? this.coldStartPollWindowMs),
1079
- initialError,
1080
- probe: async (timeoutMs) => {
1081
- const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
1082
- if (response.status >= 200 && response.status < 300) {
1083
- await drainResponseBody(response);
1084
- return;
1085
- }
1086
- const text = await response.text();
1087
- try {
1088
- const health = JSON.parse(text);
1089
- if (typeof health.startup_blocked === 'string')
1082
+ let startupFailure;
1083
+ try {
1084
+ await waitForDaemonAvailability({
1085
+ windowMs: Math.min(this.coldStartPollWindowMs, remainingBudgetMs ?? this.coldStartPollWindowMs),
1086
+ initialError,
1087
+ retry: (error) => error !== startupFailure,
1088
+ probe: async (timeoutMs) => {
1089
+ const failure = this.coldStartFailure?.();
1090
+ if (failure !== undefined) {
1091
+ startupFailure = new ApiError(503, 'daemon_unavailable', `crtrd failed to start: ${failure}`);
1092
+ throw startupFailure;
1093
+ }
1094
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
1095
+ if (response.status >= 200 && response.status < 300) {
1096
+ await drainResponseBody(response);
1090
1097
  return;
1091
- }
1092
- catch {
1093
- // The normal unavailable error below carries the response body.
1094
- }
1095
- throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
1096
- },
1097
- });
1098
+ }
1099
+ const text = await response.text();
1100
+ try {
1101
+ const health = JSON.parse(text);
1102
+ if (typeof health.startup_blocked === 'string')
1103
+ return;
1104
+ }
1105
+ catch {
1106
+ // The normal unavailable error below carries the response body.
1107
+ }
1108
+ throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
1109
+ },
1110
+ });
1111
+ }
1112
+ catch (error) {
1113
+ if (error === startupFailure)
1114
+ this.startupFailureReported = true;
1115
+ throw error;
1116
+ }
1098
1117
  }
1099
1118
  /** Optionally start the daemon, then observe availability before retrying the
1100
1119
  * request. Waiting is independent from permission to spawn: externally
@@ -1109,7 +1128,7 @@ export class CrtrClient {
1109
1128
  await this.awaitAvailability(initialError, deadlineAt);
1110
1129
  }
1111
1130
  catch (error) {
1112
- if (startedHere) {
1131
+ if (startedHere && !this.startupFailureReported) {
1113
1132
  const diagnostic = safeColdStartDiagnostic(this.coldStartDiagnostic);
1114
1133
  if (diagnostic !== undefined) {
1115
1134
  const transportError = toTransportApiError(error);
@@ -0,0 +1,7 @@
1
+ /** The source or runtime directory containing every shipped asset tree.
2
+ *
3
+ * `import.meta.url` names the module when loaded directly, but names the
4
+ * attach viewer bundle when esbuild inlines this module. Walking to the
5
+ * directory marked by the built-in assets preserves one resolution rule for
6
+ * both loading contexts. */
7
+ export declare function assetRoot(): string;
@@ -0,0 +1,18 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** The source or runtime directory containing every shipped asset tree.
5
+ *
6
+ * `import.meta.url` names the module when loaded directly, but names the
7
+ * attach viewer bundle when esbuild inlines this module. Walking to the
8
+ * directory marked by the built-in assets preserves one resolution rule for
9
+ * both loading contexts. */
10
+ export function assetRoot() {
11
+ for (let current = dirname(fileURLToPath(import.meta.url));; current = dirname(current)) {
12
+ if (existsSync(join(current, 'builtin-memory')) && existsSync(join(current, 'builtin-pi-packages')))
13
+ return current;
14
+ const parent = dirname(current);
15
+ if (parent === current)
16
+ throw new Error(`cannot locate crouter assets from ${import.meta.url}`);
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ /** The Linux kernel's per-boot identity at `/proc/sys/kernel/random/boot_id` —
2
+ * a fresh random UUID minted on every kernel boot, or null when unreadable
3
+ * (non-Linux dev, or the file is absent). Exact when present: a microVM
4
+ * recreate boots a new kernel (new id); a plain daemon restart within the same
5
+ * boot leaves it identical. */
6
+ export declare function readKernelBootId(): string | null;
@@ -0,0 +1,26 @@
1
+ // boot-id.ts — the PURE kernel-boot-identity reader, split out of boot.ts so the
2
+ // low-level liveness probe (`canvas/pid.ts`) can import it WITHOUT dragging in
3
+ // boot.ts's `openDb` persistence machinery. boot.ts's boot-change reconciler
4
+ // persists the last-seen identity in canvas.db (hence its top-level `openDb`
5
+ // import); `readKernelBootId` itself is a plain `/proc` read with zero db reach,
6
+ // and `pid.ts` needs only that. Keeping it here means `pid.ts` (and everything
7
+ // that imports it down — including the CLI's daemon-liveness front door) stays
8
+ // canvas.db-free.
9
+ //
10
+ // PURITY: Node built-ins only. Never `core/canvas/db.js` or any db-touching
11
+ // module.
12
+ import { readFileSync } from 'node:fs';
13
+ /** The Linux kernel's per-boot identity at `/proc/sys/kernel/random/boot_id` —
14
+ * a fresh random UUID minted on every kernel boot, or null when unreadable
15
+ * (non-Linux dev, or the file is absent). Exact when present: a microVM
16
+ * recreate boots a new kernel (new id); a plain daemon restart within the same
17
+ * boot leaves it identical. */
18
+ export function readKernelBootId() {
19
+ try {
20
+ const id = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
21
+ return id.length > 0 ? id : null;
22
+ }
23
+ catch {
24
+ return null; // non-Linux, or unreadable — fall back to the wall-clock epoch
25
+ }
26
+ }
@@ -0,0 +1,72 @@
1
+ /** Runtime-owned records; never a bind source for an app sandbox. */
2
+ export declare function crtrHome(): string;
3
+ /** The canvas home requires durable storage with SQLite WAL locking support. */
4
+ export declare function canvasDbPath(): string;
5
+ export declare function onDiskMigrationLockPath(): string;
6
+ export declare function onDiskMigrationMarkerPath(): string;
7
+ /** The viewer socket belongs to the per-node run folder, not the durable records. */
8
+ export declare function viewSocketPath(nodeId: string): string;
9
+ export declare function nodesRoot(): string;
10
+ export declare function reviewsRoot(): string;
11
+ export declare function reviewDir(reviewId: string): string;
12
+ export declare function reviewResultPath(reviewId: string): string;
13
+ export declare function ensureReviewDir(reviewId: string): void;
14
+ /** Absolute path to crtrd's API listener unix socket, a sibling of
15
+ * `canvasDbPath()` under the canvas home (`~/.crouter/canvas/crtrd.sock` unless
16
+ * `CRTR_HOME` is set). Pure path builder — no db reach — so both the daemon
17
+ * (which binds it) and a CLI-side resolver can import it freely. `src/api`'s
18
+ * `defaultSocketPath()` (in `api/node-transport.ts`) deliberately re-derives
19
+ * this same path from a Node-builtin `join(crtrHome-equivalent, 'crtrd.sock')`
20
+ * rather than importing here, to keep the exported `/api` contract
21
+ * dependency-light; the two must agree. */
22
+ export declare function apiSocketPath(): string;
23
+ /** Global, cwd-agnostic broker cache of per-session picker metadata, keyed by
24
+ * absolute `.jsonl` path. Shared across every node's broker (paths are absolute)
25
+ * and survives broker restarts so the first `/resume` open after a restart is
26
+ * also fast. Machine-local performance state — safe to delete; rebuilt on demand. */
27
+ export declare function sessionListCachePath(): string;
28
+ /** Whether a persisted node ID is safe as a single filesystem path segment. */
29
+ export declare function isSafeNodeId(nodeId: string): boolean;
30
+ export declare function nodeRecordDir(nodeId: string): string;
31
+ /** Existing callers use nodeDir for the daemon's records only. */
32
+ export declare const nodeDir: typeof nodeRecordDir;
33
+ /** Pure path builder. A broker may resolve its own app from its launch identity;
34
+ * every other caller must supply the app from the daemon's node row. */
35
+ export declare function nodeAgentDir(nodeId: string, grantee?: string): string;
36
+ /** The home folder of one node of an isolated run, `/apps/<A>/nodes/<id>/home`, used
37
+ * in place of the app's shared `home` so two isolated runs share no writable folder. */
38
+ export declare function isolatedNodeHome(nodeId: string, grantee: string): string;
39
+ /** The HOME a node's processes get: its own home in an isolated run, else the app's. */
40
+ export declare function nodeHome(nodeId: string, grantee: string, isolated: boolean): string;
41
+ export declare function nodeRunDir(nodeId: string): string;
42
+ export declare function contextDir(nodeId: string, grantee?: string): string;
43
+ /** Replace the literal env-var tokens `$CRTR_CONTEXT_DIR` / `$CRTR_NODE_ID` in
44
+ * agent-facing doc text with the node's real values, so a doc surfaced INTO a
45
+ * node hands it a concrete absolute path instead of a shell-only string. The
46
+ * env vars only expand in bash; an agent that pastes `$CRTR_CONTEXT_DIR/x.md`
47
+ * into the (non-expanding) Write tool creates a literal `$CRTR_CONTEXT_DIR/`
48
+ * dir under the project repo. The resolved absolute path works in both bash and
49
+ * Write, so this substitution is strictly safer. Callers must only apply it
50
+ * when they have a concrete node to render for (never on generic, node-less
51
+ * output). Pure string work — no I/O. */
52
+ export declare function interpolateNodePaths(text: string, nodeId: string, grantee?: string): string;
53
+ export declare function jobDir(nodeId: string): string;
54
+ export declare function nodeRunJobDir(nodeId: string): string;
55
+ /** Overflow store for inbox entries whose inline body exceeds the digest's
56
+ * bounded preview (a direct `node message send` or a close/cancel doctrine
57
+ * wake) — the full body spills here, mirroring how a
58
+ * push's body always lives in a file, never inline; the inbox entry then
59
+ * carries `ref` at this path, same as a push pointer. */
60
+ export declare function messagesDir(nodeId: string): string;
61
+ export declare function nodeMetaPath(nodeId: string): string;
62
+ export declare function inboxPath(nodeId: string): string;
63
+ /** Rendered ambient context lives outside the user-authored context directory. */
64
+ export declare function situationalContextPath(nodeId: string): string;
65
+ export declare function passivePath(nodeId: string): string;
66
+ export declare function transcriptPath(nodeId: string): string;
67
+ export declare function sessionPtrPath(nodeId: string): string;
68
+ /** Create the full directory skeleton for a node. Idempotent. The agent and
69
+ * run folders are app-writable, so their subfolders are made beneath them. */
70
+ export declare function ensureNodeDirs(nodeId: string, grantee: string): void;
71
+ /** Ensure the canvas home exists. Idempotent. */
72
+ export declare function ensureHome(): void;
@@ -0,0 +1,163 @@
1
+ import { join } from 'node:path';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { appSpace, runtimeRoot, socketDir } from '../layout.js';
4
+ import { envGrantee, envNodeId } from '../../shared/env.js';
5
+ import { withDirBeneath } from '../spaces/open-beneath.js';
6
+ /** Runtime-owned records; never a bind source for an app sandbox. */
7
+ export function crtrHome() { return runtimeRoot(); }
8
+ /** The canvas home requires durable storage with SQLite WAL locking support. */
9
+ export function canvasDbPath() {
10
+ return join(crtrHome(), 'canvas.db');
11
+ }
12
+ export function onDiskMigrationLockPath() {
13
+ return join(crtrHome(), 'on-disk-migration.lock');
14
+ }
15
+ export function onDiskMigrationMarkerPath() {
16
+ return join(crtrHome(), 'on-disk-migrations.json');
17
+ }
18
+ /** The viewer socket belongs to the per-node run folder, not the durable records. */
19
+ export function viewSocketPath(nodeId) {
20
+ return join(nodeRunDir(nodeId), 'view.sock');
21
+ }
22
+ export function nodesRoot() {
23
+ return join(crtrHome(), 'nodes');
24
+ }
25
+ export function reviewsRoot() {
26
+ return join(crtrHome(), 'reviews');
27
+ }
28
+ export function reviewDir(reviewId) {
29
+ assertSafeNodeId(reviewId);
30
+ return join(reviewsRoot(), reviewId);
31
+ }
32
+ export function reviewResultPath(reviewId) {
33
+ return join(reviewDir(reviewId), 'result.json');
34
+ }
35
+ export function ensureReviewDir(reviewId) {
36
+ mkdirSync(reviewDir(reviewId), { recursive: true, mode: 0o700 });
37
+ }
38
+ /** Absolute path to crtrd's API listener unix socket, a sibling of
39
+ * `canvasDbPath()` under the canvas home (`~/.crouter/canvas/crtrd.sock` unless
40
+ * `CRTR_HOME` is set). Pure path builder — no db reach — so both the daemon
41
+ * (which binds it) and a CLI-side resolver can import it freely. `src/api`'s
42
+ * `defaultSocketPath()` (in `api/node-transport.ts`) deliberately re-derives
43
+ * this same path from a Node-builtin `join(crtrHome-equivalent, 'crtrd.sock')`
44
+ * rather than importing here, to keep the exported `/api` contract
45
+ * dependency-light; the two must agree. */
46
+ export function apiSocketPath() {
47
+ return join(socketDir(), 'api.sock');
48
+ }
49
+ /** Global, cwd-agnostic broker cache of per-session picker metadata, keyed by
50
+ * absolute `.jsonl` path. Shared across every node's broker (paths are absolute)
51
+ * and survives broker restarts so the first `/resume` open after a restart is
52
+ * also fast. Machine-local performance state — safe to delete; rebuilt on demand. */
53
+ export function sessionListCachePath() {
54
+ return join(crtrHome(), 'session-list-cache.json');
55
+ }
56
+ const MAX_NODE_ID_BYTES = 128;
57
+ /** Whether a persisted node ID is safe as a single filesystem path segment. */
58
+ export function isSafeNodeId(nodeId) {
59
+ return typeof nodeId === 'string'
60
+ && nodeId !== ''
61
+ && nodeId !== '.'
62
+ && nodeId !== '..'
63
+ && !/[\\/\0]/u.test(nodeId)
64
+ && Buffer.byteLength(nodeId, 'utf8') <= MAX_NODE_ID_BYTES;
65
+ }
66
+ function assertSafeNodeId(nodeId) {
67
+ if (!isSafeNodeId(nodeId)) {
68
+ throw new TypeError('node_id must be a non-empty path segment of at most 128 UTF-8 bytes');
69
+ }
70
+ }
71
+ export function nodeRecordDir(nodeId) {
72
+ assertSafeNodeId(nodeId);
73
+ return join(nodesRoot(), nodeId);
74
+ }
75
+ /** Existing callers use nodeDir for the daemon's records only. */
76
+ export const nodeDir = nodeRecordDir;
77
+ /** Pure path builder. A broker may resolve its own app from its launch identity;
78
+ * every other caller must supply the app from the daemon's node row. */
79
+ export function nodeAgentDir(nodeId, grantee) {
80
+ assertSafeNodeId(nodeId);
81
+ const app = grantee ?? (envNodeId() === nodeId ? envGrantee() : undefined);
82
+ if (!app)
83
+ throw new Error(`node ${nodeId} has no app assignment`);
84
+ return join(appSpace(app).nodes, nodeId);
85
+ }
86
+ /** The home folder of one node of an isolated run, `/apps/<A>/nodes/<id>/home`, used
87
+ * in place of the app's shared `home` so two isolated runs share no writable folder. */
88
+ export function isolatedNodeHome(nodeId, grantee) {
89
+ return join(nodeAgentDir(nodeId, grantee), 'home');
90
+ }
91
+ /** The HOME a node's processes get: its own home in an isolated run, else the app's. */
92
+ export function nodeHome(nodeId, grantee, isolated) {
93
+ return isolated ? isolatedNodeHome(nodeId, grantee) : appSpace(grantee).home;
94
+ }
95
+ export function nodeRunDir(nodeId) {
96
+ assertSafeNodeId(nodeId);
97
+ return process.platform === 'linux' ? join(socketDir(), 'nodes', nodeId) : join(nodeRecordDir(nodeId), 'run');
98
+ }
99
+ export function contextDir(nodeId, grantee) {
100
+ return join(nodeAgentDir(nodeId, grantee), 'context');
101
+ }
102
+ /** Replace the literal env-var tokens `$CRTR_CONTEXT_DIR` / `$CRTR_NODE_ID` in
103
+ * agent-facing doc text with the node's real values, so a doc surfaced INTO a
104
+ * node hands it a concrete absolute path instead of a shell-only string. The
105
+ * env vars only expand in bash; an agent that pastes `$CRTR_CONTEXT_DIR/x.md`
106
+ * into the (non-expanding) Write tool creates a literal `$CRTR_CONTEXT_DIR/`
107
+ * dir under the project repo. The resolved absolute path works in both bash and
108
+ * Write, so this substitution is strictly safer. Callers must only apply it
109
+ * when they have a concrete node to render for (never on generic, node-less
110
+ * output). Pure string work — no I/O. */
111
+ export function interpolateNodePaths(text, nodeId, grantee) {
112
+ return text
113
+ .replaceAll('$CRTR_CONTEXT_DIR', contextDir(nodeId, grantee))
114
+ .replaceAll('$CRTR_NODE_ID', nodeId);
115
+ }
116
+ export function jobDir(nodeId) {
117
+ return join(nodeRecordDir(nodeId), 'job');
118
+ }
119
+ export function nodeRunJobDir(nodeId) {
120
+ return join(nodeRunDir(nodeId), 'job');
121
+ }
122
+ /** Overflow store for inbox entries whose inline body exceeds the digest's
123
+ * bounded preview (a direct `node message send` or a close/cancel doctrine
124
+ * wake) — the full body spills here, mirroring how a
125
+ * push's body always lives in a file, never inline; the inbox entry then
126
+ * carries `ref` at this path, same as a push pointer. */
127
+ export function messagesDir(nodeId) {
128
+ return join(nodeDir(nodeId), 'messages');
129
+ }
130
+ export function nodeMetaPath(nodeId) {
131
+ return join(nodeDir(nodeId), 'meta.json');
132
+ }
133
+ export function inboxPath(nodeId) {
134
+ return join(nodeDir(nodeId), 'inbox.jsonl');
135
+ }
136
+ /** Rendered ambient context lives outside the user-authored context directory. */
137
+ export function situationalContextPath(nodeId) {
138
+ return join(nodeRunDir(nodeId), 'situational-context.md');
139
+ }
140
+ export function passivePath(nodeId) {
141
+ return join(nodeDir(nodeId), 'passive.jsonl');
142
+ }
143
+ export function transcriptPath(nodeId) {
144
+ return join(nodeDir(nodeId), 'transcript.jsonl');
145
+ }
146
+ export function sessionPtrPath(nodeId) {
147
+ return join(nodeDir(nodeId), 'session.ptr');
148
+ }
149
+ /** Create the full directory skeleton for a node. Idempotent. The agent and
150
+ * run folders are app-writable, so their subfolders are made beneath them. */
151
+ export function ensureNodeDirs(nodeId, grantee) {
152
+ const agent = nodeAgentDir(nodeId, grantee);
153
+ mkdirSync(nodeRecordDir(nodeId), { recursive: true });
154
+ mkdirSync(jobDir(nodeId), { recursive: true });
155
+ for (const d of ['context', 'reports', 'attachments', 'worktree']) {
156
+ withDirBeneath(appSpace(grantee).nodes, join(agent, d), () => undefined, true);
157
+ }
158
+ withDirBeneath(nodeRunDir(nodeId), nodeRunJobDir(nodeId), () => undefined, true);
159
+ }
160
+ /** Ensure the canvas home exists. Idempotent. */
161
+ export function ensureHome() {
162
+ mkdirSync(nodesRoot(), { recursive: true });
163
+ }