@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/dist/client.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// CrtrClient — the typed HTTP+WS client over crtrd's API (spec §3.4).
|
|
2
|
+
//
|
|
3
|
+
// PURITY (spec §3.1): the ONLY runtime imports are Node built-ins
|
|
4
|
+
// (`node:http`, `node:https`, `node:os`, `node:path`) plus `src/api/*`. Never
|
|
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). Absent the hook,
|
|
8
|
+
// a cold socket throws `daemon_unavailable` immediately.
|
|
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 { request as httpRequest } from 'node:http';
|
|
13
|
+
import { request as httpsRequest } from 'node:https';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ApiError, isErrorBody } from './errors.js';
|
|
17
|
+
import { routes } from './routes.js';
|
|
18
|
+
import { isSafeNodeId } from './dto/common.js';
|
|
19
|
+
import { isSafeCronId, } from './dto/crons.js';
|
|
20
|
+
/** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
|
|
21
|
+
* and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
|
|
22
|
+
* imported — to keep the exported `/api` contract dependency-light; the two
|
|
23
|
+
* MUST agree on the resolved socket path. */
|
|
24
|
+
const CRTR_DIR_NAME = '.crouter';
|
|
25
|
+
const SOCKET_BASENAME = 'crtrd.sock';
|
|
26
|
+
/** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
|
|
27
|
+
* via Node built-ins only (no `core/canvas/paths.ts` import). */
|
|
28
|
+
function defaultSocketPath() {
|
|
29
|
+
const override = process.env['CRTR_HOME'];
|
|
30
|
+
const home = override !== undefined && override !== ''
|
|
31
|
+
? override
|
|
32
|
+
: join(homedir(), CRTR_DIR_NAME, 'canvas');
|
|
33
|
+
return join(home, SOCKET_BASENAME);
|
|
34
|
+
}
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
36
|
+
/** Default bounded window to wait for `/healthz` to come up after
|
|
37
|
+
* `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
|
|
38
|
+
const HEALTHZ_POLL_WINDOW_MS = 10_000;
|
|
39
|
+
const HEALTHZ_POLL_INTERVAL_MS = 200;
|
|
40
|
+
export class CrtrClient {
|
|
41
|
+
socketPath;
|
|
42
|
+
baseUrl;
|
|
43
|
+
headers;
|
|
44
|
+
autostart;
|
|
45
|
+
timeoutMs;
|
|
46
|
+
onColdSocket;
|
|
47
|
+
coldStartDiagnostic;
|
|
48
|
+
coldStartPollWindowMs;
|
|
49
|
+
/** Guards against re-entering the autostart path more than once per client. */
|
|
50
|
+
coldStartAttempted = false;
|
|
51
|
+
constructor(opts) {
|
|
52
|
+
const hasSocket = opts.socketPath !== undefined && opts.socketPath !== '';
|
|
53
|
+
const hasBaseUrl = opts.baseUrl !== undefined && opts.baseUrl !== '';
|
|
54
|
+
if (hasSocket === hasBaseUrl) {
|
|
55
|
+
throw new TypeError('CrtrClient requires exactly one of socketPath | baseUrl');
|
|
56
|
+
}
|
|
57
|
+
if (hasSocket)
|
|
58
|
+
this.socketPath = opts.socketPath;
|
|
59
|
+
if (hasBaseUrl)
|
|
60
|
+
this.baseUrl = new URL(opts.baseUrl);
|
|
61
|
+
this.headers = { ...opts.headers };
|
|
62
|
+
this.autostart = opts.autostart ?? hasSocket;
|
|
63
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
if (opts.onColdSocket !== undefined)
|
|
65
|
+
this.onColdSocket = opts.onColdSocket;
|
|
66
|
+
if (opts.coldStartDiagnostic !== undefined)
|
|
67
|
+
this.coldStartDiagnostic = opts.coldStartDiagnostic;
|
|
68
|
+
this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
|
|
69
|
+
}
|
|
70
|
+
/** Construct a client bound to the default local socket with autostart on. Pass
|
|
71
|
+
* `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
|
|
72
|
+
* socket fails loud with `daemon_unavailable`. */
|
|
73
|
+
static forLocalSocket(opts) {
|
|
74
|
+
return new CrtrClient({ socketPath: defaultSocketPath(), autostart: true, ...opts });
|
|
75
|
+
}
|
|
76
|
+
// ---- Health / status ---------------------------------------------------
|
|
77
|
+
healthz() {
|
|
78
|
+
return this.request('GET', routes.healthz());
|
|
79
|
+
}
|
|
80
|
+
status() {
|
|
81
|
+
return this.request('GET', routes.status());
|
|
82
|
+
}
|
|
83
|
+
/** Ask the daemon to replace itself with a successor running the currently
|
|
84
|
+
* selected runtime generation. Answers before the handover starts, so a
|
|
85
|
+
* caller living inside a node the handover will tear down still gets a
|
|
86
|
+
* settled result. */
|
|
87
|
+
restartDaemon() {
|
|
88
|
+
return this.request('POST', routes.daemonRestart());
|
|
89
|
+
}
|
|
90
|
+
// ---- Nodes -------------------------------------------------------------
|
|
91
|
+
createNode(req) {
|
|
92
|
+
return this.request('POST', routes.nodes(), req);
|
|
93
|
+
}
|
|
94
|
+
listNodes(q) {
|
|
95
|
+
return this.request('GET', withQuery(routes.nodes(), q));
|
|
96
|
+
}
|
|
97
|
+
getNode(id) {
|
|
98
|
+
return this.request('GET', routes.node(this.nodePath(id)));
|
|
99
|
+
}
|
|
100
|
+
sendMessage(id, req) {
|
|
101
|
+
return this.request('POST', routes.nodeMessages(this.nodePath(id)), req);
|
|
102
|
+
}
|
|
103
|
+
/** First-class interrupt (the human Esc): cancels pending undelivered
|
|
104
|
+
* human-send inbox entries, then aborts a live in-flight turn. NEVER
|
|
105
|
+
* revives a dormant target. */
|
|
106
|
+
interruptNode(id) {
|
|
107
|
+
return this.request('POST', routes.nodeInterrupt(this.nodePath(id)), {});
|
|
108
|
+
}
|
|
109
|
+
pushReport(id, req) {
|
|
110
|
+
return this.request('POST', routes.nodeReports(this.nodePath(id)), req);
|
|
111
|
+
}
|
|
112
|
+
forkNode(id) {
|
|
113
|
+
return this.request('POST', routes.nodeFork(this.nodePath(id)), {});
|
|
114
|
+
}
|
|
115
|
+
reviveNode(id, req) {
|
|
116
|
+
return this.request('POST', routes.nodeRevive(this.nodePath(id)), req ?? {});
|
|
117
|
+
}
|
|
118
|
+
relaunchRoot(id) {
|
|
119
|
+
return this.request('POST', routes.nodeRelaunchRoot(this.nodePath(id)), {});
|
|
120
|
+
}
|
|
121
|
+
closeNode(id, req) {
|
|
122
|
+
return this.request('POST', routes.nodeClose(this.nodePath(id)), req ?? {});
|
|
123
|
+
}
|
|
124
|
+
recycleNode(id) {
|
|
125
|
+
return this.request('POST', routes.nodeRecycle(this.nodePath(id)), {});
|
|
126
|
+
}
|
|
127
|
+
demoteNode(id) {
|
|
128
|
+
return this.request('POST', routes.nodeDemote(this.nodePath(id)), {});
|
|
129
|
+
}
|
|
130
|
+
promoteNode(id, req) {
|
|
131
|
+
return this.request('POST', routes.nodePromote(this.nodePath(id)), req);
|
|
132
|
+
}
|
|
133
|
+
yieldNode(id, req) {
|
|
134
|
+
return this.request('POST', routes.nodeYield(this.nodePath(id)), req);
|
|
135
|
+
}
|
|
136
|
+
waitNode(id, req) {
|
|
137
|
+
return this.request('POST', routes.nodeWait(this.nodePath(id)), req);
|
|
138
|
+
}
|
|
139
|
+
patchConfig(id, patch) {
|
|
140
|
+
return this.request('PATCH', routes.nodeConfig(this.nodePath(id)), patch);
|
|
141
|
+
}
|
|
142
|
+
/** Land + close the node's managed git worktree (spec §6.2). Server-side
|
|
143
|
+
* because it interleaves a canvas WRITE with a git land transaction and
|
|
144
|
+
* crtrd is the repo host (same principle as spawnChild's creation git). */
|
|
145
|
+
closeWorktree(id) {
|
|
146
|
+
return this.request('POST', routes.nodeWorktreeClose(this.nodePath(id)), {});
|
|
147
|
+
}
|
|
148
|
+
subscribe(id, req) {
|
|
149
|
+
return this.request('POST', routes.nodeSubscriptions(this.nodePath(id)), req);
|
|
150
|
+
}
|
|
151
|
+
// ---- Focuses (viewer registry) ----------------------------------------
|
|
152
|
+
// The tmux verbs that open/move/close a viewer pane run LOCALLY in the
|
|
153
|
+
// caller's session (placement-tmux); only the canvas.db focus rows route here.
|
|
154
|
+
// Reads GC lazily server-side, so a returned row is always live; a `null` body
|
|
155
|
+
// is the normal "no viewer" answer, not a 404.
|
|
156
|
+
listFocuses() {
|
|
157
|
+
return this.request('GET', routes.focuses());
|
|
158
|
+
}
|
|
159
|
+
focusOf(nodeId) {
|
|
160
|
+
return this.request('GET', withQuery(routes.focusByNode(), { node_id: nodeId }));
|
|
161
|
+
}
|
|
162
|
+
focusByPane(pane) {
|
|
163
|
+
return this.request('GET', withQuery(routes.focusByPane(), { pane }));
|
|
164
|
+
}
|
|
165
|
+
registerFocus(req) {
|
|
166
|
+
return this.request('POST', routes.focuses(), req);
|
|
167
|
+
}
|
|
168
|
+
async setFocusPane(focusId, req) {
|
|
169
|
+
await this.request('PATCH', routes.focus(focusId), req);
|
|
170
|
+
}
|
|
171
|
+
async closeFocus(focusId) {
|
|
172
|
+
await this.request('DELETE', routes.focus(focusId));
|
|
173
|
+
}
|
|
174
|
+
async unsubscribe(id, target) {
|
|
175
|
+
await this.request('DELETE', routes.nodeSubscription(this.nodePath(id), this.nodePath(target)));
|
|
176
|
+
}
|
|
177
|
+
/** Arm one cron (`POST /v1/crons`) — the server mints the cron_id. */
|
|
178
|
+
armCron(req) {
|
|
179
|
+
return this.request('POST', routes.crons(), req);
|
|
180
|
+
}
|
|
181
|
+
/** Crons visible to the caller (`GET /v1/crons`). With `q.profile` that is
|
|
182
|
+
* that profile's crons plus every global one; omit it only for a
|
|
183
|
+
* canvas-home-wide provenance read ("which crons did node X arm"). */
|
|
184
|
+
listCrons(q) {
|
|
185
|
+
return this.request('GET', withQuery(routes.crons(), q));
|
|
186
|
+
}
|
|
187
|
+
/** One cron with its run-log ring (`GET /v1/crons/:cronId`). */
|
|
188
|
+
showCron(cronId, q) {
|
|
189
|
+
return this.request('GET', withQuery(routes.cron(this.cronPath(cronId)), q));
|
|
190
|
+
}
|
|
191
|
+
/** Pause one cron (`POST /v1/crons/:cronId/pause`) — stops firing, keeps config+history. */
|
|
192
|
+
pauseCron(cronId, q) {
|
|
193
|
+
return this.request('POST', withQuery(routes.cronPause(this.cronPath(cronId)), q), {});
|
|
194
|
+
}
|
|
195
|
+
/** Resume one paused cron (`POST /v1/crons/:cronId/resume`). */
|
|
196
|
+
resumeCron(cronId, q) {
|
|
197
|
+
return this.request('POST', withQuery(routes.cronResume(this.cronPath(cronId)), q), {});
|
|
198
|
+
}
|
|
199
|
+
/** Run one cron NOW, out of band (`POST /v1/crons/:cronId/run`) — synchronous:
|
|
200
|
+
* resolves with the settled run record after the subprocess closes. Does not
|
|
201
|
+
* advance the schedule or consume a one-shot; never escalates. */
|
|
202
|
+
runCron(cronId, q) {
|
|
203
|
+
return this.request('POST', withQuery(routes.cronRun(this.cronPath(cronId)), q), {});
|
|
204
|
+
}
|
|
205
|
+
/** Cancel one cron (`DELETE /v1/crons/:cronId`, idempotent). */
|
|
206
|
+
async cancelCron(cronId, q) {
|
|
207
|
+
await this.request('DELETE', withQuery(routes.cron(this.cronPath(cronId)), q));
|
|
208
|
+
}
|
|
209
|
+
ensureAttach(id, req) {
|
|
210
|
+
return this.request('POST', routes.nodeAttach(this.nodePath(id)), req ?? {});
|
|
211
|
+
}
|
|
212
|
+
// ---- Reads / feed ------------------------------------------------------
|
|
213
|
+
getReports(id, q) {
|
|
214
|
+
return this.request('GET', withQuery(routes.nodeReports(this.nodePath(id)), q));
|
|
215
|
+
}
|
|
216
|
+
getTranscript(id, q) {
|
|
217
|
+
return this.request('GET', withQuery(routes.nodeTranscript(this.nodePath(id)), q));
|
|
218
|
+
}
|
|
219
|
+
getSnapshot(id) {
|
|
220
|
+
return this.request('GET', routes.nodeSnapshot(this.nodePath(id)));
|
|
221
|
+
}
|
|
222
|
+
getArtifacts(id, q) {
|
|
223
|
+
return this.request('GET', withQuery(routes.nodeArtifacts(this.nodePath(id)), q));
|
|
224
|
+
}
|
|
225
|
+
getContext(id) {
|
|
226
|
+
return this.request('GET', routes.nodeContext(this.nodePath(id)));
|
|
227
|
+
}
|
|
228
|
+
// ---- Host file read ----------------------------------------------------
|
|
229
|
+
/** Read an absolute host path as UTF-8 (capped, `truncated` when clipped) for
|
|
230
|
+
* the browser file-peek panel. */
|
|
231
|
+
peekFile(path) {
|
|
232
|
+
return this.request('GET', withQuery(routes.filePeek(), { path }));
|
|
233
|
+
}
|
|
234
|
+
// ---- Profiles ----------------------------------------------------------
|
|
235
|
+
ensureProfile(name, req) {
|
|
236
|
+
return this.request('PUT', routes.profile(name), req ?? {});
|
|
237
|
+
}
|
|
238
|
+
listProfiles() {
|
|
239
|
+
return this.request('GET', routes.profiles());
|
|
240
|
+
}
|
|
241
|
+
getProfile(name) {
|
|
242
|
+
return this.request('GET', routes.profile(name));
|
|
243
|
+
}
|
|
244
|
+
/** Delete one profile by exact id or unique name (`DELETE /v1/profiles/:name`,
|
|
245
|
+
* idempotent — a miss is success). */
|
|
246
|
+
async deleteProfile(name) {
|
|
247
|
+
await this.request('DELETE', routes.profile(name));
|
|
248
|
+
}
|
|
249
|
+
// ---- Model auth --------------------------------------------------------
|
|
250
|
+
installCredential(provider, req) {
|
|
251
|
+
return this.request('PUT', routes.modelAuth(provider), req);
|
|
252
|
+
}
|
|
253
|
+
// ---- Human bridge + completion-handler forwarding (spec §6.5) ----------
|
|
254
|
+
/** Create a terminal `kind:'human'` bridge node with NO broker engine
|
|
255
|
+
* (`spawnNode` server-side). Distinct from `createNode` (which launches a
|
|
256
|
+
* broker) precisely because a human bridge must never have one. */
|
|
257
|
+
createHumanBridge(req) {
|
|
258
|
+
return this.request('POST', routes.humanBridge(), req);
|
|
259
|
+
}
|
|
260
|
+
/** Run the registered humanloop completion handler server-side for one
|
|
261
|
+
* `humanloop.completion/v1` event. crtrd re-verifies the full trust binding
|
|
262
|
+
* before performing any canvas mutation. */
|
|
263
|
+
deliverHuman(event) {
|
|
264
|
+
return this.request('POST', routes.humanDeliver(), event);
|
|
265
|
+
}
|
|
266
|
+
/** Run the registered follow-up handler server-side for one
|
|
267
|
+
* `humanloop.followup-request/v1` event. */
|
|
268
|
+
consultHuman(event) {
|
|
269
|
+
return this.request('POST', routes.humanConsult(), event);
|
|
270
|
+
}
|
|
271
|
+
/** Run the registered visual handler server-side for one
|
|
272
|
+
* `humanloop.visual-request-event/v1` event. */
|
|
273
|
+
visualHuman(event) {
|
|
274
|
+
return this.request('POST', routes.humanVisual(), event);
|
|
275
|
+
}
|
|
276
|
+
// ---- Humanloop inbox (Northlight crouter-inbox v1, inbox-contract.md §A) --
|
|
277
|
+
/** Pending deck/review tickets across every available crouter-owned
|
|
278
|
+
* humanloop root. */
|
|
279
|
+
listHumanInbox() {
|
|
280
|
+
return this.request('GET', routes.humanInbox());
|
|
281
|
+
}
|
|
282
|
+
/** Read one pending deck by its opaque ticket id, with Markdown bodies
|
|
283
|
+
* resolved inline. */
|
|
284
|
+
getHumanInboxDeck(ticketId) {
|
|
285
|
+
return this.request('GET', routes.humanInboxTicket(this.ticketId(ticketId)));
|
|
286
|
+
}
|
|
287
|
+
/** Submit ordered interaction responses for a pending deck. Single-assignment
|
|
288
|
+
* server-side: a competing resolution races to `ticket_already_resolved`. */
|
|
289
|
+
respondHumanInboxDeck(ticketId, request) {
|
|
290
|
+
return this.request('POST', routes.humanInboxRespond(this.ticketId(ticketId)), request);
|
|
291
|
+
}
|
|
292
|
+
/** Cancel a pending deck (terminal response, never deletion). */
|
|
293
|
+
cancelHumanInboxTicket(ticketId, request) {
|
|
294
|
+
return this.request('POST', routes.humanInboxCancel(this.ticketId(ticketId)), request ?? {});
|
|
295
|
+
}
|
|
296
|
+
// ---- Canvas reads / maintenance ---------------------------------------
|
|
297
|
+
/** Composed client-side from `GET /v1/nodes` + `GET /v1/status` (spec §6.3 —
|
|
298
|
+
* the dashboard is absorbed into those two reads; there is no single route).
|
|
299
|
+
* `generated_at` is the client-side capture instant of the composition. */
|
|
300
|
+
async dashboard(q) {
|
|
301
|
+
const listQuery = q?.under !== undefined ? { under: q.under } : undefined;
|
|
302
|
+
const [nodes, status] = await Promise.all([this.listNodes(listQuery), this.status()]);
|
|
303
|
+
return { nodes, counts: status.node_counts, generated_at: new Date().toISOString() };
|
|
304
|
+
}
|
|
305
|
+
attention() {
|
|
306
|
+
return this.request('GET', routes.canvasAttention());
|
|
307
|
+
}
|
|
308
|
+
/** Per-node pending-ticket counts for a bounded viewer slice. */
|
|
309
|
+
attentionCounts(node_ids) {
|
|
310
|
+
const body = { node_ids };
|
|
311
|
+
return this.request('POST', routes.canvasAttentionCounts(), body);
|
|
312
|
+
}
|
|
313
|
+
/** Ranked/filtered content search over the per-cwd episodic corpus
|
|
314
|
+
* (`crtr canvas history search`). Optional query: ranked when present,
|
|
315
|
+
* recency browse when omitted. POST-bodied — the query carries arrays and
|
|
316
|
+
* free text; the whole search executes server-side (spec §6.3). */
|
|
317
|
+
historySearch(q) {
|
|
318
|
+
return this.request('POST', routes.canvasHistorySearch(), q);
|
|
319
|
+
}
|
|
320
|
+
/** Required-pattern line-hit search over the per-cwd episodic corpus
|
|
321
|
+
* (`crtr canvas history grep`). Distinct stable schema from `historySearch`
|
|
322
|
+
* — POST-bodied for the same reasons. */
|
|
323
|
+
historyGrep(q) {
|
|
324
|
+
return this.request('POST', routes.canvasHistoryGrep(), q);
|
|
325
|
+
}
|
|
326
|
+
/** Resolve one `<node-id>:<relpath>` history ref to its full body
|
|
327
|
+
* (`crtr canvas history read`). */
|
|
328
|
+
historyRead(q) {
|
|
329
|
+
return this.request('GET', withQuery(routes.canvasHistoryRead(), q));
|
|
330
|
+
}
|
|
331
|
+
/** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
|
|
332
|
+
* distinct from the per-node `getSnapshot`. */
|
|
333
|
+
canvasSnapshot() {
|
|
334
|
+
return this.request('GET', routes.canvasSnapshot());
|
|
335
|
+
}
|
|
336
|
+
/** The lean, set-based topology roster (`GET /v1/canvas/roster`) — exactly
|
|
337
|
+
* two indexed queries server-side, no per-row enrichment. The recurring
|
|
338
|
+
* poll target for attach/browser topology; use `canvasSnapshot` for the
|
|
339
|
+
* enriched on-demand view. */
|
|
340
|
+
canvasRoster() {
|
|
341
|
+
return this.request('GET', routes.canvasRoster());
|
|
342
|
+
}
|
|
343
|
+
prune(req) {
|
|
344
|
+
return this.request('POST', routes.canvasPrune(), req);
|
|
345
|
+
}
|
|
346
|
+
rebuildIndex() {
|
|
347
|
+
return this.request('POST', routes.canvasRebuildIndex(), {});
|
|
348
|
+
}
|
|
349
|
+
// ---- Escape hatch ------------------------------------------------------
|
|
350
|
+
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
351
|
+
* autostart + error-mapping semantics. */
|
|
352
|
+
async request(method, path, body) {
|
|
353
|
+
let res;
|
|
354
|
+
try {
|
|
355
|
+
res = await this.transport(method, path, body);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
if (this.isColdSocketError(err)) {
|
|
359
|
+
await this.handleColdSocket();
|
|
360
|
+
res = await this.transport(method, path, body);
|
|
361
|
+
}
|
|
362
|
+
else if (this.isHandoverHangup(err)) {
|
|
363
|
+
res = await this.rideOutHandover(method, path, body);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
throw toTransportApiError(err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return parse(res);
|
|
370
|
+
}
|
|
371
|
+
// ---- internals ---------------------------------------------------------
|
|
372
|
+
nodePath(id) {
|
|
373
|
+
if (!isSafeNodeId(id)) {
|
|
374
|
+
throw new ApiError(400, 'invalid_node_id', `invalid node id: ${JSON.stringify(id)}`);
|
|
375
|
+
}
|
|
376
|
+
return id;
|
|
377
|
+
}
|
|
378
|
+
/** Validate a cron id before route construction — `routes.ts` interpolates
|
|
379
|
+
* it raw, so a value carrying `/`, whitespace or `?` would corrupt the
|
|
380
|
+
* request line rather than 404 cleanly. Mirrors `nodePath`. */
|
|
381
|
+
cronPath(id) {
|
|
382
|
+
if (!isSafeCronId(id)) {
|
|
383
|
+
throw new ApiError(400, 'invalid_cron_id', `invalid cron id: ${JSON.stringify(id)}`);
|
|
384
|
+
}
|
|
385
|
+
return id;
|
|
386
|
+
}
|
|
387
|
+
/** Validate an opaque inbox ticket id before route construction. A local
|
|
388
|
+
* shape violation is a caller bug, not a server-rejectable request — throws
|
|
389
|
+
* `TypeError` (matching the existing safe-segment discipline of a local
|
|
390
|
+
* precondition, distinct from `nodePath`'s `ApiError` because that one IS a
|
|
391
|
+
* request the server could plausibly receive and reject itself). */
|
|
392
|
+
ticketId(id) {
|
|
393
|
+
if (!/^[a-f0-9]{64}$/.test(id)) {
|
|
394
|
+
throw new TypeError(`invalid inbox ticket id: ${JSON.stringify(id)}`);
|
|
395
|
+
}
|
|
396
|
+
return id;
|
|
397
|
+
}
|
|
398
|
+
transport(method, path, body) {
|
|
399
|
+
const usingHttps = this.baseUrl?.protocol === 'https:';
|
|
400
|
+
const doRequest = usingHttps ? httpsRequest : httpRequest;
|
|
401
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
402
|
+
const headers = { accept: 'application/json', ...this.headers };
|
|
403
|
+
if (payload !== undefined) {
|
|
404
|
+
headers['content-type'] = 'application/json';
|
|
405
|
+
headers['content-length'] = String(Buffer.byteLength(payload));
|
|
406
|
+
}
|
|
407
|
+
const options = {
|
|
408
|
+
method,
|
|
409
|
+
path,
|
|
410
|
+
headers,
|
|
411
|
+
timeout: this.timeoutMs,
|
|
412
|
+
};
|
|
413
|
+
if (this.socketPath !== undefined) {
|
|
414
|
+
options.socketPath = this.socketPath;
|
|
415
|
+
}
|
|
416
|
+
else if (this.baseUrl !== undefined) {
|
|
417
|
+
options.protocol = this.baseUrl.protocol;
|
|
418
|
+
options.hostname = this.baseUrl.hostname;
|
|
419
|
+
if (this.baseUrl.port !== '')
|
|
420
|
+
options.port = this.baseUrl.port;
|
|
421
|
+
}
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const req = doRequest(options, (res) => {
|
|
424
|
+
const chunks = [];
|
|
425
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
426
|
+
res.on('end', () => {
|
|
427
|
+
resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8') });
|
|
428
|
+
});
|
|
429
|
+
res.on('error', reject);
|
|
430
|
+
});
|
|
431
|
+
req.on('error', reject);
|
|
432
|
+
req.on('timeout', () => {
|
|
433
|
+
req.destroy(Object.assign(new Error('request timed out'), { code: 'ETIMEDOUT' }));
|
|
434
|
+
});
|
|
435
|
+
if (payload !== undefined)
|
|
436
|
+
req.write(payload);
|
|
437
|
+
req.end();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
isColdSocketError(err) {
|
|
441
|
+
if (this.socketPath === undefined)
|
|
442
|
+
return false;
|
|
443
|
+
const code = err?.code;
|
|
444
|
+
return code === 'ECONNREFUSED' || code === 'ENOENT';
|
|
445
|
+
}
|
|
446
|
+
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
447
|
+
* pipe) — as distinct from a refused connect, which means nothing is
|
|
448
|
+
* listening. On the local socket that is `crtr sys daemon restart` doing its
|
|
449
|
+
* generation handover: the daemon acks, then tears itself down and hands
|
|
450
|
+
* over to a successor it spawned. The daemon IS coming back. */
|
|
451
|
+
isHandoverHangup(err) {
|
|
452
|
+
if (this.socketPath === undefined)
|
|
453
|
+
return false;
|
|
454
|
+
const code = err?.code;
|
|
455
|
+
return code === 'ECONNRESET' || code === 'EPIPE';
|
|
456
|
+
}
|
|
457
|
+
/** Wait for the successor to answer `/healthz`, then replay the request when
|
|
458
|
+
* replaying is safe. GET/HEAD are idempotent, so they retry transparently —
|
|
459
|
+
* the handover stays invisible, which is the whole point of a restart that
|
|
460
|
+
* resumes every node. A mutation may already have been applied server-side
|
|
461
|
+
* before the socket dropped, so it fails with `daemon_restarting` (retry),
|
|
462
|
+
* never `daemon_unavailable` ("start the daemon" is the wrong advice for a
|
|
463
|
+
* daemon that is mid-handover). */
|
|
464
|
+
async rideOutHandover(method, path, body) {
|
|
465
|
+
if (!(await this.awaitHandover())) {
|
|
466
|
+
throw new ApiError(503, 'daemon_unavailable', `crtrd went away mid-request and did not come back within ${this.coldStartPollWindowMs}ms.`);
|
|
467
|
+
}
|
|
468
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
469
|
+
throw new ApiError(503, 'daemon_restarting', `crtrd handed over to a new runtime generation mid-request; this ${method} may or may not have been applied.`);
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
return await this.transport(method, path, body);
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
throw toTransportApiError(err);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** Poll `/healthz` until the successor daemon answers, bounded by the
|
|
479
|
+
* cold-start window. Tolerates both the pre-listen gap (cold socket) and a
|
|
480
|
+
* second hang-up from a server still tearing down. */
|
|
481
|
+
async awaitHandover() {
|
|
482
|
+
const deadline = Date.now() + this.coldStartPollWindowMs;
|
|
483
|
+
for (;;) {
|
|
484
|
+
try {
|
|
485
|
+
const res = await this.transport('GET', routes.healthz());
|
|
486
|
+
if (res.status >= 200 && res.status < 300)
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
if (!this.isColdSocketError(err) && !this.isHandoverHangup(err))
|
|
491
|
+
throw toTransportApiError(err);
|
|
492
|
+
}
|
|
493
|
+
if (Date.now() >= deadline)
|
|
494
|
+
return false;
|
|
495
|
+
await sleep(HEALTHZ_POLL_INTERVAL_MS);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Trigger the injected daemon-start hook, poll `/healthz`, then let the caller
|
|
499
|
+
* retry once. Fail loud with `daemon_unavailable` when autostart is off, no
|
|
500
|
+
* hook is wired, or the daemon never becomes reachable. */
|
|
501
|
+
async handleColdSocket() {
|
|
502
|
+
if (!this.autostart || this.onColdSocket === undefined) {
|
|
503
|
+
throw new ApiError(503, 'daemon_unavailable', 'crtrd is not running and autostart is disabled; run `crtr sys daemon start`.');
|
|
504
|
+
}
|
|
505
|
+
if (this.coldStartAttempted) {
|
|
506
|
+
throw new ApiError(503, 'daemon_unavailable', 'crtrd did not become reachable after autostart.');
|
|
507
|
+
}
|
|
508
|
+
this.coldStartAttempted = true;
|
|
509
|
+
await this.onColdSocket();
|
|
510
|
+
const deadline = Date.now() + this.coldStartPollWindowMs;
|
|
511
|
+
for (;;) {
|
|
512
|
+
try {
|
|
513
|
+
const res = await this.transport('GET', routes.healthz());
|
|
514
|
+
if (res.status >= 200 && res.status < 300)
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
if (!this.isColdSocketError(err))
|
|
519
|
+
throw toTransportApiError(err);
|
|
520
|
+
}
|
|
521
|
+
if (Date.now() >= deadline) {
|
|
522
|
+
const diagnostic = safeColdStartDiagnostic(this.coldStartDiagnostic);
|
|
523
|
+
throw new ApiError(503, 'daemon_unavailable', coldStartTimeoutMessage(diagnostic));
|
|
524
|
+
}
|
|
525
|
+
await sleep(HEALTHZ_POLL_INTERVAL_MS);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Append defined query params to a path. Kept in the client (not `routes.ts`,
|
|
530
|
+
* which stays logic-free). Undefined/null values are skipped. */
|
|
531
|
+
function withQuery(base, query) {
|
|
532
|
+
if (query === undefined)
|
|
533
|
+
return base;
|
|
534
|
+
const params = new URLSearchParams();
|
|
535
|
+
for (const [key, value] of Object.entries(query)) {
|
|
536
|
+
if (value !== undefined && value !== null)
|
|
537
|
+
params.set(key, String(value));
|
|
538
|
+
}
|
|
539
|
+
const qs = params.toString();
|
|
540
|
+
return qs === '' ? base : `${base}?${qs}`;
|
|
541
|
+
}
|
|
542
|
+
/** Parse a raw response into `T`, or throw `ApiError` on non-2xx. A 204/empty
|
|
543
|
+
* body yields `undefined` (callers that type `void`/optional handle it). */
|
|
544
|
+
function parse(res) {
|
|
545
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
546
|
+
const trimmed = res.text.trim();
|
|
547
|
+
let payload;
|
|
548
|
+
if (trimmed !== '') {
|
|
549
|
+
try {
|
|
550
|
+
payload = JSON.parse(trimmed);
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
if (ok)
|
|
554
|
+
return undefined;
|
|
555
|
+
throw new ApiError(res.status, 'invalid_response', res.text.slice(0, 500));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (ok)
|
|
559
|
+
return payload;
|
|
560
|
+
if (isErrorBody(payload)) {
|
|
561
|
+
throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details);
|
|
562
|
+
}
|
|
563
|
+
throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`);
|
|
564
|
+
}
|
|
565
|
+
/** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
|
|
566
|
+
* connection refusal here means the daemon is unreachable and autostart could
|
|
567
|
+
* not recover it. */
|
|
568
|
+
function toTransportApiError(err) {
|
|
569
|
+
if (err instanceof ApiError)
|
|
570
|
+
return err;
|
|
571
|
+
const code = err?.code;
|
|
572
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
573
|
+
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
|
|
574
|
+
return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
|
|
575
|
+
}
|
|
576
|
+
if (code === 'ETIMEDOUT') {
|
|
577
|
+
// A per-request timeout against a REACHABLE daemon is not "crtrd not running"
|
|
578
|
+
// (§8 reserves daemon_unavailable for unreachable/autostart-failed).
|
|
579
|
+
return new ApiError(504, 'request_timeout', `crtrd request timed out: ${message}`);
|
|
580
|
+
}
|
|
581
|
+
return new ApiError(503, 'transport_error', message);
|
|
582
|
+
}
|
|
583
|
+
function sleep(ms) {
|
|
584
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
585
|
+
}
|
|
586
|
+
/** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
|
|
587
|
+
* appending the injected diagnostic (issue #516) when one is present instead
|
|
588
|
+
* of discarding the real startup failure behind a bare message. Exported (not
|
|
589
|
+
* from the package's public `index.ts` surface, which is dependency-light by
|
|
590
|
+
* design) purely so the regression test can assert the composition without
|
|
591
|
+
* waiting out the real poll window. */
|
|
592
|
+
export function coldStartTimeoutMessage(diagnostic) {
|
|
593
|
+
const base = 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.';
|
|
594
|
+
return diagnostic !== undefined && diagnostic !== '' ? `${base}\n${diagnostic}` : base;
|
|
595
|
+
}
|
|
596
|
+
/** Invoke the injected `coldStartDiagnostic` hook, treating a THROW the same
|
|
597
|
+
* as an absent/undefined result — the contract `CrtrClientOptions` documents
|
|
598
|
+
* ("a thrown/undefined result is treated as 'no diagnostic'"). Without this,
|
|
599
|
+
* a broken hook would propagate and replace the typed `daemon_unavailable`
|
|
600
|
+
* error the caller is entitled to. Exported alongside `coldStartTimeoutMessage`
|
|
601
|
+
* for the same direct-unit-test reason. */
|
|
602
|
+
export function safeColdStartDiagnostic(hook) {
|
|
603
|
+
if (hook === undefined)
|
|
604
|
+
return undefined;
|
|
605
|
+
try {
|
|
606
|
+
return hook();
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NodeIdDTO } from './common.js';
|
|
2
|
+
/** `POST /v1/nodes/{id}/attach` body (local ensure). */
|
|
3
|
+
export interface AttachEnsureRequest {
|
|
4
|
+
/** Resume saved history on revive (default true). */
|
|
5
|
+
resume?: boolean;
|
|
6
|
+
/** Revive if dormant (default true); false returns a possibly-dead socket. */
|
|
7
|
+
revive?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Result of a local attach-ensure. */
|
|
10
|
+
export interface AttachEnsureResultDTO {
|
|
11
|
+
node_id: NodeIdDTO;
|
|
12
|
+
/** Host-local path to the node's broker `view.sock`. */
|
|
13
|
+
socket_path: string;
|
|
14
|
+
revived: boolean;
|
|
15
|
+
resumed: boolean;
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Attach DTOs (spec §5). Two shapes, one concept:
|
|
2
|
+
//
|
|
3
|
+
// - `POST /v1/nodes/{id}/attach` (LOCAL) — crtrd revives (unless opted out) and
|
|
4
|
+
// returns `socket_path`; the local viewer then connects to `view.sock`
|
|
5
|
+
// directly with the existing `ViewSocketClient`. Meaningful only over the
|
|
6
|
+
// unix socket (the path is host-local). This is `ensureAttach()` on the client.
|
|
7
|
+
//
|
|
8
|
+
// - `GET /v1/nodes/{id}/attach` with `Upgrade: websocket` (REMOTE) — crtrd
|
|
9
|
+
// bridges the WS ⇄ the node's `view.sock` byte-for-byte, one WS per node.
|
|
10
|
+
// Opened DIRECTLY against the URL, NOT via a `CrtrClient` method. Opt out of
|
|
11
|
+
// the implicit revive with `?revive=0` or header `X-Crtr-Attach-Revive: 0`;
|
|
12
|
+
// a dormant node with revive opted out refuses the upgrade with 409.
|
|
13
|
+
export {};
|