@lotics/cli 0.185.0 → 0.186.0

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/src/cli.js CHANGED
@@ -44182,6 +44182,66 @@ function transportErrorMessage(status, parsed) {
44182
44182
  return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
44183
44183
  }
44184
44184
 
44185
+ // ../shared/src/multipart_parts.ts
44186
+ var DEFAULT_MAX_RETRIES = 5;
44187
+ var MAX_RETRY_DELAY_MS = 15e3;
44188
+ var TIMEOUT_BASE_MS = 3e4;
44189
+ var TIMEOUT_PER_MB_MS = 15e3;
44190
+ function partTimeoutMs(partSizeBytes) {
44191
+ return TIMEOUT_BASE_MS + Math.ceil(partSizeBytes / (1024 * 1024)) * TIMEOUT_PER_MB_MS;
44192
+ }
44193
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
44194
+ var PermanentPartError = class extends Error {
44195
+ };
44196
+ function isRetryableStatus(status) {
44197
+ return status >= 500 || status === 408 || status === 429;
44198
+ }
44199
+ function partErrorFor(partNumber, status) {
44200
+ const message2 = `part ${partNumber} rejected by storage (${status})`;
44201
+ return isRetryableStatus(status) ? new Error(message2) : new PermanentPartError(message2);
44202
+ }
44203
+ async function uploadParts(options) {
44204
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
44205
+ const concurrency = Math.max(1, options.concurrency ?? 1);
44206
+ const done = [...options.alreadyUploaded ?? []];
44207
+ const finished = new Set(done.map((part) => part.part_number));
44208
+ const queue = options.parts.filter((part) => !finished.has(part.part_number));
44209
+ const uploadOne = async (part) => {
44210
+ const bytes = options.partBytes(part);
44211
+ for (let attempt = 1; ; attempt += 1) {
44212
+ options.signal?.throwIfAborted();
44213
+ try {
44214
+ const timeout = AbortSignal.timeout(partTimeoutMs(bytes));
44215
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
44216
+ const etag = await options.putPart(part, await options.readPart(part), signal);
44217
+ if (!etag) {
44218
+ throw new PermanentPartError(`part ${part.part_number} stored without an ETag`);
44219
+ }
44220
+ return { part_number: part.part_number, etag };
44221
+ } catch (error52) {
44222
+ if (options.signal?.aborted) throw error52;
44223
+ if (error52 instanceof PermanentPartError) throw error52;
44224
+ if (attempt >= maxRetries) {
44225
+ options.onExhausted?.({ partNumber: part.part_number, attempts: attempt, error: error52 });
44226
+ throw error52;
44227
+ }
44228
+ options.onRetry?.({ partNumber: part.part_number, attempt, error: error52 });
44229
+ const base = 1e3 * 2 ** (attempt - 1);
44230
+ await sleep(Math.min(base + Math.random() * base * 0.5, MAX_RETRY_DELAY_MS));
44231
+ }
44232
+ }
44233
+ };
44234
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
44235
+ for (let next = queue.shift(); next !== void 0; next = queue.shift()) {
44236
+ const part = await uploadOne(next);
44237
+ done.push(part);
44238
+ options.onPartUploaded?.(part);
44239
+ }
44240
+ });
44241
+ await Promise.all(workers);
44242
+ return done.sort((a, b) => a.part_number - b.part_number);
44243
+ }
44244
+
44185
44245
  // src/client.ts
44186
44246
  import crypto from "node:crypto";
44187
44247
  import fs2 from "node:fs";
@@ -44301,6 +44361,7 @@ function getMimeType(filename) {
44301
44361
  const ext = path2.extname(filename).toLowerCase();
44302
44362
  return MIME_MAP[ext] ?? "application/octet-stream";
44303
44363
  }
44364
+ var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
44304
44365
  var API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai";
44305
44366
  var WEB_APP_URL = "https://lotics.ai";
44306
44367
  async function fetchOfficialStarters() {
@@ -45077,15 +45138,118 @@ var LoticsClient = class {
45077
45138
  );
45078
45139
  }
45079
45140
  async uploadFiles(filePaths, options) {
45080
- const items = [];
45141
+ const small = [];
45142
+ const large = [];
45081
45143
  for (let i2 = 0; i2 < filePaths.length; i2++) {
45082
45144
  const absolutePath = path2.resolve(filePaths[i2]);
45083
- items.push({
45084
- bytes: await fs2.promises.readFile(absolutePath),
45085
- filename: options?.filenames?.[i2] ?? path2.basename(absolutePath)
45145
+ const filename = options?.filenames?.[i2] ?? path2.basename(absolutePath);
45146
+ const { size: size2 } = await fs2.promises.stat(absolutePath);
45147
+ if (size2 >= MULTIPART_THRESHOLD_BYTES) {
45148
+ large.push({ absolutePath, filename, size: size2 });
45149
+ } else {
45150
+ small.push({ bytes: await fs2.promises.readFile(absolutePath), filename });
45151
+ }
45152
+ }
45153
+ const files = [];
45154
+ const errors2 = [];
45155
+ if (small.length > 0) {
45156
+ const result = await this.uploadFileBytes(small);
45157
+ files.push(...result.files);
45158
+ errors2.push(...result.errors);
45159
+ }
45160
+ for (const item of large) {
45161
+ try {
45162
+ files.push(await this.uploadLargeFile(item));
45163
+ } catch (error52) {
45164
+ errors2.push({
45165
+ filename: item.filename,
45166
+ error: error52 instanceof Error ? error52.message : String(error52)
45167
+ });
45168
+ }
45169
+ }
45170
+ return { files, errors: errors2 };
45171
+ }
45172
+ /**
45173
+ * Store one file by handing its bytes straight to object storage.
45174
+ *
45175
+ * The API mints presigned part URLs and registers the row; the bytes go
45176
+ * directly from disk to the bucket and never enter the API process. That is
45177
+ * what makes the ceiling "what a workspace may store" rather than "what one
45178
+ * request may cost the server" — the reason `POST /v1/files` and the ticketed
45179
+ * upload are both capped far lower, and the reason this exists at all.
45180
+ *
45181
+ * Parts are read one at a time rather than streamed as a request body: a part
45182
+ * is bounded (the server picks 5–10 MiB), a `fetch` body needs a length the
45183
+ * bucket will check anyway, and reading a slice keeps peak memory at one part
45184
+ * instead of one file.
45185
+ *
45186
+ * The PUT loop itself — retry, backoff, part timeout, ordering — is
45187
+ * `@lotics/shared/multipart_parts`, shared with the browser client so the
45188
+ * hardening cannot exist on one side only.
45189
+ */
45190
+ async uploadLargeFile(input) {
45191
+ const mimeType = getMimeType(input.filename);
45192
+ const init = await this.request("POST", "/v1/files/multipart/init", {
45193
+ filename: input.filename,
45194
+ mime_type: mimeType,
45195
+ file_size: input.size
45196
+ });
45197
+ const partLength = (part) => Math.min(init.part_size, input.size - (part.part_number - 1) * init.part_size);
45198
+ const handle = await fs2.promises.open(input.absolutePath, "r");
45199
+ let uploaded;
45200
+ try {
45201
+ uploaded = await uploadParts({
45202
+ parts: init.parts,
45203
+ partBytes: partLength,
45204
+ // Sequential. Peak memory is then one part rather than one file, which
45205
+ // is the property that lets this run against a 2 GiB upload at all;
45206
+ // raising it trades that for wall-clock, and no upload has asked yet.
45207
+ concurrency: 1,
45208
+ putPart: async (part, body, signal) => {
45209
+ const response = await fetch(part.url, { method: "PUT", body, signal });
45210
+ if (!response.ok) throw partErrorFor(part.part_number, response.status);
45211
+ return response.headers.get("etag") ?? "";
45212
+ },
45213
+ readPart: async (part) => {
45214
+ const offset = (part.part_number - 1) * init.part_size;
45215
+ const length2 = partLength(part);
45216
+ const buffer = Buffer.alloc(length2);
45217
+ let filled = 0;
45218
+ while (filled < length2) {
45219
+ const { bytesRead } = await handle.read(buffer, filled, length2 - filled, offset + filled);
45220
+ if (bytesRead === 0) {
45221
+ throw new Error(
45222
+ `${input.filename} ended after ${offset + filled} bytes, short of the ${input.size} it reported \u2014 it changed while uploading`
45223
+ );
45224
+ }
45225
+ filled += bytesRead;
45226
+ }
45227
+ return buffer;
45228
+ }
45229
+ });
45230
+ } catch (error52) {
45231
+ await this.request("POST", "/v1/files/multipart/abort", {
45232
+ upload_id: init.upload_id,
45233
+ file_storage_key: init.file_storage_key
45234
+ }).catch(() => {
45086
45235
  });
45236
+ throw error52;
45237
+ } finally {
45238
+ await handle.close();
45239
+ }
45240
+ const result = await this.request("POST", "/v1/files/multipart/complete", {
45241
+ file_id: init.file_id,
45242
+ upload_id: init.upload_id,
45243
+ file_storage_key: init.file_storage_key,
45244
+ filename: input.filename,
45245
+ mime_type: mimeType,
45246
+ parts: uploaded
45247
+ });
45248
+ const stored = result.files[0];
45249
+ if (!stored) {
45250
+ throw new Error(result.errors[0]?.error ?? "upload completed but stored no file");
45087
45251
  }
45088
- return this.uploadFileBytes(items);
45252
+ return stored;
45089
45253
  }
45090
45254
  /**
45091
45255
  * Store files from bytes the caller already holds — the path for a caller that
@@ -45711,7 +45875,7 @@ function resultSideEffects(result) {
45711
45875
  }
45712
45876
 
45713
45877
  // src/version.ts
45714
- var VERSION = "0.185.0";
45878
+ var VERSION = "0.186.0";
45715
45879
 
45716
45880
  // src/timezone.ts
45717
45881
  function machineTimezone() {
@@ -103240,7 +103404,7 @@ import { readFileSync, writeFileSync, existsSync as existsSync2, mkdtempSync, rm
103240
103404
  import { tmpdir as tmpdir2 } from "node:os";
103241
103405
  import { join as join2, dirname, resolve, extname, basename } from "node:path";
103242
103406
  import { fileURLToPath as fileURLToPath2 } from "node:url";
103243
- import { setTimeout as sleep } from "node:timers/promises";
103407
+ import { setTimeout as sleep2 } from "node:timers/promises";
103244
103408
  var HERE = dirname(fileURLToPath2(import.meta.url));
103245
103409
  function fail3(msg) {
103246
103410
  console.error(msg);
@@ -103372,7 +103536,7 @@ async function runPreviewCommand(filePath, flags) {
103372
103536
  const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
103373
103537
  if (p) cdpPort = p;
103374
103538
  }
103375
- if (!cdpPort) await sleep(100);
103539
+ if (!cdpPort) await sleep2(100);
103376
103540
  }
103377
103541
  if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
103378
103542
  let target;
@@ -103382,7 +103546,7 @@ async function runPreviewCommand(filePath, flags) {
103382
103546
  target = list2.find((t) => t.type === "page");
103383
103547
  } catch {
103384
103548
  }
103385
- if (!target?.webSocketDebuggerUrl) await sleep(100);
103549
+ if (!target?.webSocketDebuggerUrl) await sleep2(100);
103386
103550
  }
103387
103551
  if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
103388
103552
  const cdp = await cdpConnect(target.webSocketDebuggerUrl);
@@ -103404,7 +103568,7 @@ async function runPreviewCommand(filePath, flags) {
103404
103568
  if (v.warnings?.length) warnings.push(...v.warnings);
103405
103569
  break;
103406
103570
  }
103407
- await sleep(75);
103571
+ await sleep2(75);
103408
103572
  }
103409
103573
  if (!done) throw new Error("Render timed out (page never signaled completion).");
103410
103574
  if (err2) throw new Error(`Render engine error: ${err2}`);
@@ -968,6 +968,7 @@ export declare class LoticsClient {
968
968
  uploadFiles(filePaths: string[], options?: {
969
969
  filenames?: string[];
970
970
  }): Promise<FileUploadResult>;
971
+ private uploadLargeFile;
971
972
  /**
972
973
  * Store files from bytes the caller already holds — the path for a caller that
973
974
  * never had them on disk (an email attachment decoded in memory, a generated