@north-light/api 0.0.0-stage → 0.3.414

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 (247) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +66 -2
  3. package/dist/api/__tests__/error-codes.test.d.ts +1 -0
  4. package/dist/api/__tests__/error-codes.test.js +79 -0
  5. package/dist/api/__tests__/integration/client.test.d.ts +1 -0
  6. package/dist/api/__tests__/integration/client.test.js +276 -0
  7. package/dist/api/client.d.ts +501 -0
  8. package/dist/api/client.js +1281 -0
  9. package/dist/api/command-manifest/index.d.ts +3 -0
  10. package/dist/api/command-manifest/index.js +3 -0
  11. package/dist/api/command-manifest/manifest.d.ts +51 -0
  12. package/dist/api/command-manifest/manifest.js +332 -0
  13. package/dist/api/command-manifest/result.d.ts +25 -0
  14. package/dist/api/command-manifest/result.js +97 -0
  15. package/dist/api/command-manifest/schema.d.ts +28 -0
  16. package/dist/api/command-manifest/schema.js +856 -0
  17. package/dist/api/dto/analytics.d.ts +184 -0
  18. package/dist/api/dto/analytics.js +3 -0
  19. package/dist/api/dto/attach.d.ts +22 -0
  20. package/dist/api/dto/attach.js +13 -0
  21. package/dist/api/dto/bash-jobs.d.ts +24 -0
  22. package/dist/api/dto/bash-jobs.js +9 -0
  23. package/dist/api/dto/bash.d.ts +17 -0
  24. package/dist/api/dto/bash.js +1 -0
  25. package/dist/api/dto/broker-ops.d.ts +187 -0
  26. package/dist/api/dto/broker-ops.js +6 -0
  27. package/dist/api/dto/broker-signals.d.ts +25 -0
  28. package/dist/api/dto/broker-signals.js +1 -0
  29. package/dist/api/dto/broker.d.ts +86 -0
  30. package/dist/api/dto/broker.js +20 -0
  31. package/dist/api/dto/canvas.d.ts +359 -0
  32. package/dist/api/dto/canvas.js +2 -0
  33. package/dist/api/dto/chat-inventory.d.ts +56 -0
  34. package/dist/api/dto/chat-inventory.js +11 -0
  35. package/dist/api/dto/common.d.ts +29 -0
  36. package/dist/api/dto/common.js +15 -0
  37. package/dist/api/dto/config.d.ts +46 -0
  38. package/dist/api/dto/config.js +3 -0
  39. package/dist/api/dto/crons.d.ts +158 -0
  40. package/dist/api/dto/crons.js +10 -0
  41. package/dist/api/dto/custom-objects.d.ts +66 -0
  42. package/dist/api/dto/custom-objects.js +1 -0
  43. package/dist/api/dto/delivery.d.ts +71 -0
  44. package/dist/api/dto/delivery.js +7 -0
  45. package/dist/api/dto/docs.d.ts +135 -0
  46. package/dist/api/dto/docs.js +8 -0
  47. package/dist/api/dto/files.d.ts +21 -0
  48. package/dist/api/dto/files.js +1 -0
  49. package/dist/api/dto/focus.d.ts +24 -0
  50. package/dist/api/dto/focus.js +10 -0
  51. package/dist/api/dto/grants.d.ts +28 -0
  52. package/dist/api/dto/grants.js +1 -0
  53. package/dist/api/dto/health.d.ts +116 -0
  54. package/dist/api/dto/health.js +2 -0
  55. package/dist/api/dto/human-requests.d.ts +113 -0
  56. package/dist/api/dto/human-requests.js +4 -0
  57. package/dist/api/dto/human.d.ts +28 -0
  58. package/dist/api/dto/human.js +4 -0
  59. package/dist/api/dto/inbox.d.ts +273 -0
  60. package/dist/api/dto/inbox.js +4 -0
  61. package/dist/api/dto/lifecycle.d.ts +88 -0
  62. package/dist/api/dto/lifecycle.js +3 -0
  63. package/dist/api/dto/limits.d.ts +29 -0
  64. package/dist/api/dto/limits.js +2 -0
  65. package/dist/api/dto/mail.d.ts +44 -0
  66. package/dist/api/dto/mail.js +1 -0
  67. package/dist/api/dto/messages.d.ts +104 -0
  68. package/dist/api/dto/messages.js +2 -0
  69. package/dist/api/dto/model-config.d.ts +25 -0
  70. package/dist/api/dto/model-config.js +1 -0
  71. package/dist/api/dto/modelauth.d.ts +132 -0
  72. package/dist/api/dto/modelauth.js +4 -0
  73. package/dist/api/dto/node-events.d.ts +65 -0
  74. package/dist/api/dto/node-events.js +4 -0
  75. package/dist/api/dto/node-outcomes.d.ts +88 -0
  76. package/dist/api/dto/node-outcomes.js +2 -0
  77. package/dist/api/dto/node-records.d.ts +35 -0
  78. package/dist/api/dto/node-records.js +5 -0
  79. package/dist/api/dto/nodes.d.ts +371 -0
  80. package/dist/api/dto/nodes.js +3 -0
  81. package/dist/api/dto/objects.d.ts +172 -0
  82. package/dist/api/dto/objects.js +5 -0
  83. package/dist/api/dto/profiles.d.ts +117 -0
  84. package/dist/api/dto/profiles.js +4 -0
  85. package/dist/api/dto/recovery.d.ts +104 -0
  86. package/dist/api/dto/recovery.js +1 -0
  87. package/dist/api/dto/reports.d.ts +95 -0
  88. package/dist/api/dto/reports.js +2 -0
  89. package/dist/api/dto/review-comments.d.ts +146 -0
  90. package/dist/api/dto/review-comments.js +5 -0
  91. package/dist/api/dto/reviews.d.ts +113 -0
  92. package/dist/api/dto/reviews.js +5 -0
  93. package/dist/api/dto/run-events.d.ts +295 -0
  94. package/dist/api/dto/run-events.js +6 -0
  95. package/dist/api/dto/structured-requests.d.ts +64 -0
  96. package/dist/api/dto/structured-requests.js +5 -0
  97. package/dist/api/dto/subscriptions.d.ts +14 -0
  98. package/dist/api/dto/subscriptions.js +2 -0
  99. package/dist/api/dto/worktree.d.ts +55 -0
  100. package/dist/api/dto/worktree.js +6 -0
  101. package/dist/api/error-codes.d.ts +259 -0
  102. package/dist/api/error-codes.js +56 -0
  103. package/dist/api/errors.d.ts +47 -0
  104. package/dist/api/errors.js +66 -0
  105. package/dist/api/index.d.ts +48 -0
  106. package/dist/api/index.js +45 -0
  107. package/dist/api/long-poll.d.ts +59 -0
  108. package/dist/api/long-poll.js +67 -0
  109. package/dist/api/node-transport.d.ts +18 -0
  110. package/dist/api/node-transport.js +105 -0
  111. package/dist/api/plugin-manifest-schema.d.ts +233 -0
  112. package/dist/api/plugin-manifest-schema.js +23 -0
  113. package/dist/api/routes.d.ts +164 -0
  114. package/dist/api/routes.js +197 -0
  115. package/dist/api/runtime-version.d.ts +31 -0
  116. package/dist/api/runtime-version.js +50 -0
  117. package/dist/core/asset-root.d.ts +7 -0
  118. package/dist/core/asset-root.js +18 -0
  119. package/dist/core/canvas/boot-id.d.ts +6 -0
  120. package/dist/core/canvas/boot-id.js +26 -0
  121. package/dist/core/canvas/paths.d.ts +72 -0
  122. package/dist/core/canvas/paths.js +163 -0
  123. package/dist/core/canvas/pid.d.ts +391 -0
  124. package/dist/core/canvas/pid.js +948 -0
  125. package/dist/core/command-plugins/bundle.d.ts +149 -0
  126. package/dist/core/command-plugins/bundle.js +588 -0
  127. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  128. package/dist/core/command-plugins/endpoint.js +51 -0
  129. package/dist/core/config.d.ts +233 -0
  130. package/dist/core/config.js +1120 -0
  131. package/dist/core/env-name.d.ts +6 -0
  132. package/dist/core/env-name.js +9 -0
  133. package/dist/core/errors.d.ts +38 -0
  134. package/dist/core/errors.js +90 -0
  135. package/dist/core/events/emit.d.ts +6 -0
  136. package/dist/core/events/emit.js +42 -0
  137. package/dist/core/events/envelope.d.ts +2 -0
  138. package/dist/core/events/envelope.js +84 -0
  139. package/dist/core/events/errors.d.ts +4 -0
  140. package/dist/core/events/errors.js +69 -0
  141. package/dist/core/events/operation-id.d.ts +4 -0
  142. package/dist/core/events/operation-id.js +24 -0
  143. package/dist/core/events/serialize.d.ts +4 -0
  144. package/dist/core/events/serialize.js +199 -0
  145. package/dist/core/events/source.d.ts +16 -0
  146. package/dist/core/events/source.js +31 -0
  147. package/dist/core/events/types.d.ts +68 -0
  148. package/dist/core/events/types.js +11 -0
  149. package/dist/core/exclusive-lock.d.ts +34 -0
  150. package/dist/core/exclusive-lock.js +197 -0
  151. package/dist/core/fs-utils.d.ts +44 -0
  152. package/dist/core/fs-utils.js +208 -0
  153. package/dist/core/help.d.ts +309 -0
  154. package/dist/core/help.js +406 -0
  155. package/dist/core/human/page-catalog.d.ts +57 -0
  156. package/dist/core/human/page-catalog.js +172 -0
  157. package/dist/core/installed-plugins.d.ts +2 -0
  158. package/dist/core/installed-plugins.js +79 -0
  159. package/dist/core/io.d.ts +122 -0
  160. package/dist/core/io.js +373 -0
  161. package/dist/core/keybindings/attach-control.d.ts +49 -0
  162. package/dist/core/keybindings/attach-control.js +42 -0
  163. package/dist/core/keybindings/catalog.d.ts +18 -0
  164. package/dist/core/keybindings/catalog.js +257 -0
  165. package/dist/core/keybindings/types.d.ts +42 -0
  166. package/dist/core/keybindings/types.js +1 -0
  167. package/dist/core/layout.d.ts +34 -0
  168. package/dist/core/layout.js +106 -0
  169. package/dist/core/locked-file.d.ts +27 -0
  170. package/dist/core/locked-file.js +118 -0
  171. package/dist/core/log.d.ts +9 -0
  172. package/dist/core/log.js +89 -0
  173. package/dist/core/manifest.d.ts +5 -0
  174. package/dist/core/manifest.js +15 -0
  175. package/dist/core/plugin-env.d.ts +8 -0
  176. package/dist/core/plugin-env.js +31 -0
  177. package/dist/core/plugin-extensions.d.ts +29 -0
  178. package/dist/core/plugin-extensions.js +191 -0
  179. package/dist/core/plugin-swap-lock.d.ts +9 -0
  180. package/dist/core/plugin-swap-lock.js +31 -0
  181. package/dist/core/preview-result-path.d.ts +4 -0
  182. package/dist/core/preview-result-path.js +26 -0
  183. package/dist/core/profiles/env-store.d.ts +22 -0
  184. package/dist/core/profiles/env-store.js +163 -0
  185. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  186. package/dist/core/profiles/fuzzy-match.js +92 -0
  187. package/dist/core/profiles/manifest.d.ts +120 -0
  188. package/dist/core/profiles/manifest.js +529 -0
  189. package/dist/core/rate-limit-scope.d.ts +25 -0
  190. package/dist/core/rate-limit-scope.js +64 -0
  191. package/dist/core/render.d.ts +12 -0
  192. package/dist/core/render.js +138 -0
  193. package/dist/core/resolver.d.ts +14 -0
  194. package/dist/core/resolver.js +111 -0
  195. package/dist/core/runtime/branded-host.d.ts +55 -0
  196. package/dist/core/runtime/branded-host.js +485 -0
  197. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  198. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  199. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  200. package/dist/core/runtime/broker/signal-stream.js +149 -0
  201. package/dist/core/scope.d.ts +32 -0
  202. package/dist/core/scope.js +184 -0
  203. package/dist/core/scoped-state/db.d.ts +17 -0
  204. package/dist/core/scoped-state/db.js +247 -0
  205. package/dist/core/scoped-state/migrate.d.ts +8 -0
  206. package/dist/core/scoped-state/migrate.js +187 -0
  207. package/dist/core/scoped-state/paths.d.ts +9 -0
  208. package/dist/core/scoped-state/paths.js +27 -0
  209. package/dist/core/scoped-state/profiles.d.ts +27 -0
  210. package/dist/core/scoped-state/profiles.js +93 -0
  211. package/dist/core/scoped-state/providers.d.ts +24 -0
  212. package/dist/core/scoped-state/providers.js +19 -0
  213. package/dist/core/scoped-state/schema.d.ts +6 -0
  214. package/dist/core/scoped-state/schema.js +43 -0
  215. package/dist/core/scoped-state/settings.d.ts +28 -0
  216. package/dist/core/scoped-state/settings.js +83 -0
  217. package/dist/core/spaces/open-beneath.d.ts +71 -0
  218. package/dist/core/spaces/open-beneath.js +581 -0
  219. package/dist/core/sqlite-statements.d.ts +4 -0
  220. package/dist/core/sqlite-statements.js +17 -0
  221. package/dist/core/subscription-state.d.ts +121 -0
  222. package/dist/core/subscription-state.js +287 -0
  223. package/dist/core/user-settings.d.ts +377 -0
  224. package/dist/core/user-settings.js +458 -0
  225. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  226. package/dist/daemon/broker-signals/bus.js +87 -0
  227. package/dist/daemon/manage.d.ts +176 -0
  228. package/dist/daemon/manage.js +664 -0
  229. package/dist/daemon/pidfile.d.ts +8 -0
  230. package/dist/daemon/pidfile.js +37 -0
  231. package/dist/daemon/startup-policy.d.ts +1 -0
  232. package/dist/daemon/startup-policy.js +1 -0
  233. package/dist/native/linux.d.ts +29 -0
  234. package/dist/native/linux.js +20 -0
  235. package/dist/shared/env.d.ts +116 -0
  236. package/dist/shared/env.js +271 -0
  237. package/dist/shared/generated-context.d.ts +83 -0
  238. package/dist/shared/generated-context.js +235 -0
  239. package/dist/shared/inbox-entry-body.d.ts +22 -0
  240. package/dist/shared/inbox-entry-body.js +116 -0
  241. package/dist/shared/predicates.d.ts +2 -0
  242. package/dist/shared/predicates.js +4 -0
  243. package/dist/shared/working-activity.d.ts +9 -0
  244. package/dist/shared/working-activity.js +27 -0
  245. package/dist/types.d.ts +562 -0
  246. package/dist/types.js +186 -0
  247. package/package.json +47 -4
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Northlight Systems. All rights reserved.
2
+
3
+ This software and its associated documentation files (the "Software") are the
4
+ proprietary and confidential property of Northlight Systems. No licence or
5
+ right to use, copy, modify, merge, publish, distribute, sublicense, sell,
6
+ reverse engineer, decompile or create derivative works of the Software is
7
+ granted except under a separate written agreement with Northlight Systems.
8
+
9
+ The Software is made available as compiled and minified packages through the
10
+ npm registry solely so that it can be installed and run by those authorised by
11
+ Northlight Systems. Installing a package from the registry does not grant any
12
+ licence to the Software's source code.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
17
+ NORTHLIGHT SYSTEMS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING
18
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # @north-light/api — the typed client and contract for the crtrd `/v1` API
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![crouter](https://raw.githubusercontent.com/Northlight-Systems/crouter/main/assets/banner.svg)
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@north-light/api"><img alt="npm" src="https://img.shields.io/npm/v/@north-light/api?label=npm"></a>
7
+ </p>
8
+
9
+ `@north-light/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.
10
+
11
+ It is not the package most applications want. [`@north-light/sdk`](https://www.npmjs.com/package/@north-light/sdk) is built on it and adds run management, streaming, typed results and OAuth. Use `@north-light/api` when you need the wire contract itself — a thin client, a proxy, or tooling that validates plugin manifests.
12
+
13
+ [Docs](https://docs.crouter.ai/docs/sdk) · [SDK resource map](https://docs.crouter.ai/docs/sdk/resources) · [crouter repository](https://github.com/Northlight-Systems/crouter) · [Source](https://github.com/Northlight-Systems/crouter/tree/main/src/api)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @north-light/api
19
+ ```
20
+
21
+ The package is ESM-only. Import it from an ES module, or use dynamic `import()` from CommonJS. A crouter daemon (`npm install -g @north-light/cli`) must be reachable for any request.
22
+
23
+ ## Connect to a daemon
24
+
25
+ `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.
26
+
27
+ ```ts
28
+ import { CrtrClient } from '@north-light/api';
29
+
30
+ const client = new CrtrClient({
31
+ baseUrl: 'https://my-crtrd.example.com',
32
+ headers: { authorization: 'Bearer a-daemon-token' },
33
+ });
34
+
35
+ const nodes = await client.listNodes();
36
+ console.log(nodes.map((node) => node.node_id));
37
+ ```
38
+
39
+ For the local Unix socket, import the Node-only entry point:
40
+
41
+ ```ts
42
+ import { localClient } from '@north-light/api/node';
43
+
44
+ const client = localClient();
45
+ console.log(await client.healthz());
46
+ ```
47
+
48
+ ## Exports
49
+
50
+ - `CrtrClient` and `waitForDaemonAvailability` for typed `/v1` requests.
51
+ - `routes` and `API_VERSION` for route construction.
52
+ - `LONG_POLL_ROUTES`, `longPollRoute` and `longPollHoldSeconds` — which daemon routes hold a request open (`?wait=`) and for how long, for a proxy that must not cut a held request off.
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
+ - `@north-light/api/node` — `defaultSocketPath`, `socketFetch` and `localClient`.
56
+ - `@north-light/api/plugin-manifest` and `@north-light/api/command-manifest` — the plugin command-manifest schema and validation types.
57
+ - `@north-light/api/cards` — generated context types.
58
+
59
+ ## Related
60
+
61
+ - [`@north-light/sdk`](https://www.npmjs.com/package/@north-light/sdk) — the client most applications should use.
62
+ - [`@north-light/plugin`](https://www.npmjs.com/package/@north-light/plugin) — author plugins whose manifests use this package's types.
63
+ - [Main repository](https://github.com/Northlight-Systems/crouter) — the `crtr` CLI, the `crtrd` daemon.
64
+
65
+ ## License
66
+
67
+ Proprietary. Copyright (c) 2026 Northlight Systems. All rights reserved. See `LICENSE`.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
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.deepEqual(errorCodes.provider_setup_pending, { type: 'permission_error', status: 403, retryable: false });
18
+ assert.equal('provider_error' in errorCodes, false); // provider's 4xx status varies
19
+ assert.equal('usage' in errorCodes, false); // legacy socket-only code
20
+ assert.equal('stream_gap' in errorCodes, false); // stream protocol, not a fixed HTTP row
21
+ });
22
+ test('every statically named RunError and ProviderError agrees with the fixed table', () => {
23
+ const src = fileURLToPath(new URL('../..', import.meta.url));
24
+ const files = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
25
+ const path = join(dir, entry.name);
26
+ return entry.isDirectory() ? files(path) : entry.name.endsWith('.ts') && !path.includes('/__tests__/') ? [path] : [];
27
+ });
28
+ const literals = (expr) => {
29
+ if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr))
30
+ return [ts.isNumericLiteral(expr) ? Number(expr.text) : expr.text];
31
+ if (ts.isConditionalExpression(expr))
32
+ return [...literals(expr.whenTrue), ...literals(expr.whenFalse)];
33
+ return [];
34
+ };
35
+ let checked = 0;
36
+ for (const path of files(src)) {
37
+ const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
38
+ const visit = (node) => {
39
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && ['RunError', 'ProviderError'].includes(node.expression.text)) {
40
+ const args = node.arguments ?? [];
41
+ const run = node.expression.text === 'RunError';
42
+ const codes = args[run ? 1 : 0] ? literals(args[run ? 1 : 0]) : [];
43
+ const statuses = run && args[0] ? literals(args[0]) : [];
44
+ const options = !run && args[2] && ts.isObjectLiteralExpression(args[2]) ? args[2] : undefined;
45
+ const override = options?.properties.find((prop) => ts.isPropertyAssignment(prop) && prop.name.getText(source) === 'status');
46
+ const providerStatuses = override && ts.isPropertyAssignment(override) ? literals(override.initializer) : [];
47
+ for (const code of codes) {
48
+ if (typeof code !== 'string' || ['provider_error', 'usage', 'stream_gap', 'stream_dropped', 'stream_error'].includes(code))
49
+ continue;
50
+ const row = errorCodes[code];
51
+ assert.ok(row, `${path}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}: ${code} missing from errorCodes`);
52
+ for (const status of run ? statuses : providerStatuses)
53
+ assert.equal(status, row.status, `${path}: ${code} status`);
54
+ if (run && args[6] && args[6].kind !== ts.SyntaxKind.UndefinedKeyword && args[6].kind !== ts.SyntaxKind.NullKeyword) {
55
+ const retry = args[6].kind === ts.SyntaxKind.TrueKeyword ? true : args[6].kind === ts.SyntaxKind.FalseKeyword ? false : undefined;
56
+ if (retry !== undefined)
57
+ assert.equal(retry, row.retryable, `${path}: ${code} retryable`);
58
+ }
59
+ else if (run && statuses.length) {
60
+ for (const status of statuses)
61
+ assert.equal(status === 503, row.retryable, `${path}: ${code} default retryable`);
62
+ }
63
+ checked++;
64
+ }
65
+ }
66
+ ts.forEachChild(node, visit);
67
+ };
68
+ visit(source);
69
+ }
70
+ assert.ok(checked > 100, `checked only ${checked} throw sites`);
71
+ });
72
+ test('app origin belongs to the full HTTP envelope without a body status', () => {
73
+ const row = errorCodes.invalid_request;
74
+ const body = { error: { code: 'invalid_request', message: 'Missing prompt',
75
+ type: row.type, origin: 'app', retryable: row.retryable, request_id: 'request-1', param: 'prompt' } };
76
+ assert.equal(body.error.origin, 'app');
77
+ assert.equal('status' in body.error, false);
78
+ assert.equal(row.status, 400);
79
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,276 @@
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
+ import { DaemonExitedBeforeReadyError, daemonAutostartStartupFailure, ensureDaemon, resetDaemonAutostart, } from '../../../daemon/manage.js';
12
+ function startDelayedHealthzServer(socketPath, delayMs) {
13
+ const server = createServer((_req, res) => {
14
+ res.writeHead(200, { 'content-type': 'application/json' });
15
+ res.end(JSON.stringify({ ok: true }));
16
+ });
17
+ const timer = setTimeout(() => server.listen(socketPath), delayMs);
18
+ return { server, cancel: () => clearTimeout(timer) };
19
+ }
20
+ // A dropped response is distinct from a refused connect. Its cause is unknown, so reads may wait and retry but mutations never replay.
21
+ function startHandoverServer(socketPath) {
22
+ let dropped = false;
23
+ const server = createServer((req, res) => {
24
+ if (!dropped) {
25
+ dropped = true; // model the old server dying mid-request
26
+ req.socket.destroy();
27
+ return;
28
+ }
29
+ res.writeHead(200, { 'content-type': 'application/json' });
30
+ res.end(JSON.stringify({ ok: true, method: req.method }));
31
+ });
32
+ return {
33
+ server,
34
+ ready: new Promise((resolvePromise) => server.listen(socketPath, resolvePromise)),
35
+ };
36
+ }
37
+ test('a hang-up mid-request rides out the daemon handover and replays the idempotent call', async () => {
38
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
39
+ const socketPath = join(dir, 'crtrd.sock');
40
+ const { server, ready } = startHandoverServer(socketPath);
41
+ try {
42
+ await ready;
43
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
44
+ assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
45
+ }
46
+ finally {
47
+ server.close();
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+ test('an interrupted mutation is not replayed or labelled a daemon handover', async () => {
52
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
53
+ const socketPath = join(dir, 'crtrd.sock');
54
+ const { server, ready } = startHandoverServer(socketPath);
55
+ try {
56
+ await ready;
57
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
58
+ await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
59
+ // Replaying is unsafe (the daemon may have applied it), so this one is the
60
+ // caller's call — but it must not be reported as a daemon that is down.
61
+ (err) => err instanceof ApiError && err.code === 'daemon_request_interrupted');
62
+ }
63
+ finally {
64
+ server.close();
65
+ rmSync(dir, { recursive: true, force: true });
66
+ }
67
+ });
68
+ test('a non-socket cold-start path surfaces its diagnostic after autostart times out', async () => {
69
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-nonsocket-'));
70
+ const socketPath = join(dir, 'crtrd.sock');
71
+ mkdirSync(socketPath);
72
+ const diagnostic = 'crtrd.log (tail):\napi.server.failed: EADDRINUSE';
73
+ const client = localClient({
74
+ socketPath,
75
+ autostart: true,
76
+ onColdSocket: async () => { },
77
+ coldStartPollWindowMs: 50,
78
+ coldStartDiagnostic: () => diagnostic,
79
+ });
80
+ try {
81
+ await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError
82
+ && error.code === 'daemon_unavailable'
83
+ && error.message.includes(diagnostic));
84
+ }
85
+ finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+ test('cliClient-style cold start fails loud when the injected poll window is shorter than the (valid) startup delay', async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
91
+ const socketPath = join(dir, 'crtrd.sock');
92
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
93
+ try {
94
+ const client = localClient({
95
+ socketPath,
96
+ autostart: true,
97
+ onColdSocket: async () => {
98
+ /* fire-and-forget: the delayed listen() above models ensureDaemon() */
99
+ },
100
+ coldStartPollWindowMs: 100, // shorter than the 300ms the daemon actually needs
101
+ });
102
+ await assert.rejects(() => client.healthz(), (err) => err instanceof ApiError && err.code === 'daemon_unavailable');
103
+ }
104
+ finally {
105
+ cancel(); // the client already timed out at 100ms; never let listen() fire at 300ms
106
+ server.close();
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+ test('a disabled-autostart client waits for an externally managed listener without invoking its spawn hook', async () => {
111
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
112
+ const socketPath = join(dir, 'crtrd.sock');
113
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
114
+ let spawnAttempts = 0;
115
+ try {
116
+ const client = localClient({
117
+ socketPath,
118
+ autostart: false,
119
+ onColdSocket: async () => { spawnAttempts += 1; },
120
+ coldStartPollWindowMs: 1_000,
121
+ });
122
+ assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true });
123
+ assert.equal(spawnAttempts, 0, 'disabled autostart only waits; it never invokes the spawn hook');
124
+ }
125
+ finally {
126
+ cancel();
127
+ server.close();
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ test('availability expiry keeps the final transport failure inside one wall-clock budget', async () => {
132
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
133
+ const socketPath = join(dir, 'crtrd.sock');
134
+ const server = createServer((_req, _res) => { });
135
+ let listenTimer;
136
+ try {
137
+ const client = localClient({
138
+ socketPath,
139
+ autostart: true,
140
+ timeoutMs: 5_000,
141
+ coldStartPollWindowMs: 250,
142
+ onColdSocket: async () => { listenTimer = setTimeout(() => server.listen(socketPath), 10); },
143
+ });
144
+ const started = Date.now();
145
+ await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError && error.code === 'request_timeout' && error.message.includes('request timed out'));
146
+ assert.ok(Date.now() - started < 500, 'a probe cannot extend the 250ms availability budget');
147
+ }
148
+ finally {
149
+ if (listenTimer !== undefined)
150
+ clearTimeout(listenTimer);
151
+ server.close();
152
+ rmSync(dir, { recursive: true, force: true });
153
+ }
154
+ });
155
+ test('a caller abort with a custom reason is request_aborted', async () => {
156
+ const controller = new AbortController();
157
+ const client = new CrtrClient({
158
+ baseUrl: 'http://fixture.test',
159
+ fetch: async (_input, init) => await new Promise((_resolve, reject) => {
160
+ const signal = init?.signal;
161
+ if (signal === null || signal === undefined)
162
+ throw new Error('request signal is required');
163
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
164
+ }),
165
+ });
166
+ const request = client.request('GET', '/healthz', undefined, { signal: controller.signal });
167
+ controller.abort(new Error('caller custom abort'));
168
+ await assert.rejects(request, (error) => error instanceof ApiError && error.code === 'request_aborted');
169
+ });
170
+ test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
171
+ assert.equal(safeColdStartDiagnostic(undefined), undefined);
172
+ });
173
+ test('safeColdStartDiagnostic returns the hook result when it succeeds', () => {
174
+ assert.equal(safeColdStartDiagnostic(() => 'crtrd.log (tail):\nboom'), 'crtrd.log (tail):\nboom');
175
+ });
176
+ test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated error', () => {
177
+ assert.equal(safeColdStartDiagnostic(() => {
178
+ throw new Error('custom hook blew up');
179
+ }), undefined);
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
+ });