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
@@ -20,6 +20,7 @@ import type {
20
20
  ApiWorkflow,
21
21
  ApiWorkflowVersion,
22
22
  ApiWorkflowVersionSummary,
23
+ WorkflowCandidateSource,
23
24
  WorkflowVersionMetadata,
24
25
  StoredWorkflow,
25
26
  WorkflowVersionRecord,
@@ -97,6 +98,45 @@ const extractAvatarEmoji = (metadata: unknown): string | undefined => {
97
98
  return undefined;
98
99
  };
99
100
 
101
+ const extractCandidateSource = (
102
+ metadata: unknown,
103
+ ): WorkflowCandidateSource | undefined => {
104
+ if (!isRecord(metadata)) {
105
+ return undefined;
106
+ }
107
+
108
+ const source =
109
+ typeof metadata.source === "string" ? metadata.source : undefined;
110
+ const candidateId =
111
+ typeof metadata.candidate_id === "string"
112
+ ? metadata.candidate_id
113
+ : undefined;
114
+ const candidateHandle =
115
+ typeof metadata.candidate_handle === "string"
116
+ ? metadata.candidate_handle
117
+ : undefined;
118
+ const candidateVersion =
119
+ typeof metadata.candidate_version === "string"
120
+ ? metadata.candidate_version
121
+ : undefined;
122
+ const candidateSourceRef =
123
+ typeof metadata.candidate_source_ref === "string"
124
+ ? metadata.candidate_source_ref
125
+ : undefined;
126
+
127
+ if (!source && !candidateId && !candidateHandle && !candidateVersion) {
128
+ return undefined;
129
+ }
130
+
131
+ return {
132
+ source,
133
+ candidateId,
134
+ candidateHandle,
135
+ candidateVersion,
136
+ candidateSourceRef,
137
+ };
138
+ };
139
+
100
140
  const toAuthor = (id: string | undefined): Workflow["owner"] => {
101
141
  if (!id) {
102
142
  return { ...DEFAULT_OWNER };
@@ -176,8 +216,10 @@ const parseWorkflowMetadata = (
176
216
  ): WorkflowVersionMetadata => {
177
217
  const configurableSchemas = parseConfigurableSchemas(
178
218
  isRecord(metadata) ? metadata.configurable_schema : undefined,
219
+ isRecord(metadata) ? metadata.configurable_schema_order : undefined,
179
220
  );
180
221
  const avatarEmoji = extractAvatarEmoji(metadata);
222
+ const candidateSource = extractCandidateSource(metadata);
181
223
  const resolveTemplateFallback = (): WorkflowVersionMetadata | undefined => {
182
224
  if (!metadata || typeof metadata !== "object") {
183
225
  return undefined;
@@ -204,6 +246,7 @@ const parseWorkflowMetadata = (
204
246
  templateId,
205
247
  configurableSchemas,
206
248
  avatarEmoji,
249
+ candidateSource,
207
250
  };
208
251
  };
209
252
 
@@ -214,6 +257,7 @@ const parseWorkflowMetadata = (
214
257
  templateId: undefined,
215
258
  configurableSchemas: undefined,
216
259
  avatarEmoji: undefined,
260
+ candidateSource,
217
261
  };
218
262
  }
219
263
 
@@ -226,6 +270,7 @@ const parseWorkflowMetadata = (
226
270
  summary: { ...DEFAULT_SUMMARY },
227
271
  configurableSchemas,
228
272
  avatarEmoji,
273
+ candidateSource,
229
274
  }
230
275
  );
231
276
  }
@@ -280,11 +325,13 @@ const parseWorkflowMetadata = (
280
325
  templateId,
281
326
  configurableSchemas,
282
327
  avatarEmoji,
328
+ candidateSource,
283
329
  };
284
330
  };
285
331
 
286
332
  const parseConfigurableSchemas = (
287
333
  value: unknown,
334
+ order: unknown,
288
335
  ): Record<string, RJSFSchema> | undefined => {
289
336
  if (!value || typeof value !== "object" || Array.isArray(value)) {
290
337
  return undefined;
@@ -299,6 +346,23 @@ const parseConfigurableSchemas = (
299
346
  return undefined;
300
347
  }
301
348
 
349
+ // The schema object is persisted in a JSONB column that does not preserve key
350
+ // order, so reorder by the authored `configurable_schema_order` array when
351
+ // present. Keys missing from the order list keep their existing position at
352
+ // the end, keeping output stable for legacy versions without the array.
353
+ if (Array.isArray(order) && order.length > 0) {
354
+ const byKey = new Map(entries);
355
+ const orderedKeys = order.filter(
356
+ (key): key is string => typeof key === "string" && byKey.has(key),
357
+ );
358
+ const seen = new Set(orderedKeys);
359
+ const orderedEntries = [
360
+ ...orderedKeys.map((key) => [key, byKey.get(key)] as const),
361
+ ...entries.filter(([key]) => !seen.has(key)),
362
+ ];
363
+ return Object.fromEntries(orderedEntries) as Record<string, RJSFSchema>;
364
+ }
365
+
302
366
  return Object.fromEntries(entries) as Record<string, RJSFSchema>;
303
367
  };
304
368
 
@@ -337,6 +401,7 @@ const toVersionRecord = (
337
401
  graphToWorkflow: metadata.graphToWorkflow,
338
402
  templateId: metadata.templateId,
339
403
  avatarEmoji: metadata.avatarEmoji,
404
+ candidateSource: metadata.candidateSource,
340
405
  };
341
406
  };
342
407
 
@@ -16,6 +16,7 @@ import {
16
16
  fetchWorkflowVersions,
17
17
  onboardCandidate,
18
18
  request,
19
+ updateCandidateWorkflow,
19
20
  upsertWorkflow,
20
21
  } from "./workflow-storage-api";
21
22
  import {
@@ -472,6 +473,22 @@ export const onboardCandidateAsWorkflow = async (
472
473
  return stored;
473
474
  };
474
475
 
476
+ export const updateCandidateWorkflowVersion = async (
477
+ workflowId: string,
478
+ candidateId: string,
479
+ ): Promise<StoredWorkflow> => {
480
+ const apiWorkflow = await updateCandidateWorkflow(workflowId, candidateId);
481
+ invalidateWorkflowCache(apiWorkflow.id);
482
+ const stored = await ensureWorkflow(apiWorkflow.id);
483
+ if (!stored) {
484
+ throw new Error("Failed to load updated candidate workflow");
485
+ }
486
+ invalidateWorkflowListCache();
487
+ primeWorkflowCache(stored);
488
+ emitUpdate();
489
+ return stored;
490
+ };
491
+
475
492
  export type {
476
493
  StoredWorkflow,
477
494
  WorkflowVersionRecord,
@@ -68,6 +68,20 @@ export interface ApiWorkflowVersionSummary {
68
68
  updated_at: string;
69
69
  }
70
70
 
71
+ export interface CandidateUpdateNote {
72
+ version: string;
73
+ summary: string;
74
+ migration?: string | null;
75
+ }
76
+
77
+ export interface WorkflowCandidateSource {
78
+ source?: string;
79
+ candidateId?: string;
80
+ candidateHandle?: string;
81
+ candidateVersion?: string;
82
+ candidateSourceRef?: string;
83
+ }
84
+
71
85
  export interface ApiWorkflowPageData {
72
86
  workflow: ApiWorkflow;
73
87
  versions?: ApiWorkflowVersionSummary[];
@@ -219,6 +233,7 @@ export interface WorkflowVersionMetadata {
219
233
  templateId?: string;
220
234
  configurableSchemas?: Record<string, RJSFSchema>;
221
235
  avatarEmoji?: string;
236
+ candidateSource?: WorkflowCandidateSource;
222
237
  }
223
238
 
224
239
  export interface RequestOptions extends RequestInit {
@@ -241,6 +256,7 @@ export interface WorkflowVersionRecord {
241
256
  configurableSchemas?: Record<string, RJSFSchema>;
242
257
  graphToWorkflow?: Record<string, string>;
243
258
  templateId?: string;
259
+ candidateSource?: WorkflowCandidateSource;
244
260
  }
245
261
 
246
262
  export interface StoredWorkflow extends Workflow {
@@ -44,12 +44,28 @@ const baseWorkflowConfigSchema: RJSFSchema = {
44
44
  },
45
45
  };
46
46
 
47
- const workflowConfigUiSchema: UiSchema = {
48
- configurable: {
49
- fields: {
50
- "ui:field": "jsonObject",
47
+ const buildWorkflowConfigUiSchema = (
48
+ configurableSchemas?: Record<string, RJSFSchema>,
49
+ ): UiSchema => {
50
+ // Opt any key declared with `"format": "cron"` into the visual cron widget.
51
+ const cronEntries: UiSchema = {};
52
+ for (const [key, schema] of Object.entries(configurableSchemas ?? {})) {
53
+ if (schema?.format === "cron") {
54
+ cronEntries[key] = { "ui:widget": "cron" };
55
+ }
56
+ }
57
+
58
+ return {
59
+ configurable: {
60
+ // Render configurable fields in the order declared by config.json,
61
+ // with any data-only keys appended afterwards via the "*" wildcard.
62
+ "ui:order": [...Object.keys(configurableSchemas ?? {}), "*"],
63
+ fields: {
64
+ "ui:field": "jsonObject",
65
+ },
66
+ ...cronEntries,
51
67
  },
52
- },
68
+ };
53
69
  };
54
70
 
55
71
  interface WorkflowConfigSheetProps {
@@ -91,6 +107,11 @@ export function WorkflowConfigSheet({
91
107
  };
92
108
  }, [formData.configurable, configurableSchemas]);
93
109
 
110
+ const workflowConfigUiSchema = useMemo<UiSchema>(
111
+ () => buildWorkflowConfigUiSchema(configurableSchemas),
112
+ [configurableSchemas],
113
+ );
114
+
94
115
  useEffect(() => {
95
116
  if (open) {
96
117
  setFormData(toFormData(initialConfig));
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
2
2
  import { useUploadsAllowed } from "@/hooks/use-uploads-allowed";
3
3
  import {
4
4
  AlertTriangle,
5
+ CheckCircle2,
5
6
  Copy,
6
7
  ExternalLink,
7
8
  LoaderCircle,
8
9
  Play,
9
10
  RefreshCw,
10
11
  UserMinus,
12
+ XCircle,
11
13
  } from "lucide-react";
12
14
  import { useNavigate } from "react-router-dom";
13
15
  import { Controls, ReactFlow, type Node, type NodeProps } from "@xyflow/react";
@@ -56,6 +58,7 @@ import {
56
58
  } from "@features/workflow/lib/mermaid-renderer";
57
59
  import { resolveWorkflowVersionMermaidSource } from "@features/workflow/lib/workflow-storage-helpers";
58
60
  import { WorkflowConfigSheet } from "@features/workflow/pages/workflow/components/workflow-config-sheet";
61
+ import type { WorkflowExecutionStatus } from "@features/workflow/pages/workflow/helpers/types";
59
62
 
60
63
  export interface WorkflowTabContentProps {
61
64
  workflowId: string | null;
@@ -66,6 +69,7 @@ export interface WorkflowTabContentProps {
66
69
  loadError: string | null;
67
70
  isRunPending: boolean;
68
71
  isRunning: boolean;
72
+ lastRunStatus?: WorkflowExecutionStatus | null;
69
73
  onRunWorkflow: () => Promise<void>;
70
74
  onSaveConfig: (nextConfig: WorkflowRunnableConfig | null) => Promise<void>;
71
75
  hasCronTriggerNode: boolean;
@@ -189,6 +193,49 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
189
193
  return rawMessage;
190
194
  };
191
195
 
196
+ interface RunResultBanner {
197
+ Icon: typeof CheckCircle2;
198
+ className: string;
199
+ message: string;
200
+ }
201
+
202
+ const resolveRunResultBanner = (
203
+ status: WorkflowExecutionStatus,
204
+ ): RunResultBanner => {
205
+ switch (status) {
206
+ case "success":
207
+ return {
208
+ Icon: CheckCircle2,
209
+ className:
210
+ "border-emerald-500/40 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300",
211
+ message: "Workflow run succeeded. See the full trace on the Trace tab.",
212
+ };
213
+ case "failed":
214
+ return {
215
+ Icon: XCircle,
216
+ className: "border-destructive/40 bg-destructive/5 text-destructive",
217
+ message: "Workflow run failed. Check the Trace tab for details.",
218
+ };
219
+ case "partial":
220
+ return {
221
+ Icon: AlertTriangle,
222
+ className:
223
+ "border-amber-500/40 bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300",
224
+ message:
225
+ "Workflow run finished with partial results. Check the Trace tab for details.",
226
+ };
227
+ default:
228
+ // "running" reaching here means the stream ended without a final status.
229
+ return {
230
+ Icon: AlertTriangle,
231
+ className:
232
+ "border-amber-500/40 bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300",
233
+ message:
234
+ "Workflow run ended without a final status. Check the Trace tab for the latest status.",
235
+ };
236
+ }
237
+ };
238
+
192
239
  export function WorkflowTabContent({
193
240
  workflowId,
194
241
  workflowName,
@@ -197,6 +244,7 @@ export function WorkflowTabContent({
197
244
  loadError,
198
245
  isRunPending,
199
246
  isRunning,
247
+ lastRunStatus = null,
200
248
  onRunWorkflow,
201
249
  onSaveConfig,
202
250
  hasCronTriggerNode,
@@ -390,6 +438,11 @@ export function WorkflowTabContent({
390
438
  }
391
439
 
392
440
  const canConfigure = Boolean(workflowId);
441
+ const isRunActive = isRunPending || isRunning;
442
+ const runResultBanner =
443
+ !isRunActive && lastRunStatus
444
+ ? resolveRunResultBanner(lastRunStatus)
445
+ : null;
393
446
  const canRun = Boolean(workflowId && latestVersion);
394
447
  const runButtonDisabled = !canRun || isRunPending || isRunning;
395
448
  const runButtonLabel = isRunPending || isRunning ? "Running..." : "Run";
@@ -667,7 +720,7 @@ export function WorkflowTabContent({
667
720
  </div>
668
721
  </div>
669
722
 
670
- {(isRunPending || isRunning) && (
723
+ {isRunActive && (
671
724
  <div
672
725
  role="status"
673
726
  className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-primary"
@@ -677,6 +730,16 @@ export function WorkflowTabContent({
677
730
  </div>
678
731
  )}
679
732
 
733
+ {runResultBanner && (
734
+ <div
735
+ role="status"
736
+ className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${runResultBanner.className}`}
737
+ >
738
+ <runResultBanner.Icon className="h-4 w-4 shrink-0" />
739
+ <span>{runResultBanner.message}</span>
740
+ </div>
741
+ )}
742
+
680
743
  {isPublished && shareUrl && (
681
744
  <div className="flex items-center justify-between rounded-md border border-border/60 bg-muted/20 px-3 py-2">
682
745
  <div className="min-w-0">
@@ -75,6 +75,10 @@ export function buildWorkflowLayoutProps(
75
75
  resources: WorkflowResources,
76
76
  execution: WorkflowExecutionController,
77
77
  ): WorkflowLayoutProps {
78
+ const activeExecution = core.execution.executions.find(
79
+ (execution) => execution.id === core.execution.activeExecutionId,
80
+ );
81
+
78
82
  const workflowProps: WorkflowTabContentProps = {
79
83
  workflowId: core.metadata.currentWorkflowId,
80
84
  workflowRouteRef: core.routeWorkflowRef,
@@ -84,6 +88,7 @@ export function buildWorkflowLayoutProps(
84
88
  loadError: core.metadata.workflowLoadError,
85
89
  isRunPending: execution.isRunPending,
86
90
  isRunning: core.execution.isRunning,
91
+ lastRunStatus: activeExecution?.status ?? null,
87
92
  onRunWorkflow: execution.handleRunPersistedWorkflow,
88
93
  onSaveConfig: resources.saver.handleSaveWorkflowConfig,
89
94
  hasCronTriggerNode: hasSchedulableCronTrigger(
@@ -0,0 +1,107 @@
1
+ import type { Workflow } from "@features/workflow/data/workflow-data";
2
+ import type { CandidateBadgeDefinition } from "@features/workflow/data/templates/candidate-badges";
3
+ import type { CandidateUpdateNote } from "@features/workflow/lib/workflow-storage.types";
4
+
5
+ interface SemVer {
6
+ major: number;
7
+ minor: number;
8
+ patch: number;
9
+ }
10
+
11
+ export interface CandidateUpdateAvailability {
12
+ candidateId: string;
13
+ candidateName: string;
14
+ currentVersion: string;
15
+ latestVersion: string;
16
+ latestSummary?: string;
17
+ firstMigration?: string;
18
+ crossedUpdates: CandidateUpdateNote[];
19
+ isMajor: boolean;
20
+ }
21
+
22
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
23
+
24
+ export const parseSemVer = (value: string): SemVer | null => {
25
+ const match = SEMVER_PATTERN.exec(value);
26
+ if (!match) {
27
+ return null;
28
+ }
29
+
30
+ return {
31
+ major: Number.parseInt(match[1] ?? "0", 10),
32
+ minor: Number.parseInt(match[2] ?? "0", 10),
33
+ patch: Number.parseInt(match[3] ?? "0", 10),
34
+ };
35
+ };
36
+
37
+ export const compareSemVer = (left: string, right: string): number | null => {
38
+ const leftVersion = parseSemVer(left);
39
+ const rightVersion = parseSemVer(right);
40
+ if (!leftVersion || !rightVersion) {
41
+ return null;
42
+ }
43
+
44
+ for (const key of ["major", "minor", "patch"] as const) {
45
+ const delta = leftVersion[key] - rightVersion[key];
46
+ if (delta !== 0) {
47
+ return delta > 0 ? 1 : -1;
48
+ }
49
+ }
50
+
51
+ return 0;
52
+ };
53
+
54
+ export const getCandidateUpdateAvailability = (
55
+ workflow: Workflow,
56
+ candidate: CandidateBadgeDefinition | undefined,
57
+ ): CandidateUpdateAvailability | undefined => {
58
+ if (!candidate?.version) {
59
+ return undefined;
60
+ }
61
+
62
+ const candidateSource = workflow.versions?.at(-1)?.candidateSource;
63
+ if (candidateSource?.source !== "candidate-onboard") {
64
+ return undefined;
65
+ }
66
+
67
+ const currentVersion = candidateSource.candidateVersion;
68
+ if (!currentVersion) {
69
+ return undefined;
70
+ }
71
+
72
+ const latestVersion = candidate.version;
73
+ if (compareSemVer(latestVersion, currentVersion) !== 1) {
74
+ return undefined;
75
+ }
76
+
77
+ const currentParsed = parseSemVer(currentVersion);
78
+ const latestParsed = parseSemVer(latestVersion);
79
+ if (!currentParsed || !latestParsed) {
80
+ return undefined;
81
+ }
82
+
83
+ const crossedUpdates = (candidate.updates ?? []).filter((note) => {
84
+ const newerThanCurrent = compareSemVer(note.version, currentVersion);
85
+ const notNewerThanLatest = compareSemVer(note.version, latestVersion);
86
+ return (
87
+ newerThanCurrent === 1 &&
88
+ notNewerThanLatest !== null &&
89
+ notNewerThanLatest <= 0
90
+ );
91
+ });
92
+
93
+ const firstMigration = crossedUpdates.find(
94
+ (note) => typeof note.migration === "string" && note.migration.trim(),
95
+ )?.migration;
96
+
97
+ return {
98
+ candidateId: candidate.candidateId,
99
+ candidateName: candidate.name,
100
+ currentVersion,
101
+ latestVersion,
102
+ latestSummary: crossedUpdates[0]?.summary,
103
+ firstMigration,
104
+ crossedUpdates,
105
+ isMajor: latestParsed.major > currentParsed.major,
106
+ };
107
+ };
@@ -9,6 +9,7 @@ import { getCandidateBadgeDefinition } from "@features/workflow/data/templates/c
9
9
  import {
10
10
  deleteWorkflow,
11
11
  onboardCandidateAsWorkflow,
12
+ updateCandidateWorkflowVersion,
12
13
  } from "@features/workflow/lib/workflow-storage";
13
14
  import {
14
15
  type ApiTeam,
@@ -104,10 +105,9 @@ export const useWorkflowGalleryActions = (
104
105
  const handleOpenWorkflow = useCallback(
105
106
  (workflowId: string, teamSlug?: string) => {
106
107
  const slug = getSelectedWorkspaceSlug();
107
- const path =
108
- teamSlug
109
- ? getWorkspaceTeamWorkflowPath(slug, teamSlug, workflowId)
110
- : getWorkspaceWorkflowPath(slug, workflowId);
108
+ const path = teamSlug
109
+ ? getWorkspaceTeamWorkflowPath(slug, teamSlug, workflowId)
110
+ : getWorkspaceWorkflowPath(slug, workflowId);
111
111
  navigate(path);
112
112
  },
113
113
  [navigate],
@@ -302,6 +302,30 @@ export const useWorkflowGalleryActions = (
302
302
  [],
303
303
  );
304
304
 
305
+ const handleUpdateCandidateWorkflow = useCallback(
306
+ async (workflowId: string, candidateId: string) => {
307
+ try {
308
+ const workflow = await updateCandidateWorkflowVersion(
309
+ workflowId,
310
+ candidateId,
311
+ );
312
+ toast({
313
+ title: "Candidate updated",
314
+ description: `"${workflow.name}" is now on the latest candidate version.`,
315
+ });
316
+ } catch (error) {
317
+ toast({
318
+ title: "Failed to update candidate",
319
+ description:
320
+ error instanceof Error ? error.message : "Unknown error occurred",
321
+ variant: "destructive",
322
+ });
323
+ throw error;
324
+ }
325
+ },
326
+ [],
327
+ );
328
+
305
329
  const openCreateTeamDialog = useCallback(() => setIsCreateTeamOpen(true), []);
306
330
  const closeCreateTeamDialog = useCallback(
307
331
  () => setIsCreateTeamOpen(false),
@@ -314,6 +338,7 @@ export const useWorkflowGalleryActions = (
314
338
  handleImportStarterPack,
315
339
  handleExportWorkflow,
316
340
  handleDeleteWorkflow,
341
+ handleUpdateCandidateWorkflow,
317
342
  onboardTarget,
318
343
  confirmOnboardTeam,
319
344
  cancelOnboardTeam,
@@ -49,6 +49,8 @@ const toCandidateSpec = (candidate: ApiCandidate): CandidateBadgeSpec => ({
49
49
  avatar: candidate.avatar ?? undefined,
50
50
  notes: candidate.notes,
51
51
  mermaid: candidate.mermaid,
52
+ version: candidate.version,
53
+ updates: candidate.updates,
52
54
  rawMetadata: candidate.metadata,
53
55
  });
54
56
 
@@ -75,17 +77,22 @@ export const useWorkflowGalleryState = (): WorkflowGalleryStateSlice => {
75
77
  useEffect(() => {
76
78
  let isMounted = true;
77
79
 
78
- const loadCandidates = async () => {
80
+ const loadCandidates = async (existingWorkflows?: StoredWorkflow[]) => {
79
81
  try {
80
82
  // Load both candidates and existing workflows to check for handle conflicts
81
- const [candidates, existingWorkflows] = await Promise.all([
83
+ const [candidates, workflowsForConflicts] = await Promise.all([
82
84
  fetchCandidates(),
83
- listWorkflows(),
85
+ existingWorkflows
86
+ ? Promise.resolve(existingWorkflows)
87
+ : listWorkflows(),
84
88
  ]);
85
89
  if (!isMounted) {
86
90
  return;
87
91
  }
88
- setCandidateBadges(candidates.map(toCandidateSpec), existingWorkflows);
92
+ setCandidateBadges(
93
+ candidates.map(toCandidateSpec),
94
+ workflowsForConflicts,
95
+ );
89
96
  setCandidateWorkflows(getCandidateWorkflows());
90
97
  } catch (error) {
91
98
  if (isMounted) {
@@ -94,26 +101,8 @@ export const useWorkflowGalleryState = (): WorkflowGalleryStateSlice => {
94
101
  }
95
102
  };
96
103
 
97
- void loadCandidates();
98
-
99
- return () => {
100
- isMounted = false;
101
- };
102
- }, []);
103
-
104
- const refreshTeams = useCallback(async () => {
105
- try {
106
- const teamList = await fetchTeams();
107
- setTeams(teamList);
108
- } catch {
109
- // non-fatal — teams list will stay stale
110
- }
111
- }, []);
112
-
113
- useEffect(() => {
114
- let isMounted = true;
115
-
116
- const load = async (forceRefresh = false) => {
104
+ const load = async (forceRefresh = false, refreshCandidates = false) => {
105
+ let loadedWorkflows: StoredWorkflow[] | undefined;
117
106
  if (isMounted) {
118
107
  setIsLoadingWorkflows(true);
119
108
  }
@@ -122,6 +111,7 @@ export const useWorkflowGalleryState = (): WorkflowGalleryStateSlice => {
122
111
  listWorkflows({ forceRefresh }),
123
112
  fetchTeams().catch(() => [] as ApiTeam[]),
124
113
  ]);
114
+ loadedWorkflows = items;
125
115
  if (isMounted) {
126
116
  setWorkflows(items);
127
117
  setTeams(teamList);
@@ -143,14 +133,19 @@ export const useWorkflowGalleryState = (): WorkflowGalleryStateSlice => {
143
133
  setIsLoadingWorkflows(false);
144
134
  }
145
135
  }
136
+
137
+ if (refreshCandidates && loadedWorkflows) {
138
+ await loadCandidates(loadedWorkflows);
139
+ }
146
140
  };
147
141
 
142
+ void loadCandidates();
148
143
  void load();
149
144
 
150
145
  const targetWindow = typeof window !== "undefined" ? window : undefined;
151
146
  if (targetWindow) {
152
147
  const handler = () => {
153
- void load(true);
148
+ void load(true, true);
154
149
  };
155
150
  targetWindow.addEventListener(WORKFLOW_STORAGE_EVENT, handler);
156
151
 
@@ -165,6 +160,15 @@ export const useWorkflowGalleryState = (): WorkflowGalleryStateSlice => {
165
160
  };
166
161
  }, []);
167
162
 
163
+ const refreshTeams = useCallback(async () => {
164
+ try {
165
+ const teamList = await fetchTeams();
166
+ setTeams(teamList);
167
+ } catch {
168
+ // non-fatal — teams list will stay stale
169
+ }
170
+ }, []);
171
+
168
172
  const templates = useMemo(
169
173
  () => [...GALLERY_TEMPLATE_WORKFLOWS, ...candidateWorkflows],
170
174
  [candidateWorkflows],