@crouter/api 0.3.387 → 0.3.389
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/__tests__/integration/client.test.js +97 -0
- package/dist/api/client.d.ts +7 -0
- package/dist/api/client.js +40 -21
- package/dist/api/dto/config.d.ts +11 -1
- package/dist/core/asset-root.d.ts +7 -0
- package/dist/core/asset-root.js +18 -0
- package/dist/core/canvas/boot-id.d.ts +6 -0
- package/dist/core/canvas/boot-id.js +26 -0
- package/dist/core/canvas/paths.d.ts +72 -0
- package/dist/core/canvas/paths.js +163 -0
- package/dist/core/canvas/pid.d.ts +391 -0
- package/dist/core/canvas/pid.js +948 -0
- package/dist/core/command-plugins/bundle.d.ts +149 -0
- package/dist/core/command-plugins/bundle.js +588 -0
- package/dist/core/command-plugins/endpoint.d.ts +24 -0
- package/dist/core/command-plugins/endpoint.js +51 -0
- package/dist/core/config.d.ts +233 -0
- package/dist/core/config.js +1120 -0
- package/dist/core/env-name.d.ts +6 -0
- package/dist/core/env-name.js +9 -0
- package/dist/core/errors.d.ts +38 -0
- package/dist/core/errors.js +90 -0
- package/dist/core/events/emit.d.ts +6 -0
- package/dist/core/events/emit.js +42 -0
- package/dist/core/events/envelope.d.ts +2 -0
- package/dist/core/events/envelope.js +84 -0
- package/dist/core/events/errors.d.ts +4 -0
- package/dist/core/events/errors.js +69 -0
- package/dist/core/events/operation-id.d.ts +4 -0
- package/dist/core/events/operation-id.js +24 -0
- package/dist/core/events/serialize.d.ts +4 -0
- package/dist/core/events/serialize.js +199 -0
- package/dist/core/events/source.d.ts +16 -0
- package/dist/core/events/source.js +31 -0
- package/dist/core/events/types.d.ts +68 -0
- package/dist/core/events/types.js +11 -0
- package/dist/core/exclusive-lock.d.ts +34 -0
- package/dist/core/exclusive-lock.js +197 -0
- package/dist/core/fs-utils.d.ts +44 -0
- package/dist/core/fs-utils.js +208 -0
- package/dist/core/help.d.ts +309 -0
- package/dist/core/help.js +406 -0
- package/dist/core/human/page-catalog.d.ts +57 -0
- package/dist/core/human/page-catalog.js +172 -0
- package/dist/core/installed-plugins.d.ts +2 -0
- package/dist/core/installed-plugins.js +79 -0
- package/dist/core/io.d.ts +122 -0
- package/dist/core/io.js +373 -0
- package/dist/core/keybindings/attach-control.d.ts +49 -0
- package/dist/core/keybindings/attach-control.js +42 -0
- package/dist/core/keybindings/catalog.d.ts +18 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/types.d.ts +42 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/layout.d.ts +26 -0
- package/dist/core/layout.js +94 -0
- package/dist/core/locked-file.d.ts +27 -0
- package/dist/core/locked-file.js +118 -0
- package/dist/core/log.d.ts +9 -0
- package/dist/core/log.js +89 -0
- package/dist/core/manifest.d.ts +5 -0
- package/dist/core/manifest.js +15 -0
- package/dist/core/plugin-env.d.ts +8 -0
- package/dist/core/plugin-env.js +31 -0
- package/dist/core/plugin-extensions.d.ts +29 -0
- package/dist/core/plugin-extensions.js +191 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +26 -0
- package/dist/core/profiles/env-store.d.ts +22 -0
- package/dist/core/profiles/env-store.js +163 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +120 -0
- package/dist/core/profiles/manifest.js +529 -0
- package/dist/core/rate-limit-scope.d.ts +25 -0
- package/dist/core/rate-limit-scope.js +64 -0
- package/dist/core/render.d.ts +12 -0
- package/dist/core/render.js +138 -0
- package/dist/core/resolver.d.ts +14 -0
- package/dist/core/resolver.js +111 -0
- package/dist/core/runtime/branded-host.d.ts +25 -0
- package/dist/core/runtime/branded-host.js +264 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
- package/dist/core/runtime/broker/daemon-ops.js +177 -0
- package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
- package/dist/core/runtime/broker/signal-stream.js +149 -0
- package/dist/core/scope.d.ts +32 -0
- package/dist/core/scope.js +184 -0
- package/dist/core/scoped-state/db.d.ts +17 -0
- package/dist/core/scoped-state/db.js +247 -0
- package/dist/core/scoped-state/migrate.d.ts +8 -0
- package/dist/core/scoped-state/migrate.js +187 -0
- package/dist/core/scoped-state/paths.d.ts +9 -0
- package/dist/core/scoped-state/paths.js +27 -0
- package/dist/core/scoped-state/profiles.d.ts +27 -0
- package/dist/core/scoped-state/profiles.js +93 -0
- package/dist/core/scoped-state/providers.d.ts +24 -0
- package/dist/core/scoped-state/providers.js +19 -0
- package/dist/core/scoped-state/schema.d.ts +6 -0
- package/dist/core/scoped-state/schema.js +43 -0
- package/dist/core/scoped-state/settings.d.ts +28 -0
- package/dist/core/scoped-state/settings.js +83 -0
- package/dist/core/spaces/open-beneath.d.ts +71 -0
- package/dist/core/spaces/open-beneath.js +581 -0
- package/dist/core/sqlite-statements.d.ts +4 -0
- package/dist/core/sqlite-statements.js +17 -0
- package/dist/core/subscription-state.d.ts +121 -0
- package/dist/core/subscription-state.js +287 -0
- package/dist/core/user-settings.d.ts +377 -0
- package/dist/core/user-settings.js +458 -0
- package/dist/daemon/broker-signals/bus.d.ts +30 -0
- package/dist/daemon/broker-signals/bus.js +87 -0
- package/dist/daemon/manage.d.ts +176 -0
- package/dist/daemon/manage.js +664 -0
- package/dist/daemon/pidfile.d.ts +8 -0
- package/dist/daemon/pidfile.js +37 -0
- package/dist/daemon/startup-policy.d.ts +1 -0
- package/dist/daemon/startup-policy.js +1 -0
- package/dist/native/linux.d.ts +29 -0
- package/dist/native/linux.js +20 -0
- package/dist/shared/env.d.ts +116 -0
- package/dist/shared/env.js +271 -0
- package/dist/shared/inbox-entry-body.d.ts +22 -0
- package/dist/shared/inbox-entry-body.js +116 -0
- package/dist/shared/working-activity.d.ts +9 -0
- package/dist/shared/working-activity.js +27 -0
- package/dist/types.d.ts +562 -0
- package/dist/types.js +186 -0
- 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
|
+
});
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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);
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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);
|
package/dist/api/dto/config.d.ts
CHANGED
|
@@ -14,8 +14,18 @@ export interface NodeConfigPatch {
|
|
|
14
14
|
lifecycle?: LifecycleDTO;
|
|
15
15
|
/** Rename the node (and, when it has a live viewer window, that window). */
|
|
16
16
|
name?: string;
|
|
17
|
-
/**
|
|
17
|
+
/** Replacement launch directory; exclusive of every other field except `keep_session` and `restart`.
|
|
18
|
+
* Without `keep_session` it is repair-only (dormant node, recorded cwd gone, Pi session cleared). */
|
|
18
19
|
cwd?: string;
|
|
20
|
+
/** With `cwd`: the new path is the SAME directory as the recorded one (a symlink alias, or an
|
|
21
|
+
* alias that is already gone — the caller asserts it). Relocates without clearing the Pi session
|
|
22
|
+
* and rewrites the transcript header's cwd; allowed for live nodes. */
|
|
23
|
+
keep_session?: boolean;
|
|
24
|
+
/** Reassign the node to this profile (id or exact name). Exclusive of every other field except `restart`. */
|
|
25
|
+
profile?: string;
|
|
26
|
+
/** With `profile` or `cwd`+`keep_session` on a live node: SIGTERM the broker after the change so the
|
|
27
|
+
* daemon resumes it (same transcript) with the new profile/cwd. Ignored for a node with no live broker. */
|
|
28
|
+
restart?: boolean;
|
|
19
29
|
/** Per-run allow-list. A patch may only remove scopes; it applies to a live broker on its next revive. */
|
|
20
30
|
scopes?: string[];
|
|
21
31
|
/** The node's default listening configuration: event level ("1".."10") → delivery
|
|
@@ -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
|
+
}
|