@north-light/api 0.3.414 → 0.3.415

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ // CrtrClient: one absolute deadline per call (audit P2). It covers every attempt and every retry wait; a Retry-After
2
+ // longer than the time left fails at once with `deadline_exceeded` instead of sleeping past the deadline.
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import { CrtrClient, sleepWithin } from '../client.js';
6
+ import { ApiError } from '../errors.js';
7
+ const busy = (retryAfter) => new Response(JSON.stringify({ error: { code: 'service_unavailable', message: 'busy' } }), {
8
+ status: 503, headers: { 'content-type': 'application/json', ...(retryAfter ? { 'retry-after': retryAfter } : {}) },
9
+ });
10
+ test('a GET whose Retry-After is longer than the time left fails at once, typed, without sleeping', async () => {
11
+ let attempts = 0;
12
+ const client = new CrtrClient({ baseUrl: 'http://daemon.test', fetch: async () => { attempts++; return busy('120'); }, maxRetries: 3 });
13
+ const started = Date.now();
14
+ await assert.rejects(client.request('GET', '/v1/x', undefined, { budgetMs: 5_000 }), (error) => {
15
+ assert.ok(error instanceof ApiError);
16
+ assert.equal(error.code, 'deadline_exceeded');
17
+ assert.equal(error.status, 504);
18
+ const details = error.details;
19
+ assert.equal(details.reason, 'retry_after');
20
+ assert.equal(details.retry_after_s, 120);
21
+ assert.ok(details.deadline_at >= started + 4_000);
22
+ return true;
23
+ });
24
+ assert.ok(Date.now() - started < 1_000);
25
+ assert.equal(attempts, 1);
26
+ });
27
+ test('a delayed response ends at the deadline with deadline_exceeded, even from a fetch that ignores its signal', async () => {
28
+ const client = new CrtrClient({ baseUrl: 'http://daemon.test', fetch: () => new Promise(() => { }), timeoutMs: 60_000 });
29
+ const started = Date.now();
30
+ await assert.rejects(client.request('GET', '/v1/x', undefined, { deadlineAt: Date.now() + 120 }), (error) => error instanceof ApiError && error.code === 'deadline_exceeded');
31
+ assert.ok(Date.now() - started < 1_000);
32
+ });
33
+ test('with no deadline a Retry-After is not waited on: the plain backoff applies', async () => {
34
+ let attempts = 0;
35
+ const client = new CrtrClient({ baseUrl: 'http://daemon.test', fetch: async () => { attempts++; return attempts === 1 ? busy('120') : Response.json({ ok: true }); }, maxRetries: 1 });
36
+ const started = Date.now();
37
+ assert.deepEqual(await client.request('GET', '/v1/x'), { ok: true });
38
+ assert.ok(Date.now() - started < 5_000);
39
+ });
40
+ test('sleepWithin refuses a wait that reaches the deadline and stops early on an abort', async () => {
41
+ await assert.rejects(sleepWithin(10, { deadlineAt: Date.now() + 50 }, 60), (error) => error instanceof ApiError && error.code === 'deadline_exceeded');
42
+ const controller = new AbortController();
43
+ const waiting = sleepWithin(5_000, { signal: controller.signal });
44
+ setTimeout(() => controller.abort(), 20);
45
+ await assert.rejects(waiting, (error) => error instanceof ApiError && error.code === 'request_aborted');
46
+ });
@@ -1,3 +1,4 @@
1
+ import { ApiError } from './errors.js';
1
2
  import type { AppLimitsChangeDTO, LimitsDTO, SetAppLimitsRequest } from './dto/limits.js';
2
3
  import type { DaemonAdmitDTO, DaemonRestartDTO, DueDTO, HealthDTO, MigrateStateDTO, MigrateStateRequest, StatusDTO } from './dto/health.js';
3
4
  import type { BashJobStatusDTO, BashJobStopResultDTO } from './dto/bash-jobs.js';
@@ -88,7 +89,18 @@ export interface CrtrRequestOptions {
88
89
  * interrupted-response recovery — to one wall-clock budget, without changing
89
90
  * the client's default inactivity timeout. */
90
91
  budgetMs?: number;
92
+ /** The same limit as an absolute instant (epoch milliseconds), for a caller that already holds one deadline
93
+ * for a larger operation and passes it to every call inside it. With `budgetMs` too, the earlier wins. */
94
+ deadlineAt?: number;
91
95
  }
96
+ /** Why a call stopped at its deadline: `elapsed` — the time ran out; `retry_after` — the server asked for a wait
97
+ * longer than the time left, so the call stopped without sleeping. */
98
+ export type DeadlineReason = 'elapsed' | 'retry_after';
99
+ /** The one error a call raises when its deadline passes (or a wait would pass it): status 504, code `deadline_exceeded`,
100
+ * `details: { deadline_at, reason, retry_after_s? }`. */
101
+ /** The longest delay a timer accepts (2^31-1 ms, about 24.8 days); a longer deadline would fire at once. */
102
+ export declare const MAX_TIMER_MS = 2147483647;
103
+ export declare function deadlineExceeded(deadlineAt: number, reason?: DeadlineReason, retryAfterS?: number): ApiError;
92
104
  export declare class CrtrClient {
93
105
  private readonly baseUrl;
94
106
  private readonly fetch;
@@ -498,4 +510,12 @@ export declare class CrtrClient {
498
510
  * managed daemons still get the same bounded readiness window. */
499
511
  private handleColdSocket;
500
512
  }
513
+ /** One retry wait inside a call's deadline. A server-requested wait (`retryAfterS`) is honored only against a deadline:
514
+ * when it would not finish before the deadline the call fails now with `deadline_exceeded` (`reason: 'retry_after'`)
515
+ * instead of sleeping past it; a call without a deadline keeps the plain backoff. The sleep ends early, with the same
516
+ * error, if the caller aborts. */
517
+ export declare function sleepWithin(backoffMs: number, opts: {
518
+ deadlineAt?: number;
519
+ signal?: AbortSignal;
520
+ }, retryAfterS?: number, cause?: unknown): Promise<void>;
501
521
  export declare function safeColdStartDiagnostic(hook: (() => string | undefined) | undefined): string | undefined;
@@ -37,7 +37,19 @@ export async function waitForDaemonAvailability({ windowMs, probe, initialError,
37
37
  await sleep(Math.min(pollIntervalMs, afterProbe));
38
38
  }
39
39
  }
40
- const budgetExhausted = () => Object.assign(new Error('request budget exhausted'), { code: 'ETIMEDOUT' });
40
+ /** The one error a call raises when its deadline passes (or a wait would pass it): status 504, code `deadline_exceeded`,
41
+ * `details: { deadline_at, reason, retry_after_s? }`. */
42
+ /** The longest delay a timer accepts (2^31-1 ms, about 24.8 days); a longer deadline would fire at once. */
43
+ export const MAX_TIMER_MS = 2_147_483_647;
44
+ export function deadlineExceeded(deadlineAt, reason = 'elapsed', retryAfterS) {
45
+ const message = reason === 'retry_after'
46
+ ? `the server asked to retry after ${retryAfterS}s, which is later than the call's deadline (${new Date(deadlineAt).toISOString()}); not waiting`
47
+ : `the call's deadline (${new Date(deadlineAt).toISOString()}) passed`;
48
+ return new ApiError(504, 'deadline_exceeded', message, {
49
+ deadline_at: deadlineAt, reason, ...(retryAfterS === undefined ? {} : { retry_after_s: retryAfterS }),
50
+ });
51
+ }
52
+ const budgetExhausted = (deadlineAt) => deadlineExceeded(deadlineAt ?? Date.now());
41
53
  export class CrtrClient {
42
54
  baseUrl;
43
55
  fetch;
@@ -831,7 +843,10 @@ export class CrtrClient {
831
843
  * autostart + retry + error-mapping semantics. */
832
844
  async request(method, path, body, opts) {
833
845
  try {
834
- return await parse(await this.send(method, path, body, opts));
846
+ // The deadline is fixed once here so the headers, the retries and the reading of the body share it.
847
+ const resolved = opts === undefined ? undefined : this.resolveBudget(opts);
848
+ const response = await this.send(method, path, body, resolved);
849
+ return await parse(response, resolved?.deadlineAt);
835
850
  }
836
851
  catch (error) {
837
852
  throw toTransportApiError(error, opts?.signal);
@@ -866,6 +881,9 @@ export class CrtrClient {
866
881
  response = await this.transport(method, path, body, opts);
867
882
  }
868
883
  catch (recoveryError) {
884
+ // The caller's deadline, not the last probe failure, is why the call stopped when it has passed.
885
+ if (opts.deadlineAt !== undefined && Date.now() >= opts.deadlineAt && !isAbortError(recoveryError, opts.signal))
886
+ throw deadlineExceeded(opts.deadlineAt);
869
887
  throw toTransportApiError(recoveryError, opts.signal);
870
888
  }
871
889
  }
@@ -873,7 +891,7 @@ export class CrtrClient {
873
891
  return await this.rideOutInterruptedRequest(method, path, body, opts);
874
892
  }
875
893
  else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
876
- await sleepMs(retryDelayMs(attempt + 1));
894
+ await sleepWithin(retryDelayMs(attempt + 1), opts);
877
895
  continue;
878
896
  }
879
897
  else {
@@ -881,8 +899,8 @@ export class CrtrClient {
881
899
  }
882
900
  }
883
901
  if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
884
- await drainResponseBody(response);
885
- await sleepMs(retryDelayMs(attempt + 1));
902
+ await drainResponseBody(response, opts.deadlineAt);
903
+ await sleepWithin(retryDelayMs(attempt + 1), opts, retryAfterHeaderS(response));
886
904
  continue;
887
905
  }
888
906
  return response;
@@ -890,24 +908,26 @@ export class CrtrClient {
890
908
  }
891
909
  // internals
892
910
  resolveBudget(opts) {
893
- const { budgetMs, ...rest } = opts;
894
- if (budgetMs === undefined)
911
+ const { budgetMs, deadlineAt, ...rest } = opts;
912
+ if (budgetMs === undefined && deadlineAt === undefined)
895
913
  return rest;
896
- if (!Number.isFinite(budgetMs) || budgetMs <= 0)
897
- throw new RangeError('budgetMs must be a positive finite number');
898
- return { ...rest, deadlineAt: Date.now() + budgetMs };
914
+ if (budgetMs !== undefined && (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > MAX_TIMER_MS))
915
+ throw new RangeError(`budgetMs must be a positive finite number of at most ${MAX_TIMER_MS}`);
916
+ if (deadlineAt !== undefined && (!Number.isFinite(deadlineAt) || deadlineAt - Date.now() > MAX_TIMER_MS))
917
+ throw new RangeError(`deadlineAt must be a finite epoch-millisecond instant at most ${MAX_TIMER_MS} ms from now`);
918
+ return { ...rest, deadlineAt: Math.min(deadlineAt ?? Infinity, budgetMs === undefined ? Infinity : Date.now() + budgetMs) };
899
919
  }
900
920
  async awaitWithinBudget(promise, deadlineAt) {
901
921
  if (deadlineAt === undefined)
902
922
  return promise;
903
923
  const remainingMs = deadlineAt - Date.now();
904
924
  if (remainingMs <= 0)
905
- throw budgetExhausted();
925
+ throw budgetExhausted(deadlineAt);
906
926
  let timer;
907
927
  try {
908
928
  return await Promise.race([
909
929
  promise,
910
- new Promise((_resolve, reject) => { timer = setTimeout(() => reject(budgetExhausted()), remainingMs); }),
930
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(budgetExhausted(deadlineAt)), remainingMs); }),
911
931
  ]);
912
932
  }
913
933
  finally {
@@ -994,7 +1014,7 @@ export class CrtrClient {
994
1014
  transport(method, path, body, opts = {}) {
995
1015
  const remainingBudgetMs = opts.deadlineAt === undefined ? undefined : opts.deadlineAt - Date.now();
996
1016
  if (remainingBudgetMs !== undefined && remainingBudgetMs <= 0)
997
- return Promise.reject(budgetExhausted());
1017
+ return Promise.reject(budgetExhausted(opts.deadlineAt));
998
1018
  const payload = body === undefined ? undefined : JSON.stringify(body);
999
1019
  const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
1000
1020
  if (payload !== undefined)
@@ -1004,8 +1024,10 @@ export class CrtrClient {
1004
1024
  ? baseTimeoutMs
1005
1025
  : baseTimeoutMs > 0 ? Math.min(baseTimeoutMs, remainingBudgetMs) : remainingBudgetMs;
1006
1026
  const controller = new AbortController();
1027
+ // The deadline, not the per-attempt timeout, ended this attempt when it was the nearer of the two.
1028
+ const deadlineEnds = remainingBudgetMs !== undefined && (baseTimeoutMs <= 0 || remainingBudgetMs <= baseTimeoutMs);
1007
1029
  const timer = timeoutMs > 0
1008
- ? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
1030
+ ? setTimeout(() => controller.abort(deadlineEnds ? budgetExhausted(opts.deadlineAt) : new DOMException('request timed out', 'TimeoutError')), timeoutMs)
1009
1031
  : undefined;
1010
1032
  const externalSignal = opts.signal;
1011
1033
  const onExternalAbort = () => controller.abort(externalSignal?.reason);
@@ -1020,7 +1042,7 @@ export class CrtrClient {
1020
1042
  clearTimeout(timer);
1021
1043
  externalSignal?.removeEventListener('abort', onExternalAbort);
1022
1044
  };
1023
- return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
1045
+ return abortable(this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal }), controller.signal)
1024
1046
  .then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
1025
1047
  finish();
1026
1048
  throw error;
@@ -1096,7 +1118,7 @@ export class CrtrClient {
1096
1118
  async awaitAvailability(initialError, deadlineAt) {
1097
1119
  const remainingBudgetMs = deadlineAt === undefined ? undefined : deadlineAt - Date.now();
1098
1120
  if (remainingBudgetMs !== undefined && remainingBudgetMs <= 0)
1099
- throw budgetExhausted();
1121
+ throw budgetExhausted(deadlineAt);
1100
1122
  let startupFailure;
1101
1123
  try {
1102
1124
  await waitForDaemonAvailability({
@@ -1173,9 +1195,9 @@ function withQuery(base, query) {
1173
1195
  /** Parse a response into `T` — the first (and here, only) read of its body —
1174
1196
  * or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
1175
1197
  * (callers that type `void`/optional handle it). */
1176
- async function parse(res) {
1198
+ async function parse(res, deadlineAt) {
1177
1199
  const ok = res.status >= 200 && res.status < 300;
1178
- const trimmed = (await res.text()).trim();
1200
+ const trimmed = (await readTextWithin(res, deadlineAt)).trim();
1179
1201
  let payload;
1180
1202
  if (trimmed !== '') {
1181
1203
  try {
@@ -1201,12 +1223,49 @@ function isRetryableStatus(status) {
1201
1223
  /** A response abandoned mid-retry must have its stream drained, or the
1202
1224
  * underlying connection (and, over a unix socket, the daemon's handler) is
1203
1225
  * held open by a reader that will never arrive. */
1204
- async function drainResponseBody(res) {
1226
+ async function drainResponseBody(res, deadlineAt) {
1227
+ const cancelled = (async () => {
1228
+ try {
1229
+ await res.body?.cancel();
1230
+ }
1231
+ catch {
1232
+ // Best-effort — the response is being discarded either way.
1233
+ }
1234
+ })();
1235
+ // A body whose cancel never settles must not hold the call past its deadline: stop waiting for it then.
1236
+ if (deadlineAt === undefined)
1237
+ return cancelled;
1238
+ let timer;
1205
1239
  try {
1206
- await res.body?.cancel();
1240
+ await Promise.race([cancelled, new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, deadlineAt - Date.now())); })]);
1207
1241
  }
1208
- catch {
1209
- // Best-effort — the response is being discarded either way.
1242
+ finally {
1243
+ if (timer !== undefined)
1244
+ clearTimeout(timer);
1245
+ }
1246
+ }
1247
+ /** The response body as text, or `deadline_exceeded` once `deadlineAt` passes first (the body is then cancelled
1248
+ * without waiting for it). A transport that sends headers and then stalls cannot hold the call past its deadline. */
1249
+ async function readTextWithin(res, deadlineAt) {
1250
+ if (deadlineAt === undefined)
1251
+ return res.text();
1252
+ const remaining = deadlineAt - Date.now();
1253
+ const reading = res.text();
1254
+ reading.catch(() => { });
1255
+ if (remaining <= 0) {
1256
+ void res.body?.cancel().catch(() => { });
1257
+ throw deadlineExceeded(deadlineAt);
1258
+ }
1259
+ let timer;
1260
+ try {
1261
+ return await Promise.race([
1262
+ reading,
1263
+ new Promise((_resolve, reject) => { timer = setTimeout(() => { void res.body?.cancel().catch(() => { }); reject(deadlineExceeded(deadlineAt)); }, remaining); }),
1264
+ ]);
1265
+ }
1266
+ finally {
1267
+ if (timer !== undefined)
1268
+ clearTimeout(timer);
1210
1269
  }
1211
1270
  }
1212
1271
  /** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
@@ -1266,6 +1325,54 @@ function toTransportApiError(err, signal) {
1266
1325
  }
1267
1326
  return new ApiError(503, 'transport_error', message);
1268
1327
  }
1328
+ /** The `Retry-After` header as seconds (delta-seconds form only), else undefined. */
1329
+ function retryAfterHeaderS(response) {
1330
+ const seconds = Number(response.headers.get('retry-after'));
1331
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
1332
+ }
1333
+ /** One retry wait inside a call's deadline. A server-requested wait (`retryAfterS`) is honored only against a deadline:
1334
+ * when it would not finish before the deadline the call fails now with `deadline_exceeded` (`reason: 'retry_after'`)
1335
+ * instead of sleeping past it; a call without a deadline keeps the plain backoff. The sleep ends early, with the same
1336
+ * error, if the caller aborts. */
1337
+ export async function sleepWithin(backoffMs, opts, retryAfterS, cause) {
1338
+ const { deadlineAt, signal } = opts;
1339
+ if (signal?.aborted)
1340
+ throw new ApiError(0, 'request_aborted', 'request aborted by caller signal');
1341
+ const requestedMs = deadlineAt !== undefined && retryAfterS !== undefined ? retryAfterS * 1_000 : undefined;
1342
+ const waitMs = Math.max(backoffMs, requestedMs ?? 0);
1343
+ if (deadlineAt !== undefined && Date.now() + waitMs >= deadlineAt) {
1344
+ const error = deadlineExceeded(deadlineAt, requestedMs !== undefined && requestedMs >= backoffMs ? 'retry_after' : 'elapsed', requestedMs === undefined ? undefined : retryAfterS);
1345
+ if (cause !== undefined)
1346
+ Object.defineProperty(error, 'cause', { value: cause, configurable: true, writable: true });
1347
+ throw error;
1348
+ }
1349
+ if (signal === undefined)
1350
+ return sleepMs(waitMs);
1351
+ await new Promise((resolve, reject) => {
1352
+ const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(new ApiError(0, 'request_aborted', 'request aborted by caller signal')); };
1353
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, waitMs);
1354
+ signal.addEventListener('abort', onAbort, { once: true });
1355
+ });
1356
+ }
1357
+ /** `pending`, or its rejection with the signal's reason once the signal aborts first. A transport that ignores its
1358
+ * signal (a custom `fetch`) therefore still ends at the call's deadline; a response that arrives after the abort is
1359
+ * discarded and its body cancelled. */
1360
+ function abortable(pending, signal) {
1361
+ return new Promise((resolve, reject) => {
1362
+ const onAbort = () => reject(signal.reason);
1363
+ if (signal.aborted)
1364
+ onAbort();
1365
+ else
1366
+ signal.addEventListener('abort', onAbort, { once: true });
1367
+ pending.then((response) => {
1368
+ signal.removeEventListener('abort', onAbort);
1369
+ if (signal.aborted)
1370
+ void response.body?.cancel().catch(() => { });
1371
+ else
1372
+ resolve(response);
1373
+ }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
1374
+ });
1375
+ }
1269
1376
  function sleepMs(ms) {
1270
1377
  return new Promise((resolve) => setTimeout(resolve, ms));
1271
1378
  }
@@ -20,7 +20,7 @@ export type ModeDTO = 'base' | 'orchestrator';
20
20
  /** Why a node last stopped (mirrors the runtime `ExitIntent` union). */
21
21
  export type ExitIntentDTO = 'done' | 'refresh' | 'idle-release' | 'parked' | null;
22
22
  /** Why a terminal node ended (mirrors the runtime `TerminalReason` union). */
23
- export type TerminalReasonDTO = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded' | 'boot_failed' | 'crash_looped' | 'launch_failed' | 'context_overflow' | 'deadline_exceeded' | 'provider_fatal' | 'declined' | 'out_of_memory';
23
+ export type TerminalReasonDTO = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded' | 'boot_failed' | 'crash_looped' | 'launch_failed' | 'context_overflow' | 'deadline_exceeded' | 'provider_fatal' | 'declined' | 'out_of_memory' | 'cap_exhausted';
24
24
  /** Inbox urgency tier for a delivered message (mirrors feed/inbox `InboxTier`). */
25
25
  export type InboxTierDTO = 'critical' | 'urgent' | 'normal' | 'deferred';
26
26
  /** Whether a node id is a safe single filesystem path segment. Re-declared here
@@ -27,6 +27,12 @@ export interface CronDTO {
27
27
  created_by: NodeIdDTO | null;
28
28
  /** The bash command each fire runs. */
29
29
  command: string;
30
+ /** The grant the cron runs under (checked at every fire, call and birth); null only for the terminal on a runtime with no issuer. */
31
+ grant_id: string | null;
32
+ /** The scopes the cron is bounded to; null = the grant's whole current set (a person-armed terminal cron). */
33
+ scopes: string[] | null;
34
+ /** The run of the node that armed it, when a node did. */
35
+ origin_run_id: string | null;
30
36
  /** The shell and any spawned node stay isolated when armed from an isolated node. */
31
37
  isolated: boolean;
32
38
  /** Next occurrence (UTC). */
@@ -131,7 +137,7 @@ export interface ArmCronRequest {
131
137
  isolated?: boolean;
132
138
  /** Execution context, snapshotted at arm time — chosen by the client, else
133
139
  * inherited from the creating node; never re-resolved at run time. Omitted
134
- * means the daemon's default: the creating node's folder for an isolated
140
+ * means the daemon's default: the cron's own folder for an isolated
135
141
  * cron, else the app's files directory. A runtime whose cron runs are
136
142
  * sandboxed refuses (400 `cwd_not_mounted`) a chosen cwd the run's mounts do
137
143
  * not reach, so a cron is never armed with a directory it cannot start in. */
@@ -41,7 +41,7 @@ export interface CreateHumanRequestDTO {
41
41
  delivery_state: HumanRequestDeliveryState;
42
42
  }
43
43
  export interface HumanRequestDeliveryFailureDTO {
44
- kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
44
+ kind: 'exit' | 'signal' | 'timeout' | 'spawn_error' | 'grant_refused';
45
45
  exit_code?: number;
46
46
  signal?: string;
47
47
  message?: string;
@@ -57,7 +57,7 @@ export type RunOutcomeDTO = (OutcomeBase & {
57
57
  details: DeclinedResultDTO | null;
58
58
  }) | (OutcomeBase & {
59
59
  kind: 'failure';
60
- reason: 'canceled' | 'deadline_exceeded';
60
+ reason: 'canceled' | 'deadline_exceeded' | 'cap_exhausted';
61
61
  declined: null;
62
62
  }) | (OutcomeBase & {
63
63
  kind: 'failure';
@@ -0,0 +1,27 @@
1
+ /** One served model's month-to-date usage (`usage.get` `by_model`). */
2
+ export interface SpendUsageModelRowDTO {
3
+ served_model: string;
4
+ input_tokens: number;
5
+ output_tokens: number;
6
+ /** List-price cost; null when the served model has no list price (an app-registered endpoint, or an unpriced model). */
7
+ usd_equivalent: number | null;
8
+ }
9
+ /** `usage.get`: the caller's grantee's month-to-date spend (daemon operations, `usage.get`). */
10
+ export interface SpendUsageDTO {
11
+ grantee: string;
12
+ /** UTC calendar month, `YYYY-MM`. */
13
+ month: string;
14
+ /** User-paid spend this month: every model completion and provider receipt. Receipts have no `by_model` row, so this can exceed the sum of `by_model`. */
15
+ usd_equivalent: number;
16
+ /** User-paid model completions, one row per served model. */
17
+ by_model: SpendUsageModelRowDTO[];
18
+ /** App-paid usage; never counts toward `spend_cap`. */
19
+ app_paid: {
20
+ usd_equivalent: number;
21
+ by_model: SpendUsageModelRowDTO[];
22
+ };
23
+ /** The monthly cap on user-paid spend; once `usd_equivalent` reaches it, user-paid model calls answer `cap_exhausted` until `reset_at`. Null when uncapped. */
24
+ spend_cap: number | null;
25
+ /** 00:00Z on the first day of the next UTC month, when the count starts over. */
26
+ reset_at: string;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -51,7 +51,7 @@ export function isDaemonTransportApiError(error) {
51
51
  return false;
52
52
  return (error.status === 503 && (error.code === 'daemon_unavailable'
53
53
  || error.code === 'transport_error'
54
- || error.code === 'daemon_request_interrupted')) || (error.status === 504 && error.code === 'request_timeout')
54
+ || error.code === 'daemon_request_interrupted')) || (error.status === 504 && (error.code === 'request_timeout' || error.code === 'deadline_exceeded'))
55
55
  || (error.status >= 500 && error.code === 'daemon_health_unavailable');
56
56
  }
57
57
  /** Type guard for an `ErrorBody`-shaped parsed payload. */
@@ -1,5 +1,5 @@
1
- export { CrtrClient, waitForDaemonAvailability } from './client.js';
2
- export type { CrtrClientOptions, CrtrRequestOptions } from './client.js';
1
+ export { CrtrClient, deadlineExceeded, MAX_TIMER_MS, sleepWithin, waitForDaemonAvailability } from './client.js';
2
+ export type { CrtrClientOptions, CrtrRequestOptions, DeadlineReason } from './client.js';
3
3
  export { APIError, ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
5
5
  export { errorCodes } from './error-codes.js';
@@ -14,6 +14,7 @@ export * from './dto/common.js';
14
14
  export * from './dto/health.js';
15
15
  export * from './dto/limits.js';
16
16
  export * from './dto/grants.js';
17
+ export * from './dto/usage.js';
17
18
  export * from './dto/nodes.js';
18
19
  export * from './dto/node-outcomes.js';
19
20
  export * from './dto/node-events.js';
package/dist/api/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // `crouter/api` — the single source of truth for crtrd's API DTOs,
2
2
  // route constants, error contract, and the typed `CrtrClient`. Dependency-light
3
3
  // by design (spec §3.1): Node built-ins + `src/api/*` only.
4
- export { CrtrClient, waitForDaemonAvailability } from './client.js';
4
+ export { CrtrClient, deadlineExceeded, MAX_TIMER_MS, sleepWithin, waitForDaemonAvailability } from './client.js';
5
5
  export { APIError, ApiError, isErrorBody } from './errors.js';
6
6
  export { errorCodes } from './error-codes.js';
7
7
  export { API_VERSION, routes } from './routes.js';
@@ -12,6 +12,7 @@ export * from './dto/common.js';
12
12
  export * from './dto/health.js';
13
13
  export * from './dto/limits.js';
14
14
  export * from './dto/grants.js';
15
+ export * from './dto/usage.js';
15
16
  export * from './dto/nodes.js';
16
17
  export * from './dto/node-outcomes.js';
17
18
  export * from './dto/node-events.js';
@@ -22,6 +22,8 @@ export declare function appSpace(app: string): {
22
22
  home: string;
23
23
  cache: string;
24
24
  };
25
+ /** The folder an isolated cron runs in: `<app space>/crons/<cron_id>/`. It belongs to the cron, not to the node that armed it. */
26
+ export declare function cronFolder(app: string, cronId: string): string;
25
27
  /** The HOME an app's processes run with. With no sandbox (a Mac) the terminal is
26
28
  * the person at their own machine — its grant already holds all of the person's
27
29
  * files and nothing at the OS level confines its bash — so it gets the person's
@@ -63,6 +63,12 @@ export function appSpace(app) {
63
63
  const root = join(appsRoot(), id);
64
64
  return { root, memory: join(root, 'memory'), conversations: join(root, 'conversations'), nodes: join(root, 'nodes'), files: join(root, 'files'), home: join(root, 'home'), cache: join(root, 'cache') };
65
65
  }
66
+ /** The folder an isolated cron runs in: `<app space>/crons/<cron_id>/`. It belongs to the cron, not to the node that armed it. */
67
+ export function cronFolder(app, cronId) {
68
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(cronId))
69
+ throw new Error(`invalid cron id: ${cronId}`);
70
+ return join(appSpace(app).root, 'crons', cronId);
71
+ }
66
72
  /** The HOME an app's processes run with. With no sandbox (a Mac) the terminal is
67
73
  * the person at their own machine — its grant already holds all of the person's
68
74
  * files and nothing at the OS level confines its bash — so it gets the person's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.414",
3
+ "version": "0.3.415",
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",