@north-light/api 0.3.414 → 0.3.416
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/api/__tests__/deadline.test.d.ts +1 -0
- package/dist/api/__tests__/deadline.test.js +46 -0
- package/dist/api/client.d.ts +20 -0
- package/dist/api/client.js +129 -22
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/crons.d.ts +7 -1
- package/dist/api/dto/human-requests.d.ts +1 -1
- package/dist/api/dto/run-events.d.ts +1 -1
- package/dist/api/dto/usage.d.ts +27 -0
- package/dist/api/dto/usage.js +1 -0
- package/dist/api/errors.js +1 -1
- package/dist/api/index.d.ts +3 -2
- package/dist/api/index.js +2 -1
- package/dist/core/layout.d.ts +2 -0
- package/dist/core/layout.js +6 -0
- package/dist/shared/env.d.ts +6 -5
- package/dist/shared/env.js +14 -6
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/client.js
CHANGED
|
@@ -37,7 +37,19 @@ export async function waitForDaemonAvailability({ windowMs, probe, initialError,
|
|
|
37
37
|
await sleep(Math.min(pollIntervalMs, afterProbe));
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
898
|
-
|
|
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
|
|
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
|
|
1240
|
+
await Promise.race([cancelled, new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, deadlineAt - Date.now())); })]);
|
|
1207
1241
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
}
|
package/dist/api/dto/common.d.ts
CHANGED
|
@@ -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
|
package/dist/api/dto/crons.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/api/errors.js
CHANGED
|
@@ -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. */
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/core/layout.d.ts
CHANGED
|
@@ -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
|
package/dist/core/layout.js
CHANGED
|
@@ -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/dist/shared/env.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** The current node's id (`CRTR_NODE_ID`), or undefined outside a node. */
|
|
2
2
|
export declare function envNodeId(): string | undefined;
|
|
3
3
|
/** Find the single launch snapshot mounted by the launcher into this process's
|
|
4
|
-
* Linux namespace. Cron bash has no CRTR_NODE_ID
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
|
|
4
|
+
* Linux namespace. Cron bash has no CRTR_NODE_ID: its own execution folder
|
|
5
|
+
* (`cron-<execution_id>`, holding `cron-launch.json` and the execution socket)
|
|
6
|
+
* supplies config and granted-command projection without claiming the cron is
|
|
7
|
+
* a node, and never falls back to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
|
|
9
8
|
* the caller-editable CRTR_API_SOCKET selects the launch identity. */
|
|
9
|
+
/** The launch snapshot a mounted run folder carries: a node's `broker-launch.json`, or a cron execution's `cron-launch.json`. */
|
|
10
|
+
export declare function launchSnapshotFile(dir: string): string | undefined;
|
|
10
11
|
export declare function sandboxCliNodeId(): string | undefined;
|
|
11
12
|
/** A Linux app command uses its mounted launch snapshot, not host user stores. */
|
|
12
13
|
export declare function isSandboxedNode(): boolean;
|
package/dist/shared/env.js
CHANGED
|
@@ -30,12 +30,20 @@ export function envNodeId() {
|
|
|
30
30
|
return process.env['CRTR_NODE_ID'];
|
|
31
31
|
}
|
|
32
32
|
/** Find the single launch snapshot mounted by the launcher into this process's
|
|
33
|
-
* Linux namespace. Cron bash has no CRTR_NODE_ID
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
|
|
33
|
+
* Linux namespace. Cron bash has no CRTR_NODE_ID: its own execution folder
|
|
34
|
+
* (`cron-<execution_id>`, holding `cron-launch.json` and the execution socket)
|
|
35
|
+
* supplies config and granted-command projection without claiming the cron is
|
|
36
|
+
* a node, and never falls back to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
|
|
38
37
|
* the caller-editable CRTR_API_SOCKET selects the launch identity. */
|
|
38
|
+
/** The launch snapshot a mounted run folder carries: a node's `broker-launch.json`, or a cron execution's `cron-launch.json`. */
|
|
39
|
+
export function launchSnapshotFile(dir) {
|
|
40
|
+
for (const name of ['broker-launch.json', 'cron-launch.json']) {
|
|
41
|
+
const path = join(dir, name);
|
|
42
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isFile())
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
39
47
|
export function sandboxCliNodeId() {
|
|
40
48
|
if (process.platform !== 'linux')
|
|
41
49
|
return undefined;
|
|
@@ -45,7 +53,7 @@ export function sandboxCliNodeId() {
|
|
|
45
53
|
const ids = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() &&
|
|
46
54
|
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(entry.name) &&
|
|
47
55
|
mounted.has(join(root, entry.name)) &&
|
|
48
|
-
|
|
56
|
+
launchSnapshotFile(join(root, entry.name)) !== undefined);
|
|
49
57
|
return ids.length === 1 ? ids[0].name : undefined;
|
|
50
58
|
}
|
|
51
59
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.416",
|
|
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",
|