@dypai-ai/mcp 1.0.10 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +9 -32
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +91 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +411 -0
- package/src/tools/sync/push.js +397 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_test — YAML-defined tests against endpoints. Zero engine changes:
|
|
3
|
+
* orchestrates `execute_sql` + `test_workflow` (impersonation) + `execute_sql`
|
|
4
|
+
* using what already exists.
|
|
5
|
+
*
|
|
6
|
+
* File layout (committable under dypai/tests/):
|
|
7
|
+
* endpoint: create-order # or endpoint_id: <uuid>
|
|
8
|
+
* tests:
|
|
9
|
+
* - name: "creates order with valid product"
|
|
10
|
+
* setup_sql: INSERT INTO products ...
|
|
11
|
+
* as_user: 11111111-1111-1111-1111-111111111111
|
|
12
|
+
* input: { product_id: "p1", qty: 3 }
|
|
13
|
+
* expect:
|
|
14
|
+
* success: true
|
|
15
|
+
* response:
|
|
16
|
+
* ok: true
|
|
17
|
+
* order_id: { matches: uuid }
|
|
18
|
+
* db_queries:
|
|
19
|
+
* - sql: "SELECT COUNT(*)::int FROM orders WHERE product_id='p1'"
|
|
20
|
+
* equals: 1
|
|
21
|
+
* teardown_sql: DELETE FROM orders WHERE product_id='p1'; DELETE FROM products WHERE id='p1';
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir } from "fs/promises"
|
|
25
|
+
import { join, resolve as resolvePath } from "path"
|
|
26
|
+
import YAML from "yaml"
|
|
27
|
+
import { proxyToolCall } from "../proxy.js"
|
|
28
|
+
import { readLocalConfig } from "./planner.js"
|
|
29
|
+
|
|
30
|
+
// ─── Test file discovery ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function findTestFiles(testsDir) {
|
|
33
|
+
const out = []
|
|
34
|
+
async function walk(dir) {
|
|
35
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const full = join(dir, e.name)
|
|
38
|
+
if (e.isDirectory()) await walk(full)
|
|
39
|
+
else if (e.isFile() && (e.name.endsWith(".test.yaml") || e.name.endsWith(".test.yml"))) out.push(full)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await walk(testsDir)
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── SQL helpers ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
async function execSql(projectId, sql) {
|
|
49
|
+
const res = await proxyToolCall("execute_sql", { project_id: projectId, sql })
|
|
50
|
+
if (res?.error) throw new Error(res.error)
|
|
51
|
+
return res
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function firstCellOf(res) {
|
|
55
|
+
const row = res?.rows?.[0]
|
|
56
|
+
if (!row) return undefined
|
|
57
|
+
const k = Object.keys(row)[0]
|
|
58
|
+
return k ? row[k] : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve an endpoint name to its UUID via system.endpoints (the remote
|
|
62
|
+
* test_workflow tool only accepts endpoint_id, not endpoint_name). */
|
|
63
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
64
|
+
async function resolveEndpointId(projectId, ref, cache) {
|
|
65
|
+
if (UUID_RE.test(ref)) return ref
|
|
66
|
+
if (cache.has(ref)) return cache.get(ref)
|
|
67
|
+
const safeName = String(ref).replace(/'/g, "''")
|
|
68
|
+
const res = await execSql(projectId,
|
|
69
|
+
`SELECT id FROM system.endpoints WHERE name = '${safeName}' LIMIT 1`)
|
|
70
|
+
const id = firstCellOf(res)
|
|
71
|
+
if (!id) throw new Error(`Endpoint '${ref}' not found (looked up in system.endpoints by name).`)
|
|
72
|
+
cache.set(ref, id)
|
|
73
|
+
return id
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Assertion matchers ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deep match `actual` against `expected`. Expected can contain meta-matchers
|
|
80
|
+
* via special objects with keys `matches`, `equals`, `contains`, `type`,
|
|
81
|
+
* `exists`, `gte`, `lte`. Returns { ok, errors: [] }.
|
|
82
|
+
*/
|
|
83
|
+
function matchValue(actual, expected, path = "") {
|
|
84
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected)) {
|
|
85
|
+
const metaKeys = new Set(["matches", "equals", "contains", "type", "exists", "gte", "lte"])
|
|
86
|
+
const keys = Object.keys(expected)
|
|
87
|
+
if (keys.length && keys.every(k => metaKeys.has(k))) return matchMeta(actual, expected, path)
|
|
88
|
+
|
|
89
|
+
if (actual == null || typeof actual !== "object") {
|
|
90
|
+
return { ok: false, errors: [`${path || "(root)"}: expected object, got ${JSON.stringify(actual)}`] }
|
|
91
|
+
}
|
|
92
|
+
const errors = []
|
|
93
|
+
for (const k of keys) {
|
|
94
|
+
const r = matchValue(actual[k], expected[k], path ? `${path}.${k}` : k)
|
|
95
|
+
if (!r.ok) errors.push(...r.errors)
|
|
96
|
+
}
|
|
97
|
+
return errors.length ? { ok: false, errors } : { ok: true }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(expected)) {
|
|
101
|
+
if (!Array.isArray(actual)) return { ok: false, errors: [`${path}: expected array, got ${typeof actual}`] }
|
|
102
|
+
if (actual.length !== expected.length) return { ok: false, errors: [`${path}: length ${expected.length} expected, got ${actual.length}`] }
|
|
103
|
+
const errors = []
|
|
104
|
+
expected.forEach((item, i) => {
|
|
105
|
+
const r = matchValue(actual[i], item, `${path}[${i}]`)
|
|
106
|
+
if (!r.ok) errors.push(...r.errors)
|
|
107
|
+
})
|
|
108
|
+
return errors.length ? { ok: false, errors } : { ok: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (actual !== expected) {
|
|
112
|
+
return { ok: false, errors: [`${path || "(root)"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`] }
|
|
113
|
+
}
|
|
114
|
+
return { ok: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchMeta(actual, meta, path) {
|
|
118
|
+
const errs = []
|
|
119
|
+
if ("equals" in meta && actual !== meta.equals) {
|
|
120
|
+
errs.push(`${path}: expected ${JSON.stringify(meta.equals)}, got ${JSON.stringify(actual)}`)
|
|
121
|
+
}
|
|
122
|
+
if ("matches" in meta) {
|
|
123
|
+
const m = meta.matches
|
|
124
|
+
const str = String(actual ?? "")
|
|
125
|
+
if (m === "uuid") {
|
|
126
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str)) {
|
|
127
|
+
errs.push(`${path}: expected UUID, got ${JSON.stringify(actual)}`)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
if (!new RegExp(m).test(str)) errs.push(`${path}: did not match /${m}/, got ${JSON.stringify(actual)}`)
|
|
132
|
+
} catch {
|
|
133
|
+
errs.push(`${path}: invalid regex ${JSON.stringify(m)}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if ("contains" in meta) {
|
|
138
|
+
if (typeof actual !== "string" || !actual.includes(String(meta.contains))) {
|
|
139
|
+
errs.push(`${path}: expected to contain ${JSON.stringify(meta.contains)}, got ${JSON.stringify(actual)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if ("type" in meta && typeof actual !== meta.type) {
|
|
143
|
+
errs.push(`${path}: expected type ${meta.type}, got ${typeof actual}`)
|
|
144
|
+
}
|
|
145
|
+
if ("exists" in meta) {
|
|
146
|
+
const exists = actual !== undefined && actual !== null
|
|
147
|
+
if (meta.exists !== exists) errs.push(`${path}: expected exists=${meta.exists}, got ${exists}`)
|
|
148
|
+
}
|
|
149
|
+
if ("gte" in meta && !(Number(actual) >= Number(meta.gte))) {
|
|
150
|
+
errs.push(`${path}: expected >= ${meta.gte}, got ${actual}`)
|
|
151
|
+
}
|
|
152
|
+
if ("lte" in meta && !(Number(actual) <= Number(meta.lte))) {
|
|
153
|
+
errs.push(`${path}: expected <= ${meta.lte}, got ${actual}`)
|
|
154
|
+
}
|
|
155
|
+
return errs.length ? { ok: false, errors: errs } : { ok: true }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Single test runner ─────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async function runSingleTest(test, fileCtx) {
|
|
161
|
+
const result = { name: test.name || "(unnamed)", status: "pass", errors: [] }
|
|
162
|
+
|
|
163
|
+
// Setup
|
|
164
|
+
if (test.setup_sql) {
|
|
165
|
+
try { await execSql(fileCtx.projectId, test.setup_sql) }
|
|
166
|
+
catch (e) { return { ...result, status: "error", phase: "setup", errors: [`setup_sql failed: ${e.message}`] } }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Resolve endpoint → endpoint_id (remote only accepts UUIDs)
|
|
171
|
+
const endpointRef = test.endpoint || test.endpoint_id || fileCtx.endpoint
|
|
172
|
+
if (!endpointRef) {
|
|
173
|
+
return { ...result, status: "error", errors: ["No endpoint specified. Set `endpoint` at file root or test level."] }
|
|
174
|
+
}
|
|
175
|
+
const endpointId = await resolveEndpointId(fileCtx.projectId, endpointRef, fileCtx.endpointCache)
|
|
176
|
+
|
|
177
|
+
const execArgs = {
|
|
178
|
+
project_id: fileCtx.projectId,
|
|
179
|
+
endpoint_id: endpointId,
|
|
180
|
+
data: test.input || {},
|
|
181
|
+
trace_mode: "minimal", // keep tests fast; detail on failure via dypai_trace
|
|
182
|
+
}
|
|
183
|
+
if (test.as_user) execArgs.impersonated_user_id = test.as_user
|
|
184
|
+
|
|
185
|
+
const runResponse = await proxyToolCall("test_workflow", execArgs)
|
|
186
|
+
|
|
187
|
+
// Normalize what counts as the workflow "result body" (vary by engine version)
|
|
188
|
+
const body = runResponse?.result ?? runResponse?.data ?? runResponse?.output ?? runResponse
|
|
189
|
+
const trace = runResponse?.trace
|
|
190
|
+
|
|
191
|
+
// Assertions
|
|
192
|
+
const expect = test.expect || {}
|
|
193
|
+
|
|
194
|
+
// expect.success — did the workflow complete?
|
|
195
|
+
if ("success" in expect) {
|
|
196
|
+
const status = trace?.status ?? trace?.workflow?.status
|
|
197
|
+
const actualSuccess = status
|
|
198
|
+
? status === "completed"
|
|
199
|
+
: !runResponse?.error // fallback: presence of error field
|
|
200
|
+
if (actualSuccess !== expect.success) {
|
|
201
|
+
result.errors.push(`expected success=${expect.success}, got ${actualSuccess}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// expect.response — match body
|
|
206
|
+
if (expect.response !== undefined) {
|
|
207
|
+
const r = matchValue(body, expect.response, "response")
|
|
208
|
+
if (!r.ok) result.errors.push(...r.errors)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// expect.failed_at — name of the node that should have failed
|
|
212
|
+
if (expect.failed_at) {
|
|
213
|
+
const failedAt = trace?.failed_at ?? trace?.workflow?.failed_at
|
|
214
|
+
if (failedAt !== expect.failed_at) {
|
|
215
|
+
result.errors.push(`expected failed_at='${expect.failed_at}', got ${JSON.stringify(failedAt)}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// expect.db_queries — each runs a SELECT and compares first cell or full rows
|
|
220
|
+
if (Array.isArray(expect.db_queries)) {
|
|
221
|
+
for (const q of expect.db_queries) {
|
|
222
|
+
try {
|
|
223
|
+
const res = await execSql(fileCtx.projectId, q.sql)
|
|
224
|
+
const first = firstCellOf(res)
|
|
225
|
+
if ("equals" in q && first !== q.equals) {
|
|
226
|
+
result.errors.push(`db_query "${q.sql.slice(0, 50)}": expected ${q.equals}, got ${JSON.stringify(first)}`)
|
|
227
|
+
}
|
|
228
|
+
if ("gte" in q && !(Number(first) >= Number(q.gte))) {
|
|
229
|
+
result.errors.push(`db_query: expected >= ${q.gte}, got ${first}`)
|
|
230
|
+
}
|
|
231
|
+
if ("lte" in q && !(Number(first) <= Number(q.lte))) {
|
|
232
|
+
result.errors.push(`db_query: expected <= ${q.lte}, got ${first}`)
|
|
233
|
+
}
|
|
234
|
+
if (q.match) {
|
|
235
|
+
const r = matchValue(res?.rows, q.match, "db_rows")
|
|
236
|
+
if (!r.ok) result.errors.push(...r.errors)
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
result.errors.push(`db_query execution failed: ${e.message}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
result.errors.push(`execution error: ${e.message}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Teardown — always try, even if the test failed
|
|
248
|
+
if (test.teardown_sql) {
|
|
249
|
+
try { await execSql(fileCtx.projectId, test.teardown_sql) }
|
|
250
|
+
catch (e) { result.errors.push(`teardown_sql failed: ${e.message}`) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (result.errors.length) result.status = "fail"
|
|
254
|
+
else delete result.errors
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export const dypaiTestTool = {
|
|
261
|
+
name: "dypai_test",
|
|
262
|
+
description:
|
|
263
|
+
"Run YAML-defined tests from dypai/tests/*.test.yaml. Each test does: setup_sql → test_workflow (with impersonation) → response assertions → db_queries assertions → teardown_sql. " +
|
|
264
|
+
"Uses existing remote tools (execute_sql, test_workflow) — no engine changes needed. " +
|
|
265
|
+
"Pass `only` to run a subset (substring match on test name).",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: "object",
|
|
268
|
+
properties: {
|
|
269
|
+
project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml." },
|
|
270
|
+
root_dir: { type: "string", default: "./dypai" },
|
|
271
|
+
only: { type: "string", description: "Only run tests whose name includes this substring." },
|
|
272
|
+
file: { type: "string", description: "Relative path to a single test file under dypai/tests/." },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
async execute({ project_id, root_dir = "./dypai", only, file } = {}) {
|
|
276
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
277
|
+
const config = await readLocalConfig(rootDir)
|
|
278
|
+
const projectId = project_id || config?.project_id
|
|
279
|
+
if (!projectId) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly.",
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const testsDir = join(rootDir, "tests")
|
|
287
|
+
let files = []
|
|
288
|
+
if (file) {
|
|
289
|
+
files = [file.startsWith("/") ? file : join(testsDir, file)]
|
|
290
|
+
} else {
|
|
291
|
+
files = await findTestFiles(testsDir)
|
|
292
|
+
}
|
|
293
|
+
if (!files.length) {
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
total: 0, pass: 0, fail: 0, error: 0,
|
|
297
|
+
results: [],
|
|
298
|
+
hint: "No test files found. Create dypai/tests/<endpoint>.test.yaml — see README for format.",
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const allResults = []
|
|
303
|
+
const parseErrors = []
|
|
304
|
+
for (const f of files) {
|
|
305
|
+
let doc
|
|
306
|
+
try {
|
|
307
|
+
const raw = await readFile(f, "utf8")
|
|
308
|
+
doc = YAML.parse(raw) || {}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
parseErrors.push({ file: f, error: e.message })
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
const tests = Array.isArray(doc) ? doc : (doc.tests || [])
|
|
314
|
+
const fileCtx = {
|
|
315
|
+
projectId,
|
|
316
|
+
endpoint: doc.endpoint || doc.endpoint_id,
|
|
317
|
+
rootDir,
|
|
318
|
+
endpointCache: new Map(),
|
|
319
|
+
}
|
|
320
|
+
for (const t of tests) {
|
|
321
|
+
if (only && !(t.name || "").toLowerCase().includes(only.toLowerCase())) continue
|
|
322
|
+
const r = await runSingleTest(t, fileCtx)
|
|
323
|
+
r.file = f.replace(rootDir + "/", "")
|
|
324
|
+
allResults.push(r)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const pass = allResults.filter(r => r.status === "pass").length
|
|
329
|
+
const fail = allResults.filter(r => r.status === "fail").length
|
|
330
|
+
const error = allResults.filter(r => r.status === "error").length
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
success: fail === 0 && error === 0 && parseErrors.length === 0,
|
|
334
|
+
total: allResults.length,
|
|
335
|
+
pass, fail, error,
|
|
336
|
+
results: allResults,
|
|
337
|
+
parse_errors: parseErrors.length ? parseErrors : undefined,
|
|
338
|
+
hint: (fail + error) > 0
|
|
339
|
+
? `${fail} test(s) failed, ${error} errored. For failing ones, use dypai_trace on the reported execution_id for deeper inspection.`
|
|
340
|
+
: undefined,
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bidirectional transformations between the DB's executable JSON workflow
|
|
3
|
+
* format and the declarative YAML/files format.
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: every transform declares its pull (DB → file) and push (file → DB)
|
|
6
|
+
* direction together. This guarantees round-trip symmetry by construction —
|
|
7
|
+
* there's no separate "push" file that can drift out of sync with "pull".
|
|
8
|
+
*
|
|
9
|
+
* Adding a new transform = edit one object here, both directions in one commit.
|
|
10
|
+
*
|
|
11
|
+
* Context shape for pull:
|
|
12
|
+
* {
|
|
13
|
+
* endpointName, nodeId,
|
|
14
|
+
* credIdToName, groupIdToName, endpointIdToName,
|
|
15
|
+
* emitFile(path, content), // collects files to write to disk
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Context shape for push:
|
|
19
|
+
* {
|
|
20
|
+
* endpointName, nodeId,
|
|
21
|
+
* credNameToId, groupNameToId, endpointNameToId,
|
|
22
|
+
* readFile(path), // returns file contents
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Only extract truly large content. Below these thresholds, SQL and prompts
|
|
27
|
+
// stay inline in the YAML so the endpoint is one self-contained file.
|
|
28
|
+
const SQL_INLINE_MAX_CHARS = 500
|
|
29
|
+
const PROMPT_INLINE_MAX_CHARS = 800
|
|
30
|
+
|
|
31
|
+
const shouldInlineSql = (q) => !q || q.length <= SQL_INLINE_MAX_CHARS
|
|
32
|
+
|
|
33
|
+
// ─── Node-level field transforms ────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export const NODE_FIELD_TRANSFORMS = [
|
|
36
|
+
{
|
|
37
|
+
name: "credential",
|
|
38
|
+
pull(params, ctx) {
|
|
39
|
+
if (params.credential_id && ctx.credIdToName[params.credential_id]) {
|
|
40
|
+
return { credential: ctx.credIdToName[params.credential_id] }
|
|
41
|
+
}
|
|
42
|
+
return {}
|
|
43
|
+
},
|
|
44
|
+
push(params, ctx) {
|
|
45
|
+
if (params.credential && ctx.credNameToId[params.credential]) {
|
|
46
|
+
return { credential_id: ctx.credNameToId[params.credential] }
|
|
47
|
+
}
|
|
48
|
+
return {}
|
|
49
|
+
},
|
|
50
|
+
pullConsumes: ["credential_id"],
|
|
51
|
+
pushConsumes: ["credential"],
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
name: "agent_tools",
|
|
56
|
+
appliesWhen: (nodeType) => nodeType === "agent",
|
|
57
|
+
pull(params, ctx) {
|
|
58
|
+
if (Array.isArray(params.tool_ids)) {
|
|
59
|
+
return { tools: params.tool_ids.map(id => ctx.endpointIdToName[id] || id) }
|
|
60
|
+
}
|
|
61
|
+
return {}
|
|
62
|
+
},
|
|
63
|
+
push(params, ctx) {
|
|
64
|
+
if (Array.isArray(params.tools)) {
|
|
65
|
+
return { tool_ids: params.tools.map(n => ctx.endpointNameToId[n] || n) }
|
|
66
|
+
}
|
|
67
|
+
return {}
|
|
68
|
+
},
|
|
69
|
+
pullConsumes: ["tool_ids"],
|
|
70
|
+
pushConsumes: ["tools"],
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
name: "sql_extraction",
|
|
75
|
+
appliesWhen: (nodeType) => nodeType === "dypai_database",
|
|
76
|
+
pull(params, ctx) {
|
|
77
|
+
if (params.query && !shouldInlineSql(params.query)) {
|
|
78
|
+
// Flat layout: sql/<endpoint>.sql if only one SQL node, else sql/<endpoint>.<node>.sql
|
|
79
|
+
const suffix = ctx.sqlNodeCount > 1 ? `.${ctx.nodeId}` : ""
|
|
80
|
+
const path = `sql/${ctx.endpointName}${suffix}.sql`
|
|
81
|
+
ctx.emitFile(path, params.query.trim() + "\n")
|
|
82
|
+
return { query_file: path }
|
|
83
|
+
}
|
|
84
|
+
return {}
|
|
85
|
+
},
|
|
86
|
+
push(params, ctx) {
|
|
87
|
+
if (params.query_file) {
|
|
88
|
+
return { query: ctx.readFile(params.query_file).trimEnd() }
|
|
89
|
+
}
|
|
90
|
+
return {}
|
|
91
|
+
},
|
|
92
|
+
pullConsumes: ["query"],
|
|
93
|
+
pushConsumes: ["query_file"],
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
name: "prompt_extraction",
|
|
98
|
+
appliesWhen: (nodeType) => nodeType === "agent",
|
|
99
|
+
pull(params, ctx) {
|
|
100
|
+
if (params.system_prompt && params.system_prompt.length > PROMPT_INLINE_MAX_CHARS) {
|
|
101
|
+
const suffix = ctx.promptNodeCount > 1 ? `.${ctx.nodeId}` : ""
|
|
102
|
+
const path = `prompts/${ctx.endpointName}${suffix}.md`
|
|
103
|
+
ctx.emitFile(path, params.system_prompt.trim() + "\n")
|
|
104
|
+
return { system_prompt_file: path }
|
|
105
|
+
}
|
|
106
|
+
return {}
|
|
107
|
+
},
|
|
108
|
+
push(params, ctx) {
|
|
109
|
+
if (params.system_prompt_file) {
|
|
110
|
+
return { system_prompt: ctx.readFile(params.system_prompt_file).trimEnd() }
|
|
111
|
+
}
|
|
112
|
+
return {}
|
|
113
|
+
},
|
|
114
|
+
pullConsumes: ["system_prompt"],
|
|
115
|
+
pushConsumes: ["system_prompt_file"],
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
name: "code_extraction",
|
|
120
|
+
appliesWhen: (nodeType) => nodeType === "javascript_code" || nodeType === "python_code",
|
|
121
|
+
pull(params, ctx) {
|
|
122
|
+
if (params.code && params.code.length > SQL_INLINE_MAX_CHARS) {
|
|
123
|
+
const ext = ctx.nodeType === "python_code" ? "py" : "js"
|
|
124
|
+
const suffix = ctx.codeNodeCount > 1 ? `.${ctx.nodeId}` : ""
|
|
125
|
+
const path = `code/${ctx.endpointName}${suffix}.${ext}`
|
|
126
|
+
ctx.emitFile(path, params.code.trim() + "\n")
|
|
127
|
+
return { code_file: path }
|
|
128
|
+
}
|
|
129
|
+
return {}
|
|
130
|
+
},
|
|
131
|
+
push(params, ctx) {
|
|
132
|
+
if (params.code_file) {
|
|
133
|
+
return { code: ctx.readFile(params.code_file).trimEnd() }
|
|
134
|
+
}
|
|
135
|
+
return {}
|
|
136
|
+
},
|
|
137
|
+
pullConsumes: ["code"],
|
|
138
|
+
pushConsumes: ["code_file"],
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
// ─── Engine ────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function applyTransforms(input, direction, ctx, nodeType) {
|
|
145
|
+
let result = { ...input }
|
|
146
|
+
for (const t of NODE_FIELD_TRANSFORMS) {
|
|
147
|
+
if (t.appliesWhen && !t.appliesWhen(nodeType)) continue
|
|
148
|
+
const added = t[direction](result, ctx) || {}
|
|
149
|
+
const consumes = direction === "pull" ? t.pullConsumes : t.pushConsumes
|
|
150
|
+
for (const f of consumes || []) delete result[f]
|
|
151
|
+
Object.assign(result, added)
|
|
152
|
+
}
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const pullNodeParams = (params, ctx, nodeType) => applyTransforms(params, "pull", ctx, nodeType)
|
|
157
|
+
export const pushNodeParams = (params, ctx, nodeType) => applyTransforms(params, "push", ctx, nodeType)
|