@dypai-ai/mcp 1.0.9 → 1.1.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/package.json +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +27 -97
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +97 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +414 -0
- package/src/tools/sync/push.js +411 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_validate — static analysis for DYPAI projects (like ESLint for endpoints).
|
|
3
|
+
*
|
|
4
|
+
* Catches entire classes of runtime errors BEFORE push:
|
|
5
|
+
* - ${input.X} references that don't match the input schema
|
|
6
|
+
* - ${nodes.X.Y} references to nodes that don't exist in this endpoint
|
|
7
|
+
* - ${current_user_id} / ${current_user_role} used without jwt auth
|
|
8
|
+
* - `credential: foo` pointing to a credential that doesn't exist remotely
|
|
9
|
+
* - Agent `tools: [foo]` pointing to endpoints that aren't tools
|
|
10
|
+
* - SQL queries referencing tables that don't exist in schema.sql
|
|
11
|
+
*
|
|
12
|
+
* Returns a list of diagnostics with severity + fix_hint. Non-zero errors
|
|
13
|
+
* mean push will refuse (unless skip_validation is passed).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from "fs/promises"
|
|
17
|
+
import { join, resolve as resolvePath } from "path"
|
|
18
|
+
import { fetchRemoteState, readLocalState, readLocalConfig, readLocalRealtime } from "./planner.js"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate dypai/realtime.yaml against known schemas/tables. Rules:
|
|
22
|
+
* rt_table_not_found — references a table that doesn't exist in schema.sql
|
|
23
|
+
* rt_invalid_placeholder — uses a placeholder name outside the allowed set
|
|
24
|
+
* rt_invalid_events — events array contains values other than INSERT/UPDATE/DELETE
|
|
25
|
+
* rt_empty_target — no tables and no channels declared but realtime.yaml exists
|
|
26
|
+
*/
|
|
27
|
+
async function validateRealtime(rootDir, ctx) {
|
|
28
|
+
const local = await readLocalRealtime(rootDir)
|
|
29
|
+
if (!local) return []
|
|
30
|
+
|
|
31
|
+
const diagnostics = []
|
|
32
|
+
const ALLOWED_PLACEHOLDERS = new Set(["current_user_id", "current_user_role", "channel_param"])
|
|
33
|
+
const ALLOWED_EVENTS = new Set(["INSERT", "UPDATE", "DELETE"])
|
|
34
|
+
|
|
35
|
+
if (local.rows.length === 0) {
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
severity: "warn",
|
|
38
|
+
rule: "rt_empty_target",
|
|
39
|
+
file: "realtime.yaml",
|
|
40
|
+
message: "realtime.yaml exists but declares no tables or channels.",
|
|
41
|
+
fix_hint: "Add at least one entry, or delete the file to go back to deny-by-default.",
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const p of local.rows) {
|
|
46
|
+
const tag = `${p.target_type}:${p.target_name}`
|
|
47
|
+
|
|
48
|
+
// Table must exist in schema.sql
|
|
49
|
+
if (p.target_type === "table" && ctx.schemaTables && !ctx.schemaTables.has(p.target_name)) {
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
severity: "error",
|
|
52
|
+
rule: "rt_table_not_found",
|
|
53
|
+
file: "realtime.yaml",
|
|
54
|
+
loc: `tables.${p.target_name}`,
|
|
55
|
+
message: `realtime.yaml declares table '${p.target_name}' but it's not in schema.sql.`,
|
|
56
|
+
fix_hint: `Create the table first (CREATE TABLE public.${p.target_name} ...) or fix the typo.`,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// subscribe_filter placeholders
|
|
61
|
+
if (p.subscribe_filter) {
|
|
62
|
+
const placeholderRe = /\$\{([^}]+)\}/g
|
|
63
|
+
let m
|
|
64
|
+
while ((m = placeholderRe.exec(p.subscribe_filter)) !== null) {
|
|
65
|
+
const name = m[1].trim()
|
|
66
|
+
if (!ALLOWED_PLACEHOLDERS.has(name)) {
|
|
67
|
+
diagnostics.push({
|
|
68
|
+
severity: "error",
|
|
69
|
+
rule: "rt_invalid_placeholder",
|
|
70
|
+
file: "realtime.yaml",
|
|
71
|
+
loc: tag,
|
|
72
|
+
message: `Unknown placeholder \${${name}} in subscribe_filter.`,
|
|
73
|
+
fix_hint: `Allowed: ${[...ALLOWED_PLACEHOLDERS].map(p => "${" + p + "}").join(", ")}`,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
// channel_param only makes sense for channels with wildcards
|
|
77
|
+
if (name === "channel_param" && (p.target_type !== "channel" || !p.target_name.includes("%"))) {
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
severity: "error",
|
|
80
|
+
rule: "rt_invalid_placeholder",
|
|
81
|
+
file: "realtime.yaml",
|
|
82
|
+
loc: tag,
|
|
83
|
+
message: `\${channel_param} only resolves on channels with a '%' wildcard in target_name.`,
|
|
84
|
+
fix_hint: `Use a pattern like 'chat:%' so channel_param captures the variable part.`,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// events validation
|
|
91
|
+
if (Array.isArray(p.events)) {
|
|
92
|
+
for (const e of p.events) {
|
|
93
|
+
if (!ALLOWED_EVENTS.has(e)) {
|
|
94
|
+
diagnostics.push({
|
|
95
|
+
severity: "error",
|
|
96
|
+
rule: "rt_invalid_events",
|
|
97
|
+
file: "realtime.yaml",
|
|
98
|
+
loc: tag,
|
|
99
|
+
message: `Invalid event '${e}' in events array.`,
|
|
100
|
+
fix_hint: `Allowed values: INSERT, UPDATE, DELETE.`,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Perf warning: complex filters (subqueries, JOINs, EXISTS) fall off the
|
|
107
|
+
// in-memory fast path and hit the DB on every notification. Nudge toward
|
|
108
|
+
// flat filters when possible.
|
|
109
|
+
if (p.subscribe_filter && /\b(SELECT|JOIN|EXISTS|UNION|WITH)\b/i.test(p.subscribe_filter)) {
|
|
110
|
+
diagnostics.push({
|
|
111
|
+
severity: "warn",
|
|
112
|
+
rule: "rt_complex_filter",
|
|
113
|
+
file: "realtime.yaml",
|
|
114
|
+
loc: tag,
|
|
115
|
+
message: `Subscribe filter has subqueries/JOINs — each notification will hit the DB.`,
|
|
116
|
+
fix_hint: `For high-throughput tables, prefer a flat filter (e.g. 'user_id = \${current_user_id}'). If complex is unavoidable, expect ~1-5ms added latency per event.`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return diagnostics
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load the node catalog that dypai_pull cached to `dypai/node-catalog.json`.
|
|
126
|
+
* The catalog is the single source of truth (dumped from public.node_catalog
|
|
127
|
+
* in the central control plane). If it's missing, the validator falls back
|
|
128
|
+
* to basic rules — no curated drift-prone JSON here.
|
|
129
|
+
*/
|
|
130
|
+
async function loadNodeCatalog(rootDir) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = await readFile(join(rootDir, "node-catalog.json"), "utf8")
|
|
133
|
+
const parsed = JSON.parse(raw)
|
|
134
|
+
const schemas = {}
|
|
135
|
+
const knownTypes = new Set()
|
|
136
|
+
for (const [nodeType, data] of Object.entries(parsed.nodes || {})) {
|
|
137
|
+
knownTypes.add(nodeType)
|
|
138
|
+
schemas[nodeType] = {
|
|
139
|
+
label: data.label,
|
|
140
|
+
description: data.description,
|
|
141
|
+
inputs: data.inputs && Object.keys(data.inputs).length ? data.inputs : null,
|
|
142
|
+
outputs: data.outputs && Object.keys(data.outputs).length ? data.outputs : null,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { schemas, knownTypes, missing: false }
|
|
146
|
+
} catch {
|
|
147
|
+
return { schemas: {}, knownTypes: new Set(), missing: true }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** Walk an object, yielding every string value with its JSON path. */
|
|
154
|
+
function* walkStrings(node, path = "") {
|
|
155
|
+
if (node == null) return
|
|
156
|
+
if (typeof node === "string") {
|
|
157
|
+
yield { path, value: node }
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(node)) {
|
|
161
|
+
for (let i = 0; i < node.length; i++) {
|
|
162
|
+
yield* walkStrings(node[i], `${path}[${i}]`)
|
|
163
|
+
}
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (typeof node === "object") {
|
|
167
|
+
for (const [k, v] of Object.entries(node)) {
|
|
168
|
+
yield* walkStrings(v, path ? `${path}.${k}` : k)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Extract all ${...} placeholder expressions from a string. */
|
|
174
|
+
function extractPlaceholders(s) {
|
|
175
|
+
const out = []
|
|
176
|
+
const re = /\$\{([^}]+)\}/g
|
|
177
|
+
let m
|
|
178
|
+
while ((m = re.exec(s)) !== null) out.push(m[1])
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
|
|
183
|
+
function levenshteinSmall(a, b) {
|
|
184
|
+
if (a === b) return 0
|
|
185
|
+
if (Math.abs(a.length - b.length) > 3) return 99
|
|
186
|
+
const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
|
|
187
|
+
for (let j = 0; j <= b.length; j++) m[0][j] = j
|
|
188
|
+
for (let i = 1; i <= a.length; i++) {
|
|
189
|
+
for (let j = 1; j <= b.length; j++) {
|
|
190
|
+
m[i][j] = Math.min(
|
|
191
|
+
m[i-1][j] + 1,
|
|
192
|
+
m[i][j-1] + 1,
|
|
193
|
+
m[i-1][j-1] + (a[i-1] === b[j-1] ? 0 : 1),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return m[a.length][b.length]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Parse schema.sql to extract the set of public.<table> names. */
|
|
201
|
+
async function readSchemaTables(rootDir) {
|
|
202
|
+
try {
|
|
203
|
+
const raw = await readFile(join(rootDir, "schema.sql"), "utf8")
|
|
204
|
+
const tables = new Set()
|
|
205
|
+
const re = /CREATE\s+TABLE\s+public\.(\w+)/gi
|
|
206
|
+
let m
|
|
207
|
+
while ((m = re.exec(raw)) !== null) tables.add(m[1])
|
|
208
|
+
return tables
|
|
209
|
+
} catch {
|
|
210
|
+
return null // schema.sql missing — skip SQL checks
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
|
|
215
|
+
function extractSqlTables(sql) {
|
|
216
|
+
const tables = new Set()
|
|
217
|
+
const re = /(?:FROM|JOIN|INTO|UPDATE)\s+public\.(\w+)/gi
|
|
218
|
+
let m
|
|
219
|
+
while ((m = re.exec(sql)) !== null) tables.add(m[1])
|
|
220
|
+
return tables
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Rules ──────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function ruleUsesJwt(trigger) {
|
|
226
|
+
// current_user_* requires http_api + jwt
|
|
227
|
+
return trigger?.http_api?.auth_mode === "jwt"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateEndpoint(entry, ctx) {
|
|
231
|
+
const { doc, fileMap } = entry
|
|
232
|
+
const diagnostics = []
|
|
233
|
+
const name = doc.name
|
|
234
|
+
const file = ctx.fileByName[name] || `endpoints/${name}.yaml`
|
|
235
|
+
|
|
236
|
+
const inputProps = doc.input?.properties || {}
|
|
237
|
+
const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
|
|
238
|
+
|
|
239
|
+
const jwt = ruleUsesJwt(doc.trigger)
|
|
240
|
+
|
|
241
|
+
// Collect all strings INCLUDING file contents (query_file, system_prompt_file, code_file)
|
|
242
|
+
const sources = []
|
|
243
|
+
for (const { path, value } of walkStrings(doc)) {
|
|
244
|
+
sources.push({ source: "yaml", loc: path, value })
|
|
245
|
+
}
|
|
246
|
+
for (const [filePath, content] of Object.entries(fileMap || {})) {
|
|
247
|
+
sources.push({ source: "file", loc: filePath, value: content })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Collect SQL tables referenced (before checking each individually)
|
|
251
|
+
const referencedTables = new Set()
|
|
252
|
+
|
|
253
|
+
for (const { source, loc, value } of sources) {
|
|
254
|
+
// --- Placeholder checks ---
|
|
255
|
+
for (const expr of extractPlaceholders(value)) {
|
|
256
|
+
// Strip leading/trailing whitespace in the expression
|
|
257
|
+
const e = expr.trim()
|
|
258
|
+
|
|
259
|
+
// ${input.X} or ${input.X.Y}
|
|
260
|
+
// Only validate against the input schema if one is declared; DYPAI allows
|
|
261
|
+
// schemaless input (placeholders pass through at runtime), so missing
|
|
262
|
+
// schema is not an error by itself — at most a WARNING.
|
|
263
|
+
if (e.startsWith("input.")) {
|
|
264
|
+
const first = e.slice(6).split(/[.\[]/)[0]
|
|
265
|
+
const hasSchema = Object.keys(inputProps).length > 0
|
|
266
|
+
if (hasSchema && !inputProps[first]) {
|
|
267
|
+
diagnostics.push({
|
|
268
|
+
severity: "error",
|
|
269
|
+
rule: "input_placeholder_missing",
|
|
270
|
+
endpoint: name, file, loc,
|
|
271
|
+
message: `\${${expr}} references input.${first}, but the endpoint's input schema has no '${first}' property.`,
|
|
272
|
+
fix_hint: `Valid properties: ${Object.keys(inputProps).join(", ")}`,
|
|
273
|
+
})
|
|
274
|
+
} else if (!hasSchema) {
|
|
275
|
+
// One warning per endpoint max — accumulate in a set
|
|
276
|
+
ctx.schemaless ??= new Set()
|
|
277
|
+
if (!ctx.schemaless.has(name)) {
|
|
278
|
+
ctx.schemaless.add(name)
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
severity: "warn",
|
|
281
|
+
rule: "no_input_schema",
|
|
282
|
+
endpoint: name, file,
|
|
283
|
+
message: `This endpoint uses \${input.*} but has no input schema declared.`,
|
|
284
|
+
fix_hint: `Add an input schema at top level so typos in placeholders can be caught: input: { type: object, properties: { ... } }`,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ${nodes.X.Y}
|
|
291
|
+
else if (e.startsWith("nodes.")) {
|
|
292
|
+
const nodeId = e.slice(6).split(/[.\[]/)[0]
|
|
293
|
+
if (!nodeIds.has(nodeId)) {
|
|
294
|
+
diagnostics.push({
|
|
295
|
+
severity: "error",
|
|
296
|
+
rule: "node_ref_missing",
|
|
297
|
+
endpoint: name, file, loc,
|
|
298
|
+
message: `\${${expr}} references node '${nodeId}' but that node is not declared in this workflow.`,
|
|
299
|
+
fix_hint: nodeIds.size
|
|
300
|
+
? `Known nodes: ${[...nodeIds].join(", ")}`
|
|
301
|
+
: "This endpoint has no nodes yet.",
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ${current_user_id} / ${current_user_role}
|
|
307
|
+
else if (e === "current_user_id" || e === "current_user_role") {
|
|
308
|
+
if (!jwt) {
|
|
309
|
+
diagnostics.push({
|
|
310
|
+
severity: "error",
|
|
311
|
+
rule: "current_user_without_jwt",
|
|
312
|
+
endpoint: name, file, loc,
|
|
313
|
+
message: `\${${e}} is only available when the endpoint uses http_api with auth_mode: jwt.`,
|
|
314
|
+
fix_hint: "Add `trigger: { http_api: { auth_mode: jwt } }` or remove the placeholder.",
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- SQL: collect referenced tables for later comparison against schema.sql ---
|
|
321
|
+
// Heuristic: look like SQL (contains SELECT/INSERT/UPDATE/DELETE/WITH)
|
|
322
|
+
if (/\b(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(value)) {
|
|
323
|
+
for (const t of extractSqlTables(value)) referencedTables.add(t)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- SQL table existence (if schema.sql available) ---
|
|
328
|
+
if (ctx.schemaTables) {
|
|
329
|
+
for (const table of referencedTables) {
|
|
330
|
+
if (!ctx.schemaTables.has(table)) {
|
|
331
|
+
diagnostics.push({
|
|
332
|
+
severity: "error",
|
|
333
|
+
rule: "sql_table_not_found",
|
|
334
|
+
endpoint: name, file,
|
|
335
|
+
message: `SQL references public.${table}, but that table does not exist in schema.sql.`,
|
|
336
|
+
fix_hint: `Check typos, or create the table first with execute_sql. Known tables: ${[...ctx.schemaTables].slice(0, 8).join(", ")}${ctx.schemaTables.size > 8 ? "…" : ""}`,
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
|
|
343
|
+
for (const node of doc.workflow?.nodes || []) {
|
|
344
|
+
// node_type exists in catalog?
|
|
345
|
+
if (ctx.knownTypes.size && !ctx.knownTypes.has(node.type)) {
|
|
346
|
+
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, node.type) <= 2).slice(0, 3)
|
|
347
|
+
diagnostics.push({
|
|
348
|
+
severity: "error",
|
|
349
|
+
rule: "unknown_node_type",
|
|
350
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].type`,
|
|
351
|
+
message: `Node type '${node.type}' is not registered.`,
|
|
352
|
+
fix_hint: suggestions.length
|
|
353
|
+
? `Did you mean: ${suggestions.join(", ")}?`
|
|
354
|
+
: `Run dypai_pull to refresh node-catalog.json. Or call search_nodes to discover.`,
|
|
355
|
+
})
|
|
356
|
+
continue // no point checking params of an unknown type
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Schema-based parameter validation (only when we have a schema for this node_type)
|
|
360
|
+
const schema = ctx.catalog[node.type]
|
|
361
|
+
if (schema?.inputs?.properties) {
|
|
362
|
+
const { properties, required = [] } = schema.inputs
|
|
363
|
+
// Ignore node metadata keys (id, type, variable, return, credential, *_file) — they're not "params"
|
|
364
|
+
const META_KEYS = new Set(["id", "type", "variable", "return", "credential", "query_file", "code_file", "system_prompt_file"])
|
|
365
|
+
const paramKeys = Object.keys(node).filter(k => !META_KEYS.has(k))
|
|
366
|
+
|
|
367
|
+
// Required params present? Severity: warn (not error) because current
|
|
368
|
+
// engine node_catalog schemas list all conditional params as "required"
|
|
369
|
+
// (e.g. dypai_database flags `data` AND `query` both required regardless
|
|
370
|
+
// of `operation`). Once schemas model conditional required properly
|
|
371
|
+
// (oneOf / dependentRequired), we can bump this back to error.
|
|
372
|
+
for (const req of required) {
|
|
373
|
+
if (!paramKeys.includes(req)) {
|
|
374
|
+
const hasFileEquivalent = META_KEYS.has(`${req}_file`) && node[`${req}_file`]
|
|
375
|
+
if (!hasFileEquivalent) {
|
|
376
|
+
diagnostics.push({
|
|
377
|
+
severity: "warn",
|
|
378
|
+
rule: "missing_required_param",
|
|
379
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
380
|
+
message: `Node '${node.id}' (type '${node.type}') may be missing parameter '${req}'.`,
|
|
381
|
+
fix_hint: `Schema lists required: [${required.join(", ")}]. Verify this param is actually needed for your operation.`,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Unknown/typo params?
|
|
388
|
+
for (const key of paramKeys) {
|
|
389
|
+
if (!properties[key]) {
|
|
390
|
+
const knownKeys = Object.keys(properties)
|
|
391
|
+
const suggestions = knownKeys.filter(k => levenshteinSmall(k, key) <= 2).slice(0, 2)
|
|
392
|
+
diagnostics.push({
|
|
393
|
+
severity: "warn",
|
|
394
|
+
rule: "unknown_param",
|
|
395
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
396
|
+
message: `Node '${node.id}' (type '${node.type}') has unknown parameter '${key}'.`,
|
|
397
|
+
fix_hint: suggestions.length
|
|
398
|
+
? `Did you mean: ${suggestions.join(", ")}?`
|
|
399
|
+
: `Valid params: ${knownKeys.slice(0, 8).join(", ")}${knownKeys.length > 8 ? "…" : ""}`,
|
|
400
|
+
})
|
|
401
|
+
} else {
|
|
402
|
+
// Enum / range checks for primitive values
|
|
403
|
+
const prop = properties[key]
|
|
404
|
+
const v = node[key]
|
|
405
|
+
if (prop.enum && typeof v === "string" && !prop.enum.includes(v)) {
|
|
406
|
+
diagnostics.push({
|
|
407
|
+
severity: "error",
|
|
408
|
+
rule: "param_enum_violation",
|
|
409
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
410
|
+
message: `Node '${node.id}' parameter '${key}' = '${v}' is not one of: ${prop.enum.join(", ")}.`,
|
|
411
|
+
fix_hint: `Allowed values: ${prop.enum.join(", ")}`,
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
if (prop.type === "number" || prop.type === "integer") {
|
|
415
|
+
if (typeof v === "number") {
|
|
416
|
+
if (prop.min != null && v < prop.min) {
|
|
417
|
+
diagnostics.push({
|
|
418
|
+
severity: "error",
|
|
419
|
+
rule: "param_range_violation",
|
|
420
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
421
|
+
message: `Node '${node.id}' parameter '${key}' = ${v} is below the minimum ${prop.min}.`,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
if (prop.max != null && v > prop.max) {
|
|
425
|
+
diagnostics.push({
|
|
426
|
+
severity: "error",
|
|
427
|
+
rule: "param_range_violation",
|
|
428
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
429
|
+
message: `Node '${node.id}' parameter '${key}' = ${v} is above the maximum ${prop.max}.`,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- Credential references ---
|
|
440
|
+
for (const node of doc.workflow?.nodes || []) {
|
|
441
|
+
const cred = node.credential
|
|
442
|
+
if (cred && !ctx.remoteCredentials.has(cred)) {
|
|
443
|
+
diagnostics.push({
|
|
444
|
+
severity: "error",
|
|
445
|
+
rule: "credential_not_found",
|
|
446
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].credential`,
|
|
447
|
+
message: `credential '${cred}' is not defined remotely.`,
|
|
448
|
+
fix_hint: ctx.remoteCredentials.size
|
|
449
|
+
? `Available: ${[...ctx.remoteCredentials].join(", ")}. Create '${cred}' in the dashboard first.`
|
|
450
|
+
: "No credentials configured yet. Create one in the dashboard with this exact name.",
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Agent tool references
|
|
455
|
+
if (node.type === "agent" && Array.isArray(node.tools)) {
|
|
456
|
+
for (const toolName of node.tools) {
|
|
457
|
+
if (!ctx.toolEndpoints.has(toolName)) {
|
|
458
|
+
diagnostics.push({
|
|
459
|
+
severity: "error",
|
|
460
|
+
rule: "agent_tool_not_found",
|
|
461
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].tools`,
|
|
462
|
+
message: `agent references tool '${toolName}' but no endpoint with that name is marked is_tool: true.`,
|
|
463
|
+
fix_hint: ctx.toolEndpoints.size
|
|
464
|
+
? `Available tool endpoints: ${[...ctx.toolEndpoints].join(", ")}`
|
|
465
|
+
: "No tool endpoints exist yet. Set `tool: true` on the YAML of an existing endpoint to expose it.",
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return diagnostics
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
export async function runValidation(rootDir, projectId) {
|
|
478
|
+
// Load everything in parallel
|
|
479
|
+
const config = await readLocalConfig(rootDir)
|
|
480
|
+
const targetProjectId = projectId || config?.project_id || null
|
|
481
|
+
|
|
482
|
+
const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
|
|
483
|
+
readLocalState(rootDir),
|
|
484
|
+
fetchRemoteState(targetProjectId),
|
|
485
|
+
readSchemaTables(rootDir),
|
|
486
|
+
loadNodeCatalog(rootDir),
|
|
487
|
+
])
|
|
488
|
+
|
|
489
|
+
// Build context for the rules
|
|
490
|
+
const remoteCredentials = new Set(Object.keys(remote.mapsCtx.credNameToId || {}))
|
|
491
|
+
// Tool endpoints come from LOCAL YAMLs — the ones marked `tool: true`
|
|
492
|
+
const toolEndpoints = new Set(
|
|
493
|
+
Object.values(local.byName)
|
|
494
|
+
.filter(e => e.doc?.tool === true)
|
|
495
|
+
.map(e => e.doc.name)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
const ctx = {
|
|
499
|
+
remoteCredentials,
|
|
500
|
+
toolEndpoints,
|
|
501
|
+
schemaTables,
|
|
502
|
+
catalog: nodeCatalog.schemas,
|
|
503
|
+
knownTypes: nodeCatalog.knownTypes,
|
|
504
|
+
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const diagnostics = []
|
|
508
|
+
for (const entry of Object.values(local.byName)) {
|
|
509
|
+
diagnostics.push(...validateEndpoint(entry, ctx))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Realtime YAML rules
|
|
513
|
+
diagnostics.push(...await validateRealtime(rootDir, ctx))
|
|
514
|
+
|
|
515
|
+
// Surface any file-read errors too
|
|
516
|
+
for (const err of local.errors || []) {
|
|
517
|
+
diagnostics.push({
|
|
518
|
+
severity: "error",
|
|
519
|
+
rule: "file_read_error",
|
|
520
|
+
file: err.file,
|
|
521
|
+
message: err.error,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const errors = diagnostics.filter(d => d.severity === "error")
|
|
526
|
+
const warnings = diagnostics.filter(d => d.severity === "warn")
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
success: errors.length === 0,
|
|
530
|
+
summary: {
|
|
531
|
+
total: diagnostics.length,
|
|
532
|
+
errors: errors.length,
|
|
533
|
+
warnings: warnings.length,
|
|
534
|
+
endpoints_checked: Object.keys(local.byName).length,
|
|
535
|
+
schema_sql_available: schemaTables !== null,
|
|
536
|
+
node_catalog_nodes: nodeCatalog.knownTypes.size,
|
|
537
|
+
node_catalog_missing: nodeCatalog.missing,
|
|
538
|
+
node_catalog_has_schemas: Object.values(nodeCatalog.schemas).some(s => s?.inputs?.properties),
|
|
539
|
+
},
|
|
540
|
+
diagnostics,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export const dypaiValidateTool = {
|
|
545
|
+
name: "dypai_validate",
|
|
546
|
+
description:
|
|
547
|
+
"Lint the local dypai/ folder BEFORE pushing. Catches ${input.x} / ${nodes.x.y} / ${current_user_*} refs that don't resolve, " +
|
|
548
|
+
"SQL tables not in schema.sql, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
|
|
549
|
+
"Run this after editing YAMLs to catch typos pre-flight. Push already calls it by default — pass skip_validation:true to override.",
|
|
550
|
+
inputSchema: {
|
|
551
|
+
type: "object",
|
|
552
|
+
properties: {
|
|
553
|
+
project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml if omitted." },
|
|
554
|
+
root_dir: { type: "string", default: "./dypai" },
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
async execute({ project_id, root_dir = "./dypai" } = {}) {
|
|
558
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
559
|
+
const result = await runValidation(rootDir, project_id)
|
|
560
|
+
return {
|
|
561
|
+
...result,
|
|
562
|
+
hint: result.success
|
|
563
|
+
? undefined
|
|
564
|
+
: `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
}
|