@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
package/src/tools/bulk-upsert.js
CHANGED
|
@@ -11,6 +11,59 @@ import { readFileSync, existsSync } from "fs"
|
|
|
11
11
|
import { basename } from "path"
|
|
12
12
|
import { proxyToolCall } from "./proxy.js"
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Minimal RFC 4180-compliant CSV parser.
|
|
16
|
+
* Handles: quoted fields, embedded commas, escaped quotes (""), CRLF, embedded newlines in quoted fields.
|
|
17
|
+
* Returns an array of rows, each row an array of string fields.
|
|
18
|
+
*
|
|
19
|
+
* The previous implementation did `line.split(",")` which silently corrupted
|
|
20
|
+
* any CSV with quoted commas (e.g. `"Smith, Jr.",100`).
|
|
21
|
+
*/
|
|
22
|
+
function parseCsv(text) {
|
|
23
|
+
const rows = []
|
|
24
|
+
let row = []
|
|
25
|
+
let field = ""
|
|
26
|
+
let i = 0
|
|
27
|
+
let inQuotes = false
|
|
28
|
+
while (i < text.length) {
|
|
29
|
+
const c = text[i]
|
|
30
|
+
if (inQuotes) {
|
|
31
|
+
if (c === '"') {
|
|
32
|
+
if (text[i + 1] === '"') { field += '"'; i += 2; continue }
|
|
33
|
+
inQuotes = false
|
|
34
|
+
i++
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
field += c
|
|
38
|
+
i++
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (c === '"') { inQuotes = true; i++; continue }
|
|
42
|
+
if (c === ",") { row.push(field); field = ""; i++; continue }
|
|
43
|
+
if (c === "\r") {
|
|
44
|
+
// Treat CRLF as one line break
|
|
45
|
+
if (text[i + 1] === "\n") i++
|
|
46
|
+
row.push(field); rows.push(row); row = []; field = ""
|
|
47
|
+
i++
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (c === "\n") {
|
|
51
|
+
row.push(field); rows.push(row); row = []; field = ""
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
field += c
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
// Tail row (no trailing newline)
|
|
59
|
+
if (field.length > 0 || row.length > 0) {
|
|
60
|
+
row.push(field); rows.push(row)
|
|
61
|
+
}
|
|
62
|
+
// Drop trailing empty rows (common when the file ends with \n)
|
|
63
|
+
while (rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === "") rows.pop()
|
|
64
|
+
return rows
|
|
65
|
+
}
|
|
66
|
+
|
|
14
67
|
export const bulkUpsertTool = {
|
|
15
68
|
name: "bulk_upsert",
|
|
16
69
|
description: `Import data from a local CSV or JSON file into a database table.
|
|
@@ -63,21 +116,20 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
63
116
|
// Read and parse data
|
|
64
117
|
let rows
|
|
65
118
|
try {
|
|
66
|
-
|
|
119
|
+
// Strip UTF-8 BOM if present — otherwise the first header column ends up
|
|
120
|
+
// as "\uFEFFname" instead of "name" and silently misaligns everything.
|
|
121
|
+
const raw = readFileSync(file_path, "utf-8").replace(/^\uFEFF/, "")
|
|
67
122
|
|
|
68
123
|
if (ext === "json") {
|
|
69
124
|
const parsed = JSON.parse(raw)
|
|
70
125
|
rows = Array.isArray(parsed) ? parsed : [parsed]
|
|
71
126
|
} else {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const headers = lines[0].split(",").map(h => h.trim().replace(/^"|"$/g, ""))
|
|
77
|
-
rows = lines.slice(1).map(line => {
|
|
78
|
-
const values = line.split(",").map(v => v.trim().replace(/^"|"$/g, ""))
|
|
127
|
+
const records = parseCsv(raw)
|
|
128
|
+
if (records.length < 2) return { error: "CSV must have a header row and at least one data row" }
|
|
129
|
+
const headers = records[0].map(h => h.trim())
|
|
130
|
+
rows = records.slice(1).map(values => {
|
|
79
131
|
const obj = {}
|
|
80
|
-
headers.forEach((h, i) => { obj[h] = values[i] || null })
|
|
132
|
+
headers.forEach((h, i) => { obj[h] = (values[i] === undefined || values[i] === "") ? null : values[i] })
|
|
81
133
|
return obj
|
|
82
134
|
})
|
|
83
135
|
}
|
|
@@ -110,11 +162,16 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
110
162
|
let inserted = 0
|
|
111
163
|
let errors = []
|
|
112
164
|
|
|
165
|
+
// Quote a Postgres identifier safely. Doubles any embedded quotes.
|
|
166
|
+
const qIdent = (s) => `"${String(s).replace(/"/g, '""')}"`
|
|
167
|
+
const qTable = qIdent(table)
|
|
168
|
+
|
|
113
169
|
// Batch in chunks of 50 rows
|
|
114
170
|
const BATCH_SIZE = 50
|
|
115
171
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
116
172
|
const batch = rows.slice(i, i + BATCH_SIZE)
|
|
117
173
|
const batchCols = Object.keys(batch[0])
|
|
174
|
+
const quotedCols = batchCols.map(qIdent).join(", ")
|
|
118
175
|
const batchValues = batch.map(row =>
|
|
119
176
|
`(${batchCols.map(col => {
|
|
120
177
|
const v = row[col]
|
|
@@ -126,10 +183,10 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
126
183
|
|
|
127
184
|
let batchSql
|
|
128
185
|
if (upsert_key && batchCols.includes(upsert_key)) {
|
|
129
|
-
const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${c} = EXCLUDED.${c}`).join(", ")
|
|
130
|
-
batchSql = `INSERT INTO ${
|
|
186
|
+
const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${qIdent(c)} = EXCLUDED.${qIdent(c)}`).join(", ")
|
|
187
|
+
batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}\nON CONFLICT (${qIdent(upsert_key)}) DO UPDATE SET ${updateCols}`
|
|
131
188
|
} else {
|
|
132
|
-
batchSql = `INSERT INTO ${
|
|
189
|
+
batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}`
|
|
133
190
|
}
|
|
134
191
|
|
|
135
192
|
try {
|
package/src/tools/frontend.js
CHANGED
|
@@ -13,33 +13,53 @@
|
|
|
13
13
|
|
|
14
14
|
import { api } from "../api.js"
|
|
15
15
|
import { deployFromSource } from "./deploy.js"
|
|
16
|
+
import { syncFromRemote } from "./sync.js"
|
|
16
17
|
|
|
17
18
|
export const manageFrontendTool = {
|
|
18
19
|
name: "manage_frontend",
|
|
19
20
|
description:
|
|
20
|
-
"Manage the project's frontend deploy lifecycle
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
" -
|
|
25
|
-
"
|
|
21
|
+
"Manage the project's frontend: download the source code to disk AND the deploy lifecycle.\n\n" +
|
|
22
|
+
"Use `sync` FIRST whenever you start working on a project whose frontend code isn't already on this machine — " +
|
|
23
|
+
"without it you have no React/Vite source to read or edit. Call `deploy` to ship your changes.\n\n" +
|
|
24
|
+
"Operations:\n" +
|
|
25
|
+
" - sync: Download the project's frontend source code (React/Vite/etc.) into a local directory. " +
|
|
26
|
+
"Use when: starting a session on an existing project, onboarding on a new machine, or refreshing after edits made from elsewhere. " +
|
|
27
|
+
"Also known as: clone, download source, get source, initial setup. " +
|
|
28
|
+
"Writes only; does NOT delete local files that were removed upstream — you may have stale files after sync (call them out to the user). " +
|
|
29
|
+
"By default refuses to overwrite a directory that already has a package.json — pass overwrite:true to allow it (local-only files like .env, node_modules, .vscode are always preserved). " +
|
|
30
|
+
"AFTER SYNC: .env is gitignored so it's NOT included in the download. If the target directory has no .env, the response sets `env_file_missing: true` and adds a `next_steps` line with the exact VITE_DYPAI_URL / NEXT_PUBLIC_DYPAI_URL value to write. Follow it — without .env the SDK can't reach the engine.\n" +
|
|
31
|
+
" - deploy: Upload source files from a local directory and queue a build. Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\".\n" +
|
|
32
|
+
" - status: Current live deploy info (URL, last deploy time, size).\n" +
|
|
33
|
+
" - build_status: Progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
|
|
34
|
+
" - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
|
|
35
|
+
" - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
|
|
36
|
+
"Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.",
|
|
26
37
|
|
|
27
38
|
inputSchema: {
|
|
28
39
|
type: "object",
|
|
29
40
|
properties: {
|
|
30
41
|
operation: {
|
|
31
42
|
type: "string",
|
|
32
|
-
enum: ["deploy", "status", "build_status", "list_deployments", "logs"],
|
|
43
|
+
enum: ["deploy", "sync", "status", "build_status", "list_deployments", "logs"],
|
|
33
44
|
description: "Which action to run.",
|
|
34
45
|
},
|
|
35
46
|
project_id: {
|
|
36
47
|
type: "string",
|
|
37
|
-
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted (except `deploy`, which
|
|
48
|
+
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted (except `deploy` and `sync`, which need it explicit).",
|
|
38
49
|
},
|
|
39
50
|
sourceDirectory: {
|
|
40
51
|
type: "string",
|
|
41
52
|
description: "deploy only. Absolute path to the project source directory (must contain package.json).",
|
|
42
53
|
},
|
|
54
|
+
targetDirectory: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "sync only. Absolute path where the project source will be written. If it already contains a package.json, pass overwrite: true to allow replacing files.",
|
|
57
|
+
},
|
|
58
|
+
overwrite: {
|
|
59
|
+
type: "boolean",
|
|
60
|
+
description: "sync only. When true, allows writing into a directory that already has a project. Local-only files (.env, node_modules, etc.) are NOT touched. Default: false.",
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
43
63
|
deployment_id: {
|
|
44
64
|
type: "string",
|
|
45
65
|
description: "logs only. Deployment UUID obtained from list_deployments.",
|
|
@@ -52,9 +72,9 @@ export const manageFrontendTool = {
|
|
|
52
72
|
required: ["operation"],
|
|
53
73
|
},
|
|
54
74
|
|
|
55
|
-
async execute({ operation, project_id, sourceDirectory, deployment_id, limit } = {}) {
|
|
75
|
+
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, deployment_id, limit } = {}) {
|
|
56
76
|
if (!operation) {
|
|
57
|
-
return { success: false, error: "operation is required (deploy | status | build_status | list_deployments | logs)." }
|
|
77
|
+
return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
|
|
58
78
|
}
|
|
59
79
|
if (!project_id) {
|
|
60
80
|
return { success: false, error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly." }
|
|
@@ -68,6 +88,12 @@ export const manageFrontendTool = {
|
|
|
68
88
|
}
|
|
69
89
|
return await deployFromSource({ sourceDirectory, project_id })
|
|
70
90
|
|
|
91
|
+
case "sync":
|
|
92
|
+
if (!targetDirectory) {
|
|
93
|
+
return { success: false, error: "operation 'sync' requires 'targetDirectory' (absolute path where the source will be written)." }
|
|
94
|
+
}
|
|
95
|
+
return await syncFromRemote({ project_id, targetDirectory, overwrite: !!overwrite })
|
|
96
|
+
|
|
71
97
|
case "status":
|
|
72
98
|
return await api.get(`/api/engine/${project_id}/frontend`)
|
|
73
99
|
|
|
@@ -84,7 +110,7 @@ export const manageFrontendTool = {
|
|
|
84
110
|
return await api.get(`/api/engine/${project_id}/frontend/deployments/${deployment_id}/logs`)
|
|
85
111
|
|
|
86
112
|
default:
|
|
87
|
-
return { success: false, error: `Unknown operation '${operation}'. Use deploy | status | build_status | list_deployments | logs.` }
|
|
113
|
+
return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | list_deployments | logs.` }
|
|
88
114
|
}
|
|
89
115
|
} catch (e) {
|
|
90
116
|
return { success: false, error: e.message, operation }
|
package/src/tools/proxy.js
CHANGED
|
@@ -76,28 +76,41 @@ function mcpRequest(body) {
|
|
|
76
76
|
})
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Single in-flight init promise so concurrent first-calls (e.g. push with
|
|
80
|
+
// concurrency=5) don't all race to initialize and clobber each other's
|
|
81
|
+
// session id mid-handshake.
|
|
82
|
+
let initPromise = null
|
|
83
|
+
|
|
79
84
|
async function ensureInitialized() {
|
|
80
85
|
if (sessionId) return
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
86
|
+
if (initPromise) return initPromise
|
|
87
|
+
initPromise = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
await mcpRequest({
|
|
90
|
+
jsonrpc: "2.0",
|
|
91
|
+
id: "init-proxy",
|
|
92
|
+
method: "initialize",
|
|
93
|
+
params: {
|
|
94
|
+
protocolVersion: "2024-11-05",
|
|
95
|
+
capabilities: {},
|
|
96
|
+
clientInfo: { name: "dypai-mcp-local", version: "1.0.0" },
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
// Send initialized notification
|
|
100
|
+
await mcpRequest({
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
method: "notifications/initialized",
|
|
103
|
+
})
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// Don't swallow silently — calls right after will appear to "work"
|
|
106
|
+
// returning empty results and we'd never know init failed.
|
|
107
|
+
process.stderr.write(`MCP proxy init failed: ${e.message}\n`)
|
|
108
|
+
// Reset so a future call gets a chance to retry.
|
|
109
|
+
initPromise = null
|
|
110
|
+
throw e
|
|
111
|
+
}
|
|
112
|
+
})()
|
|
113
|
+
return initPromise
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
export async function proxyToolCall(toolName, args) {
|
package/src/tools/scaffold.js
CHANGED
|
@@ -47,9 +47,10 @@ Or use "blank" for an empty starter project.`,
|
|
|
47
47
|
return { error: `Directory already has a package.json. Pick an empty directory or a new name.` }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
50
|
+
// Production engine URL: https://<project-id>.dypai.app (NOT .dypai.dev — that's the dev tier).
|
|
51
|
+
// Override with DYPAI_ENGINE_BASE for self-hosted / staging setups.
|
|
52
|
+
const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.app"
|
|
53
|
+
const engineUrl = `https://${project_id}.${engineBase}`
|
|
53
54
|
|
|
54
55
|
// Try to download template from API
|
|
55
56
|
let files = []
|
|
@@ -79,6 +80,35 @@ Or use "blank" for an empty starter project.`,
|
|
|
79
80
|
// .env with engine URL
|
|
80
81
|
files.push({ path: ".env", content: `VITE_DYPAI_URL=${engineUrl}\nVITE_PROJECT_ID=${project_id}\n` })
|
|
81
82
|
|
|
83
|
+
// .gitignore — keep node_modules / .dypai cache / build outputs / secrets out of git.
|
|
84
|
+
files.push({ path: ".gitignore", content: [
|
|
85
|
+
"# Dependencies",
|
|
86
|
+
"node_modules/",
|
|
87
|
+
"",
|
|
88
|
+
"# Build outputs",
|
|
89
|
+
"dist/",
|
|
90
|
+
"build/",
|
|
91
|
+
".vite/",
|
|
92
|
+
".turbo/",
|
|
93
|
+
"",
|
|
94
|
+
"# Logs",
|
|
95
|
+
"*.log",
|
|
96
|
+
"",
|
|
97
|
+
"# Secrets (never commit)",
|
|
98
|
+
".env",
|
|
99
|
+
".env.local",
|
|
100
|
+
".env.*.local",
|
|
101
|
+
"",
|
|
102
|
+
"# DYPAI local cache (committed: dypai/, gitignored: dypai/.dypai/)",
|
|
103
|
+
"dypai/.dypai/",
|
|
104
|
+
"",
|
|
105
|
+
"# OS / editor",
|
|
106
|
+
".DS_Store",
|
|
107
|
+
".vscode/",
|
|
108
|
+
".idea/",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n") })
|
|
111
|
+
|
|
82
112
|
// SDK client helper (lib/dypai.ts)
|
|
83
113
|
files.push({ path: "src/lib/dypai.ts", content: `import { createClient } from "@dypai-ai/client-sdk";\n\nexport const dypai = createClient(import.meta.env.VITE_DYPAI_URL);\n` })
|
|
84
114
|
|
package/src/tools/sync/codec.js
CHANGED
|
@@ -18,11 +18,14 @@ import { pullNodeParams, pushNodeParams } from "./transforms.js"
|
|
|
18
18
|
|
|
19
19
|
function triggersToYaml(triggers = {}) {
|
|
20
20
|
const out = {}
|
|
21
|
+
// Telegram takes precedence over webhook because the engine enables both
|
|
22
|
+
// (telegram piggybacks on webhook plumbing) but conceptually it's one trigger.
|
|
23
|
+
const telegramEnabled = triggers.telegram?.enabled
|
|
21
24
|
for (const [kind, cfg] of Object.entries(triggers)) {
|
|
22
25
|
if (!cfg) continue
|
|
23
26
|
if (kind === "http_api" && cfg.enabled !== false) {
|
|
24
27
|
out.http_api = { auth_mode: cfg.auth_mode || "jwt" }
|
|
25
|
-
} else if (kind === "webhook" && cfg.enabled) {
|
|
28
|
+
} else if (kind === "webhook" && cfg.enabled && !telegramEnabled) {
|
|
26
29
|
out.webhook = { path: cfg.path || "" }
|
|
27
30
|
} else if (kind === "schedule" && cfg.enabled) {
|
|
28
31
|
const s = { cron: cfg.cron }
|
|
@@ -30,6 +33,9 @@ function triggersToYaml(triggers = {}) {
|
|
|
30
33
|
out.schedule = s
|
|
31
34
|
} else if (kind === "manual" && cfg.enabled) {
|
|
32
35
|
out.manual = true
|
|
36
|
+
} else if (kind === "telegram" && cfg.enabled) {
|
|
37
|
+
const { enabled, ...rest } = cfg
|
|
38
|
+
out.telegram = Object.keys(rest).length ? rest : {}
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
if (!Object.keys(out).length) out.http_api = { auth_mode: "jwt" }
|
|
@@ -43,14 +49,20 @@ function triggersToDb(trigger = {}) {
|
|
|
43
49
|
http_api: { enabled: true, auth_mode: "jwt" },
|
|
44
50
|
schedule: { enabled: false },
|
|
45
51
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
// Accept `http_api: true` (shorthand) by treating it as `http_api: {}`.
|
|
53
|
+
// Accept `auth_mode` or `mode` for forward/backward compat with older docs.
|
|
54
|
+
const httpApi = trigger.http_api === true ? {} : trigger.http_api
|
|
55
|
+
const hasNonHttpTrigger = trigger.webhook || trigger.schedule || trigger.manual || trigger.telegram
|
|
56
|
+
if (httpApi) {
|
|
57
|
+
const authMode = httpApi.auth_mode || httpApi.mode || "jwt"
|
|
58
|
+
out.http_api = { enabled: true, auth_mode: authMode }
|
|
59
|
+
} else if (hasNonHttpTrigger) {
|
|
49
60
|
// Non-HTTP trigger: disable http_api
|
|
50
61
|
out.http_api = { enabled: false, auth_mode: "jwt" }
|
|
51
62
|
}
|
|
52
63
|
if (trigger.webhook) {
|
|
53
|
-
|
|
64
|
+
const wh = trigger.webhook === true ? {} : trigger.webhook
|
|
65
|
+
out.webhook = { path: wh.path || "", enabled: true }
|
|
54
66
|
}
|
|
55
67
|
if (trigger.schedule) {
|
|
56
68
|
out.schedule = { ...trigger.schedule, enabled: true }
|
|
@@ -58,6 +70,14 @@ function triggersToDb(trigger = {}) {
|
|
|
58
70
|
if (trigger.manual) {
|
|
59
71
|
out.manual = { enabled: true }
|
|
60
72
|
}
|
|
73
|
+
if (trigger.telegram) {
|
|
74
|
+
// Telegram triggers piggyback on the webhook plumbing (the engine sets up
|
|
75
|
+
// the telegram webhook URL automatically). We mark webhook as enabled so
|
|
76
|
+
// the engine routes the inbound HTTP correctly.
|
|
77
|
+
const tg = trigger.telegram === true ? {} : trigger.telegram
|
|
78
|
+
out.telegram = { enabled: true, ...tg }
|
|
79
|
+
out.webhook = { path: "", enabled: true }
|
|
80
|
+
}
|
|
61
81
|
return out
|
|
62
82
|
}
|
|
63
83
|
|
|
@@ -74,13 +94,30 @@ function edgesToYaml(edges = []) {
|
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
function edgesToDb(edges = []) {
|
|
77
|
-
return edges.map(e => {
|
|
78
|
-
|
|
97
|
+
return (edges || []).map(e => {
|
|
98
|
+
if (!e || typeof e !== "object") return null
|
|
99
|
+
// Tolerate both YAML-canonical (`from`/`to`) and engine-style (`source`/`target`).
|
|
100
|
+
// Without this, an agent that wrote `source/target` in YAML would silently
|
|
101
|
+
// produce edges with `source: undefined`, breaking workflow execution.
|
|
102
|
+
const source = e.from ?? e.source
|
|
103
|
+
const target = e.to ?? e.target
|
|
104
|
+
if (!source || !target) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Edge is missing source/target: ${JSON.stringify(e).slice(0, 120)}. ` +
|
|
107
|
+
`Edges must have either 'from'/'to' (canonical) or 'source'/'target' (engine-style).`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const out = { source, target }
|
|
79
111
|
if (e.condition) out.condition = e.condition
|
|
80
|
-
|
|
112
|
+
// Accept `source_handle` (canonical) plus a `handle` shorthand. The `handle`
|
|
113
|
+
// shorthand mirrors how branching looks in a UI ("which branch did this come
|
|
114
|
+
// out of?") and shows up in agent-written YAML often. Engine only knows
|
|
115
|
+
// source_handle, so we always emit that key.
|
|
116
|
+
const sh = e.source_handle ?? e.handle
|
|
117
|
+
if (sh != null && sh !== "") out.source_handle = String(sh)
|
|
81
118
|
if (e.target_handle) out.target_handle = e.target_handle
|
|
82
119
|
return out
|
|
83
|
-
})
|
|
120
|
+
}).filter(Boolean)
|
|
84
121
|
}
|
|
85
122
|
|
|
86
123
|
// ─── Public codec ───────────────────────────────────────────────────────────
|
|
@@ -143,11 +180,114 @@ export function serializeEndpoint(row, mapsCtx) {
|
|
|
143
180
|
return { doc, extractedFiles }
|
|
144
181
|
}
|
|
145
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Detect legacy `start_trigger` nodes and extract their config to a top-level
|
|
185
|
+
* trigger if one isn't already declared. Returns:
|
|
186
|
+
* - cleanedNodes: workflow.nodes minus any start_trigger entries
|
|
187
|
+
* - cleanedEdges: workflow.edges minus any edge that referenced a removed node
|
|
188
|
+
* - inferredTrigger: the trigger object to use if doc.trigger is missing
|
|
189
|
+
*
|
|
190
|
+
* This makes the codec tolerant of an agent that copied an old-format example
|
|
191
|
+
* (where the trigger was a node) into a YAML workflow. Without this they'd
|
|
192
|
+
* get "unknown_node_type: start_trigger" because the canonical form has no
|
|
193
|
+
* such node — trigger lives at the top level.
|
|
194
|
+
*/
|
|
195
|
+
function liftStartTriggerNodes(workflow) {
|
|
196
|
+
const allNodes = workflow?.nodes || []
|
|
197
|
+
const allEdges = workflow?.edges || []
|
|
198
|
+
const startNodes = allNodes.filter(n => {
|
|
199
|
+
const t = n?.type ?? n?.node_type
|
|
200
|
+
return t === "start_trigger"
|
|
201
|
+
})
|
|
202
|
+
if (!startNodes.length) {
|
|
203
|
+
return { cleanedNodes: allNodes, cleanedEdges: allEdges, inferredTrigger: null, lifted: false }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const startIds = new Set(startNodes.map(n => n.id))
|
|
207
|
+
const cleanedNodes = allNodes.filter(n => !startIds.has(n?.id))
|
|
208
|
+
const cleanedEdges = allEdges.filter(e =>
|
|
209
|
+
!startIds.has(e?.from ?? e?.source) && !startIds.has(e?.to ?? e?.target)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
// Build a trigger from the FIRST start_trigger node's parameters. If multiple
|
|
213
|
+
// are present (rare), the first wins — we don't try to merge.
|
|
214
|
+
const first = startNodes[0]
|
|
215
|
+
const params = first.parameters || first
|
|
216
|
+
const triggerType = params.trigger_type || params.triggerType || "http_api"
|
|
217
|
+
let inferredTrigger
|
|
218
|
+
switch (triggerType) {
|
|
219
|
+
case "http_api":
|
|
220
|
+
inferredTrigger = { http_api: { auth_mode: params.auth_mode || params.mode || "jwt" } }
|
|
221
|
+
break
|
|
222
|
+
case "webhook":
|
|
223
|
+
inferredTrigger = { webhook: params.path ? { path: params.path } : {} }
|
|
224
|
+
break
|
|
225
|
+
case "schedule": {
|
|
226
|
+
const s = {}
|
|
227
|
+
if (params.cron) s.cron = params.cron
|
|
228
|
+
if (params.timezone) s.timezone = params.timezone
|
|
229
|
+
inferredTrigger = { schedule: s }
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
case "telegram":
|
|
233
|
+
inferredTrigger = { telegram: {} }
|
|
234
|
+
break
|
|
235
|
+
default:
|
|
236
|
+
inferredTrigger = { http_api: { auth_mode: "jwt" } }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { cleanedNodes, cleanedEdges, inferredTrigger, lifted: true }
|
|
240
|
+
}
|
|
241
|
+
|
|
146
242
|
export function deserializeEndpoint(doc, mapsCtx) {
|
|
147
243
|
const readFile = mapsCtx.readFile || (() => "")
|
|
148
244
|
|
|
149
|
-
|
|
150
|
-
|
|
245
|
+
// Lift legacy `start_trigger` nodes into the canonical top-level trigger.
|
|
246
|
+
// If the doc already has a `trigger:` block, we trust that one and just
|
|
247
|
+
// strip the redundant nodes (no merge — top-level is canonical).
|
|
248
|
+
const { cleanedNodes, cleanedEdges, inferredTrigger } = liftStartTriggerNodes(doc.workflow)
|
|
249
|
+
const effectiveTrigger = doc.trigger || inferredTrigger
|
|
250
|
+
const effectiveWorkflow = { ...doc.workflow, nodes: cleanedNodes, edges: cleanedEdges }
|
|
251
|
+
|
|
252
|
+
const nodes = (effectiveWorkflow.nodes || []).map(clean => {
|
|
253
|
+
// Tolerate both YAML-canonical `type` and engine-style `node_type`. The
|
|
254
|
+
// canonical form is `type`, but agents writing YAML by hand sometimes use
|
|
255
|
+
// `node_type` because that's what the engine returns. We accept either,
|
|
256
|
+
// but FAIL LOUD if neither is present — silently sending undefined to the
|
|
257
|
+
// engine causes accepted-but-not-created ghost endpoints (the bug from 1.2.2).
|
|
258
|
+
if (!clean || typeof clean !== "object") {
|
|
259
|
+
throw new Error(`workflow.nodes contains a non-object entry in endpoint '${doc.name}'.`)
|
|
260
|
+
}
|
|
261
|
+
// Tolerate both YAML-canonical (`type`, `return`) and engine-style
|
|
262
|
+
// (`node_type`, `is_return`) for the keys that have a "renamed" form.
|
|
263
|
+
// Anything still in `rest` after this is treated as a node parameter
|
|
264
|
+
// (inline form). This is the layer that prevents silent data loss when
|
|
265
|
+
// an agent mixes the two conventions.
|
|
266
|
+
const {
|
|
267
|
+
id,
|
|
268
|
+
type: typeA, node_type: typeB,
|
|
269
|
+
variable,
|
|
270
|
+
return: isReturnA, is_return: isReturnB,
|
|
271
|
+
parameters: explicitParams,
|
|
272
|
+
...rest
|
|
273
|
+
} = clean
|
|
274
|
+
const type = typeA || typeB
|
|
275
|
+
const isReturn = isReturnA || isReturnB
|
|
276
|
+
if (!id) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`A node in endpoint '${doc.name}' is missing 'id'. Every node needs a unique id within the workflow.`
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
if (!type) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Node '${id}' in endpoint '${doc.name}' is missing 'type'. ` +
|
|
284
|
+
`Each node in workflow.nodes must declare a node type, e.g. \`type: dypai_database\`. ` +
|
|
285
|
+
`(Both 'type' and 'node_type' are accepted.)`
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
// Params: prefer an explicit `parameters:` object, otherwise fall back to
|
|
289
|
+
// the inline shape (everything that isn't id/type/variable/return is a param).
|
|
290
|
+
const params = explicitParams && typeof explicitParams === "object" ? explicitParams : rest
|
|
151
291
|
const nodeCtx = {
|
|
152
292
|
...mapsCtx,
|
|
153
293
|
endpointName: doc.name,
|
|
@@ -164,21 +304,25 @@ export function deserializeEndpoint(doc, mapsCtx) {
|
|
|
164
304
|
|
|
165
305
|
const workflow_code = {
|
|
166
306
|
nodes,
|
|
167
|
-
edges: edgesToDb(
|
|
168
|
-
execution_config: { triggers: triggersToDb(
|
|
307
|
+
edges: edgesToDb(effectiveWorkflow.edges),
|
|
308
|
+
execution_config: { triggers: triggersToDb(effectiveTrigger) },
|
|
169
309
|
schema_version: "2.0",
|
|
170
310
|
}
|
|
171
|
-
if (
|
|
311
|
+
if (effectiveWorkflow.on_error) workflow_code.on_error = effectiveWorkflow.on_error
|
|
172
312
|
|
|
173
313
|
return {
|
|
174
314
|
name: doc.name,
|
|
175
|
-
|
|
315
|
+
// Engine expects uppercase HTTP methods. Normalize so YAML can use any case.
|
|
316
|
+
method: String(doc.method || "GET").toUpperCase(),
|
|
176
317
|
description: doc.description || null,
|
|
177
318
|
workflow_code,
|
|
178
319
|
input: doc.input || null,
|
|
179
320
|
output: doc.output || null,
|
|
180
321
|
allowed_roles: doc.allowed_roles || [],
|
|
181
|
-
is_tool:
|
|
322
|
+
// Accept both `tool: true` (canonical) and `is_tool: true` (engine-style).
|
|
323
|
+
// Without this, an agent that wrote `is_tool` in YAML would silently get
|
|
324
|
+
// a non-tool endpoint and `allowed_roles` would not be enforced as expected.
|
|
325
|
+
is_tool: !!(doc.tool || doc.is_tool),
|
|
182
326
|
tool_description: doc.tool_description || null,
|
|
183
327
|
group_id: doc.group && mapsCtx.groupNameToId ? (mapsCtx.groupNameToId[doc.group] || null) : null,
|
|
184
328
|
}
|
package/src/tools/sync/diff.js
CHANGED
|
@@ -52,6 +52,14 @@ export const dypaiDiffTool = {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
if (!remote || !remote.mapsCtx) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
phase: "fetch_remote",
|
|
59
|
+
error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
56
64
|
deleteOrphans: delete_orphans,
|
|
57
65
|
stateSnapshot,
|
|
@@ -178,9 +178,15 @@ async function findEndpointYamls(endpointsDir) {
|
|
|
178
178
|
const sub = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
|
|
179
179
|
await walk(join(dir, e.name), sub, group || e.name)
|
|
180
180
|
} else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
|
|
181
|
+
// Skip files ending in .disabled (e.g. _example.yaml.disabled — the
|
|
182
|
+
// reference template that ships with empty projects). Anything ending
|
|
183
|
+
// in .disabled BEFORE the .yaml suffix is also treated as disabled.
|
|
184
|
+
if (e.name.endsWith(".disabled.yaml") || e.name.endsWith(".disabled.yml")) continue
|
|
181
185
|
const rel = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
|
|
182
186
|
out.push({ rel, group })
|
|
183
187
|
}
|
|
188
|
+
// Note: files ending in .disabled (with no .yaml/.yml after) are also
|
|
189
|
+
// skipped naturally because the isFile branch only matches .yaml/.yml.
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
192
|
await walk(endpointsDir, "", null)
|
|
@@ -232,8 +238,17 @@ export async function readLocalState(rootDir) {
|
|
|
232
238
|
* This is what we'd send to update_endpoint.
|
|
233
239
|
*/
|
|
234
240
|
export function localToCanonical(entry, mapsCtx) {
|
|
241
|
+
if (!entry || !entry.doc) {
|
|
242
|
+
throw new Error("localToCanonical: entry must have a `doc` field (parsed YAML).")
|
|
243
|
+
}
|
|
244
|
+
if (!mapsCtx || typeof mapsCtx !== "object") {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"localToCanonical: mapsCtx required to resolve credential/group/tool references. " +
|
|
247
|
+
"If you see this, fetchRemoteState probably returned an empty/failed response."
|
|
248
|
+
)
|
|
249
|
+
}
|
|
235
250
|
const { doc, fileMap } = entry
|
|
236
|
-
const ctx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
|
|
251
|
+
const ctx = { ...mapsCtx, readFile: (p) => (fileMap || {})[p] ?? "" }
|
|
237
252
|
return deserializeEndpoint(doc, ctx)
|
|
238
253
|
}
|
|
239
254
|
|