@lotics/cli 0.289.0 → 0.290.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.
@@ -62,6 +62,9 @@ async function uploadParts(options) {
62
62
  var CLI_CAPABILITIES_HEADER = "x-lotics-cli-capabilities";
63
63
  var CLI_CAPABILITIES = ["docs-fallback"];
64
64
 
65
+ // ../shared/src/proxy_upload_limit.ts
66
+ var PROXY_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
67
+
65
68
  // src/client.ts
66
69
  import crypto from "node:crypto";
67
70
  import fs from "node:fs";
@@ -126,6 +129,7 @@ function getMimeType(filename) {
126
129
  return MIME_MAP[ext] ?? "application/octet-stream";
127
130
  }
128
131
  var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
132
+ var PROXY_BATCH_MAX_BYTES = PROXY_UPLOAD_MAX_BYTES / 2;
129
133
  var LoticsRequestError = class extends Error {
130
134
  constructor(message, status, body) {
131
135
  super(message);
@@ -578,25 +582,37 @@ var LoticsClient = class {
578
582
  );
579
583
  }
580
584
  async uploadFiles(filePaths, options) {
581
- const small = [];
585
+ const files = [];
586
+ const errors = [];
582
587
  const large = [];
588
+ let batch = [];
589
+ let batchBytes = 0;
590
+ const sendBatch = async () => {
591
+ const sent = batch;
592
+ batch = [];
593
+ batchBytes = 0;
594
+ try {
595
+ const result = await this.uploadFileBytes(sent);
596
+ files.push(...result.files);
597
+ errors.push(...result.errors);
598
+ } catch (error) {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ errors.push(...sent.map((item) => ({ filename: item.filename, error: message })));
601
+ }
602
+ };
583
603
  for (let i = 0; i < filePaths.length; i++) {
584
604
  const absolutePath = path.resolve(filePaths[i]);
585
605
  const filename = options?.filenames?.[i] ?? path.basename(absolutePath);
586
606
  const { size } = await fs.promises.stat(absolutePath);
587
607
  if (size >= MULTIPART_THRESHOLD_BYTES) {
588
608
  large.push({ absolutePath, filename, size });
589
- } else {
590
- small.push({ bytes: await fs.promises.readFile(absolutePath), filename });
609
+ continue;
591
610
  }
611
+ if (batch.length > 0 && batchBytes + size > PROXY_BATCH_MAX_BYTES) await sendBatch();
612
+ batch.push({ bytes: await fs.promises.readFile(absolutePath), filename });
613
+ batchBytes += size;
592
614
  }
593
- const files = [];
594
- const errors = [];
595
- if (small.length > 0) {
596
- const result = await this.uploadFileBytes(small);
597
- files.push(...result.files);
598
- errors.push(...result.errors);
599
- }
615
+ if (batch.length > 0) await sendBatch();
600
616
  for (const item of large) {
601
617
  try {
602
618
  files.push(await this.uploadLargeFile(item));
@@ -31,7 +31,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
31
31
  | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
32
32
  | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
33
33
  | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
34
- | `lotics file upload <file\|dir...>` (alias `lotics upload`) · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files` in one request, and several such files go in the same one; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
34
+ | `lotics file upload <file\|dir...>` (alias `lotics upload`) · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
35
35
  | `lotics file download <file_id> [<path>]` · `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there — the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once — a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY — there is no single file for N files to be. |
36
36
  | `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first — id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset — pass it back as given — so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to "what is in here" short of reading Postgres. |
37
37
  | `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. **Refused while a record cell, a comment, a knowledge doc, a document template or a voice session still references it** — the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what "deleted" means here. There is no `lotics delete`: the verb needs its noun. |
package/docs/workflows.md CHANGED
@@ -6,9 +6,10 @@ Only the forms below are accepted; anything else is refused at save with the lin
6
6
  what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
7
7
  loop that may never end. Read them.
8
8
 
9
- The same grammar serves an automation, a table's lifecycle workflow and an app's workflow. Only an
10
- automation's source opens with a trigger declaration; a lifecycle workflow takes its trigger from its
11
- table and event, an app workflow from the app that calls it.
9
+ The same grammar serves an automation, a table's lifecycle workflow and an app's workflow, and the
10
+ expressions of a table check. Only an automation's source opens with a trigger declaration; a
11
+ lifecycle workflow takes its trigger from its table and event, an app workflow from the app that calls
12
+ it.
12
13
 
13
14
  ## Triggers
14
15
 
@@ -63,10 +64,10 @@ await send_email({
63
64
  | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
64
65
  | validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
65
66
 
66
- `return` is a call, not a JavaScript `return` statement. `validate` refuses the write when a check's
67
- `fail_when` is truthy; the check's `field_key` names the control its message lands on — a field of the
68
- trigger table in a lifecycle workflow; in an app workflow a declared input, or the `fld_` key of a
69
- field on a table the body names; in an automation it is not checked.
67
+ `return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error
68
+ when a check's `fail_when` is truthy; the check's `field_key` names the control its message lands on —
69
+ in an app workflow a declared input, or the `fld_` key of a field on a table the body names; a field of
70
+ the trigger table in a lifecycle workflow; in an automation it is not checked.
70
71
 
71
72
  The name in `const x = await tool({...})` is the step's id; later expressions read its output as
72
73
  `x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit
@@ -122,8 +123,7 @@ step — summarize a record, classify, draft text.
122
123
  - `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
123
124
 
124
125
  Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
125
- `schema` declares. The agent's own tool calls are not readable. An agent step cannot give a
126
- synchronous verdict, so a `before_*` lifecycle workflow refuses it.
126
+ `schema` declares. The agent's own tool calls are not readable.
127
127
 
128
128
  ## Keys, not names
129
129
 
@@ -264,25 +264,6 @@ field when there is none. Use `coalesce(x, fallback)` only for a real fallback v
264
264
 
265
265
  ## Examples
266
266
 
267
- Refuse a duplicate before it is created (a `before_create` lifecycle workflow; the keys come from
268
- `get_table`):
269
-
270
- ```
271
- const dup = await query_records({
272
- table_id: "tbl_orders",
273
- filters: { node_type: "group", logic: "and", children: [
274
- { node_type: "condition", field_key: "fld_ref", operator: "equals", value: record["fld_ref"] },
275
- { node_type: "condition", field_key: "fld_closed_at", operator: "is_empty" },
276
- ]},
277
- });
278
-
279
- validate({ checks: [{
280
- fail_when: size(dup.records) > 0,
281
- field_key: "fld_ref",
282
- message: "An open order already has this reference.",
283
- }]});
284
- ```
285
-
286
267
  Compare a link by id. Display text is not unique, and a link reads as a list of ids — `==` against a
287
268
  string is a type error:
288
269
 
@@ -302,16 +283,14 @@ if (customer && includes(record["fld_customer"], customer.id)) {
302
283
 
303
284
  A lifecycle workflow is bound to one table and one event, and its source has no `on({...})` line:
304
285
 
305
- - `before_create` / `after_create` — a create, and a draft's submit (the moment it becomes a record).
306
- - `before_update` / `after_update` — every field edit, a draft's included: a `before_update` check
307
- runs while someone fills in a draft, not only once it is submitted.
308
- - `before_delete` / `after_delete`.
286
+ - `after_create` — a create, and a draft's submit (the moment it becomes a record).
287
+ - `after_update` — every field edit, a draft's included.
288
+ - `after_delete`.
309
289
 
310
- A `before_*` workflow runs before the write commits and may refuse it, with `validate` or
311
- `return({ status: "error", ... })`; its errors come back as field errors on the write. It cannot
312
- `wait`, `wait_for_event`, `wait_for_approval` or run an `agent` step. An `after_*` workflow runs after
313
- the commit, with every step; its errors are logged and never fail the write. An `after_update`
314
- workflow that writes its own table saves with a `loop_potential` warning: its write fires it again.
290
+ A lifecycle workflow runs after the write commits, with every step; its errors are logged and never
291
+ fail the write. What refuses a write before it lands is a table check (see **Table
292
+ checks**). An `after_update` workflow that writes its own table saves with a `loop_potential`
293
+ warning: its write fires it again.
315
294
 
316
295
  | reads | on |
317
296
  |---|---|
@@ -334,3 +313,63 @@ that cares about some events only skips the rest:
334
313
 
335
314
  In `if_source`, `runtime.change_origin` is the origin of the write that fired it; in the body, it is
336
315
  the workflow's own.
316
+
317
+ ## Table checks
318
+
319
+ A table check is one condition its table's record writes must not meet, declared beside the table's
320
+ `unique`. It is not a workflow: it reads the write and a few lookups and answers yes or no, so it
321
+ cannot change data. It runs inside the write, on every create, update or delete it is `on`, whichever
322
+ surface makes it — a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and
323
+ answers with the check's message, under its field when the check names one, and names the check.
324
+
325
+ On the CLI or in chat, `set_table_check` creates one, or changes one when given its `table_check_id`
326
+ and only the parts that change, and `remove_table_check` removes one. `get_table` lists a table's
327
+ checks in the source they are written in.
328
+
329
+ | part | what it holds |
330
+ |---|---|
331
+ | `on` | the writes it checks: any of `create`, `update`, `delete` |
332
+ | `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |
333
+ | `lookups` | optional, by name, the rows to read: `{ table_id, filter, limit }`, the filter as `query_records` takes it, `limit` from 1 to 50 (50 when left out). A filter value may read the record. |
334
+ | `fail_when` | expression; truthy refuses the write |
335
+ | `message` | expression for the refusal's text — a fixed text is a quoted string |
336
+ | `field_key` | optional; the field the refusal is shown under |
337
+
338
+ Every expression is written in the grammar above and reads:
339
+
340
+ - `record` — the record as it will be stored; on a delete, the record being deleted.
341
+ - `prev_record` — on an update or a delete, the record before.
342
+ - `changes` — on an update, the fields it changes.
343
+ - `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read.
344
+ - `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the
345
+ write's origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member
346
+ made), and `now()`.
347
+
348
+ A check on several operations reads only what all of them carry, so one on creates and updates reads
349
+ neither `prev_record` nor `changes`.
350
+
351
+ A lookup reads with the access of the member who last saved the check, so saving one requires reading
352
+ every table its lookups read. It reads committed rows: two writes at the same moment can each pass a
353
+ check the other would fail, so values no two rows may share are the table's `unique`, not a check.
354
+
355
+ A `when`, a lookup or a `fail_when` that cannot decide — it fails, or the write's time runs out —
356
+ refuses the write. A draft is checked when it is submitted, as a create; its edits before that are
357
+ not. A restored record is checked as a create. Cloning a table copies its checks to the member who
358
+ clones it, and is refused when that member cannot read a table a check's lookup reads.
359
+
360
+ One open order per reference, a row never counting against itself:
361
+
362
+ ```
363
+ {
364
+ "table_id": "tbl_orders",
365
+ "name": "One open order per reference",
366
+ "on": ["create", "update"],
367
+ "when": "isNull(record[\"fld_closed_at\"])",
368
+ "lookups": {
369
+ "same_ref": "{ table_id: \"tbl_orders\", filter: { node_type: \"group\", logic: \"and\", children: [ { field_key: \"fld_ref\", operator: \"equals\", value: record[\"fld_ref\"] }, { field_key: \"fld_closed_at\", operator: \"is_empty\" } ] }, limit: 2 }"
370
+ },
371
+ "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",
372
+ "message": "\"An open order already has this reference.\"",
373
+ "field_key": "fld_ref"
374
+ }
375
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.289.0",
3
+ "version": "0.290.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {