@alexkroman1/aai-ui 9.2.0 → 10.0.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.
@@ -6,7 +6,7 @@
6
6
  <title>aai</title>
7
7
  <link rel="icon" href="./favicon.ico" />
8
8
  <style>html, body { background: #FBF8F2; margin: 0; }</style>
9
- <script type="module" crossorigin src="./assets/index-CA2w_SUi.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-DS-RCrri.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="./assets/client-audio-constants-Ck0IJO4c.js">
11
11
  <link rel="stylesheet" crossorigin href="./assets/index-S5fkKi6B.css">
12
12
  </head>
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export type { AgentCustomEvent, SessionCore, SessionSnapshot, } from "./session-
25
25
  export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor, } from "./types.ts";
26
26
  export { type ConversationItem, type UseConversationResult, useConversation, } from "./use-conversation.ts";
27
27
  export { type UseDownloadUrlOptions, type UseDownloadUrlResult, useDownloadUrl, } from "./use-download-url.ts";
28
+ export { useRunKey } from "./use-run-key.ts";
28
29
  export { type UseUserTranscriptResult, useUserTranscript } from "./use-user-transcript.ts";
29
30
  export { type UploadStatus, type UseWorkflowSubmitOptions, type UseWorkflowsOptions, type UseWorkflowsResult, useWorkflowSubmit, useWorkflows, type WorkflowSubmission, } from "./use-workflow-form.ts";
30
31
  export { type UseWorkflowProgressResult, useWorkflowProgress, } from "./use-workflow-progress.ts";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { n as useUserTranscript } from "./use-user-transcript-C14qWFu2.js";
10
10
  import { n as ToolCallRow } from "./tool-call-block-B7KlhaWl.js";
11
11
  import { SidebarLayout } from "./components/sidebar-layout.js";
12
12
  import { StartScreen } from "./components/start-screen.js";
13
- import { a as useWorkflowRun, c as isTerminal, n as useWorkflowProgress, o as useWorkflowApiRef, s as createWorkflowApi } from "./use-workflow-progress-C4KgVldr.js";
13
+ import { a as useWorkflowRun, c as isTerminal, n as useWorkflowProgress, o as useWorkflowApiRef, s as createWorkflowApi } from "./use-workflow-progress-Cu0SxMyg.js";
14
14
  import { t as createSessionCore } from "./session-core-0hClhPz-.js";
15
15
  import { client, mountRoot, resolveContainer } from "./define-client.js";
16
16
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
@@ -760,6 +760,94 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
760
760
  });
761
761
  }
762
762
  //#endregion
763
+ //#region _recover-run.ts
764
+ /**
765
+ * Finding a run again when the page has lost its id.
766
+ *
767
+ * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
768
+ * which was only half true of the hooks above it: the run id lived in plain
769
+ * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
770
+ * live run with nothing anywhere able to name it. The run really did continue;
771
+ * the person really could not get back to it.
772
+ *
773
+ * `StartOptions.key` is the handle that survives that, and it always was — a
774
+ * caller's own name for a run, indexed by the agent, read back with
775
+ * `find(workflow, key)`. What was missing is the two lines that ASK. This is
776
+ * them, plus the four decisions they turn out to carry.
777
+ *
778
+ * ## It is a MOUNT-time act, not "whenever there is no run"
779
+ *
780
+ * The tempting spelling is "if we hold no run id, look one up", and it breaks
781
+ * `reset()`: a form put back to its initial state holds no run id, so the next
782
+ * pass would re-adopt the very run the person had just dismissed — a Clear
783
+ * button that clears nothing. So the lookup runs once per mount (and again only
784
+ * if the KEY changes, which is a different person's run), and every later
785
+ * absence of a run id is taken at face value.
786
+ *
787
+ * ## The lookup NEVER wins a race against a submit
788
+ *
789
+ * A person who reloads and immediately submits has started the run they want,
790
+ * and an answer that was already in flight names an older one. The caller
791
+ * therefore adopts through `current ?? found`: the recovered id fills an empty
792
+ * slot and never replaces a full one.
793
+ *
794
+ * ## A failed lookup is REPORTED
795
+ *
796
+ * The alternative is a page that quietly shows an empty form to somebody whose
797
+ * run is live, who then starts a second one — the duplicated work the key
798
+ * exists to prevent, and on a workflow app that is real money. A person who has
799
+ * never run anything pays a banner they can ignore. Same trade as
800
+ * `useWorkflows`, for the same reason: an empty answer here is a confident
801
+ * false statement.
802
+ *
803
+ * ## It is OPT-IN
804
+ *
805
+ * A `key` on its own still means only "record this with the run", which is what
806
+ * a voice agent's `ctx.workflows.start({ key })` means and what a page passing
807
+ * an account id may well want. Adopting a run is a decision about the PAGE, so
808
+ * it is `recover: true` and the two together read as what they do.
809
+ */
810
+ /**
811
+ * Look up the newest run for a key, once, as the component mounts.
812
+ *
813
+ * @param opts - See {@link RecoverRunOptions}.
814
+ * @returns Whether the lookup is still out. A caller folds it into its own
815
+ * `pending`, because a form offering Submit while a live run is arriving is a
816
+ * form inviting a second one.
817
+ *
818
+ * @internal
819
+ */
820
+ function useRecoveredRun(opts) {
821
+ const { workflow, key, enabled, getClient } = opts;
822
+ const [recovering, setRecovering] = useState(enabled && key !== void 0);
823
+ const handlers = useRef(opts);
824
+ handlers.current = opts;
825
+ useEffect(() => {
826
+ if (!enabled || key === void 0) return;
827
+ let cancelled = false;
828
+ setRecovering(true);
829
+ getClient().find(workflow, key, { limit: 1 }).then((found) => {
830
+ if (cancelled) return;
831
+ const newest = found[0];
832
+ if (newest !== void 0) handlers.current.onFound(newest.runId);
833
+ setRecovering(false);
834
+ }).catch((err) => {
835
+ if (cancelled) return;
836
+ handlers.current.onError(errorMessage(err));
837
+ setRecovering(false);
838
+ });
839
+ return () => {
840
+ cancelled = true;
841
+ };
842
+ }, [
843
+ enabled,
844
+ key,
845
+ workflow,
846
+ getClient
847
+ ]);
848
+ return recovering;
849
+ }
850
+ //#endregion
763
851
  //#region _run-controls.ts
764
852
  /**
765
853
  * The two things a page does TO a run it started, bound to the run it has.
@@ -838,7 +926,7 @@ function useRunControls(runId, getClient) {
838
926
  * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
839
927
  * to the upload we would have done anyway rather than failing to start.
840
928
  */
841
- const PREFIX = "aai:upload:";
929
+ const PREFIX$1 = "aai:upload:";
842
930
  /**
843
931
  * How many ids one form keeps.
844
932
  *
@@ -850,7 +938,7 @@ const PREFIX = "aai:upload:";
850
938
  const MAX_REMEMBERED = 32;
851
939
  /** One form's slot in storage. */
852
940
  function keyFor(scope) {
853
- return `${PREFIX}${scope}`;
941
+ return `${PREFIX$1}${scope}`;
854
942
  }
855
943
  /**
856
944
  * What names this file across a reload.
@@ -1406,7 +1494,7 @@ function useWorkflows(opts = {}) {
1406
1494
  * @public
1407
1495
  */
1408
1496
  function useWorkflowSubmit(workflow, opts = {}) {
1409
- const { api, key, wait, intervalMs, parallel } = opts;
1497
+ const { api, key, recover = false, wait, intervalMs, parallel } = opts;
1410
1498
  const [runId, setRunId] = useState(void 0);
1411
1499
  const [starting, setStarting] = useState(false);
1412
1500
  const [startError, setStartError] = useState(void 0);
@@ -1418,6 +1506,16 @@ function useWorkflowSubmit(workflow, opts = {}) {
1418
1506
  intervalMs
1419
1507
  }));
1420
1508
  const { wake, cancel } = useRunControls(runId, getClient);
1509
+ const recovering = useRecoveredRun({
1510
+ workflow,
1511
+ key,
1512
+ enabled: recover,
1513
+ getClient,
1514
+ onFound: (found) => {
1515
+ setRunId((current) => current ?? found);
1516
+ },
1517
+ onError: setStartError
1518
+ });
1421
1519
  const submit = useCallback(async (input) => {
1422
1520
  const client = getClient();
1423
1521
  setStarting(true);
@@ -1466,7 +1564,7 @@ function useWorkflowSubmit(workflow, opts = {}) {
1466
1564
  pauseUpload,
1467
1565
  resumeUpload,
1468
1566
  run: tracked.run,
1469
- pending: starting || tracked.polling,
1567
+ pending: recovering || starting || tracked.polling,
1470
1568
  upload,
1471
1569
  error: startError ?? tracked.error
1472
1570
  };
@@ -1814,6 +1912,134 @@ function useDownloadUrl(uploadId, opts = {}) {
1814
1912
  return state;
1815
1913
  }
1816
1914
  //#endregion
1915
+ //#region use-run-key.ts
1916
+ /**
1917
+ * The handle a page keeps on the runs it started, across a reload.
1918
+ *
1919
+ * `useWorkflowSubmit({ key, recover: true })` is what makes a run survivable —
1920
+ * the run id is that hook's own state, so a refresh loses it while the run
1921
+ * carries on — and the `key` is deliberately the caller's to choose, because it
1922
+ * is a lookup CAPABILITY: there is no per-user filtering behind `find`, so the
1923
+ * key IS the scoping mechanism. Choosing one is easy to get wrong in three
1924
+ * separate ways, and six shipped templates had each written the same twenty
1925
+ * lines to get it right. This is those lines.
1926
+ *
1927
+ * ## Three properties, and the rejected alternatives are why each one matters
1928
+ *
1929
+ * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
1930
+ * A key derived from the input collides the moment two people submit the same
1931
+ * thing, and they then recover each other's runs; it also carries what they
1932
+ * typed into a lookup token the platform deliberately stopped logging.
1933
+ * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
1934
+ * `POST /workflows/runs` allows a key.
1935
+ * - **Minted once per load and written back for the next one**, which is the
1936
+ * whole mechanism: the load that presses the button records the key with the
1937
+ * run, and the load after it finds the run by producing the same key.
1938
+ *
1939
+ * Storage rather than the page's own URL, for all of them. A `?key=` parameter
1940
+ * survives more (a new tab, a bookmark, a shared link) and that is the problem:
1941
+ * a URL is pasted into chats, copied into referrers and kept in history, and
1942
+ * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
1943
+ * it. An app with accounts should pass the ACCOUNT's own id here instead, and
1944
+ * then a run follows the person to a new device, which is a promise only a login
1945
+ * can keep.
1946
+ *
1947
+ * ## The storage is the caller's decision, and it is not a detail
1948
+ *
1949
+ * `"session"` (the default) dies with the tab, which covers exactly the
1950
+ * interruption most pages have — a reload, a same-tab navigation, a crashed tab
1951
+ * — and is the same lifetime as this package's other two stores, the session
1952
+ * resume id (`session-resume-store.ts`) and the upload recall
1953
+ * (`_upload-recall.ts`), so both halves of a reload make the same promise.
1954
+ *
1955
+ * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
1956
+ * between digests and may live a month, where closing the browser on Tuesday and
1957
+ * coming back on Friday to press Stop is the ordinary case rather than an edge
1958
+ * one, and a tab-scoped key would answer that with an empty form beside a run
1959
+ * still posting somewhere. It is as far as a key can go without a login, and no
1960
+ * further. `podcast-digest` is that template; the other five ship the default.
1961
+ *
1962
+ * ## Anything ELSE a page stores back must be VALIDATED on read
1963
+ *
1964
+ * This key needs no validation, and it is worth saying why, because it is the
1965
+ * exception: any string is a legal key, so a value from storage can only fail to
1966
+ * match a run. A page that remembers something more — which MODE submitted, say
1967
+ * — is remembering a value it will turn into a name, and storage hands back a
1968
+ * string some earlier version of that page wrote: a renamed mode, a hand-edited
1969
+ * value, a slot another app on the origin happens to share. Unchecked, that
1970
+ * starts a run called `undefined` and answers a 400 nobody typed. Check it
1971
+ * against the page's own list on the way out (`recalledMode` in
1972
+ * `transcription-workflow/recover.ts` is the worked example) — the recall is the
1973
+ * page's, the validation is not optional.
1974
+ *
1975
+ * ## The slot is keyed by the page's own URL
1976
+ *
1977
+ * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
1978
+ * would have two agents scaffolded from the same template recover each other's
1979
+ * runs. The key is the page's own directory — resolved through `"./"`, which
1980
+ * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
1981
+ * find the same key. Same call `session-resume-store.ts` makes, for the same
1982
+ * reason.
1983
+ *
1984
+ * One key per PAGE is right even for a page driving several workflows: `find` is
1985
+ * scoped by workflow as well as by key, so three hooks sharing one key recover
1986
+ * three separate runs. `transcription-workflow` is that page.
1987
+ *
1988
+ * Every access is guarded. Storage THROWS outright in some contexts (Safari
1989
+ * private mode, an iframe blocked by policy) and is ABSENT in others (any
1990
+ * server-side render), and a page that cannot remember its key must degrade to
1991
+ * the behaviour it would have had anyway — one run per load — rather than
1992
+ * failing to render.
1993
+ */
1994
+ /** Where a run key lives, namespaced like this package's two other stores. */
1995
+ const PREFIX = "aai:run-key:";
1996
+ /** This page's own slot — see "The slot is keyed by the page's own URL". */
1997
+ function slotFor() {
1998
+ const href = globalThis.location?.href;
1999
+ if (href === void 0) return PREFIX;
2000
+ try {
2001
+ return `${PREFIX}${new URL("./", href).href}`;
2002
+ } catch {
2003
+ return `${PREFIX}${href}`;
2004
+ }
2005
+ }
2006
+ /**
2007
+ * Read the key this page already has, or mint and remember one.
2008
+ *
2009
+ * Not exported: a page that wants a key wants it for the life of a component,
2010
+ * which is what the hook is. Calling this per render would mint a fresh key and
2011
+ * hand `recover` one nothing was ever started under.
2012
+ */
2013
+ function mintRunKey(storage) {
2014
+ try {
2015
+ const store = storage === "local" ? globalThis.localStorage : globalThis.sessionStorage;
2016
+ const slot = slotFor();
2017
+ const stored = store?.getItem(slot);
2018
+ if (stored !== null && stored !== void 0) return stored;
2019
+ const minted = crypto.randomUUID();
2020
+ store?.setItem(slot, minted);
2021
+ return minted;
2022
+ } catch {
2023
+ return crypto.randomUUID();
2024
+ }
2025
+ }
2026
+ /**
2027
+ * A lookup key for `useWorkflowSubmit({ key, recover: true })`, stable across
2028
+ * reloads.
2029
+ *
2030
+ * @param options - See the module doc for the whole argument. The storage kind
2031
+ * is read once, when the key is minted: a value that changed afterwards would
2032
+ * be asking to move a key that has already been recorded with a run.
2033
+ * @returns The key to record runs under and to look them up by — the same one
2034
+ * for the life of the component, and for the next load in the same tab (or the
2035
+ * same browser, under `"local"`).
2036
+ */
2037
+ function useRunKey(options = {}) {
2038
+ const { storage = "session" } = options;
2039
+ const [key] = useState(() => mintRunKey(storage));
2040
+ return key;
2041
+ }
2042
+ //#endregion
1817
2043
  //#region use-workflow-runs.ts
1818
2044
  /**
1819
2045
  * The RUNS a workflow has had — the list a page shows beside its form.
@@ -2217,4 +2443,4 @@ const WORKFLOW_STATUS_LABELS = {
2217
2443
  cancelled: "Cancelled"
2218
2444
  };
2219
2445
  //#endregion
2220
- export { AutoScroll, Button, ChatView, CheckboxField, ConsoleShell, Controls, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress, client, createSessionCore, createWorkflowApi, fetchClientConfig, isTerminal, page, useAgentState, useConversation, useDownloadUrl, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
2446
+ export { AutoScroll, Button, ChatView, CheckboxField, ConsoleShell, Controls, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress, client, createSessionCore, createWorkflowApi, fetchClientConfig, isTerminal, page, useAgentState, useConversation, useDownloadUrl, useEvent, useRunKey, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
package/dist/internal.js CHANGED
@@ -3,6 +3,6 @@ import { SessionProvider, ThemeProvider } from "./context.js";
3
3
  import { n as SessionUrlChips, r as UiUrlChip, t as ApiUrlChip } from "./url-chips-DxXKgyL9.js";
4
4
  import { t as TRANSCRIBING_PLACEHOLDER } from "./use-user-transcript-C14qWFu2.js";
5
5
  import { t as ToolConfigContext } from "./tool-config-context-DzAofqi_.js";
6
- import { i as MAX_MISSING_READS, r as DEFAULT_WORKFLOW_POLL_MS, t as DEFAULT_PROGRESS_POLL_MS } from "./use-workflow-progress-C4KgVldr.js";
6
+ import { i as MAX_MISSING_READS, r as DEFAULT_WORKFLOW_POLL_MS, t as DEFAULT_PROGRESS_POLL_MS } from "./use-workflow-progress-Cu0SxMyg.js";
7
7
  import { VOICE_CAPTURE_CONSTRAINTS } from "./types.js";
8
8
  export { ApiUrlChip, DEFAULT_PROGRESS_POLL_MS, DEFAULT_WORKFLOW_POLL_MS, MAX_MISSING_READS, SessionProvider, SessionUrlChips, TRANSCRIBING_PLACEHOLDER, ThemeProvider, ToolConfigContext, UiUrlChip, VOICE_CAPTURE_CONSTRAINTS, buildAgentUrl, loadClientConfig };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The handle a page keeps on the runs it started, across a reload.
3
+ *
4
+ * `useWorkflowSubmit({ key, recover: true })` is what makes a run survivable —
5
+ * the run id is that hook's own state, so a refresh loses it while the run
6
+ * carries on — and the `key` is deliberately the caller's to choose, because it
7
+ * is a lookup CAPABILITY: there is no per-user filtering behind `find`, so the
8
+ * key IS the scoping mechanism. Choosing one is easy to get wrong in three
9
+ * separate ways, and six shipped templates had each written the same twenty
10
+ * lines to get it right. This is those lines.
11
+ *
12
+ * ## Three properties, and the rejected alternatives are why each one matters
13
+ *
14
+ * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
15
+ * A key derived from the input collides the moment two people submit the same
16
+ * thing, and they then recover each other's runs; it also carries what they
17
+ * typed into a lookup token the platform deliberately stopped logging.
18
+ * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
19
+ * `POST /workflows/runs` allows a key.
20
+ * - **Minted once per load and written back for the next one**, which is the
21
+ * whole mechanism: the load that presses the button records the key with the
22
+ * run, and the load after it finds the run by producing the same key.
23
+ *
24
+ * Storage rather than the page's own URL, for all of them. A `?key=` parameter
25
+ * survives more (a new tab, a bookmark, a shared link) and that is the problem:
26
+ * a URL is pasted into chats, copied into referrers and kept in history, and
27
+ * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
28
+ * it. An app with accounts should pass the ACCOUNT's own id here instead, and
29
+ * then a run follows the person to a new device, which is a promise only a login
30
+ * can keep.
31
+ *
32
+ * ## The storage is the caller's decision, and it is not a detail
33
+ *
34
+ * `"session"` (the default) dies with the tab, which covers exactly the
35
+ * interruption most pages have — a reload, a same-tab navigation, a crashed tab
36
+ * — and is the same lifetime as this package's other two stores, the session
37
+ * resume id (`session-resume-store.ts`) and the upload recall
38
+ * (`_upload-recall.ts`), so both halves of a reload make the same promise.
39
+ *
40
+ * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
41
+ * between digests and may live a month, where closing the browser on Tuesday and
42
+ * coming back on Friday to press Stop is the ordinary case rather than an edge
43
+ * one, and a tab-scoped key would answer that with an empty form beside a run
44
+ * still posting somewhere. It is as far as a key can go without a login, and no
45
+ * further. `podcast-digest` is that template; the other five ship the default.
46
+ *
47
+ * ## Anything ELSE a page stores back must be VALIDATED on read
48
+ *
49
+ * This key needs no validation, and it is worth saying why, because it is the
50
+ * exception: any string is a legal key, so a value from storage can only fail to
51
+ * match a run. A page that remembers something more — which MODE submitted, say
52
+ * — is remembering a value it will turn into a name, and storage hands back a
53
+ * string some earlier version of that page wrote: a renamed mode, a hand-edited
54
+ * value, a slot another app on the origin happens to share. Unchecked, that
55
+ * starts a run called `undefined` and answers a 400 nobody typed. Check it
56
+ * against the page's own list on the way out (`recalledMode` in
57
+ * `transcription-workflow/recover.ts` is the worked example) — the recall is the
58
+ * page's, the validation is not optional.
59
+ *
60
+ * ## The slot is keyed by the page's own URL
61
+ *
62
+ * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
63
+ * would have two agents scaffolded from the same template recover each other's
64
+ * runs. The key is the page's own directory — resolved through `"./"`, which
65
+ * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
66
+ * find the same key. Same call `session-resume-store.ts` makes, for the same
67
+ * reason.
68
+ *
69
+ * One key per PAGE is right even for a page driving several workflows: `find` is
70
+ * scoped by workflow as well as by key, so three hooks sharing one key recover
71
+ * three separate runs. `transcription-workflow` is that page.
72
+ *
73
+ * Every access is guarded. Storage THROWS outright in some contexts (Safari
74
+ * private mode, an iframe blocked by policy) and is ABSENT in others (any
75
+ * server-side render), and a page that cannot remember its key must degrade to
76
+ * the behaviour it would have had anyway — one run per load — rather than
77
+ * failing to render.
78
+ */
79
+ /**
80
+ * A lookup key for `useWorkflowSubmit({ key, recover: true })`, stable across
81
+ * reloads.
82
+ *
83
+ * @param options - See the module doc for the whole argument. The storage kind
84
+ * is read once, when the key is minted: a value that changed afterwards would
85
+ * be asking to move a key that has already been recorded with a run.
86
+ * @returns The key to record runs under and to look them up by — the same one
87
+ * for the life of the component, and for the next load in the same tab (or the
88
+ * same browser, under `"local"`).
89
+ */
90
+ export declare function useRunKey(options?: {
91
+ /**
92
+ * Which store keeps the key between loads.
93
+ *
94
+ * `"session"` (the default) dies with the tab; `"local"` survives the
95
+ * browser closing, which is what a run that sleeps for days needs. See "The
96
+ * storage is the caller's decision".
97
+ */
98
+ storage?: "session" | "local";
99
+ }): string;
@@ -231,6 +231,29 @@ export type UseWorkflowSubmitOptions = {
231
231
  api?: WorkflowApi;
232
232
  /** Correlation key recorded with the run, for finding it again without the id. */
233
233
  key?: string;
234
+ /**
235
+ * On mount, adopt the newest run this `key` already has.
236
+ *
237
+ * **This is what makes a reload survivable.** The run id is this hook's own
238
+ * state, so a refresh loses it while the run carries on — and a page that
239
+ * cannot name a run cannot show it, cancel it or wake it. With a `key` and
240
+ * this flag the hook asks `find(workflow, key)` once as it mounts and follows
241
+ * whatever comes back, so the answer, the progress and the controls are all
242
+ * there again.
243
+ *
244
+ * Inert without a `key`, because the key IS the lookup. Opt-in because a
245
+ * `key` on its own means only "record this with the run", which is what a
246
+ * page passing an account id may well want; adopting a run is a decision
247
+ * about the page.
248
+ *
249
+ * The key has to be one the next load can produce, and choosing it is the
250
+ * caller's: it is a lookup CAPABILITY (there is no per-user filtering behind
251
+ * `find`), it must fit the route's 256-character bound, and anything derived
252
+ * from a person's own input both collides and carries what they typed.
253
+ * `useRunKey()` is that key, and its module argues every alternative; a page
254
+ * with accounts passes the account's own id instead.
255
+ */
256
+ recover?: boolean;
234
257
  /**
235
258
  * Hold the `POST` open until the run settles, up to this many ms — the
236
259
  * synchronous mode. Omitted (the default) returns as soon as the run exists.
@@ -316,6 +316,15 @@ function pollUntilTerminal(getClient, runId, intervalMs, onRun, onError, onStopp
316
316
  * not: it can complete while the tab is closed, on a different sandbox, hours
317
317
  * later. There is no session to reconnect — the id is the whole state.
318
318
  *
319
+ * Which is also the limit of what this hook can do on its own. An id is state a
320
+ * RELOAD destroys, so a page holding nothing else comes back unable to name a
321
+ * run that is still going. The durable handle is `StartOptions.key`, read back
322
+ * with `find(workflow, key)`, and the hook that owns the id is where that
323
+ * belongs: `useWorkflowSubmit({ key, recover: true })` adopts the key's newest
324
+ * run as it mounts and passes the id here. See `_recover-run.ts` — the reason
325
+ * recovery is NOT in this hook is `reset()`, which leaves the owner holding no
326
+ * id on purpose, and a watcher that re-resolved one from a key would undo it.
327
+ *
319
328
  * The stream (`GET /runs/:id/events`) is tried first and the poll is its
320
329
  * fallback, so an agent deployed before that route existed still works. Watching
321
330
  * STOPS on a terminal status, so a finished run costs nothing; passing
@@ -448,9 +457,67 @@ function useWorkflowRun(runId, opts = {}) {
448
457
  * log rather than a socket — but it is a poll of a CHEAP shape: each read asks
449
458
  * only for chunks past the last index it saw, so a quiet run costs an empty
450
459
  * answer rather than the whole log again.
460
+ *
461
+ * ## A FAILED read is not an absent route, whether or not it carried a status
462
+ *
463
+ * Because the loop stops for good once it decides the route is absent, that
464
+ * decision is the one place a single bad request can cost a live run its entire
465
+ * narration — and it did. Every non-2xx used to read as "this agent serves no
466
+ * progress route", so one transient answer hid the log permanently while the run
467
+ * carried on and the status line went on saying `running`. See
468
+ * {@link isTransientRead} for the split and `readOnce` for what each arm costs.
469
+ */
470
+ /**
471
+ * How often a live run's progress is re-read once a bounded read has ended.
472
+ *
473
+ * **Five seconds, up from one, because narration is the cheapest thing on the
474
+ * page and it was the most expensive thing on the wire.**
475
+ *
476
+ * On the platform every one of these reads BROKERS (see `useWorkflowRun`'s note
477
+ * on the same hazard), and a page routinely mounts TWO of these hooks against one
478
+ * run — `transcription-workflow` renders `<WorkflowProgress>` for the run's own
479
+ * narration and `<LiveTranscript>` for the segments as they land. At one second
480
+ * that is 2 requests/second from a single tab, which is exactly the platform's
481
+ * whole per-IP surface budget (`WORKFLOW_IP_RATE_LIMIT`, 600 per 5 minutes) — so
482
+ * one tab watching one run, with a history entry expanded, answers
483
+ * `429 Too many workflow requests` partway through its own run.
484
+ *
485
+ * It is also contending for the link with the UPLOAD, which on this page is the
486
+ * thing the reader is actually waiting for: a workflow app's whole wall clock is
487
+ * bytes going out, and progress polling spends the same uplink to describe it.
488
+ *
489
+ * What five costs is that a line appears up to five seconds after the run wrote
490
+ * it. That is the right trade for a log a person SKIMS while waiting minutes —
491
+ * and it is not the run's completion, which arrives on `useWorkflowRun`'s event
492
+ * stream (see {@link DEFAULT_WORKFLOW_POLL_MS}, deliberately left at two seconds
493
+ * because it answers "is it done", not "what is it doing").
494
+ *
495
+ * A page that really wants a live feed passes `intervalMs` and owns the
496
+ * consequence. That option is the authoring surface for this choice, which is why
497
+ * this constant is `/internal` rather than public.
451
498
  */
452
- /** How often a live run's progress is re-read once a bounded read has ended. */
453
- const DEFAULT_PROGRESS_POLL_MS = 1e3;
499
+ const DEFAULT_PROGRESS_POLL_MS = 5e3;
500
+ /**
501
+ * Whether a non-2xx says "come back" rather than "there is nothing here".
502
+ *
503
+ * The 408/429/5xx split, and it is a THIRD copy of that rule stated
504
+ * deliberately: the SDK's own `isTransientStatus` is on
505
+ * `@alexkroman1/aai/step` and `RETRYABLE_STATUS` is `sdk/_upload-retry.ts`'s
506
+ * internal, so neither is reachable from a browser bundle — this package may
507
+ * not import the step surface, and an `_`-prefixed module may not be imported
508
+ * cross-package at all. Hoisting one of them onto `/utils` (where this guide's
509
+ * own prose already claims `isTransientStatus` lives) is the fix that would
510
+ * delete this; it is a published-surface change and therefore not this one.
511
+ *
512
+ * Everything else is treated as a stable answer, which keeps a permanent
513
+ * refusal — a 401 against an agent whose `AAI_WORKFLOW_API_TOKEN` this page has
514
+ * no token for — from brokering a request every interval for as long as the tab
515
+ * is open. A 404 is the specific case the route documents: an agent deployed
516
+ * before progress streams existed, or one serving no workflow API at all.
517
+ */
518
+ function isTransientRead(status) {
519
+ return status === 408 || status === 429 || status >= 500;
520
+ }
454
521
  /**
455
522
  * Drain one bounded read's frames, reporting how it ended and everything it
456
523
  * carried.
@@ -487,6 +554,15 @@ async function consumeFrames(body, signal) {
487
554
  * poll cheap: a quiet run answers with a bare `done` rather than the whole log
488
555
  * again.
489
556
  *
557
+ * `next` is a COUNT of chunks consumed, and `startIndex` is an INCLUSIVE floor,
558
+ * so the two are the same number and no adjustment sits between them. That
559
+ * identity is the whole correctness argument here, and it is why the store's
560
+ * floor is inclusive rather than exclusive — read exclusively, this loop lost the
561
+ * chunk sitting AT its cursor on every re-open, so a run writing one line per
562
+ * poll delivered every other line.
563
+ * `packages/aai-runtime/workflow-stream-cursor.test.ts` states it as a property
564
+ * over generated polling schedules; this module's own spec pins the URLs.
565
+ *
490
566
  * ## A negative `startIndex` is resolved on the FIRST read, not carried
491
567
  *
492
568
  * "The last N lines" names no position a later read can resume from — the tail
@@ -518,6 +594,7 @@ function readProgressUntilComplete(getClient, runId, options, intervalMs, onChun
518
594
  }),
519
595
  signal
520
596
  });
597
+ if (!res.ok && isTransientRead(res.status)) return "partial";
521
598
  if (!(res.ok && res.body)) return "unsupported";
522
599
  const { ending, chunks } = await consumeFrames(res.body, signal);
523
600
  next += chunks.length;
@@ -40,6 +40,15 @@
40
40
  * log rather than a socket — but it is a poll of a CHEAP shape: each read asks
41
41
  * only for chunks past the last index it saw, so a quiet run costs an empty
42
42
  * answer rather than the whole log again.
43
+ *
44
+ * ## A FAILED read is not an absent route, whether or not it carried a status
45
+ *
46
+ * Because the loop stops for good once it decides the route is absent, that
47
+ * decision is the one place a single bad request can cost a live run its entire
48
+ * narration — and it did. Every non-2xx used to read as "this agent serves no
49
+ * progress route", so one transient answer hid the log permanently while the run
50
+ * carried on and the status line went on saying `running`. See
51
+ * {@link isTransientRead} for the split and `readOnce` for what each arm costs.
43
52
  */
44
53
  import type { WorkflowApi } from "./workflow-client.ts";
45
54
  /** The slice of the client this needs: one method. */
@@ -60,8 +69,36 @@ export type UseWorkflowProgressResult<T = string> = {
60
69
  */
61
70
  supported: boolean;
62
71
  };
63
- /** How often a live run's progress is re-read once a bounded read has ended. */
64
- export declare const DEFAULT_PROGRESS_POLL_MS = 1000;
72
+ /**
73
+ * How often a live run's progress is re-read once a bounded read has ended.
74
+ *
75
+ * **Five seconds, up from one, because narration is the cheapest thing on the
76
+ * page and it was the most expensive thing on the wire.**
77
+ *
78
+ * On the platform every one of these reads BROKERS (see `useWorkflowRun`'s note
79
+ * on the same hazard), and a page routinely mounts TWO of these hooks against one
80
+ * run — `transcription-workflow` renders `<WorkflowProgress>` for the run's own
81
+ * narration and `<LiveTranscript>` for the segments as they land. At one second
82
+ * that is 2 requests/second from a single tab, which is exactly the platform's
83
+ * whole per-IP surface budget (`WORKFLOW_IP_RATE_LIMIT`, 600 per 5 minutes) — so
84
+ * one tab watching one run, with a history entry expanded, answers
85
+ * `429 Too many workflow requests` partway through its own run.
86
+ *
87
+ * It is also contending for the link with the UPLOAD, which on this page is the
88
+ * thing the reader is actually waiting for: a workflow app's whole wall clock is
89
+ * bytes going out, and progress polling spends the same uplink to describe it.
90
+ *
91
+ * What five costs is that a line appears up to five seconds after the run wrote
92
+ * it. That is the right trade for a log a person SKIMS while waiting minutes —
93
+ * and it is not the run's completion, which arrives on `useWorkflowRun`'s event
94
+ * stream (see {@link DEFAULT_WORKFLOW_POLL_MS}, deliberately left at two seconds
95
+ * because it answers "is it done", not "what is it doing").
96
+ *
97
+ * A page that really wants a live feed passes `intervalMs` and owns the
98
+ * consequence. That option is the authoring surface for this choice, which is why
99
+ * this constant is `/internal` rather than public.
100
+ */
101
+ export declare const DEFAULT_PROGRESS_POLL_MS = 5000;
65
102
  /**
66
103
  * Follow one run's progress stream.
67
104
  *
@@ -38,6 +38,15 @@ export type UseWorkflowRunResult<R = unknown> = {
38
38
  * not: it can complete while the tab is closed, on a different sandbox, hours
39
39
  * later. There is no session to reconnect — the id is the whole state.
40
40
  *
41
+ * Which is also the limit of what this hook can do on its own. An id is state a
42
+ * RELOAD destroys, so a page holding nothing else comes back unable to name a
43
+ * run that is still going. The durable handle is `StartOptions.key`, read back
44
+ * with `find(workflow, key)`, and the hook that owns the id is where that
45
+ * belongs: `useWorkflowSubmit({ key, recover: true })` adopts the key's newest
46
+ * run as it mounts and passes the id here. See `_recover-run.ts` — the reason
47
+ * recovery is NOT in this hook is `reset()`, which leaves the owner holding no
48
+ * id on purpose, and a watcher that re-resolved one from a key would undo it.
49
+ *
41
50
  * The stream (`GET /runs/:id/events`) is tried first and the poll is its
42
51
  * fallback, so an agent deployed before that route existed still works. Watching
43
52
  * STOPS on a terminal status, so a finished run costs nothing; passing
@@ -86,13 +86,22 @@ import type { SubmitInputOf } from "./workflow-def-types.ts";
86
86
  * mode: it holds the `POST` open until the run settles, and here the run is
87
87
  * started before its bytes are, so there is nothing left to hold it for.
88
88
  *
89
+ * Without `recover` either, and REFUSED rather than ignored: an option a hook
90
+ * accepts and does nothing with is the silent-no-op failure this repo keeps
91
+ * paying for. Adopting an earlier run by key would hand this hook a run whose
92
+ * input names an upload id it did not mint and is not filling — so the run
93
+ * would sit waiting for bytes nobody is sending until its own abandonment
94
+ * bound. That is the same reason `_upload-recall.ts` deliberately does not
95
+ * recall for this hook, one layer up: here the id is part of a run's INPUT.
96
+ * `key` itself still works, and still makes the run findable.
97
+ *
89
98
  * `parallel` COMPOSES with what this hook is for rather than competing with it.
90
99
  * The run still starts before the bytes, and the store still publishes how far
91
100
  * the file is readable — that number is the CONTIGUOUS prefix, so a run reading
92
101
  * ahead of the uplink sees the same growing file whether one connection or four
93
102
  * are filling it. What changes is only how fast it grows.
94
103
  */
95
- export type UseWorkflowStreamOptions = Omit<UseWorkflowSubmitOptions, "wait">;
104
+ export type UseWorkflowStreamOptions = Omit<UseWorkflowSubmitOptions, "wait" | "recover">;
96
105
  /**
97
106
  * What {@link useWorkflowStream} returns: a {@link WorkflowSubmission}, exactly.
98
107
  *