@north-light/api 0.3.459 → 0.3.460

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/io.d.ts CHANGED
@@ -59,6 +59,9 @@ interface ErrorPayloadBase {
59
59
  http_status?: number;
60
60
  /** Seconds the provider asked the caller to wait before retrying (a rate limit). */
61
61
  retry_after_s?: number;
62
+ /** The structured details the runtime attached to a refusal the caller must act on (a `machine_required`
63
+ * answer's `candidates`). Rendered below the message and kept in `--json`. */
64
+ details?: Record<string, unknown>;
62
65
  next: string;
63
66
  }
64
67
  export type ErrorPayload = ErrorPayloadBase & ({
@@ -116,9 +119,16 @@ export declare function diag(message: string): void;
116
119
  * under the daemon's coarse `provider_*` code and the provider's HTTP
117
120
  * status. The provider's code, `next`, `field` and `Retry-After` reach the
118
121
  * caller unchanged; the status→code map would flatten them to `conflict`.
119
- * 4. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
122
+ * 4. A refusal the runtime itself rendered for `provider.call` (`options.providerCall`):
123
+ * its envelope code came from the runtime's own table (`machine_required`,
124
+ * `device_offline`, …), not from the HTTP status, so it is kept together with
125
+ * its `details`. The caller of `provider.call` is the only one who knows the
126
+ * envelope is that endpoint's, which is why this is an option and not inferred.
127
+ * 5. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
120
128
  * and a caller-supplied-or-default `next`. */
121
- export declare function apiErrorToCliError(err: ApiError, next?: string): CrtrError;
129
+ export declare function apiErrorToCliError(err: ApiError, next?: string, options?: {
130
+ providerCall?: boolean;
131
+ }): CrtrError;
122
132
  /** Terminal error handler. Command-level failures (bad input, not-found,
123
133
  * ambiguous) surface as the JSON response on stdout so the caller parses one
124
134
  * contract. A daemon `ApiError` is translated to that same clean contract
package/dist/core/io.js CHANGED
@@ -190,6 +190,9 @@ export function diag(message) {
190
190
  process.stderr.write(message + '\n');
191
191
  }
192
192
  // errors
193
+ function isRecord(value) {
194
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
195
+ }
193
196
  function payloadOf(e) {
194
197
  if (e instanceof InputError)
195
198
  return e.payload;
@@ -210,6 +213,7 @@ function payloadOf(e) {
210
213
  field: d.field,
211
214
  ...(typeof d.http_status === 'number' ? { http_status: d.http_status } : {}),
212
215
  ...(typeof d.retry_after_s === 'number' ? { retry_after_s: d.retry_after_s } : {}),
216
+ ...(isRecord(d.details) ? { details: d.details } : {}),
213
217
  };
214
218
  return result === undefined
215
219
  ? { ...base, next }
@@ -280,9 +284,14 @@ const STATUS_TO_CLI_CODE = {
280
284
  * under the daemon's coarse `provider_*` code and the provider's HTTP
281
285
  * status. The provider's code, `next`, `field` and `Retry-After` reach the
282
286
  * caller unchanged; the status→code map would flatten them to `conflict`.
283
- * 4. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
287
+ * 4. A refusal the runtime itself rendered for `provider.call` (`options.providerCall`):
288
+ * its envelope code came from the runtime's own table (`machine_required`,
289
+ * `device_offline`, …), not from the HTTP status, so it is kept together with
290
+ * its `details`. The caller of `provider.call` is the only one who knows the
291
+ * envelope is that endpoint's, which is why this is an option and not inferred.
292
+ * 5. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
284
293
  * and a caller-supplied-or-default `next`. */
285
- export function apiErrorToCliError(err, next) {
294
+ export function apiErrorToCliError(err, next, options = {}) {
286
295
  // A handover is NOT a down daemon: the successor is already up (the client
287
296
  // waited for its /healthz), so telling anyone to start one is wrong — the
288
297
  // only thing left is to re-issue the call.
@@ -304,12 +313,15 @@ export function apiErrorToCliError(err, next) {
304
313
  // A responding daemon can relay an unavailable provider or storage service.
305
314
  // HTTP 503 alone does not mean that crtrd is down.
306
315
  const d = err.details;
307
- const provider = providerSupplied(err, d);
308
- if (err.status === 503) {
316
+ const provider = providerSupplied(err, d, options.providerCall === true);
317
+ // A `device_timeout` the runtime rendered for `provider.call` (504) is the same operational class as a 503.
318
+ if (err.status === 503 || (options.providerCall === true && err.status === 504 && provider.code !== undefined)) {
309
319
  const code = provider.code ?? err.code;
310
320
  const details = {
311
321
  next: provider.next ?? next ?? 'Retry the command; if it persists, inspect the unavailable service named in the error.',
312
322
  };
323
+ if (provider.details !== undefined)
324
+ details['details'] = provider.details;
313
325
  if (provider.code !== undefined) {
314
326
  if (provider.field !== undefined)
315
327
  details['field'] = provider.field;
@@ -333,6 +345,7 @@ export function apiErrorToCliError(err, next) {
333
345
  ...(provider.field !== undefined ? { field: provider.field } : {}),
334
346
  ...(provider.received !== undefined ? { received: provider.received } : {}),
335
347
  ...(provider.retryAfterS !== undefined ? { retry_after_s: provider.retryAfterS } : {}),
348
+ ...(provider.details !== undefined ? { details: provider.details } : {}),
336
349
  message: violations.length > 0 ? `${err.message}: ${violations.join('; ')}` : err.message.trim() !== '' ? err.message : `request refused (HTTP ${err.status}, ${err.code}) with no message`,
337
350
  next: provider.next ??
338
351
  next ??
@@ -357,13 +370,32 @@ export function apiErrorToCliError(err, next) {
357
370
  * did not originate at a provider, so non-provider status→code mapping is
358
371
  * untouched. A code that is not a snake_case identifier, or that came with no
359
372
  * provider HTTP status, is ignored. */
360
- function providerSupplied(err, d) {
373
+ function providerSupplied(err, d, providerCall) {
374
+ const snake = (code) => typeof code === 'string' && /^[a-z][a-z0-9_]*$/.test(code);
375
+ // `provider.call` renders every refusal as a runtime envelope (`origin` set) whose `code` the runtime chose from its own
376
+ // table, so the code stands and `details` is the runtime's structured answer (not a provider's body). A code-less
377
+ // transport failure the client synthesized has no envelope and stays on the status map.
378
+ if (providerCall && err.origin !== undefined && err.origin !== 'provider' && snake(err.code)) {
379
+ const out = { code: err.code };
380
+ if (err.status === 503 || err.status === 504) {
381
+ out.next = err.retryable === true
382
+ ? 'Retry the command; if it persists, the machine or service named in the error is unavailable.'
383
+ : 'Read the error before retrying: the action may already have run.';
384
+ }
385
+ if (isRecord(err.details))
386
+ out.details = err.details;
387
+ if (err.param !== undefined)
388
+ out.field = err.param;
389
+ if (typeof err.retryAfterS === 'number')
390
+ out.retryAfterS = err.retryAfterS;
391
+ return out;
392
+ }
361
393
  if (err.origin !== 'provider' || d === undefined || d === null || typeof d !== 'object')
362
394
  return {};
363
395
  const out = {};
364
396
  // `details.status` is the provider's own HTTP status; the runtime's transport
365
397
  // diagnostics (`{status: null, code: 'timeout'}`) are not a provider's code.
366
- if (typeof d.status === 'number' && typeof d.code === 'string' && /^[a-z][a-z0-9_]*$/.test(d.code))
398
+ if (typeof d.status === 'number' && snake(d.code))
367
399
  out.code = d.code;
368
400
  if (typeof d.next === 'string' && d.next.trim() !== '')
369
401
  out.next = d.next;
@@ -5,7 +5,7 @@ import type { ErrorPayload } from './io.js';
5
5
  * after. Scalars (and nested objects) list as `- name: value` bullets, prose
6
6
  * fields render as paragraphs, and arrays become a count lead-in plus a table
7
7
  * or bullets. No root tag — the result is read as a continuation of the prompt. */
8
- export declare function renderResult(result: Record<string, unknown>, help: LeafHelp): string;
8
+ export declare function renderResult(result: Record<string, unknown>, help: Pick<LeafHelp, 'output'>): string;
9
9
  /** Render a structured failure as an instruction-shaped block: what broke, what
10
10
  * was received, and the recovery road sign — the same recovery info the JSON
11
11
  * payload carries, shaped for the model to act on. */
@@ -131,6 +131,10 @@ export function renderError(p) {
131
131
  lines.push(`http status: ${p.http_status}`);
132
132
  if (p.retry_after_s !== undefined)
133
133
  lines.push(`retry after: ${p.retry_after_s}s`);
134
+ // The runtime's structured answer (`machine_required`'s candidates): the caller acts on these values, so they are
135
+ // listed in full, arrays as a table.
136
+ if (p.details !== undefined && Object.keys(p.details).length > 0)
137
+ lines.push(renderResult(p.details, { output: [] }));
134
138
  if ('primaryCompleted' in p && p.primaryCompleted === true) {
135
139
  lines.push('primaryCompleted: true');
136
140
  lines.push(`Primary result (the command already completed):\n${fencedJson(p.result)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.459",
3
+ "version": "0.3.460",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",