@lotics/app-sdk 0.60.1 → 0.61.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/AGENTS.md +3 -3
- package/dist/src/rpc.d.ts +2 -0
- package/dist/src/rpc.js +51 -1
- package/docs/ai.md +3 -1
- package/docs/files.md +29 -2
- package/docs/runtime.md +11 -0
- package/docs/security.md +9 -1
- package/docs/workflows.md +26 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -17,12 +17,12 @@ signature; open the file.**
|
|
|
17
17
|
| [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
|
|
18
18
|
| [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
|
|
19
19
|
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
|
|
20
|
-
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the `
|
|
21
|
-
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
20
|
+
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
|
|
21
|
+
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
22
22
|
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
|
|
23
23
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
|
24
24
|
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`** (`run_app_query` / `run_app_workflow`); the raw record read/write tools are rejected. |
|
|
25
|
-
| [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
|
|
25
|
+
| [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
|
|
26
26
|
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
|
|
27
27
|
|
|
28
28
|
## Non-negotiables (each detailed in its doc)
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -170,6 +170,8 @@ export declare function rpcAgentRunContinue(payload: AgentRunContinuePayload, on
|
|
|
170
170
|
* `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
|
|
171
171
|
*/
|
|
172
172
|
export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
173
|
+
/** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
|
|
174
|
+
export declare const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
|
|
173
175
|
/**
|
|
174
176
|
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
175
177
|
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
package/dist/src/rpc.js
CHANGED
|
@@ -250,6 +250,11 @@ export function rpcAgentRunContinue(payload, onText) {
|
|
|
250
250
|
const headers = { "content-type": "application/json" };
|
|
251
251
|
if (sessionToken)
|
|
252
252
|
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
253
|
+
// Continuing an anonymous parked run is authorized by the same per-run
|
|
254
|
+
// capability that reads it — there is no member to authorize instead.
|
|
255
|
+
const continueToken = runTokens.get(payload.run_id);
|
|
256
|
+
if (continueToken)
|
|
257
|
+
headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
|
|
253
258
|
const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
|
|
254
259
|
method: "POST",
|
|
255
260
|
headers,
|
|
@@ -297,8 +302,12 @@ function agentRunStandalone(payload, onText, onRunId) {
|
|
|
297
302
|
throw await streamStartError(res);
|
|
298
303
|
}
|
|
299
304
|
const runId = res.headers.get("x-app-agent-run-id");
|
|
300
|
-
if (runId)
|
|
305
|
+
if (runId) {
|
|
306
|
+
// Anonymous runs carry their retrieval capability here; a member run does
|
|
307
|
+
// not, and `rememberRunToken` no-ops on the null.
|
|
308
|
+
rememberRunToken(runId, res.headers.get(APP_AGENT_RUN_TOKEN_HEADER));
|
|
301
309
|
onRunId?.(runId);
|
|
310
|
+
}
|
|
302
311
|
const reader = res.body.getReader();
|
|
303
312
|
const decoder = new TextDecoder();
|
|
304
313
|
try {
|
|
@@ -343,6 +352,40 @@ let sessionToken = null;
|
|
|
343
352
|
* `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
|
|
344
353
|
*/
|
|
345
354
|
export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
355
|
+
/**
|
|
356
|
+
* Per-run retrieval credentials for ANONYMOUS agent runs, keyed by run id.
|
|
357
|
+
*
|
|
358
|
+
* A member's run is authorized by their identity, so reading it back needs
|
|
359
|
+
* nothing extra. An anonymous run on a publicly-shared app has no member to key
|
|
360
|
+
* on (`triggered_by_member_id` is null by design), so the server mints a
|
|
361
|
+
* capability token bound to that one run and returns it on the run response.
|
|
362
|
+
* Without it the recovery poll 404s and a completed answer is lost to any
|
|
363
|
+
* dropped connection — routine on mobile for a run measured in tens of seconds.
|
|
364
|
+
*
|
|
365
|
+
* Transport-level, like `sessionToken` above and for the same reason: it is a
|
|
366
|
+
* credential, not app state. Threading it through the hook would put a bearer
|
|
367
|
+
* into the app's typed surface for every app to forward by hand.
|
|
368
|
+
*
|
|
369
|
+
* Only the STANDALONE transport needs this — an embedded app always runs under
|
|
370
|
+
* a member session, so it never produces an anonymous run.
|
|
371
|
+
*/
|
|
372
|
+
const runTokens = new Map();
|
|
373
|
+
/** Bound the map so a long-lived page cannot accumulate tokens without limit.
|
|
374
|
+
* Insertion-ordered, so the oldest entry is the first key. */
|
|
375
|
+
const MAX_TRACKED_RUN_TOKENS = 8;
|
|
376
|
+
function rememberRunToken(runId, token) {
|
|
377
|
+
if (!token)
|
|
378
|
+
return;
|
|
379
|
+
runTokens.set(runId, token);
|
|
380
|
+
while (runTokens.size > MAX_TRACKED_RUN_TOKENS) {
|
|
381
|
+
const oldest = runTokens.keys().next();
|
|
382
|
+
if (oldest.done)
|
|
383
|
+
break;
|
|
384
|
+
runTokens.delete(oldest.value);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
|
|
388
|
+
export const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
|
|
346
389
|
/**
|
|
347
390
|
* The cookie the app-host Worker sets after the visitor clears the password
|
|
348
391
|
* gate. Readable by design — the SDK forwards its value as the session header
|
|
@@ -473,6 +516,11 @@ async function apiCall(method, path, body, opts) {
|
|
|
473
516
|
if (sessionToken && !opts?.skipAuth) {
|
|
474
517
|
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
475
518
|
}
|
|
519
|
+
// The per-run capability for an ANONYMOUS run — the only thing that authorizes
|
|
520
|
+
// reading or cancelling it, since there is no member identity to check.
|
|
521
|
+
if (opts?.runToken) {
|
|
522
|
+
headers[APP_AGENT_RUN_TOKEN_HEADER] = opts.runToken;
|
|
523
|
+
}
|
|
476
524
|
const controller = new AbortController();
|
|
477
525
|
let didTimeout = false;
|
|
478
526
|
const timeoutId = setTimeout(() => {
|
|
@@ -716,6 +764,7 @@ async function standaloneAgentRunGet(p) {
|
|
|
716
764
|
const { app_id } = await boot();
|
|
717
765
|
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
|
|
718
766
|
appId: app_id,
|
|
767
|
+
runToken: runTokens.get(p.run_id),
|
|
719
768
|
}));
|
|
720
769
|
return { run: r.run };
|
|
721
770
|
}
|
|
@@ -725,6 +774,7 @@ async function standaloneAgentRunCancel(p) {
|
|
|
725
774
|
const { app_id } = await boot();
|
|
726
775
|
await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
|
|
727
776
|
appId: app_id,
|
|
777
|
+
runToken: runTokens.get(p.run_id),
|
|
728
778
|
});
|
|
729
779
|
return { ok: true };
|
|
730
780
|
}
|
package/docs/ai.md
CHANGED
|
@@ -21,7 +21,7 @@ A declaration carries:
|
|
|
21
21
|
|---|---|
|
|
22
22
|
| `instructions` | System instructions — the task the agent performs per run |
|
|
23
23
|
| `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. The capability boundary for everything EXCEPT workspace data — the run can use nothing else. May be empty — including for an agent that reads documents, since a [`file` input carries its own content](#file-inputs--what-the-agent-can-actually-see) |
|
|
24
|
-
| `knowledge_doc_ids` | The knowledge docs the agent may read
|
|
24
|
+
| `knowledge_doc_ids` | The knowledge docs the agent may read, and the whole set it can reach — a doc absent from this list is unreadable even if the agent names its id. Declare `grep_knowledge` / `read_knowledge` in `tool_names` to read them. There is no size limit and nothing is inlined, so a multi-megabyte reference corpus (a full tariff, a regulation set) is a normal declaration. Reach for `code_exec` only to COMPUTE across the corpus — counting, cross-referencing — never merely to read it |
|
|
25
25
|
| `query_aliases` | The app's own named queries the agent may run via `run_app_query` — its **entire read surface** over records |
|
|
26
26
|
| `workflow_aliases` | The app's own workflows the agent may invoke via `run_app_workflow` — its **entire write surface** |
|
|
27
27
|
| `model_id` | Optional chat model pin. Omit (preferred) to follow the platform default chat model, resolved at run time — the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
|
|
@@ -184,6 +184,8 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
|
|
|
184
184
|
|
|
185
185
|
Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`kind: "connection_error" | "clean_end"`, with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
|
|
186
186
|
|
|
187
|
+
**Anonymous runs poll with a capability token, handled for you.** On a publicly-shared app a visitor has no member identity, so the server cannot authorize their poll by ownership — `triggered_by_member_id` is null by design. Instead the run response carries a per-run token (`x-app-agent-run-token`); the SDK stores it against that run id and replays it on the poll, on `cancel`, and on `answerChoice`. It is transport-level, like the password-session token — never surfaced to app code, and never issued for a member run (identity already authorizes those). Nothing to wire: an app calls `useAgentRun` the same way on both. The token is bound to ONE run, so it cannot read another visitor's, and it is why a dropped connection on a public app recovers rather than losing the answer.
|
|
188
|
+
|
|
187
189
|
The poll is bounded at 22 minutes — deliberately PAST the server's 20-minute hard run cap, so a live run always settles before the client gives up. A row still `running` at the deadline means the run's process died mid-flight (e.g. a crash that skipped the server's shutdown drain); the poll surfaces an error and the server's reaper repairs the row. Only when no run id was ever received (the run never started) does the failure reject before any polling.
|
|
188
190
|
|
|
189
191
|
On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; the full settled answer is persisted server-side but is not currently client-readable (`useAgentRuns` can't reach it on any transport — see the limitation below), so treat the streamed prefix as terminal for now.
|
package/docs/files.md
CHANGED
|
@@ -210,8 +210,11 @@ file. It is pure and never throws.
|
|
|
210
210
|
- **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
|
|
211
211
|
the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
|
|
212
212
|
falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
|
|
213
|
-
- **
|
|
214
|
-
|
|
213
|
+
- **A presigned `url` is a bearer credential for the bytes.** It carries its own authorization —
|
|
214
|
+
anyone holding it fetches the file for 24 h with no session — so it must never leave the render
|
|
215
|
+
path: not into a log line, not into telemetry, not into an error report or a bug ticket. Don't
|
|
216
|
+
persist it either; apps re-query on load, so the TTL refreshes naturally. Store `id`s, never
|
|
217
|
+
`url`s.
|
|
215
218
|
- **Warning:** SVG, HTML, and XHTML files are presigned with a forced-download disposition — their
|
|
216
219
|
`url` downloads instead of rendering inline (stored-XSS defense). An SVG in an `<img>` will not
|
|
217
220
|
display; treat these types as download-only.
|
|
@@ -278,6 +281,30 @@ if (result.status === "success" && result.files?.length) {
|
|
|
278
281
|
- For bytes the app builds *in the browser* (a client-side .xlsx/CSV export), the counterpart is
|
|
279
282
|
`downloadFile(filename, data, mimeType?)` — see [runtime](./runtime.md).
|
|
280
283
|
|
|
284
|
+
### Naming what goes into a zip
|
|
285
|
+
|
|
286
|
+
A workflow that bundles files (`zip_files`) names each entry itself: an item in `file_ids` is a
|
|
287
|
+
file id, **or** `{ id, name }`. An entry given no name keeps whatever the uploader called the file
|
|
288
|
+
(`IMG_4821.jpg`, `scan (3).pdf`) — which is exactly what the recipient opens — so pass the name the
|
|
289
|
+
app already knows (document type, record key, sequence). Renaming the stored file first is not
|
|
290
|
+
required, and would change what the record shows everyone else.
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
// in a workflow body — the two forms mix freely
|
|
294
|
+
const bundle = await zip_files({
|
|
295
|
+
file_ids: [{ id: inputs.signed_doc, name: "Bản ký" }, inputs.appendix],
|
|
296
|
+
filename: "ho-so",
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
An entry name is a **file name, not a path**. Separators are folded away, so a document number
|
|
301
|
+
survives readably (`Số 123/QĐ-BXD` arrives as `Số 123-QĐ-BXD`); a name without an extension is given
|
|
302
|
+
the stored file's; two files handed the same name are suffixed `_2`, `_3`. A name that cannot be
|
|
303
|
+
read as one fails the run rather than being quietly rewritten — a control character, a non-string,
|
|
304
|
+
over 200 characters, punctuation alone, or the empty string a name expression yields when it found
|
|
305
|
+
nothing. `null` is the one absence that is not an error (an empty record field reads as `null`): it
|
|
306
|
+
means "no name", and that entry keeps the stored filename.
|
|
307
|
+
|
|
281
308
|
## Previewing — wiring to `@lotics/ui`
|
|
282
309
|
|
|
283
310
|
Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
|
package/docs/runtime.md
CHANGED
|
@@ -288,6 +288,17 @@ It is the client-side counterpart to the server path (workflow generates a file
|
|
|
288
288
|
the browser attributes the download to the user gesture. Build the bytes in
|
|
289
289
|
the handler, then call `downloadFile` — an `await` between the click and the
|
|
290
290
|
call can void the gesture and get the download blocked.
|
|
291
|
+
- **One gesture buys one download.** Browsers block — or prompt over — repeated
|
|
292
|
+
programmatic downloads from a single interaction, so a loop calling
|
|
293
|
+
`downloadFile` once per file delivers the first and silently loses the rest.
|
|
294
|
+
Several files leave as **one archive**: build the bundle, hand it over in a
|
|
295
|
+
single call.
|
|
296
|
+
- **Guard a URL before fetching bytes you did not build.** `fetch("")` — an
|
|
297
|
+
empty or relative string where a file URL was expected — resolves against the
|
|
298
|
+
app's own page and returns **200** with the app's HTML, which then saves under
|
|
299
|
+
the filename you intended: a plausible-looking file with the wrong contents
|
|
300
|
+
and no error anywhere. Require the URL to be present and absolute, and treat a
|
|
301
|
+
missing one as a failure, never as an empty download.
|
|
291
302
|
- `mimeType` defaults to `application/octet-stream`. The blob URL is revoked on
|
|
292
303
|
the next task, after the download has started.
|
|
293
304
|
|
package/docs/security.md
CHANGED
|
@@ -30,13 +30,21 @@ Callers never submit query ASTs or workflow definitions — the server holds the
|
|
|
30
30
|
| `record_link` | Every submitted record id must live in the declared table |
|
|
31
31
|
| `file` | Every submitted file id must live in the app's workspace — a foreign tenant's `file_id` is rejected |
|
|
32
32
|
|
|
33
|
+
**Each of those bounds is a tenancy floor, not an authorization check.** It proves the value
|
|
34
|
+
belongs to the app's own workspace (or to a declared group or table) — never that *this caller*
|
|
35
|
+
should reach *that* row, file, or member. A `file` input is floored to the **workspace**, so a
|
|
36
|
+
caller may substitute the id of any file in it, including one attached to a record they have
|
|
37
|
+
never opened; a `record_link` id is floored to its declared **table**, not to the records the
|
|
38
|
+
caller may see. Whenever a run acts on ids the caller supplied, intersect them server-side with
|
|
39
|
+
what the target record actually holds before reading, bundling, or writing them.
|
|
40
|
+
|
|
33
41
|
Workflows and agent runs additionally execute behind a **workspace tenant floor**: every table a run touches must live in the app's own workspace, so no input value can steer a run at another workspace's data even under an admin owner's authority. An **agent** run is bounded harder still: it reaches records only through the queries and workflows its declaration names (`query_aliases` / `workflow_aliases`), never a raw table — so the ceiling is what you declared for that agent, not what the owner can see. See [ai](./ai.md).
|
|
34
42
|
|
|
35
43
|
What the platform does **not** constrain is the *meaning* of your params. A query param is a value hole in the template (a filter value, a search term); the server checks its type only — the group/table bindings above apply to workflow and agent-run inputs, **not** to query params — never whether the value should have been derived from the caller's identity. That's the next section.
|
|
36
44
|
|
|
37
45
|
### The devtools test
|
|
38
46
|
|
|
39
|
-
Before shipping any query or workflow, ask: **could a member open the browser devtools, replay the app's RPC, and pass someone else's id?** Every declared alias is callable by every member with app access (and by *anyone*, if the app is public) with arbitrary typed values — the app's UI is not a boundary. If substituting another member's id (or another record's id) into a param would show that member's data or perform a write only they should trigger, the design has an IDOR. The fixes are always server-side:
|
|
47
|
+
Before shipping any query or workflow, ask: **could a member open the browser devtools, replay the app's RPC, and pass someone else's id?** Every declared alias is callable by every member with app access (and by *anyone*, if the app is public) with arbitrary typed values — the app's UI is not a boundary. If substituting another member's id (or another record's or file's id) into a param would show that member's data or perform a write only they should trigger, the design has an IDOR. The fixes are always server-side:
|
|
40
48
|
|
|
41
49
|
- Reads scoped to "the current member" → an `is_current_member` filter **in the query template**, never a client-supplied member-id param.
|
|
42
50
|
- Writes attributed to the caller → `runtime.triggered_by_member_id` **in the workflow body**, never a client-supplied member input.
|
package/docs/workflows.md
CHANGED
|
@@ -50,6 +50,13 @@ leave `outputs` off to let the body's `return({ data })` derive it), write the b
|
|
|
50
50
|
`check`, then `set`. Input declaration vocabulary and validation:
|
|
51
51
|
[mutations](./mutations.md#declaring-workflow-inputs).
|
|
52
52
|
|
|
53
|
+
**Adding an input to an alias that is already bound reverses that order.** `codegen` types a
|
|
54
|
+
*registered* alias from the server's **bound** schema — the manifest declaration is only the
|
|
55
|
+
fallback for an alias the server has never seen — so a newly-declared input is absent from the
|
|
56
|
+
local globals and `check` rejects every read of it, however correct the body is. `set` pushes the
|
|
57
|
+
manifest's `inputs` with the body and the server verifies against *those*, so the working order
|
|
58
|
+
is `set` → `codegen` → `check`.
|
|
59
|
+
|
|
53
60
|
An app workflow body carries **no trigger declaration** — the binding supplies the trigger
|
|
54
61
|
context. A stray `on({ … })` line is rejected.
|
|
55
62
|
|
|
@@ -598,7 +605,9 @@ genuinely recursive.
|
|
|
598
605
|
`lotics app workflow check [alias]` runs the server's **own** parse and type passes locally: the
|
|
599
606
|
same subset parser, the same generated `.d.ts` and envelope, the same compiler options, one
|
|
600
607
|
isolated program per alias. **Green means pushable and red means real** — do not push through a
|
|
601
|
-
red check.
|
|
608
|
+
red check. It is also the *only* local gate on a body: the app project's own `npm run typecheck`
|
|
609
|
+
excludes `src/workflows` (bodies compile against the server's globals, not the app's DOM lib), so
|
|
610
|
+
it never sees one — a fully green app typecheck says nothing about any workflow.
|
|
602
611
|
|
|
603
612
|
Order matters. A subset rejection is reported *alone* and the compiler is skipped, because a body
|
|
604
613
|
the parser refuses never reaches the type checker on the server anyway. Diagnostics carry
|
|
@@ -621,6 +630,22 @@ There is no separate verify endpoint: the loop is `set` → read the returned di
|
|
|
621
630
|
round trip, not one per fix — and raw TypeScript shape errors are rewritten into field-naming,
|
|
622
631
|
fix-stating messages before you see them.
|
|
623
632
|
|
|
633
|
+
### Static green is not a run
|
|
634
|
+
|
|
635
|
+
`check` and `set` prove parse, types, name resolution, and lint — **none of them evaluates an
|
|
636
|
+
expression**. A body that saves clean can still take the wrong branch, hand a tool a filter it
|
|
637
|
+
rejects, or read a path that is null on real data. The rehearsal for that is `dry_run_workflow`
|
|
638
|
+
(`lotics run dry_run_workflow '<json>'`): pass the `source`, `trigger_type: "app_workflow"`,
|
|
639
|
+
`table_id: null`, the input **values** as `trigger_payload`, and the declared schema as
|
|
640
|
+
`app_workflow_inputs`. It walks the real step tree with the production expression evaluator and
|
|
641
|
+
hands back `planned_calls` (every tool call in order, with its fully-resolved input),
|
|
642
|
+
`return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
|
|
643
|
+
target tool would reject, which can only surface once the values are computed. **Write tools are
|
|
644
|
+
recorded, never dispatched**, and nothing is persisted. For a table-triggered body, pass
|
|
645
|
+
`record_id` + `table_id` instead of `trigger_payload` and the read-only tools (`query_records`,
|
|
646
|
+
`get_record`, `aggregate_records`) dispatch for real, so data-dependent gates are exercised
|
|
647
|
+
against actual rows. Do this before the first live run of anything that writes.
|
|
648
|
+
|
|
624
649
|
## A worked body
|
|
625
650
|
|
|
626
651
|
An app action that creates an order after checking for a duplicate, then returns the new id.
|