@dypai-ai/mcp 1.2.3 → 1.3.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 +1 -1
- package/src/index.js +535 -15
- package/src/tools/bulk-upsert.js +69 -12
- package/src/tools/frontend.js +37 -11
- package/src/tools/proxy.js +33 -20
- package/src/tools/scaffold.js +33 -3
- package/src/tools/sync/codec.js +160 -16
- package/src/tools/sync/diff.js +8 -0
- package/src/tools/sync/planner.js +16 -1
- package/src/tools/sync/pull.js +363 -3
- package/src/tools/sync/push.js +8 -0
- package/src/tools/sync/test-endpoint.js +76 -3
- package/src/tools/sync/transforms.js +5 -0
- package/src/tools/sync/validate.js +342 -29
- package/src/tools/sync.js +133 -0
- package/src/tools/trace-summarize.js +8 -0
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
* mean push will refuse (unless skip_validation is passed).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFile } from "fs/promises"
|
|
16
|
+
import { readFile, writeFile, stat } from "fs/promises"
|
|
17
17
|
import { join, resolve as resolvePath } from "path"
|
|
18
18
|
import { fetchRemoteState, readLocalState, readLocalConfig, readLocalRealtime } from "./planner.js"
|
|
19
|
+
import { proxyToolCall } from "../proxy.js"
|
|
20
|
+
import { dumpPublicSchema } from "./schema-dump.js"
|
|
21
|
+
|
|
22
|
+
/** schema.sql is considered "fresh" if it was written less than this ago.
|
|
23
|
+
* Within the freshness window, we trust it without re-checking the remote.
|
|
24
|
+
* Outside the window, sql_table_not_found triggers a remote verification. */
|
|
25
|
+
const SCHEMA_FRESHNESS_MS = 5 * 60 * 1000 // 5 minutes
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* Validate dypai/realtime.yaml against known schemas/tables. Rules:
|
|
@@ -133,13 +140,15 @@ async function loadNodeCatalog(rootDir) {
|
|
|
133
140
|
const parsed = JSON.parse(raw)
|
|
134
141
|
const schemas = {}
|
|
135
142
|
const knownTypes = new Set()
|
|
143
|
+
const isNonEmptyObj = (o) => o && typeof o === "object" && !Array.isArray(o) && Object.keys(o).length > 0
|
|
136
144
|
for (const [nodeType, data] of Object.entries(parsed.nodes || {})) {
|
|
145
|
+
if (!data || typeof data !== "object") continue
|
|
137
146
|
knownTypes.add(nodeType)
|
|
138
147
|
schemas[nodeType] = {
|
|
139
148
|
label: data.label,
|
|
140
149
|
description: data.description,
|
|
141
|
-
inputs:
|
|
142
|
-
outputs:
|
|
150
|
+
inputs: isNonEmptyObj(data.inputs) ? data.inputs : null,
|
|
151
|
+
outputs: isNonEmptyObj(data.outputs) ? data.outputs : null,
|
|
143
152
|
}
|
|
144
153
|
}
|
|
145
154
|
return { schemas, knownTypes, missing: false }
|
|
@@ -321,19 +330,27 @@ function validateEndpoint(entry, ctx) {
|
|
|
321
330
|
// Heuristic: look like SQL (contains SELECT/INSERT/UPDATE/DELETE/WITH)
|
|
322
331
|
if (/\b(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(value)) {
|
|
323
332
|
for (const t of extractSqlTables(value)) referencedTables.add(t)
|
|
333
|
+
|
|
334
|
+
// NOTE: an earlier version of this validator warned about manual
|
|
335
|
+
// `'${current_user_id}'::uuid` casts as "redundant", under the assumption
|
|
336
|
+
// that the engine auto-cast UUID-shaped values. That auto-cast was
|
|
337
|
+
// removed because it broke postgres.js binding in production. Manual
|
|
338
|
+
// ::uuid casts are now legitimate again, so no warning is emitted here.
|
|
324
339
|
}
|
|
325
340
|
}
|
|
326
341
|
|
|
327
342
|
// --- SQL table existence (if schema.sql available) ---
|
|
343
|
+
// Defer the actual error emission to runValidation: it batch-checks all
|
|
344
|
+
// missing tables against the remote in ONE query before deciding what's
|
|
345
|
+
// a real error vs a stale-local-schema. We just collect the misses here
|
|
346
|
+
// along with enough context for runValidation to emit per-endpoint errors.
|
|
328
347
|
if (ctx.schemaTables) {
|
|
329
348
|
for (const table of referencedTables) {
|
|
330
349
|
if (!ctx.schemaTables.has(table)) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 ? "…" : ""}`,
|
|
350
|
+
ctx.suspectedMissingTables.push({
|
|
351
|
+
table,
|
|
352
|
+
endpoint: name,
|
|
353
|
+
file,
|
|
337
354
|
})
|
|
338
355
|
}
|
|
339
356
|
}
|
|
@@ -341,14 +358,144 @@ function validateEndpoint(entry, ctx) {
|
|
|
341
358
|
|
|
342
359
|
// --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
|
|
343
360
|
for (const node of doc.workflow?.nodes || []) {
|
|
361
|
+
if (!node || typeof node !== "object") continue
|
|
362
|
+
// Tolerate both `type` and `node_type` (the codec accepts either).
|
|
363
|
+
const nodeType = node.type ?? node.node_type
|
|
364
|
+
// Legacy `start_trigger` node — the codec lifts it transparently, but
|
|
365
|
+
// surface a warning so the agent learns the canonical form (top-level trigger:).
|
|
366
|
+
if (nodeType === "start_trigger") {
|
|
367
|
+
diagnostics.push({
|
|
368
|
+
severity: "warn",
|
|
369
|
+
rule: "obsolete_start_trigger_node",
|
|
370
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
371
|
+
message: `'start_trigger' is no longer a node. The codec lifted it to the top-level trigger: block automatically — but please remove this node from the YAML.`,
|
|
372
|
+
fix_hint: "Move trigger config to top-level `trigger: { http_api: { auth_mode: jwt } }` (or webhook/schedule/telegram) and delete this node + any edges that referenced it.",
|
|
373
|
+
})
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if (!nodeType) {
|
|
377
|
+
diagnostics.push({
|
|
378
|
+
severity: "error",
|
|
379
|
+
rule: "missing_node_type",
|
|
380
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id || "?"}]`,
|
|
381
|
+
message: `Node '${node.id || "(no id)"}' is missing 'type'.`,
|
|
382
|
+
fix_hint: "Add `type: <node_type>` to this node. Use `dypai_pull` to refresh `node-catalog.json` for the list of valid types.",
|
|
383
|
+
})
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// dypai_database — coherence checks for the new canonical operations.
|
|
388
|
+
if (nodeType === "dypai_database") {
|
|
389
|
+
const op = node.operation
|
|
390
|
+
const LEGACY_OPS = new Set(["select", "insert", "update", "delete", "upsert", "aggregate", "copy_to", "custom_query"])
|
|
391
|
+
if (op && LEGACY_OPS.has(op)) {
|
|
392
|
+
const suggested = (op === "custom_query") ? "query" : (op === "select") ? "query" : "mutation"
|
|
393
|
+
const example = suggested === "query"
|
|
394
|
+
? `operation: query, query: "..."`
|
|
395
|
+
: `operation: mutation, table: "...", ${op}: { ... }${op === "delete" ? "" : ", where: { ... }"}`
|
|
396
|
+
diagnostics.push({
|
|
397
|
+
severity: "warn",
|
|
398
|
+
rule: "legacy_database_operation",
|
|
399
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].operation`,
|
|
400
|
+
message: `Operation '${op}' is legacy. The canonical form is '${suggested}'.`,
|
|
401
|
+
fix_hint: `Replace with: ${example}. Both still execute today; use the canonical form for new endpoints.`,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── operation: mutation coherence ─────────────────────────────────────
|
|
406
|
+
// mutation requires `table:` and exactly one of insert/update/delete.
|
|
407
|
+
// Fields like `query`, `query_file`, `params` belong to `operation: query`
|
|
408
|
+
// and are silently ignored here — surface as ERROR so the agent reroutes.
|
|
409
|
+
if (op === "mutation") {
|
|
410
|
+
if (!node.table) {
|
|
411
|
+
diagnostics.push({
|
|
412
|
+
severity: "error",
|
|
413
|
+
rule: "mutation_missing_table",
|
|
414
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
415
|
+
message: `Node '${node.id}' uses 'operation: mutation' but is missing the required 'table:' field.`,
|
|
416
|
+
fix_hint: `Add 'table: <name>'. mutation also needs exactly one of: insert / update / delete.`,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
const wantsInsert = node.insert !== undefined && node.insert !== null
|
|
420
|
+
const wantsUpdate = node.update !== undefined && node.update !== null
|
|
421
|
+
const wantsDelete = node.delete === true
|
|
422
|
+
const opCount = [wantsInsert, wantsUpdate, wantsDelete].filter(Boolean).length
|
|
423
|
+
if (opCount === 0) {
|
|
424
|
+
diagnostics.push({
|
|
425
|
+
severity: "error",
|
|
426
|
+
rule: "mutation_missing_action",
|
|
427
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
428
|
+
message: `Node '${node.id}' uses 'operation: mutation' but declares none of: insert / update / delete.`,
|
|
429
|
+
fix_hint: `Add ONE of: 'insert: { ... }', 'update: { ... } + where: { ... }', or 'delete: true + where: { ... }'.`,
|
|
430
|
+
})
|
|
431
|
+
} else if (opCount > 1) {
|
|
432
|
+
diagnostics.push({
|
|
433
|
+
severity: "error",
|
|
434
|
+
rule: "mutation_multiple_actions",
|
|
435
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
436
|
+
message: `Node '${node.id}' uses 'operation: mutation' with multiple actions set. Use one per node.`,
|
|
437
|
+
fix_hint: `Pick exactly one: insert OR update OR delete (split into separate nodes if you need more).`,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
if ((wantsUpdate || wantsDelete) && !node.where) {
|
|
441
|
+
diagnostics.push({
|
|
442
|
+
severity: "error",
|
|
443
|
+
rule: "mutation_missing_where",
|
|
444
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
445
|
+
message: `Node '${node.id}' (mutation ${wantsUpdate ? "update" : "delete"}) is missing 'where:' — refusing to operate on every row.`,
|
|
446
|
+
fix_hint: `Add 'where: { id: \${input.id}, user_id: \${current_user_id} }' (or whatever filter applies).`,
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
// Foreign fields that belong to `operation: query`
|
|
450
|
+
const QUERY_ONLY = ["query", "query_file", "params"]
|
|
451
|
+
for (const k of QUERY_ONLY) {
|
|
452
|
+
if (node[k] !== undefined) {
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
severity: "error",
|
|
455
|
+
rule: "mutation_with_query_field",
|
|
456
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}].${k}`,
|
|
457
|
+
message: `Node '${node.id}' uses 'operation: mutation' but declares '${k}' (only valid for 'operation: query').`,
|
|
458
|
+
fix_hint: `If this is raw SQL, change to 'operation: query'. If it's declarative CRUD, remove '${k}' and use insert/update/delete + where.`,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── operation: query coherence ────────────────────────────────────────
|
|
465
|
+
// query needs SQL: either inline `query:` or external `query_file:`.
|
|
466
|
+
// Fields like `table`, `insert`, `update`, `delete`, `where`, `on_conflict`,
|
|
467
|
+
// `returning` belong to `operation: mutation` and are ignored here.
|
|
468
|
+
if (op === "query") {
|
|
469
|
+
if (!node.query && !node.query_file) {
|
|
470
|
+
diagnostics.push({
|
|
471
|
+
severity: "error",
|
|
472
|
+
rule: "query_missing_sql",
|
|
473
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
474
|
+
message: `Node '${node.id}' uses 'operation: query' but is missing both 'query:' and 'query_file:'.`,
|
|
475
|
+
fix_hint: `Add 'query: "SELECT ..."' for inline SQL, or 'query_file: sql/<name>.sql' for an external file.`,
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
const MUTATION_ONLY = ["table", "insert", "update", "delete", "where", "on_conflict", "returning"]
|
|
479
|
+
const foreign = MUTATION_ONLY.filter(k => node[k] !== undefined && node[k] !== false)
|
|
480
|
+
if (foreign.length > 0) {
|
|
481
|
+
diagnostics.push({
|
|
482
|
+
severity: "error",
|
|
483
|
+
rule: "query_with_mutation_field",
|
|
484
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
485
|
+
message: `Node '${node.id}' uses 'operation: query' but declares mutation-only fields: ${foreign.join(", ")}.`,
|
|
486
|
+
fix_hint: `If this is declarative CRUD, change to 'operation: mutation'. If it's raw SQL, remove ${foreign.join(", ")} from the node.`,
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
344
491
|
// node_type exists in catalog?
|
|
345
|
-
if (ctx.knownTypes.size && !ctx.knownTypes.has(
|
|
346
|
-
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t,
|
|
492
|
+
if (ctx.knownTypes.size && !ctx.knownTypes.has(nodeType)) {
|
|
493
|
+
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, nodeType) <= 2).slice(0, 3)
|
|
347
494
|
diagnostics.push({
|
|
348
495
|
severity: "error",
|
|
349
496
|
rule: "unknown_node_type",
|
|
350
497
|
endpoint: name, file, loc: `workflow.nodes[${node.id}].type`,
|
|
351
|
-
message: `Node type '${
|
|
498
|
+
message: `Node type '${nodeType}' is not registered.`,
|
|
352
499
|
fix_hint: suggestions.length
|
|
353
500
|
? `Did you mean: ${suggestions.join(", ")}?`
|
|
354
501
|
: `Run dypai_pull to refresh node-catalog.json. Or call search_nodes to discover.`,
|
|
@@ -357,19 +504,46 @@ function validateEndpoint(entry, ctx) {
|
|
|
357
504
|
}
|
|
358
505
|
|
|
359
506
|
// Schema-based parameter validation (only when we have a schema for this node_type)
|
|
360
|
-
const schema = ctx.catalog[
|
|
507
|
+
const schema = ctx.catalog[nodeType]
|
|
361
508
|
if (schema?.inputs?.properties) {
|
|
362
509
|
const { properties, required = [] } = schema.inputs
|
|
363
510
|
// Ignore node metadata keys (id, type, variable, return, credential, *_file) — they're not "params"
|
|
364
511
|
const META_KEYS = new Set(["id", "type", "variable", "return", "credential", "query_file", "code_file", "system_prompt_file"])
|
|
365
512
|
const paramKeys = Object.keys(node).filter(k => !META_KEYS.has(k))
|
|
366
513
|
|
|
367
|
-
// Required
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
514
|
+
// Required-param check.
|
|
515
|
+
//
|
|
516
|
+
// We respect the schema as-is: top-level `required` are universally
|
|
517
|
+
// required; `allOf[].if/then` blocks express conditional requirements
|
|
518
|
+
// driven by sibling fields (e.g. `query` is required only when
|
|
519
|
+
// `operation: query`). The engine's parametersToJsonSchema emits these
|
|
520
|
+
// automatically from displayOptions.show — so the catalog is the single
|
|
521
|
+
// source of truth and we don't hardcode node names anywhere.
|
|
522
|
+
const allRequired = []
|
|
523
|
+
|
|
524
|
+
// Universal requirements
|
|
525
|
+
for (const req of required) allRequired.push({ name: req, condition: null })
|
|
526
|
+
|
|
527
|
+
// Conditional requirements from allOf[]
|
|
528
|
+
const allOf = Array.isArray(schema.inputs?.allOf) ? schema.inputs.allOf : []
|
|
529
|
+
for (const rule of allOf) {
|
|
530
|
+
const ifProps = rule?.if?.properties || {}
|
|
531
|
+
const thenRequired = rule?.then?.required || []
|
|
532
|
+
const selectorField = Object.keys(ifProps)[0]
|
|
533
|
+
const selectorValues = ifProps[selectorField]?.enum || []
|
|
534
|
+
if (!selectorField || selectorValues.length === 0) continue
|
|
535
|
+
// Activates only if the node's value for selectorField matches one of selectorValues.
|
|
536
|
+
const nodeValue = node[selectorField]
|
|
537
|
+
if (!selectorValues.includes(nodeValue)) continue
|
|
538
|
+
for (const req of thenRequired) {
|
|
539
|
+
allRequired.push({
|
|
540
|
+
name: req,
|
|
541
|
+
condition: `${selectorField}=${nodeValue}`,
|
|
542
|
+
})
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (const { name: req, condition } of allRequired) {
|
|
373
547
|
if (!paramKeys.includes(req)) {
|
|
374
548
|
const hasFileEquivalent = META_KEYS.has(`${req}_file`) && node[`${req}_file`]
|
|
375
549
|
if (!hasFileEquivalent) {
|
|
@@ -377,8 +551,12 @@ function validateEndpoint(entry, ctx) {
|
|
|
377
551
|
severity: "warn",
|
|
378
552
|
rule: "missing_required_param",
|
|
379
553
|
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
380
|
-
message:
|
|
381
|
-
|
|
554
|
+
message: condition
|
|
555
|
+
? `Node '${node.id}' (type '${nodeType}') is missing parameter '${req}' required when ${condition}.`
|
|
556
|
+
: `Node '${node.id}' (type '${nodeType}') is missing required parameter '${req}'.`,
|
|
557
|
+
fix_hint: condition
|
|
558
|
+
? `Add '${req}: ...' to this node, or change the value of '${condition.split("=")[0]}'.`
|
|
559
|
+
: `Add '${req}: ...' to this node.`,
|
|
382
560
|
})
|
|
383
561
|
}
|
|
384
562
|
}
|
|
@@ -393,7 +571,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
393
571
|
severity: "warn",
|
|
394
572
|
rule: "unknown_param",
|
|
395
573
|
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
396
|
-
message: `Node '${node.id}' (type '${
|
|
574
|
+
message: `Node '${node.id}' (type '${nodeType}') has unknown parameter '${key}'.`,
|
|
397
575
|
fix_hint: suggestions.length
|
|
398
576
|
? `Did you mean: ${suggestions.join(", ")}?`
|
|
399
577
|
: `Valid params: ${knownKeys.slice(0, 8).join(", ")}${knownKeys.length > 8 ? "…" : ""}`,
|
|
@@ -438,6 +616,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
438
616
|
|
|
439
617
|
// --- Credential references ---
|
|
440
618
|
for (const node of doc.workflow?.nodes || []) {
|
|
619
|
+
if (!node || typeof node !== "object") continue
|
|
441
620
|
const cred = node.credential
|
|
442
621
|
if (cred && !ctx.remoteCredentials.has(cred)) {
|
|
443
622
|
diagnostics.push({
|
|
@@ -452,7 +631,8 @@ function validateEndpoint(entry, ctx) {
|
|
|
452
631
|
}
|
|
453
632
|
|
|
454
633
|
// Agent tool references
|
|
455
|
-
|
|
634
|
+
const nodeType = node.type ?? node.node_type
|
|
635
|
+
if (nodeType === "agent" && Array.isArray(node.tools)) {
|
|
456
636
|
for (const toolName of node.tools) {
|
|
457
637
|
if (!ctx.toolEndpoints.has(toolName)) {
|
|
458
638
|
diagnostics.push({
|
|
@@ -472,6 +652,65 @@ function validateEndpoint(entry, ctx) {
|
|
|
472
652
|
return diagnostics
|
|
473
653
|
}
|
|
474
654
|
|
|
655
|
+
// ─── Schema staleness detection ─────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Returns mtime of dypai/schema.sql as Date, or null if missing.
|
|
659
|
+
*/
|
|
660
|
+
async function getSchemaFileAge(rootDir) {
|
|
661
|
+
try {
|
|
662
|
+
const s = await stat(join(rootDir, "schema.sql"))
|
|
663
|
+
return s.mtime
|
|
664
|
+
} catch {
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Returns true if schema.sql was written less than SCHEMA_FRESHNESS_MS ago.
|
|
671
|
+
* Within the freshness window we skip remote staleness checks.
|
|
672
|
+
*/
|
|
673
|
+
async function isSchemaFresh(rootDir) {
|
|
674
|
+
const mtime = await getSchemaFileAge(rootDir)
|
|
675
|
+
if (!mtime) return false
|
|
676
|
+
return (Date.now() - mtime.getTime()) < SCHEMA_FRESHNESS_MS
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Batch-check which of the given tables exist in the remote DB. Returns the
|
|
681
|
+
* subset that DO exist. Single SQL round-trip via execute_sql.
|
|
682
|
+
*
|
|
683
|
+
* Tolerant on failure: if the check itself fails (auth, network), returns null
|
|
684
|
+
* and the caller treats every missing table as a real "table not found" error.
|
|
685
|
+
*/
|
|
686
|
+
async function verifyTablesInRemote(missingTables, projectId) {
|
|
687
|
+
if (!missingTables.length) return new Set()
|
|
688
|
+
if (!projectId) return null
|
|
689
|
+
try {
|
|
690
|
+
const list = missingTables.map(t => `'${String(t).replace(/'/g, "''")}'`).join(",")
|
|
691
|
+
const sql = `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN (${list})`
|
|
692
|
+
const result = await proxyToolCall("execute_sql", { project_id: projectId, sql })
|
|
693
|
+
const rows = Array.isArray(result?.rows) ? result.rows : []
|
|
694
|
+
return new Set(rows.map(r => r.table_name).filter(Boolean))
|
|
695
|
+
} catch {
|
|
696
|
+
return null
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Refresh dypai/schema.sql from the remote. Best-effort — if it fails the
|
|
702
|
+
* caller continues with the existing local schema (just logs the warning).
|
|
703
|
+
*/
|
|
704
|
+
async function refreshSchemaSql(rootDir, projectId) {
|
|
705
|
+
try {
|
|
706
|
+
const fresh = await dumpPublicSchema(projectId)
|
|
707
|
+
await writeFile(join(rootDir, "schema.sql"), fresh, "utf8")
|
|
708
|
+
return true
|
|
709
|
+
} catch {
|
|
710
|
+
return false
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
475
714
|
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
476
715
|
|
|
477
716
|
export async function runValidation(rootDir, projectId) {
|
|
@@ -502,6 +741,9 @@ export async function runValidation(rootDir, projectId) {
|
|
|
502
741
|
catalog: nodeCatalog.schemas,
|
|
503
742
|
knownTypes: nodeCatalog.knownTypes,
|
|
504
743
|
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
|
|
744
|
+
// Collected during per-endpoint pass; resolved against the remote AFTER
|
|
745
|
+
// all endpoints are checked (one batch query instead of N).
|
|
746
|
+
suspectedMissingTables: [],
|
|
505
747
|
}
|
|
506
748
|
|
|
507
749
|
const diagnostics = []
|
|
@@ -509,6 +751,66 @@ export async function runValidation(rootDir, projectId) {
|
|
|
509
751
|
diagnostics.push(...validateEndpoint(entry, ctx))
|
|
510
752
|
}
|
|
511
753
|
|
|
754
|
+
// ── Resolve suspected missing tables against the remote ──────────────────
|
|
755
|
+
// The local schema.sql may be stale (someone pushed DDL outside our flow).
|
|
756
|
+
// Verify each missing table against information_schema BEFORE crying error.
|
|
757
|
+
// Skip the remote check entirely if schema.sql is fresh (recently refreshed).
|
|
758
|
+
let schemaWasRefreshed = false
|
|
759
|
+
if (ctx.suspectedMissingTables.length > 0) {
|
|
760
|
+
const uniqueTables = [...new Set(ctx.suspectedMissingTables.map(s => s.table))]
|
|
761
|
+
const fresh = await isSchemaFresh(rootDir)
|
|
762
|
+
let realMissing = new Set(uniqueTables)
|
|
763
|
+
|
|
764
|
+
if (!fresh && targetProjectId) {
|
|
765
|
+
// Schema is old → ask the remote which of these tables actually exist.
|
|
766
|
+
const existsInRemote = await verifyTablesInRemote(uniqueTables, targetProjectId)
|
|
767
|
+
if (existsInRemote !== null) {
|
|
768
|
+
// Remove tables that exist remotely from the "real missing" set.
|
|
769
|
+
const nowKnown = [...uniqueTables].filter(t => existsInRemote.has(t))
|
|
770
|
+
realMissing = new Set(uniqueTables.filter(t => !existsInRemote.has(t)))
|
|
771
|
+
|
|
772
|
+
if (nowKnown.length > 0) {
|
|
773
|
+
// Local schema was stale — refresh it so subsequent runs are accurate.
|
|
774
|
+
schemaWasRefreshed = await refreshSchemaSql(rootDir, targetProjectId)
|
|
775
|
+
// Add the now-known tables to ctx.schemaTables so any callers that
|
|
776
|
+
// re-use this validation result see a consistent picture.
|
|
777
|
+
if (ctx.schemaTables) for (const t of nowKnown) ctx.schemaTables.add(t)
|
|
778
|
+
diagnostics.push({
|
|
779
|
+
severity: "warn",
|
|
780
|
+
rule: "schema_was_stale",
|
|
781
|
+
message: `Local dypai/schema.sql was out of date — auto-refreshed from remote (added: ${nowKnown.join(", ")}).`,
|
|
782
|
+
fix_hint: "No action needed. The local file is now in sync.",
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Emit one error per (endpoint, missing_table) for the tables that really don't exist.
|
|
789
|
+
const knownTablesList = ctx.schemaTables
|
|
790
|
+
? [...ctx.schemaTables].slice(0, 8).join(", ") + (ctx.schemaTables.size > 8 ? "…" : "")
|
|
791
|
+
: "(none yet)"
|
|
792
|
+
for (const { table, endpoint, file } of ctx.suspectedMissingTables) {
|
|
793
|
+
if (realMissing.has(table)) {
|
|
794
|
+
diagnostics.push({
|
|
795
|
+
severity: "error",
|
|
796
|
+
rule: "sql_table_not_found",
|
|
797
|
+
endpoint, file,
|
|
798
|
+
message: `SQL references public.${table}, but that table does not exist in the database.`,
|
|
799
|
+
fix_hint:
|
|
800
|
+
`Create it first via execute_sql. Example:\n` +
|
|
801
|
+
` execute_sql({ sql: "CREATE TABLE public.${table} (\\n` +
|
|
802
|
+
` id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\\n` +
|
|
803
|
+
` user_id TEXT NOT NULL,\\n` +
|
|
804
|
+
` created_at TIMESTAMPTZ DEFAULT NOW()\\n` +
|
|
805
|
+
` );" })\n` +
|
|
806
|
+
`IMPORTANT: user_id must be TEXT (not UUID) — better-auth's auth.user.id is a 32-char nanoid stored as TEXT. ` +
|
|
807
|
+
`(Add other columns matching what your endpoint INSERTs.) ` +
|
|
808
|
+
`schema.sql will auto-refresh after the DDL. Existing tables: ${knownTablesList}`,
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
512
814
|
// Realtime YAML rules
|
|
513
815
|
diagnostics.push(...await validateRealtime(rootDir, ctx))
|
|
514
816
|
|
|
@@ -556,12 +858,23 @@ export const dypaiValidateTool = {
|
|
|
556
858
|
},
|
|
557
859
|
async execute({ project_id, root_dir = "./dypai" } = {}) {
|
|
558
860
|
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
861
|
+
try {
|
|
862
|
+
const result = await runValidation(rootDir, project_id)
|
|
863
|
+
return {
|
|
864
|
+
...result,
|
|
865
|
+
hint: result.success
|
|
866
|
+
? undefined
|
|
867
|
+
: `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
|
|
868
|
+
}
|
|
869
|
+
} catch (e) {
|
|
870
|
+
// Surface the real stack so failures during validation don't look like
|
|
871
|
+
// a generic "undefined.length" mystery to the agent.
|
|
872
|
+
return {
|
|
873
|
+
success: false,
|
|
874
|
+
error: `Validation crashed: ${e.message}`,
|
|
875
|
+
stack: (e.stack || "").split("\n").slice(0, 5).join("\n"),
|
|
876
|
+
hint: "This is a bug in the local MCP. Report the stack above; in the meantime you can pass skip_validation: true to dypai_push to bypass.",
|
|
877
|
+
}
|
|
565
878
|
}
|
|
566
879
|
},
|
|
567
880
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncFromRemote — Mirror of `deployFromSource`.
|
|
3
|
+
*
|
|
4
|
+
* Fetches the project's source from the API (which reads it from GitHub
|
|
5
|
+
* server-side and returns a JSON `{ files: [{ path, content }] }` payload —
|
|
6
|
+
* same shape the deploy uses, just reversed direction) and writes each file
|
|
7
|
+
* to disk under `targetDirectory`.
|
|
8
|
+
*
|
|
9
|
+
* Safety: refuses to write into a directory that already contains a
|
|
10
|
+
* `package.json` unless `overwrite: true`. When overwriting, only files
|
|
11
|
+
* present in the remote ZIP are touched — local-only files (.env,
|
|
12
|
+
* node_modules, .vscode, etc.) are preserved.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs"
|
|
16
|
+
import { join, dirname } from "path"
|
|
17
|
+
import { api } from "../api.js"
|
|
18
|
+
|
|
19
|
+
export async function syncFromRemote({ project_id, targetDirectory, overwrite = false }) {
|
|
20
|
+
if (!project_id) {
|
|
21
|
+
return { success: false, error: "project_id is required." }
|
|
22
|
+
}
|
|
23
|
+
if (!targetDirectory) {
|
|
24
|
+
return { success: false, error: "targetDirectory is required (absolute path where the source will be written)." }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Refuse to clobber an existing project unless asked.
|
|
28
|
+
// Heuristic: presence of package.json (or any file at the root) means "in use".
|
|
29
|
+
if (existsSync(join(targetDirectory, "package.json")) && !overwrite) {
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
error: `targetDirectory '${targetDirectory}' already contains a package.json. ` +
|
|
33
|
+
`Pass overwrite: true to replace files (local-only files like .env are preserved). ` +
|
|
34
|
+
`Or pick an empty directory.`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pull the JSON payload — same shape the deploy uses, just reversed.
|
|
39
|
+
let payload
|
|
40
|
+
try {
|
|
41
|
+
payload = await api.get(`/api/engine/${project_id}/frontend/source`)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { success: false, error: `Failed to fetch source from API: ${e.message}` }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!payload || !Array.isArray(payload.files) || payload.files.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
error: "API returned no files. The project may be empty or the GitHub repo unreachable.",
|
|
50
|
+
raw: payload,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Make sure the target directory exists. mkdir is recursive, idempotent.
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(targetDirectory, { recursive: true })
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { success: false, error: `Could not create targetDirectory '${targetDirectory}': ${e.message}` }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let written = 0
|
|
62
|
+
const failures = []
|
|
63
|
+
|
|
64
|
+
for (const file of payload.files) {
|
|
65
|
+
if (!file?.path || typeof file.content !== "string") {
|
|
66
|
+
failures.push({ path: file?.path ?? "<unknown>", reason: "Malformed file entry from API" })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Defense in depth: refuse anything that tries to escape targetDirectory.
|
|
71
|
+
// The API strips the GitHub wrapper but we re-validate so a future bug
|
|
72
|
+
// there can't lead to writes outside the intended folder.
|
|
73
|
+
if (file.path.startsWith("/") || file.path.includes("..")) {
|
|
74
|
+
failures.push({ path: file.path, reason: "Suspicious path (absolute or contains '..'); skipped." })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fullPath = join(targetDirectory, file.path)
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dirname(fullPath), { recursive: true })
|
|
81
|
+
// `content` is base64 from the API (uniform encoding, binary-safe —
|
|
82
|
+
// same convention as the deploy). Decode to a Buffer so binaries
|
|
83
|
+
// (images, fonts, etc.) round-trip cleanly.
|
|
84
|
+
writeFileSync(fullPath, Buffer.from(file.content, "base64"))
|
|
85
|
+
written++
|
|
86
|
+
} catch (e) {
|
|
87
|
+
failures.push({ path: file.path, reason: e.message })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Structured next_steps so the agent can act without re-prompting the LLM.
|
|
92
|
+
// `npm install` is always suggested (idempotent and cheap). When we overwrote
|
|
93
|
+
// an existing project the user's local files removed upstream are NOT
|
|
94
|
+
// deleted by sync — flag that so the agent can surface it.
|
|
95
|
+
const next_steps = ["Run `npm install` in the target directory if dependencies changed."]
|
|
96
|
+
if (overwrite) {
|
|
97
|
+
next_steps.push(
|
|
98
|
+
"Sync does not delete local files that were removed upstream. Review the target directory for stale files and remove them manually if needed."
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// .env is gitignored → the source API does NOT return it → the frontend won't
|
|
103
|
+
// know the engine URL. If it's missing after sync, tell the agent to create it.
|
|
104
|
+
// Engine URL convention: https://<project_id>.dypai.app (override with DYPAI_ENGINE_BASE).
|
|
105
|
+
const envMissing = !existsSync(join(targetDirectory, ".env"))
|
|
106
|
+
if (envMissing) {
|
|
107
|
+
const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.app"
|
|
108
|
+
const engineUrl = `https://${project_id}.${engineBase}`
|
|
109
|
+
next_steps.push(
|
|
110
|
+
`Create .env in ${targetDirectory} with \`VITE_DYPAI_URL=${engineUrl}\` (and \`VITE_PROJECT_ID=${project_id}\`). ` +
|
|
111
|
+
`Use NEXT_PUBLIC_DYPAI_URL instead of VITE_DYPAI_URL for Next.js projects. ` +
|
|
112
|
+
`The frontend SDK reads this to reach the engine — without .env, API calls will fail with network errors.`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: written > 0,
|
|
118
|
+
targetDirectory,
|
|
119
|
+
files_written: written,
|
|
120
|
+
files_failed: failures.length,
|
|
121
|
+
failures: failures.length > 0 ? failures : undefined,
|
|
122
|
+
total_bytes: payload.total_bytes ?? null,
|
|
123
|
+
ref: payload.ref ?? "main",
|
|
124
|
+
repo: payload.repo ?? null,
|
|
125
|
+
overwrite,
|
|
126
|
+
env_file_missing: envMissing,
|
|
127
|
+
next_steps,
|
|
128
|
+
message:
|
|
129
|
+
`Synced ${written} file(s) to ${targetDirectory}` +
|
|
130
|
+
(failures.length > 0 ? ` (${failures.length} failed — see failures[])` : "") +
|
|
131
|
+
". See next_steps[].",
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -113,6 +113,14 @@ export function summarizeTrace(trace, mode = "smart") {
|
|
|
113
113
|
type: n.error?.type,
|
|
114
114
|
stack: truncateString(n.error?.stack, 800),
|
|
115
115
|
}
|
|
116
|
+
// Preserve the engine's pre-computed fix_hint and category so the
|
|
117
|
+
// agent gets actionable guidance without us re-deriving it.
|
|
118
|
+
if (n.fix_hint || n.error?.fix_hint) {
|
|
119
|
+
base.fix_hint = truncateString(n.fix_hint || n.error?.fix_hint)
|
|
120
|
+
}
|
|
121
|
+
if (n.error_category || n.error?.category) {
|
|
122
|
+
base.error_category = n.error_category || n.error?.category
|
|
123
|
+
}
|
|
116
124
|
// failure_snapshot: reduce to a key outline — not full values
|
|
117
125
|
if (n.failure_snapshot) {
|
|
118
126
|
const snap = n.failure_snapshot
|