@revenexx/studio-integrations 1.3.0 → 1.4.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 (58) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.d.vue.ts +9 -0
  4. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue +53 -0
  5. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue.d.ts +9 -0
  6. package/dist/runtime/components/Integrations/EditorTip.vue +2 -15
  7. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +10 -0
  8. package/dist/runtime/components/Integrations/NodeConfigField.vue +94 -49
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +10 -0
  10. package/dist/runtime/components/Integrations/NodeInspector.vue +37 -60
  11. package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
  12. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
  13. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
  14. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
  15. package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
  16. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
  17. package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
  18. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
  19. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
  20. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
  21. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
  22. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
  23. package/dist/runtime/components/Integrations/WorkflowEditor.vue +165 -56
  24. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
  25. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
  26. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
  27. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
  28. package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
  29. package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
  30. package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
  31. package/dist/runtime/composables/useCredentialTypes.js +3 -9
  32. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
  33. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  34. package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
  35. package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
  36. package/dist/runtime/composables/useIntegrationsTemplates.js +5 -8
  37. package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
  38. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -7
  39. package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
  40. package/dist/runtime/data/canvasShortcuts.js +68 -0
  41. package/dist/runtime/i18n/messages/de.json +30 -16
  42. package/dist/runtime/i18n/messages/en.json +30 -16
  43. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
  44. package/dist/runtime/pages/integrations/workflows/[id].vue +128 -33
  45. package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
  46. package/dist/runtime/utils/apiEnvelope.js +20 -0
  47. package/dist/runtime/utils/configDefaults.d.ts +18 -0
  48. package/dist/runtime/utils/configDefaults.js +7 -0
  49. package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
  50. package/dist/runtime/utils/errorPathAdvice.js +19 -0
  51. package/dist/runtime/utils/notify.d.ts +2 -2
  52. package/dist/runtime/utils/runEnvelope.d.ts +12 -0
  53. package/dist/runtime/utils/runEnvelope.js +2 -1
  54. package/dist/runtime/utils/selectOptions.d.ts +48 -0
  55. package/dist/runtime/utils/selectOptions.js +19 -0
  56. package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
  57. package/dist/runtime/utils/shortcutLabel.js +48 -0
  58. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, reactive, ref } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import { marked } from "marked";
4
4
  import { AltArrowRight as Chevron, CheckCircle as CheckCircle2, Key, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
5
5
  import { triggerGlyph as glyphForTrigger } from "../../../data/triggerGlyphs";
@@ -9,27 +9,19 @@ import {
9
9
  Dialog,
10
10
  DialogContent,
11
11
  DialogDescription,
12
- DialogFooter,
13
12
  DialogHeader,
14
- DialogTitle,
15
- Input,
16
- Label,
17
- Select,
18
- SelectContent,
19
- SelectItem,
20
- SelectTrigger,
21
- SelectValue
13
+ DialogTitle
22
14
  } from "@revenexx/studio";
23
15
  import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
16
+ import TemplateInstallStep from "../../../components/Integrations/TemplateInstallStep.vue";
17
+ import LoadingAnnouncement from "../../../components/Integrations/LoadingAnnouncement.vue";
24
18
  import NodeMark from "../../../components/Integrations/NodeMark.vue";
25
19
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
26
20
  import { describeCron } from "../../../utils/schedule";
27
- import { errorReason } from "../../../utils/apiError";
28
- const SELECT_LATER = "__later__";
29
21
  const t = useIntegrationsT();
30
22
  const route = useRoute();
31
23
  const router = useRouter();
32
- const { get, load, loaded, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
24
+ const { get, load, loaded, fetchDetail, requirements } = useIntegrationsTemplates();
33
25
  const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
34
26
  const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
35
27
  const { ofType, load: loadCredentials } = useIntegrationsCredentials();
@@ -137,39 +129,13 @@ const setupReady = computed(() => missingSecrets.value === 0 && missingCredentia
137
129
  const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
138
130
  const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
139
131
  const installOpen = ref(false);
140
- const installing = ref(false);
141
- const installError = ref(null);
142
- const name = ref("");
143
- const bindings = reactive({});
144
- function nodesFor(usedBy) {
145
- return [...new Set(usedBy.map((u) => u.nodeId))];
146
- }
132
+ const stepLoading = ref(false);
147
133
  function openInstall() {
148
134
  if (!tpl.value) return;
149
- name.value = tpl.value.name;
150
- for (const k of Object.keys(bindings)) delete bindings[k];
151
- installError.value = null;
152
135
  installOpen.value = true;
153
136
  }
154
- async function confirmInstall() {
155
- if (!tpl.value) return;
156
- installing.value = true;
157
- installError.value = null;
158
- try {
159
- const credentialBindings = {};
160
- for (const [nodeId, credId] of Object.entries(bindings)) {
161
- if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
162
- }
163
- const id = await instantiate(tpl.value.slug, {
164
- name: name.value.trim() || void 0,
165
- credentialBindings
166
- });
167
- if (id) await router.push(`/integrations/workflows/${id}`);
168
- } catch (e) {
169
- installError.value = errorReason(e, t("integration.templates.detail.install.error.fallback"));
170
- } finally {
171
- installing.value = false;
172
- }
137
+ async function installed(id) {
138
+ await router.push(`/integrations/workflows/${id}`);
173
139
  }
174
140
  const PUBLISHER_KIND_LABEL_KEY = {
175
141
  revenexx: "integration.templates.detail.publisher.kind.revenexx",
@@ -471,7 +437,13 @@ function levelDot(level) {
471
437
  {{ t("integration.templates.detail.state.notFound") }}
472
438
  </div>
473
439
 
474
- <!-- Install: name + per-node credential binding ------------------------- -->
440
+ <LoadingAnnouncement
441
+ :label="t('integration.templates.detail.action.useTemplate')"
442
+ :loading="stepLoading"
443
+ :text="t('integration.templates.detail.install.state.loading')"
444
+ />
445
+
446
+ <!-- Install: the shared step, in this page's dialog ------------------- -->
475
447
  <Dialog v-model:open="installOpen">
476
448
  <DialogContent class="sm:max-w-lg">
477
449
  <DialogHeader>
@@ -481,95 +453,13 @@ function levelDot(level) {
481
453
  </DialogDescription>
482
454
  </DialogHeader>
483
455
 
484
- <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
485
- <div class="space-y-1.5">
486
- <Label for="wf-name">{{ t("integration.templates.detail.install.nameLabel") }}</Label>
487
- <Input id="wf-name" v-model="name" class="bg-white" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
488
- </div>
489
-
490
- <!-- Credentials -->
491
- <div class="space-y-2">
492
- <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
493
- {{ t("integration.templates.detail.credentials.title") }}
494
- </div>
495
- <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
496
- {{ t("integration.templates.detail.install.noCredentials") }}
497
- </p>
498
- <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
499
- <div class="flex items-center gap-2">
500
- <span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
501
- <Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
502
- {{ t("integration.templates.detail.install.notRegistered") }}
503
- </Badge>
504
- <Badge v-else-if="!ofType(req.slug).length" variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
505
- {{ t("integration.templates.detail.install.noneYet") }}
506
- </Badge>
507
- </div>
508
-
509
- <p v-if="!ofType(req.slug).length" class="rounded-md border-l-2 border-black/[0.08] bg-muted/30 px-2.5 py-1.5 text-sm text-muted-foreground">
510
- {{ t("integration.templates.detail.install.noCredentialsOfType") }}
511
- <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
512
- {{ t("integration.templates.detail.install.orInstallLater") }}
513
- </p>
514
- <div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
515
- <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">{{ t("integration.templates.detail.install.nodeLabel", { id: nodeId }) }}</Label>
516
- <Select
517
- :model-value="bindings[nodeId] ?? SELECT_LATER"
518
- @update:model-value="(v2) => {
519
- bindings[nodeId] = v2 ?? SELECT_LATER;
520
- }"
521
- >
522
- <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
523
- <SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
524
- </SelectTrigger>
525
- <SelectContent>
526
- <SelectItem :value="SELECT_LATER">{{ t("integration.templates.detail.install.selectLater") }}</SelectItem>
527
- <SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
528
- {{ c.name }}
529
- </SelectItem>
530
- </SelectContent>
531
- </Select>
532
- </div>
533
- </div>
534
- </div>
535
-
536
- <!-- Secrets -->
537
- <div v-if="secretChecks.length" class="space-y-2">
538
- <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
539
- {{ t("integration.templates.detail.install.secretsHeader") }}
540
- </div>
541
- <p class="text-sm text-muted-foreground">
542
- {{ t("integration.templates.detail.install.secretsDescription") }}
543
- <NuxtLink to="/integrations/secrets" class="text-primary hover:underline">{{ t("integration.nav.item.secrets") }}</NuxtLink> {{ t("integration.templates.detail.install.secretsDescriptionSuffix") }}
544
- </p>
545
- <ul class="space-y-1">
546
- <li
547
- v-for="(s, i) in secretChecks"
548
- :key="s.key ?? `dlg-secret-${i}`"
549
- class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
550
- >
551
- <code class="font-mono">{{ s.label }}</code>
552
- <Badge variant="outline" :class="s.present ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm' : 'bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm'">
553
- {{ s.present ? t("integration.templates.detail.install.secretStored") : t("integration.templates.detail.secrets.missing") }}
554
- </Badge>
555
- </li>
556
- </ul>
557
- </div>
558
-
559
- <div v-if="installError" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
560
- {{ installError }}
561
- </div>
562
- </div>
563
-
564
- <DialogFooter>
565
- <Button variant="outline" :disabled="installing" @click="installOpen = false">
566
- {{ t("integration.common.action.cancel") }}
567
- </Button>
568
- <Button :disabled="installing || !name.trim()" @click="confirmInstall">
569
- <Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
570
- {{ installing ? t("integration.templates.detail.install.submitting") : t("integration.templates.detail.install.submit") }}
571
- </Button>
572
- </DialogFooter>
456
+ <TemplateInstallStep :slug="slug" @installed="installed" @update:loading="stepLoading = $event">
457
+ <template #secondary="{ installing }">
458
+ <Button variant="outline" :disabled="installing" @click="installOpen = false">
459
+ {{ t("integration.common.action.cancel") }}
460
+ </Button>
461
+ </template>
462
+ </TemplateInstallStep>
573
463
  </DialogContent>
574
464
  </Dialog>
575
465
  </IntegrationsLayout>
@@ -46,6 +46,8 @@ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBui
46
46
  import { splitRunOutputs } from "../../../utils/runOutputs";
47
47
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
48
48
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
49
+ import { unwiredErrorPorts } from "../../../utils/errorPathAdvice";
50
+ import { newestExecutableVersion } from "../../../data/integrationNodeCatalog";
49
51
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
50
52
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
51
53
  const t = useIntegrationsT();
@@ -108,6 +110,27 @@ async function revealNode(nodeId) {
108
110
  await nextTick();
109
111
  editorRef.value?.revealNode(nodeId);
110
112
  }
113
+ const errorPathAdvice = computed(() => {
114
+ const blob = shownBlob.value;
115
+ if (!blob) return [];
116
+ const byId = new Map(blob.nodes.map((n) => [n.id, n]));
117
+ return unwiredErrorPorts(blob).flatMap((a) => {
118
+ const node = byId.get(a.nodeId);
119
+ return node ? [{ ...a, label: nodeDisplayLabel(node) }] : [];
120
+ });
121
+ });
122
+ const adviceExpanded = ref(false);
123
+ const canAttachErrorStop = computed(() => newestExecutableVersion("revenexx:stop-and-error") !== null);
124
+ async function showNodeOnCanvas(nodeId) {
125
+ tab.value = "editor";
126
+ await nextTick();
127
+ editorRef.value?.showNodeOnCanvas(nodeId);
128
+ }
129
+ async function attachErrorStop(nodeId, port) {
130
+ tab.value = "editor";
131
+ await nextTick();
132
+ editorRef.value?.attachErrorStop(nodeId, port);
133
+ }
111
134
  const { load: loadNodeCatalog } = useIntegrationsNodes();
112
135
  onMounted(() => {
113
136
  void loadNodeCatalog();
@@ -177,6 +200,7 @@ watch(id, async () => {
177
200
  if (!w) await leaveTo("/integrations/workflows");
178
201
  draft.value = null;
179
202
  warningsExpanded.value = false;
203
+ adviceExpanded.value = false;
180
204
  if (tab.value === "runs") void loadRuns(1);
181
205
  settleBuildStatus();
182
206
  }, { immediate: true });
@@ -369,7 +393,7 @@ async function run(mode) {
369
393
  const payloadDriven = computed(() => payloadFieldsFromBlob(shownBlob.value ?? { nodes: [] }).length > 0);
370
394
  async function runFromEditor() {
371
395
  if (!workflow.value) return;
372
- if (payloadDriven.value) {
396
+ if (payloadDriven.value || workflow.value.execution_mode === "caller_decides") {
373
397
  runPayloadOpen.value = true;
374
398
  return;
375
399
  }
@@ -626,16 +650,20 @@ function closeRun(target) {
626
650
  tab.value = target;
627
651
  void router.replace({ query: { ...route.query, run: void 0, view: void 0 } });
628
652
  }
629
- const canRetryOpenRun = computed(() => !!openRun.value && ["succeeded", "failed", "cancelled"].includes(openRun.value.status));
653
+ const canRetryOpenRun = computed(() => openRun.value?.status === "failed");
630
654
  const retryBusy = ref(false);
631
655
  async function retryOpenRun() {
632
- if (!openRun.value || retryBusy.value) return;
656
+ const source = openRun.value;
657
+ if (!source || retryBusy.value) return;
633
658
  retryBusy.value = true;
634
659
  try {
635
- const res = await retryRun(openRun.value.workflowId, openRun.value.id);
660
+ const res = await retryRun(source.workflowId, source.id);
636
661
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
637
- toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
662
+ toast.success(newId && newId !== source.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
638
663
  void loadRuns(1, { silent: true });
664
+ if (newId && newId !== source.id) {
665
+ await router.push({ query: { ...route.query, run: newId, view: void 0 } });
666
+ }
639
667
  } catch (e) {
640
668
  toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
641
669
  } finally {
@@ -770,32 +798,32 @@ function formatDateTime(iso) {
770
798
  <template v-if="workflow">
771
799
  <PageHeader :title="shownName">
772
800
  <template #title>
773
- <span class="inline-flex min-w-0 items-center gap-1.5">
774
- <NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
775
- <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
801
+ <span class="flex min-w-0 items-center gap-1.5">
802
+ <NuxtLink to="/integrations/workflows" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
803
+ <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
776
804
  <!-- Run open: the breadcrumb extends — name and Runs become crumbs
777
805
  back into the workflow surface. -->
778
806
  <template v-if="openRunId">
779
- <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ shownName }}</button>
780
- <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
781
- <button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
782
- <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
807
+ <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" :title="shownName" @click="closeRun('editor')">{{ shownName }}</button>
808
+ <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
809
+ <button type="button" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
810
+ <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
783
811
  <!-- The Temporal run id IS the run's identity; the internal
784
812
  number only fills in until the run is handed off. -->
785
- <span class="truncate font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
813
+ <span class="shrink-0 font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
786
814
  </template>
787
815
  <template v-else>
788
816
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
789
817
  <WorkflowIconView :icon="shownIcon" :size="16" />
790
818
  </span>
791
- <span class="truncate">{{ shownName }}</span>
819
+ <span class="truncate" :title="shownName">{{ shownName }}</span>
792
820
  <!-- The SAVED mode, beside a drafted name, and the difference is not an
793
821
  oversight. A name changes nothing about what happens when Run is
794
822
  pressed, so the header may show what the operator just entered. The
795
823
  dispatch mode decides what a run does — and a run goes to the
796
824
  workflow the platform holds, not to the draft. A badge following the
797
825
  draft would read SYNC over a button that fires an async run. -->
798
- <Badge variant="outline" class="ml-1.5 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
826
+ <Badge variant="outline" class="ml-1.5 shrink-0 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
799
827
  {{ modeLabel(workflow.execution_mode) }}
800
828
  </Badge>
801
829
  </template>
@@ -812,25 +840,33 @@ function formatDateTime(iso) {
812
840
  outside it. Leftmost there is as near the rail as the shared header
813
841
  allows without a slot of its own.
814
842
 
815
- It carries its words and not just a pictogram: an icon-only control
816
- here is one nobody finds, and the way back out of a folded rail is
817
- the one control that cannot afford to be learnt by chance. Rendered
818
- only where the canvas fills the page, the one surface it acts on. -->
843
+ The way back out carries its words; the way in does not. An
844
+ icon-only control is one nobody finds, and a folded rail is the one
845
+ state where that is unaffordable — nothing else on the page offers
846
+ the navigation back. Folding it away needs no such help: the rail
847
+ is standing right beside the pictogram, and the words cost 159 px
848
+ of a header row that a long workflow name already fills, where they
849
+ were the widest thing in it for the least important thing in it.
850
+ Icon-only, so it carries the same name as before to a screen reader
851
+ and to a test. Rendered only where the canvas fills the page, the
852
+ one surface it acts on. -->
819
853
  <Button
820
854
  v-if="canvasFillsPage"
821
855
  variant="ghost"
822
856
  size="sm"
823
- class="h-7 shrink-0 gap-1.5 px-2 text-sm font-normal text-muted-foreground hover:text-primary"
857
+ class="h-7 shrink-0 gap-1.5 text-sm font-normal text-muted-foreground hover:text-primary"
858
+ :class="railFolded ? 'px-2' : 'w-7 px-0'"
859
+ :aria-label="railFolded ? void 0 : t('integration.nav.action.hide')"
824
860
  :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
825
861
  @click="toggleRail"
826
862
  >
827
863
  <RailIcon :size="15" />
828
- {{ railFolded ? t("integration.nav.action.show") : t("integration.nav.action.hide") }}
864
+ <template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
829
865
  </Button>
830
866
 
831
867
  <!-- Run open: run-scoped actions replace the workflow Save. -->
832
868
  <template v-if="openRunId">
833
- <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" @click="retryOpenRun">
869
+ <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" :title="t('integration.editor.action.retry.title')" @click="retryOpenRun">
834
870
  <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
835
871
  <RetryIcon v-else :size="12" />
836
872
  {{ t("integration.editor.action.retry") }}
@@ -843,13 +879,13 @@ function formatDateTime(iso) {
843
879
  <template v-else>
844
880
  <span
845
881
  v-if="buildWorking"
846
- class="inline-flex h-7 items-center gap-1.5 rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
882
+ class="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
847
883
  :title="buildNotice?.text"
848
884
  >
849
885
  <Loader2 :size="12" class="animate-spin" />
850
886
  {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
851
887
  </span>
852
- <span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
888
+ <span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
853
889
  <!-- What is done to the WORKFLOW, as opposed to the draft in front
854
890
  of it: the buttons beside this menu discard and save an
855
891
  afternoon's work, and these two make and unmake the thing that
@@ -981,6 +1017,62 @@ function formatDateTime(iso) {
981
1017
  </ul>
982
1018
  </div>
983
1019
 
1020
+ <!-- Error-path advice (PO-443, specs/workflow-advice.md).
1021
+ Slate, NOT amber: amber already means "fix this before it runs" twice
1022
+ over in this editor (the reauth strip, the validation banner) and on
1023
+ the node cards, so a third would leave the colour meaning nothing.
1024
+ This blocks nothing — the activation control stays operable while it
1025
+ shows, which is the point of it not being a finding.
1026
+
1027
+ Editor tab only, unlike the validation banner above: every line here
1028
+ acts on the canvas — reveal a card, attach a step to it — so over the
1029
+ run list or the history preview it offers moves to a canvas that is not
1030
+ on screen, and its node names read as part of whatever is. That is not
1031
+ hypothetical: it put a current node's name over a preview of an
1032
+ archived revision (workflow-history.md AC-3). -->
1033
+ <div
1034
+ v-if="tab === 'editor' && !openRunId && errorPathAdvice.length"
1035
+ class="border-b border-slate-500/20 bg-slate-500/5 px-4 py-2 text-sm text-slate-700"
1036
+ >
1037
+ <p class="font-medium">
1038
+ {{ t("integration.editor.errorPath.advice", errorPathAdvice.length) }}
1039
+ </p>
1040
+ <ul
1041
+ class="mt-1 space-y-0.5"
1042
+ :class="adviceExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
1043
+ >
1044
+ <li
1045
+ v-for="a in adviceExpanded ? errorPathAdvice : errorPathAdvice.slice(0, WARN_COLLAPSED)"
1046
+ :key="`${a.nodeId}-${a.port}`"
1047
+ class="flex items-baseline gap-1.5"
1048
+ >
1049
+ <span class="select-none text-slate-400">•</span>
1050
+ <button
1051
+ type="button"
1052
+ class="text-left font-medium underline decoration-transparent underline-offset-2 hover:decoration-slate-700"
1053
+ :aria-label="t('integration.editor.errorPath.ariaReveal', { label: a.label, port: a.port })"
1054
+ @click="showNodeOnCanvas(a.nodeId)"
1055
+ >
1056
+ {{ a.label }}
1057
+ </button>
1058
+ <button
1059
+ v-if="canAttachErrorStop"
1060
+ type="button"
1061
+ class="text-slate-600 underline underline-offset-2 hover:text-slate-900"
1062
+ :aria-label="t('integration.editor.errorPath.ariaFix', { label: a.label, port: a.port })"
1063
+ @click="attachErrorStop(a.nodeId, a.port)"
1064
+ >
1065
+ {{ t("integration.editor.errorPath.action.stop") }}
1066
+ </button>
1067
+ </li>
1068
+ <li v-if="!adviceExpanded && errorPathAdvice.length > WARN_COLLAPSED" class="pl-3">
1069
+ <button type="button" class="text-slate-600 underline underline-offset-2 hover:text-slate-900" @click="adviceExpanded = true">
1070
+ {{ t("integration.editor.errorPath.more", { n: errorPathAdvice.length - WARN_COLLAPSED }) }}
1071
+ </button>
1072
+ </li>
1073
+ </ul>
1074
+ </div>
1075
+
984
1076
  <!-- Only the FAILED build state banners (its message matters); the
985
1077
  working states live as the header chip — and inside a run the
986
1078
  workflow's build state is noise, so nothing shows at all. -->
@@ -1005,7 +1097,7 @@ function formatDateTime(iso) {
1005
1097
  </div>
1006
1098
  </div>
1007
1099
  <div v-else class="min-h-0 flex-1">
1008
- <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" single />
1100
+ <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1009
1101
  </div>
1010
1102
  </template>
1011
1103
 
@@ -1033,7 +1125,7 @@ function formatDateTime(iso) {
1033
1125
  </div>
1034
1126
 
1035
1127
  <!-- Runs — this workflow's full execution history. Manual runs start
1036
- from the editor's Run split-button (payload via its lightbox). -->
1128
+ from the editor's Run button, through the run dialog. -->
1037
1129
  <div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
1038
1130
  <div v-if="runsError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
1039
1131
  {{ runsError }}
@@ -1078,7 +1170,7 @@ function formatDateTime(iso) {
1078
1170
  </div>
1079
1171
  </div>
1080
1172
 
1081
- <!-- Run-with-payload lightbox — opened by the editor Run split-button. -->
1173
+ <!-- The run dialog — opened by the editor's Run button. -->
1082
1174
  <Dialog v-model:open="runPayloadOpen">
1083
1175
  <DialogContent class="sm:max-w-lg">
1084
1176
  <DialogHeader>
@@ -1116,27 +1208,30 @@ function formatDateTime(iso) {
1116
1208
  </p>
1117
1209
  </div>
1118
1210
  <div v-if="workflow" class="flex items-center justify-end gap-2">
1211
+ <!-- Both modes, and the waiting one is the offer: the author pressed Run
1212
+ to see what comes back, so sync is the primary button — which puts it
1213
+ on the right, where every primary button in the studio sits. -->
1119
1214
  <template v-if="workflow.execution_mode === 'caller_decides'">
1120
1215
  <Button
1121
1216
  variant="outline"
1122
1217
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
1123
1218
  :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
1124
1219
  class="h-8 text-sm"
1125
- @click="runFromPayloadDialog('sync')"
1220
+ @click="runFromPayloadDialog('async')"
1126
1221
  >
1127
- <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
1222
+ <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
1128
1223
  <Play v-else :size="14" />
1129
- {{ waitingMode === "sync" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.sync") }) }}
1224
+ {{ waitingMode === "async" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.async") }) }}
1130
1225
  </Button>
1131
1226
  <Button
1132
1227
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
1133
1228
  :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
1134
1229
  class="h-8 text-sm"
1135
- @click="runFromPayloadDialog('async')"
1230
+ @click="runFromPayloadDialog('sync')"
1136
1231
  >
1137
- <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
1232
+ <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
1138
1233
  <Play v-else :size="14" />
1139
- {{ waitingMode === "async" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.async") }) }}
1234
+ {{ waitingMode === "sync" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.sync") }) }}
1140
1235
  </Button>
1141
1236
  </template>
1142
1237
  <Button
@@ -0,0 +1,27 @@
1
+ /**
2
+ * One record, from `{ data: record }` or from a bare record.
3
+ *
4
+ * Throws when the response carries an envelope with nothing in it, or no body at
5
+ * all — the case the old expression turned into an object with none of the
6
+ * caller's fields, which every page downstream drew as empty.
7
+ *
8
+ * **The thrown message is deliberately empty.** `errorReason(err, fallback)` in
9
+ * `apiError.ts` falls back to the caller's own translated sentence when the
10
+ * message is blank, so a shape violation surfaces as the page's ordinary "could
11
+ * not load this" copy rather than as an untranslated technical string in the UI.
12
+ * The response that caused it rides along in `body`.
13
+ */
14
+ export declare function unwrapRecord<T>(res: unknown, ownFields?: readonly string[]): T;
15
+ /**
16
+ * A list, from `{ data: [ … ] }` or from a bare array.
17
+ *
18
+ * An empty collection is `[]` or `{ "data": [] }` and answers empty. A body that is
19
+ * neither throws, for the same reason the record above does — and it was drafted the
20
+ * other way round, returning empty for anything unreadable, until a review pointed
21
+ * out what that costs: the workflows listing used to raise (`ingestList` mapped over
22
+ * the body and threw inside its own `try`), so an unreadable answer reached the
23
+ * reader as an error. Empty would have turned that into "no workflows yet" for a
24
+ * tenant whose workflows are all still there — a silent wrong answer in place of a
25
+ * loud one, in the change that exists to remove exactly that.
26
+ */
27
+ export declare function unwrapList<T>(res: unknown): T[];
@@ -0,0 +1,20 @@
1
+ import { IntegrationsApiError } from "./apiError.js";
2
+ export function unwrapRecord(res, ownFields = []) {
3
+ if (res === null || res === void 0) {
4
+ throw new IntegrationsApiError(200, res, "");
5
+ }
6
+ if (typeof res === "object" && "data" in res && !ownFields.some((f) => f in res)) {
7
+ const data = res.data;
8
+ if (data === null || data === void 0) {
9
+ throw new IntegrationsApiError(200, res, "");
10
+ }
11
+ return data;
12
+ }
13
+ return res;
14
+ }
15
+ export function unwrapList(res) {
16
+ if (Array.isArray(res)) return res;
17
+ const data = res?.data;
18
+ if (Array.isArray(data)) return data;
19
+ throw new IntegrationsApiError(200, res, "");
20
+ }
@@ -0,0 +1,18 @@
1
+ import type { ConfigField } from '../types/integrations.js';
2
+ /**
3
+ * The config a set of settings starts life with: what each one declares as its
4
+ * default, and nothing else.
5
+ *
6
+ * A setting with no `default` is left OUT rather than written as an empty
7
+ * string, because the two are different answers. `''` is a value — the engine
8
+ * reads it, `??` does not replace it, and a node whose own default would have
9
+ * applied never gets to apply it. An absent key is the absence the node's
10
+ * default answers to.
11
+ *
12
+ * Used in the two places a fresh set of values is made: a node dropped on the
13
+ * canvas (WorkflowEditor), and a row added to a list setting (NodeConfigField).
14
+ * Flat on purpose — it reads `default` at the level it is given and does not
15
+ * descend into `properties` or `items`, so what a newly inserted node carries
16
+ * is exactly what it carried before this became shared.
17
+ */
18
+ export declare function defaultsFor(fields?: ReadonlyArray<Pick<ConfigField, 'key' | 'default'>>): Record<string, unknown>;
@@ -0,0 +1,7 @@
1
+ export function defaultsFor(fields = []) {
2
+ const out = {};
3
+ for (const f of fields ?? []) {
4
+ if (f.default !== void 0) out[f.key] = f.default;
5
+ }
6
+ return out;
7
+ }
@@ -0,0 +1,60 @@
1
+ import type { WorkflowBlob } from '../types/integrations.js';
2
+ /**
3
+ * Nodes that route failures to an error output nothing is connected to
4
+ * (specs/workflow-advice.md).
5
+ *
6
+ * A step that fails on a path its author drew is handled. A step that fails on a
7
+ * path nobody drew is not: the engine ends the run there
8
+ * (`specs/node-failure-handling.md` AC-8 in the service repository), and before
9
+ * it did, the failure vanished and the run reported success. Either way the
10
+ * author never chose it — they simply never saw the port.
11
+ *
12
+ * ## Why this is advice and not a finding
13
+ *
14
+ * The backend's validation findings feed the activation gate AND an
15
+ * unconditional refusal to start a run. A finding here would therefore stop
16
+ * every workflow with an unwired error port from running at all — turning
17
+ * "delivers nothing quietly" into "does not start", for authors who never asked
18
+ * for either. Computing it in the editor makes that impossible rather than
19
+ * merely unlikely: there is no value for a blocking consumer to read.
20
+ *
21
+ * What the client cannot do is be authoritative — the catalogue may not be
22
+ * loaded, and a workflow built through the API never passes here at all. For
23
+ * advice that is the right trade; for a gate it would not be, which is why the
24
+ * gate stays where the manifests are.
25
+ *
26
+ * ## Deliberately not in `validationFindings.ts`
27
+ *
28
+ * Everything there is a backend finding addressed by blob path, and the node
29
+ * card counts those as *incomplete settings*. An unwired port is neither. Sent
30
+ * through `groupFindingsByNode` it would type-check perfectly and quietly
31
+ * corrupt two counts — the banner's and the card badge's.
32
+ */
33
+ export interface ErrorPathAdvice {
34
+ /** Blob node id — not an index, so it survives a node being added or removed. */
35
+ nodeId: string;
36
+ /** The error output with nothing attached. */
37
+ port: string;
38
+ }
39
+ /**
40
+ * Every unwired error output in the blob, in node order.
41
+ *
42
+ * Runs against the blob the author is *editing*, not the saved one, so wiring a
43
+ * port clears its line in the same tick — no save, and no second copy of this
44
+ * rule to decide when the advice went stale.
45
+ *
46
+ * **Resolved against whichever manifest version the catalogue has.**
47
+ * `findManifest` falls back to another version of the same slug when the pinned
48
+ * one is absent, and that fallback is accepted here: a node pinned to a version
49
+ * nobody serves any more is better described by a sibling version than by
50
+ * silence. The cost is real though — where the versions disagree about their
51
+ * ports, the advice describes the sibling's, so a port could be named that the
52
+ * author's own node does not have.
53
+ *
54
+ * **Reads the manifest's static outputs only.** A node whose ports are named at
55
+ * author time (`sourceFromConfig` / `resolveOutputs`) cannot be judged from the
56
+ * declaration alone, so nothing is claimed about it — the same forbearance the
57
+ * engine shows. No shipped node has a dynamic error port today; if one appears,
58
+ * this is where it would silently escape notice.
59
+ */
60
+ export declare function unwiredErrorPorts(blob: Pick<WorkflowBlob, 'nodes' | 'edges'>): ErrorPathAdvice[];