@lotics/cli 0.184.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
+ }
45086
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(() => {
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.184.0";
45878
+ var VERSION = "0.186.0";
45715
45879
 
45716
45880
  // src/timezone.ts
45717
45881
  function machineTimezone() {
@@ -45998,9 +46162,11 @@ var COMMANDS = [
45998
46162
  " shipping: agent schemas vs the live app, workflow",
45999
46163
  " declarations and BODIES vs what is live, aliases",
46000
46164
  " the code calls but nothing bound, undeclared",
46001
- " capabilities, query drift. Plus the one rule no",
46002
- " deploy runs: a concrete id or an id-shaped",
46003
- " placeholder in source a starter would ship.",
46165
+ " capabilities, query drift. Plus two rules no",
46166
+ " deploy runs: every body typechecked against",
46167
+ " the pulled types, and a concrete id or an",
46168
+ " id-shaped placeholder in source a starter",
46169
+ " would ship.",
46004
46170
  " Exits 1 on any of it, so CI can gate on it",
46005
46171
  " lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
46006
46172
  " through set_app_workflow (server verifies)",
@@ -71205,11 +71371,8 @@ function subsetIssues(bodySource, offset) {
71205
71371
  ];
71206
71372
  }
71207
71373
  async function loadProjectTypescript(projectDir) {
71208
- const requireFromProject = createRequire(path8.join(projectDir, "package.json"));
71209
- let resolved;
71210
- try {
71211
- resolved = requireFromProject.resolve("typescript");
71212
- } catch {
71374
+ const resolved = resolveProjectTypescript(projectDir);
71375
+ if (resolved === null) {
71213
71376
  throw new Error(
71214
71377
  `Could not resolve 'typescript' from ${projectDir}. 'lotics app workflow check' type-checks bodies with the app's own compiler \u2014 run 'npm install' (the scaffold ships typescript as a devDependency).`
71215
71378
  );
@@ -71217,6 +71380,14 @@ async function loadProjectTypescript(projectDir) {
71217
71380
  const mod2 = await import(pathToImportUrl(resolved));
71218
71381
  return mod2.default ?? mod2;
71219
71382
  }
71383
+ function resolveProjectTypescript(projectDir) {
71384
+ const requireFromProject = createRequire(path8.join(projectDir, "package.json"));
71385
+ try {
71386
+ return requireFromProject.resolve("typescript");
71387
+ } catch {
71388
+ return null;
71389
+ }
71390
+ }
71220
71391
  function pathToImportUrl(p) {
71221
71392
  const resolved = path8.resolve(p);
71222
71393
  const prefixed = resolved.startsWith("/") ? resolved : `/${resolved}`;
@@ -73079,13 +73250,35 @@ async function appCheck(client, args = {}) {
73079
73250
  warnIfUndescribed(app);
73080
73251
  const unportable = scanProjectForIds(projectDir);
73081
73252
  reportPortabilityIds(unportable);
73082
- if (!nothingPending(pending) || unportable.length > 0) {
73253
+ const bodiesFailing = await countFailingWorkflowBodies(client, projectDir, meta3);
73254
+ if (!nothingPending(pending) || unportable.length > 0 || bodiesFailing > 0) {
73083
73255
  process.exit(1);
73084
73256
  }
73085
73257
  console.error(
73086
- "Checked the app's bindings, capabilities, agent schemas and id portability \u2014 nothing blocking."
73258
+ "Checked the app's bindings, capabilities, agent schemas, workflow bodies and id portability \u2014 nothing blocking."
73087
73259
  );
73088
73260
  }
73261
+ async function countFailingWorkflowBodies(client, projectDir, meta3) {
73262
+ const bound = Object.keys(meta3.workflows ?? {});
73263
+ const typed = bound.filter((alias) => fs8.existsSync(workflowGlobalsPath(projectDir, alias)));
73264
+ const untyped = bound.filter((alias) => !typed.includes(alias));
73265
+ if (untyped.length > 0) {
73266
+ console.error(
73267
+ `\u26A0 Not type-checked, no local types for: ${untyped.join(", ")}. Run 'lotics app codegen' to write .lotics/workflows/<alias>.globals.d.ts.`
73268
+ );
73269
+ }
73270
+ const toCheck = await collectWorkflowBodyChecks(projectDir, meta3, typed, client);
73271
+ if (toCheck.length === 0) return 0;
73272
+ if (resolveProjectTypescript(projectDir) === null) {
73273
+ console.error(
73274
+ `\u26A0 ${toCheck.length} workflow bod${toCheck.length === 1 ? "y" : "ies"} not type-checked \u2014 no 'typescript' in this project's node_modules. Run 'npm install', then 'lotics app workflow check'.`
73275
+ );
73276
+ return 0;
73277
+ }
73278
+ const results = checkWorkflowBodies(await loadProjectTypescript(projectDir), toCheck);
73279
+ printWorkflowCheckResults(projectDir, results);
73280
+ return results.filter((r) => r.issues.length > 0).length;
73281
+ }
73089
73282
  async function pushPendingBindings(client, projectDir, pending) {
73090
73283
  const plan = [
73091
73284
  ...pending.queries.map((alias) => `query ${alias}`),
@@ -73974,6 +74167,18 @@ async function appWorkflowCheck(args) {
73974
74167
  console.error(`App ${meta3.app_id} has no bound workflows to check.`);
73975
74168
  return;
73976
74169
  }
74170
+ const toCheck = await collectWorkflowBodyChecks(projectDir, meta3, aliases, args.client);
74171
+ if (toCheck.length === 0) {
74172
+ console.error("No workflow bodies to check (every bound alias was skipped).");
74173
+ return;
74174
+ }
74175
+ const tsApi = await loadProjectTypescript(projectDir);
74176
+ const results = checkWorkflowBodies(tsApi, toCheck);
74177
+ printWorkflowCheckResults(projectDir, results);
74178
+ const failed = results.filter((r) => r.issues.length > 0);
74179
+ if (failed.length > 0) process.exit(1);
74180
+ }
74181
+ async function collectWorkflowBodyChecks(projectDir, meta3, aliases, client) {
73977
74182
  const toCheck = [];
73978
74183
  for (const alias of aliases) {
73979
74184
  const bodyPath = workflowFilePath(projectDir, alias);
@@ -73994,14 +74199,14 @@ async function appWorkflowCheck(args) {
73994
74199
  }
73995
74200
  for (const { alias, declaration } of staleWorkflowGlobals(projectDir, meta3.workflows ?? {})) {
73996
74201
  if (!toCheck.some((c) => c.alias === alias)) continue;
73997
- if (!args.client) {
74202
+ if (!client) {
73998
74203
  console.error(
73999
74204
  `\u26A0 "${alias}" \u2014 package.json declares inputs/outputs the local types were not built from, and they cannot be refreshed here (no credentials). Checked against the older types, so a newly declared input will read as a type error that set_app_workflow will accept.`
74000
74205
  );
74001
74206
  continue;
74002
74207
  }
74003
74208
  try {
74004
- await refreshWorkflowTypes(args.client, projectDir, meta3.app_id, alias, declaration);
74209
+ await refreshWorkflowTypes(client, projectDir, meta3.app_id, alias, declaration);
74005
74210
  console.error(`Refreshed workflow types for ${alias} \u2014 package.json had moved on.`);
74006
74211
  } catch (err2) {
74007
74212
  console.error(
@@ -74009,15 +74214,7 @@ async function appWorkflowCheck(args) {
74009
74214
  );
74010
74215
  }
74011
74216
  }
74012
- if (toCheck.length === 0) {
74013
- console.error("No workflow bodies to check (every bound alias was skipped).");
74014
- return;
74015
- }
74016
- const tsApi = await loadProjectTypescript(projectDir);
74017
- const results = checkWorkflowBodies(tsApi, toCheck);
74018
- printWorkflowCheckResults(projectDir, results);
74019
- const failed = results.filter((r) => r.issues.length > 0);
74020
- if (failed.length > 0) process.exit(1);
74217
+ return toCheck;
74021
74218
  }
74022
74219
  function printWorkflowCheckResults(projectDir, results) {
74023
74220
  let totalErrors = 0;
@@ -103207,7 +103404,7 @@ import { readFileSync, writeFileSync, existsSync as existsSync2, mkdtempSync, rm
103207
103404
  import { tmpdir as tmpdir2 } from "node:os";
103208
103405
  import { join as join2, dirname, resolve, extname, basename } from "node:path";
103209
103406
  import { fileURLToPath as fileURLToPath2 } from "node:url";
103210
- import { setTimeout as sleep } from "node:timers/promises";
103407
+ import { setTimeout as sleep2 } from "node:timers/promises";
103211
103408
  var HERE = dirname(fileURLToPath2(import.meta.url));
103212
103409
  function fail3(msg) {
103213
103410
  console.error(msg);
@@ -103339,7 +103536,7 @@ async function runPreviewCommand(filePath, flags) {
103339
103536
  const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
103340
103537
  if (p) cdpPort = p;
103341
103538
  }
103342
- if (!cdpPort) await sleep(100);
103539
+ if (!cdpPort) await sleep2(100);
103343
103540
  }
103344
103541
  if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
103345
103542
  let target;
@@ -103349,7 +103546,7 @@ async function runPreviewCommand(filePath, flags) {
103349
103546
  target = list2.find((t) => t.type === "page");
103350
103547
  } catch {
103351
103548
  }
103352
- if (!target?.webSocketDebuggerUrl) await sleep(100);
103549
+ if (!target?.webSocketDebuggerUrl) await sleep2(100);
103353
103550
  }
103354
103551
  if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
103355
103552
  const cdp = await cdpConnect(target.webSocketDebuggerUrl);
@@ -103371,7 +103568,7 @@ async function runPreviewCommand(filePath, flags) {
103371
103568
  if (v.warnings?.length) warnings.push(...v.warnings);
103372
103569
  break;
103373
103570
  }
103374
- await sleep(75);
103571
+ await sleep2(75);
103375
103572
  }
103376
103573
  if (!done) throw new Error("Render timed out (page never signaled completion).");
103377
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