@lotics/cli 0.310.0 → 0.311.1

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.
@@ -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 ? "" : `
@@ -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; the platform adds a `sha` to every row. Call it again with the same inputs and `approved`,
186
+ `{ "updates": [{ "record_id", "sha" }], "creates": [{ "key", "sha" }] }`, listing the rows to write with the `sha` each
187
+ carries in the proposal, copied as it came — never computed — 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
@@ -47,7 +47,10 @@ rows in time, a template's act — until the model states it or declines it.
47
47
  in — and beside each the query or workflow of the app that answers it (`agent_surface`). What the screen
48
48
  reads and does is generated. An ask no screen makes is declared: a read in `queries` (`model/queries`),
49
49
  a write in `workflows` (`model/workflows`). A figure an agent calls states its `alias`. An ask with
50
- nothing to answer it is a gap in the model, closed before the apply.
50
+ nothing to answer it is a gap in the model, closed before the apply. An app is built to be run by an agent:
51
+ every flow a person works can be driven end to end through its workflows, the person is asked only where
52
+ their judgement or authority is needed, and the screens show what was done and let a person do or put back
53
+ any step by hand.
51
54
  6. **A treatment for every list and every entity** — the next section. Choose it from what the rows ARE,
52
55
  before writing any register.
53
56
  7. **Plan, apply, look.** The `check_model` tool (`lotics model check` on the CLI) checks the
@@ -82,6 +85,7 @@ then check again. A note refuses nothing.
82
85
  | 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
86
  | Papers an agent reads, where the owner asked for one | an act's `intake` with `fills` | `model/papers-and-recordings` |
84
87
  | Calls and visits | an act's `record` | `model/papers-and-recordings` |
88
+ | 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
89
  | Documents covering lines | a rows block with `under` | `model/blocks` |
86
90
  | Rows on file a record takes in | a rows block with `pick` | `model/blocks` |
87
91
  | One owner's question across jobs | a dashboard app | `model/dashboards` |
@@ -199,7 +203,7 @@ refused. Worked: `examples/approval`.
199
203
  #### Readings
200
204
 
201
205
  `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
206
+ 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
207
  status chips already count — then at most the mix (`breakdown`) and the movement (`trend`), varied by job.
204
208
  A `pivot` reads on a dashboard. A dashboard app (`dashboard`) answers one owner's question across jobs, each
205
209
  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,65 @@ 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`. The platform adds a `sha` to every row
521
+ of the proposal it answers — the body states none, and one it states is dropped — and the caller echoes the `sha` of
522
+ each row it approves. A row the second run proposes with other values than the `sha` was read from is left unwritten
523
+ (`changed`), so the body must propose the same for the same inputs and the rows it reads.
524
+ 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
525
+ 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
526
+ be proposed. A create carries a `key`, a text of the body's own that names the same line on every run
527
+ (`key: toString(line["Reference"])`), unique among the creates; where a confirm repeated must not add a second row,
528
+ the body also writes the key into a field the table keeps unique, so the repeat's create is left as a `conflict`.
529
+ Each row's `data` holds only stored fields that change — never one the platform computes or stamps, nor a file — with
530
+ a `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved
531
+ row is checked against its fields before any is written, and they are written in one transaction, so a flawed one
532
+ writes none. The status history of every row written that moved, or was made holding a status, is recorded in the
533
+ 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
534
+ written — it matches against what the rows hold now — makes approving twice write once.
535
+
536
+ ```
537
+ const inputs = trigger.app_workflow.inputs;
538
+
539
+ const sheet = await excel_get_rows({ // a workflow step
540
+ file_id: inputs.statement,
541
+ columns: ["Reference", "Amount", "Paid on"],
542
+ });
543
+ const unpaid = await query_records({
544
+ table_id: "tbl_notes",
545
+ filters: { field_key: "fld_paid_on", operator: "is_empty" },
546
+ pagination: { offset: 0, limit: 500 },
547
+ });
548
+ const acted = groupBy(inputs.record_ids, (id) => toString(id));
549
+ const owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);
550
+ const byRef = groupBy(owed, (one) => toString(one.data["fld_ref"]));
551
+
552
+ const lines = sheet.rows.map((line) => ({
553
+ line,
554
+ note: first(byRef[toString(line["Reference"])] ?? []),
555
+ }));
556
+ const updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({
557
+ record_id: toString(one.note?.id),
558
+ note: `Transfer of ${toString(one.line["Amount"])}`,
559
+ ticked: toNumber(one.line["Amount"]) == one.note?.data["fld_amount"],
560
+ data: { fld_paid_on: toString(one.line["Paid on"]) },
561
+ }));
562
+ const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
563
+ title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,
564
+ note: "Matches no unpaid note",
565
+ }));
566
+
567
+ return({
568
+ status: "success",
569
+ message: `${size(updates)} of ${size(lines)} transfers match a note`,
570
+ data: { review: { version: 1, table_id: "tbl_notes", creates: [], updates, unplaced } },
571
+ });
572
+ ```
573
+
496
574
  ## Table checks
497
575
 
498
576
  A table check is one condition its table's record writes must not meet, declared beside the table's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.310.0",
3
+ "version": "0.311.1",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {