orcheo-studio 0.23.2 → 0.24.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.
Files changed (78) hide show
  1. package/.bumpversion.cfg +1 -1
  2. package/dist/assets/{arc-BokkJPzk.js → arc-DhTjQsml.js} +1 -1
  3. package/dist/assets/{architectureDiagram-VXUJARFQ-CzmPOZBu.js → architectureDiagram-VXUJARFQ-CGDm4iVL.js} +1 -1
  4. package/dist/assets/{blockDiagram-VD42YOAC-DG89LeH9.js → blockDiagram-VD42YOAC-B5xlwwyL.js} +1 -1
  5. package/dist/assets/{c4Diagram-YG6GDRKO-DymWpJZb.js → c4Diagram-YG6GDRKO-DtVXFxFX.js} +1 -1
  6. package/dist/assets/channel-CeR8j9Si.js +1 -0
  7. package/dist/assets/{chunk-4BX2VUAB-DulKpeK2.js → chunk-4BX2VUAB-CA8vZ6Tb.js} +1 -1
  8. package/dist/assets/{chunk-55IACEB6-Bi6MBeyB.js → chunk-55IACEB6-CxNWLhGM.js} +1 -1
  9. package/dist/assets/{chunk-B4BG7PRW-teHvBEMz.js → chunk-B4BG7PRW-BW8UEmwi.js} +1 -1
  10. package/dist/assets/{chunk-DI55MBZ5-Clydm5zE.js → chunk-DI55MBZ5-_6YQcHqR.js} +1 -1
  11. package/dist/assets/{chunk-FMBD7UC4-BGh0gUNB.js → chunk-FMBD7UC4-L-9FXYi4.js} +1 -1
  12. package/dist/assets/{chunk-QN33PNHL-D-9fma_8.js → chunk-QN33PNHL-DZtaWvmZ.js} +1 -1
  13. package/dist/assets/{chunk-QZHKN3VN-Bux-KM8v.js → chunk-QZHKN3VN-B3c4-un7.js} +1 -1
  14. package/dist/assets/{chunk-TZMSLE5B-pgx8j0M-.js → chunk-TZMSLE5B-DmyAt3KD.js} +1 -1
  15. package/dist/assets/classDiagram-2ON5EDUG-B2tKUrqn.js +1 -0
  16. package/dist/assets/classDiagram-v2-WZHVMYZB-B2tKUrqn.js +1 -0
  17. package/dist/assets/clone-BN-HxSe-.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-CszLJ5HU.js → cose-bilkent-S5V4N54A-r3dremCG.js} +1 -1
  19. package/dist/assets/{dagre-6UL2VRFP-Cr25CaNe.js → dagre-6UL2VRFP-C6AizynV.js} +1 -1
  20. package/dist/assets/{diagram-PSM6KHXK-CnsZe68H.js → diagram-PSM6KHXK-CKTAJrFm.js} +1 -1
  21. package/dist/assets/{diagram-QEK2KX5R-CN8ov45N.js → diagram-QEK2KX5R-B4BLbqZ6.js} +1 -1
  22. package/dist/assets/{diagram-S2PKOQOG-v2bUyBL7.js → diagram-S2PKOQOG-CLy2S4hE.js} +1 -1
  23. package/dist/assets/{erDiagram-Q2GNP2WA-BxxatPhi.js → erDiagram-Q2GNP2WA-DlemVcfS.js} +1 -1
  24. package/dist/assets/{flowDiagram-NV44I4VS-DIKLSwgl.js → flowDiagram-NV44I4VS-CpBPPeuN.js} +1 -1
  25. package/dist/assets/{ganttDiagram-JELNMOA3-D_0u3tXB.js → ganttDiagram-JELNMOA3-BY4cJPdg.js} +1 -1
  26. package/dist/assets/{gitGraphDiagram-V2S2FVAM-B_nu7ypr.js → gitGraphDiagram-V2S2FVAM-D7RJFr3i.js} +1 -1
  27. package/dist/assets/{graph-DmMuvtfO.js → graph-CJZvtDnb.js} +1 -1
  28. package/dist/assets/index-CARUdnbv.js +795 -0
  29. package/dist/assets/index-DcCYJX3c.css +1 -0
  30. package/dist/assets/{infoDiagram-HS3SLOUP-DLLSS3lr.js → infoDiagram-HS3SLOUP-CcSzbqKG.js} +1 -1
  31. package/dist/assets/{isUndefined-u8cwznUN.js → isUndefined-BbMiFvTc.js} +1 -1
  32. package/dist/assets/{journeyDiagram-XKPGCS4Q-fF1HZxKQ.js → journeyDiagram-XKPGCS4Q-DTmP5k2H.js} +1 -1
  33. package/dist/assets/{kanban-definition-3W4ZIXB7-CcmlUntf.js → kanban-definition-3W4ZIXB7-BSA4gDhk.js} +1 -1
  34. package/dist/assets/{layout-CuCF7WdL.js → layout-D3BxIqM1.js} +1 -1
  35. package/dist/assets/{linear-BVXj8lVL.js → linear-CemsbS51.js} +1 -1
  36. package/dist/assets/{min-DEoUCRl0.js → min-DkRMnH62.js} +1 -1
  37. package/dist/assets/{mindmap-definition-VGOIOE7T-DtNTD5Hm.js → mindmap-definition-VGOIOE7T-CNj0QY6e.js} +1 -1
  38. package/dist/assets/{pieDiagram-ADFJNKIX-a07QMF4s.js → pieDiagram-ADFJNKIX-CPQsqbb4.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-AYHSOK5B-NxQVfG-F.js → quadrantDiagram-AYHSOK5B-CPX25nd3.js} +1 -1
  40. package/dist/assets/{requirementDiagram-UZGBJVZJ-DfwcBFwx.js → requirementDiagram-UZGBJVZJ-7mxxRNiB.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-TZEHDZUN-BeRNGGtP.js → sankeyDiagram-TZEHDZUN-CPhitUZ9.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-WL72ISMW-dTp9Xfca.js → sequenceDiagram-WL72ISMW-BwLMsC7F.js} +1 -1
  43. package/dist/assets/{stateDiagram-FKZM4ZOC-uXrzw1Ia.js → stateDiagram-FKZM4ZOC-Crx9CUqD.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-4FDKWEC3-elh6u9Fl.js +1 -0
  45. package/dist/assets/{timeline-definition-IT6M3QCI-Jx4tDH4r.js → timeline-definition-IT6M3QCI-DDh1Iim0.js} +1 -1
  46. package/dist/assets/{treemap-GDKQZRPO-CYRu_RLQ.js → treemap-GDKQZRPO-Bc6lNY0z.js} +1 -1
  47. package/dist/assets/{xychartDiagram-PRI3JC2R-DVYMDr6g.js → xychartDiagram-PRI3JC2R-BAGZwI3e.js} +1 -1
  48. package/dist/index.html +2 -2
  49. package/package.json +2 -74
  50. package/src/features/account/pages/service-tokens.tsx +13 -60
  51. package/src/features/workflow/components/panels/rjsf-cron-widget.tsx +421 -0
  52. package/src/features/workflow/components/panels/rjsf-theme.tsx +2 -0
  53. package/src/features/workflow/data/templates/candidate-badges.ts +11 -1
  54. package/src/features/workflow/data/workflow-types.ts +3 -0
  55. package/src/features/workflow/lib/workflow-storage-api.ts +118 -2
  56. package/src/features/workflow/lib/workflow-storage-helpers.ts +65 -0
  57. package/src/features/workflow/lib/workflow-storage.ts +17 -0
  58. package/src/features/workflow/lib/workflow-storage.types.ts +16 -0
  59. package/src/features/workflow/pages/workflow/components/workflow-config-sheet.tsx +26 -5
  60. package/src/features/workflow/pages/workflow/components/workflow-tab-content.tsx +64 -1
  61. package/src/features/workflow/pages/workflow/hooks/controller/build-layout-props.ts +5 -0
  62. package/src/features/workflow/pages/workflow-gallery/candidate-updates.ts +107 -0
  63. package/src/features/workflow/pages/workflow-gallery/use-workflow-gallery-actions.ts +29 -4
  64. package/src/features/workflow/pages/workflow-gallery/use-workflow-gallery-state.ts +29 -25
  65. package/src/features/workflow/pages/workflow-gallery/workflow-card.tsx +187 -6
  66. package/src/features/workflow/pages/workflow-gallery/workflow-gallery-tabs.tsx +6 -0
  67. package/src/features/workflow/pages/workflow-gallery.tsx +2 -0
  68. package/src/lib/api.ts +2 -17
  69. package/vite.config.ts +0 -7
  70. package/dist/assets/channel-DPwJ8Ajm.js +0 -1
  71. package/dist/assets/classDiagram-2ON5EDUG-CYBYFsly.js +0 -1
  72. package/dist/assets/classDiagram-v2-WZHVMYZB-CYBYFsly.js +0 -1
  73. package/dist/assets/clone-tT9-ZTc-.js +0 -1
  74. package/dist/assets/index-BVY_PYJU.css +0 -1
  75. package/dist/assets/index-DVxGch9w.js +0 -790
  76. package/dist/assets/stateDiagram-v2-4FDKWEC3-dqZSxN9f.js +0 -1
  77. package/src/features/workflow/components/layouts/workflow-page-layout.tsx +0 -69
  78. package/src/features/workflow/data/templates/template-owner.ts +0 -7
@@ -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 };
@@ -1,6 +1,9 @@
1
1
  import { seededAvatarId } from "@/assets/avatars";
2
2
  import type { Workflow } from "../workflow-types";
3
- import type { StoredWorkflow } from "../../lib/workflow-storage.types";
3
+ import type {
4
+ CandidateUpdateNote,
5
+ StoredWorkflow,
6
+ } from "../../lib/workflow-storage.types";
4
7
  import type {
5
8
  WorkflowTemplateDefinition,
6
9
  WorkflowTemplateMetadata,
@@ -18,6 +21,8 @@ export interface CandidateBadgeSpec {
18
21
  avatar?: string;
19
22
  notes?: string | null;
20
23
  mermaid?: string | null;
24
+ version?: string | null;
25
+ updates?: CandidateUpdateNote[];
21
26
  /** Raw snake_case metadata dict from the colleague-candidates frontmatter. */
22
27
  rawMetadata?: Record<string, unknown> | null;
23
28
  }
@@ -165,6 +170,11 @@ export const getCandidateBadgeByHandle = (
165
170
  ): CandidateBadgeDefinition | undefined =>
166
171
  candidateBadges.find((badge) => badge.handle === handle);
167
172
 
173
+ export const getCandidateBadgeByCandidateId = (
174
+ candidateId: string,
175
+ ): CandidateBadgeDefinition | undefined =>
176
+ candidateBadges.find((badge) => badge.candidateId === candidateId);
177
+
168
178
  export const getCandidateTemplateDefinition = (
169
179
  templateId: string,
170
180
  ): WorkflowTemplateDefinition | undefined =>
@@ -1,3 +1,5 @@
1
+ import type { WorkflowCandidateSource } from "@features/workflow/lib/workflow-storage.types";
2
+
1
3
  export interface WorkflowNode {
2
4
  id: string;
3
5
  type: string;
@@ -28,6 +30,7 @@ export interface WorkflowMermaidPreviewVersion {
28
30
  id: string;
29
31
  mermaid?: string | null;
30
32
  templateId?: string;
33
+ candidateSource?: WorkflowCandidateSource;
31
34
  }
32
35
 
33
36
  export interface Workflow {
@@ -14,6 +14,8 @@ import type {
14
14
  WorkflowListenerMetricsResponse,
15
15
  WorkflowCredentialReadinessResponse,
16
16
  WorkflowPublishResponse,
17
+ WorkflowRunnableConfig,
18
+ CandidateUpdateNote,
17
19
  } from "./workflow-storage.types";
18
20
 
19
21
  export const API_BASE = "/api/workflows";
@@ -42,6 +44,46 @@ const readText = async (response: Response): Promise<string> => {
42
44
  }
43
45
  };
44
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
+
45
87
  export const request = async <T>(
46
88
  path: string,
47
89
  options: RequestOptions = {},
@@ -55,7 +97,8 @@ export const request = async <T>(
55
97
  });
56
98
 
57
99
  if (!response.ok) {
58
- const detail = (await readText(response)) || response.statusText;
100
+ const body = await readText(response);
101
+ const detail = body ? extractErrorMessage(body) : response.statusText;
59
102
  throw new ApiRequestError(detail, response.status);
60
103
  }
61
104
 
@@ -179,6 +222,8 @@ export interface ApiCandidate {
179
222
  notes: string | null;
180
223
  metadata: Record<string, unknown> | null;
181
224
  mermaid: string | null;
225
+ version: string | null;
226
+ updates: CandidateUpdateNote[];
182
227
  }
183
228
 
184
229
  export const fetchCandidates = async (): Promise<ApiCandidate[]> =>
@@ -214,6 +259,18 @@ export const onboardCandidate = async (
214
259
  }),
215
260
  });
216
261
 
262
+ export const updateCandidateWorkflow = async (
263
+ workflowId: string,
264
+ candidateId: string,
265
+ ): Promise<ApiWorkflow> =>
266
+ request<ApiWorkflow>("/api/candidates/update", {
267
+ method: "POST",
268
+ body: JSON.stringify({
269
+ workflow_id: workflowId,
270
+ candidate_id: candidateId,
271
+ }),
272
+ });
273
+
217
274
  export const fetchWorkflowListeners = async (
218
275
  workflowId: string,
219
276
  ): Promise<WorkflowListenerHealth[]> => {
@@ -439,6 +496,62 @@ export const extractCronConfigFromVersionGraph = (
439
496
  return extractCronConfigFromGraphNodes(graph);
440
497
  };
441
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
+
442
555
  const normalizeTemplateCronConfig = (
443
556
  config: CronTriggerConfig,
444
557
  metadata: unknown,
@@ -547,7 +660,10 @@ export const scheduleWorkflowFromLatestVersion = async (
547
660
 
548
661
  const cronConfigRaw = extractCronConfigFromVersionGraph(latest.graph);
549
662
  const cronConfig = cronConfigRaw
550
- ? normalizeTemplateCronConfig(cronConfigRaw, latest.metadata)
663
+ ? normalizeTemplateCronConfig(
664
+ resolveConfigurableCronConfig(cronConfigRaw, latest.runnable_config),
665
+ latest.metadata,
666
+ )
551
667
  : null;
552
668
  if (!cronConfig) {
553
669
  return {