@lotics/cli 0.306.1 → 0.307.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.
package/docs/workflows.md CHANGED
@@ -291,36 +291,206 @@ row of an `.xlsx`, `.xls` or `.csv` sheet in one call, each an object keyed by t
291
291
  A sheet past what one call reads is refused with its size, never cut short: the step's entry in
292
292
  `workflow_steps` states the bounds.
293
293
 
294
- A table can hold more rows than one page of `query_records` answers, so the body never reads "the
295
- table" to see what it lacks. It takes the sheet fifty rows at a time — what one `create_records` call
296
- writes — asks the table for those rows' codes alone, and writes the rest in one call:
294
+ `import_rows`, a workflow step, matches those rows to a table by a key and writes what differs. `match`
295
+ names the fields that identify a row, compared as trimmed text with case ignored: a row matching no live
296
+ row is added, one matching a row changes only the cells that differ, and one that changes nothing is
297
+ counted and not written, so the same file run twice writes nothing the second time. `values` holds a
298
+ row's cells by field key, as `create_records` takes them; a key the row omits is left as the table holds
299
+ it, and `null` is an empty cell, which differs from a filled one as any other value does.
300
+
301
+ A `match` field may also be a link field holding one row, compared by the row linked: lines keyed by
302
+ their parent and a line number state `match: ["fld_invoice", "fld_line"]`, the parent given as its
303
+ record id or as `{ lookup: "<text>" }`.
304
+
305
+ What the step is not certain of is held — answered with why, and not written: a row carrying `hold`
306
+ (its text is the message); a row whose key several live rows carry, or an earlier row of the same call;
307
+ a row the table's checks refuse, or whose row is locked; with `create: false`, a row matching none. A
308
+ held row never fails the step, and the rows beside it are written. A row whose key names a row outside
309
+ the rows the run may write is held `refused` with nothing of that row: no `record_id`, no `changes`.
310
+
311
+ The step removes no row, and a row the source leaves out is never touched. A row carrying
312
+ `remove: true`, with its key in `values`, proposes moving the row its key names to the trash: it is
313
+ always held, whatever `create` and `conflicts` say, and a key naming no row is counted unchanged.
314
+
315
+ `conflicts` states what a matched row does where a live cell holds another value: `"hold"` writes the
316
+ row's empty cells and holds the differing ones, `"overwrite"` writes over them, `"fill_empty"` leaves
317
+ them. `conflicts.fields` gives one field its own. A number and the field its unit or currency is read
318
+ from on each row are one cell, under the number's policy: both are written, or both held.
319
+
320
+ A link cell takes `{ lookup: "<text>" }` in place of record ids: the linked row is the one row holding
321
+ that text in the linked table's unique or autonumber field, else the one titled it, among the rows the
322
+ run reads of that table. A text naming no row, or several, holds the row, and the rows it could be are
323
+ answered beside it. A link holding many rows takes a list, `[{ lookup: "<text>" }, …]`: each text is
324
+ resolved by itself.
325
+
326
+ The step keeps nothing of its own: its answer is everything it did. `written` lists each record it
327
+ wrote — a changed one with each field from old to new as text, a created one by its id alone — and
328
+ `held_rows` each row it held: its `kind`, `reason` and `message`, the `record_id` of the row it matched,
329
+ its `key`, the `changes` it would make, the `links` it could not resolve with their `candidates`, and
330
+ `values` — its key and the cells it would write, as the call gave them. A later call given a held row's
331
+ `values` under `conflicts: { default: "overwrite" }` writes it. Both lists are read under the run's own
332
+ reach of the table, and a workflow hands its caller only what it returns. Two calls matching one table
333
+ by the same fields take turns, so the second matches the rows the first added. The step's entry in
334
+ `workflow_steps` states its bounds.
335
+
336
+ `undo_record_changes`, a workflow step, puts back what one run wrote on records of a table. It takes the
337
+ table, up to 500 record ids and the run — what `runtime.execution_id` held in it. A field goes back
338
+ where the row still holds what the run wrote. A row the run created goes to the trash
339
+ where nothing else has written it or linked a row to it since; in a table where a workflow writes each
340
+ new row, every row created reads as written since, and is kept. A row the run moved to the trash comes
341
+ back where nothing has touched it since, without the links to it that its removal took off other rows.
342
+ A row the run brought back from the trash stays out of it. A write of the run that was itself an undo is
343
+ not put back. What the step leaves as it stands is answered in `kept`, one entry a record and reason:
344
+ something changed it since, it is outside the rows the member may write, the table refused the write,
345
+ what the run wrote on it cannot be read, or the run brought it back from the trash.
346
+
347
+ Two more workflow steps read and settle one record's saves: `get_record_activity`, a workflow step,
348
+ lists them, and `undo_record_change`, a workflow step, puts one back. What they read is the run's to
349
+ read; inside an app's workflow it is what the app reads — the fields of the app's entity over the table,
350
+ a record that entity's own read reaches. An undo, of one save or of a run's writes, is the own save of the
351
+ member the run acts for — whoever started the app's workflow, pressed the button, or made the write a
352
+ table's workflow followed: that member holds the table's write as the step runs, each row within the
353
+ rows they may write, and the table's locks, checks and write rules answer for them. A run that acts for
354
+ no member — a schedule's, a public visitor's — reads, and its undos are refused.
355
+
356
+ What a business keeps of an import — the file, its counts, the lines waiting for a person — is rows of
357
+ its own tables, written by the workflow that ran the step. A garden centre brings its grower's price
358
+ sheet into its plants, and keeps each sheet and its lines. A line names its plant through a link
359
+ holding many rows: one holding a single row would make the line that plant's own, and a plant its
360
+ lines belong to is not moved to the trash.
361
+
362
+ These tables are shown beside the plants, in the same app: the sheets as a log of the plants' register
363
+ (`logs`, `lotics docs model/logs`), a sheet's lines on the page it opens. A line keeping what a cell held
364
+ and what it holds now in two fields reads one against the other (`compare`, `lotics docs model/blocks`).
297
365
 
298
366
  ```
299
367
  const sheet = await excel_get_rows({ // a workflow step
300
368
  file_id: trigger.app_workflow.inputs.file,
301
- columns: ["Code", "Filed", "Amount"],
369
+ columns: ["Code", "Name", "Price"],
302
370
  });
303
371
 
304
- for (let start = 0; start < sheet.row_count; start += 50) {
305
- const rows = uniqueBy(slice(sheet.rows, start, start + 50), (row) => toString(row["Code"]));
306
- const held = await query_records({
307
- table_id: "tbl_declarations",
308
- filters: { field_key: "fld_code", operator: "is_any_of", value: rows.map((row) => toString(row["Code"])) },
309
- pagination: { offset: 0, limit: 1000 },
372
+ const imported = await import_rows({ // a workflow step
373
+ table_id: "tbl_plants",
374
+ match: ["fld_code"],
375
+ rows: sheet.rows.map((row, at) => ({
376
+ line: at + 2,
377
+ hold: isNull(row["Price"]) ? "The sheet states no price" : null,
378
+ values: {
379
+ fld_code: toString(row["Code"]),
380
+ fld_name: toString(row["Name"]),
381
+ fld_price: toNumber(row["Price"]),
382
+ },
383
+ })),
384
+ });
385
+
386
+ const kept = await create_records({
387
+ table_id: "tbl_price_sheets",
388
+ records: [{
389
+ fld_file: [trigger.app_workflow.inputs.file],
390
+ fld_run: runtime.execution_id,
391
+ fld_read: imported.read,
392
+ fld_created: imported.created,
393
+ fld_changed: imported.changed,
394
+ fld_held: imported.held,
395
+ }],
396
+ });
397
+ const sheet_id = kept.record_ids[0];
398
+
399
+ for (let start = 0; start < size(imported.written); start += 50) {
400
+ await create_records({
401
+ table_id: "tbl_sheet_lines",
402
+ records: slice(imported.written, start, start + 50).map((one) => ({
403
+ fld_sheet: [sheet_id],
404
+ fld_plant: [one.record_id],
405
+ fld_line: one.line,
406
+ fld_outcome: one.kind,
407
+ fld_changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
408
+ })),
409
+ });
410
+ }
411
+ for (let start = 0; start < size(imported.held_rows); start += 50) {
412
+ await create_records({
413
+ table_id: "tbl_sheet_lines",
414
+ records: slice(imported.held_rows, start, start + 50).map((one) => ({
415
+ fld_sheet: [sheet_id],
416
+ fld_plant: isNull(one.record_id) ? [] : [one.record_id],
417
+ fld_line: one.line,
418
+ fld_outcome: "held",
419
+ fld_why: one.message,
420
+ fld_code: one.key["fld_code"],
421
+ fld_name: toString(one.values["fld_name"]),
422
+ fld_price: toNumber(one.values["fld_price"]),
423
+ fld_changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
424
+ })),
425
+ });
426
+ }
427
+
428
+ return({ status: "success", message: `${imported.created} added, ${imported.changed} changed, ${imported.held} held` });
429
+ ```
430
+
431
+ A held line keeps the `values` the step answered for it — its key and each cell it would write — in
432
+ fields of its own. It is applied by the act on it, whose workflow hands the step those values again and
433
+ lets it write over what the plant holds. A cell the line holds none of is left out, so a held line
434
+ never empties a cell of the plant:
435
+
436
+ ```
437
+ const line = await get_record({ table_id: "tbl_sheet_lines", record_id: trigger.app_workflow.inputs.record_id });
438
+ const values = {
439
+ fld_code: line.data["fld_code"],
440
+ ...(isEmpty(line.data["fld_name"]) ? {} : { fld_name: line.data["fld_name"] }),
441
+ ...(isNull(line.data["fld_price"]) ? {} : { fld_price: line.data["fld_price"] }),
442
+ };
443
+
444
+ const applied = await import_rows({ // a workflow step
445
+ table_id: "tbl_plants",
446
+ match: ["fld_code"],
447
+ conflicts: { default: "overwrite" },
448
+ rows: [{ values }],
449
+ });
450
+ if (applied.held > 0) {
451
+ return({ status: "error", message: applied.held_rows[0].message });
452
+ }
453
+
454
+ await update_records({ table_id: "tbl_sheet_lines", record_ids: [line.id], set: { fld_outcome: "applied" } });
455
+ return({ status: "success", message: "Applied." });
456
+ ```
457
+
458
+ A sheet is undone by the act on it, whose workflow hands the step the run the sheet kept and the
459
+ plants its lines name, 500 at a time:
460
+
461
+ ```
462
+ const sheet = await get_record({ table_id: "tbl_price_sheets", record_id: trigger.app_workflow.inputs.record_id });
463
+
464
+ let reverted = 0;
465
+ let left = 0;
466
+ let offset = 0;
467
+ let more = true;
468
+ while (more) {
469
+ const lines = await query_records({
470
+ table_id: "tbl_sheet_lines",
471
+ filters: {
472
+ logic: "and",
473
+ children: [
474
+ { field_key: "fld_sheet", operator: "has_any_of", value: [sheet.id] },
475
+ { field_key: "fld_outcome", operator: "is_any_of", value: ["created", "changed"] },
476
+ ],
477
+ },
478
+ pagination: { offset, limit: 500 },
310
479
  });
311
- const known = held.records.map((r) => toString(r.data["fld_code"]));
312
- const fresh = filter(rows, (row) => !includes(known, toString(row["Code"])));
313
- if (size(fresh) > 0) {
314
- await create_records({
315
- table_id: "tbl_declarations",
316
- records: fresh.map((row) => ({
317
- fld_code: toString(row["Code"]),
318
- fld_filed: isString(row["Filed"]) ? row["Filed"] : null,
319
- fld_amount: toNumber(row["Amount"]),
320
- })),
480
+ if (size(lines.records) > 0) {
481
+ const undone = await undo_record_changes({ // a workflow step
482
+ table_id: "tbl_plants",
483
+ record_ids: flatten(lines.records.map((line) => line.data["fld_plant"])),
484
+ workflow_execution_id: toString(sheet.data["fld_run"]),
321
485
  });
486
+ reverted += undone.reverted;
487
+ left += size(undone.kept);
322
488
  }
489
+ more = size(lines.records) == 500;
490
+ offset += 500;
323
491
  }
492
+
493
+ return({ status: "success", message: `${reverted} put back, ${left} left as they stand` });
324
494
  ```
325
495
 
326
496
  ## Table checks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.306.1",
3
+ "version": "0.307.1",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "generate:model-reference": "node --import tsx dev/model_reference_gen.ts",
23
23
  "generate:examples": "node --import tsx dev/examples_gen.ts",
24
24
  "generate:snapshots": "node --import tsx dev/factory_snapshots.ts",
25
+ "generate:agent-surface": "node --import tsx dev/agent_surface_gen.ts",
25
26
  "typecheck": "tsgo --noEmit",
26
27
  "lint": "oxlint",
27
28
  "test": "vitest run",