@promigence/sdk 0.1.0-beta.15 → 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/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, ERRORS_DOCS_URL, FILE_PUT_CHUNK_BYTES, MAX_OUTPUT_BYTES_DEFAULT, VERSION, WAIT_TIMEOUT_MS, PreviewsUnavailableError, exitForCode, previewsOff, 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)}`, "Pass null for the organization's setting.");
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", "Pass null for the organization's setting.");
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("projects.update needs something to change", "Pass name, outputRetentionDays or failureCaptures.");
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) {
@@ -276,30 +304,6 @@ export function readCredentials(env = process.env) {
276
304
  function storedName(store, env) {
277
305
  return store === undefined || store === "file" ? `the credentials file (${credentialsPath(env)})` : storeLabel(store);
278
306
  }
279
- const COMPAT_REFUSED_HOSTS = /(^|\.)e2b(-staging)?\.(app|dev|pro)$/i;
280
- export function apiUrlFromCompatUrl(url) {
281
- const u = (url ?? "").trim();
282
- if (!u)
283
- return undefined;
284
- let host = "";
285
- try {
286
- host = new URL(u.includes("://") ? u : `https://${u}`).hostname;
287
- }
288
- catch {
289
- return undefined;
290
- }
291
- return COMPAT_REFUSED_HOSTS.test(host) ? undefined : u;
292
- }
293
- export function apiUrlFromCompatDomain(domain) {
294
- const d = (domain ?? "").trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
295
- if (!d || COMPAT_REFUSED_HOSTS.test(d))
296
- return undefined;
297
- return `https://api.${d}/v1`;
298
- }
299
- export function compatApiKey(key) {
300
- const k = key?.trim();
301
- return k && !k.startsWith("e2b_") ? k : undefined;
302
- }
303
307
  export function keyOrigin(env = process.env) {
304
308
  if (env["PROMIGENCE_API_KEY"]?.trim())
305
309
  return "PROMIGENCE_API_KEY";
@@ -326,12 +330,13 @@ function resolveCredentials(opts, env = process.env) {
326
330
  const inputs = optKey === undefined && envKey === undefined
327
331
  ? undefined
328
332
  : `${optKey ?? "\0"}\n${envKey ?? "\0"}\n${envUrl ?? "\0"}\n${opts.apiUrl ?? "\0"}`;
329
- if (inputs !== undefined && inputs === credsLastInputs && credsLast !== undefined)
330
- return { apiKey: credsLast.apiKey, apiUrl: credsLast.apiUrl };
333
+ if (inputs !== undefined && inputs === credsLastInputs && credsLast !== undefined) {
334
+ return { apiKey: credsLast.apiKey, apiUrl: credsLast.apiUrl, ...(credsLast.origin ? { origin: credsLast.origin } : {}) };
335
+ }
331
336
  const worked = resolveCredentialsFrom(opts, env, set, trim, optKey, envKey, envUrl);
332
337
  if (inputs !== undefined && worked.refusal === undefined) {
333
338
  credsLastInputs = inputs;
334
- credsLast = { apiKey: worked.apiKey, apiUrl: worked.apiUrl };
339
+ credsLast = { apiKey: worked.apiKey, apiUrl: worked.apiUrl, ...(worked.origin ? { origin: worked.origin } : {}) };
335
340
  }
336
341
  return worked;
337
342
  }
@@ -365,8 +370,15 @@ function resolveCredentialsFrom(opts, env, set, trim, optKey, envKey, envUrl) {
365
370
  if (refusal)
366
371
  return { apiKey: undefined, apiUrl, refusal };
367
372
  }
368
- return { apiKey: key, apiUrl };
373
+ const origin = !key ? undefined
374
+ : source === "opts" ? { source: "option" }
375
+ : source === "env" ? { source: "env" }
376
+ : source === "file" ? { source: "stored", ...(store === undefined ? {} : { store }) }
377
+ : { source: "fallback" };
378
+ return { apiKey: key, apiUrl, ...(origin ? { origin } : {}) };
369
379
  }
380
+ export const NO_KEY_MESSAGE = "no API key: none was given, PROMIGENCE_API_KEY is not set, and none is stored";
381
+ export const NO_KEY_FIX = "Pass { apiKey } or set PROMIGENCE_API_KEY. No key yet: `promigence login` signs you up or in and stores one.";
370
382
  function keyRefusal(key, from) {
371
383
  if (/^[\x21-\x7E]+$/.test(key))
372
384
  return undefined;
@@ -646,6 +658,8 @@ export function snapshotCreateBody(opts) {
646
658
  }
647
659
  if (o.alias)
648
660
  body["alias"] = o.alias;
661
+ if (o.alias && o.moveAlias)
662
+ body["move_alias"] = true;
649
663
  if (o.grade) {
650
664
  if (!o.grade.cmd)
651
665
  throw new InvalidArgumentError("snapshots.create grade needs a cmd", "Pass { grade: { cmd: 'pytest -q tests/' } }.");
@@ -680,6 +694,9 @@ const REFUSED_503_CODES = new Set([
680
694
  "builder_unavailable", "builder_busy", "overloaded", "tier_unavailable", "registry_unavailable",
681
695
  "secrets_unavailable",
682
696
  ]);
697
+ const SDK_FIXES = {
698
+ cap_exceeded: "Raise `spendCap`, lower `timeoutMs` or `count`, or pass `allowPartial` to launch what fits.",
699
+ };
683
700
  function withoutCliHelp(fix) {
684
701
  const out = fix.replace(/\s*See `promigence [^`]*--help`\.?\s*$/, "").trim();
685
702
  if (out === fix.trim())
@@ -689,7 +706,7 @@ function withoutCliHelp(fix) {
689
706
  export function errorFor(code, message, fix, extra = {}) {
690
707
  const attach = (e) => {
691
708
  const w = e;
692
- w.docs = extra.docs ?? `${ERRORS_DOCS_URL}#${code}`;
709
+ w.docs = extra.docs !== undefined && e.code === code ? extra.docs : errorsDocsFor(e.code);
693
710
  if (extra.status !== undefined)
694
711
  w.status = extra.status;
695
712
  if (extra.requestId !== undefined)
@@ -705,6 +722,8 @@ export function errorFor(code, message, fix, extra = {}) {
705
722
  if ((code === "usage" || code === "invalid_argument" || code === "unsupported") && previewsOff(message)) {
706
723
  return attach(new PreviewsUnavailableError());
707
724
  }
725
+ if (!extra.cli && Object.prototype.hasOwnProperty.call(SDK_FIXES, code))
726
+ fix = SDK_FIXES[code];
708
727
  switch (code) {
709
728
  case "auth": return attach(new AuthError(message, fix));
710
729
  case "not_found": return attach(new NotFoundError(message, fix));
@@ -718,8 +737,13 @@ export function errorFor(code, message, fix, extra = {}) {
718
737
  case "not_verified": return attach(new NotVerifiedError(message, fix));
719
738
  case "timeout": return attach(new TimeoutError(message, fix));
720
739
  case "usage":
721
- case "invalid_argument":
740
+ case "invalid_argument": {
741
+ const ended = code === "usage" ? sandboxEndedIn(message) : undefined;
742
+ if (ended !== undefined) {
743
+ return attach(new SandboxEndedError(message, ended.sandboxId, ended.state, extra.cli ? fix : SANDBOX_ENDED_FIX));
744
+ }
722
745
  return attach(new InvalidArgumentError(message, extra.cli ? fix : withoutCliHelp(fix), code));
746
+ }
723
747
  case "not_released": return attach(new NotReleasedError(message));
724
748
  case "service_restart": return attach(new ServiceRestartError(message, fix));
725
749
  default:
@@ -772,6 +796,76 @@ function parseRetryAfter(header) {
772
796
  const MAX_RETRY_SLEEP_MS = 10_000;
773
797
  const STREAM_IDLE_MS = 90_000;
774
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
+ }
775
869
  const POLLING_TICK_MS = 5;
776
870
  const PARKED_TICK_MS = 50;
777
871
  async function keepingLoopAwake(everyMs, fn) {
@@ -790,6 +884,21 @@ const WAIT_MS_CAP = 30_000;
790
884
  export const LONG_POLL_MS = 5_000;
791
885
  const BUILD_POLL_MS = 10_000;
792
886
  const PROGRESS_POLL_MS = 200;
887
+ export const RUN_SETTLE_MS = 5_000;
888
+ export function runWorkDone(run, endedSince, now = Date.now()) {
889
+ if (run.state === "completed" || run.state === "cap_killed" || run.state === "aborted")
890
+ return true;
891
+ const sbs = Array.isArray(run.sandboxes) ? run.sandboxes : [];
892
+ const allDone = sbs.length > 0 && sbs.every((s) => s.state === "ended" || s.command_outcome != null);
893
+ if (!allDone) {
894
+ delete endedSince.at;
895
+ return false;
896
+ }
897
+ if (sbs.some((s) => s.state !== "ended"))
898
+ return true;
899
+ endedSince.at ??= now;
900
+ return now - endedSince.at >= RUN_SETTLE_MS;
901
+ }
793
902
  const WAITED_HEADER = "promigence-waited-ms";
794
903
  const seg = (s) => encodeURIComponent(s).replace(/%40/g, "@");
795
904
  function waitQuery(waitMs) {
@@ -827,6 +936,9 @@ export class PromigenceClient {
827
936
  apiUrl;
828
937
  #apiKey;
829
938
  #keyRefusal;
939
+ #keyOrigin;
940
+ #onKeyUse;
941
+ #keyUseSaid = false;
830
942
  #bearer;
831
943
  fetchImpl;
832
944
  ownFetch;
@@ -848,6 +960,8 @@ export class PromigenceClient {
848
960
  const resolved = resolveCredentials(opts);
849
961
  this.#apiKey = resolved.apiKey;
850
962
  this.#keyRefusal = resolved.refusal;
963
+ this.#keyOrigin = resolved.origin;
964
+ this.#onKeyUse = opts.onKeyUse;
851
965
  this.apiUrl = resolved.apiUrl;
852
966
  const env = opts.fetch === undefined ? transportEnv() : undefined;
853
967
  const proxy = env !== undefined ? proxyFor(this.apiUrl, env) : undefined;
@@ -913,11 +1027,18 @@ export class PromigenceClient {
913
1027
  catch {
914
1028
  }
915
1029
  }
1030
+ get keyOrigin() {
1031
+ return this.#keyOrigin;
1032
+ }
916
1033
  requireKey() {
917
1034
  if (this.#keyRefusal)
918
1035
  throw this.#keyRefusal;
919
1036
  if (!this.#apiKey)
920
- throw new AuthError();
1037
+ throw this.cliErrors ? new AuthError(NO_KEY_MESSAGE) : new AuthError(NO_KEY_MESSAGE, NO_KEY_FIX);
1038
+ if (!this.#keyUseSaid && this.#onKeyUse !== undefined && this.#keyOrigin !== undefined) {
1039
+ this.#keyUseSaid = true;
1040
+ this.#onKeyUse(this.#keyOrigin);
1041
+ }
921
1042
  return this.#apiKey;
922
1043
  }
923
1044
  bearer() {
@@ -965,6 +1086,82 @@ export class PromigenceClient {
965
1086
  await sleep(backoff * (0.5 + Math.random() * 0.5));
966
1087
  return true;
967
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
+ }
968
1165
  async request(opts) {
969
1166
  const tr = traceBegin(opts.method, opts.path);
970
1167
  const url = `${this.apiUrl}${opts.path}`;
@@ -995,9 +1192,16 @@ export class PromigenceClient {
995
1192
  const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
996
1193
  let dispatcher;
997
1194
  let resentDelete = false;
1195
+ const maxRetries = opts.once === true ? 0 : this.maxRetries;
1196
+ let execKey;
998
1197
  for (let attempt = 0;; attempt++) {
999
1198
  let res;
1000
1199
  this.stats.requests++;
1200
+ if (opts.held !== undefined) {
1201
+ execKey = newExecKey();
1202
+ headers[EXEC_KEY_HEADER] = execKey;
1203
+ opts.held.onKey(execKey);
1204
+ }
1001
1205
  const sentAt = performance.now();
1002
1206
  try {
1003
1207
  if (this.h2) {
@@ -1040,11 +1244,14 @@ export class PromigenceClient {
1040
1244
  }
1041
1245
  catch (err) {
1042
1246
  const e = err;
1247
+ if (execKey !== undefined && !opts.signal?.aborted && !provedUnsent(err)) {
1248
+ throw new HeldDrop(execKey, transportDetail(e));
1249
+ }
1043
1250
  if (keyedCreate && idempotencyKey && await this.againKeyed(attempt, opts.signal))
1044
1251
  continue;
1045
1252
  const cutOff = e?.cause?.code === "UND_ERR_HEADERS_TIMEOUT" || e?.cause?.code === "UND_ERR_BODY_TIMEOUT";
1046
1253
  const timedOut = e?.name === "TimeoutError" || e?.name === "AbortError" || cutOff;
1047
- if (!timedOut && replayable && attempt < this.maxRetries && !opts.signal?.aborted) {
1254
+ if (!timedOut && replayable && attempt < maxRetries && !opts.signal?.aborted) {
1048
1255
  this.stats.retries++;
1049
1256
  if (opts.method === "DELETE")
1050
1257
  resentDelete = true;
@@ -1052,9 +1259,8 @@ export class PromigenceClient {
1052
1259
  await sleep(backoff * (0.5 + Math.random() * 0.5));
1053
1260
  continue;
1054
1261
  }
1055
- if (opts.signal?.aborted) {
1056
- throw new TimeoutError(`request ${opts.method} ${opts.path} was aborted by the caller's signal`, "Nothing to fix: the caller aborted it.");
1057
- }
1262
+ if (opts.signal?.aborted)
1263
+ throw abortedByCaller(opts.method, opts.path);
1058
1264
  if (e?.name === "TimeoutError" || e?.name === "AbortError") {
1059
1265
  throw new TimeoutError(`request ${opts.method} ${opts.path} timed out after ${timeoutMs} ms`, "Retry. To allow longer: promigence --request-timeout 120s <command>, " +
1060
1266
  "PROMIGENCE_REQUEST_TIMEOUT=120s, or requestTimeoutMs in the SDK. A --wait is bounded " +
@@ -1071,6 +1277,11 @@ export class PromigenceClient {
1071
1277
  const gateway = (res.status === 502 || res.status === 504) && replayable;
1072
1278
  let retryable = (res.status === 429 || res.status === 503 || gateway) && (retryAfterMs ?? 0) <= MAX_RETRY_SLEEP_MS;
1073
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
+ }
1074
1285
  if (retryable && res.status === 503 && !replayable && retryAfterMs === undefined) {
1075
1286
  body = await res.text().catch(() => "");
1076
1287
  retryable = REFUSED_503_CODES.has(errorCodeOf(body) ?? "");
@@ -1079,7 +1290,7 @@ export class PromigenceClient {
1079
1290
  body = await res.text().catch(() => "");
1080
1291
  retryable = errorCodeOf(body) === undefined;
1081
1292
  }
1082
- if (retryable && attempt < this.maxRetries) {
1293
+ if (retryable && attempt < maxRetries) {
1083
1294
  const backoff = Math.min(MAX_RETRY_SLEEP_MS, this.retryBaseMs * 2 ** attempt);
1084
1295
  const jitter = Math.random();
1085
1296
  const waitMs = retryAfterMs !== undefined
@@ -1112,8 +1323,10 @@ export class PromigenceClient {
1112
1323
  text = "";
1113
1324
  else {
1114
1325
  const e = err;
1326
+ if (execKey !== undefined && !opts.signal?.aborted)
1327
+ throw new HeldDrop(execKey, transportDetail(e));
1115
1328
  const timedOut = e?.name === "TimeoutError" || e?.name === "AbortError";
1116
- if (!timedOut && replayable && attempt < this.maxRetries && !opts.signal?.aborted) {
1329
+ if (!timedOut && replayable && attempt < maxRetries && !opts.signal?.aborted) {
1117
1330
  this.stats.retries++;
1118
1331
  if (opts.method === "DELETE")
1119
1332
  resentDelete = true;
@@ -1239,10 +1452,12 @@ export class PromigenceClient {
1239
1452
  continue;
1240
1453
  }
1241
1454
  const code = payload.error?.code ?? codeForStatus(res.status);
1455
+ const rid = headerOf(res, "request-id") ?? headerOf(res, "x-request-id");
1242
1456
  throw errorFor(code, payload.error?.message ?? `HTTP ${res.status} from ${method} ${path}`, payload.error?.fix ?? "See the API response for details.", {
1243
1457
  status: res.status,
1244
1458
  ...bodyExtra(payload),
1245
1459
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
1460
+ ...(rid ? { requestId: rid } : {}),
1246
1461
  });
1247
1462
  }
1248
1463
  }
@@ -1322,10 +1537,12 @@ export class PromigenceClient {
1322
1537
  continue;
1323
1538
  }
1324
1539
  const code = payload.error?.code ?? codeForStatus(res.status);
1540
+ const rid = headerOf(res, "request-id") ?? headerOf(res, "x-request-id");
1325
1541
  throw errorFor(code, payload.error?.message ?? `HTTP ${res.status} from ${method} ${path}`, payload.error?.fix ?? "See the API response for details.", {
1326
1542
  status: res.status,
1327
1543
  ...bodyExtra(payload),
1328
1544
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
1545
+ ...(rid ? { requestId: rid } : {}),
1329
1546
  });
1330
1547
  }
1331
1548
  }
@@ -1486,6 +1703,7 @@ export class PromigenceClient {
1486
1703
  method: "PATCH", path: `/orgs/${orgSeg(org)}/projects/${encodeURIComponent(project)}`, body: projectUpdateBody(update),
1487
1704
  }),
1488
1705
  },
1706
+ update: async (org, update) => this.request({ method: "PATCH", path: `/orgs/${orgSeg(org)}`, body: orgUpdateBody(update) }),
1489
1707
  };
1490
1708
  }
1491
1709
  group_credits() {
@@ -1565,6 +1783,8 @@ export class PromigenceClient {
1565
1783
  body["network"] = opts.network;
1566
1784
  if (opts.optimizeFor !== undefined)
1567
1785
  body["optimize_for"] = opts.optimizeFor;
1786
+ if (opts.capture !== undefined)
1787
+ body["capture"] = opts.capture;
1568
1788
  if (opts.secrets?.length)
1569
1789
  body["secrets"] = opts.secrets;
1570
1790
  if (opts.labels && Object.keys(opts.labels).length)
@@ -1701,12 +1921,8 @@ export class PromigenceClient {
1701
1921
  return { sandboxes: r.value, timedOut: r.timedOut, waitedMs: r.waitedMs };
1702
1922
  },
1703
1923
  waitDone: async (runId, opts = {}) => {
1704
- const r = await this.waitLoop(opts, LONG_POLL_MS, { run_id: runId, state: "running" }, (read) => this.runs.get(runId, read), (run) => {
1705
- const terminal = run.state === "completed" || run.state === "cap_killed" || run.state === "aborted";
1706
- const allDone = Array.isArray(run.sandboxes) && run.sandboxes.length > 0 &&
1707
- run.sandboxes.every((s) => s.state === "ended" || s.command_outcome != null);
1708
- return terminal || allDone;
1709
- });
1924
+ const endedSince = {};
1925
+ const r = await this.waitLoop(opts, LONG_POLL_MS, { run_id: runId, state: "running" }, (read) => this.runs.get(runId, read), (run) => runWorkDone(run, endedSince));
1710
1926
  return { run: r.value, timedOut: r.timedOut, waitedMs: r.waitedMs };
1711
1927
  },
1712
1928
  };
@@ -1802,7 +2018,9 @@ export class PromigenceClient {
1802
2018
  req.idempotencyKey = opts.idempotencyKey;
1803
2019
  if (opts.signal)
1804
2020
  req.signal = opts.signal;
1805
- return this.request(req);
2021
+ if (opts.terminal)
2022
+ return this.request(req);
2023
+ return this.heldExec(sandboxId, req);
1806
2024
  },
1807
2025
  execStream: async (sandboxId, opts, on = {}) => {
1808
2026
  if (!opts.argv?.length && !opts.sh) {
@@ -1831,7 +2049,9 @@ export class PromigenceClient {
1831
2049
  body["timeout_ms"] = commandTimeoutMs(opts.timeoutMs) ?? 15 * 60_000;
1832
2050
  body["max_output_bytes"] = opts.maxOutputBytes ?? MAX_OUTPUT_BYTES_DEFAULT;
1833
2051
  const url = `${this.apiUrl}/sandboxes/${encodeURIComponent(sandboxId)}/execs`;
1834
- const idle = new AbortController();
2052
+ const keyed = opts.terminal !== true;
2053
+ let key;
2054
+ let idle = new AbortController();
1835
2055
  let silent = false;
1836
2056
  let reader;
1837
2057
  let timer;
@@ -1843,23 +2063,33 @@ export class PromigenceClient {
1843
2063
  reader?.cancel().catch(() => { });
1844
2064
  }, STREAM_IDLE_MS);
1845
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
+ };
1846
2073
  let stream;
1847
2074
  const bearer = `Bearer ${this.requireKey()}`;
1848
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
+ });
1849
2083
  for (let attempt = 0; stream === undefined; attempt++) {
1850
2084
  let r;
1851
2085
  this.stats.requests++;
2086
+ if (keyed)
2087
+ key = newExecKey();
1852
2088
  arm();
1853
2089
  try {
1854
2090
  r = await this.fetchImpl(url, {
1855
2091
  method: "POST",
1856
- headers: {
1857
- accept: "text/event-stream",
1858
- "content-type": "application/json",
1859
- "user-agent": this.userAgent,
1860
- authorization: bearer,
1861
- ...(this.caller ? { [CALLER_HEADER]: this.caller } : {}),
1862
- },
2092
+ headers: { ...headers(), ...(key === undefined ? {} : { [EXEC_KEY_HEADER]: key }) },
1863
2093
  body: JSON.stringify(body),
1864
2094
  signal: opts.signal ? AbortSignal.any([opts.signal, idle.signal]) : idle.signal,
1865
2095
  ...(dispatcher === undefined ? {} : { dispatcher }),
@@ -1869,7 +2099,10 @@ export class PromigenceClient {
1869
2099
  clearTimeout(timer);
1870
2100
  const e = err;
1871
2101
  const why = silent ? `no answer in ${STREAM_IDLE_MS / 1000} s` : transportDetail(e);
1872
- throw new UnreachableError(`cannot reach ${url}: ${why}`);
2102
+ const failed = new UnreachableError(`cannot reach ${url}: ${why}`);
2103
+ if (opts.signal?.aborted)
2104
+ return abandon(failed);
2105
+ throw failed;
1873
2106
  }
1874
2107
  if (r.ok && r.body) {
1875
2108
  stream = r.body;
@@ -1900,87 +2133,166 @@ export class PromigenceClient {
1900
2133
  const errOut = [];
1901
2134
  let exit;
1902
2135
  let failure;
1903
- const dec = new TextDecoder();
1904
- let buf = "";
2136
+ let holdS;
2137
+ const received = { stdout: 0, stderr: 0 };
1905
2138
  const lost = (how) => new UnreachableError(`the exec stream for sandbox ${sandboxId} ${how}`, "The connection dropped before the command finished; its result is unknown. " +
1906
2139
  `Read it by id (\`promigence exec output <exec_id> --sandbox ${hintArg(sandboxId)}\`) or re-run.`);
1907
- reader = stream.getReader();
1908
- try {
1909
- for (;;) {
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++;
1910
2238
  arm();
1911
- let chunk;
2239
+ let retryAfterMs;
2240
+ let r;
1912
2241
  try {
1913
- chunk = await reader.read();
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
+ });
1914
2249
  }
1915
2250
  catch (err) {
2251
+ clearTimeout(timer);
1916
2252
  if (opts.signal?.aborted)
1917
- throw err;
1918
- if (silent)
1919
- throw lost(`was silent for ${STREAM_IDLE_MS / 1000} s`);
1920
- const e = err;
1921
- throw lost(`was cut off (${e?.cause?.code ?? e?.message ?? "connection lost"})`);
2253
+ return abandon(err);
1922
2254
  }
1923
- if (chunk.done)
1924
- break;
1925
- const value = chunk.value;
1926
- buf += dec.decode(value, { stream: true });
1927
- let sep;
1928
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1929
- const block = buf.slice(0, sep);
1930
- buf = buf.slice(sep + 2);
1931
- let event = "";
1932
- let data = "";
1933
- for (const line of block.split("\n")) {
1934
- if (line.startsWith("event:"))
1935
- event = line.slice(6).trim();
1936
- else if (line.startsWith("data:"))
1937
- data = line.slice(5).replace(/^ /, "");
1938
- }
1939
- if (!event)
1940
- continue;
1941
- if (event === "heartbeat") {
1942
- on.heartbeat?.();
1943
- continue;
1944
- }
1945
- if (event === "reconnect") {
1946
- reader.cancel().catch(() => { });
1947
- 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.");
1948
- }
1949
- 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 = {};
1950
2261
  try {
1951
- parsed = JSON.parse(data);
2262
+ payload = text ? JSON.parse(text) : {};
1952
2263
  }
1953
2264
  catch { }
1954
- if (event === "stdout" || event === "stderr") {
1955
- const bytes = b64ToBytes(String(parsed["data"] ?? ""));
1956
- if (event === "stdout") {
1957
- out.push(bytes);
1958
- on.stdout?.(bytes);
1959
- }
1960
- else {
1961
- errOut.push(bytes);
1962
- on.stderr?.(bytes);
1963
- }
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");
1964
2268
  }
1965
- else if (event === "exit") {
1966
- exit = { ...(exit ?? {}), ...parsed };
1967
- }
1968
- else if (event === "error") {
1969
- failure = parsed;
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 }) });
1970
2271
  }
1971
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);
1972
2278
  }
1973
- }
1974
- finally {
1975
- clearTimeout(timer);
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();
1976
2288
  }
1977
2289
  if (failure) {
1978
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 }) });
1979
2291
  }
1980
- if (!exit && silent)
1981
- throw lost(`was silent for ${STREAM_IDLE_MS / 1000} s`);
1982
- if (!exit) {
2292
+ if (!exit)
1983
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");
1984
2296
  }
1985
2297
  const rawCode = exit["exit_code"];
1986
2298
  const signal = exit["signal"];
@@ -2101,12 +2413,14 @@ export class PromigenceClient {
2101
2413
  }),
2102
2414
  },
2103
2415
  ports: {
2104
- expose: (sandboxId, port) => {
2416
+ expose: (sandboxId, port, opts = {}) => {
2105
2417
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
2106
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.");
2107
2419
  }
2420
+ checkPreviewLength(opts.length);
2108
2421
  return withPreviewsFix(this.request({
2109
- method: "POST", path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports`, body: { port },
2422
+ method: "POST", path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports`,
2423
+ body: { port, ...(opts.length === undefined ? {} : { length: opts.length }) },
2110
2424
  }), sandboxId, port);
2111
2425
  },
2112
2426
  list: async (sandboxId) => {
@@ -2115,10 +2429,19 @@ export class PromigenceClient {
2115
2429
  }), sandboxId);
2116
2430
  return r.ports ?? [];
2117
2431
  },
2118
- revoke: (sandboxId, tokenOrPort) => this.request({
2119
- method: "DELETE",
2120
- path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/${encodeURIComponent(String(tokenOrPort))}`,
2121
- }),
2432
+ revoke: (sandboxId, tokenOrPort) => {
2433
+ const which = String(tokenOrPort).trim();
2434
+ return /^[0-9]+$/.test(which)
2435
+ ? this.request({
2436
+ method: "DELETE",
2437
+ path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/${which}`,
2438
+ })
2439
+ : this.request({
2440
+ method: "POST",
2441
+ path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/revoke`,
2442
+ body: { token: which },
2443
+ });
2444
+ },
2122
2445
  },
2123
2446
  ssh: {
2124
2447
  open: (sandboxId, opts) => {
@@ -2615,10 +2938,10 @@ export class PromigenceClient {
2615
2938
  throw new InvalidArgumentError("snapshots.delete needs a snapshot", "Pass a snap_ id, an alias, or alias@commit7.");
2616
2939
  return this.request({ method: "DELETE", path: `/snapshots/${seg(idOrAlias)}` });
2617
2940
  },
2618
- alias: (idOrAlias, alias) => this.request({
2941
+ alias: (idOrAlias, alias, opts = {}) => this.request({
2619
2942
  method: "PUT",
2620
2943
  path: `/snapshots/${seg(idOrAlias)}/alias`,
2621
- body: { alias },
2944
+ body: { alias, ...(alias !== null && opts.moveAlias ? { move_alias: true } : {}) },
2622
2945
  }),
2623
2946
  waitBuild: async (buildId, opts = {}) => {
2624
2947
  const r = await this.waitLoop(opts, BUILD_POLL_MS, { build_id: buildId, state: "running" }, (read) => this.snapshots.status(buildId, read), (b) => b.state !== "running" && b.state !== "queued");
@@ -2723,5 +3046,6 @@ export function toCommandResult(r) {
2723
3046
  stderr: r.stderr ?? "",
2724
3047
  ...(r.truncated === undefined ? {} : { truncated: r.truncated }),
2725
3048
  ...(r.exec_id === undefined ? {} : { execId: r.exec_id }),
3049
+ timedOut: answerTimedOut(r),
2726
3050
  };
2727
3051
  }