@lotics/app-sdk 0.59.0 → 0.59.4
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 +1 -1
- package/dist/src/rpc.d.ts +10 -0
- package/dist/src/rpc.js +29 -6
- package/docs/ai.md +10 -0
- package/docs/mutations.md +56 -0
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,7 @@ signature; open the file.**
|
|
|
16
16
|
|---|---|
|
|
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
|
-
| [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
|
|
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
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 `lotics app workflow check` loop. |
|
|
21
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. |
|
|
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. |
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -178,6 +178,16 @@ export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
|
178
178
|
* `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
|
|
179
179
|
*/
|
|
180
180
|
export declare function transportErrorMessage(status: number, parsed: unknown): string;
|
|
181
|
+
/**
|
|
182
|
+
* The error a stream that never started should throw.
|
|
183
|
+
*
|
|
184
|
+
* A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
|
|
185
|
+
* workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
|
|
186
|
+
* the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
|
|
187
|
+
* "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
|
|
188
|
+
* message, and a non-JSON or 5xx body never becomes one.
|
|
189
|
+
*/
|
|
190
|
+
export declare function streamStartError(res: Response): Promise<Error>;
|
|
181
191
|
/**
|
|
182
192
|
* A package installation's alias→concrete-id maps — what the generated
|
|
183
193
|
* `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
|
package/dist/src/rpc.js
CHANGED
|
@@ -257,8 +257,7 @@ export function rpcAgentRunContinue(payload, onText) {
|
|
|
257
257
|
signal: controller.signal,
|
|
258
258
|
});
|
|
259
259
|
if (!res.ok || !res.body) {
|
|
260
|
-
|
|
261
|
-
throw new Error(text || `HTTP ${res.status}`);
|
|
260
|
+
throw await streamStartError(res);
|
|
262
261
|
}
|
|
263
262
|
const reader = res.body.getReader();
|
|
264
263
|
const decoder = new TextDecoder();
|
|
@@ -295,8 +294,7 @@ function agentRunStandalone(payload, onText, onRunId) {
|
|
|
295
294
|
signal: controller.signal,
|
|
296
295
|
});
|
|
297
296
|
if (!res.ok || !res.body) {
|
|
298
|
-
|
|
299
|
-
throw new Error(text || `HTTP ${res.status}`);
|
|
297
|
+
throw await streamStartError(res);
|
|
300
298
|
}
|
|
301
299
|
const runId = res.headers.get("x-app-agent-run-id");
|
|
302
300
|
if (runId)
|
|
@@ -410,8 +408,11 @@ async function boot() {
|
|
|
410
408
|
* A user-facing message for a transport/gateway failure — derived from the HTTP
|
|
411
409
|
* status, never from the response body. A 524 (Cloudflare edge timeout on a long
|
|
412
410
|
* run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
|
|
413
|
-
* body as the error message.
|
|
414
|
-
*
|
|
411
|
+
* body as the error message.
|
|
412
|
+
*
|
|
413
|
+
* A by-value MIRROR of `@lotics/shared/transport_error`, which is canonical. This
|
|
414
|
+
* package ships to npm with zero internal dependencies, so it cannot import it;
|
|
415
|
+
* every other transport does. Change one, change both.
|
|
415
416
|
*/
|
|
416
417
|
function gatewayErrorMessage(status) {
|
|
417
418
|
if (status === 524) {
|
|
@@ -437,6 +438,28 @@ export function transportErrorMessage(status, parsed) {
|
|
|
437
438
|
? gatewayErrorMessage(status)
|
|
438
439
|
: jsonMessage;
|
|
439
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* The error a stream that never started should throw.
|
|
443
|
+
*
|
|
444
|
+
* A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
|
|
445
|
+
* workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
|
|
446
|
+
* the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
|
|
447
|
+
* "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
|
|
448
|
+
* message, and a non-JSON or 5xx body never becomes one.
|
|
449
|
+
*/
|
|
450
|
+
export async function streamStartError(res) {
|
|
451
|
+
const text = await res.text().catch(() => "");
|
|
452
|
+
let parsed = null;
|
|
453
|
+
if (text) {
|
|
454
|
+
try {
|
|
455
|
+
parsed = JSON.parse(text);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// not JSON — a gateway HTML page; transportErrorMessage will not surface it
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return new Error(transportErrorMessage(res.status, parsed));
|
|
462
|
+
}
|
|
440
463
|
// Standalone requests carried no timeout: a hung or slow backend pinned the
|
|
441
464
|
// fetch — and one of the browser's few per-origin connections — indefinitely,
|
|
442
465
|
// surfacing as an app that "loads forever". Bound every standalone call the way
|
package/docs/ai.md
CHANGED
|
@@ -90,6 +90,16 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
|
|
|
90
90
|
|
|
91
91
|
`AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
|
|
92
92
|
|
|
93
|
+
`run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
|
|
94
|
+
|
|
95
|
+
*Before it starts*: the request is refused (most often a 402 when the workspace is out of AI credits — "Credit quota exceeded. Upgrade your plan for continued AI access." — or a 403/404). You get that response's `message` alone; the rest of the body (`error`, `plan_id`, `credits_used`, …) never reaches the string, and a non-JSON body or any 5xx is replaced by a status-derived message.
|
|
96
|
+
|
|
97
|
+
*After it starts*: the run dies mid-flight, and the message is written **in the triggering member's language** (the workspace default when an anonymous visitor triggered it). An error the platform wrote surfaces as-is, so running out of credits **during** a run still says to upgrade rather than to retry. A provider or tool failure does NOT — its wording is a debugging string that names our request shape, so it becomes one of two sentences: the request itself was rejected (an attachment that is corrupt or too large — retrying unchanged cannot help), or it broke transiently and is worth another run. The raw error is kept in error tracking with the run id.
|
|
98
|
+
|
|
99
|
+
Two caveats on that language guarantee. A message written at its throw site rather than fixed on its error class stays English — those strings are also what the chat agent reads to correct its own tool calls, so they are deliberately one language. And the *before it starts* case above is the API response's own `message`, which is not localized either. So an app that must be wholly in one language should render its own copy on failure rather than the platform's.
|
|
100
|
+
|
|
101
|
+
So `error={run.error}` is safe to render verbatim. What it is NOT is machine-readable: `status` tells you a run failed, not why, and the difference between "worth retrying" and "retrying cannot help" lives in the sentence rather than in a code. **Do not parse it** — the wording is copy and will change. If your app needs to act on the distinction (a retry button that disables itself when a retry is futile, an upgrade prompt on a quota hit), that discriminator can be exposed; it is classified server-side already and simply is not on the wire yet.
|
|
102
|
+
|
|
93
103
|
### The agent asks back — `pendingChoice` / `answerChoice`
|
|
94
104
|
|
|
95
105
|
Every app agent carries the platform's MANDATORY interactive tool `ask_user_choice` —
|
package/docs/mutations.md
CHANGED
|
@@ -319,6 +319,62 @@ flash to a spinner — `loading` stays false during revalidation). `usePaginated
|
|
|
319
319
|
(`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
|
|
320
320
|
in place of an explicit refetch after a write the user is watching for.
|
|
321
321
|
|
|
322
|
+
### A read must not overtake an in-flight write
|
|
323
|
+
|
|
324
|
+
`refetch()` and a direct re-read know nothing about a write that is still running. On a record
|
|
325
|
+
surface with inline (blur-committing) fields, a single gesture starts both: the field's write on
|
|
326
|
+
mousedown, the button's handler on mouseup — so a handler that re-reads the record to build a
|
|
327
|
+
document, mint something, or copy the row can read the row as it was BEFORE the edit. It fails
|
|
328
|
+
silently: the screen shows the new value, the output carries the old one.
|
|
329
|
+
|
|
330
|
+
Serialize them with one barrier per record surface — every write chains on, and the ONE place
|
|
331
|
+
that re-reads stored state awaits it, so no handler can forget:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
// Per surface, not module-level: useMemo(createWriteBarrier, []) inside the hook.
|
|
335
|
+
export function createWriteBarrier() {
|
|
336
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
337
|
+
return {
|
|
338
|
+
// Returns the write UNCHANGED — the caller keeps its own result + error handling;
|
|
339
|
+
// the chain never rejects, it only tracks when writes SETTLE.
|
|
340
|
+
track: <T,>(write: Promise<T>): Promise<T> => {
|
|
341
|
+
chain = chain.then(() => write).catch(() => undefined);
|
|
342
|
+
return write;
|
|
343
|
+
},
|
|
344
|
+
settled: () => chain,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const saveField = async (key: string, value: unknown) => {
|
|
349
|
+
const r = await writes.track(saveRecord({ record_id, [key]: value }));
|
|
350
|
+
if (r.status === "error") throw new Error(r.message); // the inline editor keeps the edit
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/** The ONLY re-read of stored state. Every handler goes through it — never the cached row. */
|
|
354
|
+
const reload = async () => {
|
|
355
|
+
await writes.settled();
|
|
356
|
+
const res = await rpc<{ rows?: Rec[] }>("query", { alias: "record", params: { id: record_id } });
|
|
357
|
+
return res.rows?.[0] ?? null;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const printDoc = async () => {
|
|
361
|
+
const fresh = (await reload()) ?? current; // reflects the edit the press just committed
|
|
362
|
+
await issueDoc(buildDoc(fresh));
|
|
363
|
+
};
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
A handler that reads the row held in state instead has the same defect with no in-flight write
|
|
367
|
+
needed — any edit since the row was loaded is missing. Build documents and mints from the
|
|
368
|
+
re-read, not from the cached row.
|
|
369
|
+
|
|
370
|
+
The same trap one level up: **an action takes an ID, never a captured object.**
|
|
371
|
+
`onPress={() => issueInvoice(invoice)}` closes over the invoice as it was when the press
|
|
372
|
+
happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
|
|
373
|
+
pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
|
|
374
|
+
then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
|
|
375
|
+
(`docs/data_entry.md` § Inline edit), which fixes the ordering; the captured value is the app's
|
|
376
|
+
to get right.
|
|
377
|
+
|
|
322
378
|
## Diff before update — send only what changed
|
|
323
379
|
|
|
324
380
|
An edit form snapshots the record's values when it loads, and on save sends **only the fields
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.59.
|
|
4
|
-
"description": "Runtime SDK for Lotics custom-code apps
|
|
3
|
+
"version": "0.59.4",
|
|
4
|
+
"description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|