@north-light/crouter-api 0.3.321 → 0.3.323
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/__tests__/integration/client.test.js +22 -6
- package/dist/api/client.d.ts +35 -18
- package/dist/api/client.js +206 -129
- package/dist/api/dto/broker-ops.d.ts +9 -0
- package/dist/api/dto/canvas.d.ts +23 -1
- package/dist/api/dto/modelauth.d.ts +15 -0
- package/dist/api/dto/nodes.d.ts +8 -0
- package/dist/api/errors.d.ts +6 -3
- package/dist/api/errors.js +8 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/node-transport.d.ts +18 -0
- package/dist/api/node-transport.js +102 -0
- package/dist/api/routes.d.ts +3 -0
- package/dist/api/routes.js +3 -0
- package/package.json +7 -1
|
@@ -6,6 +6,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
|
|
9
|
+
import { localClient } from '../../node-transport.js';
|
|
9
10
|
import { ApiError } from '../../errors.js';
|
|
10
11
|
function startDelayedHealthzServer(socketPath, delayMs) {
|
|
11
12
|
const server = createServer((_req, res) => {
|
|
@@ -38,7 +39,7 @@ test('a hang-up mid-request rides out the daemon handover and replays the idempo
|
|
|
38
39
|
const { server, ready } = startHandoverServer(socketPath);
|
|
39
40
|
try {
|
|
40
41
|
await ready;
|
|
41
|
-
const client =
|
|
42
|
+
const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
|
|
42
43
|
assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
|
|
43
44
|
}
|
|
44
45
|
finally {
|
|
@@ -52,7 +53,7 @@ test('an interrupted mutation is not replayed or labelled a daemon handover', as
|
|
|
52
53
|
const { server, ready } = startHandoverServer(socketPath);
|
|
53
54
|
try {
|
|
54
55
|
await ready;
|
|
55
|
-
const client =
|
|
56
|
+
const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
|
|
56
57
|
await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
|
|
57
58
|
// Replaying is unsafe (the daemon may have applied it), so this one is the
|
|
58
59
|
// caller's call — but it must not be reported as a daemon that is down.
|
|
@@ -68,7 +69,7 @@ test('a non-socket cold-start path surfaces its diagnostic after autostart times
|
|
|
68
69
|
const socketPath = join(dir, 'crtrd.sock');
|
|
69
70
|
mkdirSync(socketPath);
|
|
70
71
|
const diagnostic = 'crtrd.log (tail):\napi.server.failed: EADDRINUSE';
|
|
71
|
-
const client =
|
|
72
|
+
const client = localClient({
|
|
72
73
|
socketPath,
|
|
73
74
|
autostart: true,
|
|
74
75
|
onColdSocket: async () => { },
|
|
@@ -89,7 +90,7 @@ test('cliClient-style cold start fails loud when the injected poll window is sho
|
|
|
89
90
|
const socketPath = join(dir, 'crtrd.sock');
|
|
90
91
|
const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
|
|
91
92
|
try {
|
|
92
|
-
const client =
|
|
93
|
+
const client = localClient({
|
|
93
94
|
socketPath,
|
|
94
95
|
autostart: true,
|
|
95
96
|
onColdSocket: async () => {
|
|
@@ -111,7 +112,7 @@ test('a disabled-autostart client waits for an externally managed listener witho
|
|
|
111
112
|
const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
|
|
112
113
|
let spawnAttempts = 0;
|
|
113
114
|
try {
|
|
114
|
-
const client =
|
|
115
|
+
const client = localClient({
|
|
115
116
|
socketPath,
|
|
116
117
|
autostart: false,
|
|
117
118
|
onColdSocket: async () => { spawnAttempts += 1; },
|
|
@@ -132,7 +133,7 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
|
|
|
132
133
|
const server = createServer((_req, _res) => { });
|
|
133
134
|
let listenTimer;
|
|
134
135
|
try {
|
|
135
|
-
const client =
|
|
136
|
+
const client = localClient({
|
|
136
137
|
socketPath,
|
|
137
138
|
autostart: true,
|
|
138
139
|
timeoutMs: 5_000,
|
|
@@ -150,6 +151,21 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
|
|
|
150
151
|
rmSync(dir, { recursive: true, force: true });
|
|
151
152
|
}
|
|
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
|
+
});
|
|
153
169
|
test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
|
|
154
170
|
assert.equal(safeColdStartDiagnostic(undefined), undefined);
|
|
155
171
|
});
|
package/dist/api/client.d.ts
CHANGED
|
@@ -14,27 +14,29 @@ import type { DeleteProfileRequest, DeleteProfileResultDTO, EnsureProfileRequest
|
|
|
14
14
|
import type { FilePeekDTO } from './dto/files.js';
|
|
15
15
|
import type { MemoryDocRefDTO } from './dto/memory.js';
|
|
16
16
|
import type { ChatInventoryDTO, ProspectiveChatInventoryDTO, ProspectiveChatInventoryQuery } from './dto/chat-inventory.js';
|
|
17
|
-
import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO } from './dto/modelauth.js';
|
|
17
|
+
import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO, ModelAuthReadinessDTO, ModelAuthReadinessQuery } from './dto/modelauth.js';
|
|
18
18
|
import type { CancelReviewRequest, CreateReviewRequest, ListReviewsQuery, ReviewCancelResultDTO, ReviewDocumentBaseDTO, ReviewDTO, ReviewListDTO, ReviewSubmitResultDTO } from './dto/reviews.js';
|
|
19
19
|
import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentForkDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
|
|
20
20
|
import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
|
|
21
21
|
import type { CreateHumanRequestDTO, CreateHumanRequestRequest, HumanRequestDTO, HumanRequestIdDTO, ReplaceHumanRequestRequest, RespondHumanRequestRequest, SettleHumanRequestRequest } from './dto/human-requests.js';
|
|
22
|
-
import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
|
|
22
|
+
import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, GraphDTO, PruneRequest, PruneResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
|
|
23
23
|
import type { AbandonWorktreeRequest, AbandonWorktreeResultDTO, CloseWorktreeResultDTO, QuarantinedWorktreeDTO } from './dto/worktree.js';
|
|
24
|
-
import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest } from './dto/broker-ops.js';
|
|
24
|
+
import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest, BrokerTelemetryRequest } from './dto/broker-ops.js';
|
|
25
25
|
import type { AcknowledgeMailRequest, AcknowledgeMailResultDTO, ClaimMailRequest, ClaimMailResultDTO } from './dto/mail.js';
|
|
26
26
|
import type { BrokerFaultInputDTO, BrokerFaultRequest, BrokerFaultResultDTO, BrokerProviderRetryRequest, BrokerProviderRetryResultDTO, BrokerTurnRequest, BrokerTurnResultDTO, NodeFaultClearResultDTO, RecoveryStateDTO } from './dto/recovery.js';
|
|
27
27
|
export interface CrtrClientOptions {
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
|
|
28
|
+
/** `http(s)://host:port` for a TCP/remote transport. */
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
/** Fetch implementation. The global Web fetch is used unless a caller supplies one. */
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
32
|
/** Extra headers, e.g. `{ authorization: 'Bearer <token>' }` when the target
|
|
33
33
|
* crtrd's TCP listener has `CRTRD_TOKEN` set (unix-socket transport is
|
|
34
34
|
* never checked, and a TCP daemon with no token set ignores this too). */
|
|
35
35
|
headers?: Record<string, string>;
|
|
36
|
-
/**
|
|
36
|
+
/** Retry a cold local connection through this hook once when enabled. */
|
|
37
37
|
autostart?: boolean;
|
|
38
|
+
/** Maximum retries for transient GET, HEAD, and DELETE requests. Defaults to 2. */
|
|
39
|
+
maxRetries?: number;
|
|
38
40
|
/** Per-request timeout in ms (default 30_000). */
|
|
39
41
|
timeoutMs?: number;
|
|
40
42
|
/** Injected daemon-start hook (spec §7.1). Called once on a cold socket when
|
|
@@ -64,22 +66,26 @@ export declare function waitForDaemonAvailability({ windowMs, probe, initialErro
|
|
|
64
66
|
now?: () => number;
|
|
65
67
|
sleep?: (ms: number) => Promise<void> | void;
|
|
66
68
|
}): Promise<void>;
|
|
69
|
+
export interface CrtrRequestOptions {
|
|
70
|
+
headers?: Record<string, string>;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
timeout?: number;
|
|
73
|
+
maxRetries?: number;
|
|
74
|
+
}
|
|
67
75
|
export declare class CrtrClient {
|
|
68
|
-
private readonly
|
|
69
|
-
private readonly
|
|
76
|
+
private readonly baseUrl;
|
|
77
|
+
private readonly fetch;
|
|
70
78
|
private readonly headers;
|
|
71
79
|
private readonly autostart;
|
|
72
80
|
private readonly timeoutMs;
|
|
81
|
+
private readonly maxRetries;
|
|
82
|
+
private readonly localSocketTransport;
|
|
73
83
|
private readonly onColdSocket?;
|
|
74
84
|
private readonly coldStartDiagnostic?;
|
|
75
85
|
private readonly coldStartPollWindowMs;
|
|
76
86
|
/** Guards against invoking the daemon-start hook more than once per client. */
|
|
77
87
|
private coldStartAttempted;
|
|
78
88
|
constructor(opts: CrtrClientOptions);
|
|
79
|
-
/** Construct a client bound to the default local socket with autostart on. Pass
|
|
80
|
-
* `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
|
|
81
|
-
* socket fails loud with `daemon_unavailable`. */
|
|
82
|
-
static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
|
|
83
89
|
healthz(): Promise<HealthDTO>;
|
|
84
90
|
/** One `/healthz` observation without cold-socket recovery. Availability
|
|
85
91
|
* waiters own retry policy and pass their remaining wall-clock budget here. */
|
|
@@ -123,6 +129,7 @@ export declare class CrtrClient {
|
|
|
123
129
|
settleBroker(id: string, req: BrokerSettleRequest): Promise<BrokerSettleDirective>;
|
|
124
130
|
completeBrokerPark(id: string, req: BrokerParkCompleteRequest): Promise<BrokerSettleDirective>;
|
|
125
131
|
recordBrokerParkActivity(id: string, req: BrokerExecutionRequest): Promise<BrokerParkActivityResultDTO>;
|
|
132
|
+
recordBrokerTelemetry(id: string, req: BrokerTelemetryRequest): Promise<void>;
|
|
126
133
|
claimNodeMail(id: string, req: ClaimMailRequest): Promise<ClaimMailResultDTO>;
|
|
127
134
|
acknowledgeNodeMail(id: string, req: AcknowledgeMailRequest): Promise<AcknowledgeMailResultDTO>;
|
|
128
135
|
recordBrokerTurn(id: string, req: BrokerTurnRequest): Promise<BrokerTurnResultDTO>;
|
|
@@ -219,6 +226,7 @@ export declare class CrtrClient {
|
|
|
219
226
|
/** Force-delete or detach one profile by exact id or unique name. */
|
|
220
227
|
deleteProfile(name: string, req: DeleteProfileRequest): Promise<DeleteProfileResultDTO>;
|
|
221
228
|
listModelAuth(): Promise<ModelAuthListDTO>;
|
|
229
|
+
getModelAuthReadiness(query?: ModelAuthReadinessQuery): Promise<ModelAuthReadinessDTO>;
|
|
222
230
|
installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
|
|
223
231
|
removeCredential(provider: string): Promise<CredentialRemovalResultDTO>;
|
|
224
232
|
createReview(req: CreateReviewRequest): Promise<ReviewDTO>;
|
|
@@ -308,12 +316,17 @@ export declare class CrtrClient {
|
|
|
308
316
|
* poll target for attach/browser topology; use `canvasSnapshot` for the
|
|
309
317
|
* enriched on-demand view. */
|
|
310
318
|
canvasRoster(): Promise<RosterDTO>;
|
|
319
|
+
/** The lean attach-graph projection: display rows, topology, and focus state
|
|
320
|
+
* in one daemon-owned read rather than the full node-summary list. */
|
|
321
|
+
canvasGraph(): Promise<GraphDTO>;
|
|
311
322
|
prune(req: PruneRequest): Promise<PruneResultDTO>;
|
|
312
323
|
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
313
|
-
* autostart + error-mapping semantics. */
|
|
314
|
-
request<T>(method: string, path: string, body?: unknown,
|
|
315
|
-
/** The unparsed request: cold-socket and interrupted-response recovery,
|
|
316
|
-
*
|
|
324
|
+
* autostart + retry + error-mapping semantics. */
|
|
325
|
+
request<T>(method: string, path: string, body?: unknown, opts?: CrtrRequestOptions): Promise<T>;
|
|
326
|
+
/** The unparsed request: cold-socket and interrupted-response recovery, plus
|
|
327
|
+
* the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
|
|
328
|
+
* only — POST/PATCH are never replayed once a request has actually been
|
|
329
|
+
* sent). No JSON parse; every wrapper goes through here. */
|
|
317
330
|
private send;
|
|
318
331
|
private nodePath;
|
|
319
332
|
/** Validate a background job id before route construction. Job ids arrive
|
|
@@ -343,7 +356,11 @@ export declare class CrtrClient {
|
|
|
343
356
|
* agent argv, so a bad one is a plausible request the server would also
|
|
344
357
|
* reject, not a caller-bug `TypeError` like `ticketId`. */
|
|
345
358
|
private commentPath;
|
|
359
|
+
/** The one fetch path (§1). Resolves as soon as headers arrive — the body
|
|
360
|
+
* stays an unread `ReadableStream` on the returned `Response`, so a caller
|
|
361
|
+
* that wants to stream (SSE, later) never waits on a buffered body. */
|
|
346
362
|
private transport;
|
|
363
|
+
private retainRequestLifetime;
|
|
347
364
|
private isColdSocketError;
|
|
348
365
|
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
349
366
|
* pipe). It establishes only that the response was interrupted, not why. */
|
package/dist/api/client.js
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
|
-
// CrtrClient — the typed
|
|
1
|
+
// CrtrClient — the typed fetch client over crtrd's API.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `core/*` / `node:sqlite` / TUI. The daemon-spawn logic lives in `core`/
|
|
6
|
-
// `daemon`, which this module may not import — so autostart is delegated to an
|
|
7
|
-
// injectable `onColdSocket` hook the CLI wires in (spec §7.1). A cold socket
|
|
8
|
-
// always gets one bounded availability observation; the hook only permits spawning.
|
|
9
|
-
//
|
|
10
|
-
// TRANSPORT (spec O-1): `node:http`/`node:https` `request()` — NO `undici`.
|
|
11
|
-
// unix socket via `{ socketPath }`; TCP/remote via a parsed `baseUrl`.
|
|
12
|
-
import { envHomeOverride } from '../shared/env.js';
|
|
13
|
-
import { request as httpRequest } from 'node:http';
|
|
14
|
-
import { request as httpsRequest } from 'node:https';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
16
|
-
import { join } from 'node:path';
|
|
3
|
+
// The root entry is browser-safe: Node's unix-socket fetch implementation and
|
|
4
|
+
// local path resolution live exclusively in `api/node-transport.ts`.
|
|
17
5
|
import { ApiError, isErrorBody } from './errors.js';
|
|
18
6
|
import { routes } from './routes.js';
|
|
19
7
|
import { isSafeNodeId } from './dto/common.js';
|
|
20
8
|
import { isSafeBashJobId } from './dto/bash-jobs.js';
|
|
21
9
|
import { isSafeCronId, } from './dto/crons.js';
|
|
22
|
-
/** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
|
|
23
|
-
* and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
|
|
24
|
-
* imported — to keep the exported `/api` contract dependency-light; the two
|
|
25
|
-
* MUST agree on the resolved socket path. */
|
|
26
|
-
const CRTR_DIR_NAME = '.crouter';
|
|
27
|
-
const SOCKET_BASENAME = 'crtrd.sock';
|
|
28
|
-
/** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
|
|
29
|
-
* via Node built-ins only (no `core/canvas/paths.ts` import). */
|
|
30
|
-
function defaultSocketPath() {
|
|
31
|
-
const override = envHomeOverride();
|
|
32
|
-
const home = override !== undefined && override !== ''
|
|
33
|
-
? override
|
|
34
|
-
: join(homedir(), CRTR_DIR_NAME, 'canvas');
|
|
35
|
-
return join(home, SOCKET_BASENAME);
|
|
36
|
-
}
|
|
37
10
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
|
+
const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
|
|
38
12
|
/** Default bounded window to wait for `/healthz` to come up after
|
|
39
13
|
* `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
|
|
40
14
|
const HEALTHZ_POLL_WINDOW_MS = 10_000;
|
|
@@ -64,41 +38,34 @@ export async function waitForDaemonAvailability({ windowMs, probe, initialError,
|
|
|
64
38
|
}
|
|
65
39
|
}
|
|
66
40
|
export class CrtrClient {
|
|
67
|
-
socketPath;
|
|
68
41
|
baseUrl;
|
|
42
|
+
fetch;
|
|
69
43
|
headers;
|
|
70
44
|
autostart;
|
|
71
45
|
timeoutMs;
|
|
46
|
+
maxRetries;
|
|
47
|
+
localSocketTransport;
|
|
72
48
|
onColdSocket;
|
|
73
49
|
coldStartDiagnostic;
|
|
74
50
|
coldStartPollWindowMs;
|
|
75
51
|
/** Guards against invoking the daemon-start hook more than once per client. */
|
|
76
52
|
coldStartAttempted = false;
|
|
77
53
|
constructor(opts) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
if (hasSocket)
|
|
84
|
-
this.socketPath = opts.socketPath;
|
|
85
|
-
if (hasBaseUrl)
|
|
86
|
-
this.baseUrl = new URL(opts.baseUrl);
|
|
54
|
+
if (opts.baseUrl === '')
|
|
55
|
+
throw new TypeError('CrtrClient requires baseUrl');
|
|
56
|
+
this.baseUrl = new URL(opts.baseUrl);
|
|
57
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
87
58
|
this.headers = { ...opts.headers };
|
|
88
|
-
this.autostart = opts.autostart ??
|
|
59
|
+
this.autostart = opts.autostart ?? false;
|
|
89
60
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
62
|
+
this.localSocketTransport = this.fetch[LOCAL_SOCKET_FETCH] === true;
|
|
90
63
|
if (opts.onColdSocket !== undefined)
|
|
91
64
|
this.onColdSocket = opts.onColdSocket;
|
|
92
65
|
if (opts.coldStartDiagnostic !== undefined)
|
|
93
66
|
this.coldStartDiagnostic = opts.coldStartDiagnostic;
|
|
94
67
|
this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
|
|
95
68
|
}
|
|
96
|
-
/** Construct a client bound to the default local socket with autostart on. Pass
|
|
97
|
-
* `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
|
|
98
|
-
* socket fails loud with `daemon_unavailable`. */
|
|
99
|
-
static forLocalSocket(opts) {
|
|
100
|
-
return new CrtrClient({ socketPath: defaultSocketPath(), autostart: true, ...opts });
|
|
101
|
-
}
|
|
102
69
|
// Health / status
|
|
103
70
|
healthz() {
|
|
104
71
|
return this.request('GET', routes.healthz());
|
|
@@ -106,13 +73,13 @@ export class CrtrClient {
|
|
|
106
73
|
/** One `/healthz` observation without cold-socket recovery. Availability
|
|
107
74
|
* waiters own retry policy and pass their remaining wall-clock budget here. */
|
|
108
75
|
async probeHealthz(timeoutMs) {
|
|
109
|
-
const response = await this.transport('GET', routes.healthz(), undefined,
|
|
76
|
+
const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
|
|
110
77
|
// A restricted daemon is alive and deliberately answers its health DTO with
|
|
111
78
|
// 503. Preserve that state for daemon startup management instead of treating
|
|
112
79
|
// the expected non-2xx status as a generic API failure.
|
|
113
80
|
if (response.status === 503) {
|
|
114
81
|
try {
|
|
115
|
-
const health =
|
|
82
|
+
const health = await response.clone().json();
|
|
116
83
|
if (typeof health.startup_blocked === 'string')
|
|
117
84
|
return health;
|
|
118
85
|
}
|
|
@@ -156,7 +123,7 @@ export class CrtrClient {
|
|
|
156
123
|
if (waitSeconds !== undefined && (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 25)) {
|
|
157
124
|
throw new RangeError('waitSeconds must be an integer between 0 and 25');
|
|
158
125
|
}
|
|
159
|
-
return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, (waitSeconds ?? 0) * 1_000 + this.timeoutMs);
|
|
126
|
+
return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
|
|
160
127
|
}
|
|
161
128
|
/** Register or replace an armed target for terminal-outcome delivery. */
|
|
162
129
|
registerOutcomeDelivery(id, req) {
|
|
@@ -211,6 +178,9 @@ export class CrtrClient {
|
|
|
211
178
|
recordBrokerParkActivity(id, req) {
|
|
212
179
|
return this.request('POST', routes.nodeBrokerParkActivity(this.nodePath(id)), req);
|
|
213
180
|
}
|
|
181
|
+
async recordBrokerTelemetry(id, req) {
|
|
182
|
+
await this.request('POST', routes.nodeBrokerTelemetry(this.nodePath(id)), req);
|
|
183
|
+
}
|
|
214
184
|
claimNodeMail(id, req) {
|
|
215
185
|
return this.request('POST', routes.nodeMailClaim(this.nodePath(id)), req);
|
|
216
186
|
}
|
|
@@ -438,6 +408,9 @@ export class CrtrClient {
|
|
|
438
408
|
listModelAuth() {
|
|
439
409
|
return this.request('GET', routes.modelAuths());
|
|
440
410
|
}
|
|
411
|
+
getModelAuthReadiness(query) {
|
|
412
|
+
return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
|
|
413
|
+
}
|
|
441
414
|
installCredential(provider, req) {
|
|
442
415
|
return this.request('PUT', routes.modelAuth(provider), req);
|
|
443
416
|
}
|
|
@@ -627,34 +600,64 @@ export class CrtrClient {
|
|
|
627
600
|
canvasRoster() {
|
|
628
601
|
return this.request('GET', routes.canvasRoster());
|
|
629
602
|
}
|
|
603
|
+
/** The lean attach-graph projection: display rows, topology, and focus state
|
|
604
|
+
* in one daemon-owned read rather than the full node-summary list. */
|
|
605
|
+
canvasGraph() {
|
|
606
|
+
return this.request('GET', routes.canvasGraph());
|
|
607
|
+
}
|
|
630
608
|
prune(req) {
|
|
631
609
|
return this.request('POST', routes.canvasPrune(), req);
|
|
632
610
|
}
|
|
633
611
|
// Escape hatch
|
|
634
612
|
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
635
|
-
* autostart + error-mapping semantics. */
|
|
636
|
-
async request(method, path, body,
|
|
637
|
-
return parse(await this.send(method, path, body, undefined, timeoutMs));
|
|
638
|
-
}
|
|
639
|
-
/** The unparsed request: cold-socket and interrupted-response recovery, no
|
|
640
|
-
* JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
|
|
641
|
-
async send(method, path, body, extraHeaders, timeoutMs) {
|
|
613
|
+
* autostart + retry + error-mapping semantics. */
|
|
614
|
+
async request(method, path, body, opts) {
|
|
642
615
|
try {
|
|
643
|
-
return await this.
|
|
616
|
+
return await parse(await this.send(method, path, body, opts));
|
|
644
617
|
}
|
|
645
|
-
catch (
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
618
|
+
catch (error) {
|
|
619
|
+
throw toTransportApiError(error, opts?.signal);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** The unparsed request: cold-socket and interrupted-response recovery, plus
|
|
623
|
+
* the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
|
|
624
|
+
* only — POST/PATCH are never replayed once a request has actually been
|
|
625
|
+
* sent). No JSON parse; every wrapper goes through here. */
|
|
626
|
+
async send(method, path, body, opts = {}) {
|
|
627
|
+
const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
|
|
628
|
+
const maxRetries = opts.maxRetries ?? this.maxRetries;
|
|
629
|
+
for (let attempt = 0;; attempt++) {
|
|
630
|
+
let response;
|
|
631
|
+
try {
|
|
632
|
+
response = await this.transport(method, path, body, opts);
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
if (this.isColdSocketError(err)) {
|
|
636
|
+
try {
|
|
637
|
+
await this.handleColdSocket(err);
|
|
638
|
+
response = await this.transport(method, path, body, opts);
|
|
639
|
+
}
|
|
640
|
+
catch (recoveryError) {
|
|
641
|
+
throw toTransportApiError(recoveryError, opts.signal);
|
|
642
|
+
}
|
|
650
643
|
}
|
|
651
|
-
|
|
652
|
-
|
|
644
|
+
else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
|
|
645
|
+
return await this.rideOutInterruptedRequest(method, path, body, opts);
|
|
653
646
|
}
|
|
647
|
+
else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
|
|
648
|
+
await sleepMs(retryDelayMs(attempt + 1));
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
throw toTransportApiError(err, opts.signal);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
|
|
656
|
+
await drainResponseBody(response);
|
|
657
|
+
await sleepMs(retryDelayMs(attempt + 1));
|
|
658
|
+
continue;
|
|
654
659
|
}
|
|
655
|
-
|
|
656
|
-
return await this.rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs);
|
|
657
|
-
throw toTransportApiError(err);
|
|
660
|
+
return response;
|
|
658
661
|
}
|
|
659
662
|
}
|
|
660
663
|
// internals
|
|
@@ -721,85 +724,103 @@ export class CrtrClient {
|
|
|
721
724
|
}
|
|
722
725
|
return id;
|
|
723
726
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
+
/** The one fetch path (§1). Resolves as soon as headers arrive — the body
|
|
728
|
+
* stays an unread `ReadableStream` on the returned `Response`, so a caller
|
|
729
|
+
* that wants to stream (SSE, later) never waits on a buffered body. */
|
|
730
|
+
transport(method, path, body, opts = {}) {
|
|
727
731
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
728
|
-
const headers = { accept: 'application/json', ...this.headers, ...
|
|
729
|
-
if (payload !== undefined)
|
|
732
|
+
const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
|
|
733
|
+
if (payload !== undefined)
|
|
730
734
|
headers['content-type'] = 'application/json';
|
|
731
|
-
|
|
735
|
+
const timeoutMs = opts.timeout ?? this.timeoutMs;
|
|
736
|
+
const controller = new AbortController();
|
|
737
|
+
const timer = timeoutMs > 0
|
|
738
|
+
? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
|
|
739
|
+
: undefined;
|
|
740
|
+
const externalSignal = opts.signal;
|
|
741
|
+
const onExternalAbort = () => controller.abort(externalSignal?.reason);
|
|
742
|
+
if (externalSignal !== undefined) {
|
|
743
|
+
if (externalSignal.aborted)
|
|
744
|
+
controller.abort(externalSignal.reason);
|
|
745
|
+
else
|
|
746
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
732
747
|
}
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
timeout: timeoutMs,
|
|
748
|
+
const finish = () => {
|
|
749
|
+
if (timer !== undefined)
|
|
750
|
+
clearTimeout(timer);
|
|
751
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
738
752
|
};
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
753
|
+
return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
|
|
754
|
+
.then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
|
|
755
|
+
finish();
|
|
756
|
+
throw error;
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
retainRequestLifetime(response, finish, abortReason) {
|
|
760
|
+
if (response.body === null) {
|
|
761
|
+
finish();
|
|
762
|
+
return response;
|
|
747
763
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
764
|
+
const reader = response.body.getReader();
|
|
765
|
+
let finished = false;
|
|
766
|
+
const close = () => {
|
|
767
|
+
if (finished)
|
|
768
|
+
return;
|
|
769
|
+
finished = true;
|
|
770
|
+
finish();
|
|
771
|
+
};
|
|
772
|
+
const body = new ReadableStream({
|
|
773
|
+
async pull(controller) {
|
|
774
|
+
try {
|
|
775
|
+
const { done, value } = await reader.read();
|
|
776
|
+
if (done) {
|
|
777
|
+
close();
|
|
778
|
+
controller.close();
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
controller.enqueue(value);
|
|
757
782
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
req.end();
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
close();
|
|
786
|
+
controller.error(abortReason() ?? error);
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
async cancel(reason) {
|
|
790
|
+
close();
|
|
791
|
+
await reader.cancel(reason);
|
|
792
|
+
},
|
|
769
793
|
});
|
|
794
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
770
795
|
}
|
|
771
796
|
isColdSocketError(err) {
|
|
772
|
-
|
|
773
|
-
return false;
|
|
774
|
-
const code = err?.code;
|
|
797
|
+
const code = transportErrorCode(err);
|
|
775
798
|
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
|
|
776
799
|
}
|
|
777
800
|
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
778
801
|
* pipe). It establishes only that the response was interrupted, not why. */
|
|
779
802
|
isInterruptedSocketError(err) {
|
|
780
|
-
|
|
781
|
-
return false;
|
|
782
|
-
const code = err?.code;
|
|
803
|
+
const code = transportErrorCode(err);
|
|
783
804
|
return code === 'ECONNRESET' || code === 'EPIPE';
|
|
784
805
|
}
|
|
785
806
|
/** Wait for the local API after an interrupted response. GET/HEAD can retry
|
|
786
807
|
* because they are idempotent. A mutation may already have been applied, so
|
|
787
808
|
* it never replays. */
|
|
788
|
-
async rideOutInterruptedRequest(method, path, body,
|
|
809
|
+
async rideOutInterruptedRequest(method, path, body, opts = {}) {
|
|
789
810
|
try {
|
|
790
811
|
await this.awaitAvailability();
|
|
791
812
|
}
|
|
792
813
|
catch (error) {
|
|
793
|
-
throw toTransportApiError(error);
|
|
814
|
+
throw toTransportApiError(error, opts.signal);
|
|
794
815
|
}
|
|
795
816
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
796
817
|
throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
|
|
797
818
|
}
|
|
798
819
|
try {
|
|
799
|
-
return await this.transport(method, path, body,
|
|
820
|
+
return await this.transport(method, path, body, opts);
|
|
800
821
|
}
|
|
801
822
|
catch (err) {
|
|
802
|
-
throw toTransportApiError(err);
|
|
823
|
+
throw toTransportApiError(err, opts.signal);
|
|
803
824
|
}
|
|
804
825
|
}
|
|
805
826
|
async awaitAvailability(initialError) {
|
|
@@ -807,18 +828,19 @@ export class CrtrClient {
|
|
|
807
828
|
windowMs: this.coldStartPollWindowMs,
|
|
808
829
|
initialError,
|
|
809
830
|
probe: async (timeoutMs) => {
|
|
810
|
-
const response = await this.transport('GET', routes.healthz(), undefined,
|
|
831
|
+
const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
|
|
811
832
|
if (response.status >= 200 && response.status < 300)
|
|
812
833
|
return;
|
|
834
|
+
const text = await response.text();
|
|
813
835
|
try {
|
|
814
|
-
const health = JSON.parse(
|
|
836
|
+
const health = JSON.parse(text);
|
|
815
837
|
if (typeof health.startup_blocked === 'string')
|
|
816
838
|
return;
|
|
817
839
|
}
|
|
818
840
|
catch {
|
|
819
841
|
// The normal unavailable error below carries the response body.
|
|
820
842
|
}
|
|
821
|
-
throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${
|
|
843
|
+
throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
822
844
|
},
|
|
823
845
|
});
|
|
824
846
|
}
|
|
@@ -859,11 +881,12 @@ function withQuery(base, query) {
|
|
|
859
881
|
const qs = params.toString();
|
|
860
882
|
return qs === '' ? base : `${base}?${qs}`;
|
|
861
883
|
}
|
|
862
|
-
/** Parse a
|
|
863
|
-
*
|
|
864
|
-
|
|
884
|
+
/** Parse a response into `T` — the first (and here, only) read of its body —
|
|
885
|
+
* or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
|
|
886
|
+
* (callers that type `void`/optional handle it). */
|
|
887
|
+
async function parse(res) {
|
|
865
888
|
const ok = res.status >= 200 && res.status < 300;
|
|
866
|
-
const trimmed = res.text.trim();
|
|
889
|
+
const trimmed = (await res.text()).trim();
|
|
867
890
|
let payload;
|
|
868
891
|
if (trimmed !== '') {
|
|
869
892
|
try {
|
|
@@ -872,23 +895,77 @@ function parse(res) {
|
|
|
872
895
|
catch {
|
|
873
896
|
if (ok)
|
|
874
897
|
return undefined;
|
|
875
|
-
throw new ApiError(res.status, 'invalid_response',
|
|
898
|
+
throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
|
|
876
899
|
}
|
|
877
900
|
}
|
|
878
901
|
if (ok)
|
|
879
902
|
return payload;
|
|
880
903
|
if (isErrorBody(payload)) {
|
|
881
|
-
throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details);
|
|
904
|
+
throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers);
|
|
905
|
+
}
|
|
906
|
+
throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
|
|
907
|
+
}
|
|
908
|
+
/** `429`/`5xx` are the only statuses the §7 retry policy replays. */
|
|
909
|
+
function isRetryableStatus(status) {
|
|
910
|
+
return status === 429 || status >= 500;
|
|
911
|
+
}
|
|
912
|
+
/** A response abandoned mid-retry must have its stream drained, or the
|
|
913
|
+
* underlying connection (and, over a unix socket, the daemon's handler) is
|
|
914
|
+
* held open by a reader that will never arrive. */
|
|
915
|
+
async function drainResponseBody(res) {
|
|
916
|
+
try {
|
|
917
|
+
await res.body?.cancel();
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
// Best-effort — the response is being discarded either way.
|
|
882
921
|
}
|
|
883
|
-
|
|
922
|
+
}
|
|
923
|
+
/** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
|
|
924
|
+
function retryDelayMs(attempt) {
|
|
925
|
+
return Math.min(2_000, 250 * 2 ** (attempt - 1));
|
|
926
|
+
}
|
|
927
|
+
/** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
|
|
928
|
+
* wraps a connection error as `TypeError('fetch failed', { cause })`; the
|
|
929
|
+
* Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
|
|
930
|
+
* shape so both transports classify identically here. */
|
|
931
|
+
function transportErrorCode(err) {
|
|
932
|
+
if (typeof err !== 'object' || err === null)
|
|
933
|
+
return undefined;
|
|
934
|
+
const direct = err.code;
|
|
935
|
+
if (typeof direct === 'string')
|
|
936
|
+
return direct;
|
|
937
|
+
const cause = err.cause;
|
|
938
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
939
|
+
const causeCode = cause.code;
|
|
940
|
+
if (typeof causeCode === 'string')
|
|
941
|
+
return causeCode;
|
|
942
|
+
}
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
/** True when a transport throw is the caller's OWN abort, as opposed to the
|
|
946
|
+
* client's internal per-request timeout — an aborted request must never be
|
|
947
|
+
* retried, whichever reason fired. */
|
|
948
|
+
function isAbortError(err, signal) {
|
|
949
|
+
if (signal?.aborted)
|
|
950
|
+
return true;
|
|
951
|
+
return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
|
|
884
952
|
}
|
|
885
953
|
/** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
|
|
886
954
|
* connection refusal here means the daemon is unreachable and autostart could
|
|
887
955
|
* not recover it. */
|
|
888
|
-
function toTransportApiError(err) {
|
|
956
|
+
function toTransportApiError(err, signal) {
|
|
957
|
+
if (signal?.aborted) {
|
|
958
|
+
return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
|
|
959
|
+
}
|
|
889
960
|
if (err instanceof ApiError)
|
|
890
961
|
return err;
|
|
891
|
-
|
|
962
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
963
|
+
return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
|
|
964
|
+
}
|
|
965
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
966
|
+
return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
|
|
967
|
+
}
|
|
968
|
+
const code = transportErrorCode(err);
|
|
892
969
|
const message = err instanceof Error ? err.message : String(err);
|
|
893
970
|
if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
|
|
894
971
|
return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
|
|
@@ -29,6 +29,15 @@ export interface BrokerSettleRequest extends BrokerExecutionRequest {
|
|
|
29
29
|
backgroundJobsRunning: boolean;
|
|
30
30
|
pushedFinal: boolean;
|
|
31
31
|
}
|
|
32
|
+
/** `POST /v1/nodes/{id}/broker/telemetry` body. Tokens are cumulative within
|
|
33
|
+
* the broker's current Pi session; null context/activity values preserve the
|
|
34
|
+
* last usable value in the daemon projection, matching telemetry.json. */
|
|
35
|
+
export interface BrokerTelemetryRequest extends BrokerExecutionRequest {
|
|
36
|
+
tokens_in: number;
|
|
37
|
+
context_tokens: number | null;
|
|
38
|
+
last_activity: string | null;
|
|
39
|
+
updated_at: string;
|
|
40
|
+
}
|
|
32
41
|
/** The only consequence a settle caller may enact. crtrd has already committed
|
|
33
42
|
* every canvas and placement effect before returning this directive. */
|
|
34
43
|
export type BrokerSettleDirective = {
|
package/dist/api/dto/canvas.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Cursor, IsoTime, NodeIdDTO, NodeStatusDTO } from './common.js';
|
|
2
|
-
import type { NodeSummaryDTO } from './nodes.js';
|
|
2
|
+
import type { NodeFaultDTO, NodeSummaryDTO } from './nodes.js';
|
|
3
3
|
/** `GET /v1/nodes` + `GET /v1/status` composed for the dashboard view. */
|
|
4
4
|
export interface DashboardQuery {
|
|
5
5
|
/** Restrict to the subtree under this node. */
|
|
@@ -280,6 +280,28 @@ export interface RosterDTO {
|
|
|
280
280
|
nodes: RosterNodeDTO[];
|
|
281
281
|
edges: RosterEdgeDTO[];
|
|
282
282
|
}
|
|
283
|
+
/** One row in `GET /v1/canvas/graph`: exactly the node fields the attach graph displays. */
|
|
284
|
+
export interface GraphNodeDTO {
|
|
285
|
+
node_id: NodeIdDTO;
|
|
286
|
+
name: string;
|
|
287
|
+
description: string | null;
|
|
288
|
+
cycles: number | null;
|
|
289
|
+
kind: string;
|
|
290
|
+
status: NodeStatusDTO;
|
|
291
|
+
frozen_at: IsoTime | null;
|
|
292
|
+
pi_pid: number | null;
|
|
293
|
+
telemetry_context_tokens: number | null;
|
|
294
|
+
telemetry_last_activity: string | null;
|
|
295
|
+
fault: NodeFaultDTO | null;
|
|
296
|
+
streaming: boolean;
|
|
297
|
+
}
|
|
298
|
+
/** `GET /v1/canvas/graph` — the attach graph's one-read projection: display rows, subscription topology, and the current local viewer-focus set. */
|
|
299
|
+
export interface GraphDTO {
|
|
300
|
+
generated_at: IsoTime;
|
|
301
|
+
nodes: GraphNodeDTO[];
|
|
302
|
+
edges: RosterEdgeDTO[];
|
|
303
|
+
focused_node_ids: NodeIdDTO[];
|
|
304
|
+
}
|
|
283
305
|
/** `POST /v1/canvas/prune` body — the three exclusive prune modes of
|
|
284
306
|
* `crtr canvas prune`, precedence COUNT (`limit`) > EMPTY (`empty`) > TTL sweep. */
|
|
285
307
|
export interface PruneRequest {
|
|
@@ -66,6 +66,21 @@ export type ModelAuthProviderStatusDTO = {
|
|
|
66
66
|
export interface ModelAuthListDTO {
|
|
67
67
|
providers: ModelAuthProviderStatusDTO[];
|
|
68
68
|
}
|
|
69
|
+
/** Inputs that select the exact provider a new root node would use. */
|
|
70
|
+
export interface ModelAuthReadinessQuery {
|
|
71
|
+
profile?: string;
|
|
72
|
+
cwd?: string;
|
|
73
|
+
kind?: string;
|
|
74
|
+
model?: string;
|
|
75
|
+
}
|
|
76
|
+
/** `GET /v1/model-auth/readiness` — credential state for the provider selected by a prospective root launch. */
|
|
77
|
+
export interface ModelAuthReadinessDTO {
|
|
78
|
+
provider: string;
|
|
79
|
+
model: string;
|
|
80
|
+
credential: 'ready' | 'missing' | 'unusable';
|
|
81
|
+
reason?: 'invalid_grant';
|
|
82
|
+
rate_limited_until?: string | null;
|
|
83
|
+
}
|
|
69
84
|
/** `DELETE /v1/model-auth/{provider}` — all user credentials removed for one provider. */
|
|
70
85
|
export interface CredentialRemovalResultDTO {
|
|
71
86
|
provider: string;
|
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -105,6 +105,14 @@ export interface NodeSummaryDTO {
|
|
|
105
105
|
final_report: string | null;
|
|
106
106
|
finalized_at: IsoTime | null;
|
|
107
107
|
deadline_at: IsoTime | null;
|
|
108
|
+
/** Cumulative input tokens reported by the broker telemetry producer. */
|
|
109
|
+
telemetry_tokens_in?: number | null;
|
|
110
|
+
/** Latest context-window token gauge reported by the broker. */
|
|
111
|
+
telemetry_context_tokens?: number | null;
|
|
112
|
+
/** Latest tool summary reported by the broker. */
|
|
113
|
+
telemetry_last_activity?: string | null;
|
|
114
|
+
/** Timestamp of the latest broker telemetry update. */
|
|
115
|
+
telemetry_updated_at?: IsoTime | null;
|
|
108
116
|
outcome: NodeOutcomeSummaryDTO | null;
|
|
109
117
|
fault?: NodeFaultDTO | null;
|
|
110
118
|
streaming?: boolean;
|
package/dist/api/errors.d.ts
CHANGED
|
@@ -9,14 +9,17 @@ export interface ErrorBody {
|
|
|
9
9
|
/** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
|
|
10
10
|
* failure such as an unreachable daemon). Carries the HTTP status, the stable
|
|
11
11
|
* machine `code` from the `ErrorBody`, and optional structured `details`. */
|
|
12
|
-
export declare class
|
|
12
|
+
export declare class APIError extends Error {
|
|
13
13
|
readonly status: number;
|
|
14
14
|
readonly code: string;
|
|
15
15
|
readonly details?: unknown;
|
|
16
|
-
|
|
16
|
+
readonly headers: Headers;
|
|
17
|
+
constructor(status: number, code: string, message: string, details?: unknown, headers?: HeadersInit);
|
|
17
18
|
}
|
|
19
|
+
/** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
|
|
20
|
+
export { APIError as ApiError };
|
|
18
21
|
/** A local API transport failure has stable status/code identity, independent
|
|
19
22
|
* of message prose. */
|
|
20
|
-
export declare function isDaemonTransportApiError(error: unknown): error is
|
|
23
|
+
export declare function isDaemonTransportApiError(error: unknown): error is APIError;
|
|
21
24
|
/** Type guard for an `ErrorBody`-shaped parsed payload. */
|
|
22
25
|
export declare function isErrorBody(value: unknown): value is ErrorBody;
|
package/dist/api/errors.js
CHANGED
|
@@ -5,23 +5,27 @@
|
|
|
5
5
|
/** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
|
|
6
6
|
* failure such as an unreachable daemon). Carries the HTTP status, the stable
|
|
7
7
|
* machine `code` from the `ErrorBody`, and optional structured `details`. */
|
|
8
|
-
export class
|
|
8
|
+
export class APIError extends Error {
|
|
9
9
|
status;
|
|
10
10
|
code;
|
|
11
11
|
details;
|
|
12
|
-
|
|
12
|
+
headers;
|
|
13
|
+
constructor(status, code, message, details, headers) {
|
|
13
14
|
super(message);
|
|
14
|
-
this.name = '
|
|
15
|
+
this.name = 'APIError';
|
|
15
16
|
this.status = status;
|
|
16
17
|
this.code = code;
|
|
18
|
+
this.headers = new Headers(headers);
|
|
17
19
|
if (details !== undefined)
|
|
18
20
|
this.details = details;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
23
|
+
/** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
|
|
24
|
+
export { APIError as ApiError };
|
|
21
25
|
/** A local API transport failure has stable status/code identity, independent
|
|
22
26
|
* of message prose. */
|
|
23
27
|
export function isDaemonTransportApiError(error) {
|
|
24
|
-
if (!(error instanceof
|
|
28
|
+
if (!(error instanceof APIError))
|
|
25
29
|
return false;
|
|
26
30
|
return (error.status === 503 && (error.code === 'daemon_unavailable'
|
|
27
31
|
|| error.code === 'transport_error'
|
package/dist/api/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { CrtrClient, waitForDaemonAvailability } from './client.js';
|
|
2
|
-
export type { CrtrClientOptions } from './client.js';
|
|
3
|
-
export { ApiError, isErrorBody } from './errors.js';
|
|
2
|
+
export type { CrtrClientOptions, CrtrRequestOptions } from './client.js';
|
|
3
|
+
export { APIError, ApiError, isErrorBody } from './errors.js';
|
|
4
4
|
export type { ErrorBody } from './errors.js';
|
|
5
5
|
export { API_VERSION, routes } from './routes.js';
|
|
6
6
|
export * from '../shared/generated-context.js';
|
package/dist/api/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// route constants, error contract, and the typed `CrtrClient`. Dependency-light
|
|
3
3
|
// by design (spec §3.1): Node built-ins + `src/api/*` only.
|
|
4
4
|
export { CrtrClient, waitForDaemonAvailability } from './client.js';
|
|
5
|
-
export { ApiError, isErrorBody } from './errors.js';
|
|
5
|
+
export { APIError, ApiError, isErrorBody } from './errors.js';
|
|
6
6
|
export { API_VERSION, routes } from './routes.js';
|
|
7
7
|
export * from '../shared/generated-context.js';
|
|
8
8
|
export * from './dto/common.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CrtrClient, type CrtrClientOptions } from './client.js';
|
|
2
|
+
/** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
|
|
3
|
+
* via Node built-ins only (no `core/canvas/paths.ts` import). */
|
|
4
|
+
export declare function defaultSocketPath(): string;
|
|
5
|
+
/** A standard `fetch` bound to one unix socket, built on `node:http` with
|
|
6
|
+
* `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
|
|
7
|
+
* fed as bytes arrive — never buffered before the caller sees headers —
|
|
8
|
+
* which is what lets `CrtrClient` serve a streaming route (SSE) later without
|
|
9
|
+
* a second transport. Connection failures are wrapped as `TypeError('fetch
|
|
10
|
+
* failed', { cause })`, matching the shape native `fetch` throws, so
|
|
11
|
+
* `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
|
|
12
|
+
* reads identically off either transport. */
|
|
13
|
+
export declare function socketFetch(socketPath: string): typeof fetch;
|
|
14
|
+
/** Construct a client bound to the default local socket with autostart on.
|
|
15
|
+
* Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
|
|
16
|
+
export declare function localClient(opts?: Omit<CrtrClientOptions, 'baseUrl' | 'fetch'> & {
|
|
17
|
+
socketPath?: string;
|
|
18
|
+
}): CrtrClient;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The Node-only half of `@north-light/crouter-api`: crtrd's default unix
|
|
2
|
+
// socket path, a `fetch` implementation that speaks it, and `localClient()` —
|
|
3
|
+
// the one-line way to get a `CrtrClient` bound to the owner's own daemon.
|
|
4
|
+
//
|
|
5
|
+
// This is a SEPARATE package entry (`@north-light/crouter-api/node`) from the
|
|
6
|
+
// root so a browser bundle that imports the root never resolves `node:http`,
|
|
7
|
+
// `node:os`, or `node:path`. Import from here, never re-export it off the root.
|
|
8
|
+
import { request as httpRequest } from 'node:http';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { CrtrClient } from './client.js';
|
|
12
|
+
import { envHomeOverride } from '../shared/env.js';
|
|
13
|
+
/** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
|
|
14
|
+
* and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
|
|
15
|
+
* imported — to keep the exported `/api` contract dependency-light; the two
|
|
16
|
+
* MUST agree on the resolved socket path. */
|
|
17
|
+
const CRTR_DIR_NAME = '.crouter';
|
|
18
|
+
const SOCKET_BASENAME = 'crtrd.sock';
|
|
19
|
+
const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
|
|
20
|
+
/** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
|
|
21
|
+
* via Node built-ins only (no `core/canvas/paths.ts` import). */
|
|
22
|
+
export function defaultSocketPath() {
|
|
23
|
+
const override = envHomeOverride();
|
|
24
|
+
const home = override !== undefined && override !== ''
|
|
25
|
+
? override
|
|
26
|
+
: join(homedir(), CRTR_DIR_NAME, 'canvas');
|
|
27
|
+
return join(home, SOCKET_BASENAME);
|
|
28
|
+
}
|
|
29
|
+
/** A standard `fetch` bound to one unix socket, built on `node:http` with
|
|
30
|
+
* `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
|
|
31
|
+
* fed as bytes arrive — never buffered before the caller sees headers —
|
|
32
|
+
* which is what lets `CrtrClient` serve a streaming route (SSE) later without
|
|
33
|
+
* a second transport. Connection failures are wrapped as `TypeError('fetch
|
|
34
|
+
* failed', { cause })`, matching the shape native `fetch` throws, so
|
|
35
|
+
* `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
|
|
36
|
+
* reads identically off either transport. */
|
|
37
|
+
export function socketFetch(socketPath) {
|
|
38
|
+
const fetchOverSocket = function (input, init = {}) {
|
|
39
|
+
const url = input instanceof URL ? input : new URL(typeof input === 'string' ? input : input.url);
|
|
40
|
+
const method = init.method ?? 'GET';
|
|
41
|
+
const headers = {};
|
|
42
|
+
if (init.headers !== undefined) {
|
|
43
|
+
for (const [name, value] of new Headers(init.headers).entries())
|
|
44
|
+
headers[name] = value;
|
|
45
|
+
}
|
|
46
|
+
const payload = typeof init.body === 'string' ? init.body : undefined;
|
|
47
|
+
if (payload !== undefined)
|
|
48
|
+
headers['content-length'] = String(Buffer.byteLength(payload));
|
|
49
|
+
return new Promise((resolvePromise, reject) => {
|
|
50
|
+
const req = httpRequest({ socketPath, path: url.pathname + url.search, method, headers }, (res) => {
|
|
51
|
+
const body = new ReadableStream({
|
|
52
|
+
start(controller) {
|
|
53
|
+
res.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
|
|
54
|
+
res.on('end', () => controller.close());
|
|
55
|
+
res.on('error', (err) => controller.error(err));
|
|
56
|
+
},
|
|
57
|
+
cancel() {
|
|
58
|
+
res.destroy();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const responseHeaders = new Headers();
|
|
62
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
for (const one of Array.isArray(value) ? value : [value])
|
|
66
|
+
responseHeaders.append(name, one);
|
|
67
|
+
}
|
|
68
|
+
const status = res.statusCode ?? 0;
|
|
69
|
+
resolvePromise(new Response(status === 204 ? null : body, { status, statusText: res.statusMessage, headers: responseHeaders }));
|
|
70
|
+
});
|
|
71
|
+
req.on('error', (err) => reject(new TypeError('fetch failed', { cause: err })));
|
|
72
|
+
const signal = init.signal;
|
|
73
|
+
const abort = () => {
|
|
74
|
+
req.destroy();
|
|
75
|
+
reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
|
|
76
|
+
};
|
|
77
|
+
if (signal !== null && signal !== undefined) {
|
|
78
|
+
if (signal.aborted) {
|
|
79
|
+
abort();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
83
|
+
}
|
|
84
|
+
if (payload !== undefined)
|
|
85
|
+
req.write(payload);
|
|
86
|
+
req.end();
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
fetchOverSocket[LOCAL_SOCKET_FETCH] = true;
|
|
90
|
+
return fetchOverSocket;
|
|
91
|
+
}
|
|
92
|
+
/** Construct a client bound to the default local socket with autostart on.
|
|
93
|
+
* Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
|
|
94
|
+
export function localClient(opts = {}) {
|
|
95
|
+
const { socketPath, ...rest } = opts;
|
|
96
|
+
return new CrtrClient({
|
|
97
|
+
baseUrl: 'http://localhost',
|
|
98
|
+
fetch: socketFetch(socketPath ?? defaultSocketPath()),
|
|
99
|
+
autostart: true,
|
|
100
|
+
...rest,
|
|
101
|
+
});
|
|
102
|
+
}
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export declare const routes: {
|
|
|
33
33
|
readonly nodeBrokerSettle: (id: string) => string;
|
|
34
34
|
readonly nodeBrokerParkComplete: (id: string) => string;
|
|
35
35
|
readonly nodeBrokerParkActivity: (id: string) => string;
|
|
36
|
+
readonly nodeBrokerTelemetry: (id: string) => string;
|
|
36
37
|
readonly nodeMailClaim: (id: string) => string;
|
|
37
38
|
readonly nodeMailAcknowledge: (id: string) => string;
|
|
38
39
|
readonly nodeBrokerModel: (id: string) => string;
|
|
@@ -75,6 +76,7 @@ export declare const routes: {
|
|
|
75
76
|
readonly canvasHistoryStats: () => string;
|
|
76
77
|
readonly canvasSnapshot: () => string;
|
|
77
78
|
readonly canvasRoster: () => string;
|
|
79
|
+
readonly canvasGraph: () => string;
|
|
78
80
|
readonly canvasPrune: () => string;
|
|
79
81
|
readonly humanReviews: () => string;
|
|
80
82
|
readonly humanReview: (reviewId: string) => string;
|
|
@@ -109,6 +111,7 @@ export declare const routes: {
|
|
|
109
111
|
readonly profileResume: (name: string) => string;
|
|
110
112
|
readonly profileMetadata: (name: string) => string;
|
|
111
113
|
readonly modelAuths: () => string;
|
|
114
|
+
readonly modelAuthReadiness: () => string;
|
|
112
115
|
readonly modelAuth: (provider: string) => string;
|
|
113
116
|
readonly filePeek: () => string;
|
|
114
117
|
readonly memoryResolve: () => string;
|
package/dist/api/routes.js
CHANGED
|
@@ -51,6 +51,7 @@ export const routes = {
|
|
|
51
51
|
nodeBrokerSettle: (id) => `${V}/nodes/${id}/broker/settle`,
|
|
52
52
|
nodeBrokerParkComplete: (id) => `${V}/nodes/${id}/broker/park-complete`,
|
|
53
53
|
nodeBrokerParkActivity: (id) => `${V}/nodes/${id}/broker/park-activity`,
|
|
54
|
+
nodeBrokerTelemetry: (id) => `${V}/nodes/${id}/broker/telemetry`,
|
|
54
55
|
nodeMailClaim: (id) => `${V}/nodes/${id}/mail/claim`,
|
|
55
56
|
nodeMailAcknowledge: (id) => `${V}/nodes/${id}/mail/acknowledge`,
|
|
56
57
|
nodeBrokerModel: (id) => `${V}/nodes/${id}/broker/model`,
|
|
@@ -97,6 +98,7 @@ export const routes = {
|
|
|
97
98
|
canvasHistoryStats: () => `${V}/canvas/history/stats`,
|
|
98
99
|
canvasSnapshot: () => `${V}/canvas/snapshot`,
|
|
99
100
|
canvasRoster: () => `${V}/canvas/roster`,
|
|
101
|
+
canvasGraph: () => `${V}/canvas/graph`,
|
|
100
102
|
canvasPrune: () => `${V}/canvas/prune`,
|
|
101
103
|
// Daemon-owned document reviews and comments. All interpolated ids are
|
|
102
104
|
// guarded by `CrtrClient` before they reach these pure builders.
|
|
@@ -138,6 +140,7 @@ export const routes = {
|
|
|
138
140
|
profileMetadata: (name) => `${V}/profiles/${name}/metadata`,
|
|
139
141
|
// Model auth
|
|
140
142
|
modelAuths: () => `${V}/model-auth`,
|
|
143
|
+
modelAuthReadiness: () => `${V}/model-auth/readiness`,
|
|
141
144
|
modelAuth: (provider) => `${V}/model-auth/${provider}`,
|
|
142
145
|
// Host file read (browser file-peek panel). The absolute path rides as a
|
|
143
146
|
// `path` query param, not a path segment — it is not a single safe segment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter-api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.323",
|
|
4
4
|
"description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/api/index.js",
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
"require": "./dist/api/index.js",
|
|
13
13
|
"default": "./dist/api/index.js"
|
|
14
14
|
},
|
|
15
|
+
"./node": {
|
|
16
|
+
"types": "./dist/api/node-transport.d.ts",
|
|
17
|
+
"import": "./dist/api/node-transport.js",
|
|
18
|
+
"require": "./dist/api/node-transport.js",
|
|
19
|
+
"default": "./dist/api/node-transport.js"
|
|
20
|
+
},
|
|
15
21
|
"./plugin-manifest": {
|
|
16
22
|
"types": "./dist/api/plugin-manifest-schema.d.ts",
|
|
17
23
|
"import": "./dist/api/plugin-manifest-schema.js",
|