@revenexx/studio-integrations 1.2.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 (82) hide show
  1. package/CHANGELOG.md +581 -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/NodeConfigDynamicSchema.vue +10 -2
  8. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +15 -1
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue +158 -49
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +15 -1
  11. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +5 -1
  12. package/dist/runtime/components/Integrations/NodeInspector.vue +136 -81
  13. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +5 -1
  14. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.d.vue.ts +21 -0
  15. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue +133 -0
  16. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue.d.ts +21 -0
  17. package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
  18. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.d.vue.ts +16 -0
  19. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +111 -0
  20. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue.d.ts +16 -0
  21. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
  22. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
  23. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
  24. package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
  25. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
  26. package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
  27. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
  28. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
  29. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
  30. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
  31. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
  32. package/dist/runtime/components/Integrations/WorkflowEditor.vue +200 -54
  33. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
  34. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
  35. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
  36. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
  37. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.d.vue.ts +13 -0
  38. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +216 -0
  39. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue.d.ts +13 -0
  40. package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
  41. package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
  42. package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
  43. package/dist/runtime/composables/useCredentialTypes.js +3 -9
  44. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
  45. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
  47. package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
  48. package/dist/runtime/composables/useIntegrationsTemplates.js +10 -8
  49. package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
  50. package/dist/runtime/composables/useIntegrationsWorkflows.js +11 -7
  51. package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
  52. package/dist/runtime/data/canvasShortcuts.js +68 -0
  53. package/dist/runtime/data/integrationNodeCatalog.d.ts +25 -0
  54. package/dist/runtime/data/integrationNodeCatalog.js +8 -0
  55. package/dist/runtime/data/nodeVersionMove.d.ts +72 -0
  56. package/dist/runtime/data/nodeVersionMove.js +65 -0
  57. package/dist/runtime/i18n/messages/de.json +83 -20
  58. package/dist/runtime/i18n/messages/en.json +83 -20
  59. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
  60. package/dist/runtime/pages/integrations/workflows/[id].vue +158 -53
  61. package/dist/runtime/pages/integrations/workflows/index.vue +23 -4
  62. package/dist/runtime/types/integrations.d.ts +44 -1
  63. package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
  64. package/dist/runtime/utils/apiEnvelope.js +20 -0
  65. package/dist/runtime/utils/buildSettle.d.ts +68 -0
  66. package/dist/runtime/utils/buildSettle.js +29 -0
  67. package/dist/runtime/utils/configDefaults.d.ts +18 -0
  68. package/dist/runtime/utils/configDefaults.js +7 -0
  69. package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
  70. package/dist/runtime/utils/errorPathAdvice.js +19 -0
  71. package/dist/runtime/utils/notify.d.ts +2 -2
  72. package/dist/runtime/utils/runEnvelope.d.ts +12 -0
  73. package/dist/runtime/utils/runEnvelope.js +2 -1
  74. package/dist/runtime/utils/selectOptions.d.ts +48 -0
  75. package/dist/runtime/utils/selectOptions.js +19 -0
  76. package/dist/runtime/utils/settingCondition.d.ts +48 -0
  77. package/dist/runtime/utils/settingCondition.js +84 -0
  78. package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
  79. package/dist/runtime/utils/shortcutLabel.js +48 -0
  80. package/dist/runtime/utils/stateNamespaceName.d.ts +23 -0
  81. package/dist/runtime/utils/stateNamespaceName.js +8 -0
  82. 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>
@@ -42,9 +42,12 @@ import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue"
42
42
  import { toast } from "vue-sonner";
43
43
  import { IntegrationsApiError, apiValidationIssues, errorReason } from "../../../utils/apiError";
44
44
  import { createPollBudget, SAFETY_POLL_MS } from "../../../utils/pollBudget";
45
+ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBuildUnsettled } from "../../../utils/buildSettle";
45
46
  import { splitRunOutputs } from "../../../utils/runOutputs";
46
47
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
47
48
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
49
+ import { unwiredErrorPorts } from "../../../utils/errorPathAdvice";
50
+ import { newestExecutableVersion } from "../../../data/integrationNodeCatalog";
48
51
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
49
52
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
50
53
  const t = useIntegrationsT();
@@ -107,6 +110,27 @@ async function revealNode(nodeId) {
107
110
  await nextTick();
108
111
  editorRef.value?.revealNode(nodeId);
109
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
+ }
110
134
  const { load: loadNodeCatalog } = useIntegrationsNodes();
111
135
  onMounted(() => {
112
136
  void loadNodeCatalog();
@@ -124,8 +148,8 @@ const reauthCredentials = computed(() => {
124
148
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
125
149
  const buildFailed = computed(() => workflow.value?.buildStatus === "failed");
126
150
  const waitingForBuild = ref(false);
127
- const BUILD_WAIT_POLL_MS = 2500;
128
- const BUILD_WAIT_TIMEOUT_MS = 5 * 6e4;
151
+ const BUILD_WAIT_POLL_MS = BUILD_SETTLE_POLL_MS;
152
+ const BUILD_WAIT_TIMEOUT_MS = BUILD_SETTLE_TIMEOUT_MS;
129
153
  async function waitForBuildReady() {
130
154
  waitingForBuild.value = true;
131
155
  try {
@@ -148,19 +172,14 @@ async function waitForBuildReady() {
148
172
  waitingForBuild.value = false;
149
173
  }
150
174
  }
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
- }
175
+ const buildSettle = createBuildSettle();
176
+ function settleBuildStatus() {
177
+ void buildSettle.settle({
178
+ unsettled: () => !!workflow.value && isBuildUnsettled(workflow.value.buildStatus),
179
+ read: () => get(workflow.value.id)
180
+ });
163
181
  }
182
+ onUnmounted(() => buildSettle.stop());
164
183
  const buildNotice = computed(() => {
165
184
  switch (workflow.value?.buildStatus) {
166
185
  case "building":
@@ -181,8 +200,9 @@ watch(id, async () => {
181
200
  if (!w) await leaveTo("/integrations/workflows");
182
201
  draft.value = null;
183
202
  warningsExpanded.value = false;
203
+ adviceExpanded.value = false;
184
204
  if (tab.value === "runs") void loadRuns(1);
185
- void settleBuildStatus();
205
+ settleBuildStatus();
186
206
  }, { immediate: true });
187
207
  function onChange(blob) {
188
208
  const d = draftFrom();
@@ -252,7 +272,7 @@ async function onRevisionRestored() {
252
272
  if (!workflow.value) return;
253
273
  await get(workflow.value.id);
254
274
  draft.value = null;
255
- void settleBuildStatus();
275
+ settleBuildStatus();
256
276
  }
257
277
  const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, retryRun } = useIntegrationsRuns();
258
278
  const running = ref(false);
@@ -373,7 +393,7 @@ async function run(mode) {
373
393
  const payloadDriven = computed(() => payloadFieldsFromBlob(shownBlob.value ?? { nodes: [] }).length > 0);
374
394
  async function runFromEditor() {
375
395
  if (!workflow.value) return;
376
- if (payloadDriven.value) {
396
+ if (payloadDriven.value || workflow.value.execution_mode === "caller_decides") {
377
397
  runPayloadOpen.value = true;
378
398
  return;
379
399
  }
@@ -630,16 +650,20 @@ function closeRun(target) {
630
650
  tab.value = target;
631
651
  void router.replace({ query: { ...route.query, run: void 0, view: void 0 } });
632
652
  }
633
- const canRetryOpenRun = computed(() => !!openRun.value && ["succeeded", "failed", "cancelled"].includes(openRun.value.status));
653
+ const canRetryOpenRun = computed(() => openRun.value?.status === "failed");
634
654
  const retryBusy = ref(false);
635
655
  async function retryOpenRun() {
636
- if (!openRun.value || retryBusy.value) return;
656
+ const source = openRun.value;
657
+ if (!source || retryBusy.value) return;
637
658
  retryBusy.value = true;
638
659
  try {
639
- const res = await retryRun(openRun.value.workflowId, openRun.value.id);
660
+ const res = await retryRun(source.workflowId, source.id);
640
661
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
641
- 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"));
642
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
+ }
643
667
  } catch (e) {
644
668
  toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
645
669
  } finally {
@@ -711,7 +735,7 @@ async function save() {
711
735
  executionMode: d.executionMode
712
736
  });
713
737
  draft.value = null;
714
- void settleBuildStatus();
738
+ settleBuildStatus();
715
739
  } catch (e) {
716
740
  const issues = apiValidationIssues(e);
717
741
  if (issues.length && workflow.value.active) {
@@ -748,7 +772,7 @@ async function confirmDeactivate() {
748
772
  draft.value = null;
749
773
  deactivationOpen.value = false;
750
774
  toast.success(t("integration.editor.deactivate.toast.savedPaused"));
751
- void settleBuildStatus();
775
+ settleBuildStatus();
752
776
  } catch (e) {
753
777
  deactivationOpen.value = false;
754
778
  saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
@@ -774,32 +798,32 @@ function formatDateTime(iso) {
774
798
  <template v-if="workflow">
775
799
  <PageHeader :title="shownName">
776
800
  <template #title>
777
- <span class="inline-flex min-w-0 items-center gap-1.5">
778
- <NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
779
- <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" />
780
804
  <!-- Run open: the breadcrumb extends — name and Runs become crumbs
781
805
  back into the workflow surface. -->
782
806
  <template v-if="openRunId">
783
- <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ shownName }}</button>
784
- <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
785
- <button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
786
- <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" />
787
811
  <!-- The Temporal run id IS the run's identity; the internal
788
812
  number only fills in until the run is handed off. -->
789
- <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>
790
814
  </template>
791
815
  <template v-else>
792
816
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
793
817
  <WorkflowIconView :icon="shownIcon" :size="16" />
794
818
  </span>
795
- <span class="truncate">{{ shownName }}</span>
819
+ <span class="truncate" :title="shownName">{{ shownName }}</span>
796
820
  <!-- The SAVED mode, beside a drafted name, and the difference is not an
797
821
  oversight. A name changes nothing about what happens when Run is
798
822
  pressed, so the header may show what the operator just entered. The
799
823
  dispatch mode decides what a run does — and a run goes to the
800
824
  workflow the platform holds, not to the draft. A badge following the
801
825
  draft would read SYNC over a button that fires an async run. -->
802
- <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) })">
803
827
  {{ modeLabel(workflow.execution_mode) }}
804
828
  </Badge>
805
829
  </template>
@@ -816,25 +840,33 @@ function formatDateTime(iso) {
816
840
  outside it. Leftmost there is as near the rail as the shared header
817
841
  allows without a slot of its own.
818
842
 
819
- It carries its words and not just a pictogram: an icon-only control
820
- here is one nobody finds, and the way back out of a folded rail is
821
- the one control that cannot afford to be learnt by chance. Rendered
822
- 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. -->
823
853
  <Button
824
854
  v-if="canvasFillsPage"
825
855
  variant="ghost"
826
856
  size="sm"
827
- 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')"
828
860
  :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
829
861
  @click="toggleRail"
830
862
  >
831
863
  <RailIcon :size="15" />
832
- {{ railFolded ? t("integration.nav.action.show") : t("integration.nav.action.hide") }}
864
+ <template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
833
865
  </Button>
834
866
 
835
867
  <!-- Run open: run-scoped actions replace the workflow Save. -->
836
868
  <template v-if="openRunId">
837
- <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">
838
870
  <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
839
871
  <RetryIcon v-else :size="12" />
840
872
  {{ t("integration.editor.action.retry") }}
@@ -847,13 +879,13 @@ function formatDateTime(iso) {
847
879
  <template v-else>
848
880
  <span
849
881
  v-if="buildWorking"
850
- 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"
851
883
  :title="buildNotice?.text"
852
884
  >
853
885
  <Loader2 :size="12" class="animate-spin" />
854
886
  {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
855
887
  </span>
856
- <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>
857
889
  <!-- What is done to the WORKFLOW, as opposed to the draft in front
858
890
  of it: the buttons beside this menu discard and save an
859
891
  afternoon's work, and these two make and unmake the thing that
@@ -985,6 +1017,62 @@ function formatDateTime(iso) {
985
1017
  </ul>
986
1018
  </div>
987
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
+
988
1076
  <!-- Only the FAILED build state banners (its message matters); the
989
1077
  working states live as the header chip — and inside a run the
990
1078
  workflow's build state is noise, so nothing shows at all. -->
@@ -1009,7 +1097,7 @@ function formatDateTime(iso) {
1009
1097
  </div>
1010
1098
  </div>
1011
1099
  <div v-else class="min-h-0 flex-1">
1012
- <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" single />
1100
+ <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1013
1101
  </div>
1014
1102
  </template>
1015
1103
 
@@ -1037,7 +1125,7 @@ function formatDateTime(iso) {
1037
1125
  </div>
1038
1126
 
1039
1127
  <!-- Runs — this workflow's full execution history. Manual runs start
1040
- from the editor's Run split-button (payload via its lightbox). -->
1128
+ from the editor's Run button, through the run dialog. -->
1041
1129
  <div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
1042
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">
1043
1131
  {{ runsError }}
@@ -1082,7 +1170,7 @@ function formatDateTime(iso) {
1082
1170
  </div>
1083
1171
  </div>
1084
1172
 
1085
- <!-- Run-with-payload lightbox — opened by the editor Run split-button. -->
1173
+ <!-- The run dialog — opened by the editor's Run button. -->
1086
1174
  <Dialog v-model:open="runPayloadOpen">
1087
1175
  <DialogContent class="sm:max-w-lg">
1088
1176
  <DialogHeader>
@@ -1120,27 +1208,30 @@ function formatDateTime(iso) {
1120
1208
  </p>
1121
1209
  </div>
1122
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. -->
1123
1214
  <template v-if="workflow.execution_mode === 'caller_decides'">
1124
1215
  <Button
1125
1216
  variant="outline"
1126
1217
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
1127
1218
  :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
1128
1219
  class="h-8 text-sm"
1129
- @click="runFromPayloadDialog('sync')"
1220
+ @click="runFromPayloadDialog('async')"
1130
1221
  >
1131
- <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
1222
+ <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
1132
1223
  <Play v-else :size="14" />
1133
- {{ 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") }) }}
1134
1225
  </Button>
1135
1226
  <Button
1136
1227
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
1137
1228
  :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
1138
1229
  class="h-8 text-sm"
1139
- @click="runFromPayloadDialog('async')"
1230
+ @click="runFromPayloadDialog('sync')"
1140
1231
  >
1141
- <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
1232
+ <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
1142
1233
  <Play v-else :size="14" />
1143
- {{ 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") }) }}
1144
1235
  </Button>
1145
1236
  </template>
1146
1237
  <Button
@@ -1195,7 +1286,18 @@ function formatDateTime(iso) {
1195
1286
  afternoon this dialog exists to protect.
1196
1287
  `as-child` so the studio's own Button is that element rather than a second one
1197
1288
  beside it. Escape means the same thing, through `update:open`; a click beside
1198
- the dialog does nothing at all, which an alert dialog is entitled to. -->
1289
+ the dialog does nothing at all, which an alert dialog is entitled to.
1290
+
1291
+ The class list on that Button is the price of `as-child`: `AlertDialogCancel`
1292
+ applies the outline variant to whatever it renders, and every dialog in this
1293
+ module draws the answer on the right as a filled one. So the two variants meet
1294
+ on one element and `tailwind-merge` decides between them per group — which it
1295
+ cannot do where the outline variant names a group the default one does not, and
1296
+ those are the five that have to be said here. Left to itself the merge produced
1297
+ a background from the outline variant and a text colour from the default, which
1298
+ resolve to the same colour in this theme: the label was invisible until hovered
1299
+ (PO-415). Nothing marks this answer as the one holding the keyboard otherwise —
1300
+ `focus-visible` does not match after the click that opened the dialog. -->
1199
1301
  <AlertDialog :open="leaveOpen" @update:open="(v) => {
1200
1302
  if (!v) answerLeave(false);
1201
1303
  }">
@@ -1211,7 +1313,10 @@ function formatDateTime(iso) {
1211
1313
  {{ t("integration.editor.leave.dialog.discard") }}
1212
1314
  </Button>
1213
1315
  <AlertDialogCancel as-child>
1214
- <Button @click="answerLeave(false)">
1316
+ <Button
1317
+ 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"
1318
+ @click="answerLeave(false)"
1319
+ >
1215
1320
  {{ t("integration.editor.leave.dialog.stay") }}
1216
1321
  </Button>
1217
1322
  </AlertDialogCancel>