@lotics/app-sdk 0.52.2 → 0.52.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/dist/src/hooks.js CHANGED
@@ -515,11 +515,23 @@ export function useAgentRun(alias) {
515
515
  : null;
516
516
  if (aborted)
517
517
  return undefined;
518
- acc = settled ? adoptSettledRun(acc, settled) : { ...acc, status: "completed" };
518
+ // No settled row after the full poll window means the run's status
519
+ // could NOT be confirmed (an orphan the server's reaper hasn't
520
+ // repaired yet, or no run id ever arrived) — that is an error, never
521
+ // a fake "completed": a structured consumer reading a completed
522
+ // state with no output would render success around a missing result.
523
+ acc = settled
524
+ ? adoptSettledRun(acc, settled)
525
+ : {
526
+ ...acc,
527
+ status: "error",
528
+ error: "The run was interrupted and its result could not be confirmed. Run it again.",
529
+ };
519
530
  safeSetState(acc);
520
531
  // Fleet visibility: how often edges cut agent SSE streams. One
521
532
  // event per truncation, with whether the row rescued the result.
522
533
  captureAppEvent("app_agent_stream_truncated", {
534
+ kind: "clean_end",
523
535
  recovered: settled != null,
524
536
  settled_status: settled?.status ?? null,
525
537
  has_output: acc.output !== undefined,
@@ -536,13 +548,24 @@ export function useAgentRun(alias) {
536
548
  const runId = runIdRef.current;
537
549
  if (runId) {
538
550
  const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
539
- if (settled && !aborted) {
551
+ if (aborted)
552
+ return undefined;
553
+ if (settled) {
540
554
  acc = adoptSettledRun(acc, settled);
541
555
  safeSetState(acc);
542
- return acc.output;
543
556
  }
544
- if (aborted)
545
- return undefined;
557
+ // The same fleet-visibility beacon as the clean-end path — a dropped
558
+ // connection is the OTHER way a stream dies mid-run, and it was
559
+ // previously invisible (the beacon fired only on clean-end
560
+ // truncations, undercounting every network-error cut).
561
+ captureAppEvent("app_agent_stream_truncated", {
562
+ kind: "connection_error",
563
+ recovered: settled != null,
564
+ settled_status: settled?.status ?? null,
565
+ has_output: acc.output !== undefined,
566
+ });
567
+ if (settled)
568
+ return acc.output;
546
569
  }
547
570
  acc = { ...acc, status: "error", error: err.message };
548
571
  safeSetState(acc);
@@ -583,6 +606,16 @@ export function useAgentRun(alias) {
583
606
  /** Stable empty transcript so an idle hook returns a constant `items` reference
584
607
  * (no new [] each render → dependents don't re-run needlessly). */
585
608
  const EMPTY_ITEMS = [];
609
+ /**
610
+ * Bounded PAST the server-side max-run cap (20 min) plus settle grace. The
611
+ * server guarantees a LIVE run settles by its own cap timer, so a row still
612
+ * `running` at this deadline is an orphan (its process died mid-run) — the
613
+ * server's reapers repair the row; the client stops polling and surfaces the
614
+ * error. An earlier 11-minute bound gave up BEFORE the cap: a slow run that
615
+ * lost its stream showed failure while the server later completed it — the
616
+ * exact result-loss class this poll exists to prevent.
617
+ */
618
+ const POLL_DEADLINE_MS = 22 * 60_000;
586
619
  /**
587
620
  * Poll a single run until it leaves `running` — the resume path when a run's
588
621
  * stream connection drops mid-flight. Bounded just past the server-side max-run
@@ -590,7 +623,7 @@ const EMPTY_ITEMS = [];
590
623
  * aborts or unmounts. Returns the settled run, or null if it never settled.
591
624
  */
592
625
  async function pollAgentRunToSettle(runId, cancelled) {
593
- const deadline = Date.now() + 11 * 60_000;
626
+ const deadline = Date.now() + POLL_DEADLINE_MS;
594
627
  while (Date.now() < deadline) {
595
628
  await new Promise((r) => setTimeout(r, 2500));
596
629
  if (cancelled())
package/docs/ai.md CHANGED
@@ -123,10 +123,12 @@ Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is
123
123
 
124
124
  The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start and recovers through the **persisted run row** — the source of truth — in BOTH failure shapes:
125
125
 
126
- - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error.
127
- - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way. Each such truncation emits the `app_agent_stream_truncated` analytics event (with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
126
+ - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s) and resolves with the settled result instead of surfacing a network error.
127
+ - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way.
128
128
 
129
- A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
129
+ 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.
130
+
131
+ 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.
130
132
 
131
133
  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`; read the full settled answer via `useAgentRuns(sessionId)` if you need it.
132
134
 
package/docs/mutations.md CHANGED
@@ -199,6 +199,11 @@ When the alias declares `inputs`, the server validates the payload before the wo
199
199
  is treated as *omitted* (HTML form controls emit `""` for "left blank"). Nested optional
200
200
  object fields are plain-optional: omit the key. A required `text` input does accept `""` —
201
201
  emptiness is not a type error; validate non-emptiness in the workflow body if it matters.
202
+ - **Required reference inputs reject empty** — a required `record_link` / `member` / `file`
203
+ must resolve to at least one real id: the single form rejects `""`, and the multi form
204
+ rejects `[]` (an empty array is the multi-analog of an empty value — it would otherwise slip
205
+ past both the required check and the reference bindings below and write an empty link). Make
206
+ the input `required: false` if "attach nothing" is valid; then `""` omits and `[]` clears.
202
207
  - **Server-side reference bindings** — every `record_link` id must reference an existing
203
208
  record in its declared `table_id`; every group-scoped `member` id must belong to the
204
209
  declared group; every `file` id must live in the app's workspace. These are real write-time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.52.2",
3
+ "version": "0.52.4",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {