@crouter/api 0.3.377

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 (112) hide show
  1. package/README.md +67 -0
  2. package/dist/api/__tests__/error-codes.test.d.ts +1 -0
  3. package/dist/api/__tests__/error-codes.test.js +78 -0
  4. package/dist/api/__tests__/integration/client.test.d.ts +1 -0
  5. package/dist/api/__tests__/integration/client.test.js +179 -0
  6. package/dist/api/client.d.ts +467 -0
  7. package/dist/api/client.js +1179 -0
  8. package/dist/api/command-manifest/index.d.ts +3 -0
  9. package/dist/api/command-manifest/index.js +3 -0
  10. package/dist/api/command-manifest/manifest.d.ts +51 -0
  11. package/dist/api/command-manifest/manifest.js +332 -0
  12. package/dist/api/command-manifest/result.d.ts +25 -0
  13. package/dist/api/command-manifest/result.js +97 -0
  14. package/dist/api/command-manifest/schema.d.ts +28 -0
  15. package/dist/api/command-manifest/schema.js +856 -0
  16. package/dist/api/dto/analytics.d.ts +184 -0
  17. package/dist/api/dto/analytics.js +3 -0
  18. package/dist/api/dto/attach.d.ts +22 -0
  19. package/dist/api/dto/attach.js +13 -0
  20. package/dist/api/dto/bash-jobs.d.ts +24 -0
  21. package/dist/api/dto/bash-jobs.js +9 -0
  22. package/dist/api/dto/bash.d.ts +17 -0
  23. package/dist/api/dto/bash.js +1 -0
  24. package/dist/api/dto/broker-ops.d.ts +187 -0
  25. package/dist/api/dto/broker-ops.js +6 -0
  26. package/dist/api/dto/broker-signals.d.ts +25 -0
  27. package/dist/api/dto/broker-signals.js +1 -0
  28. package/dist/api/dto/broker.d.ts +86 -0
  29. package/dist/api/dto/broker.js +20 -0
  30. package/dist/api/dto/canvas.d.ts +359 -0
  31. package/dist/api/dto/canvas.js +2 -0
  32. package/dist/api/dto/chat-inventory.d.ts +56 -0
  33. package/dist/api/dto/chat-inventory.js +11 -0
  34. package/dist/api/dto/common.d.ts +29 -0
  35. package/dist/api/dto/common.js +15 -0
  36. package/dist/api/dto/config.d.ts +36 -0
  37. package/dist/api/dto/config.js +3 -0
  38. package/dist/api/dto/crons.d.ts +150 -0
  39. package/dist/api/dto/crons.js +10 -0
  40. package/dist/api/dto/custom-objects.d.ts +66 -0
  41. package/dist/api/dto/custom-objects.js +1 -0
  42. package/dist/api/dto/delivery.d.ts +71 -0
  43. package/dist/api/dto/delivery.js +7 -0
  44. package/dist/api/dto/docs.d.ts +135 -0
  45. package/dist/api/dto/docs.js +8 -0
  46. package/dist/api/dto/files.d.ts +21 -0
  47. package/dist/api/dto/files.js +1 -0
  48. package/dist/api/dto/focus.d.ts +24 -0
  49. package/dist/api/dto/focus.js +10 -0
  50. package/dist/api/dto/grants.d.ts +14 -0
  51. package/dist/api/dto/grants.js +1 -0
  52. package/dist/api/dto/health.d.ts +106 -0
  53. package/dist/api/dto/health.js +2 -0
  54. package/dist/api/dto/human-requests.d.ts +113 -0
  55. package/dist/api/dto/human-requests.js +4 -0
  56. package/dist/api/dto/human.d.ts +28 -0
  57. package/dist/api/dto/human.js +4 -0
  58. package/dist/api/dto/inbox.d.ts +273 -0
  59. package/dist/api/dto/inbox.js +4 -0
  60. package/dist/api/dto/lifecycle.d.ts +88 -0
  61. package/dist/api/dto/lifecycle.js +3 -0
  62. package/dist/api/dto/mail.d.ts +44 -0
  63. package/dist/api/dto/mail.js +1 -0
  64. package/dist/api/dto/messages.d.ts +88 -0
  65. package/dist/api/dto/messages.js +2 -0
  66. package/dist/api/dto/model-config.d.ts +25 -0
  67. package/dist/api/dto/model-config.js +1 -0
  68. package/dist/api/dto/modelauth.d.ts +132 -0
  69. package/dist/api/dto/modelauth.js +4 -0
  70. package/dist/api/dto/node-events.d.ts +65 -0
  71. package/dist/api/dto/node-events.js +4 -0
  72. package/dist/api/dto/node-outcomes.d.ts +88 -0
  73. package/dist/api/dto/node-outcomes.js +2 -0
  74. package/dist/api/dto/node-records.d.ts +35 -0
  75. package/dist/api/dto/node-records.js +5 -0
  76. package/dist/api/dto/nodes.d.ts +368 -0
  77. package/dist/api/dto/nodes.js +3 -0
  78. package/dist/api/dto/objects.d.ts +172 -0
  79. package/dist/api/dto/objects.js +5 -0
  80. package/dist/api/dto/profiles.d.ts +117 -0
  81. package/dist/api/dto/profiles.js +4 -0
  82. package/dist/api/dto/recovery.d.ts +104 -0
  83. package/dist/api/dto/recovery.js +1 -0
  84. package/dist/api/dto/reports.d.ts +93 -0
  85. package/dist/api/dto/reports.js +2 -0
  86. package/dist/api/dto/review-comments.d.ts +146 -0
  87. package/dist/api/dto/review-comments.js +5 -0
  88. package/dist/api/dto/reviews.d.ts +113 -0
  89. package/dist/api/dto/reviews.js +5 -0
  90. package/dist/api/dto/run-events.d.ts +293 -0
  91. package/dist/api/dto/run-events.js +6 -0
  92. package/dist/api/dto/subscriptions.d.ts +14 -0
  93. package/dist/api/dto/subscriptions.js +2 -0
  94. package/dist/api/dto/worktree.d.ts +55 -0
  95. package/dist/api/dto/worktree.js +6 -0
  96. package/dist/api/error-codes.d.ts +254 -0
  97. package/dist/api/error-codes.js +54 -0
  98. package/dist/api/errors.d.ts +47 -0
  99. package/dist/api/errors.js +66 -0
  100. package/dist/api/index.d.ts +42 -0
  101. package/dist/api/index.js +41 -0
  102. package/dist/api/node-transport.d.ts +18 -0
  103. package/dist/api/node-transport.js +105 -0
  104. package/dist/api/plugin-manifest-schema.d.ts +233 -0
  105. package/dist/api/plugin-manifest-schema.js +23 -0
  106. package/dist/api/routes.d.ts +160 -0
  107. package/dist/api/routes.js +193 -0
  108. package/dist/shared/generated-context.d.ts +79 -0
  109. package/dist/shared/generated-context.js +232 -0
  110. package/dist/shared/predicates.d.ts +2 -0
  111. package/dist/shared/predicates.js +4 -0
  112. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @crouter/api — the typed client and contract for the crtrd `/v1` API
2
+
3
+ ![crouter](https://raw.githubusercontent.com/crouton-labs/crouter/main/assets/banner.svg)
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@crouter/api"><img alt="npm" src="https://img.shields.io/npm/v/@crouter/api?label=npm"></a>
7
+ <a href="https://github.com/crouton-labs/crouter/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/badge/license-GPL--3.0-blue"></a>
8
+ </p>
9
+
10
+ `@crouter/api` is the low-level contract for the crouter daemon's `/v1` HTTP API: the `CrtrClient` class, route builders, the API error type, request and response types (DTOs), and the schema for command-plugin manifests. It has no runtime dependencies and no install scripts.
11
+
12
+ It is not the package most applications want. [`@crouter/sdk`](https://www.npmjs.com/package/@crouter/sdk) is built on it and adds run management, streaming, typed results and OAuth. Use `@crouter/api` when you need the wire contract itself — a thin client, a proxy, or tooling that validates plugin manifests.
13
+
14
+ [Docs](https://docs.crouter.ai/docs/sdk) · [SDK resource map](https://docs.crouter.ai/docs/sdk/resources) · [crouter repository](https://github.com/crouton-labs/crouter) · [Source](https://github.com/crouton-labs/crouter/tree/main/src/api)
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @crouter/api
20
+ ```
21
+
22
+ The package is ESM-only. Import it from an ES module, or use dynamic `import()` from CommonJS. A crouter daemon (`npm install -g crouter`) must be reachable for any request.
23
+
24
+ ## Connect to a daemon
25
+
26
+ `CrtrClient` is the typed HTTP client. Give it a TCP base URL and any required bearer header. `crtr sys connect` prints both for a local daemon.
27
+
28
+ ```ts
29
+ import { CrtrClient } from '@crouter/api';
30
+
31
+ const client = new CrtrClient({
32
+ baseUrl: 'https://my-crtrd.example.com',
33
+ headers: { authorization: 'Bearer a-daemon-token' },
34
+ });
35
+
36
+ const nodes = await client.listNodes();
37
+ console.log(nodes.map((node) => node.node_id));
38
+ ```
39
+
40
+ For the local Unix socket, import the Node-only entry point:
41
+
42
+ ```ts
43
+ import { localClient } from '@crouter/api/node';
44
+
45
+ const client = localClient();
46
+ console.log(await client.healthz());
47
+ ```
48
+
49
+ ## Exports
50
+
51
+ - `CrtrClient` and `waitForDaemonAvailability` for typed `/v1` requests.
52
+ - `routes` and `API_VERSION` for route construction.
53
+ - `ApiError`, `APIError`, `ErrorBody` and `isErrorBody` for the API error contract.
54
+ - DTOs for canvas, nodes, reports, lifecycle, files, bash, memory, models, human requests, crons, reviews and related routes.
55
+ - `@crouter/api/node` — `defaultSocketPath`, `socketFetch` and `localClient`.
56
+ - `@crouter/api/plugin-manifest` and `@crouter/api/command-manifest` — the plugin command-manifest schema and validation types.
57
+ - `@crouter/api/cards` — generated context types.
58
+
59
+ ## Related
60
+
61
+ - [`@crouter/sdk`](../crouter-sdk) — the client most applications should use.
62
+ - [`@crouter/plugin`](../crouter-plugin) — author plugins whose manifests use this package's types.
63
+ - [Main repository](https://github.com/crouton-labs/crouter) — the `crtr` CLI, the `crtrd` daemon, and contribution guidelines.
64
+
65
+ ## License
66
+
67
+ [GPL-3.0-only](https://github.com/crouton-labs/crouter/blob/main/LICENSE).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import ts from 'typescript';
7
+ import { errorCodes } from '../error-codes.js';
8
+ test('SDK and API expose one fixed code table with contract 2 tuples', () => {
9
+ assert.deepEqual(errorCodes.server_error, { type: 'server_error', status: 500, retryable: true });
10
+ assert.deepEqual(errorCodes.runtime_starting, { type: 'server_error', status: 503, retryable: true });
11
+ assert.deepEqual(errorCodes.payer_not_app, { type: 'permission_error', status: 403, retryable: false });
12
+ assert.deepEqual(errorCodes.cap_exhausted, { type: 'rate_limit_error', status: 402, retryable: false });
13
+ assert.deepEqual(errorCodes.concurrency_limit, { type: 'rate_limit_error', status: 429, retryable: true });
14
+ for (const code of ['grant_revoked', 'grant_removed', 'grant_suspended', 'refresh_token_expired', 'refresh_token_reused']) {
15
+ assert.deepEqual(errorCodes[code], { type: 'authentication_error', status: 401, retryable: false }, code);
16
+ }
17
+ assert.equal('provider_error' in errorCodes, false); // provider's 4xx status varies
18
+ assert.equal('usage' in errorCodes, false); // legacy socket-only code
19
+ assert.equal('stream_gap' in errorCodes, false); // stream protocol, not a fixed HTTP row
20
+ });
21
+ test('every statically named RunError and ProviderError agrees with the fixed table', () => {
22
+ const src = fileURLToPath(new URL('../..', import.meta.url));
23
+ const files = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
24
+ const path = join(dir, entry.name);
25
+ return entry.isDirectory() ? files(path) : entry.name.endsWith('.ts') && !path.includes('/__tests__/') ? [path] : [];
26
+ });
27
+ const literals = (expr) => {
28
+ if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr))
29
+ return [ts.isNumericLiteral(expr) ? Number(expr.text) : expr.text];
30
+ if (ts.isConditionalExpression(expr))
31
+ return [...literals(expr.whenTrue), ...literals(expr.whenFalse)];
32
+ return [];
33
+ };
34
+ let checked = 0;
35
+ for (const path of files(src)) {
36
+ const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
37
+ const visit = (node) => {
38
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && ['RunError', 'ProviderError'].includes(node.expression.text)) {
39
+ const args = node.arguments ?? [];
40
+ const run = node.expression.text === 'RunError';
41
+ const codes = args[run ? 1 : 0] ? literals(args[run ? 1 : 0]) : [];
42
+ const statuses = run && args[0] ? literals(args[0]) : [];
43
+ const options = !run && args[2] && ts.isObjectLiteralExpression(args[2]) ? args[2] : undefined;
44
+ const override = options?.properties.find((prop) => ts.isPropertyAssignment(prop) && prop.name.getText(source) === 'status');
45
+ const providerStatuses = override && ts.isPropertyAssignment(override) ? literals(override.initializer) : [];
46
+ for (const code of codes) {
47
+ if (typeof code !== 'string' || ['provider_error', 'usage', 'stream_gap', 'stream_dropped', 'stream_error'].includes(code))
48
+ continue;
49
+ const row = errorCodes[code];
50
+ assert.ok(row, `${path}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}: ${code} missing from errorCodes`);
51
+ for (const status of run ? statuses : providerStatuses)
52
+ assert.equal(status, row.status, `${path}: ${code} status`);
53
+ if (run && args[6] && args[6].kind !== ts.SyntaxKind.UndefinedKeyword && args[6].kind !== ts.SyntaxKind.NullKeyword) {
54
+ const retry = args[6].kind === ts.SyntaxKind.TrueKeyword ? true : args[6].kind === ts.SyntaxKind.FalseKeyword ? false : undefined;
55
+ if (retry !== undefined)
56
+ assert.equal(retry, row.retryable, `${path}: ${code} retryable`);
57
+ }
58
+ else if (run && statuses.length) {
59
+ for (const status of statuses)
60
+ assert.equal(status === 503, row.retryable, `${path}: ${code} default retryable`);
61
+ }
62
+ checked++;
63
+ }
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(source);
68
+ }
69
+ assert.ok(checked > 100, `checked only ${checked} throw sites`);
70
+ });
71
+ test('app origin belongs to the full HTTP envelope without a body status', () => {
72
+ const row = errorCodes.invalid_request;
73
+ const body = { error: { code: 'invalid_request', message: 'Missing prompt',
74
+ type: row.type, origin: 'app', retryable: row.retryable, request_id: 'request-1', param: 'prompt' } };
75
+ assert.equal(body.error.origin, 'app');
76
+ assert.equal('status' in body.error, false);
77
+ assert.equal(row.status, 400);
78
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,179 @@
1
+ // Client availability recovery regressions.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { createServer } from 'node:http';
5
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
9
+ import { localClient } from '../../node-transport.js';
10
+ import { ApiError } from '../../errors.js';
11
+ function startDelayedHealthzServer(socketPath, delayMs) {
12
+ const server = createServer((_req, res) => {
13
+ res.writeHead(200, { 'content-type': 'application/json' });
14
+ res.end(JSON.stringify({ ok: true }));
15
+ });
16
+ const timer = setTimeout(() => server.listen(socketPath), delayMs);
17
+ return { server, cancel: () => clearTimeout(timer) };
18
+ }
19
+ // A dropped response is distinct from a refused connect. Its cause is unknown, so reads may wait and retry but mutations never replay.
20
+ function startHandoverServer(socketPath) {
21
+ let dropped = false;
22
+ const server = createServer((req, res) => {
23
+ if (!dropped) {
24
+ dropped = true; // model the old server dying mid-request
25
+ req.socket.destroy();
26
+ return;
27
+ }
28
+ res.writeHead(200, { 'content-type': 'application/json' });
29
+ res.end(JSON.stringify({ ok: true, method: req.method }));
30
+ });
31
+ return {
32
+ server,
33
+ ready: new Promise((resolvePromise) => server.listen(socketPath, resolvePromise)),
34
+ };
35
+ }
36
+ test('a hang-up mid-request rides out the daemon handover and replays the idempotent call', async () => {
37
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
38
+ const socketPath = join(dir, 'crtrd.sock');
39
+ const { server, ready } = startHandoverServer(socketPath);
40
+ try {
41
+ await ready;
42
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
43
+ assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
44
+ }
45
+ finally {
46
+ server.close();
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ test('an interrupted mutation is not replayed or labelled a daemon handover', async () => {
51
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
52
+ const socketPath = join(dir, 'crtrd.sock');
53
+ const { server, ready } = startHandoverServer(socketPath);
54
+ try {
55
+ await ready;
56
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
57
+ await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
58
+ // Replaying is unsafe (the daemon may have applied it), so this one is the
59
+ // caller's call — but it must not be reported as a daemon that is down.
60
+ (err) => err instanceof ApiError && err.code === 'daemon_request_interrupted');
61
+ }
62
+ finally {
63
+ server.close();
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+ test('a non-socket cold-start path surfaces its diagnostic after autostart times out', async () => {
68
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-nonsocket-'));
69
+ const socketPath = join(dir, 'crtrd.sock');
70
+ mkdirSync(socketPath);
71
+ const diagnostic = 'crtrd.log (tail):\napi.server.failed: EADDRINUSE';
72
+ const client = localClient({
73
+ socketPath,
74
+ autostart: true,
75
+ onColdSocket: async () => { },
76
+ coldStartPollWindowMs: 50,
77
+ coldStartDiagnostic: () => diagnostic,
78
+ });
79
+ try {
80
+ await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError
81
+ && error.code === 'daemon_unavailable'
82
+ && error.message.includes(diagnostic));
83
+ }
84
+ finally {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ test('cliClient-style cold start fails loud when the injected poll window is shorter than the (valid) startup delay', async () => {
89
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
90
+ const socketPath = join(dir, 'crtrd.sock');
91
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
92
+ try {
93
+ const client = localClient({
94
+ socketPath,
95
+ autostart: true,
96
+ onColdSocket: async () => {
97
+ /* fire-and-forget: the delayed listen() above models ensureDaemon() */
98
+ },
99
+ coldStartPollWindowMs: 100, // shorter than the 300ms the daemon actually needs
100
+ });
101
+ await assert.rejects(() => client.healthz(), (err) => err instanceof ApiError && err.code === 'daemon_unavailable');
102
+ }
103
+ finally {
104
+ cancel(); // the client already timed out at 100ms; never let listen() fire at 300ms
105
+ server.close();
106
+ rmSync(dir, { recursive: true, force: true });
107
+ }
108
+ });
109
+ test('a disabled-autostart client waits for an externally managed listener without invoking its spawn hook', async () => {
110
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
111
+ const socketPath = join(dir, 'crtrd.sock');
112
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
113
+ let spawnAttempts = 0;
114
+ try {
115
+ const client = localClient({
116
+ socketPath,
117
+ autostart: false,
118
+ onColdSocket: async () => { spawnAttempts += 1; },
119
+ coldStartPollWindowMs: 1_000,
120
+ });
121
+ assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true });
122
+ assert.equal(spawnAttempts, 0, 'disabled autostart only waits; it never invokes the spawn hook');
123
+ }
124
+ finally {
125
+ cancel();
126
+ server.close();
127
+ rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+ test('availability expiry keeps the final transport failure inside one wall-clock budget', async () => {
131
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
132
+ const socketPath = join(dir, 'crtrd.sock');
133
+ const server = createServer((_req, _res) => { });
134
+ let listenTimer;
135
+ try {
136
+ const client = localClient({
137
+ socketPath,
138
+ autostart: true,
139
+ timeoutMs: 5_000,
140
+ coldStartPollWindowMs: 250,
141
+ onColdSocket: async () => { listenTimer = setTimeout(() => server.listen(socketPath), 10); },
142
+ });
143
+ const started = Date.now();
144
+ await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError && error.code === 'request_timeout' && error.message.includes('request timed out'));
145
+ assert.ok(Date.now() - started < 500, 'a probe cannot extend the 250ms availability budget');
146
+ }
147
+ finally {
148
+ if (listenTimer !== undefined)
149
+ clearTimeout(listenTimer);
150
+ server.close();
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ });
154
+ test('a caller abort with a custom reason is request_aborted', async () => {
155
+ const controller = new AbortController();
156
+ const client = new CrtrClient({
157
+ baseUrl: 'http://fixture.test',
158
+ fetch: async (_input, init) => await new Promise((_resolve, reject) => {
159
+ const signal = init?.signal;
160
+ if (signal === null || signal === undefined)
161
+ throw new Error('request signal is required');
162
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
163
+ }),
164
+ });
165
+ const request = client.request('GET', '/healthz', undefined, { signal: controller.signal });
166
+ controller.abort(new Error('caller custom abort'));
167
+ await assert.rejects(request, (error) => error instanceof ApiError && error.code === 'request_aborted');
168
+ });
169
+ test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
170
+ assert.equal(safeColdStartDiagnostic(undefined), undefined);
171
+ });
172
+ test('safeColdStartDiagnostic returns the hook result when it succeeds', () => {
173
+ assert.equal(safeColdStartDiagnostic(() => 'crtrd.log (tail):\nboom'), 'crtrd.log (tail):\nboom');
174
+ });
175
+ test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated error', () => {
176
+ assert.equal(safeColdStartDiagnostic(() => {
177
+ throw new Error('custom hook blew up');
178
+ }), undefined);
179
+ });