@revenexx/studio-integrations 1.4.0 → 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.
@@ -18,20 +18,20 @@ export function apiValidationIssues(err) {
18
18
  }
19
19
  return issues;
20
20
  }
21
- export function apiFieldErrors(err) {
22
- if (!(err instanceof IntegrationsApiError) || err.status !== 422) return {};
21
+ export function apiRefusal(err, drawn) {
22
+ const refusal = { atFields: {}, unshown: [] };
23
+ if (!(err instanceof IntegrationsApiError) || err.status !== 422) return refusal;
23
24
  const errors = err.body?.errors;
24
- if (!errors || typeof errors !== "object") return {};
25
- const out = {};
26
- for (const [key, val] of Object.entries(errors)) {
27
- if (key === "blob" || key.startsWith("blob.")) continue;
28
- const first = Array.isArray(val) ? val[0] : val;
29
- if (first !== void 0 && first !== null) out[key] = String(first);
30
- }
31
- for (const key of Object.keys(out)) {
32
- if (Object.keys(out).some((other) => other.startsWith(`${key}.`))) delete out[key];
25
+ if (!errors || typeof errors !== "object") return refusal;
26
+ const keys = Object.keys(errors).filter((key) => key !== "blob" && !key.startsWith("blob."));
27
+ for (const key of keys) {
28
+ if (keys.some((other) => other.startsWith(`${key}.`))) continue;
29
+ const messages = (Array.isArray(errors[key]) ? errors[key] : [errors[key]]).filter((message) => message !== void 0 && message !== null).map(String);
30
+ if (!messages.length) continue;
31
+ if (drawn.includes(key)) refusal.atFields[key] = messages[0];
32
+ else refusal.unshown.push(...messages.map((message) => ({ key, message })));
33
33
  }
34
- return out;
34
+ return refusal;
35
35
  }
36
36
  export function errorReason(err, fallback) {
37
37
  const raw = err?.message?.trim();
@@ -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.0",
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
- }