@crouter/api 0.3.377
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 +67 -0
- package/dist/api/__tests__/error-codes.test.d.ts +1 -0
- package/dist/api/__tests__/error-codes.test.js +78 -0
- package/dist/api/__tests__/integration/client.test.d.ts +1 -0
- package/dist/api/__tests__/integration/client.test.js +179 -0
- package/dist/api/client.d.ts +467 -0
- package/dist/api/client.js +1179 -0
- package/dist/api/command-manifest/index.d.ts +3 -0
- package/dist/api/command-manifest/index.js +3 -0
- package/dist/api/command-manifest/manifest.d.ts +51 -0
- package/dist/api/command-manifest/manifest.js +332 -0
- package/dist/api/command-manifest/result.d.ts +25 -0
- package/dist/api/command-manifest/result.js +97 -0
- package/dist/api/command-manifest/schema.d.ts +28 -0
- package/dist/api/command-manifest/schema.js +856 -0
- package/dist/api/dto/analytics.d.ts +184 -0
- package/dist/api/dto/analytics.js +3 -0
- package/dist/api/dto/attach.d.ts +22 -0
- package/dist/api/dto/attach.js +13 -0
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/bash.d.ts +17 -0
- package/dist/api/dto/bash.js +1 -0
- package/dist/api/dto/broker-ops.d.ts +187 -0
- package/dist/api/dto/broker-ops.js +6 -0
- package/dist/api/dto/broker-signals.d.ts +25 -0
- package/dist/api/dto/broker-signals.js +1 -0
- package/dist/api/dto/broker.d.ts +86 -0
- package/dist/api/dto/broker.js +20 -0
- package/dist/api/dto/canvas.d.ts +359 -0
- package/dist/api/dto/canvas.js +2 -0
- package/dist/api/dto/chat-inventory.d.ts +56 -0
- package/dist/api/dto/chat-inventory.js +11 -0
- package/dist/api/dto/common.d.ts +29 -0
- package/dist/api/dto/common.js +15 -0
- package/dist/api/dto/config.d.ts +36 -0
- package/dist/api/dto/config.js +3 -0
- package/dist/api/dto/crons.d.ts +150 -0
- package/dist/api/dto/crons.js +10 -0
- package/dist/api/dto/custom-objects.d.ts +66 -0
- package/dist/api/dto/custom-objects.js +1 -0
- package/dist/api/dto/delivery.d.ts +71 -0
- package/dist/api/dto/delivery.js +7 -0
- package/dist/api/dto/docs.d.ts +135 -0
- package/dist/api/dto/docs.js +8 -0
- package/dist/api/dto/files.d.ts +21 -0
- package/dist/api/dto/files.js +1 -0
- package/dist/api/dto/focus.d.ts +24 -0
- package/dist/api/dto/focus.js +10 -0
- package/dist/api/dto/grants.d.ts +14 -0
- package/dist/api/dto/grants.js +1 -0
- package/dist/api/dto/health.d.ts +106 -0
- package/dist/api/dto/health.js +2 -0
- package/dist/api/dto/human-requests.d.ts +113 -0
- package/dist/api/dto/human-requests.js +4 -0
- package/dist/api/dto/human.d.ts +28 -0
- package/dist/api/dto/human.js +4 -0
- package/dist/api/dto/inbox.d.ts +273 -0
- package/dist/api/dto/inbox.js +4 -0
- package/dist/api/dto/lifecycle.d.ts +88 -0
- package/dist/api/dto/lifecycle.js +3 -0
- package/dist/api/dto/mail.d.ts +44 -0
- package/dist/api/dto/mail.js +1 -0
- package/dist/api/dto/messages.d.ts +88 -0
- package/dist/api/dto/messages.js +2 -0
- package/dist/api/dto/model-config.d.ts +25 -0
- package/dist/api/dto/model-config.js +1 -0
- package/dist/api/dto/modelauth.d.ts +132 -0
- package/dist/api/dto/modelauth.js +4 -0
- package/dist/api/dto/node-events.d.ts +65 -0
- package/dist/api/dto/node-events.js +4 -0
- package/dist/api/dto/node-outcomes.d.ts +88 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/node-records.d.ts +35 -0
- package/dist/api/dto/node-records.js +5 -0
- package/dist/api/dto/nodes.d.ts +368 -0
- package/dist/api/dto/nodes.js +3 -0
- package/dist/api/dto/objects.d.ts +172 -0
- package/dist/api/dto/objects.js +5 -0
- package/dist/api/dto/profiles.d.ts +117 -0
- package/dist/api/dto/profiles.js +4 -0
- package/dist/api/dto/recovery.d.ts +104 -0
- package/dist/api/dto/recovery.js +1 -0
- package/dist/api/dto/reports.d.ts +93 -0
- package/dist/api/dto/reports.js +2 -0
- package/dist/api/dto/review-comments.d.ts +146 -0
- package/dist/api/dto/review-comments.js +5 -0
- package/dist/api/dto/reviews.d.ts +113 -0
- package/dist/api/dto/reviews.js +5 -0
- package/dist/api/dto/run-events.d.ts +293 -0
- package/dist/api/dto/run-events.js +6 -0
- package/dist/api/dto/subscriptions.d.ts +14 -0
- package/dist/api/dto/subscriptions.js +2 -0
- package/dist/api/dto/worktree.d.ts +55 -0
- package/dist/api/dto/worktree.js +6 -0
- package/dist/api/error-codes.d.ts +254 -0
- package/dist/api/error-codes.js +54 -0
- package/dist/api/errors.d.ts +47 -0
- package/dist/api/errors.js +66 -0
- package/dist/api/index.d.ts +42 -0
- package/dist/api/index.js +41 -0
- package/dist/api/node-transport.d.ts +18 -0
- package/dist/api/node-transport.js +105 -0
- package/dist/api/plugin-manifest-schema.d.ts +233 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/api/routes.d.ts +160 -0
- package/dist/api/routes.js +193 -0
- package/dist/shared/generated-context.d.ts +79 -0
- package/dist/shared/generated-context.js +232 -0
- package/dist/shared/predicates.d.ts +2 -0
- package/dist/shared/predicates.js +4 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
// CrtrClient — the typed fetch client over crtrd's API.
|
|
2
|
+
//
|
|
3
|
+
// The root entry is browser-safe: Node's unix-socket fetch implementation and
|
|
4
|
+
// local path resolution live exclusively in `api/node-transport.ts`.
|
|
5
|
+
import { ApiError, isErrorBody } from './errors.js';
|
|
6
|
+
import { routes } from './routes.js';
|
|
7
|
+
import { isSafeNodeId } from './dto/common.js';
|
|
8
|
+
import { isSafeBashJobId } from './dto/bash-jobs.js';
|
|
9
|
+
import { isSafeCronId, } from './dto/crons.js';
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
|
+
const LOCAL_SOCKET_FETCH = Symbol.for('@crouter/api/local-socket-fetch');
|
|
12
|
+
/** Default bounded window to wait for `/healthz` to come up after
|
|
13
|
+
* `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
|
|
14
|
+
const HEALTHZ_POLL_WINDOW_MS = 10_000;
|
|
15
|
+
const HEALTHZ_POLL_INTERVAL_MS = 200;
|
|
16
|
+
/** One strict wall-clock availability window shared by local API clients and
|
|
17
|
+
* daemon management. Each probe gets only the budget remaining at its start. */
|
|
18
|
+
export async function waitForDaemonAvailability({ windowMs, probe, initialError, pollIntervalMs = HEALTHZ_POLL_INTERVAL_MS, retry = () => true, now = Date.now, sleep = sleepMs, }) {
|
|
19
|
+
const deadline = now() + windowMs;
|
|
20
|
+
let lastError = initialError;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const remaining = deadline - now();
|
|
23
|
+
if (remaining <= 0)
|
|
24
|
+
throw lastError ?? new Error('crtrd availability window expired without a probe failure');
|
|
25
|
+
try {
|
|
26
|
+
await probe(remaining);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
lastError = error;
|
|
31
|
+
if (!retry(error))
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
const afterProbe = deadline - now();
|
|
35
|
+
if (afterProbe <= 0)
|
|
36
|
+
throw lastError;
|
|
37
|
+
await sleep(Math.min(pollIntervalMs, afterProbe));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class CrtrClient {
|
|
41
|
+
baseUrl;
|
|
42
|
+
fetch;
|
|
43
|
+
headers;
|
|
44
|
+
autostart;
|
|
45
|
+
timeoutMs;
|
|
46
|
+
maxRetries;
|
|
47
|
+
localSocketTransport;
|
|
48
|
+
onColdSocket;
|
|
49
|
+
coldStartDiagnostic;
|
|
50
|
+
coldStartPollWindowMs;
|
|
51
|
+
/** Guards against invoking the daemon-start hook more than once per client. */
|
|
52
|
+
coldStartAttempted = false;
|
|
53
|
+
constructor(opts) {
|
|
54
|
+
if (opts.baseUrl === '')
|
|
55
|
+
throw new TypeError('CrtrClient requires baseUrl');
|
|
56
|
+
this.baseUrl = new URL(opts.baseUrl);
|
|
57
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
58
|
+
this.headers = { ...opts.headers };
|
|
59
|
+
this.autostart = opts.autostart ?? false;
|
|
60
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
62
|
+
this.localSocketTransport = this.fetch[LOCAL_SOCKET_FETCH] === true;
|
|
63
|
+
if (opts.onColdSocket !== undefined)
|
|
64
|
+
this.onColdSocket = opts.onColdSocket;
|
|
65
|
+
if (opts.coldStartDiagnostic !== undefined)
|
|
66
|
+
this.coldStartDiagnostic = opts.coldStartDiagnostic;
|
|
67
|
+
this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
|
|
68
|
+
}
|
|
69
|
+
// Health / status
|
|
70
|
+
healthz() {
|
|
71
|
+
return this.request('GET', routes.healthz());
|
|
72
|
+
}
|
|
73
|
+
/** One `/healthz` observation without cold-socket recovery. Availability
|
|
74
|
+
* waiters own retry policy and pass their remaining wall-clock budget here. */
|
|
75
|
+
async probeHealthz(timeoutMs) {
|
|
76
|
+
const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
|
|
77
|
+
// A restricted daemon is alive and deliberately answers its health DTO with
|
|
78
|
+
// 503. Preserve that state for daemon startup management instead of treating
|
|
79
|
+
// the expected non-2xx status as a generic API failure.
|
|
80
|
+
if (response.status === 503) {
|
|
81
|
+
try {
|
|
82
|
+
const health = await response.clone().json();
|
|
83
|
+
if (typeof health.startup_blocked === 'string')
|
|
84
|
+
return health;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// `parse` below reports malformed/non-health 503 responses normally.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return parse(response);
|
|
91
|
+
}
|
|
92
|
+
status() {
|
|
93
|
+
return this.request('GET', routes.status());
|
|
94
|
+
}
|
|
95
|
+
/** Ask the daemon to replace itself with a successor running the currently
|
|
96
|
+
* selected runtime generation. Answers before the handover starts, so a
|
|
97
|
+
* caller living inside a node the handover will tear down still gets a
|
|
98
|
+
* settled result. */
|
|
99
|
+
restartDaemon() {
|
|
100
|
+
return this.request('POST', routes.daemonRestart());
|
|
101
|
+
}
|
|
102
|
+
admitDaemon() {
|
|
103
|
+
return this.request('POST', routes.daemonAdmit());
|
|
104
|
+
}
|
|
105
|
+
migrateState(req) {
|
|
106
|
+
return this.request('POST', routes.daemonMigrate(), req);
|
|
107
|
+
}
|
|
108
|
+
// Nodes
|
|
109
|
+
appendNodeLog(id, req) {
|
|
110
|
+
return this.request('POST', routes.nodeLog(this.nodePath(id)), req);
|
|
111
|
+
}
|
|
112
|
+
async putNodeTelemetry(id, req) {
|
|
113
|
+
await this.request('PUT', routes.nodeTelemetry(this.nodePath(id)), req);
|
|
114
|
+
}
|
|
115
|
+
async putNodeRecap(id, req) {
|
|
116
|
+
await this.request('PUT', routes.nodeRecap(this.nodePath(id)), req);
|
|
117
|
+
}
|
|
118
|
+
readNodeInboxReport(id, ref, executionId) {
|
|
119
|
+
return this.request('GET', withQuery(routes.nodeInboxReport(this.nodePath(id)), { ref, execution_id: executionId }));
|
|
120
|
+
}
|
|
121
|
+
readNodePassiveMessage(id, ref) {
|
|
122
|
+
return this.request('GET', withQuery(routes.nodePassiveMessage(this.nodePath(id)), { ref }));
|
|
123
|
+
}
|
|
124
|
+
nodePushedFinal(id, req) {
|
|
125
|
+
return this.request('POST', routes.nodePushedFinal(this.nodePath(id)), req);
|
|
126
|
+
}
|
|
127
|
+
createNode(req) {
|
|
128
|
+
return this.request('POST', routes.nodes(), req);
|
|
129
|
+
}
|
|
130
|
+
/** List canvas nodes with composable row filters. `include: 'activity'` adds
|
|
131
|
+
* latest/canonical reports and pending-human counts in the same response. */
|
|
132
|
+
listNodes(q) {
|
|
133
|
+
return this.request('GET', withQuery(routes.nodes(), q));
|
|
134
|
+
}
|
|
135
|
+
getNode(id) {
|
|
136
|
+
return this.request('GET', routes.node(this.nodePath(id)));
|
|
137
|
+
}
|
|
138
|
+
/** Read a node outcome, optionally awaiting it for at most 25 seconds. This
|
|
139
|
+
* is a GET so the client can safely replay it across daemon handover. */
|
|
140
|
+
getNodeOutcome(id, { waitSeconds } = {}) {
|
|
141
|
+
if (waitSeconds !== undefined && (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 25)) {
|
|
142
|
+
throw new RangeError('waitSeconds must be an integer between 0 and 25');
|
|
143
|
+
}
|
|
144
|
+
return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
|
|
145
|
+
}
|
|
146
|
+
/** Open the raw server-sent event response for a node. The caller owns SSE
|
|
147
|
+
* parsing and must consume or cancel the response body. Streams deliberately
|
|
148
|
+
* have no client wall-clock timeout. */
|
|
149
|
+
getNodeEvents(id, query = {}, options = {}) {
|
|
150
|
+
if (query.after !== undefined && (!Number.isSafeInteger(query.after) || query.after < 0)) {
|
|
151
|
+
throw new RangeError('after must be a non-negative safe integer');
|
|
152
|
+
}
|
|
153
|
+
return this.transport('GET', withQuery(routes.nodeEvents(this.nodePath(id)), query), undefined, {
|
|
154
|
+
...options,
|
|
155
|
+
headers: { accept: 'text/event-stream', ...(options.headers ?? {}) },
|
|
156
|
+
timeout: 0,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Parsed NDJSON hints, consumed eagerly and line by line. The caller must
|
|
160
|
+
* cancel with `signal` when done; no wall-clock timeout applies to the stream. */
|
|
161
|
+
async *getBrokerSignals(id, executionId, options = {}) {
|
|
162
|
+
const response = await this.send('GET', withQuery(routes.nodeBrokerSignals(this.nodePath(id)), { execution_id: executionId }), undefined, {
|
|
163
|
+
...options, headers: { accept: 'application/x-ndjson', ...(options.headers ?? {}) }, timeout: 0,
|
|
164
|
+
});
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
await parse(response);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (response.body === null)
|
|
170
|
+
throw new ApiError(response.status, 'invalid_response', 'broker signal stream has no body');
|
|
171
|
+
const reader = response.body.getReader();
|
|
172
|
+
const decoder = new TextDecoder();
|
|
173
|
+
let buffer = '';
|
|
174
|
+
try {
|
|
175
|
+
while (true) {
|
|
176
|
+
const { done, value } = await reader.read();
|
|
177
|
+
if (done)
|
|
178
|
+
break;
|
|
179
|
+
buffer += decoder.decode(value, { stream: true });
|
|
180
|
+
let newline;
|
|
181
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
182
|
+
const line = buffer.slice(0, newline);
|
|
183
|
+
buffer = buffer.slice(newline + 1);
|
|
184
|
+
if (line !== '')
|
|
185
|
+
yield JSON.parse(line);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
buffer += decoder.decode();
|
|
189
|
+
if (buffer.trim() !== '')
|
|
190
|
+
throw new ApiError(502, 'invalid_response', 'broker signal stream ended mid-line');
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await reader.cancel().catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Register or replace an armed target for terminal-outcome delivery. */
|
|
197
|
+
registerOutcomeDelivery(id, req) {
|
|
198
|
+
return this.request('PUT', routes.nodeOutcomeDelivery(this.nodePath(id)), req);
|
|
199
|
+
}
|
|
200
|
+
getOutcomeDelivery(id) {
|
|
201
|
+
return this.request('GET', routes.nodeOutcomeDelivery(this.nodePath(id)));
|
|
202
|
+
}
|
|
203
|
+
/** Disarm an unsettled outcome-delivery registration. */
|
|
204
|
+
async disarmOutcomeDelivery(id) {
|
|
205
|
+
await this.request('DELETE', routes.nodeOutcomeDelivery(this.nodePath(id)));
|
|
206
|
+
}
|
|
207
|
+
listBashJobs(id) {
|
|
208
|
+
return this.request('GET', routes.nodeJobs(this.nodePath(id)));
|
|
209
|
+
}
|
|
210
|
+
stopBashJob(id, jobId) {
|
|
211
|
+
return this.request('DELETE', routes.nodeJob(this.nodePath(id), this.jobPath(jobId)));
|
|
212
|
+
}
|
|
213
|
+
sendMessage(id, req) {
|
|
214
|
+
return this.request('POST', routes.nodeMessages(this.nodePath(id)), req);
|
|
215
|
+
}
|
|
216
|
+
/** First-class interrupt (the human Esc): cancels pending undelivered
|
|
217
|
+
* human-send inbox entries, then aborts a live in-flight turn. NEVER
|
|
218
|
+
* revives a dormant target. */
|
|
219
|
+
interruptNode(id) {
|
|
220
|
+
return this.request('POST', routes.nodeInterrupt(this.nodePath(id)), {});
|
|
221
|
+
}
|
|
222
|
+
pushReport(id, req) {
|
|
223
|
+
return this.request('POST', routes.nodeReports(this.nodePath(id)), req);
|
|
224
|
+
}
|
|
225
|
+
submitResult(id, req) {
|
|
226
|
+
return this.request('POST', routes.nodeResult(this.nodePath(id)), req);
|
|
227
|
+
}
|
|
228
|
+
forkNode(id) {
|
|
229
|
+
return this.request('POST', routes.nodeFork(this.nodePath(id)), {});
|
|
230
|
+
}
|
|
231
|
+
reviveNode(id, req) {
|
|
232
|
+
return this.request('POST', routes.nodeRevive(this.nodePath(id)), req ?? {});
|
|
233
|
+
}
|
|
234
|
+
relaunchRoot(id) {
|
|
235
|
+
return this.request('POST', routes.nodeRelaunchRoot(this.nodePath(id)), {});
|
|
236
|
+
}
|
|
237
|
+
bindBrokerSession(id, req) {
|
|
238
|
+
return this.request('POST', routes.nodeBrokerSessionBound(this.nodePath(id)), req);
|
|
239
|
+
}
|
|
240
|
+
settleBroker(id, req) {
|
|
241
|
+
return this.request('POST', routes.nodeBrokerSettle(this.nodePath(id)), req);
|
|
242
|
+
}
|
|
243
|
+
completeBrokerPark(id, req) {
|
|
244
|
+
return this.request('POST', routes.nodeBrokerParkComplete(this.nodePath(id)), req);
|
|
245
|
+
}
|
|
246
|
+
recordBrokerParkActivity(id, req) {
|
|
247
|
+
return this.request('POST', routes.nodeBrokerParkActivity(this.nodePath(id)), req);
|
|
248
|
+
}
|
|
249
|
+
async recordBrokerTelemetry(id, req) {
|
|
250
|
+
await this.request('POST', routes.nodeBrokerTelemetry(this.nodePath(id)), req);
|
|
251
|
+
}
|
|
252
|
+
claimNodeMail(id, req) {
|
|
253
|
+
return this.request('POST', routes.nodeMailClaim(this.nodePath(id)), req);
|
|
254
|
+
}
|
|
255
|
+
acknowledgeNodeMail(id, req) {
|
|
256
|
+
return this.request('POST', routes.nodeMailAcknowledge(this.nodePath(id)), req);
|
|
257
|
+
}
|
|
258
|
+
recordBrokerTurn(id, req) {
|
|
259
|
+
return this.request('POST', routes.brokerTurn(this.nodePath(id)), req);
|
|
260
|
+
}
|
|
261
|
+
mutateBrokerProviderRetry(id, req) {
|
|
262
|
+
return this.request('POST', routes.brokerProviderRetry(this.nodePath(id)), req);
|
|
263
|
+
}
|
|
264
|
+
mutateBrokerFault(id, req) {
|
|
265
|
+
return this.request('POST', routes.brokerFault(this.nodePath(id)), req);
|
|
266
|
+
}
|
|
267
|
+
getBrokerRecovery(id, expectedExecutionId) {
|
|
268
|
+
return this.request('GET', `${routes.brokerRecovery(this.nodePath(id))}?expected_execution_id=${encodeURIComponent(expectedExecutionId)}`);
|
|
269
|
+
}
|
|
270
|
+
recordNodeFault(id, req) {
|
|
271
|
+
return this.request('POST', routes.nodeFault(this.nodePath(id)), req);
|
|
272
|
+
}
|
|
273
|
+
clearNodeFault(id, opts = {}) {
|
|
274
|
+
const query = new URLSearchParams();
|
|
275
|
+
if (opts.link !== undefined)
|
|
276
|
+
query.set('link', opts.link);
|
|
277
|
+
if (opts.preserve_episode !== undefined)
|
|
278
|
+
query.set('preserve_episode', String(opts.preserve_episode));
|
|
279
|
+
const suffix = query.size === 0 ? '' : `?${query.toString()}`;
|
|
280
|
+
return this.request('DELETE', `${routes.nodeFault(this.nodePath(id))}${suffix}`);
|
|
281
|
+
}
|
|
282
|
+
commitBrokerModel(id, req) {
|
|
283
|
+
return this.request('POST', routes.nodeBrokerModel(this.nodePath(id)), req);
|
|
284
|
+
}
|
|
285
|
+
brokerExtensionState(id) {
|
|
286
|
+
return this.request('GET', routes.nodeBrokerExtensionState(this.nodePath(id)));
|
|
287
|
+
}
|
|
288
|
+
commitBrokerGeneratedName(id, req) {
|
|
289
|
+
return this.request('POST', routes.nodeBrokerGeneratedName(this.nodePath(id)), req);
|
|
290
|
+
}
|
|
291
|
+
commitBrokerPersonaAck(id, req) {
|
|
292
|
+
return this.request('POST', routes.nodeBrokerPersonaAck(this.nodePath(id)), req);
|
|
293
|
+
}
|
|
294
|
+
closeNode(id, req) {
|
|
295
|
+
return this.request('POST', routes.nodeClose(this.nodePath(id)), req ?? {});
|
|
296
|
+
}
|
|
297
|
+
recycleNode(id) {
|
|
298
|
+
return this.request('POST', routes.nodeRecycle(this.nodePath(id)), {});
|
|
299
|
+
}
|
|
300
|
+
demoteNode(id) {
|
|
301
|
+
return this.request('POST', routes.nodeDemote(this.nodePath(id)), {});
|
|
302
|
+
}
|
|
303
|
+
promoteNode(id, req) {
|
|
304
|
+
return this.request('POST', routes.nodePromote(this.nodePath(id)), req);
|
|
305
|
+
}
|
|
306
|
+
yieldNode(id, req) {
|
|
307
|
+
return this.request('POST', routes.nodeYield(this.nodePath(id)), req);
|
|
308
|
+
}
|
|
309
|
+
waitNode(id, req) {
|
|
310
|
+
return this.request('POST', routes.nodeWait(this.nodePath(id)), req);
|
|
311
|
+
}
|
|
312
|
+
listKinds() {
|
|
313
|
+
return this.request('GET', routes.kinds());
|
|
314
|
+
}
|
|
315
|
+
/** A model change may first wait up to 30 s for a starting broker's view
|
|
316
|
+
* socket and then 15 s for its ack, so it gets that budget on top. */
|
|
317
|
+
patchConfig(id, patch) {
|
|
318
|
+
return this.request('PATCH', routes.nodeConfig(this.nodePath(id)), patch, patch.model !== undefined ? { timeout: 45_000 + this.timeoutMs } : undefined);
|
|
319
|
+
}
|
|
320
|
+
/** Land + close the node's managed git worktree (spec §6.2). Server-side
|
|
321
|
+
* because it interleaves a canvas WRITE with a git land transaction and
|
|
322
|
+
* crtrd is the repo host (same principle as spawnChild's creation git). */
|
|
323
|
+
closeWorktree(id) {
|
|
324
|
+
return this.request('POST', routes.nodeWorktreeClose(this.nodePath(id)), {});
|
|
325
|
+
}
|
|
326
|
+
abandonWorktree(id, req) {
|
|
327
|
+
return this.request('POST', routes.nodeWorktreeAbandon(this.nodePath(id)), req);
|
|
328
|
+
}
|
|
329
|
+
listQuarantinedWorktrees() {
|
|
330
|
+
return this.request('GET', routes.quarantinedWorktrees());
|
|
331
|
+
}
|
|
332
|
+
/** Every space on the runtime with its size. */
|
|
333
|
+
listSpaces() {
|
|
334
|
+
return this.request('GET', routes.spaces());
|
|
335
|
+
}
|
|
336
|
+
subscribe(id, req) {
|
|
337
|
+
return this.request('POST', routes.nodeSubscriptions(this.nodePath(id)), req);
|
|
338
|
+
}
|
|
339
|
+
// Focuses (viewer registry)
|
|
340
|
+
// The tmux verbs that open/move/close a viewer pane run LOCALLY in the
|
|
341
|
+
// caller's session (placement-tmux); only the canvas.db focus rows route here.
|
|
342
|
+
// Reads GC lazily server-side, so a returned row is always live; a `null` body
|
|
343
|
+
// is the normal "no viewer" answer, not a 404.
|
|
344
|
+
listFocuses() {
|
|
345
|
+
return this.request('GET', routes.focuses());
|
|
346
|
+
}
|
|
347
|
+
focusOf(nodeId) {
|
|
348
|
+
return this.request('GET', withQuery(routes.focusByNode(), { node_id: nodeId }));
|
|
349
|
+
}
|
|
350
|
+
focusByPane(pane) {
|
|
351
|
+
return this.request('GET', withQuery(routes.focusByPane(), { pane }));
|
|
352
|
+
}
|
|
353
|
+
registerFocus(req) {
|
|
354
|
+
return this.request('POST', routes.focuses(), req);
|
|
355
|
+
}
|
|
356
|
+
async setFocusPane(focusId, req) {
|
|
357
|
+
await this.request('PATCH', routes.focus(focusId), req);
|
|
358
|
+
}
|
|
359
|
+
async closeFocus(focusId) {
|
|
360
|
+
await this.request('DELETE', routes.focus(focusId));
|
|
361
|
+
}
|
|
362
|
+
async unsubscribe(id, target) {
|
|
363
|
+
await this.request('DELETE', routes.nodeSubscription(this.nodePath(id), this.nodePath(target)));
|
|
364
|
+
}
|
|
365
|
+
/** Arm one cron (`POST /v1/crons`) — the server mints the cron_id. */
|
|
366
|
+
armCron(req) {
|
|
367
|
+
return this.request('POST', routes.crons(), req);
|
|
368
|
+
}
|
|
369
|
+
/** Crons visible to the caller (`GET /v1/crons`). With `q.profile` that is
|
|
370
|
+
* that profile's crons plus every global one; omit it only for a
|
|
371
|
+
* canvas-home-wide provenance read ("which crons did node X arm"). */
|
|
372
|
+
listCrons(q) {
|
|
373
|
+
return this.request('GET', withQuery(routes.crons(), q));
|
|
374
|
+
}
|
|
375
|
+
/** One cron with its run-log ring (`GET /v1/crons/:cronId`). */
|
|
376
|
+
showCron(cronId, q) {
|
|
377
|
+
return this.request('GET', withQuery(routes.cron(this.cronPath(cronId)), q));
|
|
378
|
+
}
|
|
379
|
+
/** Pause one cron (`POST /v1/crons/:cronId/pause`) — stops firing, keeps config+history. */
|
|
380
|
+
pauseCron(cronId, q) {
|
|
381
|
+
return this.request('POST', withQuery(routes.cronPause(this.cronPath(cronId)), q), {});
|
|
382
|
+
}
|
|
383
|
+
/** Resume one paused cron (`POST /v1/crons/:cronId/resume`). */
|
|
384
|
+
resumeCron(cronId, q) {
|
|
385
|
+
return this.request('POST', withQuery(routes.cronResume(this.cronPath(cronId)), q), {});
|
|
386
|
+
}
|
|
387
|
+
/** Run one cron NOW, out of band (`POST /v1/crons/:cronId/run`) — synchronous:
|
|
388
|
+
* resolves with the settled run record after the subprocess closes. Does not
|
|
389
|
+
* advance the schedule or consume a one-shot; never escalates. */
|
|
390
|
+
runCron(cronId, q) {
|
|
391
|
+
return this.request('POST', withQuery(routes.cronRun(this.cronPath(cronId)), q), {});
|
|
392
|
+
}
|
|
393
|
+
/** Cancel one cron (`DELETE /v1/crons/:cronId`, idempotent). */
|
|
394
|
+
async cancelCron(cronId, q) {
|
|
395
|
+
await this.request('DELETE', withQuery(routes.cron(this.cronPath(cronId)), q));
|
|
396
|
+
}
|
|
397
|
+
/** Bare eligibility poke (`POST /v1/crons/poke`): re-dues every held active
|
|
398
|
+
* cron now — "something changed; re-check now". Canvas-wide, label-free,
|
|
399
|
+
* idempotent, and free when nothing is held. */
|
|
400
|
+
pokeCrons() {
|
|
401
|
+
return this.request('POST', routes.cronsPoke(), {});
|
|
402
|
+
}
|
|
403
|
+
ensureAttach(id, req) {
|
|
404
|
+
return this.request('POST', routes.nodeAttach(this.nodePath(id)), req ?? {});
|
|
405
|
+
}
|
|
406
|
+
// Reads / feed
|
|
407
|
+
getReports(id, q) {
|
|
408
|
+
return this.request('GET', withQuery(routes.nodeReports(this.nodePath(id)), q));
|
|
409
|
+
}
|
|
410
|
+
getTranscript(id, q) {
|
|
411
|
+
return this.request('GET', withQuery(routes.nodeTranscript(this.nodePath(id)), q));
|
|
412
|
+
}
|
|
413
|
+
getSnapshot(id) {
|
|
414
|
+
return this.request('GET', routes.nodeSnapshot(this.nodePath(id)));
|
|
415
|
+
}
|
|
416
|
+
/** The node-config subject substrate gates evaluate against. */
|
|
417
|
+
nodeSubject(id) {
|
|
418
|
+
return this.request('GET', routes.nodeSubject(this.nodePath(id)));
|
|
419
|
+
}
|
|
420
|
+
getNodeMessages(id, q) {
|
|
421
|
+
return this.request('GET', withQuery(routes.nodeMessages(this.nodePath(id)), q));
|
|
422
|
+
}
|
|
423
|
+
/** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
|
|
424
|
+
* assembled system prompt. For exports; `getSnapshot` is for renderers. */
|
|
425
|
+
getSession(id) {
|
|
426
|
+
return this.request('GET', routes.nodeSession(this.nodePath(id)));
|
|
427
|
+
}
|
|
428
|
+
/** What a non-terminal chat surface may offer for this node: the chat-capable
|
|
429
|
+
* slash commands its live engine registered, and the memory documents an
|
|
430
|
+
* inline `/name` token resolves to. Never revives — a node whose broker is
|
|
431
|
+
* not live answers `broker_live: false` with empty arrays. */
|
|
432
|
+
getChatInventory(id) {
|
|
433
|
+
return this.request('GET', routes.nodeChatInventory(this.nodePath(id)));
|
|
434
|
+
}
|
|
435
|
+
getProspectiveChatInventory(q) {
|
|
436
|
+
return this.request('GET', withQuery(routes.prospectiveChatInventory(), q));
|
|
437
|
+
}
|
|
438
|
+
getContext(id) {
|
|
439
|
+
return this.request('GET', routes.nodeContext(this.nodePath(id)));
|
|
440
|
+
}
|
|
441
|
+
// Host file read
|
|
442
|
+
/** Read an absolute host path (capped, `truncated` when clipped). */
|
|
443
|
+
peekFile(path, encoding, options) {
|
|
444
|
+
return this.request('GET', withQuery(routes.filePeek(), { path, encoding }), undefined, options);
|
|
445
|
+
}
|
|
446
|
+
writeFile(path, content, encoding, options) {
|
|
447
|
+
return this.request('POST', routes.fileWrite(), { path, content, encoding }, options);
|
|
448
|
+
}
|
|
449
|
+
listFiles(path, limit, options) {
|
|
450
|
+
return this.request('GET', withQuery(routes.fileList(), { path, limit }), undefined, options);
|
|
451
|
+
}
|
|
452
|
+
runBash(params, options) {
|
|
453
|
+
return this.request('POST', routes.bash(), params, options);
|
|
454
|
+
}
|
|
455
|
+
// Canvas objects and documents
|
|
456
|
+
/** `crtr canvas` shared verbs. A ref is a name (with or without `[[ ]]`) or a raw object id. */
|
|
457
|
+
objects = {
|
|
458
|
+
read: (ref, query) => this.request('GET', withQuery(routes.object(ref), query)),
|
|
459
|
+
list: (query) => this.request('GET', withQuery(routes.objects(), query)),
|
|
460
|
+
search: (request) => this.request('POST', routes.objectsSearch(), request),
|
|
461
|
+
watch: (ref, request = {}) => this.request('POST', routes.objectWatch(ref), request),
|
|
462
|
+
unwatch: (ref, query) => this.request('DELETE', withQuery(routes.objectWatch(ref), query)),
|
|
463
|
+
edges: (ref, query) => this.request('GET', withQuery(routes.objectEdges(ref), query)),
|
|
464
|
+
};
|
|
465
|
+
/** `crtr doc` write verbs. */
|
|
466
|
+
docs = {
|
|
467
|
+
write: (request) => this.request('POST', routes.docs(), request),
|
|
468
|
+
edit: (ref, request) => this.request('PATCH', routes.doc(ref), request),
|
|
469
|
+
move: (ref, request) => this.request('POST', routes.docMove(ref), request),
|
|
470
|
+
delete: (ref) => this.request('DELETE', routes.doc(ref)),
|
|
471
|
+
history: (ref, query) => this.request('GET', withQuery(routes.docHistory(ref), query)),
|
|
472
|
+
lint: (request = {}) => this.request('POST', routes.docsLint(), request),
|
|
473
|
+
};
|
|
474
|
+
/** Package (builtin and plugin) documents. */
|
|
475
|
+
documents = {
|
|
476
|
+
reconcilePackages: () => this.request('POST', routes.documentsReconcilePackages()),
|
|
477
|
+
};
|
|
478
|
+
/** The broker's delivery of one node activity. */
|
|
479
|
+
nodeDelivery(id, request) {
|
|
480
|
+
return this.request('POST', routes.nodeDelivery(this.nodePath(id)), request);
|
|
481
|
+
}
|
|
482
|
+
/** A person's dry run of one delivery activity, for a live or hypothetical node. Writes nothing. */
|
|
483
|
+
deliveryDryRun(request) {
|
|
484
|
+
return this.request('POST', routes.deliveryDryRun(), request);
|
|
485
|
+
}
|
|
486
|
+
/** Record a backgrounded bash command as a job object. */
|
|
487
|
+
recordBackgroundedJob(id, jobId, request) {
|
|
488
|
+
return this.request('POST', routes.nodeJobBackground(this.nodePath(id), this.jobPath(jobId)), request);
|
|
489
|
+
}
|
|
490
|
+
// Profiles
|
|
491
|
+
/** Create-or-return by name. Supplied `projects` are shape-checked even when
|
|
492
|
+
* the profile already exists; their directories are only required to exist
|
|
493
|
+
* when this call creates the profile. */
|
|
494
|
+
ensureProfile(name, req) {
|
|
495
|
+
return this.request('PUT', routes.profile(name), req ?? {});
|
|
496
|
+
}
|
|
497
|
+
/** Create a profile of the caller's app; a name it already holds is refused. */
|
|
498
|
+
createProfile(req) {
|
|
499
|
+
return this.request('POST', routes.profiles(), req);
|
|
500
|
+
}
|
|
501
|
+
listProfiles() {
|
|
502
|
+
return this.request('GET', routes.profiles());
|
|
503
|
+
}
|
|
504
|
+
/** Rename, set the default kind, or add or remove one project. */
|
|
505
|
+
updateProfile(name, req) {
|
|
506
|
+
return this.request('PATCH', routes.profile(name), req);
|
|
507
|
+
}
|
|
508
|
+
listAppEnv() {
|
|
509
|
+
return this.request('GET', routes.appEnv());
|
|
510
|
+
}
|
|
511
|
+
setAppEnv(variable, value) {
|
|
512
|
+
return this.request('PUT', routes.appEnvVar(variable), { value });
|
|
513
|
+
}
|
|
514
|
+
removeAppEnv(variable) {
|
|
515
|
+
return this.request('DELETE', routes.appEnvVar(variable));
|
|
516
|
+
}
|
|
517
|
+
listProfileEnv(name) {
|
|
518
|
+
return this.request('GET', routes.profileEnv(name));
|
|
519
|
+
}
|
|
520
|
+
setProfileEnv(name, variable, value) {
|
|
521
|
+
return this.request('PUT', routes.profileEnvVar(name, variable), { value });
|
|
522
|
+
}
|
|
523
|
+
removeProfileEnv(name, variable) {
|
|
524
|
+
return this.request('DELETE', routes.profileEnvVar(name, variable));
|
|
525
|
+
}
|
|
526
|
+
getProfile(name) {
|
|
527
|
+
return this.request('GET', routes.profile(name));
|
|
528
|
+
}
|
|
529
|
+
pauseProfile(name) {
|
|
530
|
+
return this.request('POST', routes.profilePause(name), {});
|
|
531
|
+
}
|
|
532
|
+
resumeProfile(name) {
|
|
533
|
+
return this.request('POST', routes.profileResume(name), {});
|
|
534
|
+
}
|
|
535
|
+
/** Merge and remove entries in a profile's metadata map. */
|
|
536
|
+
updateProfileMetadata(name, req) {
|
|
537
|
+
return this.request('PATCH', routes.profileMetadata(name), req);
|
|
538
|
+
}
|
|
539
|
+
/** Force-delete or detach one profile by exact id or unique name. */
|
|
540
|
+
deleteProfile(name, req) {
|
|
541
|
+
return this.request('DELETE', routes.profile(name), req);
|
|
542
|
+
}
|
|
543
|
+
// Model auth
|
|
544
|
+
listModelAuth() {
|
|
545
|
+
return this.request('GET', routes.modelAuths());
|
|
546
|
+
}
|
|
547
|
+
getModelAuthReadiness(query) {
|
|
548
|
+
return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
|
|
549
|
+
}
|
|
550
|
+
installCredential(provider, req) {
|
|
551
|
+
return this.request('PUT', routes.modelAuth(provider), req);
|
|
552
|
+
}
|
|
553
|
+
removeCredential(provider) {
|
|
554
|
+
return this.request('DELETE', routes.modelAuth(provider));
|
|
555
|
+
}
|
|
556
|
+
/** `model_auth.start` — the daemon runs the vendor OAuth; the caller only shows `url`. */
|
|
557
|
+
startModelAuth(req) {
|
|
558
|
+
return this.request('POST', routes.modelAuthFlows(), req, { timeout: 30_000 + this.timeoutMs });
|
|
559
|
+
}
|
|
560
|
+
/** `model_auth.complete` — hand the daemon the code the vendor showed. */
|
|
561
|
+
completeModelAuth(flowId, req) {
|
|
562
|
+
return this.request('POST', routes.modelAuthFlowComplete(flowId), req, { timeout: 35_000 + this.timeoutMs });
|
|
563
|
+
}
|
|
564
|
+
/** `model_auth.cancel` — end an in-flight flow the person gave up on; it writes nothing. */
|
|
565
|
+
cancelModelAuth(flowId) {
|
|
566
|
+
return this.request('POST', routes.modelAuthFlowCancel(flowId), {});
|
|
567
|
+
}
|
|
568
|
+
/** A flow's state; `waitSeconds` (1-25) long-polls until it leaves `pending`. */
|
|
569
|
+
getModelAuthFlow(flowId, waitSeconds) {
|
|
570
|
+
return this.request('GET', withQuery(routes.modelAuthFlow(flowId), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
|
|
571
|
+
}
|
|
572
|
+
// Daemon-owned document reviews and comments
|
|
573
|
+
createReview(req) {
|
|
574
|
+
return this.request('POST', routes.humanReviews(), req);
|
|
575
|
+
}
|
|
576
|
+
listReviews(query) {
|
|
577
|
+
return this.request('GET', withQuery(routes.humanReviews(), query));
|
|
578
|
+
}
|
|
579
|
+
getReview(reviewId) {
|
|
580
|
+
return this.request('GET', routes.humanReview(this.reviewPath(reviewId)));
|
|
581
|
+
}
|
|
582
|
+
submitReview(reviewId) {
|
|
583
|
+
return this.request('POST', routes.humanReviewSubmit(this.reviewPath(reviewId)), {});
|
|
584
|
+
}
|
|
585
|
+
cancelReview(reviewId, req = {}) {
|
|
586
|
+
return this.request('POST', routes.humanReviewCancel(this.reviewPath(reviewId)), req);
|
|
587
|
+
}
|
|
588
|
+
getReviewDocumentBase(reviewId) {
|
|
589
|
+
return this.request('GET', routes.humanReviewDocument(this.reviewPath(reviewId)));
|
|
590
|
+
}
|
|
591
|
+
createReviewComment(reviewId, req) {
|
|
592
|
+
const path = reviewId === undefined ? 'self' : this.reviewPath(reviewId);
|
|
593
|
+
return this.request('POST', routes.humanReviewComments(path), req);
|
|
594
|
+
}
|
|
595
|
+
listReviewComments(reviewId, query) {
|
|
596
|
+
const path = reviewId === undefined ? 'self' : this.reviewPath(reviewId);
|
|
597
|
+
return this.request('GET', withQuery(routes.humanReviewComments(path), query));
|
|
598
|
+
}
|
|
599
|
+
readReviewCommentEvents(reviewId, query) {
|
|
600
|
+
return this.request('GET', withQuery(routes.humanReviewCommentEvents(this.reviewPath(reviewId)), query));
|
|
601
|
+
}
|
|
602
|
+
updateReviewCommentRanges(reviewId, req) {
|
|
603
|
+
return this.request('POST', routes.humanReviewCommentRanges(this.reviewPath(reviewId)), req);
|
|
604
|
+
}
|
|
605
|
+
getReviewComment(commentId) {
|
|
606
|
+
return this.request('GET', routes.humanComment(this.commentPath(commentId)));
|
|
607
|
+
}
|
|
608
|
+
editReviewComment(commentId, req) {
|
|
609
|
+
return this.request('POST', routes.humanCommentEdit(this.commentPath(commentId)), req);
|
|
610
|
+
}
|
|
611
|
+
resolveReviewComment(commentId, req = {}) {
|
|
612
|
+
return this.request('POST', routes.humanCommentResolve(this.commentPath(commentId)), req);
|
|
613
|
+
}
|
|
614
|
+
reopenReviewComment(commentId, req = {}) {
|
|
615
|
+
return this.request('POST', routes.humanCommentReopen(this.commentPath(commentId)), req);
|
|
616
|
+
}
|
|
617
|
+
deleteReviewComment(commentId, req = {}) {
|
|
618
|
+
return this.request('POST', routes.humanCommentDelete(this.commentPath(commentId)), req);
|
|
619
|
+
}
|
|
620
|
+
forkReviewComment(commentId) {
|
|
621
|
+
return this.request('POST', routes.humanCommentFork(this.commentPath(commentId)), {});
|
|
622
|
+
}
|
|
623
|
+
// ---- Humanloop inbox (crouter cloud crouter-inbox v1, inbox-contract.md §A) --
|
|
624
|
+
/** Pending page/review tickets across every available crouter-owned
|
|
625
|
+
* humanloop root. */
|
|
626
|
+
listHumanInbox() {
|
|
627
|
+
return this.request('GET', routes.humanInbox());
|
|
628
|
+
}
|
|
629
|
+
/** Read one page ticket by its opaque ticket id (pending, resolved, or canceled). */
|
|
630
|
+
getHumanInboxPage(ticketId) {
|
|
631
|
+
return this.request('GET', routes.humanInboxTicket(this.ticketId(ticketId)));
|
|
632
|
+
}
|
|
633
|
+
/** All page tickets, oldest first (pending, resolved, or canceled) —
|
|
634
|
+
* home-wide, or narrowed to one raising node when `nodeId` is given. */
|
|
635
|
+
getInboxHistory(nodeId) {
|
|
636
|
+
if (nodeId === undefined) {
|
|
637
|
+
return this.request('GET', routes.humanInbox() + '/history');
|
|
638
|
+
}
|
|
639
|
+
if (typeof nodeId !== 'string' || nodeId === '') {
|
|
640
|
+
throw new TypeError(`invalid node id: ${JSON.stringify(nodeId)}`);
|
|
641
|
+
}
|
|
642
|
+
return this.request('GET', withQuery(routes.humanInbox() + '/history', { node_id: nodeId }));
|
|
643
|
+
}
|
|
644
|
+
/** Submit responses for a page ticket. Single-assignment server-side: a competing
|
|
645
|
+
* resolution races to `ticket_already_resolved`. */
|
|
646
|
+
respondHumanInboxPage(ticketId, request) {
|
|
647
|
+
return this.request('POST', routes.humanInboxRespond(this.ticketId(ticketId)), request);
|
|
648
|
+
}
|
|
649
|
+
/** Autosave partial page work. Omitted slots are permitted in progress updates. */
|
|
650
|
+
postInboxProgress(ticketId, responses) {
|
|
651
|
+
return this.request('POST', routes.humanInboxProgress(this.ticketId(ticketId)), { responses });
|
|
652
|
+
}
|
|
653
|
+
/** Get the published response for a resolved page ticket. 404 if pending or canceled. */
|
|
654
|
+
getInboxResponse(ticketId) {
|
|
655
|
+
return this.request('GET', routes.humanInboxResponse(this.ticketId(ticketId)));
|
|
656
|
+
}
|
|
657
|
+
/** Cancel a ticket (terminal response, never deletion). */
|
|
658
|
+
cancelHumanInboxTicket(ticketId, request) {
|
|
659
|
+
return this.request('POST', routes.humanInboxCancel(this.ticketId(ticketId)), request ?? {});
|
|
660
|
+
}
|
|
661
|
+
// Durable programmatic human requests
|
|
662
|
+
/** Create one durable human request. The minted `request_id` identifies the
|
|
663
|
+
* request; inbox presentation has its own ticket id. An unresolvable
|
|
664
|
+
* `action.name` is rejected before the page is published, leaving no inbox
|
|
665
|
+
* row behind. */
|
|
666
|
+
createHumanRequest(request) {
|
|
667
|
+
return this.request('POST', routes.humanRequests(), request);
|
|
668
|
+
}
|
|
669
|
+
/** Product page components registered on the runtime (config + enabled plugins). */
|
|
670
|
+
listPageComponents() {
|
|
671
|
+
return this.request('GET', routes.humanComponents());
|
|
672
|
+
}
|
|
673
|
+
/** Read one request: its current state, its answer when answered, and the
|
|
674
|
+
* delivery state of its completion action when it bound one. */
|
|
675
|
+
getHumanRequest(requestId) {
|
|
676
|
+
return this.request('GET', routes.humanRequest(this.requestPath(requestId)));
|
|
677
|
+
}
|
|
678
|
+
/** Revise a pending request's page in place. Identity, provenance, and the
|
|
679
|
+
* frozen action binding are preserved; a settled request refuses. */
|
|
680
|
+
replaceHumanRequest(requestId, request) {
|
|
681
|
+
return this.request('POST', routes.humanRequestReplace(this.requestPath(requestId)), request);
|
|
682
|
+
}
|
|
683
|
+
/** Settle a request `answered` programmatically. Races a human answer to the
|
|
684
|
+
* same first-writer-wins result. */
|
|
685
|
+
respondHumanRequest(requestId, request) {
|
|
686
|
+
return this.request('POST', routes.humanRequestRespond(this.requestPath(requestId)), request);
|
|
687
|
+
}
|
|
688
|
+
/** The recipient surface closing a request without answering. */
|
|
689
|
+
dismissHumanRequest(requestId, request = {}) {
|
|
690
|
+
return this.request('POST', routes.humanRequestDismiss(this.requestPath(requestId)), request);
|
|
691
|
+
}
|
|
692
|
+
/** The requester withdrawing its own request. */
|
|
693
|
+
cancelHumanRequest(requestId, request = {}) {
|
|
694
|
+
return this.request('POST', routes.humanRequestCancel(this.requestPath(requestId)), request);
|
|
695
|
+
}
|
|
696
|
+
/** Resolve one page feedback comment — the bound companion's report that it
|
|
697
|
+
* has been dealt with. `nodeId` names the caller; the daemon refuses any
|
|
698
|
+
* node but the ticket's companion. Terminal for the comment; appends no
|
|
699
|
+
* chat turn. */
|
|
700
|
+
resolvePageFeedbackComment(ticketId, commentId, nodeId) {
|
|
701
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(commentId)) {
|
|
702
|
+
throw new TypeError(`invalid feedback comment id: ${JSON.stringify(commentId)}`);
|
|
703
|
+
}
|
|
704
|
+
return this.request('POST', routes.humanInboxFeedbackResolve(this.ticketId(ticketId), commentId), { node_id: nodeId });
|
|
705
|
+
}
|
|
706
|
+
// Canvas reads / maintenance
|
|
707
|
+
/** Composed client-side from `GET /v1/nodes` + `GET /v1/status` (spec §6.3 —
|
|
708
|
+
* the dashboard is absorbed into those two reads; there is no single route).
|
|
709
|
+
* `generated_at` is the client-side capture instant of the composition. */
|
|
710
|
+
async dashboard(q) {
|
|
711
|
+
const listQuery = q?.under !== undefined ? { under: q.under } : undefined;
|
|
712
|
+
const [nodes, status] = await Promise.all([this.listNodes(listQuery), this.status()]);
|
|
713
|
+
return { nodes, counts: status.node_counts, generated_at: new Date().toISOString() };
|
|
714
|
+
}
|
|
715
|
+
attention() {
|
|
716
|
+
return this.request('GET', routes.canvasAttention());
|
|
717
|
+
}
|
|
718
|
+
/** Per-node pending-ticket counts for a bounded viewer slice. */
|
|
719
|
+
attentionCounts(node_ids) {
|
|
720
|
+
const body = { node_ids };
|
|
721
|
+
return this.request('POST', routes.canvasAttentionCounts(), body);
|
|
722
|
+
}
|
|
723
|
+
/** Ranked/filtered content search over the per-cwd episodic corpus
|
|
724
|
+
* (`crtr canvas history search`). Optional query: ranked when present,
|
|
725
|
+
* recency browse when omitted. POST-bodied — the query carries arrays and
|
|
726
|
+
* free text; the whole search executes server-side (spec §6.3). */
|
|
727
|
+
historySearch(q) {
|
|
728
|
+
return this.request('POST', routes.canvasHistorySearch(), q);
|
|
729
|
+
}
|
|
730
|
+
/** Required-pattern line-hit search over the per-cwd episodic corpus
|
|
731
|
+
* (`crtr canvas history grep`). Distinct stable schema from `historySearch`
|
|
732
|
+
* — POST-bodied for the same reasons. */
|
|
733
|
+
historyGrep(q) {
|
|
734
|
+
return this.request('POST', routes.canvasHistoryGrep(), q);
|
|
735
|
+
}
|
|
736
|
+
/** Resolve one `<node-id>:<relpath>` history ref to its full body
|
|
737
|
+
* (`crtr canvas history read`). */
|
|
738
|
+
historyRead(q) {
|
|
739
|
+
return this.request('GET', withQuery(routes.canvasHistoryRead(), q));
|
|
740
|
+
}
|
|
741
|
+
/** Grouped-count projection over the per-cwd episodic corpus
|
|
742
|
+
* (`crtr canvas history stats`). Same filters as search/grep, aggregate
|
|
743
|
+
* result instead of a hit page — POST-bodied for the same reasons. */
|
|
744
|
+
historyStats(q) {
|
|
745
|
+
return this.request('POST', routes.canvasHistoryStats(), q);
|
|
746
|
+
}
|
|
747
|
+
/** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
|
|
748
|
+
* distinct from the per-node `getSnapshot`. */
|
|
749
|
+
canvasSnapshot() {
|
|
750
|
+
return this.request('GET', routes.canvasSnapshot());
|
|
751
|
+
}
|
|
752
|
+
/** The Analytics page model (`GET /v1/canvas/analytics`). The
|
|
753
|
+
* daemon scans transcripts for the window: a cold 7d scan of ~2 GB of
|
|
754
|
+
* transcripts measured 10 s, and with scan workers capped at 4 a queued
|
|
755
|
+
* request waits about one scan more. 60 s is 3x that worst case; past it the
|
|
756
|
+
* page shows the error instead of computing forever. */
|
|
757
|
+
analytics(q) {
|
|
758
|
+
return this.request('GET', withQuery(routes.canvasAnalytics(), q), undefined, { timeout: 60_000 });
|
|
759
|
+
}
|
|
760
|
+
/** Record memory reads/loads for one node (`POST /v1/nodes/:id/memory-reads`). */
|
|
761
|
+
async recordMemoryReads(nodeId, body) {
|
|
762
|
+
await this.request('POST', routes.nodeMemoryReads(this.nodePath(nodeId)), body);
|
|
763
|
+
}
|
|
764
|
+
/** The lean, set-based topology roster (`GET /v1/canvas/roster`) — exactly
|
|
765
|
+
* two indexed queries server-side, no per-row enrichment. The recurring
|
|
766
|
+
* poll target for attach/browser topology; use `canvasSnapshot` for the
|
|
767
|
+
* enriched on-demand view. */
|
|
768
|
+
canvasRoster() {
|
|
769
|
+
return this.request('GET', routes.canvasRoster());
|
|
770
|
+
}
|
|
771
|
+
/** Explicitly compressed browse snapshot; the Node-only canvas source
|
|
772
|
+
* decompresses it because socketFetch does not interpret content-encoding. */
|
|
773
|
+
async canvasBrowseCompressed() {
|
|
774
|
+
const response = await this.send('GET', routes.canvasBrowse());
|
|
775
|
+
if (!response.ok)
|
|
776
|
+
await parse(response); // retain the normal structured API error
|
|
777
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
778
|
+
}
|
|
779
|
+
/** The lean attach-graph projection: display rows, topology, and focus state
|
|
780
|
+
* in one daemon-owned read rather than the full node-summary list. */
|
|
781
|
+
canvasGraph() {
|
|
782
|
+
return this.request('GET', routes.canvasGraph());
|
|
783
|
+
}
|
|
784
|
+
prune(req) {
|
|
785
|
+
return this.request('POST', routes.canvasPrune(), req);
|
|
786
|
+
}
|
|
787
|
+
// Escape hatch
|
|
788
|
+
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
789
|
+
* autostart + retry + error-mapping semantics. */
|
|
790
|
+
async request(method, path, body, opts) {
|
|
791
|
+
try {
|
|
792
|
+
return await parse(await this.send(method, path, body, opts));
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
throw toTransportApiError(error, opts?.signal);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/** Open a live response with the normal cold-socket and retry policy, without consuming its body. */
|
|
799
|
+
async rawResponse(method, path, opts) {
|
|
800
|
+
try {
|
|
801
|
+
return await this.send(method, path, undefined, opts);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
throw toTransportApiError(error, opts?.signal);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** The unparsed request: cold-socket and interrupted-response recovery, plus
|
|
808
|
+
* the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
|
|
809
|
+
* only — POST/PATCH are never replayed once a request has actually been
|
|
810
|
+
* sent). No JSON parse; every wrapper goes through here. */
|
|
811
|
+
async send(method, path, body, opts = {}) {
|
|
812
|
+
const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
|
|
813
|
+
const maxRetries = opts.maxRetries ?? this.maxRetries;
|
|
814
|
+
for (let attempt = 0;; attempt++) {
|
|
815
|
+
let response;
|
|
816
|
+
try {
|
|
817
|
+
response = await this.transport(method, path, body, opts);
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
if (this.isColdSocketError(err)) {
|
|
821
|
+
try {
|
|
822
|
+
await this.handleColdSocket(err);
|
|
823
|
+
response = await this.transport(method, path, body, opts);
|
|
824
|
+
}
|
|
825
|
+
catch (recoveryError) {
|
|
826
|
+
throw toTransportApiError(recoveryError, opts.signal);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
|
|
830
|
+
return await this.rideOutInterruptedRequest(method, path, body, opts);
|
|
831
|
+
}
|
|
832
|
+
else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
|
|
833
|
+
await sleepMs(retryDelayMs(attempt + 1));
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
throw toTransportApiError(err, opts.signal);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
|
|
841
|
+
await drainResponseBody(response);
|
|
842
|
+
await sleepMs(retryDelayMs(attempt + 1));
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
return response;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// internals
|
|
849
|
+
nodePath(id) {
|
|
850
|
+
if (!isSafeNodeId(id)) {
|
|
851
|
+
throw new ApiError(400, 'invalid_node_id', `invalid node id: ${JSON.stringify(id)}`);
|
|
852
|
+
}
|
|
853
|
+
return id;
|
|
854
|
+
}
|
|
855
|
+
/** Validate a background job id before route construction. Job ids arrive
|
|
856
|
+
* from the daemon's file-backed roster and must remain one path segment. */
|
|
857
|
+
jobPath(id) {
|
|
858
|
+
if (!isSafeBashJobId(id)) {
|
|
859
|
+
throw new ApiError(400, 'invalid_job_id', `invalid background job id: ${JSON.stringify(id)}`);
|
|
860
|
+
}
|
|
861
|
+
return id;
|
|
862
|
+
}
|
|
863
|
+
/** Validate a cron id before route construction — `routes.ts` interpolates
|
|
864
|
+
* it raw, so a value carrying `/`, whitespace or `?` would corrupt the
|
|
865
|
+
* request line rather than 404 cleanly. Mirrors `nodePath`. */
|
|
866
|
+
cronPath(id) {
|
|
867
|
+
if (!isSafeCronId(id)) {
|
|
868
|
+
throw new ApiError(400, 'invalid_cron_id', `invalid cron id: ${JSON.stringify(id)}`);
|
|
869
|
+
}
|
|
870
|
+
return id;
|
|
871
|
+
}
|
|
872
|
+
/** Validate a request id before route construction. Requests use the daemon's
|
|
873
|
+
* node-id-shaped identifiers, while inbox presentation uses a separate ticket
|
|
874
|
+
* id. A malformed request id is a server-rejectable request. */
|
|
875
|
+
requestPath(id) {
|
|
876
|
+
if (!isSafeNodeId(id)) {
|
|
877
|
+
throw new ApiError(400, 'invalid_request_id', `invalid request id: ${JSON.stringify(id)}`);
|
|
878
|
+
}
|
|
879
|
+
return id;
|
|
880
|
+
}
|
|
881
|
+
/** Validate an opaque inbox ticket id before route construction. A local
|
|
882
|
+
* shape violation is a caller bug, not a server-rejectable request — throws
|
|
883
|
+
* `TypeError` (matching the existing safe-segment discipline of a local
|
|
884
|
+
* precondition, distinct from `nodePath`'s `ApiError` because that one IS a
|
|
885
|
+
* request the server could plausibly receive and reject itself). */
|
|
886
|
+
ticketId(id) {
|
|
887
|
+
if (!/^[a-f0-9]{64}$/.test(id)) {
|
|
888
|
+
throw new TypeError(`invalid inbox ticket id: ${JSON.stringify(id)}`);
|
|
889
|
+
}
|
|
890
|
+
return id;
|
|
891
|
+
}
|
|
892
|
+
/** Validate a review id before route construction (D-A6). Reviews are minted
|
|
893
|
+
* and addressed like node ids, but arrive from agent argv, so a malformed
|
|
894
|
+
* one is a plausible request the server would also reject — `ApiError`,
|
|
895
|
+
* not `TypeError`, matching `nodePath`. */
|
|
896
|
+
reviewPath(id) {
|
|
897
|
+
if (!isSafeNodeId(id)) {
|
|
898
|
+
throw new ApiError(400, 'invalid_review_id', `invalid review id: ${JSON.stringify(id)}`);
|
|
899
|
+
}
|
|
900
|
+
return id;
|
|
901
|
+
}
|
|
902
|
+
/** Validate a comment id before route construction (D-A6). Comment ids are
|
|
903
|
+
* daemon-minted 32-char lowercase hex, but — like `reviewPath` — arrive from
|
|
904
|
+
* agent argv, so a bad one is a plausible request the server would also
|
|
905
|
+
* reject, not a caller-bug `TypeError` like `ticketId`. */
|
|
906
|
+
commentPath(id) {
|
|
907
|
+
if (!/^[a-f0-9]{32}$/.test(id)) {
|
|
908
|
+
throw new ApiError(400, 'invalid_comment_id', `invalid comment id: ${JSON.stringify(id)}`);
|
|
909
|
+
}
|
|
910
|
+
return id;
|
|
911
|
+
}
|
|
912
|
+
/** The one fetch path (§1). Resolves as soon as headers arrive — the body
|
|
913
|
+
* stays an unread `ReadableStream` on the returned `Response`, so a caller
|
|
914
|
+
* that wants to stream (SSE, later) never waits on a buffered body. */
|
|
915
|
+
transport(method, path, body, opts = {}) {
|
|
916
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
917
|
+
const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
|
|
918
|
+
if (payload !== undefined)
|
|
919
|
+
headers['content-type'] = 'application/json';
|
|
920
|
+
const timeoutMs = opts.timeout ?? this.timeoutMs;
|
|
921
|
+
const controller = new AbortController();
|
|
922
|
+
const timer = timeoutMs > 0
|
|
923
|
+
? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
|
|
924
|
+
: undefined;
|
|
925
|
+
const externalSignal = opts.signal;
|
|
926
|
+
const onExternalAbort = () => controller.abort(externalSignal?.reason);
|
|
927
|
+
if (externalSignal !== undefined) {
|
|
928
|
+
if (externalSignal.aborted)
|
|
929
|
+
controller.abort(externalSignal.reason);
|
|
930
|
+
else
|
|
931
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
932
|
+
}
|
|
933
|
+
const finish = () => {
|
|
934
|
+
if (timer !== undefined)
|
|
935
|
+
clearTimeout(timer);
|
|
936
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
937
|
+
};
|
|
938
|
+
return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
|
|
939
|
+
.then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
|
|
940
|
+
finish();
|
|
941
|
+
throw error;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
retainRequestLifetime(response, finish, abortReason) {
|
|
945
|
+
if (response.body === null) {
|
|
946
|
+
finish();
|
|
947
|
+
return response;
|
|
948
|
+
}
|
|
949
|
+
const reader = response.body.getReader();
|
|
950
|
+
let finished = false;
|
|
951
|
+
const close = () => {
|
|
952
|
+
if (finished)
|
|
953
|
+
return;
|
|
954
|
+
finished = true;
|
|
955
|
+
finish();
|
|
956
|
+
};
|
|
957
|
+
const body = new ReadableStream({
|
|
958
|
+
async pull(controller) {
|
|
959
|
+
try {
|
|
960
|
+
const { done, value } = await reader.read();
|
|
961
|
+
if (done) {
|
|
962
|
+
close();
|
|
963
|
+
controller.close();
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
controller.enqueue(value);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
catch (error) {
|
|
970
|
+
close();
|
|
971
|
+
controller.error(abortReason() ?? error);
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
async cancel(reason) {
|
|
975
|
+
close();
|
|
976
|
+
await reader.cancel(reason);
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
980
|
+
}
|
|
981
|
+
isColdSocketError(err) {
|
|
982
|
+
const code = transportErrorCode(err);
|
|
983
|
+
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
|
|
984
|
+
}
|
|
985
|
+
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
986
|
+
* pipe). It establishes only that the response was interrupted, not why. */
|
|
987
|
+
isInterruptedSocketError(err) {
|
|
988
|
+
const code = transportErrorCode(err);
|
|
989
|
+
return code === 'ECONNRESET' || code === 'EPIPE';
|
|
990
|
+
}
|
|
991
|
+
/** Wait for the local API after an interrupted response. GET/HEAD can retry
|
|
992
|
+
* because they are idempotent. A mutation may already have been applied, so
|
|
993
|
+
* it never replays. */
|
|
994
|
+
async rideOutInterruptedRequest(method, path, body, opts = {}) {
|
|
995
|
+
try {
|
|
996
|
+
await this.awaitAvailability();
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
throw toTransportApiError(error, opts.signal);
|
|
1000
|
+
}
|
|
1001
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
1002
|
+
throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
return await this.transport(method, path, body, opts);
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
throw toTransportApiError(err, opts.signal);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async awaitAvailability(initialError) {
|
|
1012
|
+
await waitForDaemonAvailability({
|
|
1013
|
+
windowMs: this.coldStartPollWindowMs,
|
|
1014
|
+
initialError,
|
|
1015
|
+
probe: async (timeoutMs) => {
|
|
1016
|
+
const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
|
|
1017
|
+
if (response.status >= 200 && response.status < 300) {
|
|
1018
|
+
await drainResponseBody(response);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const text = await response.text();
|
|
1022
|
+
try {
|
|
1023
|
+
const health = JSON.parse(text);
|
|
1024
|
+
if (typeof health.startup_blocked === 'string')
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
// The normal unavailable error below carries the response body.
|
|
1029
|
+
}
|
|
1030
|
+
throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
/** Optionally start the daemon, then observe availability before retrying the
|
|
1035
|
+
* request. Waiting is independent from permission to spawn: externally
|
|
1036
|
+
* managed daemons still get the same bounded readiness window. */
|
|
1037
|
+
async handleColdSocket(initialError) {
|
|
1038
|
+
const startedHere = this.autostart && this.onColdSocket !== undefined && !this.coldStartAttempted;
|
|
1039
|
+
if (startedHere) {
|
|
1040
|
+
this.coldStartAttempted = true;
|
|
1041
|
+
await this.onColdSocket();
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
await this.awaitAvailability(initialError);
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
if (startedHere) {
|
|
1048
|
+
const diagnostic = safeColdStartDiagnostic(this.coldStartDiagnostic);
|
|
1049
|
+
if (diagnostic !== undefined) {
|
|
1050
|
+
const transportError = toTransportApiError(error);
|
|
1051
|
+
throw new ApiError(transportError.status, transportError.code, `${transportError.message}\n${diagnostic}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Append defined query params to a path. Kept in the client (not `routes.ts`,
|
|
1059
|
+
* which stays logic-free). Undefined/null values are skipped. */
|
|
1060
|
+
function withQuery(base, query) {
|
|
1061
|
+
if (query === undefined)
|
|
1062
|
+
return base;
|
|
1063
|
+
const params = new URLSearchParams();
|
|
1064
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1065
|
+
if (value !== undefined && value !== null)
|
|
1066
|
+
params.set(key, String(value));
|
|
1067
|
+
}
|
|
1068
|
+
const qs = params.toString();
|
|
1069
|
+
return qs === '' ? base : `${base}?${qs}`;
|
|
1070
|
+
}
|
|
1071
|
+
/** Parse a response into `T` — the first (and here, only) read of its body —
|
|
1072
|
+
* or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
|
|
1073
|
+
* (callers that type `void`/optional handle it). */
|
|
1074
|
+
async function parse(res) {
|
|
1075
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
1076
|
+
const trimmed = (await res.text()).trim();
|
|
1077
|
+
let payload;
|
|
1078
|
+
if (trimmed !== '') {
|
|
1079
|
+
try {
|
|
1080
|
+
payload = JSON.parse(trimmed);
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
if (ok)
|
|
1084
|
+
return undefined;
|
|
1085
|
+
throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (ok)
|
|
1089
|
+
return payload;
|
|
1090
|
+
if (isErrorBody(payload)) {
|
|
1091
|
+
throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers, payload.error);
|
|
1092
|
+
}
|
|
1093
|
+
throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
|
|
1094
|
+
}
|
|
1095
|
+
/** `429`/`5xx` are the only statuses the §7 retry policy replays. */
|
|
1096
|
+
function isRetryableStatus(status) {
|
|
1097
|
+
return status === 429 || status >= 500;
|
|
1098
|
+
}
|
|
1099
|
+
/** A response abandoned mid-retry must have its stream drained, or the
|
|
1100
|
+
* underlying connection (and, over a unix socket, the daemon's handler) is
|
|
1101
|
+
* held open by a reader that will never arrive. */
|
|
1102
|
+
async function drainResponseBody(res) {
|
|
1103
|
+
try {
|
|
1104
|
+
await res.body?.cancel();
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
// Best-effort — the response is being discarded either way.
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
|
|
1111
|
+
function retryDelayMs(attempt) {
|
|
1112
|
+
return Math.min(2_000, 250 * 2 ** (attempt - 1));
|
|
1113
|
+
}
|
|
1114
|
+
/** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
|
|
1115
|
+
* wraps a connection error as `TypeError('fetch failed', { cause })`; the
|
|
1116
|
+
* Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
|
|
1117
|
+
* shape so both transports classify identically here. */
|
|
1118
|
+
function transportErrorCode(err) {
|
|
1119
|
+
if (typeof err !== 'object' || err === null)
|
|
1120
|
+
return undefined;
|
|
1121
|
+
const direct = err.code;
|
|
1122
|
+
if (typeof direct === 'string')
|
|
1123
|
+
return direct;
|
|
1124
|
+
const cause = err.cause;
|
|
1125
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
1126
|
+
const causeCode = cause.code;
|
|
1127
|
+
if (typeof causeCode === 'string')
|
|
1128
|
+
return causeCode;
|
|
1129
|
+
}
|
|
1130
|
+
return undefined;
|
|
1131
|
+
}
|
|
1132
|
+
/** True when a transport throw is the caller's OWN abort, as opposed to the
|
|
1133
|
+
* client's internal per-request timeout — an aborted request must never be
|
|
1134
|
+
* retried, whichever reason fired. */
|
|
1135
|
+
function isAbortError(err, signal) {
|
|
1136
|
+
if (signal?.aborted)
|
|
1137
|
+
return true;
|
|
1138
|
+
return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
|
|
1139
|
+
}
|
|
1140
|
+
/** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
|
|
1141
|
+
* connection refusal here means the daemon is unreachable and autostart could
|
|
1142
|
+
* not recover it. */
|
|
1143
|
+
function toTransportApiError(err, signal) {
|
|
1144
|
+
if (signal?.aborted) {
|
|
1145
|
+
return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
|
|
1146
|
+
}
|
|
1147
|
+
if (err instanceof ApiError)
|
|
1148
|
+
return err;
|
|
1149
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
1150
|
+
return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
|
|
1151
|
+
}
|
|
1152
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
1153
|
+
return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
|
|
1154
|
+
}
|
|
1155
|
+
const code = transportErrorCode(err);
|
|
1156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1157
|
+
if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
|
|
1158
|
+
return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
|
|
1159
|
+
}
|
|
1160
|
+
if (code === 'ETIMEDOUT') {
|
|
1161
|
+
// A per-request timeout against a REACHABLE daemon is not "crtrd not running"
|
|
1162
|
+
// (§8 reserves daemon_unavailable for unreachable/autostart-failed).
|
|
1163
|
+
return new ApiError(504, 'request_timeout', `crtrd request timed out: ${message}`);
|
|
1164
|
+
}
|
|
1165
|
+
return new ApiError(503, 'transport_error', message);
|
|
1166
|
+
}
|
|
1167
|
+
function sleepMs(ms) {
|
|
1168
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1169
|
+
}
|
|
1170
|
+
export function safeColdStartDiagnostic(hook) {
|
|
1171
|
+
if (hook === undefined)
|
|
1172
|
+
return undefined;
|
|
1173
|
+
try {
|
|
1174
|
+
return hook();
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
return undefined;
|
|
1178
|
+
}
|
|
1179
|
+
}
|