@north-light/crouter-api 0.3.156
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/README.md +51 -0
- package/dist/__tests__/client.test.d.ts +1 -0
- package/dist/__tests__/client.test.js +274 -0
- package/dist/client.d.ts +246 -0
- package/dist/client.js +611 -0
- package/dist/dto/attach.d.ts +16 -0
- package/dist/dto/attach.js +13 -0
- package/dist/dto/broker.d.ts +45 -0
- package/dist/dto/broker.js +20 -0
- package/dist/dto/canvas.d.ts +253 -0
- package/dist/dto/canvas.js +2 -0
- package/dist/dto/common.d.ts +27 -0
- package/dist/dto/common.js +15 -0
- package/dist/dto/config.d.ts +19 -0
- package/dist/dto/config.js +3 -0
- package/dist/dto/crons.d.ts +124 -0
- package/dist/dto/crons.js +10 -0
- package/dist/dto/files.d.ts +11 -0
- package/dist/dto/files.js +7 -0
- package/dist/dto/focus.d.ts +24 -0
- package/dist/dto/focus.js +10 -0
- package/dist/dto/health.d.ts +41 -0
- package/dist/dto/health.js +2 -0
- package/dist/dto/human.d.ts +57 -0
- package/dist/dto/human.js +4 -0
- package/dist/dto/inbox.d.ts +105 -0
- package/dist/dto/inbox.js +10 -0
- package/dist/dto/lifecycle.d.ts +79 -0
- package/dist/dto/lifecycle.js +3 -0
- package/dist/dto/messages.d.ts +55 -0
- package/dist/dto/messages.js +2 -0
- package/dist/dto/modelauth.d.ts +41 -0
- package/dist/dto/modelauth.js +3 -0
- package/dist/dto/nodes.d.ts +194 -0
- package/dist/dto/nodes.js +3 -0
- package/dist/dto/profiles.d.ts +14 -0
- package/dist/dto/profiles.js +3 -0
- package/dist/dto/reports.d.ts +41 -0
- package/dist/dto/reports.js +2 -0
- package/dist/dto/subscriptions.d.ts +14 -0
- package/dist/dto/subscriptions.js +2 -0
- package/dist/dto/worktree.d.ts +19 -0
- package/dist/dto/worktree.js +6 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +30 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +25 -0
- package/dist/routes.d.ts +63 -0
- package/dist/routes.js +91 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @north-light/crouter-api
|
|
2
|
+
|
|
3
|
+
The typed contract for **crtrd**'s `/v1` HTTP API — DTOs, route builders, the
|
|
4
|
+
error contract, and the `CrtrClient` transport. This is the client-side surface
|
|
5
|
+
external consumers (e.g. Northlight Core) use to talk to a crtrd instance
|
|
6
|
+
without depending on the full `@crouton-kit/crouter` runtime.
|
|
7
|
+
|
|
8
|
+
Zero runtime dependencies: the built package is Node built-ins (`node:http`,
|
|
9
|
+
`node:https`, `node:os`, `node:path`) plus its own modules only. No install
|
|
10
|
+
scripts.
|
|
11
|
+
|
|
12
|
+
> Private package (`"access": "restricted"`) published under the `@north-light`
|
|
13
|
+
> org. Not a general-purpose SDK.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @north-light/crouter-api
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { CrtrClient, type NodeSummaryDTO } from '@north-light/crouter-api';
|
|
25
|
+
|
|
26
|
+
// Talk to a remote crtrd over TCP (e.g. a Blaxel preview edge).
|
|
27
|
+
const client = new CrtrClient({ baseUrl: 'https://my-crtrd.example.com' });
|
|
28
|
+
|
|
29
|
+
const nodes: NodeSummaryDTO[] = await client.listNodes();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`CrtrClient` also drives a local crtrd over its unix socket; see the
|
|
33
|
+
`CrtrClientOptions` type for `socketPath`, auth headers, and the `onColdSocket`
|
|
34
|
+
autostart hook.
|
|
35
|
+
|
|
36
|
+
## What's exported
|
|
37
|
+
|
|
38
|
+
- **`CrtrClient`** — typed HTTP client wrapping every `/v1` route.
|
|
39
|
+
- **DTOs** — request/response types for nodes, lifecycle, messages, reports,
|
|
40
|
+
subscriptions, focuses, triggers, config, attach, profiles, model-auth,
|
|
41
|
+
canvas, worktree, and human-in-the-loop.
|
|
42
|
+
- **`routes` / `API_VERSION`** — pure path builders for the `/v1` surface.
|
|
43
|
+
- **`ApiError` / `ErrorBody` / `isErrorBody`** — the error contract.
|
|
44
|
+
|
|
45
|
+
## Source of truth & releases
|
|
46
|
+
|
|
47
|
+
The source lives in the [`crouter`](https://github.com/vallum-security/crouter)
|
|
48
|
+
repo at `src/api/`; this package is a build-and-publish mirror of that subtree
|
|
49
|
+
(it carries no source of its own). It is published by the `publish-api.yml`
|
|
50
|
+
GitHub Action when a tag matching `api-vX.Y.Z` is pushed — the tag version must
|
|
51
|
+
match this package's `version`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// client.test.ts — regression for issue #516: a cold-start `/healthz` timeout
|
|
2
|
+
// used to throw a bare `daemon_unavailable` message that discarded the actual
|
|
3
|
+
// startup failure (the tail of crtrd.err). `coldStartTimeoutMessage` is the
|
|
4
|
+
// pure composition `handleColdSocket` uses on timeout; this proves an injected
|
|
5
|
+
// diagnostic is appended (not dropped) and that a missing/empty diagnostic
|
|
6
|
+
// still falls back to the original bare message unchanged. `safeColdStartDiagnostic`
|
|
7
|
+
// is the guard `handleColdSocket` applies before that composition; this proves a
|
|
8
|
+
// THROWING hook is treated as absent (per the documented `CrtrClientOptions`
|
|
9
|
+
// contract) instead of propagating and replacing the typed `daemon_unavailable`
|
|
10
|
+
// error.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { coldStartTimeoutMessage, CrtrClient, safeColdStartDiagnostic } from '../client.js';
|
|
18
|
+
import { ApiError } from '../errors.js';
|
|
19
|
+
test('coldStartTimeoutMessage appends a present diagnostic to the base message', () => {
|
|
20
|
+
const msg = coldStartTimeoutMessage('crtrd.err (tail):\nError: bind EADDRINUSE');
|
|
21
|
+
assert.match(msg, /crtrd did not start/);
|
|
22
|
+
assert.match(msg, /crtrd\.err \(tail\):\nError: bind EADDRINUSE/);
|
|
23
|
+
});
|
|
24
|
+
test('coldStartTimeoutMessage falls back to the bare message when no diagnostic is available', () => {
|
|
25
|
+
assert.equal(coldStartTimeoutMessage(undefined), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
|
|
26
|
+
assert.equal(coldStartTimeoutMessage(''), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
|
|
27
|
+
});
|
|
28
|
+
function startDelayedHealthzServer(socketPath, delayMs) {
|
|
29
|
+
const server = createServer((_req, res) => {
|
|
30
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify({ ok: true }));
|
|
32
|
+
});
|
|
33
|
+
const timer = setTimeout(() => server.listen(socketPath), delayMs);
|
|
34
|
+
return { server, cancel: () => clearTimeout(timer) };
|
|
35
|
+
}
|
|
36
|
+
// REGRESSION: `crtr sys daemon restart` acks, then tears the old server down and
|
|
37
|
+
// hands over to a successor — a request crossing that window dies with a hang-up
|
|
38
|
+
// (ECONNRESET), not a refused connect. That used to surface as
|
|
39
|
+
// `daemon_unavailable: socket hang up` telling the caller to run
|
|
40
|
+
// `crtr sys daemon start` against a daemon that was already coming back up.
|
|
41
|
+
function startHandoverServer(socketPath) {
|
|
42
|
+
let dropped = false;
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
if (!dropped) {
|
|
45
|
+
dropped = true; // model the old server dying mid-request
|
|
46
|
+
req.socket.destroy();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify({ ok: true, method: req.method }));
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
server,
|
|
54
|
+
ready: new Promise((resolvePromise) => server.listen(socketPath, resolvePromise)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
test('a hang-up mid-request rides out the daemon handover and replays the idempotent call', async () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
|
|
59
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
60
|
+
const { server, ready } = startHandoverServer(socketPath);
|
|
61
|
+
try {
|
|
62
|
+
await ready;
|
|
63
|
+
const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
|
|
64
|
+
assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
server.close();
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
test('a hang-up mid-mutation reports daemon_restarting, not daemon_unavailable', async () => {
|
|
72
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
|
|
73
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
74
|
+
const { server, ready } = startHandoverServer(socketPath);
|
|
75
|
+
try {
|
|
76
|
+
await ready;
|
|
77
|
+
const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
|
|
78
|
+
await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
|
|
79
|
+
// Replaying is unsafe (the daemon may have applied it), so this one is the
|
|
80
|
+
// caller's call — but it must not be reported as a daemon that is down.
|
|
81
|
+
(err) => err instanceof ApiError && err.code === 'daemon_restarting');
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
server.close();
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test('cliClient-style cold start fails loud when the injected poll window is shorter than the (valid) startup delay', async () => {
|
|
89
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
|
|
90
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
91
|
+
const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
|
|
92
|
+
try {
|
|
93
|
+
const client = new CrtrClient({
|
|
94
|
+
socketPath,
|
|
95
|
+
autostart: true,
|
|
96
|
+
onColdSocket: async () => {
|
|
97
|
+
/* fire-and-forget: the delayed listen() above models ensureDaemon() */
|
|
98
|
+
},
|
|
99
|
+
coldStartPollWindowMs: 100, // shorter than the 300ms the daemon actually needs
|
|
100
|
+
});
|
|
101
|
+
await assert.rejects(() => client.healthz(), (err) => err instanceof ApiError && err.code === 'daemon_unavailable');
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
cancel(); // the client already timed out at 100ms; never let listen() fire at 300ms
|
|
105
|
+
server.close();
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
test('cliClient-style cold start succeeds when the injected poll window covers the (valid) startup delay', async () => {
|
|
110
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
|
|
111
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
112
|
+
const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
|
|
113
|
+
try {
|
|
114
|
+
const client = new CrtrClient({
|
|
115
|
+
socketPath,
|
|
116
|
+
autostart: true,
|
|
117
|
+
onColdSocket: async () => {
|
|
118
|
+
/* fire-and-forget: the delayed listen() above models ensureDaemon() */
|
|
119
|
+
},
|
|
120
|
+
coldStartPollWindowMs: 2_000, // long enough to see the 300ms-delayed daemon come up
|
|
121
|
+
});
|
|
122
|
+
const health = await client.healthz();
|
|
123
|
+
assert.deepEqual(health, { ok: true });
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
cancel(); // already fired by the time we get here; harmless no-op
|
|
127
|
+
server.close();
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
|
|
132
|
+
assert.equal(safeColdStartDiagnostic(undefined), undefined);
|
|
133
|
+
});
|
|
134
|
+
test('safeColdStartDiagnostic returns the hook result when it succeeds', () => {
|
|
135
|
+
assert.equal(safeColdStartDiagnostic(() => 'crtrd.err (tail):\nboom'), 'crtrd.err (tail):\nboom');
|
|
136
|
+
});
|
|
137
|
+
test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated error', () => {
|
|
138
|
+
assert.equal(safeColdStartDiagnostic(() => {
|
|
139
|
+
throw new Error('custom hook blew up');
|
|
140
|
+
}), undefined);
|
|
141
|
+
});
|
|
142
|
+
/** Spin a loopback server on a temp unix socket that records every request and
|
|
143
|
+
* replies with `respond(req)`. `respond` returning `undefined` means "handler
|
|
144
|
+
* not reached for this call" and is never exercised by these tests. */
|
|
145
|
+
async function startCapturingServer(respond) {
|
|
146
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-inbox-'));
|
|
147
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
148
|
+
const requests = [];
|
|
149
|
+
const server = createServer((req, res) => {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
152
|
+
req.on('end', () => {
|
|
153
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
154
|
+
const body = raw.trim() === '' ? undefined : JSON.parse(raw);
|
|
155
|
+
const captured = { method: req.method ?? '', path: req.url ?? '', body };
|
|
156
|
+
requests.push(captured);
|
|
157
|
+
const { status, body: resBody } = respond(captured);
|
|
158
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
159
|
+
res.end(JSON.stringify(resBody));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
await new Promise((resolvePromise) => server.listen(socketPath, resolvePromise));
|
|
163
|
+
const client = new CrtrClient({ socketPath, autostart: false });
|
|
164
|
+
return {
|
|
165
|
+
client,
|
|
166
|
+
requests,
|
|
167
|
+
close: () => new Promise((resolvePromise, rejectPromise) => {
|
|
168
|
+
server.close((err) => (err ? rejectPromise(err) : resolvePromise()));
|
|
169
|
+
}).finally(() => rmSync(dir, { recursive: true, force: true })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const SAMPLE_TICKET_ID = 'a'.repeat(64);
|
|
173
|
+
test('listHumanInbox sends GET /v1/human/inbox with no body and returns the parsed list', async () => {
|
|
174
|
+
const inboxList = { tickets: [{ ticket_id: SAMPLE_TICKET_ID, kind: 'deck', title: 'Approve deploy', blocked_since: '2026-01-01T00:00:00Z', source: {} }] };
|
|
175
|
+
const srv = await startCapturingServer(() => ({ status: 200, body: inboxList }));
|
|
176
|
+
try {
|
|
177
|
+
const result = await srv.client.listHumanInbox();
|
|
178
|
+
assert.deepEqual(result, inboxList);
|
|
179
|
+
assert.equal(srv.requests.length, 1);
|
|
180
|
+
assert.equal(srv.requests[0]?.method, 'GET');
|
|
181
|
+
assert.equal(srv.requests[0]?.path, '/v1/human/inbox');
|
|
182
|
+
assert.equal(srv.requests[0]?.body, undefined);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await srv.close();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
test('getHumanInboxDeck sends GET /v1/human/inbox/:ticket_id with no body and returns the parsed deck', async () => {
|
|
189
|
+
const deck = { ticket_id: SAMPLE_TICKET_ID, kind: 'deck', deck: { title: 'Approve deploy', interactions: [] } };
|
|
190
|
+
const srv = await startCapturingServer(() => ({ status: 200, body: deck }));
|
|
191
|
+
try {
|
|
192
|
+
const result = await srv.client.getHumanInboxDeck(SAMPLE_TICKET_ID);
|
|
193
|
+
assert.deepEqual(result, deck);
|
|
194
|
+
assert.equal(srv.requests[0]?.method, 'GET');
|
|
195
|
+
assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}`);
|
|
196
|
+
assert.equal(srv.requests[0]?.body, undefined);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await srv.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
test('respondHumanInboxDeck sends POST with the exact responses body and returns the canonical result unchanged', async () => {
|
|
203
|
+
const request = { responses: [{ id: 'notify', selectedOptionId: 'ok' }] };
|
|
204
|
+
const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: request.responses, summary: 'Acknowledged', completedAt: '2026-01-01T00:00:00Z' };
|
|
205
|
+
const srv = await startCapturingServer(() => ({ status: 200, body: result }));
|
|
206
|
+
try {
|
|
207
|
+
const got = await srv.client.respondHumanInboxDeck(SAMPLE_TICKET_ID, request);
|
|
208
|
+
assert.deepEqual(got, result);
|
|
209
|
+
assert.equal(srv.requests[0]?.method, 'POST');
|
|
210
|
+
assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}/respond`);
|
|
211
|
+
assert.deepEqual(srv.requests[0]?.body, request);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await srv.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
test('cancelHumanInboxTicket sends POST with the reason body when a request is passed', async () => {
|
|
218
|
+
const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: '2026-01-01T00:00:00Z', reason: 'no longer needed', actor: 'human' };
|
|
219
|
+
const srv = await startCapturingServer(() => ({ status: 200, body: result }));
|
|
220
|
+
try {
|
|
221
|
+
const got = await srv.client.cancelHumanInboxTicket(SAMPLE_TICKET_ID, { reason: 'no longer needed' });
|
|
222
|
+
assert.deepEqual(got, result);
|
|
223
|
+
assert.equal(srv.requests[0]?.method, 'POST');
|
|
224
|
+
assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}/cancel`);
|
|
225
|
+
assert.deepEqual(srv.requests[0]?.body, { reason: 'no longer needed' });
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await srv.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
test('cancelHumanInboxTicket sends an empty object body when no request is passed', async () => {
|
|
232
|
+
const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: '2026-01-01T00:00:00Z' };
|
|
233
|
+
const srv = await startCapturingServer(() => ({ status: 200, body: result }));
|
|
234
|
+
try {
|
|
235
|
+
const got = await srv.client.cancelHumanInboxTicket(SAMPLE_TICKET_ID);
|
|
236
|
+
assert.deepEqual(got, result);
|
|
237
|
+
assert.deepEqual(srv.requests[0]?.body, {});
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await srv.close();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
test('a non-2xx inbox error body decodes to a typed ApiError (status/code/message)', async () => {
|
|
244
|
+
const srv = await startCapturingServer(() => ({
|
|
245
|
+
status: 409,
|
|
246
|
+
body: { error: { code: 'ticket_kind_unsupported', message: 'review tickets have no v1 response operation' } },
|
|
247
|
+
}));
|
|
248
|
+
try {
|
|
249
|
+
await assert.rejects(() => srv.client.respondHumanInboxDeck(SAMPLE_TICKET_ID, { responses: [] }), (err) => {
|
|
250
|
+
assert.ok(err instanceof ApiError);
|
|
251
|
+
assert.equal(err.status, 409);
|
|
252
|
+
assert.equal(err.code, 'ticket_kind_unsupported');
|
|
253
|
+
assert.match(err.message, /review tickets have no v1 response operation/);
|
|
254
|
+
return true;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await srv.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
test('a malformed local ticket id throws a synchronous TypeError and never reaches the transport', async () => {
|
|
262
|
+
const srv = await startCapturingServer(() => {
|
|
263
|
+
throw new Error('handler must never be invoked for a locally-rejected ticket id');
|
|
264
|
+
});
|
|
265
|
+
try {
|
|
266
|
+
assert.throws(() => srv.client.getHumanInboxDeck('not-a-valid-hash'), TypeError);
|
|
267
|
+
assert.throws(() => srv.client.respondHumanInboxDeck('short', { responses: [] }), TypeError);
|
|
268
|
+
assert.throws(() => srv.client.cancelHumanInboxTicket('UPPERCASE'.repeat(7)), TypeError);
|
|
269
|
+
assert.equal(srv.requests.length, 0);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await srv.close();
|
|
273
|
+
}
|
|
274
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { DaemonRestartDTO, HealthDTO, StatusDTO } from './dto/health.js';
|
|
2
|
+
import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
|
|
3
|
+
import type { InterruptResultDTO, MessageResultDTO, SendMessageRequest } from './dto/messages.js';
|
|
4
|
+
import type { PushReportRequest, PushReportResultDTO, ReportDTO, ReportsQuery } from './dto/reports.js';
|
|
5
|
+
import type { CloseRequest, CloseResultDTO, PromoteRequest, RelaunchRootResultDTO, ReviveRequest, ReviveResultDTO, WaitRequest, YieldRequest } from './dto/lifecycle.js';
|
|
6
|
+
import type { SubscribeRequest, SubscriptionDTO } from './dto/subscriptions.js';
|
|
7
|
+
import type { FocusDTO, RegisterFocusRequest, SetFocusPaneRequest } from './dto/focus.js';
|
|
8
|
+
import { type ArmCronRequest, type CronDTO, type CronRunDTO, type CronScopeQuery, type CronShowDTO, type ListCronsQuery } from './dto/crons.js';
|
|
9
|
+
import type { NodeConfigPatch } from './dto/config.js';
|
|
10
|
+
import type { AttachEnsureRequest, AttachEnsureResultDTO } from './dto/attach.js';
|
|
11
|
+
import type { EnsureProfileRequest, ProfileDTO } from './dto/profiles.js';
|
|
12
|
+
import type { FilePeekDTO } from './dto/files.js';
|
|
13
|
+
import type { CredentialResultDTO, InstallCredentialRequest } from './dto/modelauth.js';
|
|
14
|
+
import type { CreateHumanBridgeRequest, HumanBridgeResultDTO, HumanConsultResultDTO, HumanDeliverResultDTO, HumanVisualResultDTO } from './dto/human.js';
|
|
15
|
+
import type { CancelInboxTicketRequest, CanceledTicketResultDTO, DeckTicketResultDTO, InboxDeckDTO, InboxListDTO, InboxTicketIdDTO, RespondInboxDeckRequest } from './dto/inbox.js';
|
|
16
|
+
import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RebuildIndexResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
|
|
17
|
+
import type { CloseWorktreeResultDTO } from './dto/worktree.js';
|
|
18
|
+
export interface CrtrClientOptions {
|
|
19
|
+
/** Unix socket path (default local transport). Exactly one of socketPath|baseUrl. */
|
|
20
|
+
socketPath?: string;
|
|
21
|
+
/** `http(s)://host:port` for TCP/remote transport. */
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
/** Extra headers (e.g. an edge auth token on TCP; crtrd ignores it). */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** Autostart on a cold socket (default true for socketPath, false for baseUrl). */
|
|
26
|
+
autostart?: boolean;
|
|
27
|
+
/** Per-request timeout in ms (default 30_000). */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
/** Injected daemon-start hook (spec §7.1). Called once on a cold socket when
|
|
30
|
+
* autostart is on; after it resolves, the client polls `/healthz` and retries
|
|
31
|
+
* the original request once. Absent → a cold socket throws `daemon_unavailable`. */
|
|
32
|
+
onColdSocket?: () => Promise<void>;
|
|
33
|
+
/** Injected cold-start diagnostic (issue #516). Called ONLY when the bounded
|
|
34
|
+
* `/healthz` poll times out after `onColdSocket`, so the caller can attach
|
|
35
|
+
* operator-useful context (e.g. a bounded tail of crtrd's stderr log) to the
|
|
36
|
+
* `daemon_unavailable` error instead of it staying a bare message. Must
|
|
37
|
+
* return synchronously and cheaply — it runs on the failure path, not the
|
|
38
|
+
* happy path. A thrown/undefined result is treated as "no diagnostic". */
|
|
39
|
+
coldStartDiagnostic?: () => string | undefined;
|
|
40
|
+
/** Bounded window (ms) to poll `/healthz` after `onColdSocket` resolves
|
|
41
|
+
* before giving up with `daemon_unavailable`. Defaults to
|
|
42
|
+
* `HEALTHZ_POLL_WINDOW_MS`. This must NOT silently drift from whatever
|
|
43
|
+
* window actually governs "did the daemon start" (issue #508 follow-up):
|
|
44
|
+
* `onColdSocket` is fire-and-forget, so this poll is the ONLY deadline that
|
|
45
|
+
* determines whether the CLI reports success. A caller whose `onColdSocket`
|
|
46
|
+
* hook triggers a differently-windowed startup verifier (e.g. the CLI's
|
|
47
|
+
* `ensureDaemon`/`verifyDaemonStartup`) must pass that same window here so
|
|
48
|
+
* a slow-but-valid cold start cannot pass the authoritative verifier while
|
|
49
|
+
* this poll times out first. */
|
|
50
|
+
coldStartPollWindowMs?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare class CrtrClient {
|
|
53
|
+
private readonly socketPath?;
|
|
54
|
+
private readonly baseUrl?;
|
|
55
|
+
private readonly headers;
|
|
56
|
+
private readonly autostart;
|
|
57
|
+
private readonly timeoutMs;
|
|
58
|
+
private readonly onColdSocket?;
|
|
59
|
+
private readonly coldStartDiagnostic?;
|
|
60
|
+
private readonly coldStartPollWindowMs;
|
|
61
|
+
/** Guards against re-entering the autostart path more than once per client. */
|
|
62
|
+
private coldStartAttempted;
|
|
63
|
+
constructor(opts: CrtrClientOptions);
|
|
64
|
+
/** Construct a client bound to the default local socket with autostart on. Pass
|
|
65
|
+
* `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
|
|
66
|
+
* socket fails loud with `daemon_unavailable`. */
|
|
67
|
+
static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
|
|
68
|
+
healthz(): Promise<HealthDTO>;
|
|
69
|
+
status(): Promise<StatusDTO>;
|
|
70
|
+
/** Ask the daemon to replace itself with a successor running the currently
|
|
71
|
+
* selected runtime generation. Answers before the handover starts, so a
|
|
72
|
+
* caller living inside a node the handover will tear down still gets a
|
|
73
|
+
* settled result. */
|
|
74
|
+
restartDaemon(): Promise<DaemonRestartDTO>;
|
|
75
|
+
createNode(req: CreateNodeRequest): Promise<NodeDetailDTO>;
|
|
76
|
+
listNodes(q?: ListNodesQuery): Promise<NodeSummaryDTO[]>;
|
|
77
|
+
getNode(id: string): Promise<NodeDetailDTO>;
|
|
78
|
+
sendMessage(id: string, req: SendMessageRequest): Promise<MessageResultDTO>;
|
|
79
|
+
/** First-class interrupt (the human Esc): cancels pending undelivered
|
|
80
|
+
* human-send inbox entries, then aborts a live in-flight turn. NEVER
|
|
81
|
+
* revives a dormant target. */
|
|
82
|
+
interruptNode(id: string): Promise<InterruptResultDTO>;
|
|
83
|
+
pushReport(id: string, req: PushReportRequest): Promise<PushReportResultDTO>;
|
|
84
|
+
forkNode(id: string): Promise<NodeDetailDTO>;
|
|
85
|
+
reviveNode(id: string, req?: ReviveRequest): Promise<ReviveResultDTO>;
|
|
86
|
+
relaunchRoot(id: string): Promise<RelaunchRootResultDTO>;
|
|
87
|
+
closeNode(id: string, req?: CloseRequest): Promise<CloseResultDTO>;
|
|
88
|
+
recycleNode(id: string): Promise<NodeDetailDTO>;
|
|
89
|
+
demoteNode(id: string): Promise<NodeDetailDTO>;
|
|
90
|
+
promoteNode(id: string, req: PromoteRequest): Promise<NodeDetailDTO>;
|
|
91
|
+
yieldNode(id: string, req: YieldRequest): Promise<NodeDetailDTO>;
|
|
92
|
+
waitNode(id: string, req: WaitRequest): Promise<NodeDetailDTO>;
|
|
93
|
+
patchConfig(id: string, patch: NodeConfigPatch): Promise<NodeDetailDTO>;
|
|
94
|
+
/** Land + close the node's managed git worktree (spec §6.2). Server-side
|
|
95
|
+
* because it interleaves a canvas WRITE with a git land transaction and
|
|
96
|
+
* crtrd is the repo host (same principle as spawnChild's creation git). */
|
|
97
|
+
closeWorktree(id: string): Promise<CloseWorktreeResultDTO>;
|
|
98
|
+
subscribe(id: string, req: SubscribeRequest): Promise<SubscriptionDTO>;
|
|
99
|
+
listFocuses(): Promise<FocusDTO[]>;
|
|
100
|
+
focusOf(nodeId: string): Promise<FocusDTO | null>;
|
|
101
|
+
focusByPane(pane: string): Promise<FocusDTO | null>;
|
|
102
|
+
registerFocus(req: RegisterFocusRequest): Promise<FocusDTO | null>;
|
|
103
|
+
setFocusPane(focusId: string, req: SetFocusPaneRequest): Promise<void>;
|
|
104
|
+
closeFocus(focusId: string): Promise<void>;
|
|
105
|
+
unsubscribe(id: string, target: string): Promise<void>;
|
|
106
|
+
/** Arm one cron (`POST /v1/crons`) — the server mints the cron_id. */
|
|
107
|
+
armCron(req: ArmCronRequest): Promise<CronDTO>;
|
|
108
|
+
/** Crons visible to the caller (`GET /v1/crons`). With `q.profile` that is
|
|
109
|
+
* that profile's crons plus every global one; omit it only for a
|
|
110
|
+
* canvas-home-wide provenance read ("which crons did node X arm"). */
|
|
111
|
+
listCrons(q?: ListCronsQuery): Promise<CronDTO[]>;
|
|
112
|
+
/** One cron with its run-log ring (`GET /v1/crons/:cronId`). */
|
|
113
|
+
showCron(cronId: string, q?: CronScopeQuery): Promise<CronShowDTO>;
|
|
114
|
+
/** Pause one cron (`POST /v1/crons/:cronId/pause`) — stops firing, keeps config+history. */
|
|
115
|
+
pauseCron(cronId: string, q?: CronScopeQuery): Promise<CronDTO>;
|
|
116
|
+
/** Resume one paused cron (`POST /v1/crons/:cronId/resume`). */
|
|
117
|
+
resumeCron(cronId: string, q?: CronScopeQuery): Promise<CronDTO>;
|
|
118
|
+
/** Run one cron NOW, out of band (`POST /v1/crons/:cronId/run`) — synchronous:
|
|
119
|
+
* resolves with the settled run record after the subprocess closes. Does not
|
|
120
|
+
* advance the schedule or consume a one-shot; never escalates. */
|
|
121
|
+
runCron(cronId: string, q?: CronScopeQuery): Promise<CronRunDTO>;
|
|
122
|
+
/** Cancel one cron (`DELETE /v1/crons/:cronId`, idempotent). */
|
|
123
|
+
cancelCron(cronId: string, q?: CronScopeQuery): Promise<void>;
|
|
124
|
+
ensureAttach(id: string, req?: AttachEnsureRequest): Promise<AttachEnsureResultDTO>;
|
|
125
|
+
getReports(id: string, q?: ReportsQuery): Promise<ReportDTO[]>;
|
|
126
|
+
getTranscript(id: string, q?: TranscriptQuery): Promise<TranscriptDTO>;
|
|
127
|
+
getSnapshot(id: string): Promise<NodeSnapshotDTO>;
|
|
128
|
+
getArtifacts(id: string, q?: ArtifactsQuery): Promise<ArtifactListDTO>;
|
|
129
|
+
getContext(id: string): Promise<ContextListDTO>;
|
|
130
|
+
/** Read an absolute host path as UTF-8 (capped, `truncated` when clipped) for
|
|
131
|
+
* the browser file-peek panel. */
|
|
132
|
+
peekFile(path: string): Promise<FilePeekDTO>;
|
|
133
|
+
ensureProfile(name: string, req?: EnsureProfileRequest): Promise<ProfileDTO>;
|
|
134
|
+
listProfiles(): Promise<ProfileDTO[]>;
|
|
135
|
+
getProfile(name: string): Promise<ProfileDTO>;
|
|
136
|
+
/** Delete one profile by exact id or unique name (`DELETE /v1/profiles/:name`,
|
|
137
|
+
* idempotent — a miss is success). */
|
|
138
|
+
deleteProfile(name: string): Promise<void>;
|
|
139
|
+
installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
|
|
140
|
+
/** Create a terminal `kind:'human'` bridge node with NO broker engine
|
|
141
|
+
* (`spawnNode` server-side). Distinct from `createNode` (which launches a
|
|
142
|
+
* broker) precisely because a human bridge must never have one. */
|
|
143
|
+
createHumanBridge(req: CreateHumanBridgeRequest): Promise<HumanBridgeResultDTO>;
|
|
144
|
+
/** Run the registered humanloop completion handler server-side for one
|
|
145
|
+
* `humanloop.completion/v1` event. crtrd re-verifies the full trust binding
|
|
146
|
+
* before performing any canvas mutation. */
|
|
147
|
+
deliverHuman(event: unknown): Promise<HumanDeliverResultDTO>;
|
|
148
|
+
/** Run the registered follow-up handler server-side for one
|
|
149
|
+
* `humanloop.followup-request/v1` event. */
|
|
150
|
+
consultHuman(event: unknown): Promise<HumanConsultResultDTO>;
|
|
151
|
+
/** Run the registered visual handler server-side for one
|
|
152
|
+
* `humanloop.visual-request-event/v1` event. */
|
|
153
|
+
visualHuman(event: unknown): Promise<HumanVisualResultDTO>;
|
|
154
|
+
/** Pending deck/review tickets across every available crouter-owned
|
|
155
|
+
* humanloop root. */
|
|
156
|
+
listHumanInbox(): Promise<InboxListDTO>;
|
|
157
|
+
/** Read one pending deck by its opaque ticket id, with Markdown bodies
|
|
158
|
+
* resolved inline. */
|
|
159
|
+
getHumanInboxDeck(ticketId: InboxTicketIdDTO): Promise<InboxDeckDTO>;
|
|
160
|
+
/** Submit ordered interaction responses for a pending deck. Single-assignment
|
|
161
|
+
* server-side: a competing resolution races to `ticket_already_resolved`. */
|
|
162
|
+
respondHumanInboxDeck(ticketId: InboxTicketIdDTO, request: RespondInboxDeckRequest): Promise<DeckTicketResultDTO>;
|
|
163
|
+
/** Cancel a pending deck (terminal response, never deletion). */
|
|
164
|
+
cancelHumanInboxTicket(ticketId: InboxTicketIdDTO, request?: CancelInboxTicketRequest): Promise<CanceledTicketResultDTO>;
|
|
165
|
+
/** Composed client-side from `GET /v1/nodes` + `GET /v1/status` (spec §6.3 —
|
|
166
|
+
* the dashboard is absorbed into those two reads; there is no single route).
|
|
167
|
+
* `generated_at` is the client-side capture instant of the composition. */
|
|
168
|
+
dashboard(q?: DashboardQuery): Promise<DashboardDTO>;
|
|
169
|
+
attention(): Promise<AttentionDTO>;
|
|
170
|
+
/** Per-node pending-ticket counts for a bounded viewer slice. */
|
|
171
|
+
attentionCounts(node_ids: string[]): Promise<AttentionCountsDTO>;
|
|
172
|
+
/** Ranked/filtered content search over the per-cwd episodic corpus
|
|
173
|
+
* (`crtr canvas history search`). Optional query: ranked when present,
|
|
174
|
+
* recency browse when omitted. POST-bodied — the query carries arrays and
|
|
175
|
+
* free text; the whole search executes server-side (spec §6.3). */
|
|
176
|
+
historySearch(q: HistorySearchQuery): Promise<HistorySearchResultDTO>;
|
|
177
|
+
/** Required-pattern line-hit search over the per-cwd episodic corpus
|
|
178
|
+
* (`crtr canvas history grep`). Distinct stable schema from `historySearch`
|
|
179
|
+
* — POST-bodied for the same reasons. */
|
|
180
|
+
historyGrep(q: HistoryGrepQuery): Promise<HistoryGrepResultDTO>;
|
|
181
|
+
/** Resolve one `<node-id>:<relpath>` history ref to its full body
|
|
182
|
+
* (`crtr canvas history read`). */
|
|
183
|
+
historyRead(q: HistoryReadQuery): Promise<HistoryReadResultDTO>;
|
|
184
|
+
/** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
|
|
185
|
+
* distinct from the per-node `getSnapshot`. */
|
|
186
|
+
canvasSnapshot(): Promise<SnapshotDTO>;
|
|
187
|
+
/** The lean, set-based topology roster (`GET /v1/canvas/roster`) — exactly
|
|
188
|
+
* two indexed queries server-side, no per-row enrichment. The recurring
|
|
189
|
+
* poll target for attach/browser topology; use `canvasSnapshot` for the
|
|
190
|
+
* enriched on-demand view. */
|
|
191
|
+
canvasRoster(): Promise<RosterDTO>;
|
|
192
|
+
prune(req: PruneRequest): Promise<PruneResultDTO>;
|
|
193
|
+
rebuildIndex(): Promise<RebuildIndexResultDTO>;
|
|
194
|
+
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
195
|
+
* autostart + error-mapping semantics. */
|
|
196
|
+
request<T>(method: string, path: string, body?: unknown): Promise<T>;
|
|
197
|
+
private nodePath;
|
|
198
|
+
/** Validate a cron id before route construction — `routes.ts` interpolates
|
|
199
|
+
* it raw, so a value carrying `/`, whitespace or `?` would corrupt the
|
|
200
|
+
* request line rather than 404 cleanly. Mirrors `nodePath`. */
|
|
201
|
+
private cronPath;
|
|
202
|
+
/** Validate an opaque inbox ticket id before route construction. A local
|
|
203
|
+
* shape violation is a caller bug, not a server-rejectable request — throws
|
|
204
|
+
* `TypeError` (matching the existing safe-segment discipline of a local
|
|
205
|
+
* precondition, distinct from `nodePath`'s `ApiError` because that one IS a
|
|
206
|
+
* request the server could plausibly receive and reject itself). */
|
|
207
|
+
private ticketId;
|
|
208
|
+
private transport;
|
|
209
|
+
private isColdSocketError;
|
|
210
|
+
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
211
|
+
* pipe) — as distinct from a refused connect, which means nothing is
|
|
212
|
+
* listening. On the local socket that is `crtr sys daemon restart` doing its
|
|
213
|
+
* generation handover: the daemon acks, then tears itself down and hands
|
|
214
|
+
* over to a successor it spawned. The daemon IS coming back. */
|
|
215
|
+
private isHandoverHangup;
|
|
216
|
+
/** Wait for the successor to answer `/healthz`, then replay the request when
|
|
217
|
+
* replaying is safe. GET/HEAD are idempotent, so they retry transparently —
|
|
218
|
+
* the handover stays invisible, which is the whole point of a restart that
|
|
219
|
+
* resumes every node. A mutation may already have been applied server-side
|
|
220
|
+
* before the socket dropped, so it fails with `daemon_restarting` (retry),
|
|
221
|
+
* never `daemon_unavailable` ("start the daemon" is the wrong advice for a
|
|
222
|
+
* daemon that is mid-handover). */
|
|
223
|
+
private rideOutHandover;
|
|
224
|
+
/** Poll `/healthz` until the successor daemon answers, bounded by the
|
|
225
|
+
* cold-start window. Tolerates both the pre-listen gap (cold socket) and a
|
|
226
|
+
* second hang-up from a server still tearing down. */
|
|
227
|
+
private awaitHandover;
|
|
228
|
+
/** Trigger the injected daemon-start hook, poll `/healthz`, then let the caller
|
|
229
|
+
* retry once. Fail loud with `daemon_unavailable` when autostart is off, no
|
|
230
|
+
* hook is wired, or the daemon never becomes reachable. */
|
|
231
|
+
private handleColdSocket;
|
|
232
|
+
}
|
|
233
|
+
/** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
|
|
234
|
+
* appending the injected diagnostic (issue #516) when one is present instead
|
|
235
|
+
* of discarding the real startup failure behind a bare message. Exported (not
|
|
236
|
+
* from the package's public `index.ts` surface, which is dependency-light by
|
|
237
|
+
* design) purely so the regression test can assert the composition without
|
|
238
|
+
* waiting out the real poll window. */
|
|
239
|
+
export declare function coldStartTimeoutMessage(diagnostic: string | undefined): string;
|
|
240
|
+
/** Invoke the injected `coldStartDiagnostic` hook, treating a THROW the same
|
|
241
|
+
* as an absent/undefined result — the contract `CrtrClientOptions` documents
|
|
242
|
+
* ("a thrown/undefined result is treated as 'no diagnostic'"). Without this,
|
|
243
|
+
* a broken hook would propagate and replace the typed `daemon_unavailable`
|
|
244
|
+
* error the caller is entitled to. Exported alongside `coldStartTimeoutMessage`
|
|
245
|
+
* for the same direct-unit-test reason. */
|
|
246
|
+
export declare function safeColdStartDiagnostic(hook: (() => string | undefined) | undefined): string | undefined;
|