orcheo-studio 0.24.0 → 0.24.2
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/.bumpversion.cfg +1 -1
- package/dist/assets/{arc-DAv23sxp.js → arc-B5ofENSG.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-D_gUzQPQ.js → architectureDiagram-VXUJARFQ-DxYHdW7c.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-2_hx47qT.js → blockDiagram-VD42YOAC-DJFNLztP.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-dehdDKxT.js → c4Diagram-YG6GDRKO-i42OpjrQ.js} +1 -1
- package/dist/assets/channel-D4idvlBv.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CtLOuP9F.js → chunk-4BX2VUAB-BP38Jeu1.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CwinXV4-.js → chunk-55IACEB6-DgOteLeo.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-BI7iPFRv.js → chunk-B4BG7PRW-DSFMHhnv.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-CNYNrYjW.js → chunk-DI55MBZ5-houO_rDU.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BFVTHaU-.js → chunk-FMBD7UC4-2uWxVMuK.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-xWy7sSv1.js → chunk-QN33PNHL-CNE7iDzV.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Ds_R1QaP.js → chunk-QZHKN3VN-CuJasZjZ.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-D8g2OOzb.js → chunk-TZMSLE5B-DdOfh1YS.js} +1 -1
- package/dist/assets/classDiagram-2ON5EDUG-BbsIqyB3.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BbsIqyB3.js +1 -0
- package/dist/assets/clone-BtsJ7Mgy.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-DnkMvL-f.js → cose-bilkent-S5V4N54A-n5L5u7aX.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-dpKYJ1gN.js → dagre-6UL2VRFP-DA0iPKXN.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-BnFoR8YQ.js → diagram-PSM6KHXK-3FRZnvw9.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-Brg1AGVu.js → diagram-QEK2KX5R-ePGkAGnY.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-Dhmom7Q4.js → diagram-S2PKOQOG-FYWv9iIE.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-BTez8fDL.js → erDiagram-Q2GNP2WA-2la-V9DA.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-CVXmtc0D.js → flowDiagram-NV44I4VS-Cv-HCQV2.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-CHbw3AtF.js → ganttDiagram-JELNMOA3-DZhWBmY1.js} +1 -1
- package/dist/assets/{gitGraphDiagram-V2S2FVAM-CaPZ1SUE.js → gitGraphDiagram-V2S2FVAM-CBfpN1uw.js} +1 -1
- package/dist/assets/{graph-DjLsaDIL.js → graph-D6aI-bPq.js} +1 -1
- package/dist/assets/index-D8PZ9Puu.js +795 -0
- package/dist/assets/index-Df4jFRhd.css +1 -0
- package/dist/assets/{infoDiagram-HS3SLOUP-D_9DW9VU.js → infoDiagram-HS3SLOUP-Lvd-vDDU.js} +1 -1
- package/dist/assets/{isUndefined-BkijP54r.js → isUndefined-DLcwy6eJ.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-xxRWc5wn.js → journeyDiagram-XKPGCS4Q-rVvUlw1X.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-BCI2NbpB.js → kanban-definition-3W4ZIXB7-Dg-tIUSI.js} +1 -1
- package/dist/assets/{layout-M840MAqW.js → layout-CYgJ9n1K.js} +1 -1
- package/dist/assets/{linear--_gMRK53.js → linear-CfAw1dgz.js} +1 -1
- package/dist/assets/{min-D06s3WKk.js → min-BBMETs6R.js} +1 -1
- package/dist/assets/{mindmap-definition-VGOIOE7T-B1-ALKFj.js → mindmap-definition-VGOIOE7T-DY__eUNT.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-76kjoNcf.js → pieDiagram-ADFJNKIX-Nr7ukKrm.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-O_d3GTQl.js → quadrantDiagram-AYHSOK5B-Bb54Oaa_.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-CasSIIPr.js → requirementDiagram-UZGBJVZJ-BtLViRZC.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-CL_kG7jD.js → sankeyDiagram-TZEHDZUN-COqbhL3I.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-RYnztN0k.js → sequenceDiagram-WL72ISMW-DHKy94Yw.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-NHFLaLiz.js → stateDiagram-FKZM4ZOC-8dJVXo1X.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-_96AsFOw.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-BH1cZoua.js → timeline-definition-IT6M3QCI-CyS6rBy_.js} +1 -1
- package/dist/assets/{treemap-GDKQZRPO-QAQDPzuO.js → treemap-GDKQZRPO-CUM9baT2.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-C3M4ZzCP.js → xychartDiagram-PRI3JC2R-BztiRmM1.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/features/account/pages/service-tokens.tsx +13 -60
- package/src/features/auth/pages/login.tsx +8 -1
- package/src/features/workflow/components/dialogs/update-workflow-dialog.tsx +1 -1
- package/src/features/workflow/components/dialogs/upload-workflow-dialog.tsx +14 -2
- package/src/features/workflow/components/panels/rjsf-cron-widget.tsx +421 -0
- package/src/features/workflow/components/panels/rjsf-theme.tsx +2 -0
- package/src/features/workflow/data/workflow-types.ts +5 -0
- package/src/features/workflow/lib/workflow-storage-api.ts +103 -2
- package/src/features/workflow/lib/workflow-storage-helpers.ts +40 -0
- package/src/features/workflow/lib/workflow-storage.ts +58 -31
- package/src/features/workflow/lib/workflow-storage.types.ts +11 -0
- package/src/features/workflow/lib/workflow-upload-errors.ts +81 -0
- package/src/features/workflow/pages/workflow/components/workflow-config-sheet.tsx +26 -5
- package/src/features/workflow/pages/workflow/components/workflow-layout.tsx +4 -1
- package/src/features/workflow/pages/workflow/components/workflow-tab-content.tsx +169 -5
- package/src/features/workflow/pages/workflow/hooks/controller/build-layout-props.ts +8 -0
- package/src/features/workflow/pages/workflow/hooks/controller/use-workflow-lifecycle.ts +6 -0
- package/src/features/workflow/pages/workflow/hooks/controller/use-workflow-resources.ts +2 -0
- package/src/features/workflow/pages/workflow/hooks/execution-runtime-updates.ts +1 -1
- package/src/features/workflow/pages/workflow/hooks/use-workflow-loader.ts +22 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-metadata-state.ts +11 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-saver.ts +8 -0
- package/src/features/workflow/pages/workflow/hooks/use-workflow-storage-listener.ts +14 -0
- package/src/features/workflow/pages/workflow-gallery/workflow-card.tsx +26 -4
- package/src/lib/api.ts +2 -17
- package/dist/assets/channel-C3cH7p5A.js +0 -1
- package/dist/assets/classDiagram-2ON5EDUG-o6x5AEsV.js +0 -1
- package/dist/assets/classDiagram-v2-WZHVMYZB-o6x5AEsV.js +0 -1
- package/dist/assets/clone-CnJR7M_G.js +0 -1
- package/dist/assets/index-DSthAh3a.css +0 -1
- package/dist/assets/index-DlptXU2u.js +0 -795
- package/dist/assets/stateDiagram-v2-4FDKWEC3-3y5AUrwb.js +0 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
2
|
+
/**
|
|
3
|
+
* Visual cron-expression builder widget.
|
|
4
|
+
*
|
|
5
|
+
* Renders the classic "Every <freq> on <days> at <hh>:<mm>" row instead of a
|
|
6
|
+
* raw string input, but still stores a standard 5-field cron expression
|
|
7
|
+
* (`minute hour day-of-month month day-of-week`) as the field value so the
|
|
8
|
+
* backend scheduler keeps receiving a plain string.
|
|
9
|
+
*
|
|
10
|
+
* It understands day-of-week ranges (e.g. `1-5`) on input. Expressions that the
|
|
11
|
+
* builder cannot represent losslessly (lists/ranges in minute or hour, specific
|
|
12
|
+
* months, combined day-of-month + day-of-week, named tokens, ...) fall back to a
|
|
13
|
+
* raw text input so editing never silently rewrites them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { ChevronDown } from "lucide-react";
|
|
18
|
+
import { WidgetProps } from "@rjsf/utils";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import {
|
|
21
|
+
Select,
|
|
22
|
+
SelectContent,
|
|
23
|
+
SelectItem,
|
|
24
|
+
SelectTrigger,
|
|
25
|
+
SelectValue,
|
|
26
|
+
} from "@/design-system/ui/select";
|
|
27
|
+
import {
|
|
28
|
+
DropdownMenu,
|
|
29
|
+
DropdownMenuCheckboxItem,
|
|
30
|
+
DropdownMenuContent,
|
|
31
|
+
DropdownMenuTrigger,
|
|
32
|
+
} from "@/design-system/ui/dropdown-menu";
|
|
33
|
+
import { Input } from "@/design-system/ui/input";
|
|
34
|
+
|
|
35
|
+
type Frequency = "minute" | "hour" | "day" | "week" | "month";
|
|
36
|
+
|
|
37
|
+
interface CronParts {
|
|
38
|
+
/** Step interval (in minutes) used by the "minute" frequency, e.g. star-slash-30. */
|
|
39
|
+
interval: number;
|
|
40
|
+
/** Step interval (in hours) used by the "hour" frequency, e.g. star-slash-2. */
|
|
41
|
+
hourInterval: number;
|
|
42
|
+
minute: number;
|
|
43
|
+
hour: number;
|
|
44
|
+
dayOfMonth: number;
|
|
45
|
+
daysOfWeek: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
|
|
49
|
+
|
|
50
|
+
const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
|
|
51
|
+
{ value: "minute", label: "minutes" },
|
|
52
|
+
{ value: "hour", label: "hours" },
|
|
53
|
+
{ value: "day", label: "day" },
|
|
54
|
+
{ value: "week", label: "week" },
|
|
55
|
+
{ value: "month", label: "month" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Common step intervals offered in the "every N minutes/hours" dropdowns. The
|
|
59
|
+
// current value is merged in (see `intervalOptions`) so hand-edited steps such
|
|
60
|
+
// as `*/7` still render a matching option.
|
|
61
|
+
const MINUTE_INTERVAL_PRESETS = [1, 2, 3, 5, 10, 15, 20, 30];
|
|
62
|
+
const HOUR_INTERVAL_PRESETS = [1, 2, 3, 4, 6, 8, 12];
|
|
63
|
+
|
|
64
|
+
const range = (count: number, start = 0) =>
|
|
65
|
+
Array.from({ length: count }, (_, i) => i + start);
|
|
66
|
+
|
|
67
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
68
|
+
|
|
69
|
+
const clampInt = (value: string, max: number, fallback: number) => {
|
|
70
|
+
const n = Number.parseInt(value, 10);
|
|
71
|
+
return Number.isFinite(n) && n >= 0 && n <= max ? n : fallback;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Parse a single cron field's first numeric token, falling back to `fallback`. */
|
|
75
|
+
const parseField = (field: string | undefined, max: number, fallback: number) =>
|
|
76
|
+
field && field !== "*" ? clampInt(field.split(/[,/-]/)[0], max, fallback) : fallback;
|
|
77
|
+
|
|
78
|
+
/** Extract the step N from a `*\/N` field (clamped to `max`), or undefined if it isn't a step. */
|
|
79
|
+
const parseStep = (field: string | undefined, max: number): number | undefined => {
|
|
80
|
+
const match = /^\*\/(\d+)$/.exec(field ?? "");
|
|
81
|
+
return match ? clampInt(match[1], max, 1) || 1 : undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Parse the day-of-week field, supporting comma lists and ranges (e.g. "1,6", "1-5"). */
|
|
85
|
+
const parseDaysOfWeek = (field: string | undefined): number[] => {
|
|
86
|
+
if (!field || field === "*") return [];
|
|
87
|
+
const days = field
|
|
88
|
+
.split(",")
|
|
89
|
+
.flatMap((token) => {
|
|
90
|
+
const m = /^(\d)-(\d)$/.exec(token);
|
|
91
|
+
if (!m) return [clampInt(token, 7, -1)];
|
|
92
|
+
const from = clampInt(m[1], 7, -1);
|
|
93
|
+
const to = clampInt(m[2], 7, -1);
|
|
94
|
+
return from >= 0 && to >= from ? range(to - from + 1, from) : [-1];
|
|
95
|
+
})
|
|
96
|
+
.map((d) => (d === 7 ? 0 : d)) // normalise Sunday (cron allows 0 or 7)
|
|
97
|
+
.filter((d) => d >= 0 && d <= 6);
|
|
98
|
+
return Array.from(new Set(days));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const parseCron = (value: string | undefined): { freq: Frequency; parts: CronParts } => {
|
|
102
|
+
const [minute, hour, dom, , dow] = (value ?? "").trim().split(/\s+/);
|
|
103
|
+
const minuteStep = parseStep(minute, 59);
|
|
104
|
+
const hourStep = parseStep(hour, 23);
|
|
105
|
+
const parts: CronParts = {
|
|
106
|
+
interval: minuteStep ?? 1,
|
|
107
|
+
hourInterval: hourStep ?? 1,
|
|
108
|
+
minute: parseField(minute, 59, 0),
|
|
109
|
+
hour: parseField(hour, 23, 0),
|
|
110
|
+
dayOfMonth: dom && dom !== "*" ? clampInt(dom, 31, 1) || 1 : 1,
|
|
111
|
+
daysOfWeek: parseDaysOfWeek(dow),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let freq: Frequency = "day";
|
|
115
|
+
if (dow && dow !== "*") freq = "week";
|
|
116
|
+
else if (dom && dom !== "*") freq = "month";
|
|
117
|
+
// `*/30 * * * *` (step) or `* * * * *` (every minute) → minute interval.
|
|
118
|
+
else if (minuteStep !== undefined || minute === "*") freq = "minute";
|
|
119
|
+
// `M */2 * * *` (step) or `M * * * *` (every hour) → hour interval.
|
|
120
|
+
else if (hourStep !== undefined || hour === "*" || hour === undefined) freq = "hour";
|
|
121
|
+
|
|
122
|
+
return { freq, parts };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const buildCron = (freq: Frequency, parts: CronParts): string => {
|
|
126
|
+
const { interval, hourInterval, minute, hour, dayOfMonth, daysOfWeek } = parts;
|
|
127
|
+
switch (freq) {
|
|
128
|
+
case "minute":
|
|
129
|
+
// `*/1` is just "every minute"; emit the canonical wildcard form.
|
|
130
|
+
return interval === 1 ? "* * * * *" : `*/${interval} * * * *`;
|
|
131
|
+
case "hour":
|
|
132
|
+
// `*/1` is just "every hour"; emit the canonical wildcard form.
|
|
133
|
+
return hourInterval === 1
|
|
134
|
+
? `${minute} * * * *`
|
|
135
|
+
: `${minute} */${hourInterval} * * *`;
|
|
136
|
+
case "day":
|
|
137
|
+
return `${minute} ${hour} * * *`;
|
|
138
|
+
case "week": {
|
|
139
|
+
const dow = daysOfWeek.length ? [...daysOfWeek].sort((a, b) => a - b).join(",") : "*";
|
|
140
|
+
return `${minute} ${hour} * * ${dow}`;
|
|
141
|
+
}
|
|
142
|
+
case "month":
|
|
143
|
+
return `${minute} ${hour} ${dayOfMonth} * *`;
|
|
144
|
+
default:
|
|
145
|
+
return `${minute} ${hour} * * *`;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Collapse `*\/1` to `*` so an explicit unit step compares equal to the wildcard. */
|
|
150
|
+
const collapseUnitStep = (field: string) => (field === "*/1" ? "*" : field);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Normalise a cron string to the canonical 5-field form the builder emits:
|
|
154
|
+
* drop redundant `*\/1` steps and expand day-of-week ranges to sorted comma
|
|
155
|
+
* lists. Returns `null` for anything malformed or unrepresentable (wrong field
|
|
156
|
+
* count, named tokens), so callers can fall back to raw editing.
|
|
157
|
+
*/
|
|
158
|
+
const canonicalCron = (value: string | undefined): string | null => {
|
|
159
|
+
const raw = (value ?? "").trim();
|
|
160
|
+
if (!raw) return "";
|
|
161
|
+
const fields = raw.split(/\s+/);
|
|
162
|
+
if (fields.length !== 5) return null;
|
|
163
|
+
const [minute, hour, dom, month, dow] = fields.map(collapseUnitStep);
|
|
164
|
+
// Named tokens (MON, JAN) aren't supported by the visual builder.
|
|
165
|
+
if (/[a-z]/i.test(fields.join(""))) return null;
|
|
166
|
+
const days = parseDaysOfWeek(dow);
|
|
167
|
+
const dowField =
|
|
168
|
+
dow === "*" ? "*" : days.length ? [...days].sort((a, b) => a - b).join(",") : dow;
|
|
169
|
+
return [minute, hour, dom, month, dowField].join(" ");
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** True when `value` can be represented losslessly by the visual builder. */
|
|
173
|
+
const roundTrips = (value: string | undefined): boolean => {
|
|
174
|
+
const canonical = canonicalCron(value);
|
|
175
|
+
if (canonical === null) return false;
|
|
176
|
+
if (canonical === "") return true;
|
|
177
|
+
const { freq, parts } = parseCron(canonical);
|
|
178
|
+
return buildCron(freq, parts) === canonical;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Interval presets plus the current value, sorted and de-duplicated. */
|
|
182
|
+
const intervalOptions = (current: number, presets: number[]): number[] =>
|
|
183
|
+
Array.from(new Set([...presets, current])).sort((a, b) => a - b);
|
|
184
|
+
|
|
185
|
+
interface UnitSelectProps {
|
|
186
|
+
id?: string;
|
|
187
|
+
value: number;
|
|
188
|
+
max: number;
|
|
189
|
+
start?: number;
|
|
190
|
+
format?: (n: number) => string;
|
|
191
|
+
disabled?: boolean;
|
|
192
|
+
onChange: (value: number) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function UnitSelect({
|
|
196
|
+
id,
|
|
197
|
+
value,
|
|
198
|
+
max,
|
|
199
|
+
start = 0,
|
|
200
|
+
format = String,
|
|
201
|
+
disabled,
|
|
202
|
+
onChange,
|
|
203
|
+
}: UnitSelectProps) {
|
|
204
|
+
return (
|
|
205
|
+
<Select
|
|
206
|
+
value={String(value)}
|
|
207
|
+
onValueChange={(v) => onChange(Number(v))}
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
>
|
|
210
|
+
<SelectTrigger id={id} className="w-auto min-w-[4rem]">
|
|
211
|
+
<SelectValue />
|
|
212
|
+
</SelectTrigger>
|
|
213
|
+
<SelectContent className="max-h-60">
|
|
214
|
+
{range(max - start + 1, start).map((n) => (
|
|
215
|
+
<SelectItem key={n} value={String(n)}>
|
|
216
|
+
{format(n)}
|
|
217
|
+
</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function CronWidget(props: WidgetProps) {
|
|
225
|
+
const { id, value, onChange, disabled, readonly } = props;
|
|
226
|
+
const isDisabled = disabled || readonly;
|
|
227
|
+
|
|
228
|
+
const representable = React.useMemo(() => roundTrips(value), [value]);
|
|
229
|
+
const { freq, parts } = React.useMemo(
|
|
230
|
+
() => parseCron(canonicalCron(value) ?? value),
|
|
231
|
+
[value],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const update = (nextFreq: Frequency, nextParts: CronParts) => {
|
|
235
|
+
onChange(buildCron(nextFreq, nextParts));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Expressions the builder can't represent losslessly are edited as a raw cron
|
|
239
|
+
// string so tweaking one control never silently discards parts it can't model.
|
|
240
|
+
if (!representable) {
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex flex-col gap-1">
|
|
243
|
+
<Input
|
|
244
|
+
id={id}
|
|
245
|
+
value={value ?? ""}
|
|
246
|
+
onChange={(e) => onChange(e.target.value)}
|
|
247
|
+
disabled={isDisabled}
|
|
248
|
+
spellCheck={false}
|
|
249
|
+
className="font-mono"
|
|
250
|
+
/>
|
|
251
|
+
<span className="text-xs text-muted-foreground">
|
|
252
|
+
This schedule is too advanced for the visual builder — editing the raw
|
|
253
|
+
cron expression.
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const setFreq = (nextFreq: Frequency) => {
|
|
260
|
+
// Default to a sensible day when switching to "week" with nothing selected.
|
|
261
|
+
const next =
|
|
262
|
+
nextFreq === "week" && parts.daysOfWeek.length === 0
|
|
263
|
+
? { ...parts, daysOfWeek: [1] }
|
|
264
|
+
: parts;
|
|
265
|
+
update(nextFreq, next);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const toggleDay = (day: number, checked: boolean) => {
|
|
269
|
+
const daysOfWeek = checked
|
|
270
|
+
? [...parts.daysOfWeek, day]
|
|
271
|
+
: parts.daysOfWeek.filter((d) => d !== day);
|
|
272
|
+
// Keep at least one day selected in "week" mode. An empty list serializes
|
|
273
|
+
// to a "day" cron, which would round-trip as a different frequency and
|
|
274
|
+
// bounce the user out to the raw-expression editor.
|
|
275
|
+
if (daysOfWeek.length === 0) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
update("week", { ...parts, daysOfWeek });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const selectedDaysLabel =
|
|
282
|
+
parts.daysOfWeek.length === 0
|
|
283
|
+
? "Select days"
|
|
284
|
+
: [...parts.daysOfWeek]
|
|
285
|
+
.sort((a, b) => a - b)
|
|
286
|
+
.map((d) => DAY_LABELS[d])
|
|
287
|
+
.join(",");
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="flex flex-wrap items-center gap-2 text-sm" id={id}>
|
|
291
|
+
<span className="text-muted-foreground">Every</span>
|
|
292
|
+
|
|
293
|
+
{(freq === "minute" || freq === "hour") && (
|
|
294
|
+
<Select
|
|
295
|
+
value={String(freq === "minute" ? parts.interval : parts.hourInterval)}
|
|
296
|
+
onValueChange={(v) =>
|
|
297
|
+
freq === "minute"
|
|
298
|
+
? update("minute", { ...parts, interval: Number(v) })
|
|
299
|
+
: update("hour", { ...parts, hourInterval: Number(v) })
|
|
300
|
+
}
|
|
301
|
+
disabled={isDisabled}
|
|
302
|
+
>
|
|
303
|
+
<SelectTrigger className="w-auto min-w-[4rem]">
|
|
304
|
+
<SelectValue />
|
|
305
|
+
</SelectTrigger>
|
|
306
|
+
<SelectContent className="max-h-60">
|
|
307
|
+
{(freq === "minute"
|
|
308
|
+
? intervalOptions(parts.interval, MINUTE_INTERVAL_PRESETS)
|
|
309
|
+
: intervalOptions(parts.hourInterval, HOUR_INTERVAL_PRESETS)
|
|
310
|
+
).map((n) => (
|
|
311
|
+
<SelectItem key={n} value={String(n)}>
|
|
312
|
+
{n}
|
|
313
|
+
</SelectItem>
|
|
314
|
+
))}
|
|
315
|
+
</SelectContent>
|
|
316
|
+
</Select>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
<Select
|
|
320
|
+
value={freq}
|
|
321
|
+
onValueChange={(v) => setFreq(v as Frequency)}
|
|
322
|
+
disabled={isDisabled}
|
|
323
|
+
>
|
|
324
|
+
<SelectTrigger className="w-auto min-w-[5rem]">
|
|
325
|
+
<SelectValue />
|
|
326
|
+
</SelectTrigger>
|
|
327
|
+
<SelectContent>
|
|
328
|
+
{FREQUENCY_OPTIONS.map((option) => (
|
|
329
|
+
<SelectItem key={option.value} value={option.value}>
|
|
330
|
+
{option.label}
|
|
331
|
+
</SelectItem>
|
|
332
|
+
))}
|
|
333
|
+
</SelectContent>
|
|
334
|
+
</Select>
|
|
335
|
+
|
|
336
|
+
{freq === "week" && (
|
|
337
|
+
<>
|
|
338
|
+
<span className="text-muted-foreground">on</span>
|
|
339
|
+
<DropdownMenu>
|
|
340
|
+
<DropdownMenuTrigger
|
|
341
|
+
disabled={isDisabled}
|
|
342
|
+
className={cn(
|
|
343
|
+
"flex h-9 min-w-[6rem] items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
344
|
+
)}
|
|
345
|
+
>
|
|
346
|
+
<span className={cn(parts.daysOfWeek.length === 0 && "text-muted-foreground")}>
|
|
347
|
+
{selectedDaysLabel}
|
|
348
|
+
</span>
|
|
349
|
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
350
|
+
</DropdownMenuTrigger>
|
|
351
|
+
<DropdownMenuContent align="start">
|
|
352
|
+
{DAY_LABELS.map((label, day) => (
|
|
353
|
+
<DropdownMenuCheckboxItem
|
|
354
|
+
key={label}
|
|
355
|
+
checked={parts.daysOfWeek.includes(day)}
|
|
356
|
+
onCheckedChange={(checked) => toggleDay(day, checked)}
|
|
357
|
+
onSelect={(e) => e.preventDefault()}
|
|
358
|
+
>
|
|
359
|
+
{label}
|
|
360
|
+
</DropdownMenuCheckboxItem>
|
|
361
|
+
))}
|
|
362
|
+
</DropdownMenuContent>
|
|
363
|
+
</DropdownMenu>
|
|
364
|
+
</>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
{freq === "month" && (
|
|
368
|
+
<>
|
|
369
|
+
<span className="text-muted-foreground">on day</span>
|
|
370
|
+
<UnitSelect
|
|
371
|
+
value={parts.dayOfMonth}
|
|
372
|
+
max={31}
|
|
373
|
+
start={1}
|
|
374
|
+
disabled={isDisabled}
|
|
375
|
+
onChange={(dayOfMonth) => update("month", { ...parts, dayOfMonth })}
|
|
376
|
+
/>
|
|
377
|
+
</>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{(freq === "day" || freq === "week" || freq === "month") && (
|
|
381
|
+
<>
|
|
382
|
+
<span className="text-muted-foreground">at</span>
|
|
383
|
+
<UnitSelect
|
|
384
|
+
value={parts.hour}
|
|
385
|
+
max={23}
|
|
386
|
+
format={pad}
|
|
387
|
+
disabled={isDisabled}
|
|
388
|
+
onChange={(hour) => update(freq, { ...parts, hour })}
|
|
389
|
+
/>
|
|
390
|
+
<span className="text-muted-foreground">:</span>
|
|
391
|
+
<UnitSelect
|
|
392
|
+
value={parts.minute}
|
|
393
|
+
max={59}
|
|
394
|
+
format={pad}
|
|
395
|
+
disabled={isDisabled}
|
|
396
|
+
onChange={(minute) => update(freq, { ...parts, minute })}
|
|
397
|
+
/>
|
|
398
|
+
</>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
{freq === "hour" && (
|
|
402
|
+
<>
|
|
403
|
+
<span className="text-muted-foreground">at minute</span>
|
|
404
|
+
<UnitSelect
|
|
405
|
+
value={parts.minute}
|
|
406
|
+
max={59}
|
|
407
|
+
format={pad}
|
|
408
|
+
disabled={isDisabled}
|
|
409
|
+
onChange={(minute) => update("hour", { ...parts, minute })}
|
|
410
|
+
/>
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
type CronWidgetMap = { cron: typeof CronWidget };
|
|
418
|
+
|
|
419
|
+
export const cronWidgets: CronWidgetMap = { cron: CronWidget };
|
|
420
|
+
|
|
421
|
+
export { CronWidget, parseCron, buildCron, canonicalCron, roundTrips };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { RegistryWidgetsType } from "@rjsf/utils";
|
|
2
2
|
import validator from "@rjsf/validator-ajv8";
|
|
3
3
|
import { basicWidgets } from "./rjsf-basic-widgets";
|
|
4
|
+
import { cronWidgets } from "./rjsf-cron-widget";
|
|
4
5
|
import { customTemplates } from "./rjsf-templates";
|
|
5
6
|
|
|
6
7
|
export const customWidgets = {
|
|
7
8
|
...basicWidgets,
|
|
9
|
+
...cronWidgets,
|
|
8
10
|
} satisfies RegistryWidgetsType;
|
|
9
11
|
|
|
10
12
|
export { customTemplates, validator };
|
|
@@ -37,6 +37,7 @@ export interface Workflow {
|
|
|
37
37
|
id: string;
|
|
38
38
|
handle?: string;
|
|
39
39
|
teamId?: string | null;
|
|
40
|
+
teamSlug?: string | null;
|
|
40
41
|
/**
|
|
41
42
|
* Group slug for candidate colleagues, derived from the first path segment
|
|
42
43
|
* of the colleague-candidates directory (e.g. "news_desk"). Null when the
|
|
@@ -46,6 +47,10 @@ export interface Workflow {
|
|
|
46
47
|
name: string;
|
|
47
48
|
description?: string;
|
|
48
49
|
avatarEmoji?: string | null;
|
|
50
|
+
uploadError?: {
|
|
51
|
+
message: string;
|
|
52
|
+
occurredAt: string;
|
|
53
|
+
};
|
|
49
54
|
draftAccess?: "personal" | "authenticated" | "workspace";
|
|
50
55
|
createdAt: string;
|
|
51
56
|
updatedAt: string;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
WorkflowListenerMetricsResponse,
|
|
15
15
|
WorkflowCredentialReadinessResponse,
|
|
16
16
|
WorkflowPublishResponse,
|
|
17
|
+
WorkflowRunnableConfig,
|
|
17
18
|
CandidateUpdateNote,
|
|
18
19
|
} from "./workflow-storage.types";
|
|
19
20
|
|
|
@@ -43,6 +44,46 @@ const readText = async (response: Response): Promise<string> => {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Extract a human-readable message from an error response body.
|
|
49
|
+
*
|
|
50
|
+
* FastAPI serializes errors as `{ "detail": ... }`, where `detail` may be a
|
|
51
|
+
* plain string, our structured `{ message, code }` object, or a list of
|
|
52
|
+
* validation errors. Surface the readable message instead of the raw JSON so
|
|
53
|
+
* error toasts stay legible; fall back to the original text otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export const extractErrorMessage = (body: string): string => {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(body) as unknown;
|
|
58
|
+
const detail = (parsed as { detail?: unknown })?.detail ?? parsed;
|
|
59
|
+
|
|
60
|
+
if (typeof detail === "string") {
|
|
61
|
+
return detail;
|
|
62
|
+
}
|
|
63
|
+
if (detail && typeof detail === "object") {
|
|
64
|
+
const message = (detail as { message?: unknown }).message;
|
|
65
|
+
if (typeof message === "string") {
|
|
66
|
+
return message;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(detail)) {
|
|
69
|
+
const messages = detail
|
|
70
|
+
.map((item) =>
|
|
71
|
+
item && typeof item === "object"
|
|
72
|
+
? (item as { msg?: unknown }).msg
|
|
73
|
+
: undefined,
|
|
74
|
+
)
|
|
75
|
+
.filter((msg): msg is string => typeof msg === "string");
|
|
76
|
+
if (messages.length > 0) {
|
|
77
|
+
return messages.join("; ");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Body was not JSON; fall through to the raw text.
|
|
83
|
+
}
|
|
84
|
+
return body;
|
|
85
|
+
};
|
|
86
|
+
|
|
46
87
|
export const request = async <T>(
|
|
47
88
|
path: string,
|
|
48
89
|
options: RequestOptions = {},
|
|
@@ -56,7 +97,8 @@ export const request = async <T>(
|
|
|
56
97
|
});
|
|
57
98
|
|
|
58
99
|
if (!response.ok) {
|
|
59
|
-
const
|
|
100
|
+
const body = await readText(response);
|
|
101
|
+
const detail = body ? extractErrorMessage(body) : response.statusText;
|
|
60
102
|
throw new ApiRequestError(detail, response.status);
|
|
61
103
|
}
|
|
62
104
|
|
|
@@ -454,6 +496,62 @@ export const extractCronConfigFromVersionGraph = (
|
|
|
454
496
|
return extractCronConfigFromGraphNodes(graph);
|
|
455
497
|
};
|
|
456
498
|
|
|
499
|
+
// Matches a value that is exactly a single `{{config.configurable.<name>}}`
|
|
500
|
+
// placeholder, optionally padded with whitespace inside the braces.
|
|
501
|
+
const CONFIGURABLE_TEMPLATE = /^\{\{\s*config\.configurable\.([^}\s.]+)\s*\}\}$/;
|
|
502
|
+
|
|
503
|
+
const resolveConfigurableTemplate = (
|
|
504
|
+
value: string | null | undefined,
|
|
505
|
+
configurable: Record<string, unknown> | undefined,
|
|
506
|
+
): string | null | undefined => {
|
|
507
|
+
if (typeof value !== "string") {
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
const match = CONFIGURABLE_TEMPLATE.exec(value.trim());
|
|
511
|
+
if (!match) {
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
const name = match[1];
|
|
515
|
+
const resolved = configurable?.[name];
|
|
516
|
+
if (typeof resolved !== "string") {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`Cron trigger references '{{config.configurable.${name}}}' but the ` +
|
|
519
|
+
`workflow has no configurable value named '${name}'. Set a default ` +
|
|
520
|
+
`for it in the workflow config.`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
return resolved;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// Cron triggers may parametrize fields (e.g. `expression`) with
|
|
527
|
+
// `{{config.configurable.X}}` placeholders that the workflow runtime resolves
|
|
528
|
+
// from its `configurable` config. Scheduling needs the concrete value, so
|
|
529
|
+
// resolve any such placeholder against the version's configurable values
|
|
530
|
+
// before the payload is validated as a cron expression by the backend.
|
|
531
|
+
const resolveConfigurableCronConfig = (
|
|
532
|
+
config: CronTriggerConfig,
|
|
533
|
+
runnableConfig: WorkflowRunnableConfig | null | undefined,
|
|
534
|
+
): CronTriggerConfig => {
|
|
535
|
+
const configurable = runnableConfig?.configurable;
|
|
536
|
+
return {
|
|
537
|
+
...config,
|
|
538
|
+
expression:
|
|
539
|
+
resolveConfigurableTemplate(config.expression, configurable) ??
|
|
540
|
+
config.expression,
|
|
541
|
+
timezone: resolveConfigurableTemplate(config.timezone, configurable) as
|
|
542
|
+
| string
|
|
543
|
+
| undefined,
|
|
544
|
+
start_at: resolveConfigurableTemplate(config.start_at, configurable) as
|
|
545
|
+
| string
|
|
546
|
+
| null
|
|
547
|
+
| undefined,
|
|
548
|
+
end_at: resolveConfigurableTemplate(config.end_at, configurable) as
|
|
549
|
+
| string
|
|
550
|
+
| null
|
|
551
|
+
| undefined,
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
|
|
457
555
|
const normalizeTemplateCronConfig = (
|
|
458
556
|
config: CronTriggerConfig,
|
|
459
557
|
metadata: unknown,
|
|
@@ -562,7 +660,10 @@ export const scheduleWorkflowFromLatestVersion = async (
|
|
|
562
660
|
|
|
563
661
|
const cronConfigRaw = extractCronConfigFromVersionGraph(latest.graph);
|
|
564
662
|
const cronConfig = cronConfigRaw
|
|
565
|
-
? normalizeTemplateCronConfig(
|
|
663
|
+
? normalizeTemplateCronConfig(
|
|
664
|
+
resolveConfigurableCronConfig(cronConfigRaw, latest.runnable_config),
|
|
665
|
+
latest.metadata,
|
|
666
|
+
)
|
|
566
667
|
: null;
|
|
567
668
|
if (!cronConfig) {
|
|
568
669
|
return {
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
DEFAULT_SUMMARY,
|
|
13
13
|
HISTORY_LIMIT,
|
|
14
14
|
} from "./workflow-storage.constants";
|
|
15
|
+
import {
|
|
16
|
+
clearWorkflowUploadError,
|
|
17
|
+
readWorkflowUploadError,
|
|
18
|
+
} from "./workflow-upload-errors";
|
|
15
19
|
import {
|
|
16
20
|
forceMermaidLeftToRight,
|
|
17
21
|
normalizeMermaidPalette,
|
|
@@ -216,6 +220,7 @@ const parseWorkflowMetadata = (
|
|
|
216
220
|
): WorkflowVersionMetadata => {
|
|
217
221
|
const configurableSchemas = parseConfigurableSchemas(
|
|
218
222
|
isRecord(metadata) ? metadata.configurable_schema : undefined,
|
|
223
|
+
isRecord(metadata) ? metadata.configurable_schema_order : undefined,
|
|
219
224
|
);
|
|
220
225
|
const avatarEmoji = extractAvatarEmoji(metadata);
|
|
221
226
|
const candidateSource = extractCandidateSource(metadata);
|
|
@@ -330,6 +335,7 @@ const parseWorkflowMetadata = (
|
|
|
330
335
|
|
|
331
336
|
const parseConfigurableSchemas = (
|
|
332
337
|
value: unknown,
|
|
338
|
+
order: unknown,
|
|
333
339
|
): Record<string, RJSFSchema> | undefined => {
|
|
334
340
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
335
341
|
return undefined;
|
|
@@ -344,6 +350,23 @@ const parseConfigurableSchemas = (
|
|
|
344
350
|
return undefined;
|
|
345
351
|
}
|
|
346
352
|
|
|
353
|
+
// The schema object is persisted in a JSONB column that does not preserve key
|
|
354
|
+
// order, so reorder by the authored `configurable_schema_order` array when
|
|
355
|
+
// present. Keys missing from the order list keep their existing position at
|
|
356
|
+
// the end, keeping output stable for legacy versions without the array.
|
|
357
|
+
if (Array.isArray(order) && order.length > 0) {
|
|
358
|
+
const byKey = new Map(entries);
|
|
359
|
+
const orderedKeys = order.filter(
|
|
360
|
+
(key): key is string => typeof key === "string" && byKey.has(key),
|
|
361
|
+
);
|
|
362
|
+
const seen = new Set(orderedKeys);
|
|
363
|
+
const orderedEntries = [
|
|
364
|
+
...orderedKeys.map((key) => [key, byKey.get(key)] as const),
|
|
365
|
+
...entries.filter(([key]) => !seen.has(key)),
|
|
366
|
+
];
|
|
367
|
+
return Object.fromEntries(orderedEntries) as Record<string, RJSFSchema>;
|
|
368
|
+
}
|
|
369
|
+
|
|
347
370
|
return Object.fromEntries(entries) as Record<string, RJSFSchema>;
|
|
348
371
|
};
|
|
349
372
|
|
|
@@ -400,11 +423,28 @@ export const toStoredWorkflow = (
|
|
|
400
423
|
versionRecords.at(-1)?.snapshot ??
|
|
401
424
|
emptySnapshot(workflow.name, workflow.description ?? undefined);
|
|
402
425
|
const avatarEmoji = versionRecords.at(-1)?.avatarEmoji ?? undefined;
|
|
426
|
+
const hasBackendUploadError = Object.prototype.hasOwnProperty.call(
|
|
427
|
+
workflow,
|
|
428
|
+
"upload_error",
|
|
429
|
+
);
|
|
430
|
+
const uploadError = workflow.upload_error
|
|
431
|
+
? {
|
|
432
|
+
message: workflow.upload_error.message,
|
|
433
|
+
occurredAt: workflow.upload_error.occurred_at,
|
|
434
|
+
}
|
|
435
|
+
: hasBackendUploadError
|
|
436
|
+
? undefined
|
|
437
|
+
: readWorkflowUploadError(workflow.id);
|
|
438
|
+
if (hasBackendUploadError && workflow.upload_error === null) {
|
|
439
|
+
clearWorkflowUploadError(workflow.id);
|
|
440
|
+
}
|
|
403
441
|
|
|
404
442
|
return {
|
|
405
443
|
id: workflow.id,
|
|
406
444
|
handle: workflow.handle ?? undefined,
|
|
407
445
|
teamId: workflow.team_id ?? undefined,
|
|
446
|
+
teamSlug: workflow.team_slug ?? undefined,
|
|
447
|
+
uploadError,
|
|
408
448
|
name: workflow.name,
|
|
409
449
|
description: workflow.description ?? undefined,
|
|
410
450
|
avatarEmoji,
|