@revenexx/studio-integrations 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 60e6143: A template whose requirements the platform could not answer for is no longer shown as ready to install. The template page's setup rail and the install step now say they could not check what the template needs, with the platform's reason, instead of "Everything is ready to install" and "This template needs no credentials"; installing stays possible. While the answer is still on its way, the rail claims nothing.
8
+
9
+ For hosts: `useIntegrationsTemplates()` gains `checkRequirements(slug)`, which rejects with the platform's reason where `requirements(slug)` resolves to `null`, and `uncheckedReason(error)`, which turns that rejection into the sentence the studio shows. `requirements` still behaves as before but is now deprecated: its `null` cannot tell "could not check" from "needs nothing", so a host that reads it as an empty answer shows an unchecked template as ready. Switch to `checkRequirements`.
10
+
11
+ ### Patch Changes
12
+
13
+ - e62ae58: A failed step now says why it failed in one sentence. The run record's step list showed the whole error the platform sends, as a block of JSON; it now shows the message. The replay's debug panel shows the message and the failure's type, and the places in the node's own code where it failed as a list that stays folded until opened.
14
+
3
15
  ## 3.1.0
4
16
 
5
17
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -4,6 +4,7 @@ import { Download as DownloadIcon, RefreshCircle as RefreshIcon } from "#solar-i
4
4
  import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@revenexx/studio";
5
5
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
6
6
  import { isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
7
+ import { stepError } from "../../utils/stepError";
7
8
  import { IntegrationsApiError, errorReason } from "../../utils/apiError";
8
9
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
9
10
  import { formatDuration } from "../../utils/formatDuration";
@@ -764,8 +765,8 @@ async function confirmAction() {
764
765
  <span class="text-xs text-muted-foreground tabular-nums shrink-0">{{ fmtMs(st.duration_ms) }}</span>
765
766
  <span v-if="!isSkippedStep(st)" class="text-[10px] text-muted-foreground shrink-0">{{ expandedStep === stepKey(st, i) ? "\u25BC" : "\u25B6" }}</span>
766
767
  </button>
767
- <div v-if="st.error" class="px-3 pb-2 -mt-1">
768
- <div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words">{{ st.error }}</div>
768
+ <div v-if="stepError(st.error)" class="px-3 pb-2 -mt-1">
769
+ <div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words whitespace-pre-wrap" data-testid="step-error">{{ stepError(st.error).message }}</div>
769
770
  </div>
770
771
  <div v-if="expandedStep === stepKey(st, i)" class="px-3 pb-3 space-y-2">
771
772
  <template v-if="hasPayload(st)">
@@ -20,10 +20,11 @@ const props = defineProps({
20
20
  });
21
21
  const emit = defineEmits(["installed", "update:loading", "update:installing"]);
22
22
  const t = useIntegrationsT();
23
- const { get, load, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
23
+ const { get, load, fetchDetail, checkRequirements, uncheckedReason, instantiate } = useIntegrationsTemplates();
24
24
  const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
25
25
  const { ofType, refresh: refreshCredentials } = useIntegrationsCredentials();
26
26
  const reqs = ref(null);
27
+ const reqsError = ref(null);
27
28
  const loading = ref(true);
28
29
  const installing = ref(false);
29
30
  const error = ref(null);
@@ -35,40 +36,37 @@ watch(() => props.slug, async (slug) => {
35
36
  const mine = ++run;
36
37
  loading.value = true;
37
38
  reqs.value = null;
39
+ reqsError.value = null;
38
40
  error.value = null;
39
41
  name.value = "";
40
42
  for (const k of Object.keys(bindings)) delete bindings[k];
41
- await load();
42
- if (mine !== run) return;
43
- if (name.value === "") name.value = get(slug)?.name ?? "";
44
- await Promise.all([fetchDetail(slug), refreshCredentials(), loadSecrets()]);
45
- if (mine !== run) return;
46
- const answer = await requirements(slug);
43
+ let answer = null;
44
+ let failure = null;
45
+ try {
46
+ await load();
47
+ if (mine !== run) return;
48
+ if (name.value === "") name.value = get(slug)?.name ?? "";
49
+ await Promise.all([fetchDetail(slug), refreshCredentials(), loadSecrets()]);
50
+ if (mine !== run) return;
51
+ answer = await checkRequirements(slug);
52
+ } catch (e) {
53
+ failure = uncheckedReason(e);
54
+ }
47
55
  if (mine !== run) return;
48
56
  reqs.value = answer;
57
+ reqsError.value = failure;
49
58
  loading.value = false;
50
59
  }, { immediate: true });
51
60
  watch(loading, (v) => emit("update:loading", v), { immediate: true });
52
61
  watch(installing, (v) => emit("update:installing", v));
53
62
  onBeforeUnmount(() => emit("update:loading", false));
54
- const tpl = computed(() => get(props.slug));
55
63
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
56
64
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
57
- const secretChecks = computed(() => {
58
- const fromApi = secretReqs.value;
59
- if (fromApi.length) {
60
- return fromApi.map((s) => ({
61
- key: s.key,
62
- label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
63
- present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
64
- }));
65
- }
66
- return (tpl.value?.requiredSecrets ?? []).map((key) => ({
67
- key,
68
- label: key,
69
- present: secretKeys.value.includes(key)
70
- }));
71
- });
65
+ const secretChecks = computed(() => secretReqs.value.map((s) => ({
66
+ key: s.key,
67
+ label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
68
+ present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
69
+ })));
72
70
  function slotsFor(usedBy) {
73
71
  const all = credentialReqs.value.flatMap((r) => r.usedBy);
74
72
  const seen = /* @__PURE__ */ new Set();
@@ -128,7 +126,13 @@ async function confirmInstall() {
128
126
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
129
127
  {{ t("integration.templates.detail.credentials.title") }}
130
128
  </div>
131
- <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
129
+ <!-- Two lines, not one sentence around {reason}: the platform's message
130
+ ends in a full stop or not, and a template cannot know which. -->
131
+ <div v-if="reqsError" class="rounded-md border-l-2 border-amber-500/40 bg-amber-500/5 px-2.5 py-1.5 text-sm text-amber-700" data-testid="template-install-unchecked">
132
+ <p>{{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}</p>
133
+ <p>{{ t("integration.templates.detail.install.stillInstallable") }}</p>
134
+ </div>
135
+ <p v-else-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
132
136
  {{ t("integration.templates.detail.install.noCredentials") }}
133
137
  </p>
134
138
  <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
@@ -31,6 +31,7 @@ import { formatDuration } from "../../utils/formatDuration";
31
31
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
32
32
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
33
33
  import { executedNodeIds, isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
34
+ import { frameLabel, stepError } from "../../utils/stepError";
34
35
  import { errorReason } from "../../utils/apiError";
35
36
  const props = defineProps({
36
37
  workflow: { type: Object, required: true },
@@ -108,6 +109,7 @@ const playing = ref(false);
108
109
  let playTimer = null;
109
110
  const safeCursor = computed(() => steps.value.length === 0 ? 0 : Math.min(cursor.value, steps.value.length - 1));
110
111
  const activeStep = computed(() => steps.value[safeCursor.value]);
112
+ const activeError = computed(() => stepError(activeStep.value?.error));
111
113
  function seek(i) {
112
114
  playing.value = false;
113
115
  cursor.value = Math.max(0, Math.min(i, steps.value.length - 1));
@@ -529,9 +531,19 @@ watch(() => props.initialRunId, (id) => {
529
531
  <code class="rounded bg-black/[0.05] px-1.5 py-0.5 font-mono">{{ activeStep.branch }}</code>
530
532
  </div>
531
533
 
532
- <div v-if="activeStep.error" class="text-sm">
534
+ <div v-if="activeError" class="text-sm" data-testid="replay-step-error">
533
535
  <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.error") }}</div>
534
- <pre class="max-h-40 overflow-auto rounded-md border border-rose-500/20 bg-rose-500/5 p-2 font-mono text-sm text-rose-700">{{ activeStep.error }}</pre>
536
+ <div class="max-h-40 overflow-auto rounded-md border border-rose-500/20 bg-rose-500/5 p-2 text-sm text-rose-700 break-words whitespace-pre-wrap">{{ activeError.message }}</div>
537
+ <div v-if="activeError.type" class="mt-1 font-mono text-xs text-muted-foreground">{{ activeError.type }}</div>
538
+ <!-- Where the node's own code failed (PO-447): for whoever maintains the
539
+ node, so folded rather than beside the reason (IN-12). Keyed on the step so
540
+ it folds again on the next failed one. -->
541
+ <details v-if="activeError.frames?.length" :key="safeCursor" class="mt-1">
542
+ <summary class="cursor-pointer text-xs text-muted-foreground">{{ t("integration.replay.debug.field.frames") }}</summary>
543
+ <ol class="mt-1 space-y-0.5 font-mono text-xs">
544
+ <li v-for="(frame, fi) in activeError.frames" :key="fi">{{ frameLabel(frame) }}</li>
545
+ </ol>
546
+ </details>
535
547
  </div>
536
548
 
537
549
  <div v-if="activeStep.input !== void 0 && activeStep.input !== null" class="text-sm">
@@ -44,7 +44,7 @@ export declare function useIntegrationsRuns(): {
44
44
  latestLoading: any;
45
45
  loadLatestPerWorkflow: (force?: boolean) => Promise<WorkflowRun[]>;
46
46
  lastRunFor: (workflowId: number) => WorkflowRun | null;
47
- trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "sync" | "async") => Promise<unknown>;
47
+ trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "async" | "sync") => Promise<unknown>;
48
48
  cancelRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
49
49
  terminateRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
50
50
  retryRun: (workflowId: number | string, runId: number | string) => Promise<unknown>;
@@ -12,6 +12,8 @@ export declare function useIntegrationsTemplates(): {
12
12
  fetchDetail: (slug: string) => Promise<WorkflowTemplate | null>;
13
13
  ensureDetails: (slugs: string[], _concurrency?: number) => Promise<void>;
14
14
  requirements: (slug: string) => Promise<TemplateRequirements | null>;
15
+ checkRequirements: (slug: string) => Promise<TemplateRequirements>;
16
+ uncheckedReason: (err: unknown) => string;
15
17
  instantiate: (slug: string, opts?: {
16
18
  name?: string;
17
19
  credentialBindings?: Record<string, string | Record<string, string>>;
@@ -165,16 +165,22 @@ export function useIntegrationsTemplates() {
165
165
  if (!wanted.length) return;
166
166
  remember(await loader.many(wanted));
167
167
  }
168
+ async function checkRequirements(slug) {
169
+ const raw = unwrapRecord(await api.get(`/templates/${slug}/requirements`));
170
+ return {
171
+ ...raw,
172
+ credentialTypes: (raw.credentialTypes ?? []).map((c) => ({
173
+ ...c,
174
+ name: c.name ? resolveLocalized(c.name) : c.name
175
+ }))
176
+ };
177
+ }
178
+ function uncheckedReason(err) {
179
+ return errorReason(err, t("integration.templates.detail.setup.uncheckedFallback"));
180
+ }
168
181
  async function requirements(slug) {
169
182
  try {
170
- const raw = unwrapRecord(await api.get(`/templates/${slug}/requirements`));
171
- return {
172
- ...raw,
173
- credentialTypes: (raw.credentialTypes ?? []).map((c) => ({
174
- ...c,
175
- name: c.name ? resolveLocalized(c.name) : c.name
176
- }))
177
- };
183
+ return await checkRequirements(slug);
178
184
  } catch {
179
185
  return null;
180
186
  }
@@ -194,5 +200,5 @@ export function useIntegrationsTemplates() {
194
200
  }
195
201
  const total = computed(() => templates.value.length);
196
202
  const startingPoints = computed(() => templates.value.slice(0, 4));
197
- return { templates, loaded, loading, error, total, startingPoints, load, list, get, fetchDetail, ensureDetails, requirements, instantiate, install };
203
+ return { templates, loaded, loading, error, total, startingPoints, load, list, get, fetchDetail, ensureDetails, requirements, checkRequirements, uncheckedReason, instantiate, install };
198
204
  }
@@ -920,6 +920,7 @@
920
920
  "integration.replay.debug.field.config": "Konfiguration",
921
921
  "integration.replay.debug.field.duration": "Dauer",
922
922
  "integration.replay.debug.field.error": "Fehler",
923
+ "integration.replay.debug.field.frames": "Stelle im Code des Nodes",
923
924
  "integration.replay.debug.field.input": "Eingabe",
924
925
  "integration.replay.debug.field.output": "Ausgabe",
925
926
  "integration.replay.debug.field.started": "Gestartet",
@@ -1172,6 +1173,7 @@
1172
1173
  "integration.templates.detail.install.state.loading": "Es wird geprüft, was diese Vorlage benötigt…",
1173
1174
  "integration.templates.detail.install.submit": "Installieren",
1174
1175
  "integration.templates.detail.install.submitting": "Wird eingerichtet…",
1176
+ "integration.templates.detail.install.stillInstallable": "Sie können sie trotzdem installieren und die Zugangsdaten im Editor verbinden.",
1175
1177
  "integration.templates.detail.publisher.kind.community": "Community",
1176
1178
  "integration.templates.detail.publisher.kind.partner": "Partner",
1177
1179
  "integration.templates.detail.publisher.kind.revenexx": "Offiziell",
@@ -1189,6 +1191,8 @@
1189
1191
  "integration.templates.detail.setup.missingCredentials": "{n} Zugangsdatensatz zu verbinden | {n} Zugangsdatensätze zu verbinden",
1190
1192
  "integration.templates.detail.setup.missingSecrets": "{n} Secret fehlt | {n} Secrets fehlen",
1191
1193
  "integration.templates.detail.setup.ready": "Alles ist bereit für die Installation.",
1194
+ "integration.templates.detail.setup.unchecked": "Es ließ sich nicht prüfen, was diese Vorlage benötigt: {reason}",
1195
+ "integration.templates.detail.setup.uncheckedFallback": "Die Plattform hat nicht geantwortet.",
1192
1196
  "integration.templates.detail.state.loading": "Vorlage wird geladen…",
1193
1197
  "integration.templates.detail.state.notFound": "Vorlage nicht gefunden.",
1194
1198
  "integration.templates.detail.steps.empty": "Für diese Vorlage gibt es noch keine Schritt-für-Schritt-Übersicht.",
@@ -920,6 +920,7 @@
920
920
  "integration.replay.debug.field.config": "Config",
921
921
  "integration.replay.debug.field.duration": "Duration",
922
922
  "integration.replay.debug.field.error": "Error",
923
+ "integration.replay.debug.field.frames": "Where in the node's code",
923
924
  "integration.replay.debug.field.input": "Input",
924
925
  "integration.replay.debug.field.output": "Output",
925
926
  "integration.replay.debug.field.started": "Started",
@@ -1172,6 +1173,7 @@
1172
1173
  "integration.templates.detail.install.state.loading": "Checking what this template needs…",
1173
1174
  "integration.templates.detail.install.submit": "Install",
1174
1175
  "integration.templates.detail.install.submitting": "Setting up…",
1176
+ "integration.templates.detail.install.stillInstallable": "You can still install it and connect its credentials in the editor.",
1175
1177
  "integration.templates.detail.publisher.kind.community": "Community",
1176
1178
  "integration.templates.detail.publisher.kind.partner": "Partner",
1177
1179
  "integration.templates.detail.publisher.kind.revenexx": "Official",
@@ -1189,6 +1191,8 @@
1189
1191
  "integration.templates.detail.setup.missingCredentials": "{n} credential to connect | {n} credentials to connect",
1190
1192
  "integration.templates.detail.setup.missingSecrets": "{n} secret missing | {n} secrets missing",
1191
1193
  "integration.templates.detail.setup.ready": "Everything is ready to install.",
1194
+ "integration.templates.detail.setup.unchecked": "Could not check what this template needs: {reason}",
1195
+ "integration.templates.detail.setup.uncheckedFallback": "The platform did not answer.",
1192
1196
  "integration.templates.detail.state.loading": "Loading template…",
1193
1197
  "integration.templates.detail.state.notFound": "Template not found.",
1194
1198
  "integration.templates.detail.steps.empty": "No step-by-step outline for this template yet.",
@@ -22,7 +22,7 @@ import { keepOpenBeside } from "../../../utils/dialog";
22
22
  const t = useIntegrationsT();
23
23
  const route = useRoute();
24
24
  const router = useRouter();
25
- const { get, load, loaded, fetchDetail, requirements } = useIntegrationsTemplates();
25
+ const { get, load, loaded, fetchDetail, checkRequirements, uncheckedReason } = useIntegrationsTemplates();
26
26
  const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
27
27
  const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
28
28
  const { ofType, load: loadCredentials } = useIntegrationsCredentials();
@@ -30,10 +30,15 @@ const slug = computed(() => String(route.params.slug));
30
30
  const tpl = computed(() => get(slug.value));
31
31
  useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.value?.name ?? t("integration.templates.detail.head.fallbackName") }) });
32
32
  const reqs = ref(null);
33
+ const reqsError = ref(null);
33
34
  onMounted(async () => {
34
- await load();
35
- await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
36
- reqs.value = await requirements(slug.value);
35
+ try {
36
+ await load();
37
+ await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
38
+ reqs.value = await checkRequirements(slug.value);
39
+ } catch (e) {
40
+ reqsError.value = uncheckedReason(e);
41
+ }
37
42
  });
38
43
  const descriptionHtml = computed(() => renderTemplateDescription(tpl.value?.descriptionMd));
39
44
  const steps = computed(() => {
@@ -104,24 +109,14 @@ const variableColumns = computed(() => [
104
109
  ]);
105
110
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
106
111
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
107
- const secretChecks = computed(() => {
108
- const fromApi = secretReqs.value;
109
- if (fromApi.length) {
110
- return fromApi.map((s) => ({
111
- key: s.key,
112
- label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
113
- present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
114
- }));
115
- }
116
- return (tpl.value?.requiredSecrets ?? []).map((key) => ({
117
- key,
118
- label: key,
119
- present: secretKeys.value.includes(key)
120
- }));
121
- });
112
+ const secretChecks = computed(() => secretReqs.value.map((s) => ({
113
+ key: s.key,
114
+ label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
115
+ present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
116
+ })));
122
117
  const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
123
118
  const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
124
- const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
119
+ const setupReady = computed(() => reqs.value !== null && missingSecrets.value === 0 && missingCredentials.value === 0);
125
120
  const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
126
121
  const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
127
122
  const installOpen = ref(false);
@@ -298,11 +293,15 @@ function levelDot(level) {
298
293
  point `aria-labelledby` at. Copy this pattern only for a panel
299
294
  that genuinely has none. -->
300
295
  <div role="group" :aria-label="t('integration.templates.detail.setup.ariaLabel')" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
301
- <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
296
+ <div v-if="reqsError" class="flex items-start gap-1.5 text-sm text-amber-700" data-testid="template-setup-unchecked">
297
+ <ShieldAlert :size="14" class="mt-0.5 shrink-0" />
298
+ {{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}
299
+ </div>
300
+ <div v-else-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
302
301
  <CheckCircle2 :size="14" />
303
302
  {{ t("integration.templates.detail.setup.ready") }}
304
303
  </div>
305
- <div v-else class="text-sm text-amber-700 space-y-0.5">
304
+ <div v-else-if="reqs" class="text-sm text-amber-700 space-y-0.5">
306
305
  <div v-if="missingCredentials" class="flex items-center gap-1.5">
307
306
  <Key :size="12" />
308
307
  {{ t("integration.templates.detail.setup.missingCredentials", missingCredentials) }}
@@ -698,6 +698,17 @@ export interface WorkflowRunNodeStep {
698
698
  attempt: number;
699
699
  durationMs: number | null;
700
700
  }
701
+ export interface RunStepFrame {
702
+ file: string;
703
+ line: number;
704
+ column: number;
705
+ name?: string | null;
706
+ }
707
+ export interface RunStepError {
708
+ message: string;
709
+ type?: string;
710
+ frames?: RunStepFrame[];
711
+ }
701
712
  export interface RunStepRow {
702
713
  node_id: string;
703
714
  node_slug: string;
@@ -716,7 +727,11 @@ export interface RunStepRow {
716
727
  port: string;
717
728
  } | null;
718
729
  branch?: string | null;
719
- error?: string | null;
730
+ /**
731
+ * Why the step failed. The platform sends the object; a string is what older
732
+ * rows and older mocks carry. Read it through `utils/stepError.ts`, never print it.
733
+ */
734
+ error?: RunStepError | string | null;
720
735
  input?: unknown;
721
736
  output?: unknown;
722
737
  /** Resolved node config for this step (present with include_payloads). */
@@ -0,0 +1,13 @@
1
+ import type { RunStepError, RunStepFrame } from '../types/integrations.js';
2
+ /**
3
+ * A failed step's error as an operator reads it (IN-12).
4
+ *
5
+ * The platform sends it as an object — the message, the failure's type, and where
6
+ * the node package's own code was when it failed (`frames`, PO-447). The studio used
7
+ * to read it as text, and a template prints an object as JSON. A row from before the
8
+ * object, or a mock that still answers a string, is read as the message itself, so
9
+ * no shape ever reaches the screen as a dump.
10
+ */
11
+ export declare function stepError(error: unknown): RunStepError | null;
12
+ /** One frame as a reader names it: `src/nodes/Foo.ts:12:5` and the function, where known. */
13
+ export declare function frameLabel(frame: RunStepFrame): string;
@@ -0,0 +1,24 @@
1
+ export function stepError(error) {
2
+ if (typeof error === "string") return error.trim() === "" ? null : { message: error };
3
+ if (error === null || typeof error !== "object") return null;
4
+ const raw = error;
5
+ const message = typeof raw.message === "string" && raw.message.trim() !== "" ? raw.message : null;
6
+ if (message === null) return null;
7
+ return {
8
+ message,
9
+ ...typeof raw.type === "string" && raw.type.trim() !== "" ? { type: raw.type } : {},
10
+ ...Array.isArray(raw.frames) ? { frames: raw.frames.filter(isFrame).map(withName) } : {}
11
+ };
12
+ }
13
+ export function frameLabel(frame) {
14
+ const at = `${frame.file}:${frame.line}:${frame.column}`;
15
+ return frame.name ? `${frame.name} (${at})` : at;
16
+ }
17
+ function isFrame(value) {
18
+ if (value === null || typeof value !== "object") return false;
19
+ const f = value;
20
+ return typeof f.file === "string" && typeof f.line === "number" && typeof f.column === "number";
21
+ }
22
+ function withName(frame) {
23
+ return typeof frame.name === "string" && frame.name.trim() !== "" ? frame : { file: frame.file, line: frame.line, column: frame.column };
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {