@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.
Files changed (50) hide show
  1. package/README.md +51 -0
  2. package/dist/__tests__/client.test.d.ts +1 -0
  3. package/dist/__tests__/client.test.js +274 -0
  4. package/dist/client.d.ts +246 -0
  5. package/dist/client.js +611 -0
  6. package/dist/dto/attach.d.ts +16 -0
  7. package/dist/dto/attach.js +13 -0
  8. package/dist/dto/broker.d.ts +45 -0
  9. package/dist/dto/broker.js +20 -0
  10. package/dist/dto/canvas.d.ts +253 -0
  11. package/dist/dto/canvas.js +2 -0
  12. package/dist/dto/common.d.ts +27 -0
  13. package/dist/dto/common.js +15 -0
  14. package/dist/dto/config.d.ts +19 -0
  15. package/dist/dto/config.js +3 -0
  16. package/dist/dto/crons.d.ts +124 -0
  17. package/dist/dto/crons.js +10 -0
  18. package/dist/dto/files.d.ts +11 -0
  19. package/dist/dto/files.js +7 -0
  20. package/dist/dto/focus.d.ts +24 -0
  21. package/dist/dto/focus.js +10 -0
  22. package/dist/dto/health.d.ts +41 -0
  23. package/dist/dto/health.js +2 -0
  24. package/dist/dto/human.d.ts +57 -0
  25. package/dist/dto/human.js +4 -0
  26. package/dist/dto/inbox.d.ts +105 -0
  27. package/dist/dto/inbox.js +10 -0
  28. package/dist/dto/lifecycle.d.ts +79 -0
  29. package/dist/dto/lifecycle.js +3 -0
  30. package/dist/dto/messages.d.ts +55 -0
  31. package/dist/dto/messages.js +2 -0
  32. package/dist/dto/modelauth.d.ts +41 -0
  33. package/dist/dto/modelauth.js +3 -0
  34. package/dist/dto/nodes.d.ts +194 -0
  35. package/dist/dto/nodes.js +3 -0
  36. package/dist/dto/profiles.d.ts +14 -0
  37. package/dist/dto/profiles.js +3 -0
  38. package/dist/dto/reports.d.ts +41 -0
  39. package/dist/dto/reports.js +2 -0
  40. package/dist/dto/subscriptions.d.ts +14 -0
  41. package/dist/dto/subscriptions.js +2 -0
  42. package/dist/dto/worktree.d.ts +19 -0
  43. package/dist/dto/worktree.js +6 -0
  44. package/dist/errors.d.ts +19 -0
  45. package/dist/errors.js +30 -0
  46. package/dist/index.d.ts +24 -0
  47. package/dist/index.js +25 -0
  48. package/dist/routes.d.ts +63 -0
  49. package/dist/routes.js +91 -0
  50. 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 {};