@elastic.io/mcp 1.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/LICENSE +191 -0
- package/README.md +171 -0
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +124 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/jsonapi.d.ts +28 -0
- package/dist/api/jsonapi.d.ts.map +1 -0
- package/dist/api/jsonapi.js +45 -0
- package/dist/api/jsonapi.js.map +1 -0
- package/dist/api/logger.d.ts +9 -0
- package/dist/api/logger.d.ts.map +1 -0
- package/dist/api/logger.js +46 -0
- package/dist/api/logger.js.map +1 -0
- package/dist/api/samples.d.ts +21 -0
- package/dist/api/samples.d.ts.map +1 -0
- package/dist/api/samples.js +465 -0
- package/dist/api/samples.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +49 -0
- package/dist/config.js.map +1 -0
- package/dist/embeddings/factory.d.ts +7 -0
- package/dist/embeddings/factory.d.ts.map +1 -0
- package/dist/embeddings/factory.js +25 -0
- package/dist/embeddings/factory.js.map +1 -0
- package/dist/embeddings/google.d.ts +19 -0
- package/dist/embeddings/google.d.ts.map +1 -0
- package/dist/embeddings/google.js +132 -0
- package/dist/embeddings/google.js.map +1 -0
- package/dist/embeddings/types.d.ts +27 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/embeddings/types.js +2 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +226 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +361 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/confirmation.d.ts +41 -0
- package/dist/tools/confirmation.d.ts.map +1 -0
- package/dist/tools/confirmation.js +120 -0
- package/dist/tools/confirmation.js.map +1 -0
- package/dist/tools/constants.d.ts +31 -0
- package/dist/tools/constants.d.ts.map +1 -0
- package/dist/tools/constants.js +85 -0
- package/dist/tools/constants.js.map +1 -0
- package/dist/tools/definitions.d.ts +3 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +883 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/handlers/auth.d.ts +3 -0
- package/dist/tools/handlers/auth.d.ts.map +1 -0
- package/dist/tools/handlers/auth.js +540 -0
- package/dist/tools/handlers/auth.js.map +1 -0
- package/dist/tools/handlers/component.d.ts +3 -0
- package/dist/tools/handlers/component.d.ts.map +1 -0
- package/dist/tools/handlers/component.js +145 -0
- package/dist/tools/handlers/component.js.map +1 -0
- package/dist/tools/handlers/data-sample.d.ts +3 -0
- package/dist/tools/handlers/data-sample.d.ts.map +1 -0
- package/dist/tools/handlers/data-sample.js +51 -0
- package/dist/tools/handlers/data-sample.js.map +1 -0
- package/dist/tools/handlers/dynamic.d.ts +3 -0
- package/dist/tools/handlers/dynamic.d.ts.map +1 -0
- package/dist/tools/handlers/dynamic.js +110 -0
- package/dist/tools/handlers/dynamic.js.map +1 -0
- package/dist/tools/handlers/flow.d.ts +9 -0
- package/dist/tools/handlers/flow.d.ts.map +1 -0
- package/dist/tools/handlers/flow.js +899 -0
- package/dist/tools/handlers/flow.js.map +1 -0
- package/dist/tools/handlers/log.d.ts +3 -0
- package/dist/tools/handlers/log.d.ts.map +1 -0
- package/dist/tools/handlers/log.js +61 -0
- package/dist/tools/handlers/log.js.map +1 -0
- package/dist/tools/handlers/organization.d.ts +3 -0
- package/dist/tools/handlers/organization.d.ts.map +1 -0
- package/dist/tools/handlers/organization.js +39 -0
- package/dist/tools/handlers/organization.js.map +1 -0
- package/dist/tools/handlers/raw.d.ts +3 -0
- package/dist/tools/handlers/raw.d.ts.map +1 -0
- package/dist/tools/handlers/raw.js +77 -0
- package/dist/tools/handlers/raw.js.map +1 -0
- package/dist/tools/handlers/search.d.ts +3 -0
- package/dist/tools/handlers/search.d.ts.map +1 -0
- package/dist/tools/handlers/search.js +82 -0
- package/dist/tools/handlers/search.js.map +1 -0
- package/dist/tools/handlers/session.d.ts +3 -0
- package/dist/tools/handlers/session.d.ts.map +1 -0
- package/dist/tools/handlers/session.js +15 -0
- package/dist/tools/handlers/session.js.map +1 -0
- package/dist/tools/handlers.d.ts +12 -0
- package/dist/tools/handlers.d.ts.map +1 -0
- package/dist/tools/handlers.js +197 -0
- package/dist/tools/handlers.js.map +1 -0
- package/dist/tools/helpers.d.ts +45 -0
- package/dist/tools/helpers.d.ts.map +1 -0
- package/dist/tools/helpers.js +195 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/types.d.ts +32 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/validation.d.ts +24 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/tools/validation.js +748 -0
- package/dist/tools/validation.js.map +1 -0
- package/dist/types/index.d.ts +47 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/vectorstore/factory.d.ts +7 -0
- package/dist/vectorstore/factory.d.ts.map +1 -0
- package/dist/vectorstore/factory.js +25 -0
- package/dist/vectorstore/factory.js.map +1 -0
- package/dist/vectorstore/qdrant.d.ts +11 -0
- package/dist/vectorstore/qdrant.d.ts.map +1 -0
- package/dist/vectorstore/qdrant.js +107 -0
- package/dist/vectorstore/qdrant.js.map +1 -0
- package/dist/vectorstore/types.d.ts +28 -0
- package/dist/vectorstore/types.d.ts.map +1 -0
- package/dist/vectorstore/types.js +2 -0
- package/dist/vectorstore/types.js.map +1 -0
- package/package.json +51 -0
- package/resources/docs/component-test-scenarios.md +2468 -0
- package/resources/docs/credential-validation-gaps.md +35 -0
- package/resources/docs/debug-issues.md +71 -0
- package/resources/docs/deployment-and-scaling.md +136 -0
- package/resources/docs/flow-building-guide.md +565 -0
- package/resources/docs/packaging-plan.md +84 -0
- package/resources/docs/standardized-components-guide.md +66 -0
- package/resources/docs/system-instructions.md +177 -0
- package/resources/docs/unique-components.md +60 -0
- package/resources/docs/verified-creds-flow-ids.md +193 -0
- package/resources/flow-examples/batch-flow-1-collect.json +51 -0
- package/resources/flow-examples/batch-flow-2-process.json +70 -0
- package/resources/flow-examples/code-component-transform.json +65 -0
- package/resources/flow-examples/code-component-trigger.json +52 -0
- package/resources/flow-examples/code-component-with-credentials-trigger.json +53 -0
- package/resources/flow-examples/code-component-with-credentials.json +67 -0
- package/resources/flow-examples/filter-step.json +63 -0
- package/resources/flow-examples/polling-sync-trigger.json +50 -0
- package/resources/flow-examples/router-branching.json +94 -0
- package/resources/flow-examples/splitter-re-assemble.json +72 -0
- package/resources/flow-examples/splitter-split.json +68 -0
- package/resources/flow-examples/timer-query-action-sync.json +68 -0
- package/resources/flow-examples/webhook-async-sync.json +49 -0
- package/resources/flow-examples/webhook-request-reply.json +63 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { logger } from '../api/logger.js';
|
|
2
|
+
import { resolveCredentialId } from './helpers.js';
|
|
3
|
+
import { DEPRECATED_COMPONENTS, DEPRECATED_ACTIONS, BARE_FIELD_NAMES, UNSUPPORTED_ROUTER_JSONATA_FUNCTIONS, REST_API_READER_FIELDS, PLACEHOLDER_FIELD_VALUES, } from './constants.js';
|
|
4
|
+
export { DEPRECATED_COMPONENTS, DEPRECATED_ACTIONS } from './constants.js';
|
|
5
|
+
export function checkFlowLifecycle(flowId, discoveryState, existingFlow, operation) {
|
|
6
|
+
const errors = [];
|
|
7
|
+
if (!discoveryState.inspectedFlows.has(flowId)) {
|
|
8
|
+
errors.push(`You have not inspected flow '${flowId}' using 'get_flow' in this session. ` +
|
|
9
|
+
`Playbook B requires you to call 'get_flow' FIRST to inspect the current graph before making modifications. ` +
|
|
10
|
+
`Call 'get_flow' with flowId '${flowId}' and review the result before retrying.`);
|
|
11
|
+
}
|
|
12
|
+
if (existingFlow) {
|
|
13
|
+
const status = existingFlow.status || existingFlow.attributes?.status;
|
|
14
|
+
if (status === 'active') {
|
|
15
|
+
errors.push(`Flow '${flowId}' is currently ACTIVE (status: "${status}"). ` +
|
|
16
|
+
`Playbook B requires you to call 'stop_flow' and wait until the flow is inactive BEFORE applying modifications. ` +
|
|
17
|
+
`Call 'stop_flow' with flowId '${flowId}', then poll 'get_flow' until the status is "inactive", then retry.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return errors;
|
|
21
|
+
}
|
|
22
|
+
export function checkFlowBuildingGuideRead(discoveryState) {
|
|
23
|
+
if (!discoveryState.flowBuildingGuideRead) {
|
|
24
|
+
return [
|
|
25
|
+
`You have NOT read the 'elasticio://docs/flow-building-guide' resource in this session. ` +
|
|
26
|
+
`Rule 5 requires you to read this resource BEFORE creating or updating any flow to retrieve the correct JSON payload structures, schemas, and mapping rules. ` +
|
|
27
|
+
`Read the resource 'elasticio://docs/flow-building-guide' first, then retry.`
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
export function stripStringLiterals(str) {
|
|
33
|
+
let result = '';
|
|
34
|
+
let inDoubleQuote = false;
|
|
35
|
+
let inSingleQuote = false;
|
|
36
|
+
let escaped = false;
|
|
37
|
+
for (let i = 0; i < str.length; i++) {
|
|
38
|
+
const char = str[i];
|
|
39
|
+
if (escaped) {
|
|
40
|
+
escaped = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === '\\') {
|
|
44
|
+
escaped = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === '"' && !inSingleQuote) {
|
|
48
|
+
inDoubleQuote = !inDoubleQuote;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === "'" && !inDoubleQuote) {
|
|
52
|
+
inSingleQuote = !inSingleQuote;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!inDoubleQuote && !inSingleQuote) {
|
|
56
|
+
result += char;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate REST API reader fields on a node (url quoting, headers format, body format).
|
|
63
|
+
* Shared between validateFlowGraph (pre-submit) and healthCheckFlow (post-submit).
|
|
64
|
+
*/
|
|
65
|
+
function validateRestApiReader(reader, stepLabel, errors, warnings) {
|
|
66
|
+
// Check for plain URLs in JSONata-mode fields
|
|
67
|
+
for (const [fieldKey, fieldValue] of Object.entries(reader)) {
|
|
68
|
+
if (typeof fieldValue === 'string') {
|
|
69
|
+
const trimmedVal = fieldValue.trim();
|
|
70
|
+
if (/^https?:\/\//i.test(trimmedVal)) {
|
|
71
|
+
errors.push(`${stepLabel} field 'reader.${fieldKey}' contains a plain URL "${trimmedVal}" which is invalid JSONata syntax ` +
|
|
72
|
+
`(the ":" in the protocol causes a parse error). ` +
|
|
73
|
+
`This field uses JSONata Mode — wrap the URL in escaped double quotes so it is treated as a string literal: ` +
|
|
74
|
+
`"\\\"${trimmedVal}\\\"".`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Headers must be array of {key, value}, not a plain object
|
|
79
|
+
if (reader.headers && !Array.isArray(reader.headers)) {
|
|
80
|
+
errors.push(`${stepLabel} field 'reader.headers' is a plain object, but the REST API V2 component (RESTAPIView) ` +
|
|
81
|
+
`requires headers as an array of {key, value} pairs: [{"key": "Content-Type", "value": "\"application/json\"", "frozen": true}]. ` +
|
|
82
|
+
`Passing a plain object causes "TypeError: r.map is not a function" in the frontend.`);
|
|
83
|
+
}
|
|
84
|
+
// Body format for POST/PUT/PATCH
|
|
85
|
+
const method = reader.method;
|
|
86
|
+
if (method && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
|
87
|
+
const body = reader.body;
|
|
88
|
+
if (!body) {
|
|
89
|
+
errors.push(`${stepLabel} uses HTTP method '${method}' but 'reader.body' is missing. ` +
|
|
90
|
+
`For POST/PUT/PATCH requests, you MUST include a body object: ` +
|
|
91
|
+
`{"raw": "<JSONata expression or static JSON>", "formData": [], "urlencoded": [], "contentType": "application/json"}.`);
|
|
92
|
+
}
|
|
93
|
+
else if (typeof body === 'string') {
|
|
94
|
+
errors.push(`${stepLabel} field 'reader.body' is a plain string, but it MUST be an object: ` +
|
|
95
|
+
`{"raw": "${body}", "formData": [], "urlencoded": [], "contentType": "application/json"}. ` +
|
|
96
|
+
`Passing a string causes "TypeError: Cannot create property 'raw' on string" in the frontend.`);
|
|
97
|
+
}
|
|
98
|
+
else if (typeof body === 'object') {
|
|
99
|
+
if (body.raw === undefined) {
|
|
100
|
+
errors.push(`${stepLabel} field 'reader.body' is missing the 'raw' property. ` +
|
|
101
|
+
`Body must include: {"raw": "<content>", "formData": [], "urlencoded": [], "contentType": "application/json"}.`);
|
|
102
|
+
}
|
|
103
|
+
else if (body.raw === '' || body.raw === null) {
|
|
104
|
+
warnings.push(`${stepLabel} field 'reader.body.raw' is empty — the ${method} request will send no data. ` +
|
|
105
|
+
`Set it to a JSONata expression (e.g., "$getPassthrough().step_X.body") or a static JSON string.`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(body.formData)) {
|
|
108
|
+
errors.push(`${stepLabel} field 'reader.body.formData' must be an array (use [] if not needed).`);
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(body.urlencoded)) {
|
|
111
|
+
errors.push(`${stepLabel} field 'reader.body.urlencoded' must be an array (use [] if not needed).`);
|
|
112
|
+
}
|
|
113
|
+
if (!body.contentType) {
|
|
114
|
+
errors.push(`${stepLabel} field 'reader.body.contentType' is missing. Set it to the body content type (e.g., "application/json").`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Validate all nodes in a flow graph: id, command format, deprecated components/actions, fields. */
|
|
120
|
+
function validateNodes(nodes, nodeMap, errors, warnings) {
|
|
121
|
+
for (const node of nodes) {
|
|
122
|
+
if (!node.id) {
|
|
123
|
+
errors.push("Node definition is missing the 'id' field.");
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
nodeMap.set(node.id, node);
|
|
127
|
+
const command = node.command;
|
|
128
|
+
if (!command) {
|
|
129
|
+
errors.push(`Node '${node.id}' is missing the 'command' field.`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const formatRegex = /^[^/]+\/[^:]+:[^@]+@[^@]+$/;
|
|
133
|
+
if (!formatRegex.test(command)) {
|
|
134
|
+
errors.push(`Node '${node.id}' has an invalid command format: "${command}". ` +
|
|
135
|
+
`It must be formatted as "{team_name}/{repo_name}:{action_or_trigger_id}@{latestVersionId}" ` +
|
|
136
|
+
`(e.g., "elasticio/salesforce-v2:getUpdatedObjectsPolling@latestVersionId"). ` +
|
|
137
|
+
`Retrieve this from the 'get_component' tool response.`);
|
|
138
|
+
}
|
|
139
|
+
// Check for deprecated components by command prefix
|
|
140
|
+
const prefixMatch = command.match(/^([^:]+):/);
|
|
141
|
+
if (prefixMatch) {
|
|
142
|
+
const commandPrefix = prefixMatch[1];
|
|
143
|
+
const successor = DEPRECATED_COMPONENTS[commandPrefix];
|
|
144
|
+
if (successor) {
|
|
145
|
+
errors.push(`Node '${node.id}' uses deprecated component '${commandPrefix}'. ` +
|
|
146
|
+
`This component is no longer supported and may not work correctly in the UI. ` +
|
|
147
|
+
`Use '${successor}' instead. Call 'list_components' to find the replacement.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Check for deprecated actions/triggers by action ID
|
|
151
|
+
const actionMatch = command.match(/^[^:]+:([^@]+)@/);
|
|
152
|
+
if (actionMatch) {
|
|
153
|
+
const actionId = actionMatch[1];
|
|
154
|
+
const replacement = DEPRECATED_ACTIONS[actionId];
|
|
155
|
+
if (replacement) {
|
|
156
|
+
errors.push(`Node '${node.id}' uses deprecated action '${actionId}'. ` +
|
|
157
|
+
`This action is marked as deprecated in the component descriptor and shows a "Deprecated" badge in the UI. ` +
|
|
158
|
+
`Use '${replacement}' instead. Call 'get_component' to verify available non-deprecated actions.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Validate JSONata-mode node fields (e.g. Splitter's "expression") for bare references
|
|
163
|
+
// that should use $getPassthrough() paths
|
|
164
|
+
if (node.fields && node.component_id) {
|
|
165
|
+
for (const [fieldKey, fieldValue] of Object.entries(node.fields)) {
|
|
166
|
+
if (typeof fieldValue === 'string') {
|
|
167
|
+
const trimmedField = fieldValue.trim();
|
|
168
|
+
// Catch bare "body" or "body.something" that should be $getPassthrough().step_id.body
|
|
169
|
+
if (/^body(\.[a-zA-Z_]\w*)*$/.test(trimmedField)) {
|
|
170
|
+
errors.push(`Node '${node.id}' field '${fieldKey}' contains a bare reference "${trimmedField}" which will resolve to empty/undefined at runtime. ` +
|
|
171
|
+
`If this is a JSONata expression field (e.g. Splitter expression), you MUST use the passthrough path format: ` +
|
|
172
|
+
`"$getPassthrough().step_id.${trimmedField}" where step_id is the ID of the step that produces the data ` +
|
|
173
|
+
`(e.g., "$getPassthrough().step_1.${trimmedField}").`);
|
|
174
|
+
}
|
|
175
|
+
// Warn about deep $getPassthrough() paths in JSONata fields (e.g. Splitter expression).
|
|
176
|
+
const deepPathMatch = trimmedField.match(/^\$getPassthrough\(\)\.\w+\.body\.[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)+$/);
|
|
177
|
+
if (deepPathMatch) {
|
|
178
|
+
warnings.push(`Node '${node.id}' field '${fieldKey}' uses a deep JSONata path "${trimmedField}". ` +
|
|
179
|
+
`The UI form validator only accepts paths up to "$getPassthrough().step_X.body" — deeper paths ` +
|
|
180
|
+
`cause a red indicator and blocked Continue button in the UI, even though they work at runtime. ` +
|
|
181
|
+
`Consider using "$getPassthrough().step_X.body" and restructuring the incoming data to send the array ` +
|
|
182
|
+
`directly as the webhook/trigger body, or add a Code component before the Splitter to extract the nested array.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Validate placeholder/dummy values in fields that use dynamic select models.
|
|
188
|
+
if (node.fields) {
|
|
189
|
+
for (const [fieldKey, fieldValue] of Object.entries(node.fields)) {
|
|
190
|
+
if (typeof fieldValue === 'string' && PLACEHOLDER_FIELD_VALUES.includes(fieldValue)) {
|
|
191
|
+
warnings.push(`Node '${node.id}' field '${fieldKey}' has a placeholder value "${fieldValue}" which likely means it was never ` +
|
|
192
|
+
`properly configured via a dynamic select model. Call 'init_dynamic_select_model' to retrieve the valid options ` +
|
|
193
|
+
`and select a real value.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Validate REST API reader fields
|
|
198
|
+
if (node.fields?.reader && typeof node.fields.reader === 'object') {
|
|
199
|
+
validateRestApiReader(node.fields.reader, `Node '${node.id}'`, errors, warnings);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Validate all edges in a flow graph: references, mappers, router conditions. */
|
|
204
|
+
function validateEdges(edges, nodeMap, errors, warnings) {
|
|
205
|
+
// Identify router nodes for condition validation
|
|
206
|
+
const routerNodeIds = new Set();
|
|
207
|
+
for (const [id, node] of nodeMap) {
|
|
208
|
+
if (node.command && /router:route/.test(node.command)) {
|
|
209
|
+
routerNodeIds.add(id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const edge of edges) {
|
|
213
|
+
const edgeId = edge.id || `${edge.source}:${edge.target}`;
|
|
214
|
+
if (!edge.source || !edge.target) {
|
|
215
|
+
errors.push(`Edge '${edgeId}' is missing 'source' or 'target' properties.`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!nodeMap.has(edge.source)) {
|
|
219
|
+
errors.push(`Edge '${edgeId}' references source step '${edge.source}' which does not exist in the graph.`);
|
|
220
|
+
}
|
|
221
|
+
if (!nodeMap.has(edge.target)) {
|
|
222
|
+
errors.push(`Edge '${edgeId}' references target step '${edge.target}' which does not exist in the graph.`);
|
|
223
|
+
}
|
|
224
|
+
// Warn when edge has no mapper config at all for a non-first target step.
|
|
225
|
+
if (!edge.config?.mapper) {
|
|
226
|
+
const targetNode = nodeMap.get(edge.target);
|
|
227
|
+
if (targetNode && !targetNode.first) {
|
|
228
|
+
warnings.push(`Edge '${edgeId}' has no mapper configuration. Target step '${edge.target}' (${targetNode.name || 'unnamed'}) ` +
|
|
229
|
+
`will receive no mapped data from '${edge.source}', causing empty fields in the UI. ` +
|
|
230
|
+
`Add a 'config' with 'mapper_type: "jsonata"' and a 'mapper' object that maps the target step's input fields ` +
|
|
231
|
+
`using $getPassthrough() paths. For array-typed input fields, use JSONata array expressions starting with "[".`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const mapper = edge.config?.mapper;
|
|
235
|
+
if (mapper) {
|
|
236
|
+
validateMapper(mapper, edgeId, errors);
|
|
237
|
+
}
|
|
238
|
+
// Validate router edge conditions for unsupported JSONata functions
|
|
239
|
+
if (edge.config?.condition && routerNodeIds.has(edge.source)) {
|
|
240
|
+
validateRouterCondition(edge.config.condition, edgeId, errors);
|
|
241
|
+
}
|
|
242
|
+
// Check for REST API reader fields misplaced in edge mapper.
|
|
243
|
+
if (mapper && typeof mapper === 'object' && !Array.isArray(mapper)) {
|
|
244
|
+
const targetNode = nodeMap.get(edge.target);
|
|
245
|
+
if (targetNode?.fields?.reader || targetNode?.command?.includes('rest-api-component') || targetNode?.command?.includes('REST-API')) {
|
|
246
|
+
const misplacedFields = REST_API_READER_FIELDS.filter(f => f in mapper);
|
|
247
|
+
if (misplacedFields.length > 0) {
|
|
248
|
+
errors.push(`Edge '${edgeId}' mapper contains REST API reader fields [${misplacedFields.join(', ')}] ` +
|
|
249
|
+
`that belong in the target node's 'fields.reader' object, NOT in the edge mapper. ` +
|
|
250
|
+
`Move these to node '${edge.target}' fields.reader and remove them from the edge mapper. ` +
|
|
251
|
+
`The edge mapper should only map input metadata fields.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Check that router edges have conditions
|
|
256
|
+
if (routerNodeIds.has(edge.source) && !edge.config?.condition) {
|
|
257
|
+
errors.push(`Edge '${edgeId}' originates from router step '${edge.source}' but has no 'condition' in its config. ` +
|
|
258
|
+
`All outgoing edges from a router MUST have a 'condition' property with a JSONata boolean expression.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Validate mapper format and JSONata expressions within an edge mapper. */
|
|
263
|
+
function validateMapper(mapper, edgeId, errors) {
|
|
264
|
+
const mapperValues = [];
|
|
265
|
+
if (typeof mapper === 'string') {
|
|
266
|
+
errors.push(`Edge '${edgeId}' mapper is a string, but the UI requires an object-format mapper ` +
|
|
267
|
+
`with per-field JSONata expressions (e.g., {"fieldName": "$getPassthrough().step_1.body.value"}). ` +
|
|
268
|
+
`A string mapper breaks the UI visual mapper. Convert to object format.`);
|
|
269
|
+
mapperValues.push({ key: '_root', value: mapper });
|
|
270
|
+
}
|
|
271
|
+
else if (typeof mapper === 'object' && mapper !== null) {
|
|
272
|
+
for (const [key, val] of Object.entries(mapper)) {
|
|
273
|
+
if (Array.isArray(val)) {
|
|
274
|
+
errors.push(`Edge '${edgeId}' mapper field '${key}' is a native JSON array, but array-typed mapper fields MUST be ` +
|
|
275
|
+
`JSONata expression strings that evaluate to an array. The UI cannot render native JSON arrays and will show empty fields. ` +
|
|
276
|
+
`Convert it to a JSONata string, e.g.: "[{\\"role\\": \\"system\\", ...}]" instead of [{role: ...}].`);
|
|
277
|
+
}
|
|
278
|
+
else if (typeof val === 'number' || typeof val === 'boolean') {
|
|
279
|
+
mapper[key] = String(val);
|
|
280
|
+
mapperValues.push({ key, value: String(val) });
|
|
281
|
+
}
|
|
282
|
+
else if (typeof val === 'string') {
|
|
283
|
+
mapperValues.push({ key, value: val });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const { key, value } of mapperValues) {
|
|
288
|
+
const fieldLabel = key === '_root' ? `mapper` : `mapper field '${key}'`;
|
|
289
|
+
const trimmed = value.trim();
|
|
290
|
+
const hasOuterQuotes = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
291
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"));
|
|
292
|
+
if (hasOuterQuotes) {
|
|
293
|
+
const inner = trimmed.substring(1, trimmed.length - 1).trim();
|
|
294
|
+
if (inner.startsWith('{') || inner.startsWith('[') || inner.startsWith('$')) {
|
|
295
|
+
errors.push(`Edge '${edgeId}' ${fieldLabel} appears to be wrapped in outer double or single quotes: \`${value}\`. ` +
|
|
296
|
+
`This turns the JSONata expression into a static string literal, preventing variables and functions from resolving. ` +
|
|
297
|
+
`Make sure the JSONata expression value itself starts directly with the path, function, or object definition, without outer wrapping quotes.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const strippedValue = stripStringLiterals(trimmed);
|
|
301
|
+
for (const field of BARE_FIELD_NAMES) {
|
|
302
|
+
const fieldRegex = new RegExp(`(?<![$.])\\b${field}\\b(?!\\s*:)`, 'g');
|
|
303
|
+
if (fieldRegex.test(strippedValue)) {
|
|
304
|
+
errors.push(`Edge '${edgeId}' ${fieldLabel} uses direct variable reference \`${field}\` which does not resolve. ` +
|
|
305
|
+
`You MUST ALWAYS use the dynamic passthrough path format: \`$getPassthrough().step_id.body.${field}\`, ` +
|
|
306
|
+
`where \`step_id\` is the ID of the step that produced the data (e.g., \`$getPassthrough().step_1.body.${field}\`).`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Validate a router edge condition for unsupported JSONata functions. */
|
|
312
|
+
function validateRouterCondition(condition, edgeId, errors) {
|
|
313
|
+
if (typeof condition !== 'string')
|
|
314
|
+
return;
|
|
315
|
+
const strippedCondition = stripStringLiterals(condition);
|
|
316
|
+
for (const fn of UNSUPPORTED_ROUTER_JSONATA_FUNCTIONS) {
|
|
317
|
+
// Escape the leading $ so it's matched literally, not as a regex anchor
|
|
318
|
+
const escapedFn = fn.replace(/\$/g, '\\$');
|
|
319
|
+
const fnRegex = new RegExp(`${escapedFn}\\s*\\(`, 'g');
|
|
320
|
+
if (fnRegex.test(strippedCondition)) {
|
|
321
|
+
errors.push(`Edge '${edgeId}' router condition uses '${fn}()' which is NOT available in the router condition context ` +
|
|
322
|
+
`and will cause "Attempted to invoke a non-function" error at runtime. ` +
|
|
323
|
+
`Use $substring() on date strings from passthrough data instead. ` +
|
|
324
|
+
`For example, to extract the hour from a fireTime: $substring($getPassthrough().step_1.body.fireTime, 11, 2) < "12".`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function validateFlowGraph(body) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
const warnings = [];
|
|
331
|
+
const graph = body?.data?.attributes?.graph || body?.attributes?.graph || body?.graph;
|
|
332
|
+
if (!graph) {
|
|
333
|
+
return { errors, warnings };
|
|
334
|
+
}
|
|
335
|
+
const nodes = graph.nodes || [];
|
|
336
|
+
const edges = graph.edges || [];
|
|
337
|
+
const nodeMap = new Map();
|
|
338
|
+
validateNodes(nodes, nodeMap, errors, warnings);
|
|
339
|
+
validateEdges(edges, nodeMap, errors, warnings);
|
|
340
|
+
return { errors, warnings };
|
|
341
|
+
}
|
|
342
|
+
export function checkDiscoveryState(body, discoveryState, existingFlow) {
|
|
343
|
+
const errors = [];
|
|
344
|
+
const graph = body?.data?.attributes?.graph || body?.attributes?.graph || body?.graph;
|
|
345
|
+
if (!graph)
|
|
346
|
+
return errors;
|
|
347
|
+
const nodes = graph.nodes || [];
|
|
348
|
+
const existingNodeIds = new Set();
|
|
349
|
+
const existingComponentIds = new Set();
|
|
350
|
+
const existingCredSecretIds = new Set();
|
|
351
|
+
if (existingFlow) {
|
|
352
|
+
const existingNodes = existingFlow?.graph?.nodes || [];
|
|
353
|
+
existingNodes.forEach((n) => {
|
|
354
|
+
existingNodeIds.add(n.id);
|
|
355
|
+
if (n.component_id)
|
|
356
|
+
existingComponentIds.add(n.component_id);
|
|
357
|
+
const credId = resolveCredentialId(n.credentials_id);
|
|
358
|
+
if (credId)
|
|
359
|
+
existingCredSecretIds.add(credId);
|
|
360
|
+
const secId = resolveCredentialId(n.secret_id);
|
|
361
|
+
if (secId)
|
|
362
|
+
existingCredSecretIds.add(secId);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const OBJECT_ID_RE = /^[0-9a-f]{24}$/;
|
|
366
|
+
for (const node of nodes) {
|
|
367
|
+
// Format validation: component_id, credentials_id, secret_id must be valid 24-char hex ObjectIDs
|
|
368
|
+
if (node.component_id && !OBJECT_ID_RE.test(node.component_id)) {
|
|
369
|
+
errors.push(`Node '${node.id}' has invalid component_id '${node.component_id}' — must be a 24-character hex string (MongoDB ObjectID). ` +
|
|
370
|
+
`Do NOT use placeholder IDs from documentation examples. Call 'list_components' to discover real component IDs.`);
|
|
371
|
+
}
|
|
372
|
+
const rawCredId = resolveCredentialId(node.credentials_id);
|
|
373
|
+
if (rawCredId && !OBJECT_ID_RE.test(rawCredId)) {
|
|
374
|
+
errors.push(`Node '${node.id}' has invalid credentials_id '${rawCredId}' — must be a 24-character hex string. ` +
|
|
375
|
+
`Call 'list_credentials' to discover real credential IDs.`);
|
|
376
|
+
}
|
|
377
|
+
const rawSecId = resolveCredentialId(node.secret_id);
|
|
378
|
+
if (rawSecId && !OBJECT_ID_RE.test(rawSecId)) {
|
|
379
|
+
errors.push(`Node '${node.id}' has invalid secret_id '${rawSecId}' — must be a 24-character hex string. ` +
|
|
380
|
+
`Call 'list_secrets' to discover real secret IDs.`);
|
|
381
|
+
}
|
|
382
|
+
const isExistingNode = existingNodeIds.has(node.id);
|
|
383
|
+
// Discovery checks only apply to new nodes
|
|
384
|
+
if (!isExistingNode) {
|
|
385
|
+
if (node.component_id && !existingComponentIds.has(node.component_id) && !discoveryState.components.has(node.component_id)) {
|
|
386
|
+
errors.push(`Node '${node.id}' references component ID '${node.component_id}', but you have not read its schema using 'get_component'. ` +
|
|
387
|
+
`You MUST call 'get_component' for this component first to retrieve its official actions, triggers, and fields schema.`);
|
|
388
|
+
}
|
|
389
|
+
const nodeCredId = resolveCredentialId(node.credentials_id);
|
|
390
|
+
if (nodeCredId && !existingCredSecretIds.has(nodeCredId) && !discoveryState.credentials.has(nodeCredId)) {
|
|
391
|
+
errors.push(`Node '${node.id}' references credential ID '${nodeCredId}', but you have not listed or discovered credentials for component '${node.component_id}' in this workspace. ` +
|
|
392
|
+
`You MUST search/list credentials via 'list_credentials' first before configuring them in the flow.`);
|
|
393
|
+
}
|
|
394
|
+
const nodeSecId = resolveCredentialId(node.secret_id);
|
|
395
|
+
if (nodeSecId && !existingCredSecretIds.has(nodeSecId) && !discoveryState.secrets.has(nodeSecId)) {
|
|
396
|
+
errors.push(`Node '${node.id}' references secret ID '${nodeSecId}', but you have not listed or discovered secrets for component '${node.component_id}' in this workspace. ` +
|
|
397
|
+
`You MUST search/list secrets via 'list_secrets' first before configuring them in the flow.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Check secret state for ALL nodes (existing and new)
|
|
401
|
+
const secId = resolveCredentialId(node.secret_id);
|
|
402
|
+
if (secId && discoveryState.secrets.has(secId)) {
|
|
403
|
+
const secretState = discoveryState.secrets.get(secId);
|
|
404
|
+
if (secretState && secretState !== 'ready') {
|
|
405
|
+
errors.push(`Node '${node.id}' uses secret '${secId}' which is in '${secretState}' state. ` +
|
|
406
|
+
`Only secrets in 'ready' state can be used in flows. Please re-authenticate or choose a different secret.`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Check credential/secret verification results for ALL nodes
|
|
410
|
+
const credIdForVerify = resolveCredentialId(node.credentials_id) || resolveCredentialId(node.secret_id);
|
|
411
|
+
if (credIdForVerify && discoveryState.credentialVerifications.has(credIdForVerify)) {
|
|
412
|
+
const verification = discoveryState.credentialVerifications.get(credIdForVerify);
|
|
413
|
+
if (verification && !verification.ok) {
|
|
414
|
+
errors.push(`Node '${node.id}' uses credential/secret '${credIdForVerify}' which FAILED verification: ${verification.error}. ` +
|
|
415
|
+
`Fix or replace this credential/secret before using it in a flow.`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Auth check applies to ALL nodes (existing and new)
|
|
419
|
+
if (node.component_id && discoveryState.componentsRequiringAuth.has(node.component_id)
|
|
420
|
+
&& !resolveCredentialId(node.credentials_id) && !resolveCredentialId(node.secret_id)) {
|
|
421
|
+
errors.push(`Node '${node.id}' uses component '${node.component_id}' which requires authentication, but no credentials_id or secret_id is set. ` +
|
|
422
|
+
`You MUST first find or create credentials/secrets using 'list_credentials'/'list_secrets' (or 'create_credentials'/'create_secret'), ` +
|
|
423
|
+
`then assign them to the node before creating or updating the flow.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return errors;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Verify a credential/secret by calling the verify-credential endpoint and polling for the result.
|
|
430
|
+
* Returns { ok: true } on success or { error: string } on failure.
|
|
431
|
+
*/
|
|
432
|
+
async function verifyCredentialForHealthCheck(client, componentId, credOrSecretId, isSecret, workspaceId) {
|
|
433
|
+
let verifyFields = {};
|
|
434
|
+
if (isSecret && workspaceId) {
|
|
435
|
+
const secretRes = await client.get(`/workspaces/${workspaceId}/secrets/${credOrSecretId}`);
|
|
436
|
+
const rawSecretSubtype = secretRes.data?.data?.attributes?.type;
|
|
437
|
+
const secretData = secretRes.data?.data?.attributes || {};
|
|
438
|
+
const existingFields = secretData.credentials || {};
|
|
439
|
+
const secretSubtype = rawSecretSubtype || secretData.type || 'noauth';
|
|
440
|
+
if (secretSubtype === 'oauth2' && existingFields.undefined_params !== undefined) {
|
|
441
|
+
const { undefined_params: existingParams = {}, ...existingOAuth } = existingFields;
|
|
442
|
+
verifyFields = { oauth: { ...existingOAuth, undefined_params: existingParams } };
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
verifyFields = { auth: { type: secretSubtype, ...existingFields } };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const credRes = await client.get(`/credentials/${credOrSecretId}`);
|
|
450
|
+
const credData = credRes.data?.data?.attributes || credRes.data?.data || {};
|
|
451
|
+
verifyFields = credData.keys || {};
|
|
452
|
+
}
|
|
453
|
+
const requestBody = {
|
|
454
|
+
data: {
|
|
455
|
+
type: 'verify-credential',
|
|
456
|
+
attributes: { fields: verifyFields },
|
|
457
|
+
relationships: {
|
|
458
|
+
workspace: { data: { id: workspaceId, type: 'workspace' } }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const verifyRes = await client.post(`/components/${componentId}/versions/latest/verify-credential`, requestBody);
|
|
463
|
+
const execId = verifyRes.data?.data?.id;
|
|
464
|
+
if (!execId)
|
|
465
|
+
return { ok: true }; // Can't poll, assume OK
|
|
466
|
+
// Poll for result (max 3 attempts, 3s delay)
|
|
467
|
+
const maxAttempts = 3;
|
|
468
|
+
const delayMs = 3000;
|
|
469
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
470
|
+
if (attempt > 1) {
|
|
471
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const resultRes = await client.get(`/exec/result/${execId}`);
|
|
475
|
+
const resultData = resultRes.data?.data?.attributes || resultRes.data?.data || {};
|
|
476
|
+
const isPending = typeof resultData.status === 'string' && resultData.status.toLowerCase().includes('pending');
|
|
477
|
+
if (isPending)
|
|
478
|
+
continue;
|
|
479
|
+
const result = resultData.result;
|
|
480
|
+
if (result?.verified === false || result?.error || result?.status === 'error') {
|
|
481
|
+
const reason = result.error || result.message || result.reason || JSON.stringify(result);
|
|
482
|
+
return { error: `Reason: ${reason}` };
|
|
483
|
+
}
|
|
484
|
+
return { ok: true };
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
const isNotReady = err.message && (err.message.includes('409') || err.message.includes('not ready'));
|
|
488
|
+
if (isNotReady)
|
|
489
|
+
continue;
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Timed out polling — don't block, just warn
|
|
494
|
+
return { ok: true };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Runtime health check for a flow — validates credentials, required fields, URLs, and field values.
|
|
498
|
+
* Runs after create_flow/update_flow and before start_flow to catch config issues
|
|
499
|
+
* that would show as red indicators in the UI.
|
|
500
|
+
*/
|
|
501
|
+
export async function healthCheckFlow(flow, client) {
|
|
502
|
+
const errors = [];
|
|
503
|
+
const warnings = [];
|
|
504
|
+
const nodes = flow?.graph?.nodes || [];
|
|
505
|
+
if (nodes.length === 0) {
|
|
506
|
+
errors.push('Flow has no steps configured');
|
|
507
|
+
return { errors, warnings };
|
|
508
|
+
}
|
|
509
|
+
for (const node of nodes) {
|
|
510
|
+
const stepLabel = `Step '${node.id}' (${node.name || 'unnamed'})`;
|
|
511
|
+
// 1. Verify credentials exist and are accessible
|
|
512
|
+
const healthCredId = resolveCredentialId(node.credentials_id);
|
|
513
|
+
if (healthCredId) {
|
|
514
|
+
try {
|
|
515
|
+
await client.get(`/credentials/${healthCredId}`);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
errors.push(`${stepLabel}: credential '${healthCredId}' could not be fetched — it may have been deleted`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const healthSecId = resolveCredentialId(node.secret_id);
|
|
522
|
+
if (healthSecId) {
|
|
523
|
+
try {
|
|
524
|
+
const workspaceId = flow?.workspace?.id || flow?.workspace?.data?.id;
|
|
525
|
+
if (workspaceId) {
|
|
526
|
+
const secretRes = await client.get(`/workspaces/${workspaceId}/secrets/${healthSecId}`);
|
|
527
|
+
const secret = secretRes.data?.data;
|
|
528
|
+
const state = secret?.attributes?.state;
|
|
529
|
+
if (state && state !== 'ready') {
|
|
530
|
+
errors.push(`${stepLabel}: secret '${healthSecId}' is in state '${state}', expected 'ready'`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
errors.push(`${stepLabel}: secret '${healthSecId}' could not be fetched — it may have been deleted`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// 1b. Verify credentials actually work by calling verify-credential endpoint.
|
|
539
|
+
// A credential/secret can exist and be in "ready" state but still fail at runtime
|
|
540
|
+
// (e.g. OAuth token revoked, API key disabled, 403 Forbidden).
|
|
541
|
+
if (node.component_id) {
|
|
542
|
+
const credOrSecretId = healthCredId || healthSecId;
|
|
543
|
+
if (credOrSecretId) {
|
|
544
|
+
try {
|
|
545
|
+
const verifyResult = await verifyCredentialForHealthCheck(client, node.component_id, credOrSecretId, !!healthSecId, flow?.workspace?.id || flow?.workspace?.data?.id);
|
|
546
|
+
if (verifyResult.error) {
|
|
547
|
+
errors.push(`${stepLabel}: credential verification FAILED for '${credOrSecretId}'. ` +
|
|
548
|
+
`${verifyResult.error} ` +
|
|
549
|
+
`The credential may be expired, revoked, or the user no longer has access. ` +
|
|
550
|
+
`Re-authorize the credential in the UI or replace it with a working one.`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
logger.warn(`[healthCheck] Credential verification skipped for '${credOrSecretId}': ${err instanceof Error ? err.message : String(err)}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 2. Check component and required fields
|
|
559
|
+
if (node.component_id) {
|
|
560
|
+
try {
|
|
561
|
+
const compRes = await client.get(`/components/${node.component_id}`);
|
|
562
|
+
const comp = compRes.data?.data?.attributes || compRes.data?.data;
|
|
563
|
+
if (comp) {
|
|
564
|
+
const actionOrTrigger = findActionOrTrigger(comp, node.command);
|
|
565
|
+
if (actionOrTrigger?.fields) {
|
|
566
|
+
for (const [fieldId, fieldDef] of Object.entries(actionOrTrigger.fields)) {
|
|
567
|
+
// Check required fields
|
|
568
|
+
if (fieldDef.required && (!node.fields || node.fields[fieldId] === undefined || node.fields[fieldId] === '')) {
|
|
569
|
+
errors.push(`${stepLabel}: required field '${fieldId}' (${fieldDef.label || fieldId}) is not set`);
|
|
570
|
+
}
|
|
571
|
+
// Check static select model values
|
|
572
|
+
if (fieldDef.model && typeof fieldDef.model === 'object' && node.fields?.[fieldId]) {
|
|
573
|
+
const allowedKeys = Object.keys(fieldDef.model);
|
|
574
|
+
if (!allowedKeys.includes(node.fields[fieldId])) {
|
|
575
|
+
warnings.push(`${stepLabel}: field '${fieldId}' has value '${node.fields[fieldId]}' which is not in the ` +
|
|
576
|
+
`allowed static values: ${allowedKeys.join(', ')}. This may be valid if the field uses a dynamic model.`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Check if component is deprecated
|
|
582
|
+
const compName = comp.name || '';
|
|
583
|
+
const compCommandPrefix = comp.team_name && comp.repo_name
|
|
584
|
+
? `${comp.team_name}/${comp.repo_name}` : '';
|
|
585
|
+
const successor = DEPRECATED_COMPONENTS[compName] || DEPRECATED_COMPONENTS[compCommandPrefix];
|
|
586
|
+
if (successor) {
|
|
587
|
+
warnings.push(`${stepLabel}: component '${compName || compCommandPrefix}' is DEPRECATED. ` +
|
|
588
|
+
`Use '${successor}' instead.`);
|
|
589
|
+
}
|
|
590
|
+
// Check if component requires auth but none is set
|
|
591
|
+
const credFields = comp.credentials?.fields;
|
|
592
|
+
const hasCredFields = credFields && Object.keys(credFields).length > 0;
|
|
593
|
+
if (hasCredFields && !resolveCredentialId(node.credentials_id) && !resolveCredentialId(node.secret_id)) {
|
|
594
|
+
errors.push(`${stepLabel}: component requires authentication but no credentials or secret is configured`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
logger.warn(`[healthCheck] Could not fetch component '${node.component_id}': ${err instanceof Error ? err.message : String(err)}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// 3. Validate REST API reader format (headers, body for POST/PUT/PATCH)
|
|
603
|
+
if (node.fields?.reader && typeof node.fields.reader === 'object') {
|
|
604
|
+
validateRestApiReader(node.fields.reader, stepLabel, errors, warnings);
|
|
605
|
+
}
|
|
606
|
+
// 4. Validate URLs (REST API, webhooks)
|
|
607
|
+
if (node.fields?.reader?.url) {
|
|
608
|
+
const url = node.fields.reader.url;
|
|
609
|
+
// Check for plain URL in JSONata-mode field (reader fields use JSONata Mode)
|
|
610
|
+
if (/^https?:\/\//i.test(url.trim())) {
|
|
611
|
+
errors.push(`${stepLabel}: reader.url contains a plain URL "${url}" which is invalid JSONata syntax ` +
|
|
612
|
+
`(the ":" in the protocol causes a parse error). ` +
|
|
613
|
+
`This field uses JSONata Mode — wrap the URL in escaped double quotes: "\\\"${url.trim()}\\\"".`);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
// If it's a JSONata-quoted string, extract the actual URL for further validation
|
|
617
|
+
const quotedMatch = url.trim().match(/^"(.*)"$/);
|
|
618
|
+
const actualUrl = quotedMatch ? quotedMatch[1] : url;
|
|
619
|
+
try {
|
|
620
|
+
const parsed = new URL(actualUrl);
|
|
621
|
+
if (parsed.hostname === 'webhook.site' && parsed.pathname === '/') {
|
|
622
|
+
warnings.push(`${stepLabel}: URL '${actualUrl}' is the webhook.site homepage, not a unique endpoint. ` +
|
|
623
|
+
`Go to https://webhook.site to get a unique URL like https://webhook.site/<unique-id>`);
|
|
624
|
+
}
|
|
625
|
+
if (parsed.hostname === 'example.com') {
|
|
626
|
+
warnings.push(`${stepLabel}: URL '${actualUrl}' points to example.com which is a placeholder domain`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// Not a valid URL — might be a JSONata expression, which is fine
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// 5. Check edges with missing mappers for non-first target steps.
|
|
636
|
+
// Missing mappers cause empty/red fields in the UI.
|
|
637
|
+
const edges = flow?.graph?.edges || [];
|
|
638
|
+
for (const edge of edges) {
|
|
639
|
+
const edgeId = edge.id || `${edge.source}:${edge.target}`;
|
|
640
|
+
if (!edge.config?.mapper) {
|
|
641
|
+
const targetNode = nodes.find((n) => n.id === edge.target);
|
|
642
|
+
if (targetNode && !targetNode.first) {
|
|
643
|
+
warnings.push(`Edge '${edgeId}': no mapper configured. Step '${edge.target}' (${targetNode.name || 'unnamed'}) ` +
|
|
644
|
+
`will receive no mapped input data, causing empty/red fields in the UI. ` +
|
|
645
|
+
`Add a mapper with $getPassthrough() paths for the step's input fields.`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// 6. Validate mapper fields against component input schema types.
|
|
650
|
+
// If a field is defined as type:"array" in the schema but the mapper value is a plain
|
|
651
|
+
// string passthrough (not a JSONata array expression), warn that it needs to be an array expression.
|
|
652
|
+
for (const edge of edges) {
|
|
653
|
+
const edgeId = edge.id || `${edge.source}:${edge.target}`;
|
|
654
|
+
const mapper = edge.config?.mapper;
|
|
655
|
+
if (!mapper || typeof mapper !== 'object' || Array.isArray(mapper))
|
|
656
|
+
continue;
|
|
657
|
+
const targetNode = nodes.find((n) => n.id === edge.target);
|
|
658
|
+
if (!targetNode?.component_id)
|
|
659
|
+
continue;
|
|
660
|
+
try {
|
|
661
|
+
const compRes = await client.get(`/components/${targetNode.component_id}`);
|
|
662
|
+
const comp = compRes.data?.data?.attributes || compRes.data?.data;
|
|
663
|
+
if (!comp)
|
|
664
|
+
continue;
|
|
665
|
+
const actionOrTrigger = findActionOrTrigger(comp, targetNode.command);
|
|
666
|
+
const inputSchema = actionOrTrigger?.metadata?.in?.properties;
|
|
667
|
+
// For components with dynamicMetadata and no static input schema,
|
|
668
|
+
// we can't verify field types statically. Warn if any mapper field name
|
|
669
|
+
// contains common array-indicating patterns (e.g. "urls", "items", "files", "messages")
|
|
670
|
+
// and the value is not a JSONata array expression.
|
|
671
|
+
if (!inputSchema && actionOrTrigger?.dynamicMetadata === true) {
|
|
672
|
+
const arrayFieldPatterns = /^(file_urls|urls|items|files|messages|recipients|attachments|records|entries|rows|contacts|emails)$/i;
|
|
673
|
+
for (const [fieldKey, fieldValue] of Object.entries(mapper)) {
|
|
674
|
+
if (typeof fieldValue === 'string' && arrayFieldPatterns.test(fieldKey)) {
|
|
675
|
+
const trimmed = fieldValue.trim();
|
|
676
|
+
if (!trimmed.startsWith('[')) {
|
|
677
|
+
warnings.push(`Edge '${edgeId}' mapper field '${fieldKey}' looks like an array-typed field (dynamicMetadata component) ` +
|
|
678
|
+
`but is mapped as a plain string "${trimmed}". The UI visual mapper cannot render array fields mapped ` +
|
|
679
|
+
`as plain passthroughs — it shows "impossible to map properties not inside the array property". ` +
|
|
680
|
+
`Use a JSONata array expression starting with "[" to construct the array inline, e.g.: ` +
|
|
681
|
+
`"[{\\"file_url\\": $getPassthrough().step_X.body.file_url}]". ` +
|
|
682
|
+
`The mapping works at runtime but causes red indicators in the UI.`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (!inputSchema)
|
|
689
|
+
continue;
|
|
690
|
+
for (const [fieldKey, fieldValue] of Object.entries(mapper)) {
|
|
691
|
+
const schemaDef = inputSchema[fieldKey];
|
|
692
|
+
// Check array-typed fields mapped as plain strings
|
|
693
|
+
if (schemaDef?.type === 'array' && typeof fieldValue === 'string') {
|
|
694
|
+
const trimmed = fieldValue.trim();
|
|
695
|
+
// A valid array mapper should start with "[" (JSONata array expression)
|
|
696
|
+
if (!trimmed.startsWith('[')) {
|
|
697
|
+
const itemProps = schemaDef.items?.properties || schemaDef.items?.type;
|
|
698
|
+
const hint = typeof itemProps === 'object'
|
|
699
|
+
? `Expected format: [{"${Object.keys(itemProps)[0]}": ...}]`
|
|
700
|
+
: `Expected a JSONata expression that evaluates to an array`;
|
|
701
|
+
errors.push(`Edge '${edgeId}' mapper field '${fieldKey}' is mapped as a plain string, but the component schema ` +
|
|
702
|
+
`defines it as type "array". A plain string passthrough will cause a type mismatch (red field in the UI). ` +
|
|
703
|
+
`${hint}. Wrap the value in a JSONata array expression, e.g.: ` +
|
|
704
|
+
`"[{\\"field\\": $getPassthrough().step_X.body.value}]".`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Component fetch may have already been done above; skip on error
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return { errors, warnings };
|
|
714
|
+
}
|
|
715
|
+
/** Find action or trigger definition by parsing the command string */
|
|
716
|
+
function findActionOrTrigger(component, command) {
|
|
717
|
+
if (!command)
|
|
718
|
+
return null;
|
|
719
|
+
const match = command.match(/^[^:]+:([^@]+)@/);
|
|
720
|
+
if (!match)
|
|
721
|
+
return null;
|
|
722
|
+
const methodId = match[1];
|
|
723
|
+
return component.actions?.[methodId] || component.triggers?.[methodId] || null;
|
|
724
|
+
}
|
|
725
|
+
/** Format health check results as a readable string to append to tool response */
|
|
726
|
+
export function formatHealthCheck(result) {
|
|
727
|
+
if (result.errors.length === 0 && result.warnings.length === 0) {
|
|
728
|
+
return '\n\nHealth check passed — no issues found.';
|
|
729
|
+
}
|
|
730
|
+
const parts = ['\n\n--- Flow Health Check ---'];
|
|
731
|
+
if (result.errors.length > 0) {
|
|
732
|
+
parts.push(`\nERRORS (${result.errors.length}):`);
|
|
733
|
+
for (const err of result.errors) {
|
|
734
|
+
parts.push(` - ${err}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (result.warnings.length > 0) {
|
|
738
|
+
parts.push(`\nWARNINGS (${result.warnings.length}):`);
|
|
739
|
+
for (const warn of result.warnings) {
|
|
740
|
+
parts.push(` - ${warn}`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (result.errors.length > 0) {
|
|
744
|
+
parts.push('\nFix the errors above before starting the flow.');
|
|
745
|
+
}
|
|
746
|
+
return parts.join('\n');
|
|
747
|
+
}
|
|
748
|
+
//# sourceMappingURL=validation.js.map
|