@lotics/cli 0.309.0 → 0.311.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 +316 -70
- package/dist/src/client.js +6 -1
- package/docs/agent_surface.md +29 -1
- package/docs/design.md +3 -2
- package/docs/workflows.md +77 -0
- package/package.json +1 -1
package/dist/src/client.js
CHANGED
|
@@ -182,6 +182,11 @@ async function pollCliLogin(apiUrl, request_id, secret) {
|
|
|
182
182
|
function newRequestId() {
|
|
183
183
|
return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
184
184
|
}
|
|
185
|
+
function unparsedAnswer(text) {
|
|
186
|
+
if (!/^\s*<(!doctype|html)\b/i.test(text)) return text;
|
|
187
|
+
const title = /<title[^>]*>([^<]*)<\/title>/i.exec(text)?.[1]?.trim() ?? "";
|
|
188
|
+
return `a web page, not an answer from Lotics${title === "" ? "" : ` \u2014 "${title}"`}. It came from the network in front of the server.`;
|
|
189
|
+
}
|
|
185
190
|
var LoticsClient = class {
|
|
186
191
|
apiKey;
|
|
187
192
|
workspaceId;
|
|
@@ -215,7 +220,7 @@ var LoticsClient = class {
|
|
|
215
220
|
} catch {
|
|
216
221
|
body = {};
|
|
217
222
|
}
|
|
218
|
-
const message = typeof body.message === "string" ? body.message : text;
|
|
223
|
+
const message = typeof body.message === "string" ? body.message : unparsedAnswer(text);
|
|
219
224
|
const hint = typeof body.hint === "string" ? `
|
|
220
225
|
${body.hint}` : "";
|
|
221
226
|
const trace = requestId === void 0 ? "" : `
|
package/docs/agent_surface.md
CHANGED
|
@@ -115,9 +115,13 @@ lotics run run_app_query '{"app_id":"app_…","alias":"hire_list","params":{"sta
|
|
|
115
115
|
| `sort` | An order in place of the query's own: `[{ "field_key": "<column>", "order": "asc" }]`, `asc` or `desc`, each `field_key` an output column's name |
|
|
116
116
|
| `count` | `true`: `total`, the number of rows, in place of the rows; with `count_by`, `counts` per value of one column, keyed by an option's label, a member's name or a linked row's display; with `count_where` — a map of name → filter, never beside `count_by` — `counts` per name. Never with `fields`, `sort`, `limit` or `cursor` |
|
|
117
117
|
| `aggregate` | Groups of the result in place of its rows, one row per group: `by` (up to 3 columns), or `day` (a date column, answered as `__day`), and `sum` (a number column, answered as `__sum`), each group with its `__count`. Sent with `params` and `filters` alone |
|
|
118
|
-
| `limit`, `cursor` | A page — 200 rows at most, 50 where no `limit` is stated — and the `next_cursor` of the page before it; `truncated` says more rows matched |
|
|
118
|
+
| `limit`, `cursor` | A page — 200 rows at most, 50 where no `limit` is stated — and the `next_cursor` of the page before it; `truncated` says more rows matched than `rows` holds |
|
|
119
119
|
| `runs` | Several reads in one call, 20 at most, each with its own `alias` and arguments, answered in order under `runs`. Never beside an `alias` of the call's own |
|
|
120
120
|
|
|
121
|
+
A query that states its own `limit` lists at most that many rows: `truncated` is `true` where more match, and `total` is
|
|
122
|
+
the rows the filtered query matches without it. A `count`, `count_by`, `count_where` and an `aggregate` are over every row
|
|
123
|
+
the query matches, never over the rows its `limit` lists.
|
|
124
|
+
|
|
121
125
|
The rows of one call, across its reads, total at most 262,144 bytes: a read past that is refused, naming
|
|
122
126
|
`fields` and `limit` as what narrows it. The names `filters`, `fields`, `sort`, `count_by` and `aggregate`
|
|
123
127
|
take are the ones on the query's `returns` line.
|
|
@@ -172,6 +176,30 @@ answer is then `status: "not_previewed"` with
|
|
|
172
176
|
`preview: { "available": false, "reason" }`, and nothing ran — within `runs`, it ends the call. The same
|
|
173
177
|
answers a caller outside the app's organization.
|
|
174
178
|
|
|
179
|
+
## A reviewed act
|
|
180
|
+
|
|
181
|
+
An act that states `review` runs a workflow that only proposes: call it with the inputs the act asks and it answers,
|
|
182
|
+
writing nothing, the changeset its body proposes in `data.review` — `table_id`, `creates` (each with its `key`) and
|
|
183
|
+
`updates` (each with its `record_id`), each row's `data` holding only the fields that change, by field key, with an
|
|
184
|
+
optional `note` and `ticked` (`false` where the body leaves it for a person to choose), and `unplaced`, what was read
|
|
185
|
+
and matched nothing. Call it again with the same inputs and `approved`,
|
|
186
|
+
`{ "updates": [{ "record_id", "sha" }], "creates": [{ "key", "sha" }] }` — `sha` the SHA-256, in hex, of the row's `data`
|
|
187
|
+
as canonical JSON (every object's keys sorted) as it was read — and the platform writes those rows as that second call
|
|
188
|
+
proposes them, each only where it proposes the values read:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
lotics run run_app_workflow '{"app_id":"app_…","alias":"match_statement","inputs":{"record_ids":["rec_…"],"statement":"fil_…"}}'
|
|
192
|
+
lotics run run_app_workflow '{"app_id":"app_…","alias":"match_statement","inputs":{"record_ids":["rec_…"],"statement":"fil_…","approved":{"updates":[{"record_id":"rec_…","sha":"9f2c…"}],"creates":[{"key":"line-4","sha":"41d0…"}]}}}'
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The answer's `data.applied` names the run (`execution_id`), the records `updated`, each `created` record by its key,
|
|
196
|
+
and each approved row `left` with its `reason`: `not_proposed` (the second call no longer proposes it), `not_acted`
|
|
197
|
+
(an update of a record the act was not run on), `changed` (the second call proposes other values than were read — read
|
|
198
|
+
the proposal again), `empty` (an update changing no field), `missing`, `locked` or `conflict`. Every approved row is
|
|
199
|
+
checked before any is written, and all are written in one transaction or none; the status history of each row written
|
|
200
|
+
that moved is recorded by the same run, so the workflow step `undo_record_changes` with `execution_id` takes back both. An act over every
|
|
201
|
+
row in view takes no `record_ids`. A `file` input takes the id of a file uploaded to the workspace.
|
|
202
|
+
|
|
175
203
|
## A refusal
|
|
176
204
|
|
|
177
205
|
A workflow re-checks on the server every rule the screen shows: what an act requires, a bound, a duplicate,
|
package/docs/design.md
CHANGED
|
@@ -37,7 +37,7 @@ rows in time, a template's act — until the model states it or declines it.
|
|
|
37
37
|
`figure`, its `due` dates.
|
|
38
38
|
4. **Each app's composition** — the register's `columns` in the order the job reads them, as many as fit a
|
|
39
39
|
desk (one that does not fit is drawn nowhere in the row; what is read only on the way deeper is the
|
|
40
|
-
record's), the `filters` it narrows by every day (at most 3, each a field the rows show), its `layout`
|
|
40
|
+
record's), the `filters` it narrows by every day (at most 3, each a field the rows show, or a formula yes/no of fields they show), its `layout`
|
|
41
41
|
and `readings`; the record's `sections` in the order the work reaches them, each one's `fields`, `blocks`
|
|
42
42
|
and `acts`; the `checks` that warn or refuse. A rule the business states — a field needed before an act,
|
|
43
43
|
a quantity bounded, a picker narrowed, a duplicate reused — is the model's (an act's `requires`, a check's
|
|
@@ -82,6 +82,7 @@ then check again. A note refuses nothing.
|
|
|
82
82
|
| Rows that are each a place a record is sent | a rows block with `expect` on the link to those places and `post`: each ticked, the post written there and read as its feed shows it | `model/publish-desk` |
|
|
83
83
|
| Papers an agent reads, where the owner asked for one | an act's `intake` with `fills` | `model/papers-and-recordings` |
|
|
84
84
|
| Calls and visits | an act's `record` | `model/papers-and-recordings` |
|
|
85
|
+
| Changes a workflow proposes that a person approves row by row — a price list applied, a statement matched to what is owed | an act's `workflow` with `review`, asking its file as a `file` input; the body returns a changeset in `data.review` | `model/acts` |
|
|
85
86
|
| Documents covering lines | a rows block with `under` | `model/blocks` |
|
|
86
87
|
| Rows on file a record takes in | a rows block with `pick` | `model/blocks` |
|
|
87
88
|
| One owner's question across jobs | a dashboard app | `model/dashboards` |
|
|
@@ -199,7 +200,7 @@ refused. Worked: `examples/approval`.
|
|
|
199
200
|
#### Readings
|
|
200
201
|
|
|
201
202
|
`register.readings` stand above the rows and read the rows in view. Lead with the job's headline number —
|
|
202
|
-
its exception as a formula's yes/no in a `metric`'s `where` (what is late, what is short), never a stage the
|
|
203
|
+
its exception as a formula's yes/no in a `metric`'s `where` (what is late, what is short; the rows draw it where every field of its own row it reads is drawn, at that width), never a stage the
|
|
203
204
|
status chips already count — then at most the mix (`breakdown`) and the movement (`trend`), varied by job.
|
|
204
205
|
A `pivot` reads on a dashboard. A dashboard app (`dashboard`) answers one owner's question across jobs, each
|
|
205
206
|
reading windowed by the period the reader picks, and a `list` reading hands over the rows to act on. A
|
package/docs/workflows.md
CHANGED
|
@@ -255,6 +255,25 @@ initial value. A callback gets `(item, idx)` (`reduce`: `(acc, item, idx)`) and
|
|
|
255
255
|
expression — `(x) => <expr>`, `(x) => { return <expr>; }` or `function (x) { return <expr>; }`;
|
|
256
256
|
several statements, `async` and named function expressions are refused.
|
|
257
257
|
|
|
258
|
+
Look a row up by key rather than filtering for it: a `filter` inside a callback walks its whole list again
|
|
259
|
+
for every item, so a thousand items against a few hundred rows is hundreds of thousands of calls. Group the
|
|
260
|
+
rows once, then read each group by its key. `groupBy` answers an object of lists, and `byCode[code] ?? []`
|
|
261
|
+
is the group a code names, or an empty list:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
const parties = await query_records({ table_id: "tbl_parties" });
|
|
265
|
+
const lines = await query_records({ table_id: "tbl_sheet_lines" });
|
|
266
|
+
const byCode = groupBy(parties.records, (p) => p.data["fld_code"] ?? "");
|
|
267
|
+
const holders = lines.records.map((line) => ({
|
|
268
|
+
line: line.id,
|
|
269
|
+
parties: (byCode[line.data["fld_code"] ?? ""] ?? []).map((p) => p.id),
|
|
270
|
+
}));
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A key is read exactly: a code holding a dot, a slash or a bracket reads its own group, and a name every
|
|
274
|
+
object inherits (`constructor`) reads nothing unless a row made a group of that name. `get(o, "a.b")`
|
|
275
|
+
follows a dotted path instead, so a key that holds a dot is read with `o[key]`.
|
|
276
|
+
|
|
258
277
|
## Writing a field: null and undefined
|
|
259
278
|
|
|
260
279
|
In `update_records`' `set` and `create_records`' `records`:
|
|
@@ -493,6 +512,64 @@ while (more) {
|
|
|
493
512
|
return({ status: "success", message: `${reverted} put back, ${left} left as they stand` });
|
|
494
513
|
```
|
|
495
514
|
|
|
515
|
+
A shop matches a bank statement to the notes it is owed by an act that states `review`. That act's workflow only
|
|
516
|
+
proposes: its body calls steps that read and no other, and runs no agent — either is refused when the body is saved —
|
|
517
|
+
and returns in `data.review` one changeset of the act's table, which the platform writes the approved rows of. Called
|
|
518
|
+
with what the act asks, it answers the proposal and nothing is written; called again with
|
|
519
|
+
`approved: { updates: [{ record_id, sha }], creates: [{ key, sha }] }`, the body runs again and the platform writes
|
|
520
|
+
those rows as this second run proposes them, so the body never reads `approved`. `sha` is the SHA-256, in hex, of the
|
|
521
|
+
row's `data` as canonical JSON (every object's keys sorted) as the caller read it: a row the second run proposes with
|
|
522
|
+
other values is left unwritten (`changed`), so the body must propose the same for the same inputs and the rows it reads.
|
|
523
|
+
An update is of a record the act is run over (`record_id`, or one of `record_ids`): one of any other record is left
|
|
524
|
+
unwritten. An act over every row in view is handed no rows: its body reads the rows it may change, and any of them may
|
|
525
|
+
be proposed. A create carries a `key`, a text of the body's own that names the same line on every run
|
|
526
|
+
(`key: toString(line["Reference"])`), unique among the creates; where a confirm repeated must not add a second row,
|
|
527
|
+
the body also writes the key into a field the table keeps unique, so the repeat's create is left as a `conflict`.
|
|
528
|
+
Each row's `data` holds only stored fields that change — never one the platform computes or stamps, nor a file — with
|
|
529
|
+
a `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved
|
|
530
|
+
row is checked against its fields before any is written, and they are written in one transaction, so a flawed one
|
|
531
|
+
writes none. The status history of every row written that moved, or was made holding a status, is recorded in the
|
|
532
|
+
same run, so the workflow step `undo_record_changes` with the run's id takes back both. A body that stops proposing a row once it is
|
|
533
|
+
written — it matches against what the rows hold now — makes approving twice write once.
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
const inputs = trigger.app_workflow.inputs;
|
|
537
|
+
|
|
538
|
+
const sheet = await excel_get_rows({ // a workflow step
|
|
539
|
+
file_id: inputs.statement,
|
|
540
|
+
columns: ["Reference", "Amount", "Paid on"],
|
|
541
|
+
});
|
|
542
|
+
const unpaid = await query_records({
|
|
543
|
+
table_id: "tbl_notes",
|
|
544
|
+
filters: { field_key: "fld_paid_on", operator: "is_empty" },
|
|
545
|
+
pagination: { offset: 0, limit: 500 },
|
|
546
|
+
});
|
|
547
|
+
const acted = groupBy(inputs.record_ids, (id) => toString(id));
|
|
548
|
+
const owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);
|
|
549
|
+
const byRef = groupBy(owed, (one) => toString(one.data["fld_ref"]));
|
|
550
|
+
|
|
551
|
+
const lines = sheet.rows.map((line) => ({
|
|
552
|
+
line,
|
|
553
|
+
note: first(byRef[toString(line["Reference"])] ?? []),
|
|
554
|
+
}));
|
|
555
|
+
const updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({
|
|
556
|
+
record_id: toString(one.note?.id),
|
|
557
|
+
note: `Transfer of ${toString(one.line["Amount"])}`,
|
|
558
|
+
ticked: toNumber(one.line["Amount"]) == one.note?.data["fld_amount"],
|
|
559
|
+
data: { fld_paid_on: toString(one.line["Paid on"]) },
|
|
560
|
+
}));
|
|
561
|
+
const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
|
|
562
|
+
title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,
|
|
563
|
+
note: "Matches no unpaid note",
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
return({
|
|
567
|
+
status: "success",
|
|
568
|
+
message: `${size(updates)} of ${size(lines)} transfers match a note`,
|
|
569
|
+
data: { review: { version: 1, table_id: "tbl_notes", creates: [], updates, unplaced } },
|
|
570
|
+
});
|
|
571
|
+
```
|
|
572
|
+
|
|
496
573
|
## Table checks
|
|
497
574
|
|
|
498
575
|
A table check is one condition its table's record writes must not meet, declared beside the table's
|