@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/_upload-files.d.ts +83 -0
- package/dist/_upload-recall.d.ts +67 -0
- package/dist/_upload-session.d.ts +99 -0
- package/dist/components/upload-progress.d.ts +14 -1
- package/dist/default-client/assets/index-DTLrhtTF.css +2 -0
- package/dist/default-client/index.html +2 -2
- package/dist/hooks.d.ts +37 -1
- package/dist/hooks.js +4 -1
- package/dist/index.js +595 -143
- package/dist/use-workflow-form.d.ts +25 -0
- package/dist/use-workflow-stream.d.ts +32 -6
- package/package.json +2 -2
- package/dist/default-client/assets/index-BZlePk3y.css +0 -2
- /package/dist/default-client/assets/{index-CepvxbNU.js → index-DXODx_9r.js} +0 -0
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:
|
|
633
|
-
}), /* @__PURE__ */
|
|
634
|
-
className: "shrink-0
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
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
|
|
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.
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
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
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
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
|
*
|