@aerol-ai/aerolvm-sdk 0.8.2 → 0.8.3

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.
@@ -6,6 +6,12 @@ type FetchLike = typeof fetch;
6
6
  * acts for. Keep in sync with pkg/api.HeaderTeam on the server.
7
7
  */
8
8
  export declare const TEAM_HEADER = "X-AerolVM-Team";
9
+ /**
10
+ * The response header a 421 carries when the sandbox lives in another cell
11
+ * (a separate AerolVM install in the same federation): that cell's base URL.
12
+ * Keep in sync with models.CellEndpointHeader on the server.
13
+ */
14
+ export declare const CELL_ENDPOINT_HEADER = "X-Aerolvm-Cell-Endpoint";
9
15
  /**
10
16
  * Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
11
17
  * client speaks. The SDK and the server version independently — bumping the
@@ -130,6 +136,15 @@ export declare class APIClient {
130
136
  * Refreshed on a miss, because a restart can bring a newer agent.
131
137
  */
132
138
  private readonly toolboxFeatures;
139
+ /**
140
+ * The base URL of the cell each sandbox in another cell lives in, learned
141
+ * from a 421 that named it, oldest first. Shared with {@link withSignal}
142
+ * views, so what one call learns routes the next. Entries are dropped only
143
+ * on a 404 from that cell (the sandbox may be gone) or past
144
+ * {@link MAX_REMEMBERED_CELLS}: the cell is minted into the ID, so it
145
+ * never changes.
146
+ */
147
+ private readonly sandboxCells;
133
148
  constructor(config: APIClientConfig);
134
149
  /**
135
150
  * Build a versioned API path. Pass the suffix beginning with "/" (e.g.
@@ -370,6 +385,7 @@ export declare class APIClient {
370
385
  private wrap;
371
386
  private doJSON;
372
387
  private doBytes;
388
+ private authHeaders;
373
389
  /**
374
390
  * Low-level HTTP request with automatic retry for transient failures.
375
391
  *
@@ -388,9 +404,24 @@ export declare class APIClient {
388
404
  * A 429 or 503 waits as long as its `Retry-After` asks (see
389
405
  * {@link retryDelayMs}). The client's `signal` ends the request and any
390
406
  * wait.
407
+ *
408
+ * A 421 that names another cell ({@link CELL_ENDPOINT_HEADER}) is not
409
+ * retried in place: the same request goes once to that cell instead, and
410
+ * later requests for that sandbox go straight there.
391
411
  */
392
- private authHeaders;
393
412
  private request;
413
+ /** Where requests for sandbox `id` go: the cell it was found in, else the configured server. */
414
+ private baseURLFor;
415
+ private rememberCell;
416
+ /**
417
+ * The cell base URL a 421 names, or `undefined` when it names none this
418
+ * client may send its token to: not an absolute http(s) URL, or plain http
419
+ * while the client was configured for https, which would let one bad
420
+ * answer move the token to clear text.
421
+ */
422
+ private cellEndpoint;
423
+ /** One request to `url` with the retry loop described on {@link request}. */
424
+ private send;
394
425
  }
395
426
  export declare class SandboxResource implements Sandbox {
396
427
  id: string;
@@ -10,6 +10,12 @@ import { Image } from "../Image.js";
10
10
  * acts for. Keep in sync with pkg/api.HeaderTeam on the server.
11
11
  */
12
12
  export const TEAM_HEADER = "X-AerolVM-Team";
13
+ /**
14
+ * The response header a 421 carries when the sandbox lives in another cell
15
+ * (a separate AerolVM install in the same federation): that cell's base URL.
16
+ * Keep in sync with models.CellEndpointHeader on the server.
17
+ */
18
+ export const CELL_ENDPOINT_HEADER = "X-Aerolvm-Cell-Endpoint";
13
19
  const DEFAULT_API_VERSION = "v1";
14
20
  const PATH_PREFIXES = {
15
21
  v1: V1_PATH_PREFIX,
@@ -145,6 +151,46 @@ const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
145
151
  // over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
146
152
  // 504 is a hop's, not the server's, so those use the normal backoff.
147
153
  const REFUSED_STATUS_CODES = new Set([429, 503]);
154
+ // Bounds the sandbox-to-cell map, so a long-lived client that touches many
155
+ // sandboxes in other cells cannot grow without end. A dropped entry costs
156
+ // only one more 421 the next time.
157
+ const MAX_REMEMBERED_CELLS = 10_000;
158
+ /**
159
+ * A 421 that names a cell is the cell router's answer, which sending to the
160
+ * same place again cannot change. The retry loop hands it back at once and
161
+ * {@link APIClient.request} decides whether to follow it.
162
+ */
163
+ function isCellAnswer(response) {
164
+ return response.status === 421 && (response.headers.get(CELL_ENDPOINT_HEADER) ?? "").trim() !== "";
165
+ }
166
+ // fetch reads a stream while it sends it, so only a body held whole in
167
+ // memory can go out a second time.
168
+ function isReplayableBody(body) {
169
+ return (body == null ||
170
+ typeof body === "string" ||
171
+ body instanceof ArrayBuffer ||
172
+ ArrayBuffer.isView(body) ||
173
+ body instanceof Blob ||
174
+ body instanceof FormData ||
175
+ body instanceof URLSearchParams);
176
+ }
177
+ /** The sandbox ID a per-sandbox path names: the segment right after `prefix` (".../sandboxes/"). */
178
+ function sandboxIDInPath(path, prefix) {
179
+ if (!path.startsWith(prefix))
180
+ return undefined;
181
+ const rest = path.slice(prefix.length);
182
+ const end = rest.search(/[/?#]/);
183
+ const segment = end === -1 ? rest : rest.slice(0, end);
184
+ if (segment === "")
185
+ return undefined;
186
+ // Some call sites escape the ID and some do not; decoding keys both alike.
187
+ try {
188
+ return decodeURIComponent(segment);
189
+ }
190
+ catch {
191
+ return segment;
192
+ }
193
+ }
148
194
  /**
149
195
  * The toolboxd feature (on /version) behind the file verbs' options: list
150
196
  * depth and cap, the move and copy overwrite guard, mkdir without parents and
@@ -367,6 +413,15 @@ export class APIClient {
367
413
  * Refreshed on a miss, because a restart can bring a newer agent.
368
414
  */
369
415
  toolboxFeatures = new Map();
416
+ /**
417
+ * The base URL of the cell each sandbox in another cell lives in, learned
418
+ * from a 421 that named it, oldest first. Shared with {@link withSignal}
419
+ * views, so what one call learns routes the next. Entries are dropped only
420
+ * on a 404 from that cell (the sandbox may be gone) or past
421
+ * {@link MAX_REMEMBERED_CELLS}: the cell is minted into the ID, so it
422
+ * never changes.
423
+ */
424
+ sandboxCells = new Map();
370
425
  constructor(config) {
371
426
  this.baseURL = stripTrailingSlashes(config.baseURL);
372
427
  Object.defineProperty(this, "patToken", { value: config.patToken ?? "", enumerable: false });
@@ -711,7 +766,7 @@ export class APIClient {
711
766
  // argv waits for the feature check: the start message goes out only once
712
767
  // the agent has listed exec.argv, so an older one never receives it.
713
768
  const ready = options.argv?.length ? this.requireToolboxFeatures(id, "exec.argv") : undefined;
714
- return openExecStream(this.baseURL, this.versionPrefix, this.patToken, id, options, ready);
769
+ return openExecStream(this.baseURLFor(id), this.versionPrefix, this.patToken, id, options, ready);
715
770
  }
716
771
  /**
717
772
  * Watch a folder in the sandbox for changes. Resolves once the watch is in
@@ -722,7 +777,7 @@ export class APIClient {
722
777
  async watchFiles(id, path, onEvent, options = {}) {
723
778
  const query = `path=${encodeURIComponent(path)}${options.recursive ? "&recursive=true" : ""}`;
724
779
  const routePath = `${this.versioned(`/sandboxes/${encodeURIComponent(id)}/toolbox/files/watch`)}?${query}`;
725
- return openFileWatch(this.baseURL, routePath, this.patToken, onEvent, (detail) => this.fileWatchRefusal(id, routePath, detail));
780
+ return openFileWatch(this.baseURLFor(id), routePath, this.patToken, onEvent, (detail) => this.fileWatchRefusal(id, routePath, detail));
726
781
  }
727
782
  /**
728
783
  * Explains a watch whose WebSocket handshake failed. The WebSocket API
@@ -737,7 +792,7 @@ export class APIClient {
737
792
  let response;
738
793
  let text;
739
794
  try {
740
- response = await this.fetchFn(`${this.baseURL}${routePath}`, {
795
+ response = await this.fetchFn(`${this.baseURLFor(id)}${routePath}`, {
741
796
  method: "GET",
742
797
  headers: this.authHeaders(),
743
798
  ...(this.signal ? { signal: this.signal } : {}),
@@ -793,7 +848,7 @@ export class APIClient {
793
848
  return this.doBytes(`${this.versionPrefix}/sandboxes/${id}/sessions/${sessionID}/recording`);
794
849
  }
795
850
  attachSession(id, sessionID, options = {}) {
796
- return openSessionAttach(this.baseURL, this.versionPrefix, this.patToken, id, sessionID, options);
851
+ return openSessionAttach(this.baseURLFor(id), this.versionPrefix, this.patToken, id, sessionID, options);
797
852
  }
798
853
  /**
799
854
  * Plain and atomic uploads retry on 421/429/502/503/504; `append` and
@@ -1440,6 +1495,16 @@ export class APIClient {
1440
1495
  }
1441
1496
  return new Uint8Array(await response.arrayBuffer());
1442
1497
  }
1498
+ authHeaders(base) {
1499
+ const headers = new Headers(base);
1500
+ if (this.patToken !== "") {
1501
+ headers.set("Authorization", `Bearer ${this.patToken}`);
1502
+ }
1503
+ if (this.team !== "") {
1504
+ headers.set(TEAM_HEADER, this.team);
1505
+ }
1506
+ return headers;
1507
+ }
1443
1508
  /**
1444
1509
  * Low-level HTTP request with automatic retry for transient failures.
1445
1510
  *
@@ -1458,22 +1523,88 @@ export class APIClient {
1458
1523
  * A 429 or 503 waits as long as its `Retry-After` asks (see
1459
1524
  * {@link retryDelayMs}). The client's `signal` ends the request and any
1460
1525
  * wait.
1526
+ *
1527
+ * A 421 that names another cell ({@link CELL_ENDPOINT_HEADER}) is not
1528
+ * retried in place: the same request goes once to that cell instead, and
1529
+ * later requests for that sandbox go straight there.
1461
1530
  */
1462
- authHeaders(base) {
1463
- const headers = new Headers(base);
1464
- if (this.patToken !== "") {
1465
- headers.set("Authorization", `Bearer ${this.patToken}`);
1466
- }
1467
- if (this.team !== "") {
1468
- headers.set(TEAM_HEADER, this.team);
1469
- }
1470
- return headers;
1471
- }
1472
1531
  async request(method, path, init = {}, options = {}) {
1473
1532
  const headers = this.authHeaders(init.headers);
1474
- const url = `${this.baseURL}${path}`;
1475
1533
  const signal = init.signal ?? this.signal;
1476
1534
  const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
1535
+ const sandboxID = sandboxIDInPath(path, this.versioned("/sandboxes/"));
1536
+ let base = this.baseURLFor(sandboxID);
1537
+ let response = await this.send(`${base}${path}`, requestInit, options);
1538
+ const cell = this.cellEndpoint(response);
1539
+ if (cell !== undefined) {
1540
+ // Remembered even when the body cannot go again, so the caller's own
1541
+ // retry goes to the right cell.
1542
+ if (sandboxID !== undefined)
1543
+ this.rememberCell(sandboxID, cell);
1544
+ // The cell router answers before any work is done, so even an exec or
1545
+ // a folder upload may go again; only a body that fetch has already
1546
+ // read cannot.
1547
+ if (!isReplayableBody(init.body))
1548
+ return response;
1549
+ response.body?.cancel().catch(() => undefined);
1550
+ base = cell;
1551
+ // send hands back a second 421 naming a cell as it is, and nothing
1552
+ // follows it: two cells that disagree cannot bounce a request between
1553
+ // them.
1554
+ response = await this.send(`${base}${path}`, requestInit, options);
1555
+ }
1556
+ // A 404 from the remembered cell may mean the sandbox was deleted, so the
1557
+ // next call asks the configured server again. A missing file says nothing
1558
+ // about the sandbox, and forgetting on it would add a 421 to every poll.
1559
+ if (response.status === 404 &&
1560
+ sandboxID !== undefined &&
1561
+ this.sandboxCells.get(sandboxID) === base &&
1562
+ !isMissingFile(await decodeError(response.clone()))) {
1563
+ this.sandboxCells.delete(sandboxID);
1564
+ }
1565
+ return response;
1566
+ }
1567
+ /** Where requests for sandbox `id` go: the cell it was found in, else the configured server. */
1568
+ baseURLFor(id) {
1569
+ return (id !== undefined && this.sandboxCells.get(id)) || this.baseURL;
1570
+ }
1571
+ rememberCell(id, cell) {
1572
+ // Re-inserted so a re-learned entry counts as the newest.
1573
+ this.sandboxCells.delete(id);
1574
+ this.sandboxCells.set(id, cell);
1575
+ if (this.sandboxCells.size > MAX_REMEMBERED_CELLS) {
1576
+ const oldest = this.sandboxCells.keys().next().value;
1577
+ if (oldest !== undefined)
1578
+ this.sandboxCells.delete(oldest);
1579
+ }
1580
+ }
1581
+ /**
1582
+ * The cell base URL a 421 names, or `undefined` when it names none this
1583
+ * client may send its token to: not an absolute http(s) URL, or plain http
1584
+ * while the client was configured for https, which would let one bad
1585
+ * answer move the token to clear text.
1586
+ */
1587
+ cellEndpoint(response) {
1588
+ if (!isCellAnswer(response))
1589
+ return undefined;
1590
+ let url;
1591
+ try {
1592
+ url = new URL((response.headers.get(CELL_ENDPOINT_HEADER) ?? "").trim());
1593
+ }
1594
+ catch {
1595
+ return undefined;
1596
+ }
1597
+ if (url.host === "" || (url.protocol !== "https:" && (url.protocol !== "http:" || /^https:/i.test(this.baseURL)))) {
1598
+ return undefined;
1599
+ }
1600
+ // Built like the configured base URL: the cell's own path prefix stays,
1601
+ // so /v1/... lands under it. A query, fragment or userinfo has no place
1602
+ // in a base URL and is dropped.
1603
+ return url.origin + stripTrailingSlashes(url.pathname);
1604
+ }
1605
+ /** One request to `url` with the retry loop described on {@link request}. */
1606
+ async send(url, requestInit, options) {
1607
+ const signal = requestInit.signal ?? undefined;
1477
1608
  const maxRetries = options.sendOnce ? 0 : this.retryConfig.maxRetries;
1478
1609
  const idempotent = options.idempotent ?? true;
1479
1610
  const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
@@ -1490,7 +1621,7 @@ export class APIClient {
1490
1621
  if (response.status === 502 || response.status === 504) {
1491
1622
  mayHaveRun = true;
1492
1623
  }
1493
- if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
1624
+ if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries || isCellAnswer(response)) {
1494
1625
  return response;
1495
1626
  }
1496
1627
  if (REFUSED_STATUS_CODES.has(response.status)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerol-ai/aerolvm-sdk",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",