@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.
- package/LICENSE +18 -0
- package/README.md +66 -2
- package/dist/api/__tests__/error-codes.test.d.ts +1 -0
- package/dist/api/__tests__/error-codes.test.js +79 -0
- package/dist/api/__tests__/integration/client.test.d.ts +1 -0
- package/dist/api/__tests__/integration/client.test.js +276 -0
- package/dist/api/client.d.ts +501 -0
- package/dist/api/client.js +1281 -0
- package/dist/api/command-manifest/index.d.ts +3 -0
- package/dist/api/command-manifest/index.js +3 -0
- package/dist/api/command-manifest/manifest.d.ts +51 -0
- package/dist/api/command-manifest/manifest.js +332 -0
- package/dist/api/command-manifest/result.d.ts +25 -0
- package/dist/api/command-manifest/result.js +97 -0
- package/dist/api/command-manifest/schema.d.ts +28 -0
- package/dist/api/command-manifest/schema.js +856 -0
- package/dist/api/dto/analytics.d.ts +184 -0
- package/dist/api/dto/analytics.js +3 -0
- package/dist/api/dto/attach.d.ts +22 -0
- package/dist/api/dto/attach.js +13 -0
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/bash.d.ts +17 -0
- package/dist/api/dto/bash.js +1 -0
- package/dist/api/dto/broker-ops.d.ts +187 -0
- package/dist/api/dto/broker-ops.js +6 -0
- package/dist/api/dto/broker-signals.d.ts +25 -0
- package/dist/api/dto/broker-signals.js +1 -0
- package/dist/api/dto/broker.d.ts +86 -0
- package/dist/api/dto/broker.js +20 -0
- package/dist/api/dto/canvas.d.ts +359 -0
- package/dist/api/dto/canvas.js +2 -0
- package/dist/api/dto/chat-inventory.d.ts +56 -0
- package/dist/api/dto/chat-inventory.js +11 -0
- package/dist/api/dto/common.d.ts +29 -0
- package/dist/api/dto/common.js +15 -0
- package/dist/api/dto/config.d.ts +46 -0
- package/dist/api/dto/config.js +3 -0
- package/dist/api/dto/crons.d.ts +158 -0
- package/dist/api/dto/crons.js +10 -0
- package/dist/api/dto/custom-objects.d.ts +66 -0
- package/dist/api/dto/custom-objects.js +1 -0
- package/dist/api/dto/delivery.d.ts +71 -0
- package/dist/api/dto/delivery.js +7 -0
- package/dist/api/dto/docs.d.ts +135 -0
- package/dist/api/dto/docs.js +8 -0
- package/dist/api/dto/files.d.ts +21 -0
- package/dist/api/dto/files.js +1 -0
- package/dist/api/dto/focus.d.ts +24 -0
- package/dist/api/dto/focus.js +10 -0
- package/dist/api/dto/grants.d.ts +28 -0
- package/dist/api/dto/grants.js +1 -0
- package/dist/api/dto/health.d.ts +116 -0
- package/dist/api/dto/health.js +2 -0
- package/dist/api/dto/human-requests.d.ts +113 -0
- package/dist/api/dto/human-requests.js +4 -0
- package/dist/api/dto/human.d.ts +28 -0
- package/dist/api/dto/human.js +4 -0
- package/dist/api/dto/inbox.d.ts +273 -0
- package/dist/api/dto/inbox.js +4 -0
- package/dist/api/dto/lifecycle.d.ts +88 -0
- package/dist/api/dto/lifecycle.js +3 -0
- package/dist/api/dto/limits.d.ts +29 -0
- package/dist/api/dto/limits.js +2 -0
- package/dist/api/dto/mail.d.ts +44 -0
- package/dist/api/dto/mail.js +1 -0
- package/dist/api/dto/messages.d.ts +104 -0
- package/dist/api/dto/messages.js +2 -0
- package/dist/api/dto/model-config.d.ts +25 -0
- package/dist/api/dto/model-config.js +1 -0
- package/dist/api/dto/modelauth.d.ts +132 -0
- package/dist/api/dto/modelauth.js +4 -0
- package/dist/api/dto/node-events.d.ts +65 -0
- package/dist/api/dto/node-events.js +4 -0
- package/dist/api/dto/node-outcomes.d.ts +88 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/node-records.d.ts +35 -0
- package/dist/api/dto/node-records.js +5 -0
- package/dist/api/dto/nodes.d.ts +371 -0
- package/dist/api/dto/nodes.js +3 -0
- package/dist/api/dto/objects.d.ts +172 -0
- package/dist/api/dto/objects.js +5 -0
- package/dist/api/dto/profiles.d.ts +117 -0
- package/dist/api/dto/profiles.js +4 -0
- package/dist/api/dto/recovery.d.ts +104 -0
- package/dist/api/dto/recovery.js +1 -0
- package/dist/api/dto/reports.d.ts +95 -0
- package/dist/api/dto/reports.js +2 -0
- package/dist/api/dto/review-comments.d.ts +146 -0
- package/dist/api/dto/review-comments.js +5 -0
- package/dist/api/dto/reviews.d.ts +113 -0
- package/dist/api/dto/reviews.js +5 -0
- package/dist/api/dto/run-events.d.ts +295 -0
- package/dist/api/dto/run-events.js +6 -0
- package/dist/api/dto/structured-requests.d.ts +64 -0
- package/dist/api/dto/structured-requests.js +5 -0
- package/dist/api/dto/subscriptions.d.ts +14 -0
- package/dist/api/dto/subscriptions.js +2 -0
- package/dist/api/dto/worktree.d.ts +55 -0
- package/dist/api/dto/worktree.js +6 -0
- package/dist/api/error-codes.d.ts +259 -0
- package/dist/api/error-codes.js +56 -0
- package/dist/api/errors.d.ts +47 -0
- package/dist/api/errors.js +66 -0
- package/dist/api/index.d.ts +48 -0
- package/dist/api/index.js +45 -0
- package/dist/api/long-poll.d.ts +59 -0
- package/dist/api/long-poll.js +67 -0
- package/dist/api/node-transport.d.ts +18 -0
- package/dist/api/node-transport.js +105 -0
- package/dist/api/plugin-manifest-schema.d.ts +233 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/api/routes.d.ts +164 -0
- package/dist/api/routes.js +197 -0
- package/dist/api/runtime-version.d.ts +31 -0
- package/dist/api/runtime-version.js +50 -0
- 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 +34 -0
- package/dist/core/layout.js +106 -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 +55 -0
- package/dist/core/runtime/branded-host.js +485 -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/generated-context.d.ts +83 -0
- package/dist/shared/generated-context.js +235 -0
- package/dist/shared/inbox-entry-body.d.ts +22 -0
- package/dist/shared/inbox-entry-body.js +116 -0
- package/dist/shared/predicates.d.ts +2 -0
- package/dist/shared/predicates.js +4 -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 +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
|
-
#
|
|
1
|
+
# @north-light/api — the typed client and contract for the crtrd `/v1` API
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
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
|
+
});
|