@alexkroman1/aai-ui 6.7.1 → 6.8.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.
- package/dist/_upload-files.d.ts +67 -0
- package/dist/_upload-session.d.ts +99 -0
- package/dist/components/_form-readiness.d.ts +56 -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/index.js +508 -149
- 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
|
@@ -14,12 +14,98 @@ import { client, mountRoot, resolveContainer } from "./define-client.js";
|
|
|
14
14
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
15
15
|
import clsx from "clsx";
|
|
16
16
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
17
|
-
import { createElement, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
17
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
|
|
18
18
|
import { errorMessage, isTerminal, isTerminal as isTerminal$1 } from "@alexkroman1/aai";
|
|
19
19
|
import { isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
|
|
20
20
|
import { createWorkflowApiClient } from "@alexkroman1/aai/workflow-api";
|
|
21
21
|
import { createParser } from "eventsource-parser";
|
|
22
22
|
import { createEpoch } from "@alexkroman1/aai/internal";
|
|
23
|
+
//#region components/_form-readiness.ts
|
|
24
|
+
/**
|
|
25
|
+
* Whether a form's own fields exist yet.
|
|
26
|
+
*
|
|
27
|
+
* `<Form>` leans entirely on NATIVE validation — it is a real `<form>` with no
|
|
28
|
+
* `noValidate`, so a `required` field is what stops an empty submit, and the
|
|
29
|
+
* module doc next door says so. That has one hole, and it is the whole reason
|
|
30
|
+
* this exists: a field set declared REMOTELY is not in the DOM while its
|
|
31
|
+
* declaration is in flight, so there is nothing for the browser to validate and
|
|
32
|
+
* an empty submit sails through.
|
|
33
|
+
*
|
|
34
|
+
* It is not theoretical. `<WorkflowFields>` renders `null` until the workflow
|
|
35
|
+
* listing lands, so the transcription desk's first click — before the one-request
|
|
36
|
+
* lookup answered — submitted a form holding only its button. The browser was
|
|
37
|
+
* happy, the payload was `{}`, and the run was refused by the agent with
|
|
38
|
+
* `Invalid input for workflow "transcribeStream": recording: Invalid input`: a
|
|
39
|
+
* schema complaint about a field the person had not been shown, naming a workflow
|
|
40
|
+
* they did not choose by name, for a file picker that appeared a moment later.
|
|
41
|
+
*
|
|
42
|
+
* ## Readiness is DECLARED by the children, because only they know
|
|
43
|
+
*
|
|
44
|
+
* `Form` cannot ask. It renders `{children}` and reads the DOM on submit, and a
|
|
45
|
+
* pending fetch leaves no trace in the DOM at all — which is exactly the
|
|
46
|
+
* difference from `data-aai-read`, the other thing a child tells the form: that
|
|
47
|
+
* one describes an element that EXISTS. So this is a context rather than an
|
|
48
|
+
* attribute, and it carries the one fact a DOM read cannot recover.
|
|
49
|
+
*
|
|
50
|
+
* A form with no such children is ready by definition — `useFormFieldsPending`
|
|
51
|
+
* outside a provider reports nothing pending, so every hand-written form is
|
|
52
|
+
* unaffected and `Form` keeps working outside this package.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Set by `Form`, read by any field set that fetches its own declaration.
|
|
56
|
+
*
|
|
57
|
+
* `undefined` means no `Form` above, which is legal: the fields render, they are
|
|
58
|
+
* simply not gating anything.
|
|
59
|
+
*/
|
|
60
|
+
const FormReadinessContext = createContext(void 0);
|
|
61
|
+
const FormReadinessProvider = FormReadinessContext.Provider;
|
|
62
|
+
/**
|
|
63
|
+
* Track which children are still waiting for their fields.
|
|
64
|
+
*
|
|
65
|
+
* A SET keyed by the child's own `useId`, not a counter: a child that re-renders
|
|
66
|
+
* while pending must not increment twice, and one that unmounts mid-flight must
|
|
67
|
+
* not leave the form disabled forever. Both are the ordinary lifecycle here — a
|
|
68
|
+
* page that switches workflows swaps one `<WorkflowFields>` for another.
|
|
69
|
+
*/
|
|
70
|
+
function useFormReadiness() {
|
|
71
|
+
const [waiting, setWaiting] = useState(() => /* @__PURE__ */ new Set());
|
|
72
|
+
const declare = useCallback((key, pending) => {
|
|
73
|
+
setWaiting((held) => {
|
|
74
|
+
if (pending === held.has(key)) return held;
|
|
75
|
+
const next = new Set(held);
|
|
76
|
+
if (pending) next.add(key);
|
|
77
|
+
else next.delete(key);
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
}, []);
|
|
81
|
+
return {
|
|
82
|
+
pending: waiting.size > 0,
|
|
83
|
+
declare
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Declare from a child that its fields are, or are no longer, still loading.
|
|
88
|
+
*
|
|
89
|
+
* Reports through an EFFECT rather than during render, because this writes to a
|
|
90
|
+
* parent's state — doing it in the render body is the "cannot update a component
|
|
91
|
+
* while rendering a different component" warning, and under StrictMode it is a
|
|
92
|
+
* double report the parent has to be idempotent about anyway. The cleanup
|
|
93
|
+
* releases the claim, so an unmounted field set never holds the form shut.
|
|
94
|
+
*/
|
|
95
|
+
function useDeclareFieldsPending(pending) {
|
|
96
|
+
const declare = useContext(FormReadinessContext);
|
|
97
|
+
const key = useId();
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!declare) return;
|
|
100
|
+
declare(key, pending);
|
|
101
|
+
return () => declare(key, false);
|
|
102
|
+
}, [
|
|
103
|
+
declare,
|
|
104
|
+
key,
|
|
105
|
+
pending
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
23
109
|
//#region components/_form-values.ts
|
|
24
110
|
/**
|
|
25
111
|
* Read one `<form>`'s named controls into a plain object.
|
|
@@ -172,10 +258,11 @@ function dataUrl(file) {
|
|
|
172
258
|
function Form({ onSubmit, error, children, className, ...rest }) {
|
|
173
259
|
const theme = useTheme();
|
|
174
260
|
const [busy, setBusy] = useState(false);
|
|
261
|
+
const { pending: fieldsPending, declare } = useFormReadiness();
|
|
175
262
|
return /* @__PURE__ */ jsxs("form", {
|
|
176
263
|
onSubmit: useCallback((event) => {
|
|
177
264
|
event.preventDefault();
|
|
178
|
-
if (busy) return;
|
|
265
|
+
if (busy || fieldsPending) return;
|
|
179
266
|
const form = event.currentTarget;
|
|
180
267
|
setBusy(true);
|
|
181
268
|
(async () => {
|
|
@@ -185,13 +272,20 @@ function Form({ onSubmit, error, children, className, ...rest }) {
|
|
|
185
272
|
setBusy(false);
|
|
186
273
|
}
|
|
187
274
|
})();
|
|
188
|
-
}, [
|
|
275
|
+
}, [
|
|
276
|
+
busy,
|
|
277
|
+
fieldsPending,
|
|
278
|
+
onSubmit
|
|
279
|
+
]),
|
|
189
280
|
className: clsx("flex flex-col gap-5 font-aai", className),
|
|
190
281
|
...rest,
|
|
191
282
|
children: [/* @__PURE__ */ jsx("fieldset", {
|
|
192
|
-
disabled: busy,
|
|
283
|
+
disabled: busy || fieldsPending,
|
|
193
284
|
className: "contents",
|
|
194
|
-
children
|
|
285
|
+
children: /* @__PURE__ */ jsx(FormReadinessProvider, {
|
|
286
|
+
value: declare,
|
|
287
|
+
children
|
|
288
|
+
})
|
|
195
289
|
}), error !== void 0 && error !== "" && /* @__PURE__ */ jsx("p", {
|
|
196
290
|
role: "alert",
|
|
197
291
|
className: "text-sm",
|
|
@@ -496,6 +590,14 @@ function formatBytes(bytes) {
|
|
|
496
590
|
* - **The file is NAMED, and counted when there is more than one.** Files are
|
|
497
591
|
* sent one after another, so a single bar otherwise appears to restart from
|
|
498
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.
|
|
499
601
|
*
|
|
500
602
|
* @example
|
|
501
603
|
* ```tsx
|
|
@@ -515,16 +617,20 @@ function formatBytes(bytes) {
|
|
|
515
617
|
*
|
|
516
618
|
* @param upload - What `useWorkflowSubmit` reports. `undefined` renders nothing,
|
|
517
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`.
|
|
518
623
|
* @param className - Replaces the default classes rather than extending them,
|
|
519
624
|
* so a custom chrome is not fighting a default it did not ask for.
|
|
520
625
|
*
|
|
521
626
|
* @public
|
|
522
627
|
*/
|
|
523
|
-
function UploadProgressBar({ upload, className }) {
|
|
628
|
+
function UploadProgressBar({ upload, onPause, onResume, className }) {
|
|
524
629
|
const theme = useTheme();
|
|
525
630
|
const labelId = useId();
|
|
526
631
|
if (!upload) return null;
|
|
527
|
-
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;
|
|
528
634
|
const percent = fraction === void 0 ? void 0 : Math.round(fraction * 100);
|
|
529
635
|
const faint = inkTint(theme.text, theme.surface, 65);
|
|
530
636
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -535,11 +641,20 @@ function UploadProgressBar({ upload, className }) {
|
|
|
535
641
|
id: labelId,
|
|
536
642
|
className: "truncate",
|
|
537
643
|
style: { color: faint },
|
|
538
|
-
children:
|
|
539
|
-
}), /* @__PURE__ */
|
|
540
|
-
className: "shrink-0
|
|
541
|
-
|
|
542
|
-
|
|
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
|
+
})]
|
|
543
658
|
})]
|
|
544
659
|
}), /* @__PURE__ */ jsx("div", {
|
|
545
660
|
role: "progressbar",
|
|
@@ -550,7 +665,7 @@ function UploadProgressBar({ upload, className }) {
|
|
|
550
665
|
className: "h-1.5 w-full overflow-hidden rounded-full",
|
|
551
666
|
style: { backgroundColor: inkTint(theme.text, theme.surface, TRACK_TINT_PCT) },
|
|
552
667
|
children: /* @__PURE__ */ jsx("div", {
|
|
553
|
-
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"),
|
|
554
669
|
style: {
|
|
555
670
|
backgroundColor: theme.primary,
|
|
556
671
|
width: percent === void 0 ? "100%" : `${percent}%`
|
|
@@ -560,6 +675,243 @@ function UploadProgressBar({ upload, className }) {
|
|
|
560
675
|
});
|
|
561
676
|
}
|
|
562
677
|
//#endregion
|
|
678
|
+
//#region _upload-session.ts
|
|
679
|
+
/** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
|
|
680
|
+
function isAbortError(err) {
|
|
681
|
+
return err instanceof Error && err.name === "AbortError";
|
|
682
|
+
}
|
|
683
|
+
/** A fresh upload id: a capability, so it is random rather than derived. */
|
|
684
|
+
function randomUploadId() {
|
|
685
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Send one file, waiting out however many pauses the person takes.
|
|
689
|
+
*
|
|
690
|
+
* The loop from the module doc, written once: both hooks need exactly this and a
|
|
691
|
+
* second copy of it is a second place for the abort/pause distinction to be got
|
|
692
|
+
* wrong. `send` is handed whether this attempt must CLAIM the id as its own —
|
|
693
|
+
* false the first time, since a fresh id has nothing to resume and saying
|
|
694
|
+
* otherwise waives the refusal that makes a caller-chosen id safe.
|
|
695
|
+
*
|
|
696
|
+
* Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
|
|
697
|
+
* throws too: the caller distinguishes it by reading `gate.cancelled`, which is
|
|
698
|
+
* how an abandoned submission unwinds without being reported as a failure.
|
|
699
|
+
*/
|
|
700
|
+
async function sendThroughGate(gate, send) {
|
|
701
|
+
let tried = false;
|
|
702
|
+
for (;;) {
|
|
703
|
+
await gate.settle();
|
|
704
|
+
if (gate.cancelled) throw new Error("Upload cancelled.");
|
|
705
|
+
const resume = tried;
|
|
706
|
+
tried = true;
|
|
707
|
+
try {
|
|
708
|
+
await send(resume);
|
|
709
|
+
return;
|
|
710
|
+
} catch (err) {
|
|
711
|
+
if (gate.cancelled || !isAbortError(err)) throw err;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* A gate, open.
|
|
717
|
+
*
|
|
718
|
+
* One per upload rather than one per hook: the id and the windows already stored
|
|
719
|
+
* belong to a file, so a gate that outlived its file would resume something else.
|
|
720
|
+
*/
|
|
721
|
+
function createUploadGate() {
|
|
722
|
+
let controller = new AbortController();
|
|
723
|
+
let paused = false;
|
|
724
|
+
let cancelled = false;
|
|
725
|
+
let open;
|
|
726
|
+
let closed;
|
|
727
|
+
return {
|
|
728
|
+
get paused() {
|
|
729
|
+
return paused;
|
|
730
|
+
},
|
|
731
|
+
get cancelled() {
|
|
732
|
+
return cancelled;
|
|
733
|
+
},
|
|
734
|
+
get signal() {
|
|
735
|
+
return controller.signal;
|
|
736
|
+
},
|
|
737
|
+
pause() {
|
|
738
|
+
if (paused || cancelled) return;
|
|
739
|
+
paused = true;
|
|
740
|
+
const gate = Promise.withResolvers();
|
|
741
|
+
closed = gate.promise;
|
|
742
|
+
open = gate.resolve;
|
|
743
|
+
controller.abort();
|
|
744
|
+
},
|
|
745
|
+
resume() {
|
|
746
|
+
if (!paused || cancelled) return;
|
|
747
|
+
paused = false;
|
|
748
|
+
controller = new AbortController();
|
|
749
|
+
open?.();
|
|
750
|
+
open = void 0;
|
|
751
|
+
closed = void 0;
|
|
752
|
+
},
|
|
753
|
+
cancel() {
|
|
754
|
+
if (cancelled) return;
|
|
755
|
+
cancelled = true;
|
|
756
|
+
paused = false;
|
|
757
|
+
controller.abort();
|
|
758
|
+
open?.();
|
|
759
|
+
open = void 0;
|
|
760
|
+
closed = void 0;
|
|
761
|
+
},
|
|
762
|
+
async settle() {
|
|
763
|
+
if (closed) await closed;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region _workflow-files.ts
|
|
769
|
+
/**
|
|
770
|
+
* Which of a submitted form's values are FILES.
|
|
771
|
+
*
|
|
772
|
+
* Its own module because both submit hooks need the identical answer and then do
|
|
773
|
+
* two different things with it — `useWorkflowSubmit` stores each file and passes
|
|
774
|
+
* its id, `useWorkflowStream` cuts it into parts and passes the group they share.
|
|
775
|
+
* A second copy of this predicate would be a form field that one hook treats as a
|
|
776
|
+
* file and the other does not, which is invisible until the run reads the wrong
|
|
777
|
+
* kind of string.
|
|
778
|
+
*/
|
|
779
|
+
/**
|
|
780
|
+
* The files a submitted field carries, if that is what it carries.
|
|
781
|
+
*
|
|
782
|
+
* An array counts only when it is files ALL the way through — a mixed array is
|
|
783
|
+
* some other field's value that happens to contain one, and turning half of it
|
|
784
|
+
* into ids would corrupt it silently.
|
|
785
|
+
*/
|
|
786
|
+
function filesOf(value) {
|
|
787
|
+
if (value instanceof File) return [value];
|
|
788
|
+
if (!Array.isArray(value)) return [];
|
|
789
|
+
const files = value.filter((one) => one instanceof File);
|
|
790
|
+
return files.length > 0 && files.length === value.length ? files : [];
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The input properties still carrying a `File` — i.e. the ones that CANNOT survive
|
|
794
|
+
* being sent.
|
|
795
|
+
*
|
|
796
|
+
* A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
|
|
797
|
+
* own enumerable properties. So a File left in a payload does not fail to send: it
|
|
798
|
+
* arrives as an empty object, and the workflow rejects it against whatever its own
|
|
799
|
+
* schema says the property should be. Measured in production as
|
|
800
|
+
* `Invalid input for workflow "transcribe": recording: Invalid input` — a message
|
|
801
|
+
* about a type, on a form where the user had picked a perfectly good file.
|
|
802
|
+
*
|
|
803
|
+
* Exported beside {@link filesOf} because it is the same question asked at the
|
|
804
|
+
* other end: that one decides which fields to UPLOAD, this one checks that none
|
|
805
|
+
* were missed. Both hooks are the callers.
|
|
806
|
+
*/
|
|
807
|
+
function fileFields(input) {
|
|
808
|
+
if (!isRecord(input)) return [];
|
|
809
|
+
return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region _upload-files.ts
|
|
813
|
+
/**
|
|
814
|
+
* Turning a form's `File`s into stored upload ids, pauses and all.
|
|
815
|
+
*
|
|
816
|
+
* Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
|
|
817
|
+
* real one: that module is the two HOOKS and the state between them, where this
|
|
818
|
+
* is the walk over a submitted input — which is the only part of it that knows
|
|
819
|
+
* what a `File` is, holds a loop, and survives being re-entered.
|
|
820
|
+
*
|
|
821
|
+
* `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
|
|
822
|
+
* one file rather than walking an input and shares only the gate underneath both
|
|
823
|
+
* (`_upload-session.ts`).
|
|
824
|
+
*/
|
|
825
|
+
/** A fresh session for one submission. */
|
|
826
|
+
function createUploadSession() {
|
|
827
|
+
return {
|
|
828
|
+
ids: /* @__PURE__ */ new Map(),
|
|
829
|
+
stored: /* @__PURE__ */ new Map(),
|
|
830
|
+
tried: /* @__PURE__ */ new Set(),
|
|
831
|
+
gate: createUploadGate()
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Replace every `File` in a submitted form with the id of a stored upload,
|
|
836
|
+
* reporting how far each one has got.
|
|
837
|
+
*
|
|
838
|
+
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
839
|
+
* two 200 MB recordings should send them one after another rather than compete
|
|
840
|
+
* for the same connection. That is also what makes a single bar honest — one
|
|
841
|
+
* file is in flight at a time, and `index`/`count` say which.
|
|
842
|
+
*
|
|
843
|
+
* Anything that is not a `File` (or an array of them) passes through untouched,
|
|
844
|
+
* so this is invisible to every form that has none — including one whose values
|
|
845
|
+
* are not an object at all, which `submit` accepts.
|
|
846
|
+
*
|
|
847
|
+
* ## `uploadStream`, not `upload`, and the id is the reason
|
|
848
|
+
*
|
|
849
|
+
* The difference between the two calls is only who mints the id — and that is
|
|
850
|
+
* exactly what decides whether an interrupted upload can be picked up again. An
|
|
851
|
+
* `upload` mints its own at the END and hands it back, so a caller whose upload
|
|
852
|
+
* died has nothing to name what was stored and no choice but to send the file
|
|
853
|
+
* again. A `uploadStream` is told the id up front, so the windows already in the
|
|
854
|
+
* store are addressable, which is what both a pause and a server restart need.
|
|
855
|
+
*
|
|
856
|
+
* Nothing else about the submission changes: the run is still started after the
|
|
857
|
+
* last byte lands, so the incomplete record a streamed upload leaves along the
|
|
858
|
+
* way is one nobody reads.
|
|
859
|
+
*/
|
|
860
|
+
async function uploadFiles(api, input, report, parallel, session) {
|
|
861
|
+
if (!isRecord(input)) return input;
|
|
862
|
+
const entries = Object.entries(input);
|
|
863
|
+
const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
|
|
864
|
+
let index = 0;
|
|
865
|
+
const store = async (file) => {
|
|
866
|
+
index += 1;
|
|
867
|
+
const done = session.stored.get(file);
|
|
868
|
+
if (done !== void 0) return done;
|
|
869
|
+
let id = session.ids.get(file);
|
|
870
|
+
if (id === void 0) {
|
|
871
|
+
id = randomUploadId();
|
|
872
|
+
session.ids.set(file, id);
|
|
873
|
+
}
|
|
874
|
+
const position = {
|
|
875
|
+
name: file.name,
|
|
876
|
+
index,
|
|
877
|
+
count
|
|
878
|
+
};
|
|
879
|
+
await sendThroughGate(session.gate, async (resume) => {
|
|
880
|
+
await api.uploadStream(id, file, {
|
|
881
|
+
name: file.name,
|
|
882
|
+
signal: session.gate.signal,
|
|
883
|
+
onProgress: (progress) => report({
|
|
884
|
+
...position,
|
|
885
|
+
...progress,
|
|
886
|
+
paused: session.gate.paused
|
|
887
|
+
}),
|
|
888
|
+
...omitUndefined({
|
|
889
|
+
parallel,
|
|
890
|
+
resume: resume ? true : void 0
|
|
891
|
+
})
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
session.stored.set(file, id);
|
|
895
|
+
return id;
|
|
896
|
+
};
|
|
897
|
+
const out = {};
|
|
898
|
+
for (const [name, value] of entries) {
|
|
899
|
+
if (value instanceof File) {
|
|
900
|
+
out[name] = await store(value);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const chosen = filesOf(value);
|
|
904
|
+
if (chosen.length === 0) {
|
|
905
|
+
out[name] = value;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const ids = [];
|
|
909
|
+
for (const file of chosen) ids.push(await store(file));
|
|
910
|
+
out[name] = ids;
|
|
911
|
+
}
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
563
915
|
//#region workflow-client.ts
|
|
564
916
|
/**
|
|
565
917
|
* Create a workflow API client aimed at the agent serving this page.
|
|
@@ -626,50 +978,6 @@ function useWorkflowApiRef(api) {
|
|
|
626
978
|
}, []);
|
|
627
979
|
}
|
|
628
980
|
//#endregion
|
|
629
|
-
//#region _workflow-files.ts
|
|
630
|
-
/**
|
|
631
|
-
* Which of a submitted form's values are FILES.
|
|
632
|
-
*
|
|
633
|
-
* Its own module because both submit hooks need the identical answer and then do
|
|
634
|
-
* two different things with it — `useWorkflowSubmit` stores each file and passes
|
|
635
|
-
* its id, `useWorkflowStream` cuts it into parts and passes the group they share.
|
|
636
|
-
* A second copy of this predicate would be a form field that one hook treats as a
|
|
637
|
-
* file and the other does not, which is invisible until the run reads the wrong
|
|
638
|
-
* kind of string.
|
|
639
|
-
*/
|
|
640
|
-
/**
|
|
641
|
-
* The files a submitted field carries, if that is what it carries.
|
|
642
|
-
*
|
|
643
|
-
* An array counts only when it is files ALL the way through — a mixed array is
|
|
644
|
-
* some other field's value that happens to contain one, and turning half of it
|
|
645
|
-
* into ids would corrupt it silently.
|
|
646
|
-
*/
|
|
647
|
-
function filesOf(value) {
|
|
648
|
-
if (value instanceof File) return [value];
|
|
649
|
-
if (!Array.isArray(value)) return [];
|
|
650
|
-
const files = value.filter((one) => one instanceof File);
|
|
651
|
-
return files.length > 0 && files.length === value.length ? files : [];
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* The input properties still carrying a `File` — i.e. the ones that CANNOT survive
|
|
655
|
-
* being sent.
|
|
656
|
-
*
|
|
657
|
-
* A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
|
|
658
|
-
* own enumerable properties. So a File left in a payload does not fail to send: it
|
|
659
|
-
* arrives as an empty object, and the workflow rejects it against whatever its own
|
|
660
|
-
* schema says the property should be. Measured in production as
|
|
661
|
-
* `Invalid input for workflow "transcribe": recording: Invalid input` — a message
|
|
662
|
-
* about a type, on a form where the user had picked a perfectly good file.
|
|
663
|
-
*
|
|
664
|
-
* Exported beside {@link filesOf} because it is the same question asked at the
|
|
665
|
-
* other end: that one decides which fields to UPLOAD, this one checks that none
|
|
666
|
-
* were missed. Both hooks are the callers.
|
|
667
|
-
*/
|
|
668
|
-
function fileFields(input) {
|
|
669
|
-
if (!isRecord(input)) return [];
|
|
670
|
-
return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
|
|
671
|
-
}
|
|
672
|
-
//#endregion
|
|
673
981
|
//#region _repeat-until.ts
|
|
674
982
|
/**
|
|
675
983
|
* A bounded read, re-armed from the SETTLED read — the loop both workflow
|
|
@@ -1076,56 +1384,6 @@ function useWorkflows(opts = {}) {
|
|
|
1076
1384
|
return state;
|
|
1077
1385
|
}
|
|
1078
1386
|
/**
|
|
1079
|
-
* Replace every `File` in a submitted form with the id of a stored upload,
|
|
1080
|
-
* reporting how far each one has got.
|
|
1081
|
-
*
|
|
1082
|
-
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
1083
|
-
* two 200 MB recordings should send them one after another rather than compete
|
|
1084
|
-
* for the same connection. That is also what makes a single bar honest — one
|
|
1085
|
-
* file is in flight at a time, and `index`/`count` say which.
|
|
1086
|
-
*
|
|
1087
|
-
* Anything that is not a `File` (or an array of them) passes through untouched,
|
|
1088
|
-
* so this is invisible to every form that has none — including one whose values
|
|
1089
|
-
* are not an object at all, which `submit` accepts.
|
|
1090
|
-
*/
|
|
1091
|
-
async function uploadFiles(api, input, report, parallel) {
|
|
1092
|
-
if (!isRecord(input)) return input;
|
|
1093
|
-
const entries = Object.entries(input);
|
|
1094
|
-
const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
|
|
1095
|
-
let index = 0;
|
|
1096
|
-
const store = async (file) => {
|
|
1097
|
-
index += 1;
|
|
1098
|
-
const position = {
|
|
1099
|
-
name: file.name,
|
|
1100
|
-
index,
|
|
1101
|
-
count
|
|
1102
|
-
};
|
|
1103
|
-
return (await api.upload(file, {
|
|
1104
|
-
onProgress: (progress) => report({
|
|
1105
|
-
...position,
|
|
1106
|
-
...progress
|
|
1107
|
-
}),
|
|
1108
|
-
...omitUndefined({ parallel })
|
|
1109
|
-
})).id;
|
|
1110
|
-
};
|
|
1111
|
-
const out = {};
|
|
1112
|
-
for (const [name, value] of entries) {
|
|
1113
|
-
if (value instanceof File) {
|
|
1114
|
-
out[name] = await store(value);
|
|
1115
|
-
continue;
|
|
1116
|
-
}
|
|
1117
|
-
const chosen = filesOf(value);
|
|
1118
|
-
if (chosen.length === 0) {
|
|
1119
|
-
out[name] = value;
|
|
1120
|
-
continue;
|
|
1121
|
-
}
|
|
1122
|
-
const ids = [];
|
|
1123
|
-
for (const file of chosen) ids.push(await store(file));
|
|
1124
|
-
out[name] = ids;
|
|
1125
|
-
}
|
|
1126
|
-
return out;
|
|
1127
|
-
}
|
|
1128
|
-
/**
|
|
1129
1387
|
* Start a workflow from a form, and follow the run it creates.
|
|
1130
1388
|
*
|
|
1131
1389
|
* @typeParam R - The workflow's output type, which is what makes
|
|
@@ -1156,6 +1414,7 @@ function useWorkflowSubmit(workflow, opts = {}) {
|
|
|
1156
1414
|
const [starting, setStarting] = useState(false);
|
|
1157
1415
|
const [startError, setStartError] = useState(void 0);
|
|
1158
1416
|
const [upload, setUpload] = useState(void 0);
|
|
1417
|
+
const session = useRef(void 0);
|
|
1159
1418
|
const getClient = useWorkflowApiRef(api);
|
|
1160
1419
|
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
1161
1420
|
api,
|
|
@@ -1167,18 +1426,24 @@ function useWorkflowSubmit(workflow, opts = {}) {
|
|
|
1167
1426
|
setStarting(true);
|
|
1168
1427
|
setStartError(void 0);
|
|
1169
1428
|
setRunId(void 0);
|
|
1429
|
+
session.current?.gate.cancel();
|
|
1430
|
+
const current = createUploadSession();
|
|
1431
|
+
session.current = current;
|
|
1170
1432
|
try {
|
|
1171
1433
|
const options = omitUndefined({ key });
|
|
1172
|
-
const started = await uploadFiles(client, input, setUpload, parallel);
|
|
1434
|
+
const started = await uploadFiles(client, input, setUpload, parallel, current);
|
|
1173
1435
|
setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
|
|
1174
1436
|
...options,
|
|
1175
1437
|
wait
|
|
1176
1438
|
})).runId);
|
|
1177
1439
|
} catch (err) {
|
|
1178
|
-
setStartError(errorMessage(err));
|
|
1440
|
+
if (!current.gate.cancelled) setStartError(errorMessage(err));
|
|
1179
1441
|
} finally {
|
|
1180
|
-
|
|
1181
|
-
|
|
1442
|
+
if (session.current === current) {
|
|
1443
|
+
session.current = void 0;
|
|
1444
|
+
setStarting(false);
|
|
1445
|
+
setUpload(void 0);
|
|
1446
|
+
}
|
|
1182
1447
|
}
|
|
1183
1448
|
}, [
|
|
1184
1449
|
workflow,
|
|
@@ -1188,10 +1453,26 @@ function useWorkflowSubmit(workflow, opts = {}) {
|
|
|
1188
1453
|
getClient
|
|
1189
1454
|
]),
|
|
1190
1455
|
reset: useCallback(() => {
|
|
1456
|
+
session.current?.gate.cancel();
|
|
1457
|
+
session.current = void 0;
|
|
1191
1458
|
setRunId(void 0);
|
|
1192
1459
|
setStartError(void 0);
|
|
1193
1460
|
setUpload(void 0);
|
|
1194
1461
|
}, []),
|
|
1462
|
+
pauseUpload: useCallback(() => {
|
|
1463
|
+
session.current?.gate.pause();
|
|
1464
|
+
setUpload((current) => current ? {
|
|
1465
|
+
...current,
|
|
1466
|
+
paused: true
|
|
1467
|
+
} : current);
|
|
1468
|
+
}, []),
|
|
1469
|
+
resumeUpload: useCallback(() => {
|
|
1470
|
+
session.current?.gate.resume();
|
|
1471
|
+
setUpload((current) => current ? {
|
|
1472
|
+
...current,
|
|
1473
|
+
paused: false
|
|
1474
|
+
} : current);
|
|
1475
|
+
}, []),
|
|
1195
1476
|
run: tracked.run,
|
|
1196
1477
|
pending: starting || tracked.polling,
|
|
1197
1478
|
upload,
|
|
@@ -1234,8 +1515,9 @@ function useWorkflowSubmit(workflow, opts = {}) {
|
|
|
1234
1515
|
* @public
|
|
1235
1516
|
*/
|
|
1236
1517
|
function WorkflowFields({ workflow }) {
|
|
1237
|
-
const { workflows } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1518
|
+
const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1238
1519
|
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
1520
|
+
useDeclareFieldsPending(loading);
|
|
1239
1521
|
const schema = asObjectSchema(summary?.inputSchema);
|
|
1240
1522
|
if (!schema?.properties) return null;
|
|
1241
1523
|
const required = new Set(schema.required ?? []);
|
|
@@ -1849,18 +2131,33 @@ function useWorkflowRuns(workflow, opts = {}) {
|
|
|
1849
2131
|
* - **Reporting the bytes.** The same `UploadStatus` `useWorkflowSubmit` reports,
|
|
1850
2132
|
* so `<UploadProgressBar>` renders either without knowing which hook it came from.
|
|
1851
2133
|
*
|
|
1852
|
-
* ## A failed upload is RESUMED
|
|
2134
|
+
* ## A failed upload is RESUMED, and only a spent budget cancels the run
|
|
1853
2135
|
*
|
|
1854
2136
|
* An upload that dies stays in the store, incomplete, and `complete` never becomes
|
|
1855
2137
|
* true — so a run left behind polls until its own abandonment bound and then fails,
|
|
1856
2138
|
* minutes after the page has already reported the error.
|
|
1857
2139
|
*
|
|
1858
2140
|
* That used to be the whole story, and it threw away a run and a file together for
|
|
1859
|
-
* what is usually one dropped connection near the end.
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
2141
|
+
* what is usually one dropped connection near the end. **The resume lives in the
|
|
2142
|
+
* SDK now** (`aai/sdk/_upload-resume.ts`): a round that fails for a reason that
|
|
2143
|
+
* looks like an outage is re-entered with `resume: true`, sending only the windows
|
|
2144
|
+
* the store does not already have, on a budget sized to outlast a redeploy. The run
|
|
2145
|
+
* is still waiting on the same id, so a resume that succeeds is invisible to it.
|
|
2146
|
+
*
|
|
2147
|
+
* This hook used to hand-roll one such retry and no longer does — one resume with
|
|
2148
|
+
* no wait in front of it covers a dropped connection and cannot cover the case that
|
|
2149
|
+
* actually strands people, which is the agent restarting underneath the upload.
|
|
2150
|
+
* Cancelling the run is what happens when the whole budget is spent, and it is the
|
|
2151
|
+
* honest end to a submission that did not happen.
|
|
2152
|
+
*
|
|
2153
|
+
* ## Pausing is the same mechanism, asked for
|
|
2154
|
+
*
|
|
2155
|
+
* `pauseUpload()` aborts the bytes in flight and holds the uploader;
|
|
2156
|
+
* `resumeUpload()` sends what is missing. The store cannot tell that from an
|
|
2157
|
+
* outage, because there is nothing to tell apart — see `_upload-session.ts`. The
|
|
2158
|
+
* RUN is untouched either way: it goes on polling the id it was started with, and
|
|
2159
|
+
* `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
|
|
2160
|
+
* pause has become an abandonment.
|
|
1864
2161
|
*/
|
|
1865
2162
|
/**
|
|
1866
2163
|
* Start a workflow run and stream a file into it while it works.
|
|
@@ -1893,6 +2190,7 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
1893
2190
|
const [starting, setStarting] = useState(false);
|
|
1894
2191
|
const [startError, setStartError] = useState(void 0);
|
|
1895
2192
|
const [upload, setUpload] = useState(void 0);
|
|
2193
|
+
const gateRef = useRef(void 0);
|
|
1896
2194
|
const getClient = useWorkflowApiRef(api);
|
|
1897
2195
|
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
1898
2196
|
api,
|
|
@@ -1904,42 +2202,41 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
1904
2202
|
setStarting(true);
|
|
1905
2203
|
setStartError(void 0);
|
|
1906
2204
|
setRunId(void 0);
|
|
2205
|
+
gateRef.current?.cancel();
|
|
2206
|
+
const gate = createUploadGate();
|
|
2207
|
+
gateRef.current = gate;
|
|
1907
2208
|
let started;
|
|
1908
2209
|
try {
|
|
1909
|
-
const field = await uploadField(client, workflow);
|
|
1910
|
-
const chosen = field === void 0 ? void 0 : fileAt(input, field);
|
|
1911
2210
|
const id = randomUploadId();
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2211
|
+
const begun = await beginRun({
|
|
2212
|
+
client,
|
|
2213
|
+
workflow,
|
|
2214
|
+
input,
|
|
2215
|
+
id,
|
|
2216
|
+
...omitUndefined({ key })
|
|
2217
|
+
});
|
|
2218
|
+
started = begun.runId;
|
|
2219
|
+
const chosen = begun.file;
|
|
1918
2220
|
setRunId(started);
|
|
1919
2221
|
if (!chosen) return;
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
}),
|
|
1929
|
-
...omitUndefined({
|
|
1930
|
-
parallel,
|
|
1931
|
-
resume: resume ? true : void 0
|
|
1932
|
-
})
|
|
1933
|
-
});
|
|
1934
|
-
};
|
|
1935
|
-
await send(false).catch(async () => await send(true));
|
|
2222
|
+
await streamFile({
|
|
2223
|
+
client,
|
|
2224
|
+
gate,
|
|
2225
|
+
id,
|
|
2226
|
+
file: chosen,
|
|
2227
|
+
parallel,
|
|
2228
|
+
report: setUpload
|
|
2229
|
+
});
|
|
1936
2230
|
await client.wake(started).catch(() => void 0);
|
|
1937
2231
|
} catch (err) {
|
|
1938
|
-
setStartError(errorMessage(err));
|
|
2232
|
+
if (!gate.cancelled) setStartError(errorMessage(err));
|
|
1939
2233
|
if (started) await client.cancel(started).catch(() => void 0);
|
|
1940
2234
|
} finally {
|
|
1941
|
-
|
|
1942
|
-
|
|
2235
|
+
if (gateRef.current === gate) {
|
|
2236
|
+
gateRef.current = void 0;
|
|
2237
|
+
setStarting(false);
|
|
2238
|
+
setUpload(void 0);
|
|
2239
|
+
}
|
|
1943
2240
|
}
|
|
1944
2241
|
}, [
|
|
1945
2242
|
workflow,
|
|
@@ -1948,10 +2245,26 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
1948
2245
|
getClient
|
|
1949
2246
|
]),
|
|
1950
2247
|
reset: useCallback(() => {
|
|
2248
|
+
gateRef.current?.cancel();
|
|
2249
|
+
gateRef.current = void 0;
|
|
1951
2250
|
setRunId(void 0);
|
|
1952
2251
|
setStartError(void 0);
|
|
1953
2252
|
setUpload(void 0);
|
|
1954
2253
|
}, []),
|
|
2254
|
+
pauseUpload: useCallback(() => {
|
|
2255
|
+
gateRef.current?.pause();
|
|
2256
|
+
setUpload((current) => current ? {
|
|
2257
|
+
...current,
|
|
2258
|
+
paused: true
|
|
2259
|
+
} : current);
|
|
2260
|
+
}, []),
|
|
2261
|
+
resumeUpload: useCallback(() => {
|
|
2262
|
+
gateRef.current?.resume();
|
|
2263
|
+
setUpload((current) => current ? {
|
|
2264
|
+
...current,
|
|
2265
|
+
paused: false
|
|
2266
|
+
} : current);
|
|
2267
|
+
}, []),
|
|
1955
2268
|
run: tracked.run,
|
|
1956
2269
|
pending: starting || tracked.polling,
|
|
1957
2270
|
upload,
|
|
@@ -1959,6 +2272,56 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
1959
2272
|
};
|
|
1960
2273
|
}
|
|
1961
2274
|
/**
|
|
2275
|
+
* Read the declaration, substitute the id, and start the run.
|
|
2276
|
+
*
|
|
2277
|
+
* Everything that has to happen BEFORE a byte moves, which is the inversion this
|
|
2278
|
+
* hook exists for. One small `list()` per submit, deliberately: holding the
|
|
2279
|
+
* listing in state would make a submit before it landed a race, and the failure
|
|
2280
|
+
* mode of that race is a `File` reaching a run input.
|
|
2281
|
+
*/
|
|
2282
|
+
async function beginRun(opts) {
|
|
2283
|
+
const { client, workflow, input, id } = opts;
|
|
2284
|
+
const field = await uploadField(client, workflow);
|
|
2285
|
+
const chosen = field === void 0 ? void 0 : fileAt(input, field);
|
|
2286
|
+
const payload = chosen && field ? {
|
|
2287
|
+
...input,
|
|
2288
|
+
[field]: id
|
|
2289
|
+
} : input;
|
|
2290
|
+
assertSendable(workflow, payload, field);
|
|
2291
|
+
return {
|
|
2292
|
+
runId: await client.start(workflow, payload, omitUndefined({ key: opts.key })),
|
|
2293
|
+
file: chosen
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Send the file, waiting out however many pauses the person takes.
|
|
2298
|
+
*
|
|
2299
|
+
* Its own function rather than a block inside `submit` because `submit` is
|
|
2300
|
+
* already carrying the ORDER this hook exists for — read the declaration, mint
|
|
2301
|
+
* the id, start the run, then the bytes, then the wake — and the sending is the
|
|
2302
|
+
* one step of that list with a loop in it.
|
|
2303
|
+
*/
|
|
2304
|
+
async function streamFile(opts) {
|
|
2305
|
+
const { client, gate, id, file, parallel, report } = opts;
|
|
2306
|
+
await sendThroughGate(gate, async (resume) => {
|
|
2307
|
+
await client.uploadStream(id, file, {
|
|
2308
|
+
name: file.name,
|
|
2309
|
+
signal: gate.signal,
|
|
2310
|
+
onProgress: (progress) => report({
|
|
2311
|
+
...progress,
|
|
2312
|
+
name: file.name,
|
|
2313
|
+
index: 1,
|
|
2314
|
+
count: 1,
|
|
2315
|
+
paused: gate.paused
|
|
2316
|
+
}),
|
|
2317
|
+
...omitUndefined({
|
|
2318
|
+
parallel,
|
|
2319
|
+
resume: resume ? true : void 0
|
|
2320
|
+
})
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
1962
2325
|
* Refuse a payload carrying a `File`, before a run is started over it.
|
|
1963
2326
|
*
|
|
1964
2327
|
* A File cannot be SENT: a run input is JSON and `JSON.stringify(new File(…))` is
|
|
@@ -1985,10 +2348,6 @@ function assertSendable(workflow, payload, field) {
|
|
|
1985
2348
|
const declares = field === void 0 ? "" : ` (it declares "${field}")`;
|
|
1986
2349
|
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.`);
|
|
1987
2350
|
}
|
|
1988
|
-
/** A fresh upload id: a capability, so it is random rather than derived. */
|
|
1989
|
-
function randomUploadId() {
|
|
1990
|
-
return crypto.randomUUID().replaceAll("-", "");
|
|
1991
|
-
}
|
|
1992
2351
|
/**
|
|
1993
2352
|
* Which input property this workflow says carries an upload id.
|
|
1994
2353
|
*
|