@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/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)}`, "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) {
@@ -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 < this.maxRetries && !opts.signal?.aborted) {
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 new TimeoutError(`request ${opts.method} ${opts.path} was aborted by the caller's signal`, "Nothing to fix: the caller aborted it.");
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 < this.maxRetries) {
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 < this.maxRetries && !opts.signal?.aborted) {
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
- return this.request(req);
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 idle = new AbortController();
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
- 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;
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
- const dec = new TextDecoder();
1927
- let buf = "";
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
- reader = stream.getReader();
1931
- try {
1932
- 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++;
1933
2238
  arm();
1934
- let chunk;
2239
+ let retryAfterMs;
2240
+ let r;
1935
2241
  try {
1936
- 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
+ });
1937
2249
  }
1938
2250
  catch (err) {
2251
+ clearTimeout(timer);
1939
2252
  if (opts.signal?.aborted)
1940
- throw err;
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 (chunk.done)
1947
- break;
1948
- const value = chunk.value;
1949
- buf += dec.decode(value, { stream: true });
1950
- let sep;
1951
- while ((sep = buf.indexOf("\n\n")) >= 0) {
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
- parsed = JSON.parse(data);
2262
+ payload = text ? JSON.parse(text) : {};
1975
2263
  }
1976
2264
  catch { }
1977
- if (event === "stdout" || event === "stderr") {
1978
- const bytes = b64ToBytes(String(parsed["data"] ?? ""));
1979
- if (event === "stdout") {
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
- else if (event === "error") {
1992
- 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 }) });
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
- finally {
1998
- 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();
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 && silent)
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`, body: { port },
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
  }