@revenexx/studio-integrations 1.4.1 → 2.0.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,167 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 8397d7b: Every studio list is read as a table, its search box states its own name, and the Runs
8
+ filter works again on `@revenexx/studio-shared` 0.3.2 (PO-233).
9
+
10
+ **A row of any studio list used to be an unnamed sequence of values.** The shared table
11
+ rendered anonymous boxes, so nothing connected a column heading to the cell beneath it:
12
+ a schedule read as _"Order sync, Daily at 03:00, Europe/Berlin, in 18m, 1h ago, live"_,
13
+ with nothing saying which of the two times was the next run. Taking `studio-shared`
14
+ 0.3.2 gives every list — Workflows, Runs, Schedules, Credentials, Secrets and the Audit
15
+ Log — `table`, `row`, `columnheader` and `cell`, so each value is announced under the
16
+ heading it belongs to. The search above each list now states its name on the field
17
+ itself, in the words of its own placeholder, instead of leaving the browser to work one
18
+ out from that placeholder — which is a fallback rather than a name, and a documented
19
+ failure pattern when it is the only one.
20
+
21
+ **Two cells on the Schedules page lost the labels they carried.** They were there so a
22
+ test could find them, and a name on a cell is read _instead of_ its contents — so with
23
+ the semantics in place they announced "Next run" where the time should be. The heading
24
+ above the column says which time it is now, which is what it is for.
25
+
26
+ **The Runs filter is rebuilt as two selects.** In 0.3.2 the shared filter trigger opens
27
+ a popover rather than a dropdown menu, and the sub-menus this page put inside it had no
28
+ menu to attach to: they rendered nothing at all, so the control was there and the
29
+ narrowing was gone. Outcome and trigger kind are now chosen from a select each, `All`
30
+ included, and the narrowing reaches the platform exactly as before.
31
+
32
+ **A host also owes the shared table two new catalogue entries.** `studio-shared`
33
+ translates through the host's own `shared.*` catalogue and renders the key itself when
34
+ an entry is missing, and 0.3.2 asks for `shared.datatable.toolbar.filter.apply` and
35
+ `…toolbar.filter.reset` — the two buttons of the filter panel, which a host without
36
+ them draws as the key names. The cockpit already carries both, in either language; any
37
+ other host has to add them. (`…toolbar.search.aria.label` is asked for too, but no list
38
+ in this module reaches it: the table names the search box after its own placeholder
39
+ unless a surface says otherwise, and all six pass a placeholder.)
40
+
41
+ **This is a major for two of the reasons [`docs/versioning.md`](docs/versioning.md)
42
+ names.** The peer floor on `@revenexx/studio` rises from `*` to `^0.2.3`, so a host on
43
+ the old range can no longer install — and four accessible names are removed rather than
44
+ renamed: `Next run`, `Last run`, `When it fires` and `Cron expression`, which anything
45
+ addressing a schedules cell by name was using. Both are things a host has to react to.
46
+
47
+ **Hosts must be on `@revenexx/studio` ^0.2.3.** The peer range was `*` and is now the
48
+ version that carries the search field's `label` prop; below it the name falls through to
49
+ a wrapper and the box stays anonymous.
50
+
51
+ - c651aac: The editor no longer points out an error output nobody connected. The run says it instead
52
+ (PO-479).
53
+
54
+ **What goes.** A strip above the canvas read _"{n} node routes failures to an output with
55
+ nothing attached — nothing handles the failure there."_, named each step, and offered to
56
+ attach a **Stop and Error** step to the port in one click. The strip, that action, and the
57
+ five promises in `specs/workflow-advice.md` are withdrawn.
58
+
59
+ **Why, and what an author loses.** The strip and the platform behaviour it described
60
+ shipped together under PO-443. The platform half is the one that can speak with authority:
61
+ a failure routed to an output nothing leaves now **ends the run**, naming the step, what it
62
+ failed with, and which output had nowhere to send it — the `node-failure-handling` spec of
63
+ `revenexx/integrations`, AC-8. Before PO-443 such a failure vanished and the run was
64
+ recorded as a success, which is the world the strip was built for and which no longer
65
+ exists. What is genuinely lost is the timing: an author used to be told while drawing, and
66
+ is now told by the first run that actually fails.
67
+
68
+ **`revenexx:stop-and-error` is untouched.** It is still in the palette, still what all eight
69
+ shipped templates put behind each of their twenty-five error ports, and still the right
70
+ thing to attach. Only the editor's one-click attachment is gone.
71
+
72
+ **A major, for the reason [`docs/versioning.md`](docs/versioning.md) gives.** Seven strings
73
+ are removed rather than renamed, and three of them are accessible names or an action label
74
+ that a host or a test could be addressing:
75
+
76
+ | Removed | What it was |
77
+ | ------------------------------------------ | ------------------------------------------- |
78
+ | `integration.editor.errorPath.advice` | the strip's own sentence |
79
+ | `integration.editor.errorPath.action.stop` | _Stop the run on error_ |
80
+ | `integration.editor.errorPath.ariaReveal` | accessible name of the step link |
81
+ | `integration.editor.errorPath.ariaFix` | accessible name of the attach action |
82
+ | `integration.editor.errorPath.more` | _…and {n} more_ |
83
+ | `integration.editor.errorPath.message` | the pre-filled message on the attached step |
84
+ | `integration.editor.errorPath.toast.added` | the toast after attaching |
85
+
86
+ Nothing else in the editor moves: the validation banner, the reauth strip and the build
87
+ notice keep their place above the canvas, and the activation gate is unchanged — the strip
88
+ never touched it, which was the whole point of it.
89
+
90
+ ### Patch Changes
91
+
92
+ - a79380d: The dialog that imports a platform SFTP account can be drawn by any host built to the
93
+ written contract, and its wait is now a promise with a test behind it (PO-387).
94
+
95
+ **The studio's written contract for a host was five members behind what this dialog
96
+ reads.** `src/runtime/utils/cockpitSources.ts` says what the module expects of a cockpit
97
+ host, and its `sftp` entry declared neither `loading` nor `activeTenantSlug`, made
98
+ `load`'s tenant argument required where the cockpit makes it optional, and left `server`
99
+ and `home_directory` off an account. The dialog read all five. The cockpit has provided
100
+ all five throughout, so **nothing was ever wrong for an operator** — what was wrong is
101
+ that a host built to the contract instead of to the cockpit threw
102
+ `TypeError: Cannot read properties of undefined` while drawing the dialog. Vue's error
103
+ handler turns that into a console message and unmounts the tree, so the page went blank
104
+ with no error anything could catch.
105
+
106
+ **The consequence was that no test could reach the surface at all.** The playground's
107
+ stand-in for the cockpit is built from the contract, so switching it on crashed the
108
+ dialog and leaving it off gave a dialog that never waits — the only two states the suite
109
+ could produce. The waiting announcement PO-251 added here was therefore recorded as a
110
+ gap rather than a criterion. The contract and the stand-in now carry all five members,
111
+ the dialog takes its type from the contract rather than declaring its own, and
112
+ `specs/node-configuration.md` carries the wait as AC-33 with a test that fails when the
113
+ announcement is taken away.
114
+
115
+ **A failed fetch no longer reads as a tenant with no accounts.** The surrounding
116
+ application catches its own fetch errors, so the studio is handed an empty list and no
117
+ exception — and the dialog drew the same "No active platform SFTP accounts. Create one
118
+ under Integration Studio → SFTP first." for both. One of those two asks the operator to
119
+ go and create an account; the other does not, and nothing on screen let them tell which
120
+ they were looking at. The contract gains the `error` the cockpit has always exposed —
121
+ **optional**, because the standalone dev studio's stand-ins provide the other four
122
+ members and not that one, and a dialog that insisted on it would lose that host the
123
+ whole surface. Where it is absent the dialog keeps the empty state it showed before.
124
+ That is AC-34, and it adds one string in both languages: **"The platform SFTP accounts could not be loaded:
125
+ {message}"** / "Die Plattform-SFTP-Konten konnten nicht geladen werden: {message}".
126
+
127
+ **A patch.** `CockpitSftpAccount` gains two required members, which
128
+ [`docs/versioning.md`](docs/versioning.md) would put in the major column as a narrowed
129
+ type — but only for somebody who can reach it. Nobody can: `exports` in `package.json`
130
+ declares `"."` alone and no `typesVersions`, so every other subpath is sealed and
131
+ `require.resolve` of the file answers `ERR_PACKAGE_PATH_NOT_EXPORTED`. The type ships
132
+ inside the tarball and is importable from nowhere. The dialog reads exactly what it read
133
+ before, so a host that worked goes on working unchanged.
134
+
135
+ - 8975a1f: The Credentials list offers the provider step to a credential that never took it, not
136
+ only to one whose authorisation expired (PO-478).
137
+
138
+ **A three-legged credential is created before its provider is asked.** The dialog closes
139
+ on the creation, and the window that asks for consent is opened after that — so when
140
+ that second step fails, there is no dialog left to say so. What remained was a
141
+ credential that cannot work, wearing the same _Not Connected_ badge as one nobody has
142
+ tested, with nothing in its row to finish what was started. The only way on was to
143
+ delete it and make it again.
144
+
145
+ **The row now offers the step wherever a three-legged credential is not known to be
146
+ connected**, and the wording says which of the two it is: **Connect** where nothing has
147
+ ever proved the credential connected, **Reconnect** where it was working and stopped —
148
+ an authorisation that expired, or a connection test that failed. The amber stays on the
149
+ expired one, which is the case this surface has always marked for attention. A
150
+ credential whose access data is a key, a host or a password never carries either, which
151
+ is what keeps the offer from becoming background noise.
152
+
153
+ It is generous by exactly one case: a credential that _was_ authorised but never tested
154
+ also carries the offer, because nothing the service returns tells that apart from one
155
+ that was never authorised. Taking the step again is the same window the operator was
156
+ expecting, so the cost of that guess is a click.
157
+
158
+ Promised behaviour: `specs/credentials.md` AC-2. The `## Gaps` entry in
159
+ `specs/credential-create.md` that asked what this surface says when the step after the
160
+ close fails is answered, and now points here.
161
+
162
+ - c787791: No product change: a spec's `## Gaps` entry and a test comment now say only what was
163
+ measured about `canvas-navigation` AC-4 (PO-479).
164
+
3
165
  ## 1.4.1
4
166
 
5
167
  ### Patch Changes
package/README.md CHANGED
@@ -74,7 +74,7 @@ component styling silently breaks:
74
74
  Adjust the relative paths to reach `node_modules` from your CSS file.
75
75
 
76
76
  ## Peers
77
- `@revenexx/studio`, `@revenexx/studio-shared` ^0.2 (module form), `nuxt` ^4, `vue` ^3.5,
77
+ `@revenexx/studio` ^0.2.3, `@revenexx/studio-shared` ^0.3.2 (module form), `nuxt` ^4, `vue` ^3.5,
78
78
  `@vue-flow/*` + `@dagrejs/dagre` (workflow editor), `lucide-vue-next`; register
79
79
  `@solar-icons/nuxt` in the host.
80
80
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "1.4.1",
4
+ "version": "2.0.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -2,11 +2,11 @@
2
2
  import { computed, onMounted, onUnmounted, ref } from "vue";
3
3
  import {
4
4
  Badge,
5
- DropdownMenuItem,
6
- DropdownMenuSeparator,
7
- DropdownMenuSub,
8
- DropdownMenuSubContent,
9
- DropdownMenuSubTrigger
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue
10
10
  } from "@revenexx/studio";
11
11
  import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../utils/runFeedQuery";
12
12
  import { RUN_STATUS_COLUMN_WIDTH, RUN_STATUS_WORDS, normalisedRunStatus, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
@@ -85,8 +85,9 @@ function setStatus(word) {
85
85
  function setTrigger(kind) {
86
86
  emit("narrow", { triggerType: kind ?? "" });
87
87
  }
88
- const statusApplied = computed(() => chosenStatus.value[0] ? runStatusLabel(chosenStatus.value[0]) : "");
89
- const triggerApplied = computed(() => props.triggerType ? triggerTypeLabel(props.triggerType) : "");
88
+ const ALL = "all";
89
+ const statusChoice = computed(() => chosenStatus.value[0] ?? ALL);
90
+ const triggerChoice = computed(() => props.triggerType || ALL);
90
91
  const searchText = ref("");
91
92
  function onTableState(state) {
92
93
  searchText.value = state.search;
@@ -171,46 +172,66 @@ const columns = computed(() => [
171
172
  (`filterTabs`) cannot be used here, because it compares the rows in hand and
172
173
  those are one page.
173
174
 
174
- Built to the same shape as that control on purpose — a dimension per
175
- sub-menu, "All" first, one value at a time — so this list is narrowed by the
176
- same gesture as the five that narrow what they hold, and one test helper
177
- still addresses all six (`e2e/support/studio-table.ts`). What is NOT copied
178
- is the count beside each option: the built-in counts the rows in hand, and
179
- counting a page here would put a number next to "failed" that describes this
180
- page rather than the history the option narrows.
175
+ It is no longer the same gesture as the other lists. Theirs tick values and
176
+ apply them together; this one picks one value per dimension and applies it on
177
+ the spot, because each pick is a request to the platform and staging them
178
+ would only delay the same round trip. So the shared narrowing helper does not
179
+ reach this page and `runs.spec.ts` carries its own — said in both places
180
+ rather than left to be discovered (`e2e/support/studio-table.ts`).
181
+
182
+ What is still NOT copied is the count beside each option: the built-in counts
183
+ the rows in hand, and counting a page here would put a number next to
184
+ "failed" that describes this page rather than the history the option narrows.
181
185
  -->
182
186
  <template v-if="serverMode" #filter-menu>
183
- <DropdownMenuSub>
184
- <DropdownMenuSubTrigger class="gap-2">
185
- <span class="flex-1">{{ t("integration.runs.table.column.status") }}</span>
186
- <span v-if="statusApplied" class="text-xs text-primary">{{ statusApplied }}</span>
187
- </DropdownMenuSubTrigger>
188
- <DropdownMenuSubContent class="w-48">
189
- <DropdownMenuItem @select="setStatus(null)">
190
- {{ t("integration.runs.table.filter.all") }}
191
- </DropdownMenuItem>
192
- <DropdownMenuSeparator />
193
- <DropdownMenuItem v-for="word in RUN_STATUS_WORDS" :key="word" @select="setStatus(word)">
194
- {{ runStatusLabel(word) }}
195
- </DropdownMenuItem>
196
- </DropdownMenuSubContent>
197
- </DropdownMenuSub>
187
+ <!--
188
+ Two selects rather than the sub-menus this slot used to hold. Since
189
+ `studio-shared` 0.3.2 the filter trigger opens a popover instead of a
190
+ dropdown menu, and a `DropdownMenuSub` outside a menu root has nothing to
191
+ attach to: it renders nothing at all, so the narrowing was there in the
192
+ markup and gone from the screen. A select is what the new surface is built
193
+ for — it sizes `[data-slot=select-trigger]` to its full width — and it
194
+ keeps what the menu promised, the widest setting included.
195
+ -->
196
+ <div class="space-y-1">
197
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.status") }}</span>
198
+ <Select
199
+ :model-value="statusChoice"
200
+ @update:model-value="(v) => setStatus(v === ALL ? null : String(v))"
201
+ >
202
+ <SelectTrigger :aria-label="t('integration.runs.table.column.status')" class="h-8 text-sm">
203
+ <SelectValue />
204
+ </SelectTrigger>
205
+ <SelectContent>
206
+ <SelectItem :value="ALL">
207
+ {{ t("integration.runs.table.filter.all") }}
208
+ </SelectItem>
209
+ <SelectItem v-for="word in RUN_STATUS_WORDS" :key="word" :value="word">
210
+ {{ runStatusLabel(word) }}
211
+ </SelectItem>
212
+ </SelectContent>
213
+ </Select>
214
+ </div>
198
215
 
199
- <DropdownMenuSub>
200
- <DropdownMenuSubTrigger class="gap-2">
201
- <span class="flex-1">{{ t("integration.runs.table.column.trigger") }}</span>
202
- <span v-if="triggerApplied" class="text-xs text-primary">{{ triggerApplied }}</span>
203
- </DropdownMenuSubTrigger>
204
- <DropdownMenuSubContent class="w-48">
205
- <DropdownMenuItem @select="setTrigger(null)">
206
- {{ t("integration.runs.table.filter.all") }}
207
- </DropdownMenuItem>
208
- <DropdownMenuSeparator />
209
- <DropdownMenuItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" @select="setTrigger(kind)">
210
- {{ triggerTypeLabel(kind) }}
211
- </DropdownMenuItem>
212
- </DropdownMenuSubContent>
213
- </DropdownMenuSub>
216
+ <div class="space-y-1">
217
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.trigger") }}</span>
218
+ <Select
219
+ :model-value="triggerChoice"
220
+ @update:model-value="(v) => setTrigger(v === ALL ? null : String(v))"
221
+ >
222
+ <SelectTrigger :aria-label="t('integration.runs.table.column.trigger')" class="h-8 text-sm">
223
+ <SelectValue />
224
+ </SelectTrigger>
225
+ <SelectContent>
226
+ <SelectItem :value="ALL">
227
+ {{ t("integration.runs.table.filter.all") }}
228
+ </SelectItem>
229
+ <SelectItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" :value="kind">
230
+ {{ triggerTypeLabel(kind) }}
231
+ </SelectItem>
232
+ </SelectContent>
233
+ </Select>
234
+ </div>
214
235
  </template>
215
236
 
216
237
  <template #cell-status="{ row: run }">
@@ -17,13 +17,24 @@ const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
17
17
  const { create } = useIntegrationsCredentials();
18
18
  const busyId = ref(null);
19
19
  const error = ref(null);
20
- watch(open, (o) => {
21
- if (o) {
22
- error.value = null;
23
- void sftp.load();
20
+ const loadPending = ref(false);
21
+ let openCycle = 0;
22
+ watch(open, async (o) => {
23
+ if (!o) return;
24
+ const cycle = ++openCycle;
25
+ error.value = null;
26
+ loadPending.value = true;
27
+ try {
28
+ await sftp.load();
29
+ } finally {
30
+ if (cycle === openCycle) loadPending.value = false;
24
31
  }
25
32
  });
26
33
  const activeAccounts = computed(() => sftp.accounts.value.filter((a) => a.status === "active" && a.login_username));
34
+ const waiting = computed(() => (loadPending.value || sftp.loading.value) && !activeAccounts.value.length);
35
+ const loadFailed = computed(
36
+ () => !waiting.value && !activeAccounts.value.length && sftp.activeTenantSlug.value && sftp.error?.value ? t("integration.credentials.sftpImport.state.loadFailed", { message: sftp.error.value }) : null
37
+ );
27
38
  async function importAccount(account) {
28
39
  const tenant = sftp.activeTenantSlug.value;
29
40
  if (!tenant || !account.login_username) return;
@@ -69,13 +80,16 @@ async function importAccount(account) {
69
80
  limit written down there (PO-251). -->
70
81
  <LoadingAnnouncement
71
82
  :label="t('integration.credentials.sftpImport.title')"
72
- :loading="sftp.loading.value && !activeAccounts.length"
83
+ :loading="waiting"
73
84
  :text="t('integration.credentials.sftpImport.state.loading')"
74
85
  />
75
86
 
76
- <div v-if="sftp.loading.value && !activeAccounts.length" aria-hidden="true" class="space-y-2">
87
+ <div v-if="waiting" aria-hidden="true" class="space-y-2">
77
88
  <div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
78
89
  </div>
90
+ <p v-else-if="loadFailed" class="py-4 text-center text-sm text-rose-600">
91
+ {{ loadFailed }}
92
+ </p>
79
93
  <p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
80
94
  {{ t("integration.credentials.sftpImport.empty") }}
81
95
  </p>
@@ -42,42 +42,8 @@ type __VLS_Props = {
42
42
  * id that is on the canvas is in the blob by construction.
43
43
  */
44
44
  declare function revealNode(id: string): void;
45
- /**
46
- * Point at a node without opening it — for a reader who has to look at the
47
- * card's *surroundings* rather than at its settings.
48
- *
49
- * The deliberate difference from {@link revealNode} is the missing selection,
50
- * and with it the missing settings lightbox. That is the whole reason there are
51
- * two functions: the validation banner sends someone to a field at fault, and
52
- * the dialog is where that field is. The error-path advice sends them to a port
53
- * with nothing on it, and the question they have to answer — where should this
54
- * failure go? — is answered by the graph around the card, which a dialog over
55
- * the canvas covers up.
56
- *
57
- * Marked with the same pulse a newly created card gets, because the job is the
58
- * same one: say which of thirty cards is the one meant.
59
- */
60
- declare function showNodeOnCanvas(id: string): void;
61
- /**
62
- * Hang a Stop-and-Error step off a node's error output and wire it up
63
- * (specs/workflow-advice.md AC-3).
64
- *
65
- * The advice strip can tell an author their error output leads nowhere; on its
66
- * own that is a reprimand without a remedy, because the repair — drop a node,
67
- * pick the right one, connect it, write an expression quoting the failing step's
68
- * message — is four steps of tedium they have to already know. Every template we
69
- * ship does exactly this, twenty-five times over, so the shape is settled and
70
- * worth one click.
71
- *
72
- * Leaves the result unsaved and unopened, like every other insertion here: the
73
- * new step is highlighted and the author decides whether to keep it. Deleting it
74
- * is the undo, because this editor has no undo stack to hook into.
75
- */
76
- declare function attachErrorStop(sourceNodeId: string, port: string): void;
77
45
  declare const _default: import("vue").DefineComponent<__VLS_Props, {
78
46
  revealNode: typeof revealNode;
79
- showNodeOnCanvas: typeof showNodeOnCanvas;
80
- attachErrorStop: typeof attachErrorStop;
81
47
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
82
48
  run: () => any;
83
49
  toggleActive: () => any;
@@ -51,8 +51,7 @@ import { toast } from "vue-sonner";
51
51
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
52
52
  import { layoutWorkflowNodes } from "./workflowLayout";
53
53
  import { defaultsFor } from "../../utils/configDefaults";
54
- import { nodeDisplayLabel } from "../../utils/validationFindings";
55
- import { TRIGGER_TEMPLATES, findManifest, newestExecutableVersion } from "../../data/integrationNodeCatalog";
54
+ import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
56
55
  import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
57
56
  import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
58
57
  import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
@@ -865,17 +864,7 @@ function revealNode(id) {
865
864
  selectedId.value = id;
866
865
  }
867
866
  }
868
- function showNodeOnCanvas(id) {
869
- const folded = collapsedGroupOf().get(id);
870
- if (folded) patchGroup(folded.id, { collapsed: false });
871
- if (blob.value.nodes.some((n) => n.id === id)) {
872
- markBorn(id);
873
- syncFromBlob();
874
- }
875
- if (paneReady.value) centerOnNode(id);
876
- else pendingReveal.value = id;
877
- }
878
- defineExpose({ revealNode, showNodeOnCanvas, attachErrorStop });
867
+ defineExpose({ revealNode });
879
868
  const selectedUpstreamOutputs = computed(() => {
880
869
  const id = selectedId.value;
881
870
  if (!id) return [];
@@ -1108,45 +1097,6 @@ function spliceIntoEdge(edgeId, payload) {
1108
1097
  syncFromBlob();
1109
1098
  push();
1110
1099
  }
1111
- const STOP_AND_ERROR_SLUG = "revenexx:stop-and-error";
1112
- function attachErrorStop(sourceNodeId, port) {
1113
- const source = blob.value.nodes.find((n) => n.id === sourceNodeId);
1114
- const version = newestExecutableVersion(STOP_AND_ERROR_SLUG);
1115
- const manifest = version ? findManifest(STOP_AND_ERROR_SLUG, version) : null;
1116
- if (!source || !manifest) return;
1117
- const at = nodePosition(sourceNodeId);
1118
- const id = `n-${Date.now()}`;
1119
- blob.value.nodes.push({
1120
- id,
1121
- nodeSlug: manifest.slug,
1122
- nodeVersion: manifest.version,
1123
- config: {
1124
- ...defaultsFor(manifest.config ?? []),
1125
- // The template convention verbatim: name the step that failed, then quote
1126
- // its own message, so the run record says what went wrong rather than only
1127
- // that something did. `errorCode` keeps the node's own default.
1128
- message: t("integration.editor.errorPath.message", {
1129
- label: nodeDisplayLabel(source),
1130
- nodeId: source.id,
1131
- port
1132
- })
1133
- },
1134
- // Below the source and on the grid, the way the templates place theirs.
1135
- // `freeSpotNear` keeps two of them off each other.
1136
- position: freeSpotNear({ x: at.x, y: at.y + 180 })
1137
- });
1138
- blob.value.edges.push({
1139
- id: `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
1140
- from: { nodeId: sourceNodeId, output: port },
1141
- to: { nodeId: id, input: void 0 }
1142
- });
1143
- markBorn(id);
1144
- syncFromBlob();
1145
- if (paneReady.value) centerOnNode(id);
1146
- else pendingReveal.value = id;
1147
- push();
1148
- toast.info(t("integration.editor.errorPath.toast.added", { label: nodeDisplayLabel(source) }));
1149
- }
1150
1100
  function nodeDims(id) {
1151
1101
  const d = flow.findNode(id)?.dimensions;
1152
1102
  return d && d.width > 0 ? d : { width: 220, height: 64 };
@@ -42,42 +42,8 @@ type __VLS_Props = {
42
42
  * id that is on the canvas is in the blob by construction.
43
43
  */
44
44
  declare function revealNode(id: string): void;
45
- /**
46
- * Point at a node without opening it — for a reader who has to look at the
47
- * card's *surroundings* rather than at its settings.
48
- *
49
- * The deliberate difference from {@link revealNode} is the missing selection,
50
- * and with it the missing settings lightbox. That is the whole reason there are
51
- * two functions: the validation banner sends someone to a field at fault, and
52
- * the dialog is where that field is. The error-path advice sends them to a port
53
- * with nothing on it, and the question they have to answer — where should this
54
- * failure go? — is answered by the graph around the card, which a dialog over
55
- * the canvas covers up.
56
- *
57
- * Marked with the same pulse a newly created card gets, because the job is the
58
- * same one: say which of thirty cards is the one meant.
59
- */
60
- declare function showNodeOnCanvas(id: string): void;
61
- /**
62
- * Hang a Stop-and-Error step off a node's error output and wire it up
63
- * (specs/workflow-advice.md AC-3).
64
- *
65
- * The advice strip can tell an author their error output leads nowhere; on its
66
- * own that is a reprimand without a remedy, because the repair — drop a node,
67
- * pick the right one, connect it, write an expression quoting the failing step's
68
- * message — is four steps of tedium they have to already know. Every template we
69
- * ship does exactly this, twenty-five times over, so the shape is settled and
70
- * worth one click.
71
- *
72
- * Leaves the result unsaved and unopened, like every other insertion here: the
73
- * new step is highlighted and the author decides whether to keep it. Deleting it
74
- * is the undo, because this editor has no undo stack to hook into.
75
- */
76
- declare function attachErrorStop(sourceNodeId: string, port: string): void;
77
45
  declare const _default: import("vue").DefineComponent<__VLS_Props, {
78
46
  revealNode: typeof revealNode;
79
- showNodeOnCanvas: typeof showNodeOnCanvas;
80
- attachErrorStop: typeof attachErrorStop;
81
47
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
82
48
  run: () => any;
83
49
  toggleActive: () => any;
@@ -28,8 +28,8 @@
28
28
  "integration.audit.table.column.actor": "Akteur",
29
29
  "integration.audit.table.column.resource": "Ressource",
30
30
  "integration.audit.table.column.when": "Wann",
31
- "integration.audit.toast.copyFailed": "Kopieren fehlgeschlagen",
32
31
  "integration.audit.toast.copied": "Kopiert",
32
+ "integration.audit.toast.copyFailed": "Kopieren fehlgeschlagen",
33
33
  "integration.canvas.badge.trigger": "Trigger",
34
34
  "integration.canvas.color.amber": "Bernstein",
35
35
  "integration.canvas.color.blue": "Blau",
@@ -170,6 +170,8 @@
170
170
  "integration.credentials.error.oauthConnect": "OAuth-Verbindung konnte nicht gestartet werden.",
171
171
  "integration.credentials.error.save": "Zugangsdaten konnten nicht gespeichert werden.",
172
172
  "integration.credentials.error.testFailed": "Verbindungstest fehlgeschlagen.",
173
+ "integration.credentials.list.action.connect": "Verbinden",
174
+ "integration.credentials.list.action.connect.aria": "{name} verbinden",
173
175
  "integration.credentials.list.action.delete.aria": "{name} löschen",
174
176
  "integration.credentials.list.action.new": "Zugangsdaten anlegen",
175
177
  "integration.credentials.list.action.reconnect": "Erneut verbinden",
@@ -244,6 +246,7 @@
244
246
  "integration.credentials.sftpImport.empty": "Keine aktiven Plattform-SFTP-Konten. Zuerst eines unter Integration Studio → SFTP anlegen.",
245
247
  "integration.credentials.sftpImport.error.importFailed": "Import fehlgeschlagen.",
246
248
  "integration.credentials.sftpImport.error.readPassword": "Das Kontopasswort konnte nicht gelesen werden.",
249
+ "integration.credentials.sftpImport.state.loadFailed": "Die Plattform-SFTP-Konten konnten nicht geladen werden: {message}",
247
250
  "integration.credentials.sftpImport.state.loading": "SFTP-Konten werden geladen…",
248
251
  "integration.credentials.sftpImport.title": "Plattform-SFTP-Konto importieren",
249
252
  "integration.credentials.shared.action.testConnection": "Verbindung testen",
@@ -377,13 +380,6 @@
377
380
  "integration.editor.dropGhost.label": "Loslassen zum Hinzufügen",
378
381
  "integration.editor.emptyState.hint": "Ziehen Sie einen Trigger und eine Aktion aus dem Panel links.",
379
382
  "integration.editor.emptyState.title": "Leere Arbeitsfläche",
380
- "integration.editor.errorPath.action.stop": "Fehlerausgang stoppen lassen",
381
- "integration.editor.errorPath.advice": "{n} Node leitet Fehler auf einen Ausgang, an dem nichts hängt — der Fehler wird dort nicht behandelt. | {n} Nodes leiten Fehler auf einen Ausgang, an dem nichts hängt — der Fehler wird dort nicht behandelt.",
382
- "integration.editor.errorPath.ariaFix": "Ausgang „{port}“ von {label} auf Stop und Fehler legen",
383
- "integration.editor.errorPath.ariaReveal": "{label} auf der Canvas zeigen — der Ausgang „{port}“ ist nicht verbunden",
384
- "integration.editor.errorPath.message": "{label} fehlgeschlagen: ${{ nodes.{nodeId}.outputs.{port}.message }}",
385
- "integration.editor.errorPath.more": "…und {n} weitere",
386
- "integration.editor.errorPath.toast.added": "Stop und Fehler an {label} gehängt — noch nicht gespeichert.",
387
383
  "integration.editor.event.toast.noneReceived": "Noch kein {topic}-Event empfangen.",
388
384
  "integration.editor.executionMode.async.hint": "Runs werden immer in die Warteschlange gestellt; der Trigger antwortet sofort.",
389
385
  "integration.editor.executionMode.async.label": "Nur asynchron",
@@ -983,10 +979,8 @@
983
979
  "integration.runs.toast.action.requested": "{action} angefordert",
984
980
  "integration.runs.toast.action.started": "Run #{id} gestartet",
985
981
  "integration.runs.toast.terminate.confirm": "Run #{id} terminieren?",
986
- "integration.schedules.cell.cronExpression.aria": "Cron-Ausdruck",
987
982
  "integration.schedules.cell.customSchedule": "Individueller Zeitplan",
988
983
  "integration.schedules.cell.noSchedule": "Kein Zeitplan festgelegt",
989
- "integration.schedules.cell.whenItFires.aria": "Wann er ausgelöst wird",
990
984
  "integration.schedules.cron.dailyAt": "Täglich um {time}",
991
985
  "integration.schedules.cron.day.daily": "Täglich",
992
986
  "integration.schedules.cron.day.weekdays": "Wochentags",
@@ -1229,6 +1223,7 @@
1229
1223
  "integration.workflows.state.action.remove.hint": "Das Entfernen nimmt diesem Workflow den Zugriff. Die gespeicherten Einträge bleiben bestehen.",
1230
1224
  "integration.workflows.state.dialog.description": "Was sich dieser Workflow zwischen Läufen merken darf. Er erreicht ausschließlich die hier deklarierten Namespaces — alles andere wird beim Lauf abgewiesen.",
1231
1225
  "integration.workflows.state.dialog.title": "Zustands-Namespaces",
1226
+ "integration.workflows.state.empty": "Dieser Workflow merkt sich noch nichts zwischen Läufen.",
1232
1227
  "integration.workflows.state.error.invalidName": "Kleinbuchstaben, Ziffern und . - _, beginnend mit einem Buchstaben — zum Beispiel article oder crm.customers.",
1233
1228
  "integration.workflows.state.error.nameExists": "Dieser Workflow deklariert diesen Namespace bereits.",
1234
1229
  "integration.workflows.state.field.newName.label": "Namespace deklarieren",
@@ -1246,7 +1241,6 @@
1246
1241
  "integration.workflows.state.role.mapping.hint": "Verknüpft je eine ID auf beiden Seiten, damit ein späterer Lauf weiß, ob er anlegen oder aktualisieren muss.",
1247
1242
  "integration.workflows.state.role.mapping.label": "Zuordnung",
1248
1243
  "integration.workflows.state.row.aria": "Zustands-Namespace {namespace}",
1249
- "integration.workflows.state.empty": "Dieser Workflow merkt sich noch nichts zwischen Läufen.",
1250
1244
  "integration.workflows.state.visibility.private.hint": "Nur dieser Workflow liest und schreibt ihn, und er wird mit dem Workflow gelöscht.",
1251
1245
  "integration.workflows.state.visibility.private.label": "Diesen Workflow",
1252
1246
  "integration.workflows.state.visibility.shared.hint": "Jeder Workflow dieses Mandanten liest und schreibt diese Einträge, und der Namespace bleibt bestehen, wenn dieser Workflow gelöscht wird.",
@@ -28,8 +28,8 @@
28
28
  "integration.audit.table.column.actor": "Actor",
29
29
  "integration.audit.table.column.resource": "Resource",
30
30
  "integration.audit.table.column.when": "When",
31
- "integration.audit.toast.copyFailed": "Could not copy",
32
31
  "integration.audit.toast.copied": "Copied",
32
+ "integration.audit.toast.copyFailed": "Could not copy",
33
33
  "integration.canvas.badge.trigger": "Trigger",
34
34
  "integration.canvas.color.amber": "Amber",
35
35
  "integration.canvas.color.blue": "Blue",
@@ -170,6 +170,8 @@
170
170
  "integration.credentials.error.oauthConnect": "Could not start OAuth connect.",
171
171
  "integration.credentials.error.save": "Could not save credential.",
172
172
  "integration.credentials.error.testFailed": "Connection test failed.",
173
+ "integration.credentials.list.action.connect": "Connect",
174
+ "integration.credentials.list.action.connect.aria": "Connect {name}",
173
175
  "integration.credentials.list.action.delete.aria": "Delete {name}",
174
176
  "integration.credentials.list.action.new": "New credential",
175
177
  "integration.credentials.list.action.reconnect": "Reconnect",
@@ -244,6 +246,7 @@
244
246
  "integration.credentials.sftpImport.empty": "No active platform SFTP accounts. Create one under Integration Studio → SFTP first.",
245
247
  "integration.credentials.sftpImport.error.importFailed": "Import failed.",
246
248
  "integration.credentials.sftpImport.error.readPassword": "Could not read the account password.",
249
+ "integration.credentials.sftpImport.state.loadFailed": "The platform SFTP accounts could not be loaded: {message}",
247
250
  "integration.credentials.sftpImport.state.loading": "Loading the SFTP accounts…",
248
251
  "integration.credentials.sftpImport.title": "Import platform SFTP account",
249
252
  "integration.credentials.shared.action.testConnection": "Connect",
@@ -377,13 +380,6 @@
377
380
  "integration.editor.dropGhost.label": "Drop to add",
378
381
  "integration.editor.emptyState.hint": "Drag a trigger and an action from the panel on the left.",
379
382
  "integration.editor.emptyState.title": "Empty canvas",
380
- "integration.editor.errorPath.action.stop": "Stop the run on error",
381
- "integration.editor.errorPath.advice": "{n} node routes failures to an output with nothing attached — nothing handles the failure there. | {n} nodes route failures to an output with nothing attached — nothing handles the failure there.",
382
- "integration.editor.errorPath.ariaFix": "Route {label}'s \"{port}\" output to Stop and Error",
383
- "integration.editor.errorPath.ariaReveal": "Show {label} on the canvas — its \"{port}\" output is not connected",
384
- "integration.editor.errorPath.message": "{label} failed: ${{ nodes.{nodeId}.outputs.{port}.message }}",
385
- "integration.editor.errorPath.more": "…and {n} more",
386
- "integration.editor.errorPath.toast.added": "Stop and Error attached to {label} — not saved yet.",
387
383
  "integration.editor.event.toast.noneReceived": "No {topic} event received yet.",
388
384
  "integration.editor.executionMode.async.hint": "Runs are always queued; the trigger returns immediately.",
389
385
  "integration.editor.executionMode.async.label": "Async only",
@@ -983,10 +979,8 @@
983
979
  "integration.runs.toast.action.requested": "{action} requested",
984
980
  "integration.runs.toast.action.started": "Started run #{id}",
985
981
  "integration.runs.toast.terminate.confirm": "Terminate run #{id}?",
986
- "integration.schedules.cell.cronExpression.aria": "Cron expression",
987
982
  "integration.schedules.cell.customSchedule": "Custom schedule",
988
983
  "integration.schedules.cell.noSchedule": "No schedule set",
989
- "integration.schedules.cell.whenItFires.aria": "When it fires",
990
984
  "integration.schedules.cron.dailyAt": "Daily at {time}",
991
985
  "integration.schedules.cron.day.daily": "Daily",
992
986
  "integration.schedules.cron.day.weekdays": "Weekdays",
@@ -1229,6 +1223,7 @@
1229
1223
  "integration.workflows.state.action.remove.hint": "Removing the declaration takes this workflow's access away. The stored entries stay where they are.",
1230
1224
  "integration.workflows.state.dialog.description": "What this workflow may remember between runs. It can only reach the namespaces declared here — anything else is refused when the workflow runs.",
1231
1225
  "integration.workflows.state.dialog.title": "State namespaces",
1226
+ "integration.workflows.state.empty": "This workflow remembers nothing between runs yet.",
1232
1227
  "integration.workflows.state.error.invalidName": "Use lowercase letters, digits and . - _, starting with a letter — for example article or crm.customers.",
1233
1228
  "integration.workflows.state.error.nameExists": "This workflow already declares that namespace.",
1234
1229
  "integration.workflows.state.field.newName.label": "Declare a namespace",
@@ -1246,7 +1241,6 @@
1246
1241
  "integration.workflows.state.role.mapping.hint": "Correlates an id on each side, so a later run knows whether to create or update.",
1247
1242
  "integration.workflows.state.role.mapping.label": "Mapping",
1248
1243
  "integration.workflows.state.row.aria": "State namespace {namespace}",
1249
- "integration.workflows.state.empty": "This workflow remembers nothing between runs yet.",
1250
1244
  "integration.workflows.state.visibility.private.hint": "Only this workflow reads and writes it, and it is deleted along with the workflow.",
1251
1245
  "integration.workflows.state.visibility.private.label": "This workflow",
1252
1246
  "integration.workflows.state.visibility.shared.hint": "Every workflow in this tenant reads and writes these entries, and the namespace stays when this workflow is deleted.",
@@ -32,7 +32,7 @@ import {
32
32
  } from "@revenexx/studio";
33
33
  import { toast } from "../../utils/notify";
34
34
  import { apiRefusal, errorReason } from "../../utils/apiError";
35
- import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
35
+ import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
38
  const t = useIntegrationsT();
@@ -51,6 +51,17 @@ function typeName(slug) {
51
51
  function isOAuthAuthcode(t2) {
52
52
  return t2?.auth_kind === "oauth2-authcode";
53
53
  }
54
+ function providerStep(c) {
55
+ if (!isOAuthAuthcode(typeFor(c.credential_type_slug))) return null;
56
+ const state = credentialState(c).key;
57
+ const word = providerStepWord(state);
58
+ if (!word) return null;
59
+ return {
60
+ label: t(`integration.credentials.list.action.${word}`),
61
+ aria: t(`integration.credentials.list.action.${word}.aria`, { name: c.name }),
62
+ expired: state === "needs_reauth"
63
+ };
64
+ }
54
65
  function formatDateTime(iso) {
55
66
  if (!iso) return "\u2014";
56
67
  try {
@@ -306,15 +317,16 @@ async function confirmDelete() {
306
317
  <template #cell-actions="{ row: c }">
307
318
  <div class="flex items-center justify-end gap-2 text-sm" @click.stop>
308
319
  <Button
309
- v-if="c.status === 'needs_reauth' && isOAuthAuthcode(typeFor(c.credential_type_slug))"
320
+ v-if="providerStep(c)"
310
321
  variant="outline"
311
322
  size="sm"
312
- class="h-7 text-amber-600"
323
+ class="h-7"
324
+ :class="providerStep(c).expired ? 'text-amber-600' : ''"
313
325
  :disabled="connecting === c.id"
314
- :aria-label="t('integration.credentials.list.action.reconnect.aria', { name: c.name })"
326
+ :aria-label="providerStep(c).aria"
315
327
  @click="connect(c)"
316
328
  >
317
- {{ t("integration.credentials.list.action.reconnect") }}
329
+ {{ providerStep(c).label }}
318
330
  </Button>
319
331
  <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" :title="t('integration.credentials.list.action.delete.aria', { name: c.name })" :aria-label="t('integration.credentials.list.action.delete.aria', { name: c.name })" @click="pendingDelete = c">
320
332
  <TrashBin :size="13" />
@@ -147,16 +147,16 @@ const columns = computed(() => [
147
147
  at several named times is a long sentence, and half of one is
148
148
  the wrong half. Its title is the expression, so there is no
149
149
  tooltip left to carry the rest of the words. -->
150
- <div v-if="describeCron(e.cron)" :aria-label="t('integration.schedules.cell.whenItFires.aria')" :title="e.cron ?? void 0" class="text-sm leading-snug">
150
+ <div v-if="describeCron(e.cron)" :title="e.cron ?? void 0" class="text-sm leading-snug">
151
151
  {{ describeCron(e.cron) }}
152
152
  </div>
153
153
  <template v-else-if="e.cron">
154
- <div :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm truncate">
154
+ <div class="text-sm truncate">
155
155
  {{ t("integration.schedules.cell.customSchedule") }}
156
156
  </div>
157
- <code :aria-label="t('integration.schedules.cell.cronExpression.aria')" class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
157
+ <code class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
158
158
  </template>
159
- <div v-else :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm text-muted-foreground truncate">
159
+ <div v-else class="text-sm text-muted-foreground truncate">
160
160
  {{ t("integration.schedules.cell.noSchedule") }}
161
161
  </div>
162
162
  </div>
@@ -170,12 +170,13 @@ const columns = computed(() => [
170
170
  </template>
171
171
 
172
172
  <!--
173
- The two time cells carry the name their column header states, so each is
174
- addressable by what it is rather than by how it is typeset. It is not the
175
- accessible name a screen reader hears — the cell is a plain span, and
176
- ARIA prohibits naming a generic element — that needs row and cell
177
- semantics in the shared table (PO-233, and a gap in
178
- `specs/accessibility.md`).
173
+ Neither time cell carries a name of its own, and that is the fix rather
174
+ than an omission. Since studio-shared 0.3.2 the cell is a `role="cell"`
175
+ under a `columnheader`, so a screen reader reads "Next run, in 5h" from
176
+ the table itself. A name written here would be read *instead of* the
177
+ value — the row announced "Next run" where the time should be — so the
178
+ labels these cells used to carry to be addressable by a test were
179
+ removed with the semantics that replaced them (PO-233).
179
180
  -->
180
181
  <!--
181
182
  The instant the platform holds, shown as a distance from now, with the
@@ -187,7 +188,6 @@ const columns = computed(() => [
187
188
  -->
188
189
  <template #cell-nextRun="{ row: e }">
189
190
  <span
190
- :aria-label="t('integration.schedules.table.column.nextRun')"
191
191
  :title="e.nextRunLocal ?? void 0"
192
192
  class="text-sm tabular-nums"
193
193
  >{{ formatRelative(e.nextRunAt, e.timezone) }}</span>
@@ -202,7 +202,6 @@ const columns = computed(() => [
202
202
  -->
203
203
  <template #cell-lastRun="{ row: e }">
204
204
  <span
205
- :aria-label="t('integration.schedules.table.column.lastRun')"
206
205
  :title="e.lastRunAt ?? void 0"
207
206
  class="text-sm tabular-nums text-muted-foreground"
208
207
  >{{ lastRunText(e) }}</span>
@@ -46,8 +46,6 @@ 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";
51
49
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
52
50
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
53
51
  const t = useIntegrationsT();
@@ -110,27 +108,6 @@ async function revealNode(nodeId) {
110
108
  await nextTick();
111
109
  editorRef.value?.revealNode(nodeId);
112
110
  }
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
- }
134
111
  const { load: loadNodeCatalog } = useIntegrationsNodes();
135
112
  onMounted(() => {
136
113
  void loadNodeCatalog();
@@ -200,7 +177,6 @@ watch(id, async () => {
200
177
  if (!w) await leaveTo("/integrations/workflows");
201
178
  draft.value = null;
202
179
  warningsExpanded.value = false;
203
- adviceExpanded.value = false;
204
180
  if (tab.value === "runs") void loadRuns(1);
205
181
  settleBuildStatus();
206
182
  }, { immediate: true });
@@ -1017,62 +993,6 @@ function formatDateTime(iso) {
1017
993
  </ul>
1018
994
  </div>
1019
995
 
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
-
1076
996
  <!-- Only the FAILED build state banners (its message matters); the
1077
997
  working states live as the header chip — and inside a run the
1078
998
  workflow's build state is noise, so nothing shows at all. -->
@@ -65,6 +65,23 @@
65
65
  * Two members exist here only because the code reads them off values the cockpit
66
66
  * may hand back as `null`: `getCredential` and `createCredential` both resolve to
67
67
  * `MailCredential | null`, and the callers have to say what they do about it.
68
+ *
69
+ * **What "narrowed to the members it reads" costs when it is wrong, measured on
70
+ * `sftp` (PO-387).** This declaration was five members behind what
71
+ * `SftpAccountImportDialog.vue` actually reads — `loading`, `activeTenantSlug`,
72
+ * an optional rather than required argument to `load`, and `server` and
73
+ * `home_directory` on an account. The cockpit has had all five throughout
74
+ * (`app/composables/useConsoleSftp.ts`, `app/types/consoleSftp.ts`), so nothing
75
+ * was ever broken for an operator; what was broken is that the playground's
76
+ * double is built from *this* file, so a host honouring the written contract
77
+ * threw `TypeError: Cannot read properties of undefined (reading 'value')` while
78
+ * drawing that dialog. **Vue's error handler turns such a throw into a
79
+ * `console.error` and unmounts the tree, so no `pageerror` fires** — the suite's
80
+ * usual way of catching this class (`e2e/credential-managed-source.spec.ts`,
81
+ * `pageErrors`) cannot see it, and the only evidence is a blank page. The dialog
82
+ * now takes its type from here (`Pick<CockpitSources['sftp'], …>`) rather than
83
+ * declaring its own, so the two cannot disagree again; nothing yet compares
84
+ * either against the cockpit, which is [PO-310](https://linear.app/revenexx/issue/PO-310).
68
85
  */
69
86
  export interface CockpitSources {
70
87
  tenants: {
@@ -102,7 +119,53 @@ export interface CockpitSources {
102
119
  accounts: {
103
120
  value: CockpitSftpAccount[];
104
121
  };
105
- load: (tenant: string | null) => Promise<unknown>;
122
+ /**
123
+ * True while the cockpit's **account fetch** is in flight — which is narrower
124
+ * than "this source is busy", and a caller that needs the wider question has
125
+ * to keep its own answer beside this one.
126
+ *
127
+ * `useConsoleSftp.load()` resolves the tenant above the flag
128
+ * (`if (!tenantSlug) await loadTenants()` sits before `loading.value = true`),
129
+ * and leaves by two paths that never set it at all: no tenant resolved, and a
130
+ * load already in flight for another surface. `SftpAccountImportDialog.vue`
131
+ * learned this the expensive way — it drew its "there are none, go and create
132
+ * one" empty state throughout the tenants fetch on every first open.
133
+ */
134
+ loading: {
135
+ value: boolean;
136
+ };
137
+ /**
138
+ * Why the last account fetch failed, or `null`.
139
+ *
140
+ * The cockpit catches its own fetch errors — `load` never rejects — so this
141
+ * is the **only** way a caller learns the list is missing rather than empty.
142
+ * Without it a failed load and a tenant that owns no account are the same
143
+ * screen, and that screen tells the operator to go and create one.
144
+ *
145
+ * **Optional, and the only member here that is.** The standalone dev studio's
146
+ * stand-ins provide the other four and not this one
147
+ * (`integrations/studio-ui/app/composables/consoleStubs.ts`), so a required
148
+ * member would be this file's own defect a second time — PO-387 is exactly a
149
+ * dialog that dereferenced what a host did not have. Its absence costs only
150
+ * the message: a caller reading it with `?.` falls back to the empty state,
151
+ * which is what such a host showed before. A host that means to keep
152
+ * `specs/node-configuration.md` AC-34 has to provide it.
153
+ *
154
+ * It is also **not a verdict on the load that just returned.** It is a
155
+ * `useState` singleton shared by every surface in the host, and both early
156
+ * returns described under `loading` leave it at whatever it already held —
157
+ * so it can be stale and can be somebody else's. `SftpAccountImportDialog.vue`
158
+ * shows how much has to be true around it before it is worth repeating.
159
+ */
160
+ error?: {
161
+ value: string | null;
162
+ };
163
+ /** The tenant the accounts belong to — the org's only one, or `null`. */
164
+ activeTenantSlug: {
165
+ value: string | null;
166
+ };
167
+ /** The argument is **optional**: omitted, the cockpit falls back to `activeTenantSlug`. */
168
+ load: (tenant?: string | null) => Promise<unknown>;
106
169
  /** `null` while the account is still provisioning (the Console answers 404). */
107
170
  revealPassword: (tenant: string, id: number) => Promise<{
108
171
  username: string;
@@ -131,6 +194,10 @@ export interface CockpitSftpAccount {
131
194
  id: number;
132
195
  /** The gateway login. `null` until the account finished provisioning. */
133
196
  login_username: string | null;
197
+ /** The gateway host to connect to. */
198
+ server: string | null;
199
+ /** Where on the gateway the account lands; shown instead of `server` when set. */
200
+ home_directory: string | null;
134
201
  status: string;
135
202
  is_active: boolean;
136
203
  error_message: string | null;
@@ -28,6 +28,21 @@ export interface CredentialStateInfo {
28
28
  }
29
29
  type TestableCredential = Pick<Credential, 'status' | 'last_test_ok' | 'last_test_at' | 'last_test_message'>;
30
30
  /** Every state a credential can be in, in the order a reader is offered them. */
31
+ /**
32
+ * Which of the two words a three-legged credential's provider step is offered under,
33
+ * or `null` where its state gives no reason to offer one at all.
34
+ *
35
+ * The distinction is whether anything has ever proved this credential connected. Only
36
+ * a credential nothing has — never tested, and for an `oauth2-authcode` type that is
37
+ * what a provider window which failed to open leaves behind — is offered the step as
38
+ * *connecting*. One whose test failed, or whose authorisation expired, was working at
39
+ * some point, so it is offered *reconnecting*: the word says which of the two happened
40
+ * (PO-478).
41
+ *
42
+ * It answers for a state rather than a credential because both surfaces that offer the
43
+ * step derive that state first, and the word may not disagree between them.
44
+ */
45
+ export declare function providerStepWord(state: CredentialConnState): 'connect' | 'reconnect' | null;
31
46
  export declare const CREDENTIAL_STATES: readonly ["connected", "failed", "needs_reauth", "untested"];
32
47
  /**
33
48
  * What a state is called for the reader, keyed rather than derived from a
@@ -1,4 +1,8 @@
1
1
  import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
+ export function providerStepWord(state) {
3
+ if (state === "connected") return null;
4
+ return state === "untested" ? "connect" : "reconnect";
5
+ }
2
6
  export const CREDENTIAL_STATES = ["connected", "failed", "needs_reauth", "untested"];
3
7
  const MESSAGE_KEY = {
4
8
  connected: "integration.credentials.state.connected",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "1.4.1",
3
+ "version": "2.0.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": {
@@ -26,13 +26,13 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@nuxt/kit": "^4.0.0",
29
- "@revenexx/studio-shared": "^0.3.0",
29
+ "@revenexx/studio-shared": "^0.3.2",
30
30
  "@solar-icons/nuxt": "^1.2.0",
31
31
  "marked": "^18.0.5"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@dagrejs/dagre": "^3.0.0",
35
- "@revenexx/studio": "*",
35
+ "@revenexx/studio": "^0.2.3",
36
36
  "@tiptap/core": "^3.28.0",
37
37
  "@tiptap/extension-document": "^3.28.0",
38
38
  "@tiptap/extension-history": "^3.28.0",
@@ -1,60 +0,0 @@
1
- import type { WorkflowBlob } from '../types/integrations.js';
2
- /**
3
- * Nodes that route failures to an error output nothing is connected to
4
- * (specs/workflow-advice.md).
5
- *
6
- * A step that fails on a path its author drew is handled. A step that fails on a
7
- * path nobody drew is not: the engine ends the run there
8
- * (`specs/node-failure-handling.md` AC-8 in the service repository), and before
9
- * it did, the failure vanished and the run reported success. Either way the
10
- * author never chose it — they simply never saw the port.
11
- *
12
- * ## Why this is advice and not a finding
13
- *
14
- * The backend's validation findings feed the activation gate AND an
15
- * unconditional refusal to start a run. A finding here would therefore stop
16
- * every workflow with an unwired error port from running at all — turning
17
- * "delivers nothing quietly" into "does not start", for authors who never asked
18
- * for either. Computing it in the editor makes that impossible rather than
19
- * merely unlikely: there is no value for a blocking consumer to read.
20
- *
21
- * What the client cannot do is be authoritative — the catalogue may not be
22
- * loaded, and a workflow built through the API never passes here at all. For
23
- * advice that is the right trade; for a gate it would not be, which is why the
24
- * gate stays where the manifests are.
25
- *
26
- * ## Deliberately not in `validationFindings.ts`
27
- *
28
- * Everything there is a backend finding addressed by blob path, and the node
29
- * card counts those as *incomplete settings*. An unwired port is neither. Sent
30
- * through `groupFindingsByNode` it would type-check perfectly and quietly
31
- * corrupt two counts — the banner's and the card badge's.
32
- */
33
- export interface ErrorPathAdvice {
34
- /** Blob node id — not an index, so it survives a node being added or removed. */
35
- nodeId: string;
36
- /** The error output with nothing attached. */
37
- port: string;
38
- }
39
- /**
40
- * Every unwired error output in the blob, in node order.
41
- *
42
- * Runs against the blob the author is *editing*, not the saved one, so wiring a
43
- * port clears its line in the same tick — no save, and no second copy of this
44
- * rule to decide when the advice went stale.
45
- *
46
- * **Resolved against whichever manifest version the catalogue has.**
47
- * `findManifest` falls back to another version of the same slug when the pinned
48
- * one is absent, and that fallback is accepted here: a node pinned to a version
49
- * nobody serves any more is better described by a sibling version than by
50
- * silence. The cost is real though — where the versions disagree about their
51
- * ports, the advice describes the sibling's, so a port could be named that the
52
- * author's own node does not have.
53
- *
54
- * **Reads the manifest's static outputs only.** A node whose ports are named at
55
- * author time (`sourceFromConfig` / `resolveOutputs`) cannot be judged from the
56
- * declaration alone, so nothing is claimed about it — the same forbearance the
57
- * engine shows. No shipped node has a dynamic error port today; if one appears,
58
- * this is where it would silently escape notice.
59
- */
60
- export declare function unwiredErrorPorts(blob: Pick<WorkflowBlob, 'nodes' | 'edges'>): ErrorPathAdvice[];
@@ -1,19 +0,0 @@
1
- import { findManifest } from "../data/integrationNodeCatalog.js";
2
- export function unwiredErrorPorts(blob) {
3
- const advice = [];
4
- for (const node of blob.nodes) {
5
- const manifest = findManifest(node.nodeSlug, node.nodeVersion);
6
- if (!manifest) continue;
7
- const errorPorts = (manifest.outputs ?? []).filter((o) => o.kind === "error" && o.name).map((o) => o.name);
8
- if (!errorPorts.length) continue;
9
- if (!isReachable(node, blob, errorPorts)) continue;
10
- for (const port of errorPorts) {
11
- const wired = blob.edges.some((e) => e.from.nodeId === node.id && e.from.output === port);
12
- if (!wired) advice.push({ nodeId: node.id, port });
13
- }
14
- }
15
- return advice;
16
- }
17
- function isReachable(node, blob, errorPorts) {
18
- return blob.edges.some((e) => e.to.nodeId === node.id) || blob.edges.some((e) => e.from.nodeId === node.id && !errorPorts.includes(e.from.output));
19
- }