@alexkroman1/aai-ui 6.7.2 → 6.9.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/dist/index.js CHANGED
@@ -590,6 +590,14 @@ function formatBytes(bytes) {
590
590
  * - **The file is NAMED, and counted when there is more than one.** Files are
591
591
  * sent one after another, so a single bar otherwise appears to restart from
592
592
  * zero partway through with nothing to say why.
593
+ * - **A paused upload SAYS SO, rather than being a bar that stopped.** Those look
594
+ * identical, which is the whole reason `UploadStatus.paused` exists, and the
595
+ * fill stops animating so the difference is visible without reading.
596
+ *
597
+ * The pause control appears only when a handler for it is passed. That is not
598
+ * politeness about props: a button whose press does nothing is worse than no
599
+ * button, and a page holding an `upload` it did not produce (a saved status, a
600
+ * parent's state) has nothing to pause.
593
601
  *
594
602
  * @example
595
603
  * ```tsx
@@ -609,16 +617,20 @@ function formatBytes(bytes) {
609
617
  *
610
618
  * @param upload - What `useWorkflowSubmit` reports. `undefined` renders nothing,
611
619
  * so a page may pass its state straight through.
620
+ * @param onPause - The hook's `pauseUpload`. Pass it together with `onResume` to
621
+ * get the control; pass neither for a bar that only reports.
622
+ * @param onResume - The hook's `resumeUpload`.
612
623
  * @param className - Replaces the default classes rather than extending them,
613
624
  * so a custom chrome is not fighting a default it did not ask for.
614
625
  *
615
626
  * @public
616
627
  */
617
- function UploadProgressBar({ upload, className }) {
628
+ function UploadProgressBar({ upload, onPause, onResume, className }) {
618
629
  const theme = useTheme();
619
630
  const labelId = useId();
620
631
  if (!upload) return null;
621
- const { name, index, count, loaded, total, fraction } = upload;
632
+ const { name, index, count, loaded, total, fraction, paused } = upload;
633
+ const control = onPause && onResume ? paused ? onResume : onPause : void 0;
622
634
  const percent = fraction === void 0 ? void 0 : Math.round(fraction * 100);
623
635
  const faint = inkTint(theme.text, theme.surface, 65);
624
636
  return /* @__PURE__ */ jsxs("div", {
@@ -629,11 +641,20 @@ function UploadProgressBar({ upload, className }) {
629
641
  id: labelId,
630
642
  className: "truncate",
631
643
  style: { color: faint },
632
- children: count > 1 ? `Uploading ${name} (${index} of ${count})` : `Uploading ${name}`
633
- }), /* @__PURE__ */ jsx("span", {
634
- className: "shrink-0 tabular-nums",
635
- style: { color: faint },
636
- children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
644
+ children: `${paused ? "Paused" : "Uploading"} ${name}${count > 1 ? ` (${index} of ${count})` : ""}`
645
+ }), /* @__PURE__ */ jsxs("div", {
646
+ className: "flex shrink-0 items-baseline gap-3",
647
+ children: [/* @__PURE__ */ jsx("span", {
648
+ className: "tabular-nums",
649
+ style: { color: faint },
650
+ children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
651
+ }), control && /* @__PURE__ */ jsx(Button, {
652
+ type: "button",
653
+ variant: "ghost",
654
+ className: "h-6 px-2 text-[0.625rem]",
655
+ onClick: control,
656
+ children: paused ? "Resume" : "Pause"
657
+ })]
637
658
  })]
638
659
  }), /* @__PURE__ */ jsx("div", {
639
660
  role: "progressbar",
@@ -644,7 +665,7 @@ function UploadProgressBar({ upload, className }) {
644
665
  className: "h-1.5 w-full overflow-hidden rounded-full",
645
666
  style: { backgroundColor: inkTint(theme.text, theme.surface, TRACK_TINT_PCT) },
646
667
  children: /* @__PURE__ */ jsx("div", {
647
- className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && "animate-pulse"),
668
+ className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && !paused && "animate-pulse"),
648
669
  style: {
649
670
  backgroundColor: theme.primary,
650
671
  width: percent === void 0 ? "100%" : `${percent}%`
@@ -654,6 +675,431 @@ function UploadProgressBar({ upload, className }) {
654
675
  });
655
676
  }
656
677
  //#endregion
678
+ //#region _upload-recall.ts
679
+ /**
680
+ * Where an upload's ID survives a page RELOAD.
681
+ *
682
+ * A streamed upload is resumable because its id outlives the attempt that began
683
+ * it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
684
+ * pause a person can press. Both of them hold the id in MEMORY: the walk's
685
+ * `UploadSession` lives in a `useRef`, so a reload was the one interruption the
686
+ * mechanism could not survive. Everything else was already in place — the windows
687
+ * were still in the store, the agent could still name them
688
+ * (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
689
+ * had thrown away the only name for them. So a person who refreshed at 90% of a
690
+ * 200 MB recording sent the whole file again, which is the one interruption they
691
+ * are most likely to cause on purpose.
692
+ *
693
+ * This is that name, written down. It is what tus-js-client's `urlStorage` and
694
+ * Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
695
+ * uses for a session id.
696
+ *
697
+ * ## A FINGERPRINT, because a `File` has no name a page can address
698
+ *
699
+ * A file from a picker carries no path and no handle, so the key is what
700
+ * tus-js-client fingerprints on: size, last-modified, type and name. Two
701
+ * different files agreeing on all four is the case this cannot tell apart — and
702
+ * the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
703
+ * what the id actually holds before sending a byte to it, so a wrong hit costs
704
+ * one `GET` and a fresh id rather than a corrupted upload.
705
+ *
706
+ * ## `sessionStorage`, deliberately
707
+ *
708
+ * The same call `session-resume-store.ts` makes, for a reason that happens to be
709
+ * stronger here: a reload and a same-tab navigation are exactly what this is for,
710
+ * and an id from yesterday names an upload the agent's sweep has very likely
711
+ * already collected. A tab is also the boundary the walk itself has — two tabs
712
+ * uploading the same recording are two submissions.
713
+ *
714
+ * Every access is guarded. Storage throws outright in Safari private mode and
715
+ * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
716
+ * to the upload we would have done anyway rather than failing to start.
717
+ */
718
+ const PREFIX = "aai:upload:";
719
+ /**
720
+ * How many ids one form keeps.
721
+ *
722
+ * A cap rather than an expiry, because `sessionStorage` already expires with the
723
+ * tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
724
+ * submits a hundred files: the oldest go first, and the id most likely to be
725
+ * worth resuming is the one written last.
726
+ */
727
+ const MAX_REMEMBERED = 32;
728
+ /** One form's slot in storage. */
729
+ function keyFor(scope) {
730
+ return `${PREFIX}${scope}`;
731
+ }
732
+ /**
733
+ * What names this file across a reload.
734
+ *
735
+ * The four fields a browser gives a picked file that do not change between loads.
736
+ * `name` last because it is the one a person can read in a debugger.
737
+ */
738
+ function fingerprint(file) {
739
+ return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
740
+ }
741
+ /** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
742
+ function read(scope) {
743
+ try {
744
+ const raw = globalThis.sessionStorage?.getItem(keyFor(scope));
745
+ if (raw === null || raw === void 0) return {};
746
+ const parsed = JSON.parse(raw);
747
+ return isRecord(parsed) ? parsed : {};
748
+ } catch {
749
+ return {};
750
+ }
751
+ }
752
+ function write(scope, entries) {
753
+ try {
754
+ globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
755
+ } catch {}
756
+ }
757
+ /**
758
+ * The id this file was last being stored under in this tab, if any.
759
+ *
760
+ * A hit is a CANDIDATE and never a decision — see the module doc.
761
+ *
762
+ * @internal
763
+ */
764
+ function recallUploadId(scope, file) {
765
+ const found = read(scope)[fingerprint(file)];
766
+ return typeof found === "string" ? found : void 0;
767
+ }
768
+ /**
769
+ * Remember the id this file is being stored under.
770
+ *
771
+ * Called before the first byte leaves rather than after the last one lands: the
772
+ * reload this exists for happens in between, and an id written at the end is an
773
+ * id written for the one case that did not need it.
774
+ *
775
+ * @internal
776
+ */
777
+ function rememberUploadId(scope, file, id) {
778
+ const entries = read(scope);
779
+ const key = fingerprint(file);
780
+ delete entries[key];
781
+ entries[key] = id;
782
+ const keys = Object.keys(entries);
783
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
784
+ write(scope, entries);
785
+ }
786
+ /**
787
+ * Forget it: the agent holds nothing resumable under this id.
788
+ *
789
+ * The other half of the agent deciding. Without it a swept upload is re-read on
790
+ * every submission of the same file for the life of the tab, which is a round
791
+ * trip spent learning the same 404.
792
+ *
793
+ * @internal
794
+ */
795
+ function forgetUploadId(scope, file) {
796
+ const entries = read(scope);
797
+ const key = fingerprint(file);
798
+ if (!(key in entries)) return;
799
+ delete entries[key];
800
+ write(scope, entries);
801
+ }
802
+ //#endregion
803
+ //#region _upload-session.ts
804
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
805
+ function isAbortError(err) {
806
+ return err instanceof Error && err.name === "AbortError";
807
+ }
808
+ /** A fresh upload id: a capability, so it is random rather than derived. */
809
+ function randomUploadId() {
810
+ return crypto.randomUUID().replaceAll("-", "");
811
+ }
812
+ /**
813
+ * Send one file, waiting out however many pauses the person takes.
814
+ *
815
+ * The loop from the module doc, written once: both hooks need exactly this and a
816
+ * second copy of it is a second place for the abort/pause distinction to be got
817
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
818
+ * false the first time, since a fresh id has nothing to resume and saying
819
+ * otherwise waives the refusal that makes a caller-chosen id safe.
820
+ *
821
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
822
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
823
+ * how an abandoned submission unwinds without being reported as a failure.
824
+ */
825
+ async function sendThroughGate(gate, send) {
826
+ let tried = false;
827
+ for (;;) {
828
+ await gate.settle();
829
+ if (gate.cancelled) throw new Error("Upload cancelled.");
830
+ const resume = tried;
831
+ tried = true;
832
+ try {
833
+ await send(resume);
834
+ return;
835
+ } catch (err) {
836
+ if (gate.cancelled || !isAbortError(err)) throw err;
837
+ }
838
+ }
839
+ }
840
+ /**
841
+ * A gate, open.
842
+ *
843
+ * One per upload rather than one per hook: the id and the windows already stored
844
+ * belong to a file, so a gate that outlived its file would resume something else.
845
+ */
846
+ function createUploadGate() {
847
+ let controller = new AbortController();
848
+ let paused = false;
849
+ let cancelled = false;
850
+ let open;
851
+ let closed;
852
+ return {
853
+ get paused() {
854
+ return paused;
855
+ },
856
+ get cancelled() {
857
+ return cancelled;
858
+ },
859
+ get signal() {
860
+ return controller.signal;
861
+ },
862
+ pause() {
863
+ if (paused || cancelled) return;
864
+ paused = true;
865
+ const gate = Promise.withResolvers();
866
+ closed = gate.promise;
867
+ open = gate.resolve;
868
+ controller.abort();
869
+ },
870
+ resume() {
871
+ if (!paused || cancelled) return;
872
+ paused = false;
873
+ controller = new AbortController();
874
+ open?.();
875
+ open = void 0;
876
+ closed = void 0;
877
+ },
878
+ cancel() {
879
+ if (cancelled) return;
880
+ cancelled = true;
881
+ paused = false;
882
+ controller.abort();
883
+ open?.();
884
+ open = void 0;
885
+ closed = void 0;
886
+ },
887
+ async settle() {
888
+ if (closed) await closed;
889
+ }
890
+ };
891
+ }
892
+ //#endregion
893
+ //#region _workflow-files.ts
894
+ /**
895
+ * Which of a submitted form's values are FILES.
896
+ *
897
+ * Its own module because both submit hooks need the identical answer and then do
898
+ * two different things with it — `useWorkflowSubmit` stores each file and passes
899
+ * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
900
+ * A second copy of this predicate would be a form field that one hook treats as a
901
+ * file and the other does not, which is invisible until the run reads the wrong
902
+ * kind of string.
903
+ */
904
+ /**
905
+ * The files a submitted field carries, if that is what it carries.
906
+ *
907
+ * An array counts only when it is files ALL the way through — a mixed array is
908
+ * some other field's value that happens to contain one, and turning half of it
909
+ * into ids would corrupt it silently.
910
+ */
911
+ function filesOf(value) {
912
+ if (value instanceof File) return [value];
913
+ if (!Array.isArray(value)) return [];
914
+ const files = value.filter((one) => one instanceof File);
915
+ return files.length > 0 && files.length === value.length ? files : [];
916
+ }
917
+ /**
918
+ * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
919
+ * being sent.
920
+ *
921
+ * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
922
+ * own enumerable properties. So a File left in a payload does not fail to send: it
923
+ * arrives as an empty object, and the workflow rejects it against whatever its own
924
+ * schema says the property should be. Measured in production as
925
+ * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
926
+ * about a type, on a form where the user had picked a perfectly good file.
927
+ *
928
+ * Exported beside {@link filesOf} because it is the same question asked at the
929
+ * other end: that one decides which fields to UPLOAD, this one checks that none
930
+ * were missed. Both hooks are the callers.
931
+ */
932
+ function fileFields(input) {
933
+ if (!isRecord(input)) return [];
934
+ return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
935
+ }
936
+ //#endregion
937
+ //#region _upload-files.ts
938
+ /**
939
+ * Turning a form's `File`s into stored upload ids, pauses and all.
940
+ *
941
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
942
+ * real one: that module is the two HOOKS and the state between them, where this
943
+ * is the walk over a submitted input — which is the only part of it that knows
944
+ * what a `File` is, holds a loop, and survives being re-entered.
945
+ *
946
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
947
+ * one file rather than walking an input and shares only the gate underneath both
948
+ * (`_upload-session.ts`).
949
+ */
950
+ /** A fresh session for one submission of `workflow`. */
951
+ function createUploadSession(workflow) {
952
+ return {
953
+ scope: workflow,
954
+ ids: /* @__PURE__ */ new Map(),
955
+ stored: /* @__PURE__ */ new Map(),
956
+ tried: /* @__PURE__ */ new Set(),
957
+ gate: createUploadGate()
958
+ };
959
+ }
960
+ /**
961
+ * The id to store this file under: the one a previous page load was using, or a
962
+ * fresh one.
963
+ *
964
+ * **The AGENT decides, never the fingerprint.** Two files can agree on every
965
+ * field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
966
+ * be checked before a byte is sent to it — and the check is cheap and exact,
967
+ * because `uploadInfo` is the same record a resume already reads.
968
+ *
969
+ * Three answers, and the third is why this is not just a storage lookup:
970
+ *
971
+ * - **Complete.** Every byte is in from a load that is gone, so there is nothing
972
+ * to send: the caller takes the id and starts the run. This is the refresh that
973
+ * costs one `GET` instead of a second 200 MB upload.
974
+ * - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
975
+ * upload resumable at all, so the id is reused and the attempt claims it.
976
+ * - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
977
+ * upload reporting NO windows — which is a partial single `PUT`, and a second
978
+ * `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
979
+ * it would turn a reload into a failure the person cannot clear, so the entry is
980
+ * dropped and the file gets a fresh id.
981
+ */
982
+ async function claimId(api, session, file) {
983
+ const remembered = recallUploadId(session.scope, file);
984
+ if (remembered === void 0) return {
985
+ id: randomUploadId(),
986
+ complete: false
987
+ };
988
+ const info = await api.uploadInfo(remembered).catch(() => void 0);
989
+ if (info?.complete === true) return {
990
+ id: remembered,
991
+ complete: true
992
+ };
993
+ if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
994
+ session.tried.add(file);
995
+ return {
996
+ id: remembered,
997
+ complete: false
998
+ };
999
+ }
1000
+ forgetUploadId(session.scope, file);
1001
+ return {
1002
+ id: randomUploadId(),
1003
+ complete: false
1004
+ };
1005
+ }
1006
+ /**
1007
+ * Replace every `File` in a submitted form with the id of a stored upload,
1008
+ * reporting how far each one has got.
1009
+ *
1010
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
1011
+ * two 200 MB recordings should send them one after another rather than compete
1012
+ * for the same connection. That is also what makes a single bar honest — one
1013
+ * file is in flight at a time, and `index`/`count` say which.
1014
+ *
1015
+ * Anything that is not a `File` (or an array of them) passes through untouched,
1016
+ * so this is invisible to every form that has none — including one whose values
1017
+ * are not an object at all, which `submit` accepts.
1018
+ *
1019
+ * ## `uploadStream`, not `upload`, and the id is the reason
1020
+ *
1021
+ * The difference between the two calls is only who mints the id — and that is
1022
+ * exactly what decides whether an interrupted upload can be picked up again. An
1023
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
1024
+ * died has nothing to name what was stored and no choice but to send the file
1025
+ * again. A `uploadStream` is told the id up front, so the windows already in the
1026
+ * store are addressable, which is what both a pause and a server restart need.
1027
+ *
1028
+ * Nothing else about the submission changes: the run is still started after the
1029
+ * last byte lands, so the incomplete record a streamed upload leaves along the
1030
+ * way is one nobody reads.
1031
+ */
1032
+ async function uploadFiles(api, input, report, parallel, session) {
1033
+ if (!isRecord(input)) return input;
1034
+ const entries = Object.entries(input);
1035
+ const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1036
+ let index = 0;
1037
+ const store = async (file) => {
1038
+ index += 1;
1039
+ const done = session.stored.get(file);
1040
+ if (done !== void 0) return done;
1041
+ const position = {
1042
+ name: file.name,
1043
+ index,
1044
+ count
1045
+ };
1046
+ const known = session.ids.get(file);
1047
+ const claimed = known === void 0 ? await claimId(api, session, file) : {
1048
+ id: known,
1049
+ complete: false
1050
+ };
1051
+ const id = claimed.id;
1052
+ if (known === void 0) {
1053
+ session.ids.set(file, id);
1054
+ rememberUploadId(session.scope, file, id);
1055
+ }
1056
+ if (claimed.complete) {
1057
+ report({
1058
+ ...position,
1059
+ loaded: file.size,
1060
+ total: file.size,
1061
+ fraction: 1,
1062
+ paused: false
1063
+ });
1064
+ session.stored.set(file, id);
1065
+ return id;
1066
+ }
1067
+ await sendThroughGate(session.gate, async (resume) => {
1068
+ await api.uploadStream(id, file, {
1069
+ name: file.name,
1070
+ signal: session.gate.signal,
1071
+ onProgress: (progress) => report({
1072
+ ...position,
1073
+ ...progress,
1074
+ paused: session.gate.paused
1075
+ }),
1076
+ ...omitUndefined({
1077
+ parallel,
1078
+ resume: resume || session.tried.has(file) ? true : void 0
1079
+ })
1080
+ });
1081
+ });
1082
+ session.stored.set(file, id);
1083
+ return id;
1084
+ };
1085
+ const out = {};
1086
+ for (const [name, value] of entries) {
1087
+ if (value instanceof File) {
1088
+ out[name] = await store(value);
1089
+ continue;
1090
+ }
1091
+ const chosen = filesOf(value);
1092
+ if (chosen.length === 0) {
1093
+ out[name] = value;
1094
+ continue;
1095
+ }
1096
+ const ids = [];
1097
+ for (const file of chosen) ids.push(await store(file));
1098
+ out[name] = ids;
1099
+ }
1100
+ return out;
1101
+ }
1102
+ //#endregion
657
1103
  //#region workflow-client.ts
658
1104
  /**
659
1105
  * Create a workflow API client aimed at the agent serving this page.
@@ -720,50 +1166,6 @@ function useWorkflowApiRef(api) {
720
1166
  }, []);
721
1167
  }
722
1168
  //#endregion
723
- //#region _workflow-files.ts
724
- /**
725
- * Which of a submitted form's values are FILES.
726
- *
727
- * Its own module because both submit hooks need the identical answer and then do
728
- * two different things with it — `useWorkflowSubmit` stores each file and passes
729
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
730
- * A second copy of this predicate would be a form field that one hook treats as a
731
- * file and the other does not, which is invisible until the run reads the wrong
732
- * kind of string.
733
- */
734
- /**
735
- * The files a submitted field carries, if that is what it carries.
736
- *
737
- * An array counts only when it is files ALL the way through — a mixed array is
738
- * some other field's value that happens to contain one, and turning half of it
739
- * into ids would corrupt it silently.
740
- */
741
- function filesOf(value) {
742
- if (value instanceof File) return [value];
743
- if (!Array.isArray(value)) return [];
744
- const files = value.filter((one) => one instanceof File);
745
- return files.length > 0 && files.length === value.length ? files : [];
746
- }
747
- /**
748
- * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
749
- * being sent.
750
- *
751
- * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
752
- * own enumerable properties. So a File left in a payload does not fail to send: it
753
- * arrives as an empty object, and the workflow rejects it against whatever its own
754
- * schema says the property should be. Measured in production as
755
- * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
756
- * about a type, on a form where the user had picked a perfectly good file.
757
- *
758
- * Exported beside {@link filesOf} because it is the same question asked at the
759
- * other end: that one decides which fields to UPLOAD, this one checks that none
760
- * were missed. Both hooks are the callers.
761
- */
762
- function fileFields(input) {
763
- if (!isRecord(input)) return [];
764
- return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
765
- }
766
- //#endregion
767
1169
  //#region _repeat-until.ts
768
1170
  /**
769
1171
  * A bounded read, re-armed from the SETTLED read — the loop both workflow
@@ -1170,56 +1572,6 @@ function useWorkflows(opts = {}) {
1170
1572
  return state;
1171
1573
  }
1172
1574
  /**
1173
- * Replace every `File` in a submitted form with the id of a stored upload,
1174
- * reporting how far each one has got.
1175
- *
1176
- * Sequential rather than `Promise.all`: these are large bodies, and a form with
1177
- * two 200 MB recordings should send them one after another rather than compete
1178
- * for the same connection. That is also what makes a single bar honest — one
1179
- * file is in flight at a time, and `index`/`count` say which.
1180
- *
1181
- * Anything that is not a `File` (or an array of them) passes through untouched,
1182
- * so this is invisible to every form that has none — including one whose values
1183
- * are not an object at all, which `submit` accepts.
1184
- */
1185
- async function uploadFiles(api, input, report, parallel) {
1186
- if (!isRecord(input)) return input;
1187
- const entries = Object.entries(input);
1188
- const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1189
- let index = 0;
1190
- const store = async (file) => {
1191
- index += 1;
1192
- const position = {
1193
- name: file.name,
1194
- index,
1195
- count
1196
- };
1197
- return (await api.upload(file, {
1198
- onProgress: (progress) => report({
1199
- ...position,
1200
- ...progress
1201
- }),
1202
- ...omitUndefined({ parallel })
1203
- })).id;
1204
- };
1205
- const out = {};
1206
- for (const [name, value] of entries) {
1207
- if (value instanceof File) {
1208
- out[name] = await store(value);
1209
- continue;
1210
- }
1211
- const chosen = filesOf(value);
1212
- if (chosen.length === 0) {
1213
- out[name] = value;
1214
- continue;
1215
- }
1216
- const ids = [];
1217
- for (const file of chosen) ids.push(await store(file));
1218
- out[name] = ids;
1219
- }
1220
- return out;
1221
- }
1222
- /**
1223
1575
  * Start a workflow from a form, and follow the run it creates.
1224
1576
  *
1225
1577
  * @typeParam R - The workflow's output type, which is what makes
@@ -1250,6 +1602,7 @@ function useWorkflowSubmit(workflow, opts = {}) {
1250
1602
  const [starting, setStarting] = useState(false);
1251
1603
  const [startError, setStartError] = useState(void 0);
1252
1604
  const [upload, setUpload] = useState(void 0);
1605
+ const session = useRef(void 0);
1253
1606
  const getClient = useWorkflowApiRef(api);
1254
1607
  const tracked = useWorkflowRun(runId, omitUndefined({
1255
1608
  api,
@@ -1261,18 +1614,24 @@ function useWorkflowSubmit(workflow, opts = {}) {
1261
1614
  setStarting(true);
1262
1615
  setStartError(void 0);
1263
1616
  setRunId(void 0);
1617
+ session.current?.gate.cancel();
1618
+ const current = createUploadSession(workflow);
1619
+ session.current = current;
1264
1620
  try {
1265
1621
  const options = omitUndefined({ key });
1266
- const started = await uploadFiles(client, input, setUpload, parallel);
1622
+ const started = await uploadFiles(client, input, setUpload, parallel, current);
1267
1623
  setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
1268
1624
  ...options,
1269
1625
  wait
1270
1626
  })).runId);
1271
1627
  } catch (err) {
1272
- setStartError(errorMessage(err));
1628
+ if (!current.gate.cancelled) setStartError(errorMessage(err));
1273
1629
  } finally {
1274
- setStarting(false);
1275
- setUpload(void 0);
1630
+ if (session.current === current) {
1631
+ session.current = void 0;
1632
+ setStarting(false);
1633
+ setUpload(void 0);
1634
+ }
1276
1635
  }
1277
1636
  }, [
1278
1637
  workflow,
@@ -1282,10 +1641,26 @@ function useWorkflowSubmit(workflow, opts = {}) {
1282
1641
  getClient
1283
1642
  ]),
1284
1643
  reset: useCallback(() => {
1644
+ session.current?.gate.cancel();
1645
+ session.current = void 0;
1285
1646
  setRunId(void 0);
1286
1647
  setStartError(void 0);
1287
1648
  setUpload(void 0);
1288
1649
  }, []),
1650
+ pauseUpload: useCallback(() => {
1651
+ session.current?.gate.pause();
1652
+ setUpload((current) => current ? {
1653
+ ...current,
1654
+ paused: true
1655
+ } : current);
1656
+ }, []),
1657
+ resumeUpload: useCallback(() => {
1658
+ session.current?.gate.resume();
1659
+ setUpload((current) => current ? {
1660
+ ...current,
1661
+ paused: false
1662
+ } : current);
1663
+ }, []),
1289
1664
  run: tracked.run,
1290
1665
  pending: starting || tracked.polling,
1291
1666
  upload,
@@ -1944,18 +2319,33 @@ function useWorkflowRuns(workflow, opts = {}) {
1944
2319
  * - **Reporting the bytes.** The same `UploadStatus` `useWorkflowSubmit` reports,
1945
2320
  * so `<UploadProgressBar>` renders either without knowing which hook it came from.
1946
2321
  *
1947
- * ## A failed upload is RESUMED once, and only then cancels the run
2322
+ * ## A failed upload is RESUMED, and only a spent budget cancels the run
1948
2323
  *
1949
2324
  * An upload that dies stays in the store, incomplete, and `complete` never becomes
1950
2325
  * true — so a run left behind polls until its own abandonment bound and then fails,
1951
2326
  * minutes after the page has already reported the error.
1952
2327
  *
1953
2328
  * That used to be the whole story, and it threw away a run and a file together for
1954
- * what is usually one dropped connection near the end. So a failure gets one more
1955
- * attempt with `resume: true`, which sends only the windows the store does not
1956
- * already have (`UploadInfo.ranges`) — the run is still waiting on the same id, so
1957
- * a resume that succeeds is invisible to it. Cancelling is what happens when THAT
1958
- * fails too, and it is the honest end to a submission that did not happen.
2329
+ * what is usually one dropped connection near the end. **The resume lives in the
2330
+ * SDK now** (`aai/sdk/_upload-resume.ts`): a round that fails for a reason that
2331
+ * looks like an outage is re-entered with `resume: true`, sending only the windows
2332
+ * the store does not already have, on a budget sized to outlast a redeploy. The run
2333
+ * is still waiting on the same id, so a resume that succeeds is invisible to it.
2334
+ *
2335
+ * This hook used to hand-roll one such retry and no longer does — one resume with
2336
+ * no wait in front of it covers a dropped connection and cannot cover the case that
2337
+ * actually strands people, which is the agent restarting underneath the upload.
2338
+ * Cancelling the run is what happens when the whole budget is spent, and it is the
2339
+ * honest end to a submission that did not happen.
2340
+ *
2341
+ * ## Pausing is the same mechanism, asked for
2342
+ *
2343
+ * `pauseUpload()` aborts the bytes in flight and holds the uploader;
2344
+ * `resumeUpload()` sends what is missing. The store cannot tell that from an
2345
+ * outage, because there is nothing to tell apart — see `_upload-session.ts`. The
2346
+ * RUN is untouched either way: it goes on polling the id it was started with, and
2347
+ * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
2348
+ * pause has become an abandonment.
1959
2349
  */
1960
2350
  /**
1961
2351
  * Start a workflow run and stream a file into it while it works.
@@ -1988,6 +2378,7 @@ function useWorkflowStream(workflow, opts = {}) {
1988
2378
  const [starting, setStarting] = useState(false);
1989
2379
  const [startError, setStartError] = useState(void 0);
1990
2380
  const [upload, setUpload] = useState(void 0);
2381
+ const gateRef = useRef(void 0);
1991
2382
  const getClient = useWorkflowApiRef(api);
1992
2383
  const tracked = useWorkflowRun(runId, omitUndefined({
1993
2384
  api,
@@ -1999,42 +2390,41 @@ function useWorkflowStream(workflow, opts = {}) {
1999
2390
  setStarting(true);
2000
2391
  setStartError(void 0);
2001
2392
  setRunId(void 0);
2393
+ gateRef.current?.cancel();
2394
+ const gate = createUploadGate();
2395
+ gateRef.current = gate;
2002
2396
  let started;
2003
2397
  try {
2004
- const field = await uploadField(client, workflow);
2005
- const chosen = field === void 0 ? void 0 : fileAt(input, field);
2006
2398
  const id = randomUploadId();
2007
- const payload = chosen && field ? {
2008
- ...input,
2009
- [field]: id
2010
- } : input;
2011
- assertSendable(workflow, payload, field);
2012
- started = await client.start(workflow, payload, omitUndefined({ key }));
2399
+ const begun = await beginRun({
2400
+ client,
2401
+ workflow,
2402
+ input,
2403
+ id,
2404
+ ...omitUndefined({ key })
2405
+ });
2406
+ started = begun.runId;
2407
+ const chosen = begun.file;
2013
2408
  setRunId(started);
2014
2409
  if (!chosen) return;
2015
- const send = async (resume) => {
2016
- await client.uploadStream(id, chosen, {
2017
- name: chosen.name,
2018
- onProgress: (progress) => setUpload({
2019
- ...progress,
2020
- name: chosen.name,
2021
- index: 1,
2022
- count: 1
2023
- }),
2024
- ...omitUndefined({
2025
- parallel,
2026
- resume: resume ? true : void 0
2027
- })
2028
- });
2029
- };
2030
- await send(false).catch(async () => await send(true));
2410
+ await streamFile({
2411
+ client,
2412
+ gate,
2413
+ id,
2414
+ file: chosen,
2415
+ parallel,
2416
+ report: setUpload
2417
+ });
2031
2418
  await client.wake(started).catch(() => void 0);
2032
2419
  } catch (err) {
2033
- setStartError(errorMessage(err));
2420
+ if (!gate.cancelled) setStartError(errorMessage(err));
2034
2421
  if (started) await client.cancel(started).catch(() => void 0);
2035
2422
  } finally {
2036
- setStarting(false);
2037
- setUpload(void 0);
2423
+ if (gateRef.current === gate) {
2424
+ gateRef.current = void 0;
2425
+ setStarting(false);
2426
+ setUpload(void 0);
2427
+ }
2038
2428
  }
2039
2429
  }, [
2040
2430
  workflow,
@@ -2043,10 +2433,26 @@ function useWorkflowStream(workflow, opts = {}) {
2043
2433
  getClient
2044
2434
  ]),
2045
2435
  reset: useCallback(() => {
2436
+ gateRef.current?.cancel();
2437
+ gateRef.current = void 0;
2046
2438
  setRunId(void 0);
2047
2439
  setStartError(void 0);
2048
2440
  setUpload(void 0);
2049
2441
  }, []),
2442
+ pauseUpload: useCallback(() => {
2443
+ gateRef.current?.pause();
2444
+ setUpload((current) => current ? {
2445
+ ...current,
2446
+ paused: true
2447
+ } : current);
2448
+ }, []),
2449
+ resumeUpload: useCallback(() => {
2450
+ gateRef.current?.resume();
2451
+ setUpload((current) => current ? {
2452
+ ...current,
2453
+ paused: false
2454
+ } : current);
2455
+ }, []),
2050
2456
  run: tracked.run,
2051
2457
  pending: starting || tracked.polling,
2052
2458
  upload,
@@ -2054,6 +2460,56 @@ function useWorkflowStream(workflow, opts = {}) {
2054
2460
  };
2055
2461
  }
2056
2462
  /**
2463
+ * Read the declaration, substitute the id, and start the run.
2464
+ *
2465
+ * Everything that has to happen BEFORE a byte moves, which is the inversion this
2466
+ * hook exists for. One small `list()` per submit, deliberately: holding the
2467
+ * listing in state would make a submit before it landed a race, and the failure
2468
+ * mode of that race is a `File` reaching a run input.
2469
+ */
2470
+ async function beginRun(opts) {
2471
+ const { client, workflow, input, id } = opts;
2472
+ const field = await uploadField(client, workflow);
2473
+ const chosen = field === void 0 ? void 0 : fileAt(input, field);
2474
+ const payload = chosen && field ? {
2475
+ ...input,
2476
+ [field]: id
2477
+ } : input;
2478
+ assertSendable(workflow, payload, field);
2479
+ return {
2480
+ runId: await client.start(workflow, payload, omitUndefined({ key: opts.key })),
2481
+ file: chosen
2482
+ };
2483
+ }
2484
+ /**
2485
+ * Send the file, waiting out however many pauses the person takes.
2486
+ *
2487
+ * Its own function rather than a block inside `submit` because `submit` is
2488
+ * already carrying the ORDER this hook exists for — read the declaration, mint
2489
+ * the id, start the run, then the bytes, then the wake — and the sending is the
2490
+ * one step of that list with a loop in it.
2491
+ */
2492
+ async function streamFile(opts) {
2493
+ const { client, gate, id, file, parallel, report } = opts;
2494
+ await sendThroughGate(gate, async (resume) => {
2495
+ await client.uploadStream(id, file, {
2496
+ name: file.name,
2497
+ signal: gate.signal,
2498
+ onProgress: (progress) => report({
2499
+ ...progress,
2500
+ name: file.name,
2501
+ index: 1,
2502
+ count: 1,
2503
+ paused: gate.paused
2504
+ }),
2505
+ ...omitUndefined({
2506
+ parallel,
2507
+ resume: resume ? true : void 0
2508
+ })
2509
+ });
2510
+ });
2511
+ }
2512
+ /**
2057
2513
  * Refuse a payload carrying a `File`, before a run is started over it.
2058
2514
  *
2059
2515
  * A File cannot be SENT: a run input is JSON and `JSON.stringify(new File(…))` is
@@ -2080,10 +2536,6 @@ function assertSendable(workflow, payload, field) {
2080
2536
  const declares = field === void 0 ? "" : ` (it declares "${field}")`;
2081
2537
  throw new Error(`Cannot start "${workflow}": ${unsendable.join(", ")} ${carries} the workflow does not declare as an upload${declares}. Add the property to \`workflow({ uploads: [...] })\`, or submit an upload id.`);
2082
2538
  }
2083
- /** A fresh upload id: a capability, so it is random rather than derived. */
2084
- function randomUploadId() {
2085
- return crypto.randomUUID().replaceAll("-", "");
2086
- }
2087
2539
  /**
2088
2540
  * Which input property this workflow says carries an upload id.
2089
2541
  *