@revenexx/studio-integrations 1.1.0 → 1.2.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 (98) hide show
  1. package/CHANGELOG.md +520 -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 +77 -22
  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/NodeConfigField.vue +23 -0
  16. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeInspector.vue +25 -32
  18. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  19. package/dist/runtime/components/Integrations/RunActionsMenu.vue +5 -3
  20. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  21. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  22. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  23. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  24. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  25. package/dist/runtime/components/Integrations/RunsTable.vue +143 -20
  26. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  27. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  28. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  29. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  30. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  31. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  32. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  33. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  34. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  35. package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -28
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  38. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  41. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  42. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  43. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  44. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  45. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  47. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  48. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  49. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  50. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  51. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  52. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  53. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  54. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  55. package/dist/runtime/composables/useIntegrationsTemplates.js +2 -5
  56. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  57. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -7
  58. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  59. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  60. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  61. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  62. package/dist/runtime/data/expressionEditor.js +11 -5
  63. package/dist/runtime/data/handleId.d.ts +61 -1
  64. package/dist/runtime/data/handleId.js +8 -1
  65. package/dist/runtime/data/payloadForm.js +3 -2
  66. package/dist/runtime/i18n/messages/de.json +30 -11
  67. package/dist/runtime/i18n/messages/en.json +27 -8
  68. package/dist/runtime/pages/integrations/audit.vue +7 -6
  69. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  70. package/dist/runtime/pages/integrations/index.vue +70 -12
  71. package/dist/runtime/pages/integrations/runs.vue +116 -7
  72. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  73. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  74. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  75. package/dist/runtime/pages/integrations/workflows/[id].vue +177 -28
  76. package/dist/runtime/types/integrations.d.ts +5 -0
  77. package/dist/runtime/utils/apiError.d.ts +21 -1
  78. package/dist/runtime/utils/apiError.js +1 -1
  79. package/dist/runtime/utils/clipboard.d.ts +33 -0
  80. package/dist/runtime/utils/clipboard.js +11 -0
  81. package/dist/runtime/utils/deepClone.d.ts +41 -0
  82. package/dist/runtime/utils/deepClone.js +3 -0
  83. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  84. package/dist/runtime/utils/formatDuration.js +13 -0
  85. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  86. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  87. package/dist/runtime/utils/notify.d.ts +1 -1
  88. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  89. package/dist/runtime/utils/runEnvelope.js +12 -0
  90. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  91. package/dist/runtime/utils/runFeedQuery.js +66 -0
  92. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  93. package/dist/runtime/utils/runOutputs.js +10 -0
  94. package/dist/runtime/utils/statusStyle.d.ts +24 -0
  95. package/dist/runtime/utils/statusStyle.js +1 -0
  96. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  97. package/dist/runtime/utils/validationFindings.js +18 -3
  98. 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,20 @@ 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 { splitRunOutputs } from "../../../utils/runOutputs";
46
+ import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
37
47
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
38
48
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
39
49
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
@@ -62,11 +72,11 @@ function draftFrom() {
62
72
  executionMode: w.execution_mode ?? "async_only",
63
73
  tags: [...w.blob.tags ?? []],
64
74
  icon: w.blob.icon ?? null,
65
- blob: JSON.parse(JSON.stringify(w.blob))
75
+ blob: deepClone(w.blob)
66
76
  };
67
77
  }
68
78
  function payloadBlob(d) {
69
- const blob = JSON.parse(JSON.stringify(d.blob));
79
+ const blob = deepClone(d.blob);
70
80
  blob.name = d.name;
71
81
  if (d.description) blob.description = d.description;
72
82
  else delete blob.description;
@@ -138,6 +148,19 @@ async function waitForBuildReady() {
138
148
  waitingForBuild.value = false;
139
149
  }
140
150
  }
151
+ let buildWatch = 0;
152
+ async function settleBuildStatus() {
153
+ const mine = ++buildWatch;
154
+ const started = Date.now();
155
+ while (Date.now() - started < BUILD_WAIT_TIMEOUT_MS) {
156
+ if (mine !== buildWatch || !workflow.value) return;
157
+ const status = workflow.value.buildStatus;
158
+ if (status !== "pending" && status !== "building") return;
159
+ await new Promise((resolve) => setTimeout(resolve, BUILD_WAIT_POLL_MS));
160
+ if (mine !== buildWatch || !workflow.value) return;
161
+ await get(workflow.value.id);
162
+ }
163
+ }
141
164
  const buildNotice = computed(() => {
142
165
  switch (workflow.value?.buildStatus) {
143
166
  case "building":
@@ -159,6 +182,7 @@ watch(id, async () => {
159
182
  draft.value = null;
160
183
  warningsExpanded.value = false;
161
184
  if (tab.value === "runs") void loadRuns(1);
185
+ void settleBuildStatus();
162
186
  }, { immediate: true });
163
187
  function onChange(blob) {
164
188
  const d = draftFrom();
@@ -212,12 +236,25 @@ async function onDuplicated(copy) {
212
236
  toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
213
237
  await leaveTo(`/integrations/workflows/${copy.id}`);
214
238
  }
239
+ const deleteOpen = ref(false);
240
+ const deleteTarget = ref(null);
241
+ function askDelete() {
242
+ if (!workflow.value) return;
243
+ deleteTarget.value = { id: workflow.value.id, name: workflow.value.name };
244
+ deleteOpen.value = true;
245
+ }
246
+ async function onDeleted(wf) {
247
+ draft.value = null;
248
+ toast.success(t("integration.editor.toast.deleted", { name: wf.name }));
249
+ await leaveTo("/integrations/workflows");
250
+ }
215
251
  async function onRevisionRestored() {
216
252
  if (!workflow.value) return;
217
253
  await get(workflow.value.id);
218
254
  draft.value = null;
255
+ void settleBuildStatus();
219
256
  }
220
- const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
257
+ const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, retryRun } = useIntegrationsRuns();
221
258
  const running = ref(false);
222
259
  const waitingMode = ref(null);
223
260
  const payloadText = ref("{}");
@@ -227,6 +264,8 @@ const syncOpen = ref(false);
227
264
  const syncLoading = ref(false);
228
265
  const syncResponse = ref(null);
229
266
  const syncError = ref(null);
267
+ const runObject = ref(null);
268
+ const followingRun = ref(false);
230
269
  const runModalMode = ref("sync");
231
270
  const runModalRunId = ref(null);
232
271
  function extractRunId(res) {
@@ -236,6 +275,13 @@ function extractRunId(res) {
236
275
  }
237
276
  return null;
238
277
  }
278
+ function asRunObject(res) {
279
+ if (!res || typeof res !== "object" || Array.isArray(res)) return null;
280
+ const o = res;
281
+ return "id" in o && "status" in o ? o : null;
282
+ }
283
+ const runOutputs = computed(() => splitRunOutputs(runObject.value?.result, shownBlob.value?.nodes ?? []));
284
+ const runResult = computed(() => runObject.value ? runObject.value.result : syncResponse.value);
239
285
  function viewRunFromModal(id2) {
240
286
  syncOpen.value = false;
241
287
  void router.push({ query: { ...route.query, run: id2 } });
@@ -244,9 +290,8 @@ function syncErrorMessage(e) {
244
290
  if (e instanceof IntegrationsApiError) {
245
291
  if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
246
292
  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
293
  }
249
- return e?.message || t("integration.editor.run.error.syncFailed");
294
+ return errorReason(e, t("integration.editor.run.error.syncFailed"));
250
295
  }
251
296
  function refusedForSettings(e) {
252
297
  const issues = apiValidationIssues(e);
@@ -289,6 +334,8 @@ async function run(mode) {
289
334
  }
290
335
  runModalMode.value = mode;
291
336
  runModalRunId.value = null;
337
+ stopFollowingRun();
338
+ runObject.value = null;
292
339
  syncOpen.value = true;
293
340
  syncLoading.value = true;
294
341
  syncResponse.value = null;
@@ -297,6 +344,7 @@ async function run(mode) {
297
344
  try {
298
345
  syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
299
346
  runModalRunId.value = extractRunId(syncResponse.value);
347
+ runObject.value = asRunObject(syncResponse.value);
300
348
  } catch (e) {
301
349
  await reportRunFailure(e, syncErrorMessage);
302
350
  if (e instanceof IntegrationsApiError) {
@@ -312,8 +360,10 @@ async function run(mode) {
312
360
  try {
313
361
  const res = await triggerRun(workflow.value.id, payload, "async");
314
362
  runModalRunId.value = extractRunId(res);
363
+ runObject.value = asRunObject(res);
364
+ startFollowingRun();
315
365
  } catch (e) {
316
- await reportRunFailure(e, (err) => err?.message || t("integration.editor.run.error.couldNotStart"));
366
+ await reportRunFailure(e, (err) => errorReason(err, t("integration.editor.run.error.couldNotStart")));
317
367
  } finally {
318
368
  running.value = false;
319
369
  syncLoading.value = false;
@@ -397,6 +447,9 @@ const runsError = ref(null);
397
447
  const runsPage = ref(1);
398
448
  const runsTotal = ref(0);
399
449
  const runsLastPage = ref(1);
450
+ const runsWanted = ref(1);
451
+ let runsSeq = 0;
452
+ let runsLoadingSeq = 0;
400
453
  const RUNS_POLL_MS = 3e3;
401
454
  const { delivering: realtimeDelivering, runTick, lastRunEvent, buildTick, lastBuildEvent } = useIntegrationsRealtime();
402
455
  const runsBudget = createPollBudget({ intervalMs: RUNS_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
@@ -410,12 +463,69 @@ function stopRunsPoll() {
410
463
  function startRunsPoll() {
411
464
  stopRunsPoll();
412
465
  runsPoll = setInterval(() => {
413
- if (runsPage.value === 1 && runsBudget.due(realtimeDelivering.value)) void loadRuns(1, { silent: true });
466
+ if (!runsLoading.value && runsWanted.value === 1 && runsBudget.due(realtimeDelivering.value)) {
467
+ void loadRuns(1, { silent: true });
468
+ }
414
469
  }, RUNS_POLL_MS);
415
470
  }
416
471
  onUnmounted(stopRunsPoll);
472
+ const FOLLOW_POLL_MS = 3e3;
473
+ const followBudget = createPollBudget({ intervalMs: FOLLOW_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
474
+ const FOLLOW_MAX_MISSES = 3;
475
+ let followTimer = null;
476
+ let followMisses = 0;
477
+ let followSeq = 0;
478
+ function stopFollowingRun() {
479
+ if (followTimer) {
480
+ clearInterval(followTimer);
481
+ followTimer = null;
482
+ }
483
+ followSeq++;
484
+ followingRun.value = false;
485
+ }
486
+ function countFollowMiss() {
487
+ if (++followMisses >= FOLLOW_MAX_MISSES) stopFollowingRun();
488
+ }
489
+ async function readFollowedRun() {
490
+ const id2 = runModalRunId.value;
491
+ if (!workflow.value || !id2) return stopFollowingRun();
492
+ const seq = ++followSeq;
493
+ try {
494
+ const raw = await getRun(workflow.value.id, id2);
495
+ followBudget.spend();
496
+ if (seq !== followSeq) return;
497
+ const run2 = asRunObject(unwrapRun(raw));
498
+ if (!run2) return countFollowMiss();
499
+ followMisses = 0;
500
+ runObject.value = run2;
501
+ if (isTerminalRun(run2)) stopFollowingRun();
502
+ } catch {
503
+ if (seq !== followSeq) return;
504
+ countFollowMiss();
505
+ }
506
+ }
507
+ function startFollowingRun() {
508
+ stopFollowingRun();
509
+ if (!runModalRunId.value || isTerminalRun(runObject.value)) return;
510
+ followMisses = 0;
511
+ followingRun.value = true;
512
+ void readFollowedRun();
513
+ followTimer = setInterval(() => {
514
+ if (followBudget.due(realtimeDelivering.value)) void readFollowedRun();
515
+ }, FOLLOW_POLL_MS);
516
+ }
417
517
  watch(runTick, () => {
418
- if (tab.value !== "runs" || runsPage.value !== 1) return;
518
+ if (!followingRun.value) return;
519
+ const eventRun = lastRunEvent.value?.topicId;
520
+ if (eventRun && eventRun !== String(runModalRunId.value ?? "")) return;
521
+ void readFollowedRun();
522
+ });
523
+ watch(syncOpen, (open) => {
524
+ if (!open) stopFollowingRun();
525
+ });
526
+ onUnmounted(stopFollowingRun);
527
+ watch(runTick, () => {
528
+ if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
419
529
  const eventWorkflow = workflowIdOf(lastRunEvent.value);
420
530
  if (eventWorkflow && eventWorkflow !== String(workflow.value?.id ?? "")) return;
421
531
  void loadRuns(1, { silent: true });
@@ -426,10 +536,15 @@ watch(buildTick, () => {
426
536
  if (eventWorkflow && eventWorkflow !== String(workflow.value.id)) return;
427
537
  void get(workflow.value.id);
428
538
  });
429
- async function loadRuns(page = runsPage.value, opts = {}) {
539
+ async function loadRuns(page = runsWanted.value, opts = {}) {
430
540
  if (!workflow.value) return;
541
+ runsWanted.value = page;
542
+ const seq = ++runsSeq;
431
543
  runsBudget.spend();
432
- if (!opts.silent) runsLoading.value = true;
544
+ if (!opts.silent) {
545
+ runsLoading.value = true;
546
+ runsLoadingSeq = seq;
547
+ }
433
548
  runsError.value = null;
434
549
  try {
435
550
  const res = await loadForWorkflow(workflow.value.id, workflow.value.name, {
@@ -437,19 +552,23 @@ async function loadRuns(page = runsPage.value, opts = {}) {
437
552
  page,
438
553
  perPage: RUNS_PER_PAGE
439
554
  });
555
+ if (seq !== runsSeq) return;
440
556
  wfRuns.value = res.runs;
441
557
  runsTotal.value = res.total;
442
558
  runsLastPage.value = res.lastPage;
443
559
  runsPage.value = res.page;
560
+ runsWanted.value = res.page;
444
561
  } catch (e) {
445
- runsError.value = e.message || t("integration.editor.runs.error.load");
562
+ if (seq !== runsSeq) return;
563
+ runsError.value = errorReason(e, t("integration.editor.runs.error.load"));
564
+ runsWanted.value = runsPage.value;
446
565
  } finally {
447
- if (!opts.silent) runsLoading.value = false;
566
+ if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
448
567
  }
449
568
  }
450
569
  function goRunsPage(p) {
451
570
  const next = Math.min(Math.max(1, p), runsLastPage.value);
452
- if (next !== runsPage.value) void loadRuns(next);
571
+ if (next !== runsWanted.value) void loadRuns(next);
453
572
  }
454
573
  watch(tab, (t2) => {
455
574
  if (t2 === "runs") {
@@ -496,7 +615,7 @@ async function loadOpenRun() {
496
615
  try {
497
616
  openRun.value = await fetchRun(workflow.value.id, openRunId.value, workflow.value.name);
498
617
  } catch (e) {
499
- openRunError.value = e?.message ?? t("integration.editor.run.error.loadOpenRun");
618
+ openRunError.value = errorReason(e, t("integration.editor.run.error.loadOpenRun"));
500
619
  }
501
620
  }
502
621
  watch(openRunId, () => {
@@ -522,7 +641,7 @@ async function retryOpenRun() {
522
641
  toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
523
642
  void loadRuns(1, { silent: true });
524
643
  } catch (e) {
525
- toast.error(e?.message ?? t("integration.editor.retry.error.generic"));
644
+ toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
526
645
  } finally {
527
646
  retryBusy.value = false;
528
647
  }
@@ -575,7 +694,7 @@ async function onToggleActive() {
575
694
  toast.error(t("integration.editor.activate.error.validationIssues", issues.length));
576
695
  return;
577
696
  }
578
- toast.error(e.message || t("integration.editor.activate.error.generic"));
697
+ toast.error(errorReason(e, t("integration.editor.activate.error.generic")));
579
698
  }
580
699
  }
581
700
  async function save() {
@@ -592,6 +711,7 @@ async function save() {
592
711
  executionMode: d.executionMode
593
712
  });
594
713
  draft.value = null;
714
+ void settleBuildStatus();
595
715
  } catch (e) {
596
716
  const issues = apiValidationIssues(e);
597
717
  if (issues.length && workflow.value.active) {
@@ -603,7 +723,7 @@ async function save() {
603
723
  });
604
724
  deactivationOpen.value = true;
605
725
  } else {
606
- saveError.value = e.message || t("integration.editor.save.error.generic");
726
+ saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
607
727
  }
608
728
  } finally {
609
729
  saving.value = false;
@@ -628,9 +748,10 @@ async function confirmDeactivate() {
628
748
  draft.value = null;
629
749
  deactivationOpen.value = false;
630
750
  toast.success(t("integration.editor.deactivate.toast.savedPaused"));
751
+ void settleBuildStatus();
631
752
  } catch (e) {
632
753
  deactivationOpen.value = false;
633
- saveError.value = e.message || t("integration.editor.save.error.generic");
754
+ saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
634
755
  } finally {
635
756
  saving.value = false;
636
757
  }
@@ -733,10 +854,33 @@ function formatDateTime(iso) {
733
854
  {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
734
855
  </span>
735
856
  <span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
736
- <Button variant="outline" class="h-7 text-sm" :disabled="saving" :title="t('integration.editor.action.duplicate.title')" @click="duplicateOpen = true">
737
- <CopyIcon :size="12" />
738
- {{ t("integration.editor.action.duplicate.label") }}
739
- </Button>
857
+ <!-- What is done to the WORKFLOW, as opposed to the draft in front
858
+ of it: the buttons beside this menu discard and save an
859
+ afternoon's work, and these two make and unmake the thing that
860
+ work belongs to. Two reasons they are a menu rather than two more
861
+ buttons in a row of five. Deleting cannot be undone and has no
862
+ business a mis-click away from Save; and a lone Delete under a
863
+ Duplicate that stayed a button reads as an oversight rather than
864
+ as a grouping — the menu only holds together while it holds both.
865
+ Icon-only, so it carries a name of its own. -->
866
+ <DropdownMenu>
867
+ <DropdownMenuTrigger as-child>
868
+ <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
869
+ <MoreHorizontal :size="12" />
870
+ </Button>
871
+ </DropdownMenuTrigger>
872
+ <DropdownMenuContent align="end">
873
+ <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
874
+ <DropdownMenuSeparator />
875
+ <DropdownMenuItem @select="duplicateOpen = true">
876
+ {{ t("integration.editor.action.duplicate.label") }}
877
+ </DropdownMenuItem>
878
+ <DropdownMenuSeparator />
879
+ <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
880
+ {{ t("integration.common.action.delete") }}
881
+ </DropdownMenuItem>
882
+ </DropdownMenuContent>
883
+ </DropdownMenu>
740
884
  <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
741
885
  {{ t("integration.editor.action.discard.label") }}
742
886
  </Button>
@@ -884,7 +1028,7 @@ function formatDateTime(iso) {
884
1028
  :draft="draft?.blob ?? null"
885
1029
  :run-busy="running || syncLoading || waitingForBuild"
886
1030
  :run-waiting="waitingForBuild"
887
- :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
1031
+ :overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
888
1032
  @change="onChange"
889
1033
  @run="runFromEditor"
890
1034
  @toggle-active="onToggleActive"
@@ -912,10 +1056,10 @@ function formatDateTime(iso) {
912
1056
  <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
913
1057
  <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
914
1058
  <div class="flex gap-2">
915
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage <= 1 || runsLoading" @click="goRunsPage(runsPage - 1)">
1059
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
916
1060
  {{ t("integration.editor.runs.pagination.newer") }}
917
1061
  </Button>
918
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage >= runsLastPage || runsLoading" @click="goRunsPage(runsPage + 1)">
1062
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
919
1063
  {{ t("integration.editor.runs.pagination.older") }}
920
1064
  </Button>
921
1065
  </div>
@@ -1018,7 +1162,10 @@ function formatDateTime(iso) {
1018
1162
  :open="syncOpen"
1019
1163
  :loading="syncLoading"
1020
1164
  :mode="runModalMode"
1021
- :response="syncResponse"
1165
+ :run="runObject"
1166
+ :outputs="runOutputs"
1167
+ :result="runResult"
1168
+ :following="followingRun"
1022
1169
  :error="syncError"
1023
1170
  :run-id="runModalRunId"
1024
1171
  @update:open="(v) => syncOpen = v"
@@ -1035,6 +1182,8 @@ function formatDateTime(iso) {
1035
1182
  @apply="applySettings"
1036
1183
  />
1037
1184
 
1185
+ <WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" @deleted="onDeleted" />
1186
+
1038
1187
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
1039
1188
 
1040
1189
  <!-- Leaving the workflow with unsaved work.
@@ -261,6 +261,11 @@ export interface ConfigField {
261
261
  default?: unknown;
262
262
  placeholder?: LocalizedString;
263
263
  expressionAllowed?: boolean;
264
+ /** The setting takes several lines — a mail body, a list of instructions one
265
+ * per line — so it is drawn with room for them. Read for `string` and
266
+ * `expression` fields; every other type ignores it (a dropdown has no
267
+ * multi-line meaning, and the JSON fallback is already a text area). */
268
+ multiline?: boolean;
264
269
  options?: ConfigFieldOption[];
265
270
  /** When true, this field's `options` are resolved at author-time via
266
271
  * config:resolve with target === this field's `key` (see useNodeConfigResolve). */
@@ -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,33 @@
1
+ /**
2
+ * Put something on the clipboard, and tell the operator what actually happened.
3
+ *
4
+ * **The waiting is the point.** `writeText` returns a promise, and a browser can refuse
5
+ * it after the fact — a page that is not focused, a context that is not secure, a
6
+ * permission the operator declined. A caller that fires its "Copied" message beside the
7
+ * call rather than after it announces a success it has not been told about, and the
8
+ * operator pastes whatever was on the clipboard before. That shipped on the audit log,
9
+ * where the promise was neither awaited nor caught: the toast had already gone up by the
10
+ * time the rejection arrived, and the rejection went nowhere.
11
+ *
12
+ * **A missing `navigator.clipboard` is a failure, not a reason to do nothing.** The audit
13
+ * log guarded on it and skipped the whole block, so on a browser without the API the
14
+ * button did nothing at all — no copy, no message — which reads as broken rather than as
15
+ * refused. Reading through it and letting the `TypeError` land in the same `catch` is
16
+ * what makes "it did not work" one path instead of two.
17
+ *
18
+ * `vue-sonner` directly, not `notify.ts`: that wrapper exists so a *save* is never lost
19
+ * to a notification that cannot be drawn, and says in its own docblock that everywhere
20
+ * else imports the library. A copy has no such underlying action — the message IS the
21
+ * outcome — so routing six more surfaces through it would spend the indirection on the
22
+ * case it was explicitly not written for.
23
+ *
24
+ * The two messages stay with the caller because they are the only part that differs:
25
+ * copying a node's config, a webhook URL and an audit entry are three different things to
26
+ * have failed, and one message covering all of them would name none of them.
27
+ *
28
+ * Returns whether it worked, for a caller that has more to do than say so.
29
+ */
30
+ export declare function copyToClipboard(text: string, said: {
31
+ copied: string;
32
+ failed: string;
33
+ }): Promise<boolean>;
@@ -0,0 +1,11 @@
1
+ import { toast } from "vue-sonner";
2
+ export async function copyToClipboard(text, said) {
3
+ try {
4
+ await navigator.clipboard.writeText(text);
5
+ } catch {
6
+ toast.error(said.failed);
7
+ return false;
8
+ }
9
+ toast.success(said.copied);
10
+ return true;
11
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A deep copy of a value the studio is about to let someone edit.
3
+ *
4
+ * Every surface that edits a workflow takes one first, so that an unsaved change can
5
+ * be thrown away by dropping the copy rather than by undoing it: the editor's canvas,
6
+ * the node inspector's draft, the variables dialog's working set, a duplicate on its
7
+ * way to a new name, and the payload form's expert extras.
8
+ *
9
+ * **It is a JSON round trip, and that is a claim about the input rather than a
10
+ * shortcut.** A workflow blob is JSON — it is what the API stores and what the wire
11
+ * carries — so serialising and parsing it loses nothing. That is the whole guarantee,
12
+ * and it was the guarantee that made eight hand-written copies of this expression safe.
13
+ * What it cost is that the guarantee was never written down, so nothing said which
14
+ * inputs it holds for.
15
+ *
16
+ * **What it does not survive**, for whoever reaches for this with something that is not
17
+ * a blob: `undefined` becomes a missing key rather than a present one, a `Date` comes
18
+ * back as a string, a `Map`, a `Set` and a function are lost entirely, and a cycle
19
+ * throws. None of those can appear in a blob. All of them can appear in editor state
20
+ * that has not been through the API yet — a resolved sample, a cached schema — so a
21
+ * new caller is the moment to check the claim rather than to assume it.
22
+ *
23
+ * **The signature cannot enforce any of that, and says so here rather than pretending.**
24
+ * `<T>(value: T): T` claims the type it was handed comes back, which is false for exactly
25
+ * the five: `deepClone({ at: new Date() }).at` is typed `Date` and is a string, so
26
+ * `.getTime()` type-checks and throws. Constraining the parameter to a JSON-shaped type
27
+ * was tried and does not work here — `WorkflowBlob`, `WorkflowNode` and `WorkflowTrigger`
28
+ * are interfaces, which have no implicit index signature and so satisfy no recursive JSON
29
+ * type; it turned 13 type errors into 73, and collapsed the inference that gives callers
30
+ * their concrete type back. So the guarantee above is advisory, and the tests beside this
31
+ * file are where it is actually held.
32
+ *
33
+ * **Not `structuredClone`, deliberately.** It is available everywhere the module runs
34
+ * and it preserves all five of the things above, which sounds strictly better and is
35
+ * not: it would *change* what the callers get. A field that is dropped today because
36
+ * JSON cannot carry it would start surviving the copy, and something downstream may be
37
+ * relying on that loss without saying so. Swapping the mechanism is a behaviour change
38
+ * that wants its own proof; this module exists so that it becomes one edit when
39
+ * somebody makes it — which is PO-395's other half, and is not done here.
40
+ */
41
+ export declare function deepClone<T>(value: T): T;
@@ -0,0 +1,3 @@
1
+ export function deepClone(value) {
2
+ return JSON.parse(JSON.stringify(value));
3
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * How long something took, in the shortest unit that still reads.
3
+ *
4
+ * One function for every surface that prints a duration: the runs table, the run
5
+ * record's step list, the result of a run started by hand, the replay panel's step
6
+ * and run figures, and the dashboard's latest executions. Private copies are how two
7
+ * surfaces come to spell the same milliseconds differently, which is what PO-383
8
+ * closed — three implementations across those five, two of them disagreeing. The
9
+ * dashboard's was not in the ticket's enumeration: it divided by a thousand inside
10
+ * the template, so it was invisible to a search for a formatter.
11
+ *
12
+ * The form is `900ms`, `1.5s`, `1m 3s`, and it was chosen rather than inherited.
13
+ * Three candidates stood in PO-383: this one, the two-decimal `1.50s`/`1.1m` these
14
+ * surfaces used to print, and the same shape as this one with a space before the
15
+ * unit. The unit sits against the number because that is how the product already
16
+ * writes a duration everywhere it is copy rather than a computed figure — `{n}s
17
+ * ago`, `{n}m ago`, `{n}h ago` in the message catalogue, read out beside these very
18
+ * values in the runs list. A space would also have had to stop applying above a
19
+ * minute, where `1 m 3 s` is unreadable, so it was one form under two rules.
20
+ *
21
+ * One decimal under a minute, and minutes-and-seconds above: a duration is read to
22
+ * answer "was that quick", so `1m 3s` beats `1.1m` for a step that took a minute,
23
+ * and the second decimal of `1.50s` is precision nobody acts on.
24
+ *
25
+ * **A trailing zero is dropped, because it is not always a measurement.** Two kinds of
26
+ * value arrive here. A *step's* duration is measured in milliseconds and a tenth of a
27
+ * second is a real reading — `3.4s`. A *run's* is the difference between two
28
+ * timestamps the platform stores to the second (`timestamp(0)`) and serialises without
29
+ * a fraction, so it is always a whole number of seconds: there is no run-level
30
+ * millisecond value anywhere in the API to be had. Printing `4.0s` there claimed a
31
+ * precision that cannot exist. Nothing has to be told which kind it is — a value that
32
+ * measured nothing below the unit has nothing after the point, so `4s` and `1m` fall
33
+ * out of the arithmetic while `3.4s` and `1m 3s` keep what they measured.
34
+ *
35
+ * Nothing promises the form. `specs/run-row.md` AC-3 promises that a row says how
36
+ * long a run has been going and deliberately not in what shape, and that stays the
37
+ * right altitude: a criterion pinning the spelling would freeze a decision the
38
+ * product may want to revisit — the next one being a locale, which this function
39
+ * does not take (German wants `1,5 s`).
40
+ *
41
+ * `null` is not zero: a run that has not started has no duration, and an em dash
42
+ * says so where `0ms` would claim it finished instantly.
43
+ */
44
+ export declare function formatDuration(ms: number | null | undefined): string;
@@ -0,0 +1,13 @@
1
+ export function formatDuration(ms) {
2
+ if (ms == null) return "\u2014";
3
+ if (ms < 1e3) return `${ms}ms`;
4
+ const tenths = Math.round(ms / 100);
5
+ if (tenths < 600) {
6
+ const seconds2 = tenths / 10;
7
+ return tenths % 10 === 0 ? `${seconds2}s` : `${seconds2.toFixed(1)}s`;
8
+ }
9
+ const seconds = Math.round(ms / 1e3);
10
+ const rest = seconds % 60;
11
+ const minutes = Math.floor(seconds / 60);
12
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
13
+ }
@@ -0,0 +1,22 @@
1
+ /** What a node display name can be derived from — the blob's own fields. */
2
+ export interface NameableNode {
3
+ name?: unknown;
4
+ nodeSlug: string;
5
+ nodeVersion?: string;
6
+ }
7
+ /**
8
+ * What a node is called, wherever it is named.
9
+ *
10
+ * The three sources in order: the name the author typed, the manifest's name for
11
+ * that kind of node, and — where the catalogue does not know the slug — the slug
12
+ * itself. Never the node's id, which is a key the studio generated and nobody
13
+ * chose.
14
+ *
15
+ * The two resolvers are not interchangeable and the difference has already cost
16
+ * once (PO-204): the author's own name is passed through untouched, while a
17
+ * manifest string is trimmed and may carry its languages as an object. That is
18
+ * why this is one function rather than the rule restated wherever a node is
19
+ * named — the canvas card and the result of a run started by hand have to agree
20
+ * on what a node is called, or the dialog names something the graph does not.
21
+ */
22
+ export declare function nodeDisplayName(node: NameableNode): string;