@revenexx/studio-integrations 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +457 -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/CredentialDetailDialog.vue +61 -29
  7. package/dist/runtime/components/Integrations/EditorTip.vue +2 -15
  8. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +10 -0
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue +94 -49
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +10 -0
  11. package/dist/runtime/components/Integrations/NodeInspector.vue +37 -60
  12. package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
  13. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
  14. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
  15. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
  16. package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
  17. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
  18. package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
  19. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
  20. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
  21. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
  22. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
  23. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
  24. package/dist/runtime/components/Integrations/WorkflowEditor.vue +165 -56
  25. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
  26. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
  27. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
  28. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
  29. package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
  30. package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
  31. package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
  32. package/dist/runtime/composables/useCredentialTypes.js +3 -9
  33. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
  34. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  35. package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
  36. package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
  37. package/dist/runtime/composables/useIntegrationsTemplates.js +5 -8
  38. package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
  39. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -7
  40. package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
  41. package/dist/runtime/data/canvasShortcuts.js +68 -0
  42. package/dist/runtime/i18n/messages/de.json +30 -16
  43. package/dist/runtime/i18n/messages/en.json +30 -16
  44. package/dist/runtime/pages/integrations/credentials.vue +40 -18
  45. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
  46. package/dist/runtime/pages/integrations/workflows/[id].vue +128 -33
  47. package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
  48. package/dist/runtime/utils/apiEnvelope.js +20 -0
  49. package/dist/runtime/utils/apiError.d.ts +35 -8
  50. package/dist/runtime/utils/apiError.js +12 -12
  51. package/dist/runtime/utils/configDefaults.d.ts +18 -0
  52. package/dist/runtime/utils/configDefaults.js +7 -0
  53. package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
  54. package/dist/runtime/utils/errorPathAdvice.js +19 -0
  55. package/dist/runtime/utils/notify.d.ts +2 -2
  56. package/dist/runtime/utils/runEnvelope.d.ts +12 -0
  57. package/dist/runtime/utils/runEnvelope.js +2 -1
  58. package/dist/runtime/utils/selectOptions.d.ts +48 -0
  59. package/dist/runtime/utils/selectOptions.js +19 -0
  60. package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
  61. package/dist/runtime/utils/shortcutLabel.js +48 -0
  62. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, onMounted, reactive, ref } from "vue";
2
+ import { computed, nextTick, onMounted, reactive, ref } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
@@ -31,7 +31,7 @@ import {
31
31
  SelectValue
32
32
  } from "@revenexx/studio";
33
33
  import { toast } from "../../utils/notify";
34
- import { apiFieldErrors, errorReason } from "../../utils/apiError";
34
+ import { apiRefusal, errorReason } from "../../utils/apiError";
35
35
  import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
@@ -92,19 +92,36 @@ const form = reactive({ name: "", config: {} });
92
92
  const saving = ref(false);
93
93
  const testing = ref(false);
94
94
  const testResult = ref(null);
95
- const saveError = ref(null);
96
- const fieldErrors = ref({});
95
+ const saveRefusal = ref(null);
97
96
  const sourceActive = ref(false);
98
97
  const isEdit = computed(() => editing.value !== null);
99
98
  function fieldsOf(t2) {
100
99
  return t2?.fields ?? [];
101
100
  }
101
+ const drawnKeys = computed(() => [
102
+ "name",
103
+ ...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
104
+ ]);
105
+ const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
106
+ const fieldErrors = computed(() => refusal.value.atFields);
107
+ const summaryReasons = computed(() => {
108
+ if (!saveRefusal.value) return [];
109
+ if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
110
+ if (Object.keys(refusal.value.atFields).length) return [];
111
+ return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
112
+ });
102
113
  function configFieldAria(key) {
103
114
  return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
104
115
  }
116
+ async function focusFirstRefusedField() {
117
+ await nextTick();
118
+ const key = drawnKeys.value.find((k) => k in fieldErrors.value);
119
+ if (!key) return;
120
+ const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
121
+ document.getElementById(id)?.focus();
122
+ }
105
123
  function clearRefusal() {
106
- saveError.value = null;
107
- fieldErrors.value = {};
124
+ saveRefusal.value = null;
108
125
  }
109
126
  function openCreate(t2) {
110
127
  editing.value = null;
@@ -176,9 +193,9 @@ async function submit() {
176
193
  }
177
194
  }
178
195
  } catch (e) {
179
- fieldErrors.value = apiFieldErrors(e);
180
- saveError.value = errorReason(e, t("integration.credentials.error.save"));
181
- toast.error(saveError.value);
196
+ saveRefusal.value = e;
197
+ void focusFirstRefusedField();
198
+ toast.error(errorReason(e, t("integration.credentials.error.save")));
182
199
  } finally {
183
200
  saving.value = false;
184
201
  }
@@ -351,7 +368,10 @@ async function confirmDelete() {
351
368
  <IntegrationsCredentialRevenexxSource
352
369
  v-if="hasRevenexxSource(pickedType?.slug)"
353
370
  :type="pickedType"
354
- @update:active="(v) => sourceActive = v"
371
+ @update:active="(v) => {
372
+ sourceActive = v;
373
+ clearRefusal();
374
+ }"
355
375
  @apply="(c2) => Object.assign(form.config, c2)"
356
376
  />
357
377
 
@@ -438,14 +458,16 @@ async function confirmDelete() {
438
458
 
439
459
  </div>
440
460
 
441
- <!-- The save was refused. Outside the scrolling form on purpose: the Save
442
- button sits in the footer below, so a summary at the end of a form that
443
- scrolls — five fields already do — would be a refusal rendered under the
444
- fold, which is the defect this whole change is about. Stated here as well
445
- as at the field it belongs to, so a refusal with no field — a conflict, a
446
- gateway error — is not silent either. -->
447
- <div v-if="saveError && pickedType" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
448
- {{ saveError }}
461
+ <!-- The save was refused for something no field shows. Outside the scrolling
462
+ form on purpose: the Save button sits in the footer below, so a summary at
463
+ the end of a form that scrolls — five fields already do — would be a
464
+ refusal rendered under the fold. What is marked at a field is not repeated
465
+ here; what has no field to be marked at — a service-side field, a conflict,
466
+ a gateway error — would otherwise be silent. -->
467
+ <div v-if="summaryReasons.length && pickedType" role="alert" class="space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
468
+ <!-- Keyed by position: two undrawn keys can be refused with the same
469
+ sentence, and a duplicate key is how one of them goes unrendered. -->
470
+ <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
449
471
  </div>
450
472
 
451
473
  <DialogFooter v-if="pickedType" class="sm:justify-between">
@@ -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[];