@dypai-ai/mcp 1.2.4 → 1.3.1
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 +525 -14
- package/src/tools/frontend.js +37 -11
- package/src/tools/scaffold.js +33 -3
- package/src/tools/sync/codec.js +93 -7
- package/src/tools/sync/describe.js +4 -2
- package/src/tools/sync/planner.js +14 -3
- package/src/tools/sync/pull.js +395 -7
- package/src/tools/sync/schema-dump.js +7 -1
- package/src/tools/sync/test-endpoint.js +68 -2
- package/src/tools/sync/validate.js +300 -15
- 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:
|
|
@@ -323,19 +330,27 @@ function validateEndpoint(entry, ctx) {
|
|
|
323
330
|
// Heuristic: look like SQL (contains SELECT/INSERT/UPDATE/DELETE/WITH)
|
|
324
331
|
if (/\b(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(value)) {
|
|
325
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.
|
|
326
339
|
}
|
|
327
340
|
}
|
|
328
341
|
|
|
329
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.
|
|
330
347
|
if (ctx.schemaTables) {
|
|
331
348
|
for (const table of referencedTables) {
|
|
332
349
|
if (!ctx.schemaTables.has(table)) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
message: `SQL references public.${table}, but that table does not exist in schema.sql.`,
|
|
338
|
-
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,
|
|
339
354
|
})
|
|
340
355
|
}
|
|
341
356
|
}
|
|
@@ -346,6 +361,18 @@ function validateEndpoint(entry, ctx) {
|
|
|
346
361
|
if (!node || typeof node !== "object") continue
|
|
347
362
|
// Tolerate both `type` and `node_type` (the codec accepts either).
|
|
348
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
|
+
}
|
|
349
376
|
if (!nodeType) {
|
|
350
377
|
diagnostics.push({
|
|
351
378
|
severity: "error",
|
|
@@ -356,6 +383,111 @@ function validateEndpoint(entry, ctx) {
|
|
|
356
383
|
})
|
|
357
384
|
continue
|
|
358
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
|
+
}
|
|
359
491
|
// node_type exists in catalog?
|
|
360
492
|
if (ctx.knownTypes.size && !ctx.knownTypes.has(nodeType)) {
|
|
361
493
|
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, nodeType) <= 2).slice(0, 3)
|
|
@@ -379,12 +511,39 @@ function validateEndpoint(entry, ctx) {
|
|
|
379
511
|
const META_KEYS = new Set(["id", "type", "variable", "return", "credential", "query_file", "code_file", "system_prompt_file"])
|
|
380
512
|
const paramKeys = Object.keys(node).filter(k => !META_KEYS.has(k))
|
|
381
513
|
|
|
382
|
-
// Required
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
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) {
|
|
388
547
|
if (!paramKeys.includes(req)) {
|
|
389
548
|
const hasFileEquivalent = META_KEYS.has(`${req}_file`) && node[`${req}_file`]
|
|
390
549
|
if (!hasFileEquivalent) {
|
|
@@ -392,8 +551,12 @@ function validateEndpoint(entry, ctx) {
|
|
|
392
551
|
severity: "warn",
|
|
393
552
|
rule: "missing_required_param",
|
|
394
553
|
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
395
|
-
message:
|
|
396
|
-
|
|
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.`,
|
|
397
560
|
})
|
|
398
561
|
}
|
|
399
562
|
}
|
|
@@ -489,6 +652,65 @@ function validateEndpoint(entry, ctx) {
|
|
|
489
652
|
return diagnostics
|
|
490
653
|
}
|
|
491
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
|
+
|
|
492
714
|
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
493
715
|
|
|
494
716
|
export async function runValidation(rootDir, projectId) {
|
|
@@ -519,6 +741,9 @@ export async function runValidation(rootDir, projectId) {
|
|
|
519
741
|
catalog: nodeCatalog.schemas,
|
|
520
742
|
knownTypes: nodeCatalog.knownTypes,
|
|
521
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: [],
|
|
522
747
|
}
|
|
523
748
|
|
|
524
749
|
const diagnostics = []
|
|
@@ -526,6 +751,66 @@ export async function runValidation(rootDir, projectId) {
|
|
|
526
751
|
diagnostics.push(...validateEndpoint(entry, ctx))
|
|
527
752
|
}
|
|
528
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
|
+
|
|
529
814
|
// Realtime YAML rules
|
|
530
815
|
diagnostics.push(...await validateRealtime(rootDir, ctx))
|
|
531
816
|
|
|
@@ -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
|