@dypai-ai/mcp 1.2.4 → 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.
@@ -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
- diagnostics.push({
334
- severity: "error",
335
- rule: "sql_table_not_found",
336
- endpoint: name, file,
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 params present? Severity: warn (not error) because current
383
- // engine node_catalog schemas list all conditional params as "required"
384
- // (e.g. dypai_database flags `data` AND `query` both required regardless
385
- // of `operation`). Once schemas model conditional required properly
386
- // (oneOf / dependentRequired), we can bump this back to error.
387
- for (const req of required) {
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: `Node '${node.id}' (type '${nodeType}') may be missing parameter '${req}'.`,
396
- fix_hint: `Schema lists required: [${required.join(", ")}]. Verify this param is actually needed for your operation.`,
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