@revenexx/studio-integrations 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +638 -0
  2. package/README.md +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
  7. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
  8. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  9. package/dist/runtime/components/Integrations/ExpressionInput.vue +45 -19
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
  12. package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
  13. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
  14. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -2
  16. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +5 -1
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +88 -1
  18. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +5 -1
  19. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +7 -3
  20. package/dist/runtime/components/Integrations/NodeInspector.vue +125 -54
  21. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +7 -3
  22. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.d.vue.ts +21 -0
  23. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue +133 -0
  24. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue.d.ts +21 -0
  25. package/dist/runtime/components/Integrations/RunActionsMenu.vue +2 -1
  26. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  27. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  28. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  29. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  30. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  31. package/dist/runtime/components/Integrations/RunsTable.vue +129 -18
  32. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  33. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  34. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.d.vue.ts +16 -0
  35. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +111 -0
  36. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue.d.ts +16 -0
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  38. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  39. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  40. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  41. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  42. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  43. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  44. package/dist/runtime/components/Integrations/WorkflowEditor.vue +78 -30
  45. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  46. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  47. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  48. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.d.vue.ts +13 -0
  49. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +216 -0
  50. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue.d.ts +13 -0
  51. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  52. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  53. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  54. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  55. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  56. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  57. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  58. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  59. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  60. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  61. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  62. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  63. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  64. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  65. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  66. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  67. package/dist/runtime/composables/useIntegrationsTemplates.js +7 -5
  68. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  69. package/dist/runtime/composables/useIntegrationsWorkflows.js +10 -7
  70. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  71. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  72. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  73. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  74. package/dist/runtime/data/expressionEditor.js +11 -5
  75. package/dist/runtime/data/handleId.d.ts +61 -1
  76. package/dist/runtime/data/handleId.js +8 -1
  77. package/dist/runtime/data/integrationNodeCatalog.d.ts +25 -0
  78. package/dist/runtime/data/integrationNodeCatalog.js +8 -0
  79. package/dist/runtime/data/nodeVersionMove.d.ts +72 -0
  80. package/dist/runtime/data/nodeVersionMove.js +65 -0
  81. package/dist/runtime/data/payloadForm.js +3 -2
  82. package/dist/runtime/i18n/messages/de.json +79 -12
  83. package/dist/runtime/i18n/messages/en.json +79 -12
  84. package/dist/runtime/pages/integrations/audit.vue +7 -6
  85. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  86. package/dist/runtime/pages/integrations/index.vue +55 -8
  87. package/dist/runtime/pages/integrations/runs.vue +116 -7
  88. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  90. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +169 -24
  92. package/dist/runtime/pages/integrations/workflows/index.vue +23 -4
  93. package/dist/runtime/types/integrations.d.ts +49 -1
  94. package/dist/runtime/utils/apiError.d.ts +21 -1
  95. package/dist/runtime/utils/apiError.js +1 -1
  96. package/dist/runtime/utils/buildSettle.d.ts +68 -0
  97. package/dist/runtime/utils/buildSettle.js +29 -0
  98. package/dist/runtime/utils/clipboard.d.ts +33 -0
  99. package/dist/runtime/utils/clipboard.js +11 -0
  100. package/dist/runtime/utils/deepClone.d.ts +41 -0
  101. package/dist/runtime/utils/deepClone.js +3 -0
  102. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  103. package/dist/runtime/utils/formatDuration.js +13 -0
  104. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  105. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  106. package/dist/runtime/utils/notify.d.ts +1 -1
  107. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  108. package/dist/runtime/utils/runEnvelope.js +12 -0
  109. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  110. package/dist/runtime/utils/runFeedQuery.js +66 -0
  111. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  112. package/dist/runtime/utils/runOutputs.js +10 -0
  113. package/dist/runtime/utils/settingCondition.d.ts +48 -0
  114. package/dist/runtime/utils/settingCondition.js +84 -0
  115. package/dist/runtime/utils/stateNamespaceName.d.ts +23 -0
  116. package/dist/runtime/utils/stateNamespaceName.js +8 -0
  117. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  118. package/dist/runtime/utils/validationFindings.js +18 -3
  119. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
3
+ import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, RefreshCircle as Loader2, MenuDots as MoreHorizontal, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
4
4
  import {
5
5
  AlertDialog,
6
6
  AlertDialogCancel,
@@ -16,6 +16,12 @@ import {
16
16
  DialogDescription,
17
17
  DialogHeader,
18
18
  DialogTitle,
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
19
25
  Select,
20
26
  SelectContent,
21
27
  SelectItem,
@@ -24,16 +30,21 @@ import {
24
30
  Switch,
25
31
  Textarea
26
32
  } from "@revenexx/studio";
33
+ import { deepClone } from "../../../utils/deepClone";
27
34
  import WorkflowEditor from "../../../components/Integrations/WorkflowEditor.vue";
28
35
  import WorkflowHistoryPanel from "../../../components/Integrations/WorkflowHistoryPanel.vue";
29
36
  import WorkflowRunReplayPanel from "../../../components/Integrations/WorkflowRunReplayPanel.vue";
30
37
  import WorkflowSettingsDialog from "../../../components/Integrations/WorkflowSettingsDialog.vue";
38
+ import WorkflowDeleteDialog from "../../../components/Integrations/WorkflowDeleteDialog.vue";
31
39
  import WorkflowDuplicateDialog from "../../../components/Integrations/WorkflowDuplicateDialog.vue";
32
40
  import RunsTable from "../../../components/Integrations/RunsTable.vue";
33
41
  import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue";
34
42
  import { toast } from "vue-sonner";
35
- import { IntegrationsApiError, apiValidationIssues } from "../../../utils/apiError";
43
+ import { IntegrationsApiError, apiValidationIssues, errorReason } from "../../../utils/apiError";
36
44
  import { createPollBudget, SAFETY_POLL_MS } from "../../../utils/pollBudget";
45
+ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBuildUnsettled } from "../../../utils/buildSettle";
46
+ import { splitRunOutputs } from "../../../utils/runOutputs";
47
+ import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
37
48
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
38
49
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
39
50
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
@@ -62,11 +73,11 @@ function draftFrom() {
62
73
  executionMode: w.execution_mode ?? "async_only",
63
74
  tags: [...w.blob.tags ?? []],
64
75
  icon: w.blob.icon ?? null,
65
- blob: JSON.parse(JSON.stringify(w.blob))
76
+ blob: deepClone(w.blob)
66
77
  };
67
78
  }
68
79
  function payloadBlob(d) {
69
- const blob = JSON.parse(JSON.stringify(d.blob));
80
+ const blob = deepClone(d.blob);
70
81
  blob.name = d.name;
71
82
  if (d.description) blob.description = d.description;
72
83
  else delete blob.description;
@@ -114,8 +125,8 @@ const reauthCredentials = computed(() => {
114
125
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
115
126
  const buildFailed = computed(() => workflow.value?.buildStatus === "failed");
116
127
  const waitingForBuild = ref(false);
117
- const BUILD_WAIT_POLL_MS = 2500;
118
- const BUILD_WAIT_TIMEOUT_MS = 5 * 6e4;
128
+ const BUILD_WAIT_POLL_MS = BUILD_SETTLE_POLL_MS;
129
+ const BUILD_WAIT_TIMEOUT_MS = BUILD_SETTLE_TIMEOUT_MS;
119
130
  async function waitForBuildReady() {
120
131
  waitingForBuild.value = true;
121
132
  try {
@@ -138,6 +149,14 @@ async function waitForBuildReady() {
138
149
  waitingForBuild.value = false;
139
150
  }
140
151
  }
152
+ const buildSettle = createBuildSettle();
153
+ function settleBuildStatus() {
154
+ void buildSettle.settle({
155
+ unsettled: () => !!workflow.value && isBuildUnsettled(workflow.value.buildStatus),
156
+ read: () => get(workflow.value.id)
157
+ });
158
+ }
159
+ onUnmounted(() => buildSettle.stop());
141
160
  const buildNotice = computed(() => {
142
161
  switch (workflow.value?.buildStatus) {
143
162
  case "building":
@@ -159,6 +178,7 @@ watch(id, async () => {
159
178
  draft.value = null;
160
179
  warningsExpanded.value = false;
161
180
  if (tab.value === "runs") void loadRuns(1);
181
+ settleBuildStatus();
162
182
  }, { immediate: true });
163
183
  function onChange(blob) {
164
184
  const d = draftFrom();
@@ -212,12 +232,25 @@ async function onDuplicated(copy) {
212
232
  toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
213
233
  await leaveTo(`/integrations/workflows/${copy.id}`);
214
234
  }
235
+ const deleteOpen = ref(false);
236
+ const deleteTarget = ref(null);
237
+ function askDelete() {
238
+ if (!workflow.value) return;
239
+ deleteTarget.value = { id: workflow.value.id, name: workflow.value.name };
240
+ deleteOpen.value = true;
241
+ }
242
+ async function onDeleted(wf) {
243
+ draft.value = null;
244
+ toast.success(t("integration.editor.toast.deleted", { name: wf.name }));
245
+ await leaveTo("/integrations/workflows");
246
+ }
215
247
  async function onRevisionRestored() {
216
248
  if (!workflow.value) return;
217
249
  await get(workflow.value.id);
218
250
  draft.value = null;
251
+ settleBuildStatus();
219
252
  }
220
- const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
253
+ const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, retryRun } = useIntegrationsRuns();
221
254
  const running = ref(false);
222
255
  const waitingMode = ref(null);
223
256
  const payloadText = ref("{}");
@@ -227,6 +260,8 @@ const syncOpen = ref(false);
227
260
  const syncLoading = ref(false);
228
261
  const syncResponse = ref(null);
229
262
  const syncError = ref(null);
263
+ const runObject = ref(null);
264
+ const followingRun = ref(false);
230
265
  const runModalMode = ref("sync");
231
266
  const runModalRunId = ref(null);
232
267
  function extractRunId(res) {
@@ -236,6 +271,13 @@ function extractRunId(res) {
236
271
  }
237
272
  return null;
238
273
  }
274
+ function asRunObject(res) {
275
+ if (!res || typeof res !== "object" || Array.isArray(res)) return null;
276
+ const o = res;
277
+ return "id" in o && "status" in o ? o : null;
278
+ }
279
+ const runOutputs = computed(() => splitRunOutputs(runObject.value?.result, shownBlob.value?.nodes ?? []));
280
+ const runResult = computed(() => runObject.value ? runObject.value.result : syncResponse.value);
239
281
  function viewRunFromModal(id2) {
240
282
  syncOpen.value = false;
241
283
  void router.push({ query: { ...route.query, run: id2 } });
@@ -244,9 +286,8 @@ function syncErrorMessage(e) {
244
286
  if (e instanceof IntegrationsApiError) {
245
287
  if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
246
288
  if (e.status === 422 && e.body?.status === "failed") return t("integration.editor.run.error.syncFailed");
247
- return e.message || t("integration.editor.run.error.syncFailed");
248
289
  }
249
- return e?.message || t("integration.editor.run.error.syncFailed");
290
+ return errorReason(e, t("integration.editor.run.error.syncFailed"));
250
291
  }
251
292
  function refusedForSettings(e) {
252
293
  const issues = apiValidationIssues(e);
@@ -289,6 +330,8 @@ async function run(mode) {
289
330
  }
290
331
  runModalMode.value = mode;
291
332
  runModalRunId.value = null;
333
+ stopFollowingRun();
334
+ runObject.value = null;
292
335
  syncOpen.value = true;
293
336
  syncLoading.value = true;
294
337
  syncResponse.value = null;
@@ -297,6 +340,7 @@ async function run(mode) {
297
340
  try {
298
341
  syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
299
342
  runModalRunId.value = extractRunId(syncResponse.value);
343
+ runObject.value = asRunObject(syncResponse.value);
300
344
  } catch (e) {
301
345
  await reportRunFailure(e, syncErrorMessage);
302
346
  if (e instanceof IntegrationsApiError) {
@@ -312,8 +356,10 @@ async function run(mode) {
312
356
  try {
313
357
  const res = await triggerRun(workflow.value.id, payload, "async");
314
358
  runModalRunId.value = extractRunId(res);
359
+ runObject.value = asRunObject(res);
360
+ startFollowingRun();
315
361
  } catch (e) {
316
- await reportRunFailure(e, (err) => err?.message || t("integration.editor.run.error.couldNotStart"));
362
+ await reportRunFailure(e, (err) => errorReason(err, t("integration.editor.run.error.couldNotStart")));
317
363
  } finally {
318
364
  running.value = false;
319
365
  syncLoading.value = false;
@@ -419,6 +465,61 @@ function startRunsPoll() {
419
465
  }, RUNS_POLL_MS);
420
466
  }
421
467
  onUnmounted(stopRunsPoll);
468
+ const FOLLOW_POLL_MS = 3e3;
469
+ const followBudget = createPollBudget({ intervalMs: FOLLOW_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
470
+ const FOLLOW_MAX_MISSES = 3;
471
+ let followTimer = null;
472
+ let followMisses = 0;
473
+ let followSeq = 0;
474
+ function stopFollowingRun() {
475
+ if (followTimer) {
476
+ clearInterval(followTimer);
477
+ followTimer = null;
478
+ }
479
+ followSeq++;
480
+ followingRun.value = false;
481
+ }
482
+ function countFollowMiss() {
483
+ if (++followMisses >= FOLLOW_MAX_MISSES) stopFollowingRun();
484
+ }
485
+ async function readFollowedRun() {
486
+ const id2 = runModalRunId.value;
487
+ if (!workflow.value || !id2) return stopFollowingRun();
488
+ const seq = ++followSeq;
489
+ try {
490
+ const raw = await getRun(workflow.value.id, id2);
491
+ followBudget.spend();
492
+ if (seq !== followSeq) return;
493
+ const run2 = asRunObject(unwrapRun(raw));
494
+ if (!run2) return countFollowMiss();
495
+ followMisses = 0;
496
+ runObject.value = run2;
497
+ if (isTerminalRun(run2)) stopFollowingRun();
498
+ } catch {
499
+ if (seq !== followSeq) return;
500
+ countFollowMiss();
501
+ }
502
+ }
503
+ function startFollowingRun() {
504
+ stopFollowingRun();
505
+ if (!runModalRunId.value || isTerminalRun(runObject.value)) return;
506
+ followMisses = 0;
507
+ followingRun.value = true;
508
+ void readFollowedRun();
509
+ followTimer = setInterval(() => {
510
+ if (followBudget.due(realtimeDelivering.value)) void readFollowedRun();
511
+ }, FOLLOW_POLL_MS);
512
+ }
513
+ watch(runTick, () => {
514
+ if (!followingRun.value) return;
515
+ const eventRun = lastRunEvent.value?.topicId;
516
+ if (eventRun && eventRun !== String(runModalRunId.value ?? "")) return;
517
+ void readFollowedRun();
518
+ });
519
+ watch(syncOpen, (open) => {
520
+ if (!open) stopFollowingRun();
521
+ });
522
+ onUnmounted(stopFollowingRun);
422
523
  watch(runTick, () => {
423
524
  if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
424
525
  const eventWorkflow = workflowIdOf(lastRunEvent.value);
@@ -455,7 +556,7 @@ async function loadRuns(page = runsWanted.value, opts = {}) {
455
556
  runsWanted.value = res.page;
456
557
  } catch (e) {
457
558
  if (seq !== runsSeq) return;
458
- runsError.value = e.message || t("integration.editor.runs.error.load");
559
+ runsError.value = errorReason(e, t("integration.editor.runs.error.load"));
459
560
  runsWanted.value = runsPage.value;
460
561
  } finally {
461
562
  if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
@@ -510,7 +611,7 @@ async function loadOpenRun() {
510
611
  try {
511
612
  openRun.value = await fetchRun(workflow.value.id, openRunId.value, workflow.value.name);
512
613
  } catch (e) {
513
- openRunError.value = e?.message ?? t("integration.editor.run.error.loadOpenRun");
614
+ openRunError.value = errorReason(e, t("integration.editor.run.error.loadOpenRun"));
514
615
  }
515
616
  }
516
617
  watch(openRunId, () => {
@@ -536,7 +637,7 @@ async function retryOpenRun() {
536
637
  toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
537
638
  void loadRuns(1, { silent: true });
538
639
  } catch (e) {
539
- toast.error(e?.message ?? t("integration.editor.retry.error.generic"));
640
+ toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
540
641
  } finally {
541
642
  retryBusy.value = false;
542
643
  }
@@ -589,7 +690,7 @@ async function onToggleActive() {
589
690
  toast.error(t("integration.editor.activate.error.validationIssues", issues.length));
590
691
  return;
591
692
  }
592
- toast.error(e.message || t("integration.editor.activate.error.generic"));
693
+ toast.error(errorReason(e, t("integration.editor.activate.error.generic")));
593
694
  }
594
695
  }
595
696
  async function save() {
@@ -606,6 +707,7 @@ async function save() {
606
707
  executionMode: d.executionMode
607
708
  });
608
709
  draft.value = null;
710
+ settleBuildStatus();
609
711
  } catch (e) {
610
712
  const issues = apiValidationIssues(e);
611
713
  if (issues.length && workflow.value.active) {
@@ -617,7 +719,7 @@ async function save() {
617
719
  });
618
720
  deactivationOpen.value = true;
619
721
  } else {
620
- saveError.value = e.message || t("integration.editor.save.error.generic");
722
+ saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
621
723
  }
622
724
  } finally {
623
725
  saving.value = false;
@@ -642,9 +744,10 @@ async function confirmDeactivate() {
642
744
  draft.value = null;
643
745
  deactivationOpen.value = false;
644
746
  toast.success(t("integration.editor.deactivate.toast.savedPaused"));
747
+ settleBuildStatus();
645
748
  } catch (e) {
646
749
  deactivationOpen.value = false;
647
- saveError.value = e.message || t("integration.editor.save.error.generic");
750
+ saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
648
751
  } finally {
649
752
  saving.value = false;
650
753
  }
@@ -747,10 +850,33 @@ function formatDateTime(iso) {
747
850
  {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
748
851
  </span>
749
852
  <span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
750
- <Button variant="outline" class="h-7 text-sm" :disabled="saving" :title="t('integration.editor.action.duplicate.title')" @click="duplicateOpen = true">
751
- <CopyIcon :size="12" />
752
- {{ t("integration.editor.action.duplicate.label") }}
753
- </Button>
853
+ <!-- What is done to the WORKFLOW, as opposed to the draft in front
854
+ of it: the buttons beside this menu discard and save an
855
+ afternoon's work, and these two make and unmake the thing that
856
+ work belongs to. Two reasons they are a menu rather than two more
857
+ buttons in a row of five. Deleting cannot be undone and has no
858
+ business a mis-click away from Save; and a lone Delete under a
859
+ Duplicate that stayed a button reads as an oversight rather than
860
+ as a grouping — the menu only holds together while it holds both.
861
+ Icon-only, so it carries a name of its own. -->
862
+ <DropdownMenu>
863
+ <DropdownMenuTrigger as-child>
864
+ <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
865
+ <MoreHorizontal :size="12" />
866
+ </Button>
867
+ </DropdownMenuTrigger>
868
+ <DropdownMenuContent align="end">
869
+ <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
870
+ <DropdownMenuSeparator />
871
+ <DropdownMenuItem @select="duplicateOpen = true">
872
+ {{ t("integration.editor.action.duplicate.label") }}
873
+ </DropdownMenuItem>
874
+ <DropdownMenuSeparator />
875
+ <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
876
+ {{ t("integration.common.action.delete") }}
877
+ </DropdownMenuItem>
878
+ </DropdownMenuContent>
879
+ </DropdownMenu>
754
880
  <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
755
881
  {{ t("integration.editor.action.discard.label") }}
756
882
  </Button>
@@ -898,7 +1024,7 @@ function formatDateTime(iso) {
898
1024
  :draft="draft?.blob ?? null"
899
1025
  :run-busy="running || syncLoading || waitingForBuild"
900
1026
  :run-waiting="waitingForBuild"
901
- :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
1027
+ :overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
902
1028
  @change="onChange"
903
1029
  @run="runFromEditor"
904
1030
  @toggle-active="onToggleActive"
@@ -1032,7 +1158,10 @@ function formatDateTime(iso) {
1032
1158
  :open="syncOpen"
1033
1159
  :loading="syncLoading"
1034
1160
  :mode="runModalMode"
1035
- :response="syncResponse"
1161
+ :run="runObject"
1162
+ :outputs="runOutputs"
1163
+ :result="runResult"
1164
+ :following="followingRun"
1036
1165
  :error="syncError"
1037
1166
  :run-id="runModalRunId"
1038
1167
  @update:open="(v) => syncOpen = v"
@@ -1049,6 +1178,8 @@ function formatDateTime(iso) {
1049
1178
  @apply="applySettings"
1050
1179
  />
1051
1180
 
1181
+ <WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" @deleted="onDeleted" />
1182
+
1052
1183
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
1053
1184
 
1054
1185
  <!-- Leaving the workflow with unsaved work.
@@ -1060,7 +1191,18 @@ function formatDateTime(iso) {
1060
1191
  afternoon this dialog exists to protect.
1061
1192
  `as-child` so the studio's own Button is that element rather than a second one
1062
1193
  beside it. Escape means the same thing, through `update:open`; a click beside
1063
- the dialog does nothing at all, which an alert dialog is entitled to. -->
1194
+ the dialog does nothing at all, which an alert dialog is entitled to.
1195
+
1196
+ The class list on that Button is the price of `as-child`: `AlertDialogCancel`
1197
+ applies the outline variant to whatever it renders, and every dialog in this
1198
+ module draws the answer on the right as a filled one. So the two variants meet
1199
+ on one element and `tailwind-merge` decides between them per group — which it
1200
+ cannot do where the outline variant names a group the default one does not, and
1201
+ those are the five that have to be said here. Left to itself the merge produced
1202
+ a background from the outline variant and a text colour from the default, which
1203
+ resolve to the same colour in this theme: the label was invisible until hovered
1204
+ (PO-415). Nothing marks this answer as the one holding the keyboard otherwise —
1205
+ `focus-visible` does not match after the click that opened the dialog. -->
1064
1206
  <AlertDialog :open="leaveOpen" @update:open="(v) => {
1065
1207
  if (!v) answerLeave(false);
1066
1208
  }">
@@ -1076,7 +1218,10 @@ function formatDateTime(iso) {
1076
1218
  {{ t("integration.editor.leave.dialog.discard") }}
1077
1219
  </Button>
1078
1220
  <AlertDialogCancel as-child>
1079
- <Button @click="answerLeave(false)">
1221
+ <Button
1222
+ class="border-transparent bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground dark:border-transparent dark:bg-primary dark:hover:bg-primary/90"
1223
+ @click="answerLeave(false)"
1224
+ >
1080
1225
  {{ t("integration.editor.leave.dialog.stay") }}
1081
1226
  </Button>
1082
1227
  </AlertDialogCancel>
@@ -3,15 +3,30 @@ import { AddCircle as Plus } from "#solar-icons";
3
3
  import { Button } from "@revenexx/studio";
4
4
  import WorkflowsTable from "../../../components/Integrations/WorkflowsTable.vue";
5
5
  import WorkflowCreateDialog from "../../../components/Integrations/WorkflowCreateDialog.vue";
6
+ import { createBuildSettle, isBuildUnsettled } from "../../../utils/buildSettle";
6
7
  const t = useIntegrationsT();
7
8
  useHead({ title: () => t("integration.workflows.list.head.title") });
8
9
  const { workflows, loading, error, load } = useIntegrationsWorkflows();
9
- onMounted(() => {
10
- void load();
10
+ onMounted(async () => {
11
+ await load();
12
+ settleBuildStates();
13
+ });
14
+ let onScreen = true;
15
+ const buildSettle = createBuildSettle();
16
+ function settleBuildStates() {
17
+ if (!onScreen) return;
18
+ void buildSettle.settle({
19
+ unsettled: () => workflows.value.some((wf) => isBuildUnsettled(wf.buildStatus)),
20
+ read: () => load(true)
21
+ });
22
+ }
23
+ onUnmounted(() => {
24
+ onScreen = false;
25
+ buildSettle.stop();
11
26
  });
12
27
  const { buildTick } = useIntegrationsRealtime();
13
28
  watch(buildTick, () => {
14
- void load(true);
29
+ void load(true).then(() => settleBuildStates());
15
30
  });
16
31
  const loadFailed = computed(() => !!error && !!error.value && !workflows.value.length);
17
32
  const createOpen = ref(false);
@@ -43,7 +58,11 @@ const createOpen = ref(false);
43
58
  </Button>
44
59
  </template>
45
60
  </StateCard>
46
- <WorkflowsTable v-else :workflows="workflows" :loading="loading" />
61
+ <!-- `loading` is the composable's app-wide flag and goes up for every read, the
62
+ build watch's included — so passing it straight through would flip the table
63
+ between its skeleton and its empty text every few seconds while a build
64
+ runs. What the table's skeleton means is "nothing to show yet". -->
65
+ <WorkflowsTable v-else :workflows="workflows" :loading="loading && !workflows.length" />
47
66
  </div>
48
67
 
49
68
  <WorkflowCreateDialog v-model:open="createOpen" />
@@ -158,11 +158,28 @@ export interface WorkflowIcon {
158
158
  /** Image URL, e.g. a node-package logo (kind=image). */
159
159
  src?: string;
160
160
  }
161
+ /** The four things a workflow can remember between runs (PO-374). */
162
+ export type WorkflowStateRole = 'mapping' | 'cursor' | 'dedupe' | 'digest';
163
+ /** `private` belongs to this workflow; `shared` is tenant-wide and outlives it. */
164
+ export type WorkflowStateVisibility = 'private' | 'shared';
165
+ /**
166
+ * One state namespace a workflow declares it may reach. The engine mints the
167
+ * run's grant from exactly this list, so an undeclared namespace is not
168
+ * undocumented — it is refused at run time.
169
+ */
170
+ export interface WorkflowStateNamespace {
171
+ namespace: string;
172
+ role: WorkflowStateRole;
173
+ /** Absent means `private`, which is what the engine's schema defaults to. */
174
+ visibility?: WorkflowStateVisibility;
175
+ }
161
176
  export interface WorkflowBlob {
162
177
  nodeManifestVersion: string;
163
178
  name: LocalizedString;
164
179
  description?: LocalizedString;
165
180
  variables?: Record<string, WorkflowVariable>;
181
+ /** State namespaces this workflow may reach at run time (BlobDef `state`). */
182
+ state?: WorkflowStateNamespace[];
166
183
  triggers: WorkflowTrigger[];
167
184
  nodes: WorkflowNode[];
168
185
  edges: WorkflowEdge[];
@@ -247,11 +264,25 @@ export interface NodeManifestOutput {
247
264
  * publishes them. Author-time schema only, never live run data. */
248
265
  fields?: string[] | Record<string, unknown>;
249
266
  }
250
- export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref' | 'dynamic-schema';
267
+ export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref'
268
+ /** Names one of the workflow's declared state namespaces (PO-374). */
269
+ | 'state-ref' | 'dynamic-schema';
251
270
  export interface ConfigFieldOption {
252
271
  value: string | number | boolean;
253
272
  label: LocalizedString;
254
273
  }
274
+ /** The comparison words a settings condition may use. One vocabulary with the
275
+ * condition and filter nodes, so an author who has met these words in one
276
+ * place does not learn a second set for the other. */
277
+ export type SettingConditionOperator = 'equals' | 'notEquals' | 'contains' | 'notContains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' | 'exists' | 'notExists' | 'isEmpty' | 'isNotEmpty';
278
+ /** A condition on another setting's value, deciding whether this one applies. */
279
+ export interface SettingCondition {
280
+ /** The `key` of another setting at the same level. */
281
+ key: string;
282
+ op: SettingConditionOperator;
283
+ /** Left out by the four operators that read presence rather than content. */
284
+ value?: string | number | boolean;
285
+ }
255
286
  export interface ConfigField {
256
287
  key: string;
257
288
  label: LocalizedString;
@@ -261,6 +292,11 @@ export interface ConfigField {
261
292
  default?: unknown;
262
293
  placeholder?: LocalizedString;
263
294
  expressionAllowed?: boolean;
295
+ /** The setting takes several lines — a mail body, a list of instructions one
296
+ * per line — so it is drawn with room for them. Read for `string` and
297
+ * `expression` fields; every other type ignores it (a dropdown has no
298
+ * multi-line meaning, and the JSON fallback is already a text area). */
299
+ multiline?: boolean;
264
300
  options?: ConfigFieldOption[];
265
301
  /** When true, this field's `options` are resolved at author-time via
266
302
  * config:resolve with target === this field's `key` (see useNodeConfigResolve). */
@@ -273,8 +309,20 @@ export interface ConfigField {
273
309
  * (e.g. 'revenexx:business-central'). An array lets a node accept more than
274
310
  * one credential type; the picker merges the tenant's credentials across all. */
275
311
  credentialType?: string | string[];
312
+ /**
313
+ * Only for `type: 'state-ref'` (PO-374): which kind of state namespace the
314
+ * field accepts. The picker lists the workflow's declarations of that role;
315
+ * the stored value is the namespace NAME.
316
+ */
317
+ stateRole?: WorkflowStateRole;
276
318
  /** keys of other config fields this field's options/schema depend on. */
277
319
  dependsOn?: string[];
320
+ /** When this setting applies at all (PO-410): another setting's key, an
321
+ * operator, and where the operator takes one, the value to compare against.
322
+ * Drawn only while the condition holds — see utils/settingCondition. Not
323
+ * `dependsOn`, which decides what a setting resolves to rather than whether
324
+ * it is drawn. */
325
+ showIf?: SettingCondition;
278
326
  validation?: {
279
327
  pattern?: string;
280
328
  min?: number;
@@ -60,5 +60,25 @@ export declare function apiFieldErrors(err: unknown): Record<string, string>;
60
60
  * one — and it counts things no form draws. A surface that shows every field-keyed
61
61
  * message at its field does not need the number at all; what it needs is the reason
62
62
  * (PO-275, reported from use).
63
+ *
64
+ * **It answers for a load as well as a refusal, and that is why it is not called
65
+ * `refusalReason` any more.** Six composables carried a byte-identical private
66
+ * `captureError` for the load case, and forty-odd call sites spelled the cast inline —
67
+ * one contract, three implementations, and only this one knew about the suffix. The
68
+ * strip is a no-op on a load failure, because nothing but a `422` carries a count; what
69
+ * the load case gains is the two lines above it, which it never had: a whitespace-only
70
+ * message now falls back instead of drawing blank.
71
+ *
72
+ * The `instanceof IntegrationsApiError` branch every `captureError` opened with is gone
73
+ * rather than moved. It read `if (e instanceof IntegrationsApiError) return e.message`
74
+ * above a line that reaches `e.message` anyway — the class extends `Error`, and `??`
75
+ * does not fire on a non-empty string — so it never changed an answer for any input.
76
+ *
77
+ * Three other sites kept that shape with this function in the `else`, and there it stopped
78
+ * being harmless the moment the two branches differed: `integrationsApi` wraps **every**
79
+ * failure in `IntegrationsApiError`, so the guard always won and the count was never
80
+ * stripped on a node-config resolve, a schedules load or a run's detail panel. They are
81
+ * gone too. The shape is worth recognising: an `instanceof` guard in front of this
82
+ * function reintroduces exactly the split it exists to remove.
63
83
  */
64
- export declare function refusalReason(err: unknown, fallback: string): string;
84
+ export declare function errorReason(err: unknown, fallback: string): string;
@@ -33,7 +33,7 @@ export function apiFieldErrors(err) {
33
33
  }
34
34
  return out;
35
35
  }
36
- export function refusalReason(err, fallback) {
36
+ export function errorReason(err, fallback) {
37
37
  const raw = err?.message?.trim();
38
38
  if (!raw) return fallback;
39
39
  return raw.replace(/\s*\(and \d+ more errors?\)\s*$/, "").trim() || fallback;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Watching a queued bundle build out, on any surface that reports one.
3
+ *
4
+ * A save queues a build, and until the platform has finished it the workflow cannot
5
+ * run. Both surfaces that say so read the state off the workflow row — the editor's
6
+ * chip beside Save, the build column in the workflows list — so neither of them
7
+ * learns that a build finished unless the row is read again.
8
+ *
9
+ * Two things can make that happen. A realtime build report, which is the fast path
10
+ * and is not always there: the topics have no producer in every environment, the
11
+ * connection can be off for a tenant, and a socket can drop in the second after the
12
+ * save. And re-reading the row, which is this — the floor under the fast path, and
13
+ * the only one of the two that works everywhere today.
14
+ *
15
+ * The editor has had it since PO-398, where a chip that never cleared was reported
16
+ * as "saving does not work"; the list kept the same defect for as long as it had no
17
+ * loop of its own (PO-416). So the loop lives here rather than in either surface:
18
+ * one interval, one ceiling, and one rule about which call wins, for both.
19
+ *
20
+ * WHAT A CALLER SUPPLIES is what differs between the two surfaces, and it is only
21
+ * ever these two things: whether anything is still unsettled — one workflow in the
22
+ * editor, any row in the list — and how to read it again. `settle()` decides
23
+ * nothing about the answer; the caller's own state is what every surface renders
24
+ * from.
25
+ *
26
+ * A GENERATION RATHER THAN A BUSY FLAG. Two saves in quick succession must not
27
+ * leave the second one unwatched, and the earlier loop cannot be relied on to cover
28
+ * it: a read already in flight can land after the newer save and put the pre-save
29
+ * row back into the cache, after which the older loop reads `ready` and stops. So
30
+ * the newest call wins and every older loop retires at its next turn.
31
+ *
32
+ * THE CEILING is not a timeout in the usual sense — nothing is reported when it is
33
+ * reached. A build is seconds; a build still queued after five minutes is a state
34
+ * worth leaving on screen rather than asking about forever, and the operator's own
35
+ * reload starts a fresh watch (which is why arriving on an unsettled row starts one).
36
+ */
37
+ /** Between reads. Same cadence the editor's queued-run wait uses. */
38
+ export declare const BUILD_SETTLE_POLL_MS = 2500;
39
+ /** After this, the state stands as last read and nothing asks again. */
40
+ export declare const BUILD_SETTLE_TIMEOUT_MS: number;
41
+ /**
42
+ * Whether a build state is one that changes on its own.
43
+ *
44
+ * `ready` and `failed` are where a build lands; anything else — including a row
45
+ * whose state the platform did not send — is nothing to wait for.
46
+ */
47
+ export declare function isBuildUnsettled(status?: string | null): boolean;
48
+ export interface BuildSettleWatch {
49
+ /** Whether anything is still worth waiting for. Read fresh on every turn. */
50
+ unsettled: () => boolean;
51
+ /** Reads the state again. Its own failures are its business — see below. */
52
+ read: () => Promise<unknown>;
53
+ }
54
+ export interface BuildSettleOptions {
55
+ pollMs?: number;
56
+ timeoutMs?: number;
57
+ }
58
+ export interface BuildSettle {
59
+ /**
60
+ * Reads until nothing is unsettled, or until the ceiling. Resolves either way and
61
+ * throws nothing: a read that fails leaves the state as it stands and the next
62
+ * turn tries again, which is the same answer a surface gives its reader anyway.
63
+ */
64
+ settle: (watch: BuildSettleWatch) => Promise<void>;
65
+ /** Retires whatever is watching — for a surface being left. */
66
+ stop: () => void;
67
+ }
68
+ export declare function createBuildSettle(options?: BuildSettleOptions): BuildSettle;