@promigence/sdk 0.1.0-beta.16 → 0.1.0-beta.17
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/cjs/client.d.ts +123 -10
- package/dist/cjs/client.js +374 -82
- package/dist/cjs/context.d.ts +2 -1
- package/dist/cjs/context.js +5 -0
- package/dist/cjs/core.d.ts +25 -2
- package/dist/cjs/core.js +25 -4
- package/dist/cjs/handles.js +2 -1
- package/dist/cjs/http2.js +5 -0
- package/dist/cjs/models.gen.d.ts +205 -23
- package/dist/cjs/proxy.js +32 -3
- package/dist/cjs/sandbox.d.ts +12 -5
- package/dist/cjs/sandbox.js +1 -1
- package/dist/client.d.ts +123 -10
- package/dist/client.js +375 -83
- package/dist/context.d.ts +2 -1
- package/dist/context.js +5 -0
- package/dist/core.d.ts +25 -2
- package/dist/core.js +23 -3
- package/dist/handles.js +2 -1
- package/dist/http2.js +5 -0
- package/dist/models.gen.d.ts +205 -23
- package/dist/proxy.js +33 -4
- package/dist/sandbox.d.ts +12 -5
- package/dist/sandbox.js +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { inspect } from "node:util";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { ApiError, AuthError, EnvFailedError, PromigenceError, InvalidArgumentError, NotFoundError, NotReleasedError, NotVerifiedError, RateLimitError, ServiceRestartError, SpendCapError, TimeoutError, UnreachableError, DEFAULT_API_URL, FILE_PUT_CHUNK_BYTES, MAX_OUTPUT_BYTES_DEFAULT, VERSION, WAIT_TIMEOUT_MS, PreviewsUnavailableError, SANDBOX_ENDED_FIX, SandboxEndedError, errorsDocsFor, exitForCode, previewsOff, sandboxEndedIn, withPreviewsFix, } from "./core.js";
|
|
5
|
+
import { ApiError, AuthError, EnvFailedError, ExecLostError, PromigenceError, InvalidArgumentError, NotFoundError, NotReleasedError, NotVerifiedError, RateLimitError, ServiceRestartError, SpendCapError, TimeoutError, UnreachableError, DEFAULT_API_URL, FILE_PUT_CHUNK_BYTES, MAX_OUTPUT_BYTES_DEFAULT, VERSION, WAIT_TIMEOUT_MS, PreviewsUnavailableError, SANDBOX_ENDED_FIX, SandboxEndedError, errorsDocsFor, exitForCode, previewsOff, sandboxEndedIn, withPreviewsFix, } from "./core.js";
|
|
6
6
|
import { buildBundle } from "./bundle.js";
|
|
7
7
|
import { keychainReadCached, keychainStore, storeLabel } from "./credstore.js";
|
|
8
8
|
import { CALLER_HEADER, resolveCaller, userAgentWithCaller } from "./caller.js";
|
|
@@ -16,6 +16,7 @@ import { isKeyedCreate } from "./keyed.js";
|
|
|
16
16
|
import { customDispatcher, longRequestDispatcher } from "./dispatcher.js";
|
|
17
17
|
import { compromisedKeyCause, guardedFetchDispatcher } from "./ca-guard.js";
|
|
18
18
|
import { hintArg } from "./hint.js";
|
|
19
|
+
import { answerTimedOut } from "./command-errors.js";
|
|
19
20
|
import { GITHUB_REPO_PREFIX, githubCreateBody, } from "./github.js";
|
|
20
21
|
import { GITLAB_REPO_PREFIX, } from "./gitlab.js";
|
|
21
22
|
export const FILE_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -93,27 +94,46 @@ function pageQuery(limit, max, cursor) {
|
|
|
93
94
|
}
|
|
94
95
|
export const OUTPUT_RETENTION_MAX_DAYS = 30;
|
|
95
96
|
function projectUpdateBody(u) {
|
|
97
|
+
return settingsBody(u, "projects.update", "the organization's");
|
|
98
|
+
}
|
|
99
|
+
function orgUpdateBody(u) {
|
|
100
|
+
return settingsBody(u, "orgs.update", "the default");
|
|
101
|
+
}
|
|
102
|
+
function settingsBody(u, what, fallback) {
|
|
96
103
|
const body = {};
|
|
97
104
|
if (u.name !== undefined)
|
|
98
105
|
body["name"] = u.name;
|
|
99
106
|
if (u.outputRetentionDays !== undefined) {
|
|
100
107
|
const d = u.outputRetentionDays;
|
|
101
108
|
if (d !== null && (!Number.isInteger(d) || d < 1 || d > OUTPUT_RETENTION_MAX_DAYS)) {
|
|
102
|
-
throw new InvalidArgumentError(`outputRetentionDays must be a whole number from 1 to ${OUTPUT_RETENTION_MAX_DAYS}, or null, not ${String(d)}`,
|
|
109
|
+
throw new InvalidArgumentError(`outputRetentionDays must be a whole number from 1 to ${OUTPUT_RETENTION_MAX_DAYS}, or null, not ${String(d)}`, `Pass null for ${fallback} setting.`);
|
|
103
110
|
}
|
|
104
111
|
body["output_retention_days"] = d;
|
|
105
112
|
}
|
|
106
113
|
if (u.failureCaptures !== undefined) {
|
|
107
114
|
if (u.failureCaptures !== null && typeof u.failureCaptures !== "boolean") {
|
|
108
|
-
throw new InvalidArgumentError("failureCaptures must be true, false or null",
|
|
115
|
+
throw new InvalidArgumentError("failureCaptures must be true, false or null", `Pass null for ${fallback} setting.`);
|
|
109
116
|
}
|
|
110
117
|
if (Object.keys(body).length) {
|
|
111
118
|
throw new InvalidArgumentError("failureCaptures is set on its own", "Change it in a call of its own, without name or outputRetentionDays.");
|
|
112
119
|
}
|
|
113
120
|
body["failure_captures"] = u.failureCaptures;
|
|
114
121
|
}
|
|
122
|
+
if (u.previewLinkLength !== undefined) {
|
|
123
|
+
const l = u.previewLinkLength;
|
|
124
|
+
if (l === "custom") {
|
|
125
|
+
throw new InvalidArgumentError("custom domains for preview links are coming soon", `Pass "short", "long" or null (${fallback}) for now.`);
|
|
126
|
+
}
|
|
127
|
+
if (l !== null && l !== "short" && l !== "long") {
|
|
128
|
+
throw new InvalidArgumentError(`previewLinkLength must be "short", "long" or null, not ${JSON.stringify(l)}`, `Pass null for ${fallback} setting.`);
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(body).length) {
|
|
131
|
+
throw new InvalidArgumentError("previewLinkLength is set on its own", "Change it in a call of its own, without the other settings.");
|
|
132
|
+
}
|
|
133
|
+
body["preview_link_length"] = l;
|
|
134
|
+
}
|
|
115
135
|
if (!Object.keys(body).length) {
|
|
116
|
-
throw new InvalidArgumentError(
|
|
136
|
+
throw new InvalidArgumentError(`${what} needs something to change`, "Pass name, outputRetentionDays, failureCaptures or previewLinkLength.");
|
|
117
137
|
}
|
|
118
138
|
return body;
|
|
119
139
|
}
|
|
@@ -125,6 +145,14 @@ function expiryFields(o) {
|
|
|
125
145
|
body["expires_at"] = o.expiresAt;
|
|
126
146
|
return body;
|
|
127
147
|
}
|
|
148
|
+
function checkPreviewLength(length) {
|
|
149
|
+
if (length === undefined || length === "short" || length === "long")
|
|
150
|
+
return;
|
|
151
|
+
if (length === "custom") {
|
|
152
|
+
throw new InvalidArgumentError("custom domains for preview links are coming soon", "Pass length: \"short\" (the default) or \"long\" for now.");
|
|
153
|
+
}
|
|
154
|
+
throw new InvalidArgumentError(`length must be "short" or "long", got ${JSON.stringify(length)}`, "Omit it for the length your project or organization chose.");
|
|
155
|
+
}
|
|
128
156
|
export const NOTICE_HEADER = "promigence-notice";
|
|
129
157
|
export const MAX_SOURCES = 8;
|
|
130
158
|
export function configDir(env = process.env) {
|
|
@@ -768,6 +796,76 @@ function parseRetryAfter(header) {
|
|
|
768
796
|
const MAX_RETRY_SLEEP_MS = 10_000;
|
|
769
797
|
const STREAM_IDLE_MS = 90_000;
|
|
770
798
|
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
799
|
+
const EXEC_KEY_HEADER = "promigence-exec-key";
|
|
800
|
+
const EXEC_HOLD_DEFAULT_S = 120;
|
|
801
|
+
const REATTACH_SLACK_MS = 30_000;
|
|
802
|
+
const REATTACH_BACKOFF_MS = 250;
|
|
803
|
+
const REATTACH_BACKOFF_MAX_MS = 2_000;
|
|
804
|
+
const REATTACH_RETRY_AFTER_MAX_MS = 5_000;
|
|
805
|
+
const REATTACH_TAKEN_MS = 10_000;
|
|
806
|
+
const EXEC_CANCEL_TIMEOUT_MS = 3_000;
|
|
807
|
+
const REATTACH_AGAIN = new Set([429, 502, 503, 504]);
|
|
808
|
+
function newExecKey() {
|
|
809
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
810
|
+
let s = "";
|
|
811
|
+
for (const b of bytes)
|
|
812
|
+
s += b.toString(16).padStart(2, "0");
|
|
813
|
+
return s;
|
|
814
|
+
}
|
|
815
|
+
const UNSENT_CODES = /^(?:ECONNREFUSED|ENOTFOUND|EAI_[A-Z]+|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|ERR_TLS_[A-Z_]+|ERR_SSL_[A-Z_]+|CERT_[A-Z_]+|UNABLE_TO_[A-Z_]+|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|HOSTNAME_MISMATCH|EPROTO)$/;
|
|
816
|
+
function provedUnsent(err) {
|
|
817
|
+
if (compromisedKeyCause(err) !== undefined)
|
|
818
|
+
return true;
|
|
819
|
+
const top = err;
|
|
820
|
+
if (top?.name === "TypeError" && /header/i.test(top.message ?? ""))
|
|
821
|
+
return true;
|
|
822
|
+
const unsent = (x) => {
|
|
823
|
+
const code = x?.code;
|
|
824
|
+
return typeof code === "string" && UNSENT_CODES.test(code);
|
|
825
|
+
};
|
|
826
|
+
let c = err;
|
|
827
|
+
for (let depth = 0; c !== null && typeof c === "object" && depth < 6; depth++) {
|
|
828
|
+
if (unsent(c))
|
|
829
|
+
return true;
|
|
830
|
+
const each = c.errors;
|
|
831
|
+
if (Array.isArray(each) && each.length > 0 && each.every(unsent))
|
|
832
|
+
return true;
|
|
833
|
+
c = c.cause;
|
|
834
|
+
}
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
class HeldDrop extends Error {
|
|
838
|
+
key;
|
|
839
|
+
constructor(key, why) {
|
|
840
|
+
super(why);
|
|
841
|
+
this.key = key;
|
|
842
|
+
this.name = "HeldDrop";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function sleepUnless(ms, signal) {
|
|
846
|
+
if (signal === undefined)
|
|
847
|
+
return sleep(ms);
|
|
848
|
+
return new Promise((resolve) => {
|
|
849
|
+
if (signal.aborted) {
|
|
850
|
+
resolve();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const done = () => { clearTimeout(t); signal.removeEventListener("abort", done); resolve(); };
|
|
854
|
+
const t = setTimeout(done, ms);
|
|
855
|
+
signal.addEventListener("abort", done, { once: true });
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
function reattachWait(attempt, retryAfterMs) {
|
|
859
|
+
if (retryAfterMs !== undefined)
|
|
860
|
+
return Math.min(retryAfterMs, REATTACH_RETRY_AFTER_MAX_MS);
|
|
861
|
+
return Math.min(REATTACH_BACKOFF_MAX_MS, REATTACH_BACKOFF_MS * 2 ** Math.min(attempt, 8)) * (0.5 + Math.random() * 0.5);
|
|
862
|
+
}
|
|
863
|
+
function stoppedUnattended(answer) {
|
|
864
|
+
return answer?.reason === "not_reattached";
|
|
865
|
+
}
|
|
866
|
+
function abortedByCaller(method, path) {
|
|
867
|
+
return new TimeoutError(`request ${method} ${path} was aborted by the caller's signal`, "Nothing to fix: the caller aborted it.");
|
|
868
|
+
}
|
|
771
869
|
const POLLING_TICK_MS = 5;
|
|
772
870
|
const PARKED_TICK_MS = 50;
|
|
773
871
|
async function keepingLoopAwake(everyMs, fn) {
|
|
@@ -988,6 +1086,82 @@ export class PromigenceClient {
|
|
|
988
1086
|
await sleep(backoff * (0.5 + Math.random() * 0.5));
|
|
989
1087
|
return true;
|
|
990
1088
|
}
|
|
1089
|
+
async heldExec(sandboxId, req) {
|
|
1090
|
+
const signal = req.signal;
|
|
1091
|
+
const abortedBefore = signal?.aborted === true;
|
|
1092
|
+
let key;
|
|
1093
|
+
try {
|
|
1094
|
+
let res;
|
|
1095
|
+
try {
|
|
1096
|
+
res = await this.request({ ...req, held: { onKey: (k) => { key = k; } } });
|
|
1097
|
+
}
|
|
1098
|
+
catch (err) {
|
|
1099
|
+
if (!(err instanceof HeldDrop))
|
|
1100
|
+
throw err;
|
|
1101
|
+
res = await this.reattachBuffered(sandboxId, err.key, req);
|
|
1102
|
+
}
|
|
1103
|
+
if (stoppedUnattended(res)) {
|
|
1104
|
+
throw new ExecLostError(`the command on sandbox ${sandboxId} was stopped: its connection to the service dropped and no client came back for it in time`, { sandboxId }, "not_reattached");
|
|
1105
|
+
}
|
|
1106
|
+
return res;
|
|
1107
|
+
}
|
|
1108
|
+
catch (err) {
|
|
1109
|
+
if (key !== undefined && signal?.aborted && !abortedBefore)
|
|
1110
|
+
await this.cancelHeldExec(sandboxId, key);
|
|
1111
|
+
throw err;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async reattachBuffered(sandboxId, key, req) {
|
|
1115
|
+
const budgetMs = EXEC_HOLD_DEFAULT_S * 1000 + REATTACH_SLACK_MS;
|
|
1116
|
+
let droppedAt = Date.now();
|
|
1117
|
+
for (let attempt = 0;; attempt++) {
|
|
1118
|
+
if (req.signal?.aborted)
|
|
1119
|
+
throw abortedByCaller(req.method, req.path);
|
|
1120
|
+
this.stats.retries++;
|
|
1121
|
+
const sentAt = Date.now();
|
|
1122
|
+
try {
|
|
1123
|
+
return await this.request({
|
|
1124
|
+
method: "POST",
|
|
1125
|
+
path: `/sandboxes/${encodeURIComponent(sandboxId)}/execs/attach`,
|
|
1126
|
+
body: { key },
|
|
1127
|
+
once: true,
|
|
1128
|
+
...(req.timeoutMs === undefined ? {} : { timeoutMs: req.timeoutMs }),
|
|
1129
|
+
...(req.signal === undefined ? {} : { signal: req.signal }),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
if (req.signal?.aborted)
|
|
1134
|
+
throw abortedByCaller(req.method, req.path);
|
|
1135
|
+
const status = err instanceof PromigenceError ? err.status : undefined;
|
|
1136
|
+
if (status === 404) {
|
|
1137
|
+
throw new ExecLostError(`the connection to the service dropped before the command on sandbox ${sandboxId} answered, and it could not be picked up again: nothing is kept for it (it ended, it never started, or this service cannot keep it)`, { sandboxId }, "gone");
|
|
1138
|
+
}
|
|
1139
|
+
const lostConnection = status === undefined && (err instanceof UnreachableError || err instanceof TimeoutError);
|
|
1140
|
+
if (!lostConnection && !(status !== undefined && REATTACH_AGAIN.has(status)))
|
|
1141
|
+
throw err;
|
|
1142
|
+
if (Date.now() - sentAt >= REATTACH_TAKEN_MS)
|
|
1143
|
+
droppedAt = Date.now();
|
|
1144
|
+
const left = budgetMs - (Date.now() - droppedAt);
|
|
1145
|
+
if (left <= 0) {
|
|
1146
|
+
throw new ExecLostError(`the connection to the service dropped while the command on sandbox ${sandboxId} was running, and the service could not be reached again in time: the command is stopped ${EXEC_HOLD_DEFAULT_S} s after the drop`, { sandboxId }, "unreachable");
|
|
1147
|
+
}
|
|
1148
|
+
await sleepUnless(Math.min(left, reattachWait(attempt, err instanceof PromigenceError ? err.retryAfterMs : undefined)), req.signal);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
async cancelHeldExec(sandboxId, key) {
|
|
1153
|
+
try {
|
|
1154
|
+
await this.request({
|
|
1155
|
+
method: "POST",
|
|
1156
|
+
path: `/sandboxes/${encodeURIComponent(sandboxId)}/execs/cancel`,
|
|
1157
|
+
body: { key },
|
|
1158
|
+
timeoutMs: EXEC_CANCEL_TIMEOUT_MS,
|
|
1159
|
+
once: true,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
991
1165
|
async request(opts) {
|
|
992
1166
|
const tr = traceBegin(opts.method, opts.path);
|
|
993
1167
|
const url = `${this.apiUrl}${opts.path}`;
|
|
@@ -1018,9 +1192,16 @@ export class PromigenceClient {
|
|
|
1018
1192
|
const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
|
|
1019
1193
|
let dispatcher;
|
|
1020
1194
|
let resentDelete = false;
|
|
1195
|
+
const maxRetries = opts.once === true ? 0 : this.maxRetries;
|
|
1196
|
+
let execKey;
|
|
1021
1197
|
for (let attempt = 0;; attempt++) {
|
|
1022
1198
|
let res;
|
|
1023
1199
|
this.stats.requests++;
|
|
1200
|
+
if (opts.held !== undefined) {
|
|
1201
|
+
execKey = newExecKey();
|
|
1202
|
+
headers[EXEC_KEY_HEADER] = execKey;
|
|
1203
|
+
opts.held.onKey(execKey);
|
|
1204
|
+
}
|
|
1024
1205
|
const sentAt = performance.now();
|
|
1025
1206
|
try {
|
|
1026
1207
|
if (this.h2) {
|
|
@@ -1063,11 +1244,14 @@ export class PromigenceClient {
|
|
|
1063
1244
|
}
|
|
1064
1245
|
catch (err) {
|
|
1065
1246
|
const e = err;
|
|
1247
|
+
if (execKey !== undefined && !opts.signal?.aborted && !provedUnsent(err)) {
|
|
1248
|
+
throw new HeldDrop(execKey, transportDetail(e));
|
|
1249
|
+
}
|
|
1066
1250
|
if (keyedCreate && idempotencyKey && await this.againKeyed(attempt, opts.signal))
|
|
1067
1251
|
continue;
|
|
1068
1252
|
const cutOff = e?.cause?.code === "UND_ERR_HEADERS_TIMEOUT" || e?.cause?.code === "UND_ERR_BODY_TIMEOUT";
|
|
1069
1253
|
const timedOut = e?.name === "TimeoutError" || e?.name === "AbortError" || cutOff;
|
|
1070
|
-
if (!timedOut && replayable && attempt <
|
|
1254
|
+
if (!timedOut && replayable && attempt < maxRetries && !opts.signal?.aborted) {
|
|
1071
1255
|
this.stats.retries++;
|
|
1072
1256
|
if (opts.method === "DELETE")
|
|
1073
1257
|
resentDelete = true;
|
|
@@ -1075,9 +1259,8 @@ export class PromigenceClient {
|
|
|
1075
1259
|
await sleep(backoff * (0.5 + Math.random() * 0.5));
|
|
1076
1260
|
continue;
|
|
1077
1261
|
}
|
|
1078
|
-
if (opts.signal?.aborted)
|
|
1079
|
-
throw
|
|
1080
|
-
}
|
|
1262
|
+
if (opts.signal?.aborted)
|
|
1263
|
+
throw abortedByCaller(opts.method, opts.path);
|
|
1081
1264
|
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
1082
1265
|
throw new TimeoutError(`request ${opts.method} ${opts.path} timed out after ${timeoutMs} ms`, "Retry. To allow longer: promigence --request-timeout 120s <command>, " +
|
|
1083
1266
|
"PROMIGENCE_REQUEST_TIMEOUT=120s, or requestTimeoutMs in the SDK. A --wait is bounded " +
|
|
@@ -1094,6 +1277,11 @@ export class PromigenceClient {
|
|
|
1094
1277
|
const gateway = (res.status === 502 || res.status === 504) && replayable;
|
|
1095
1278
|
let retryable = (res.status === 429 || res.status === 503 || gateway) && (retryAfterMs ?? 0) <= MAX_RETRY_SLEEP_MS;
|
|
1096
1279
|
let body;
|
|
1280
|
+
if (execKey !== undefined && (res.status === 502 || res.status === 504)) {
|
|
1281
|
+
body = await res.text().catch(() => "");
|
|
1282
|
+
if (errorCodeOf(body) === undefined)
|
|
1283
|
+
throw new HeldDrop(execKey, `HTTP ${res.status}`);
|
|
1284
|
+
}
|
|
1097
1285
|
if (retryable && res.status === 503 && !replayable && retryAfterMs === undefined) {
|
|
1098
1286
|
body = await res.text().catch(() => "");
|
|
1099
1287
|
retryable = REFUSED_503_CODES.has(errorCodeOf(body) ?? "");
|
|
@@ -1102,7 +1290,7 @@ export class PromigenceClient {
|
|
|
1102
1290
|
body = await res.text().catch(() => "");
|
|
1103
1291
|
retryable = errorCodeOf(body) === undefined;
|
|
1104
1292
|
}
|
|
1105
|
-
if (retryable && attempt <
|
|
1293
|
+
if (retryable && attempt < maxRetries) {
|
|
1106
1294
|
const backoff = Math.min(MAX_RETRY_SLEEP_MS, this.retryBaseMs * 2 ** attempt);
|
|
1107
1295
|
const jitter = Math.random();
|
|
1108
1296
|
const waitMs = retryAfterMs !== undefined
|
|
@@ -1135,8 +1323,10 @@ export class PromigenceClient {
|
|
|
1135
1323
|
text = "";
|
|
1136
1324
|
else {
|
|
1137
1325
|
const e = err;
|
|
1326
|
+
if (execKey !== undefined && !opts.signal?.aborted)
|
|
1327
|
+
throw new HeldDrop(execKey, transportDetail(e));
|
|
1138
1328
|
const timedOut = e?.name === "TimeoutError" || e?.name === "AbortError";
|
|
1139
|
-
if (!timedOut && replayable && attempt <
|
|
1329
|
+
if (!timedOut && replayable && attempt < maxRetries && !opts.signal?.aborted) {
|
|
1140
1330
|
this.stats.retries++;
|
|
1141
1331
|
if (opts.method === "DELETE")
|
|
1142
1332
|
resentDelete = true;
|
|
@@ -1513,6 +1703,7 @@ export class PromigenceClient {
|
|
|
1513
1703
|
method: "PATCH", path: `/orgs/${orgSeg(org)}/projects/${encodeURIComponent(project)}`, body: projectUpdateBody(update),
|
|
1514
1704
|
}),
|
|
1515
1705
|
},
|
|
1706
|
+
update: async (org, update) => this.request({ method: "PATCH", path: `/orgs/${orgSeg(org)}`, body: orgUpdateBody(update) }),
|
|
1516
1707
|
};
|
|
1517
1708
|
}
|
|
1518
1709
|
group_credits() {
|
|
@@ -1592,6 +1783,8 @@ export class PromigenceClient {
|
|
|
1592
1783
|
body["network"] = opts.network;
|
|
1593
1784
|
if (opts.optimizeFor !== undefined)
|
|
1594
1785
|
body["optimize_for"] = opts.optimizeFor;
|
|
1786
|
+
if (opts.capture !== undefined)
|
|
1787
|
+
body["capture"] = opts.capture;
|
|
1595
1788
|
if (opts.secrets?.length)
|
|
1596
1789
|
body["secrets"] = opts.secrets;
|
|
1597
1790
|
if (opts.labels && Object.keys(opts.labels).length)
|
|
@@ -1825,7 +2018,9 @@ export class PromigenceClient {
|
|
|
1825
2018
|
req.idempotencyKey = opts.idempotencyKey;
|
|
1826
2019
|
if (opts.signal)
|
|
1827
2020
|
req.signal = opts.signal;
|
|
1828
|
-
|
|
2021
|
+
if (opts.terminal)
|
|
2022
|
+
return this.request(req);
|
|
2023
|
+
return this.heldExec(sandboxId, req);
|
|
1829
2024
|
},
|
|
1830
2025
|
execStream: async (sandboxId, opts, on = {}) => {
|
|
1831
2026
|
if (!opts.argv?.length && !opts.sh) {
|
|
@@ -1854,7 +2049,9 @@ export class PromigenceClient {
|
|
|
1854
2049
|
body["timeout_ms"] = commandTimeoutMs(opts.timeoutMs) ?? 15 * 60_000;
|
|
1855
2050
|
body["max_output_bytes"] = opts.maxOutputBytes ?? MAX_OUTPUT_BYTES_DEFAULT;
|
|
1856
2051
|
const url = `${this.apiUrl}/sandboxes/${encodeURIComponent(sandboxId)}/execs`;
|
|
1857
|
-
const
|
|
2052
|
+
const keyed = opts.terminal !== true;
|
|
2053
|
+
let key;
|
|
2054
|
+
let idle = new AbortController();
|
|
1858
2055
|
let silent = false;
|
|
1859
2056
|
let reader;
|
|
1860
2057
|
let timer;
|
|
@@ -1866,23 +2063,33 @@ export class PromigenceClient {
|
|
|
1866
2063
|
reader?.cancel().catch(() => { });
|
|
1867
2064
|
}, STREAM_IDLE_MS);
|
|
1868
2065
|
};
|
|
2066
|
+
const fresh = () => { idle = new AbortController(); silent = false; reader = undefined; };
|
|
2067
|
+
const abortedBefore = opts.signal?.aborted === true;
|
|
2068
|
+
const abandon = async (err) => {
|
|
2069
|
+
if (key !== undefined && !abortedBefore)
|
|
2070
|
+
await this.cancelHeldExec(sandboxId, key);
|
|
2071
|
+
throw err;
|
|
2072
|
+
};
|
|
1869
2073
|
let stream;
|
|
1870
2074
|
const bearer = `Bearer ${this.requireKey()}`;
|
|
1871
2075
|
const dispatcher = this.guardDispatcher();
|
|
2076
|
+
const headers = () => ({
|
|
2077
|
+
accept: "text/event-stream",
|
|
2078
|
+
"content-type": "application/json",
|
|
2079
|
+
"user-agent": this.userAgent,
|
|
2080
|
+
authorization: bearer,
|
|
2081
|
+
...(this.caller ? { [CALLER_HEADER]: this.caller } : {}),
|
|
2082
|
+
});
|
|
1872
2083
|
for (let attempt = 0; stream === undefined; attempt++) {
|
|
1873
2084
|
let r;
|
|
1874
2085
|
this.stats.requests++;
|
|
2086
|
+
if (keyed)
|
|
2087
|
+
key = newExecKey();
|
|
1875
2088
|
arm();
|
|
1876
2089
|
try {
|
|
1877
2090
|
r = await this.fetchImpl(url, {
|
|
1878
2091
|
method: "POST",
|
|
1879
|
-
headers: {
|
|
1880
|
-
accept: "text/event-stream",
|
|
1881
|
-
"content-type": "application/json",
|
|
1882
|
-
"user-agent": this.userAgent,
|
|
1883
|
-
authorization: bearer,
|
|
1884
|
-
...(this.caller ? { [CALLER_HEADER]: this.caller } : {}),
|
|
1885
|
-
},
|
|
2092
|
+
headers: { ...headers(), ...(key === undefined ? {} : { [EXEC_KEY_HEADER]: key }) },
|
|
1886
2093
|
body: JSON.stringify(body),
|
|
1887
2094
|
signal: opts.signal ? AbortSignal.any([opts.signal, idle.signal]) : idle.signal,
|
|
1888
2095
|
...(dispatcher === undefined ? {} : { dispatcher }),
|
|
@@ -1892,7 +2099,10 @@ export class PromigenceClient {
|
|
|
1892
2099
|
clearTimeout(timer);
|
|
1893
2100
|
const e = err;
|
|
1894
2101
|
const why = silent ? `no answer in ${STREAM_IDLE_MS / 1000} s` : transportDetail(e);
|
|
1895
|
-
|
|
2102
|
+
const failed = new UnreachableError(`cannot reach ${url}: ${why}`);
|
|
2103
|
+
if (opts.signal?.aborted)
|
|
2104
|
+
return abandon(failed);
|
|
2105
|
+
throw failed;
|
|
1896
2106
|
}
|
|
1897
2107
|
if (r.ok && r.body) {
|
|
1898
2108
|
stream = r.body;
|
|
@@ -1923,87 +2133,166 @@ export class PromigenceClient {
|
|
|
1923
2133
|
const errOut = [];
|
|
1924
2134
|
let exit;
|
|
1925
2135
|
let failure;
|
|
1926
|
-
|
|
1927
|
-
|
|
2136
|
+
let holdS;
|
|
2137
|
+
const received = { stdout: 0, stderr: 0 };
|
|
1928
2138
|
const lost = (how) => new UnreachableError(`the exec stream for sandbox ${sandboxId} ${how}`, "The connection dropped before the command finished; its result is unknown. " +
|
|
1929
2139
|
`Read it by id (\`promigence exec output <exec_id> --sandbox ${hintArg(sandboxId)}\`) or re-run.`);
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
2140
|
+
const consume = async (from) => {
|
|
2141
|
+
const dec = new TextDecoder();
|
|
2142
|
+
let buf = "";
|
|
2143
|
+
const rd = from.getReader();
|
|
2144
|
+
reader = rd;
|
|
2145
|
+
try {
|
|
2146
|
+
for (;;) {
|
|
2147
|
+
arm();
|
|
2148
|
+
let chunk;
|
|
2149
|
+
try {
|
|
2150
|
+
chunk = await rd.read();
|
|
2151
|
+
}
|
|
2152
|
+
catch (err) {
|
|
2153
|
+
if (opts.signal?.aborted)
|
|
2154
|
+
return abandon(err);
|
|
2155
|
+
if (holdS !== undefined && (exit || failure))
|
|
2156
|
+
return { done: true };
|
|
2157
|
+
if (silent)
|
|
2158
|
+
return { done: false, why: `was silent for ${STREAM_IDLE_MS / 1000} s`, restart: false };
|
|
2159
|
+
const e = err;
|
|
2160
|
+
return { done: false, why: `was cut off (${e?.cause?.code ?? e?.message ?? "connection lost"})`, restart: false };
|
|
2161
|
+
}
|
|
2162
|
+
if (chunk.done)
|
|
2163
|
+
break;
|
|
2164
|
+
const value = chunk.value;
|
|
2165
|
+
buf += dec.decode(value, { stream: true });
|
|
2166
|
+
let sep;
|
|
2167
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
2168
|
+
const block = buf.slice(0, sep);
|
|
2169
|
+
buf = buf.slice(sep + 2);
|
|
2170
|
+
let event = "";
|
|
2171
|
+
let data = "";
|
|
2172
|
+
for (const line of block.split("\n")) {
|
|
2173
|
+
if (line.startsWith("event:"))
|
|
2174
|
+
event = line.slice(6).trim();
|
|
2175
|
+
else if (line.startsWith("data:"))
|
|
2176
|
+
data = line.slice(5).replace(/^ /, "");
|
|
2177
|
+
}
|
|
2178
|
+
if (!event)
|
|
2179
|
+
continue;
|
|
2180
|
+
if (event === "heartbeat") {
|
|
2181
|
+
on.heartbeat?.();
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (event === "reconnect") {
|
|
2185
|
+
rd.cancel().catch(() => { });
|
|
2186
|
+
return { done: false, why: "was ended by a service restart", restart: true };
|
|
2187
|
+
}
|
|
2188
|
+
let parsed = {};
|
|
2189
|
+
try {
|
|
2190
|
+
parsed = JSON.parse(data);
|
|
2191
|
+
}
|
|
2192
|
+
catch { }
|
|
2193
|
+
if (event === "held") {
|
|
2194
|
+
const h = parsed["hold_s"];
|
|
2195
|
+
holdS = typeof h === "number" && Number.isFinite(h) && h >= 0 ? h : EXEC_HOLD_DEFAULT_S;
|
|
2196
|
+
}
|
|
2197
|
+
else if (event === "stdout" || event === "stderr") {
|
|
2198
|
+
const bytes = b64ToBytes(String(parsed["data"] ?? ""));
|
|
2199
|
+
if (event === "stdout") {
|
|
2200
|
+
received.stdout += bytes.length;
|
|
2201
|
+
out.push(bytes);
|
|
2202
|
+
on.stdout?.(bytes);
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
received.stderr += bytes.length;
|
|
2206
|
+
errOut.push(bytes);
|
|
2207
|
+
on.stderr?.(bytes);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
else if (event === "exit") {
|
|
2211
|
+
exit = { ...(exit ?? {}), ...parsed };
|
|
2212
|
+
}
|
|
2213
|
+
else if (event === "error") {
|
|
2214
|
+
failure = parsed;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
finally {
|
|
2220
|
+
clearTimeout(timer);
|
|
2221
|
+
}
|
|
2222
|
+
if (exit || failure)
|
|
2223
|
+
return { done: true };
|
|
2224
|
+
if (silent)
|
|
2225
|
+
return { done: false, why: `was silent for ${STREAM_IDLE_MS / 1000} s`, restart: false };
|
|
2226
|
+
return { done: false, why: "ended without an exit event", restart: false };
|
|
2227
|
+
};
|
|
2228
|
+
const reattach = async () => {
|
|
2229
|
+
const droppedAt = Date.now();
|
|
2230
|
+
const hold = holdS ?? EXEC_HOLD_DEFAULT_S;
|
|
2231
|
+
const budgetMs = hold * 1000 + REATTACH_SLACK_MS;
|
|
2232
|
+
for (let attempt = 0;; attempt++) {
|
|
2233
|
+
if (opts.signal?.aborted)
|
|
2234
|
+
return abandon(opts.signal.reason);
|
|
2235
|
+
fresh();
|
|
2236
|
+
this.stats.requests++;
|
|
2237
|
+
this.stats.retries++;
|
|
1933
2238
|
arm();
|
|
1934
|
-
let
|
|
2239
|
+
let retryAfterMs;
|
|
2240
|
+
let r;
|
|
1935
2241
|
try {
|
|
1936
|
-
|
|
2242
|
+
r = await this.fetchImpl(`${url}/attach`, {
|
|
2243
|
+
method: "POST",
|
|
2244
|
+
headers: headers(),
|
|
2245
|
+
body: JSON.stringify({ key, stdout_offset: received.stdout, stderr_offset: received.stderr }),
|
|
2246
|
+
signal: opts.signal ? AbortSignal.any([opts.signal, idle.signal]) : idle.signal,
|
|
2247
|
+
...(dispatcher === undefined ? {} : { dispatcher }),
|
|
2248
|
+
});
|
|
1937
2249
|
}
|
|
1938
2250
|
catch (err) {
|
|
2251
|
+
clearTimeout(timer);
|
|
1939
2252
|
if (opts.signal?.aborted)
|
|
1940
|
-
|
|
1941
|
-
if (silent)
|
|
1942
|
-
throw lost(`was silent for ${STREAM_IDLE_MS / 1000} s`);
|
|
1943
|
-
const e = err;
|
|
1944
|
-
throw lost(`was cut off (${e?.cause?.code ?? e?.message ?? "connection lost"})`);
|
|
2253
|
+
return abandon(err);
|
|
1945
2254
|
}
|
|
1946
|
-
if (
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
const block = buf.slice(0, sep);
|
|
1953
|
-
buf = buf.slice(sep + 2);
|
|
1954
|
-
let event = "";
|
|
1955
|
-
let data = "";
|
|
1956
|
-
for (const line of block.split("\n")) {
|
|
1957
|
-
if (line.startsWith("event:"))
|
|
1958
|
-
event = line.slice(6).trim();
|
|
1959
|
-
else if (line.startsWith("data:"))
|
|
1960
|
-
data = line.slice(5).replace(/^ /, "");
|
|
1961
|
-
}
|
|
1962
|
-
if (!event)
|
|
1963
|
-
continue;
|
|
1964
|
-
if (event === "heartbeat") {
|
|
1965
|
-
on.heartbeat?.();
|
|
1966
|
-
continue;
|
|
1967
|
-
}
|
|
1968
|
-
if (event === "reconnect") {
|
|
1969
|
-
reader.cancel().catch(() => { });
|
|
1970
|
-
throw new ServiceRestartError(`a service restart stopped the command on sandbox ${sandboxId} while its output was streaming`, "Not billed: the time the command ran is credited back. The sandbox kept running; the command may have done part of its work before it stopped: run it again if that is safe.");
|
|
1971
|
-
}
|
|
1972
|
-
let parsed = {};
|
|
2255
|
+
if (r !== undefined) {
|
|
2256
|
+
if (r.ok && r.body)
|
|
2257
|
+
return r.body;
|
|
2258
|
+
clearTimeout(timer);
|
|
2259
|
+
const text = await r.text().catch(() => "");
|
|
2260
|
+
let payload = {};
|
|
1973
2261
|
try {
|
|
1974
|
-
|
|
2262
|
+
payload = text ? JSON.parse(text) : {};
|
|
1975
2263
|
}
|
|
1976
2264
|
catch { }
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
out.push(bytes);
|
|
1981
|
-
on.stdout?.(bytes);
|
|
1982
|
-
}
|
|
1983
|
-
else {
|
|
1984
|
-
errOut.push(bytes);
|
|
1985
|
-
on.stderr?.(bytes);
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
else if (event === "exit") {
|
|
1989
|
-
exit = { ...(exit ?? {}), ...parsed };
|
|
2265
|
+
retryAfterMs = parseRetryAfter(r.headers.get("retry-after"));
|
|
2266
|
+
if (r.status === 404) {
|
|
2267
|
+
throw new ExecLostError(`the connection to the service dropped while the command on sandbox ${sandboxId} was running, and it could not be picked up again: it is no longer kept (it ended, or this service cannot keep it)`, { sandboxId }, "gone");
|
|
1990
2268
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
2269
|
+
if (!REATTACH_AGAIN.has(r.status)) {
|
|
2270
|
+
throw errorFor(payload.error?.code ?? codeForStatus(r.status), payload.error?.message ?? `HTTP ${r.status} from POST /sandboxes/${sandboxId}/execs/attach`, payload.error?.fix ?? "See the API response for details.", { status: r.status, ...bodyExtra(payload), ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
|
|
1993
2271
|
}
|
|
1994
2272
|
}
|
|
2273
|
+
const left = budgetMs - (Date.now() - droppedAt);
|
|
2274
|
+
if (left <= 0) {
|
|
2275
|
+
throw new ExecLostError(`the connection to the service dropped while the command on sandbox ${sandboxId} was running, and the service could not be reached again in time: the command is stopped ${hold} s after the drop`, { sandboxId }, "unreachable");
|
|
2276
|
+
}
|
|
2277
|
+
await sleepUnless(Math.min(left, reattachWait(attempt, retryAfterMs)), opts.signal);
|
|
1995
2278
|
}
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
|
|
2279
|
+
};
|
|
2280
|
+
for (let ended = await consume(stream); !ended.done; ended = await consume(stream)) {
|
|
2281
|
+
if (holdS === undefined || key === undefined) {
|
|
2282
|
+
if (ended.restart) {
|
|
2283
|
+
throw new ServiceRestartError(`a service restart stopped the command on sandbox ${sandboxId} while its output was streaming`, "Not billed: the time the command ran is credited back. The sandbox kept running; the command may have done part of its work before it stopped: run it again if that is safe.");
|
|
2284
|
+
}
|
|
2285
|
+
throw lost(ended.why);
|
|
2286
|
+
}
|
|
2287
|
+
stream = await reattach();
|
|
1999
2288
|
}
|
|
2000
2289
|
if (failure) {
|
|
2001
2290
|
throw errorFor(failure.code ?? "internal", failure.message ?? "the exec stream failed", failure.fix ?? "Re-run; if it repeats, include the exec_id.", { status: 200, ...bodyExtra({ error: failure }) });
|
|
2002
2291
|
}
|
|
2003
|
-
if (!exit
|
|
2004
|
-
throw lost(`was silent for ${STREAM_IDLE_MS / 1000} s`);
|
|
2005
|
-
if (!exit) {
|
|
2292
|
+
if (!exit)
|
|
2006
2293
|
throw lost("ended without an exit event");
|
|
2294
|
+
if (stoppedUnattended(exit)) {
|
|
2295
|
+
throw new ExecLostError(`the command on sandbox ${sandboxId} was stopped: its connection to the service dropped and no client came back for it ${holdS === undefined ? "in time" : `within ${holdS} s`}`, { sandboxId }, "not_reattached");
|
|
2007
2296
|
}
|
|
2008
2297
|
const rawCode = exit["exit_code"];
|
|
2009
2298
|
const signal = exit["signal"];
|
|
@@ -2124,12 +2413,14 @@ export class PromigenceClient {
|
|
|
2124
2413
|
}),
|
|
2125
2414
|
},
|
|
2126
2415
|
ports: {
|
|
2127
|
-
expose: (sandboxId, port) => {
|
|
2416
|
+
expose: (sandboxId, port, opts = {}) => {
|
|
2128
2417
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2129
2418
|
throw new InvalidArgumentError(`port must be an integer 1..65535, got ${port}`, "Pass the port your process listens on inside the sandbox, e.g. 3000.");
|
|
2130
2419
|
}
|
|
2420
|
+
checkPreviewLength(opts.length);
|
|
2131
2421
|
return withPreviewsFix(this.request({
|
|
2132
|
-
method: "POST", path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports`,
|
|
2422
|
+
method: "POST", path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports`,
|
|
2423
|
+
body: { port, ...(opts.length === undefined ? {} : { length: opts.length }) },
|
|
2133
2424
|
}), sandboxId, port);
|
|
2134
2425
|
},
|
|
2135
2426
|
list: async (sandboxId) => {
|
|
@@ -2755,5 +3046,6 @@ export function toCommandResult(r) {
|
|
|
2755
3046
|
stderr: r.stderr ?? "",
|
|
2756
3047
|
...(r.truncated === undefined ? {} : { truncated: r.truncated }),
|
|
2757
3048
|
...(r.exec_id === undefined ? {} : { execId: r.exec_id }),
|
|
3049
|
+
timedOut: answerTimedOut(r),
|
|
2758
3050
|
};
|
|
2759
3051
|
}
|