@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.
- package/dist/src/cli.js +243 -243
- package/dist/src/client.js +26 -10
- package/docs/cli_reference.md +1 -1
- package/docs/workflows.md +76 -37
- package/package.json +1 -1
package/dist/src/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/docs/cli_reference.md
CHANGED
|
@@ -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
|
|
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
|
|
10
|
-
automation's source opens with a trigger declaration; a
|
|
11
|
-
table and event, an app workflow from the app that calls
|
|
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`
|
|
67
|
-
`fail_when` is truthy; the check's `field_key` names the control its message lands on —
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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
|
-
- `
|
|
306
|
-
- `
|
|
307
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
```
|