@dypai-ai/mcp 1.0.9 → 1.1.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 +27 -97
- 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 +97 -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 +414 -0
- package/src/tools/sync/push.js +411 -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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_pull — snapshot remote project state to local YAML + SQL + MD files.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper: reads DB via execute_sql, calls codec.serializeEndpoint,
|
|
5
|
+
* writes the resulting doc + extractedFiles to disk. All transformations
|
|
6
|
+
* live in transforms.js (shared with push).
|
|
7
|
+
*
|
|
8
|
+
* Also writes:
|
|
9
|
+
* - schema.sql — DDL of public.* (reference for writing SQL)
|
|
10
|
+
* - .gitignore — excludes .dypai/ cache from git
|
|
11
|
+
* - .dypai/state.json — per-endpoint updated_at for conflict detection on push
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFile, mkdir, rm, access } from "fs/promises"
|
|
15
|
+
import { existsSync } from "fs"
|
|
16
|
+
import { join, resolve as resolvePath, dirname, isAbsolute, delimiter, sep } from "path"
|
|
17
|
+
import { homedir } from "os"
|
|
18
|
+
import YAML from "yaml"
|
|
19
|
+
import { proxyToolCall } from "../proxy.js"
|
|
20
|
+
import { serializeEndpoint } from "./codec.js"
|
|
21
|
+
import { dumpPublicSchema } from "./schema-dump.js"
|
|
22
|
+
import { regenerateTypes } from "../codegen.js"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a user-supplied out_dir. When an IDE spawns this MCP over stdio, the
|
|
26
|
+
* process cwd is usually the user's home dir, NOT the workspace the user sees.
|
|
27
|
+
* A relative `./foo` can land in ~/foo instead of the project root. We try
|
|
28
|
+
* several signals in order and warn the caller if the result still looks off.
|
|
29
|
+
*/
|
|
30
|
+
function resolveOutDir(outDir) {
|
|
31
|
+
if (isAbsolute(outDir)) return { path: outDir, source: "absolute" }
|
|
32
|
+
|
|
33
|
+
// 1. Env vars set by common agent hosts (Claude Code, Cursor, Cline, etc.)
|
|
34
|
+
const envCandidates = [
|
|
35
|
+
["CLAUDE_PROJECT_DIR", process.env.CLAUDE_PROJECT_DIR],
|
|
36
|
+
["DYPAI_WORKSPACE_ROOT", process.env.DYPAI_WORKSPACE_ROOT],
|
|
37
|
+
["WORKSPACE_FOLDER_PATHS", process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0]],
|
|
38
|
+
["PROJECT_ROOT", process.env.PROJECT_ROOT],
|
|
39
|
+
]
|
|
40
|
+
for (const [name, val] of envCandidates) {
|
|
41
|
+
if (val && isAbsolute(val)) return { path: resolvePath(val, outDir), source: `env:${name}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Walk up from cwd looking for a project marker (.git, package.json, dypai/)
|
|
45
|
+
let cursor = process.cwd()
|
|
46
|
+
for (let i = 0; i < 6; i++) {
|
|
47
|
+
if (existsSync(join(cursor, ".git")) ||
|
|
48
|
+
existsSync(join(cursor, "package.json")) ||
|
|
49
|
+
existsSync(join(cursor, "dypai"))) {
|
|
50
|
+
return { path: resolvePath(cursor, outDir), source: "project_marker" }
|
|
51
|
+
}
|
|
52
|
+
const parent = dirname(cursor)
|
|
53
|
+
if (parent === cursor) break
|
|
54
|
+
cursor = parent
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. Last resort — use cwd (probably ~/ when launched by IDE)
|
|
58
|
+
return { path: resolvePath(process.cwd(), outDir), source: "cwd_fallback" }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Return a warning string if the resolved path looks suspicious (e.g. sits directly in $HOME). */
|
|
62
|
+
function suspiciousPathWarning(resolvedPath, source) {
|
|
63
|
+
if (source === "absolute") return null
|
|
64
|
+
const home = homedir()
|
|
65
|
+
// If output_dir is ~/X (one level under home) and we had to fall back to cwd,
|
|
66
|
+
// the agent almost certainly didn't mean to write there.
|
|
67
|
+
if (source === "cwd_fallback" && resolvedPath.startsWith(home + sep)) {
|
|
68
|
+
const rel = resolvedPath.slice(home.length + 1)
|
|
69
|
+
if (!rel.includes(sep) || rel.split(sep).length <= 2) {
|
|
70
|
+
return (
|
|
71
|
+
`Output landed at ${resolvedPath}. The MCP process cwd is your home dir, not your project. ` +
|
|
72
|
+
`Re-run with an ABSOLUTE out_dir (e.g. ${home}/path/to/your-project/dypai) ` +
|
|
73
|
+
`or set the CLAUDE_PROJECT_DIR env var on the MCP entry.`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Subfolders that are always created so the layout is predictable. An agent
|
|
81
|
+
// never has to check "does this folder exist?" before writing a new SQL/prompt/JS file.
|
|
82
|
+
const CANONICAL_SUBDIRS = ["endpoints", "sql", "prompts", "code"]
|
|
83
|
+
|
|
84
|
+
const README_CONTENT = `# dypai/
|
|
85
|
+
|
|
86
|
+
Declarative snapshot of your DYPAI project's backend.
|
|
87
|
+
|
|
88
|
+
## Layout
|
|
89
|
+
|
|
90
|
+
- \`endpoints/\` — one YAML per endpoint (the workflow definition).
|
|
91
|
+
Subfolders represent endpoint groups, e.g. \`endpoints/Admin/foo.yaml\` → group "Admin".
|
|
92
|
+
- \`sql/\` — SQL queries extracted from \`dypai_database\` nodes when longer than 500 chars.
|
|
93
|
+
- \`prompts/\` — system prompts extracted from \`agent\` nodes when longer than 800 chars.
|
|
94
|
+
- \`code/\` — JavaScript / Python extracted from \`javascript_code\` / \`python_code\` nodes when longer than 500 chars.
|
|
95
|
+
|
|
96
|
+
## Workflow
|
|
97
|
+
|
|
98
|
+
1. \`dypai_pull\` to snapshot the remote state into this folder
|
|
99
|
+
2. Edit YAML, SQL, prompts, code with your editor or AI agent
|
|
100
|
+
3. \`dypai_diff\` to preview changes
|
|
101
|
+
4. \`dypai_push\` to apply to the remote
|
|
102
|
+
|
|
103
|
+
Paths inside YAML (e.g. \`query_file: sql/create_invoice.sql\`) are always relative
|
|
104
|
+
to this folder's root, regardless of where the YAML lives.
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
async function execSql(projectId, sql) {
|
|
108
|
+
const args = projectId ? { project_id: projectId, sql } : { sql }
|
|
109
|
+
const result = await proxyToolCall("execute_sql", args)
|
|
110
|
+
if (result?.error) throw new Error(`SQL error: ${result.error}`)
|
|
111
|
+
if (!result?.rows) {
|
|
112
|
+
throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
|
|
113
|
+
}
|
|
114
|
+
return result.rows
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseMaybeJson(v) {
|
|
118
|
+
if (v == null || typeof v !== "string") return v
|
|
119
|
+
try { return JSON.parse(v) } catch { return v }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hydrateRow(row) {
|
|
123
|
+
return {
|
|
124
|
+
...row,
|
|
125
|
+
workflow_code: parseMaybeJson(row.workflow_code) || {},
|
|
126
|
+
input: parseMaybeJson(row.input),
|
|
127
|
+
output: parseMaybeJson(row.output),
|
|
128
|
+
allowed_roles: parseMaybeJson(row.allowed_roles) || row.allowed_roles,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeFileEnsured(filePath, content) {
|
|
133
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
134
|
+
await writeFile(filePath, content, "utf8")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderYaml(doc) {
|
|
138
|
+
return YAML.stringify(doc, {
|
|
139
|
+
blockQuote: "literal",
|
|
140
|
+
lineWidth: 0,
|
|
141
|
+
singleQuote: false,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// schema.sql dump lives in ./schema-dump.js (shared with execute_sql auto-refresh)
|
|
146
|
+
|
|
147
|
+
export const dypaiPullTool = {
|
|
148
|
+
name: "dypai_pull",
|
|
149
|
+
description:
|
|
150
|
+
"Serializes the remote project state to local YAML files under ./dypai/. " +
|
|
151
|
+
"Writes endpoints/<name>.yaml + sql/ and prompts/ for extracted content. " +
|
|
152
|
+
"Canvas positions are stripped (regenerated by visual editor). Safe to run repeatedly. " +
|
|
153
|
+
"Use this to start editing a project locally with your editor + AI agent. " +
|
|
154
|
+
"IMPORTANT: when called by an IDE-hosted MCP, the process cwd is often the user's home dir — " +
|
|
155
|
+
"pass an ABSOLUTE path in out_dir (e.g. /Users/me/projects/my-app/dypai) to avoid writing to the wrong place. " +
|
|
156
|
+
"If out_dir is relative, the tool tries to auto-detect the workspace via env vars and git markers, " +
|
|
157
|
+
"and will surface a `warning` if the result looks suspicious.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
project_id: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "Project UUID. Optional if your token is project-scoped.",
|
|
164
|
+
},
|
|
165
|
+
out_dir: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description:
|
|
168
|
+
"Output directory. PREFER ABSOLUTE PATHS (e.g. /Users/me/projects/x/dypai) because IDE-hosted " +
|
|
169
|
+
"MCP servers often have the user's home dir as cwd. If relative, the tool auto-detects the " +
|
|
170
|
+
"workspace via CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS env vars or by walking up to a .git/package.json marker.",
|
|
171
|
+
default: "./dypai",
|
|
172
|
+
},
|
|
173
|
+
clean: {
|
|
174
|
+
type: "boolean",
|
|
175
|
+
description: "If true, removes existing endpoints/, sql/, prompts/ before writing.",
|
|
176
|
+
default: false,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async execute({ project_id, out_dir = "./dypai", clean = false } = {}) {
|
|
182
|
+
const { path: outDir, source: outDirSource } = resolveOutDir(out_dir)
|
|
183
|
+
const suspiciousWarning = suspiciousPathWarning(outDir, outDirSource)
|
|
184
|
+
|
|
185
|
+
if (clean) {
|
|
186
|
+
await Promise.all(
|
|
187
|
+
CANONICAL_SUBDIRS.map(sub =>
|
|
188
|
+
rm(join(outDir, sub), { recursive: true, force: true })
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Always create the canonical subdirs (empty or not) so the layout is predictable
|
|
194
|
+
await Promise.all(
|
|
195
|
+
CANONICAL_SUBDIRS.map(async sub => {
|
|
196
|
+
const dir = join(outDir, sub)
|
|
197
|
+
await mkdir(dir, { recursive: true })
|
|
198
|
+
// .gitkeep lets empty folders survive git. Non-destructive: only create if missing.
|
|
199
|
+
const keepPath = join(dir, ".gitkeep")
|
|
200
|
+
try { await access(keepPath) } catch { await writeFile(keepPath, "", "utf8") }
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// Top-level README (only write if missing — don't clobber user edits)
|
|
205
|
+
const readmePath = join(outDir, "README.md")
|
|
206
|
+
try { await access(readmePath) } catch { await writeFile(readmePath, README_CONTENT, "utf8") }
|
|
207
|
+
|
|
208
|
+
// .gitignore so the .dypai/ cache + OS junk doesn't leak to git
|
|
209
|
+
const gitignorePath = join(outDir, ".gitignore")
|
|
210
|
+
try { await access(gitignorePath) } catch {
|
|
211
|
+
await writeFile(gitignorePath, "# Auto-generated by dypai_pull\n.dypai/\n.DS_Store\n", "utf8")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies] = await Promise.all([
|
|
215
|
+
execSql(project_id, `
|
|
216
|
+
SELECT id, name, method, description, workflow_code, input, output,
|
|
217
|
+
allowed_roles, is_tool, tool_description, group_id, updated_at
|
|
218
|
+
FROM system.endpoints
|
|
219
|
+
WHERE is_active = true
|
|
220
|
+
ORDER BY name
|
|
221
|
+
`),
|
|
222
|
+
execSql(project_id, "SELECT id, name, type FROM system.credentials"),
|
|
223
|
+
execSql(project_id, "SELECT id, name FROM system.endpoints_group"),
|
|
224
|
+
dumpPublicSchema(project_id),
|
|
225
|
+
// Dump the global node catalog (central control plane) in one call.
|
|
226
|
+
// The per-project system.node_catalog is a stripped copy with empty schemas.
|
|
227
|
+
// The remote endpoints_sdk is project_aware, so it needs project_id even
|
|
228
|
+
// though the catalog itself is global.
|
|
229
|
+
proxyToolCall("list_node_catalog", project_id ? { project_id } : {}).catch(e => {
|
|
230
|
+
console.error(`[dypai_pull] list_node_catalog failed: ${e.message}`)
|
|
231
|
+
return { nodes: [] }
|
|
232
|
+
}),
|
|
233
|
+
// Realtime policies — deny-safe by default. Table may not exist yet on
|
|
234
|
+
// older engines; the catch keeps pull working.
|
|
235
|
+
execSql(project_id, `
|
|
236
|
+
SELECT target_type, target_name, subscribe_filter,
|
|
237
|
+
events, required_role, auth_required
|
|
238
|
+
FROM system.realtime_policies
|
|
239
|
+
ORDER BY target_type, target_name
|
|
240
|
+
`).catch(() => []),
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
// schema.sql: always regenerated (read-only reference)
|
|
244
|
+
await writeFile(join(outDir, "schema.sql"), schemaSql, "utf8")
|
|
245
|
+
|
|
246
|
+
// node-catalog.json: full schemas of every node this engine exposes.
|
|
247
|
+
// Committed so the validator works offline right after a git clone.
|
|
248
|
+
const catalogNodes = nodeCatalogResult?.nodes || []
|
|
249
|
+
const catalogDoc = {
|
|
250
|
+
cached_at: new Date().toISOString(),
|
|
251
|
+
source: "public.node_catalog (central control plane)",
|
|
252
|
+
total: catalogNodes.length,
|
|
253
|
+
nodes: Object.fromEntries(catalogNodes.map(n => [n.node_type || n.id, {
|
|
254
|
+
label: n.name,
|
|
255
|
+
description: n.description,
|
|
256
|
+
category: n.category,
|
|
257
|
+
provider: n.provider,
|
|
258
|
+
inputs: parseMaybeJson(n.input_schema) || {},
|
|
259
|
+
outputs: parseMaybeJson(n.output_schema) || {},
|
|
260
|
+
}])),
|
|
261
|
+
}
|
|
262
|
+
await writeFile(join(outDir, "node-catalog.json"), JSON.stringify(catalogDoc, null, 2) + "\n", "utf8")
|
|
263
|
+
|
|
264
|
+
const mapsCtx = {
|
|
265
|
+
credIdToName: Object.fromEntries(credentials.map(c => [c.id, c.name])),
|
|
266
|
+
groupIdToName: Object.fromEntries(groups.map(g => [g.id, g.name])),
|
|
267
|
+
endpointIdToName: Object.fromEntries(endpoints.map(e => [e.id, e.name])),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const filesWritten = []
|
|
271
|
+
const errors = []
|
|
272
|
+
|
|
273
|
+
// realtime.yaml: declarative access policies for WebSocket subscriptions.
|
|
274
|
+
// Committed so team members / CI can see who's allowed to subscribe to what.
|
|
275
|
+
// Missing → realtime is deny-by-default on the engine (only service_role).
|
|
276
|
+
if (Array.isArray(realtimePolicies) && realtimePolicies.length > 0) {
|
|
277
|
+
const realtimeDoc = { tables: {}, channels: {} }
|
|
278
|
+
for (const p of realtimePolicies) {
|
|
279
|
+
const entry = {}
|
|
280
|
+
if (p.subscribe_filter) entry.subscribe_filter = p.subscribe_filter
|
|
281
|
+
if (Array.isArray(p.events) && p.events.length) entry.events = p.events
|
|
282
|
+
if (p.required_role) entry.required_role = p.required_role
|
|
283
|
+
if (p.auth_required === false) entry.auth_required = false
|
|
284
|
+
const bucket = p.target_type === "channel" ? "channels" : "tables"
|
|
285
|
+
realtimeDoc[bucket][p.target_name] = entry
|
|
286
|
+
}
|
|
287
|
+
if (!Object.keys(realtimeDoc.channels).length) delete realtimeDoc.channels
|
|
288
|
+
if (!Object.keys(realtimeDoc.tables).length) delete realtimeDoc.tables
|
|
289
|
+
await writeFile(join(outDir, "realtime.yaml"),
|
|
290
|
+
"# Declarative realtime access policies. Tables / channels NOT listed here are deny-by-default.\n" +
|
|
291
|
+
"# Placeholders: ${current_user_id}, ${current_user_role}, ${channel_param} (for wildcard channels).\n" +
|
|
292
|
+
renderYaml(realtimeDoc),
|
|
293
|
+
"utf8")
|
|
294
|
+
filesWritten.push("realtime.yaml")
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const rawRow of endpoints) {
|
|
298
|
+
const row = hydrateRow(rawRow)
|
|
299
|
+
try {
|
|
300
|
+
const { doc, extractedFiles } = serializeEndpoint(row, mapsCtx)
|
|
301
|
+
|
|
302
|
+
// Group → folder convention: path encodes the group, so strip the field
|
|
303
|
+
const groupName = doc.group
|
|
304
|
+
delete doc.group
|
|
305
|
+
|
|
306
|
+
for (const f of extractedFiles) {
|
|
307
|
+
await writeFileEnsured(join(outDir, f.path), f.content)
|
|
308
|
+
filesWritten.push(f.path)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const relPath = groupName
|
|
312
|
+
? `endpoints/${groupName}/${row.name}.yaml`
|
|
313
|
+
: `endpoints/${row.name}.yaml`
|
|
314
|
+
await writeFileEnsured(join(outDir, relPath), renderYaml(doc))
|
|
315
|
+
filesWritten.push(relPath)
|
|
316
|
+
} catch (e) {
|
|
317
|
+
errors.push({ endpoint: row.name, error: e.message })
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Fetch project metadata to persist identity in the committed config
|
|
322
|
+
let projectInfo = null
|
|
323
|
+
if (project_id) {
|
|
324
|
+
try {
|
|
325
|
+
projectInfo = await proxyToolCall("get_project", { project_id })
|
|
326
|
+
} catch { /* non-fatal; we still pin the id we know */ }
|
|
327
|
+
}
|
|
328
|
+
const resolvedProjectId = projectInfo?.id || project_id || null
|
|
329
|
+
|
|
330
|
+
// dypai.config.yaml: COMMITTED — identifies which project this folder belongs to.
|
|
331
|
+
// Serves two purposes:
|
|
332
|
+
// 1. A cloner/CI knows what project to push to.
|
|
333
|
+
// 2. dypai_push refuses to push to a different project_id (hallucination guard).
|
|
334
|
+
const configPath = join(outDir, "dypai.config.yaml")
|
|
335
|
+
const configDoc = { project_id: resolvedProjectId }
|
|
336
|
+
if (projectInfo?.name) configDoc.project_name = projectInfo.name
|
|
337
|
+
if (projectInfo?.organization_name) configDoc.organization = projectInfo.organization_name
|
|
338
|
+
const configYaml =
|
|
339
|
+
"# DYPAI project configuration — commit this file.\n" +
|
|
340
|
+
"# It identifies which remote project this dypai/ folder belongs to.\n" +
|
|
341
|
+
"# Never edit by hand unless you KNOW you're retargeting to a different project.\n" +
|
|
342
|
+
renderYaml(configDoc)
|
|
343
|
+
await writeFile(configPath, configYaml, "utf8")
|
|
344
|
+
|
|
345
|
+
// .dypai/state.json: GITIGNORED — per-endpoint updated_at for conflict detection.
|
|
346
|
+
const state = {
|
|
347
|
+
pulled_at: new Date().toISOString(),
|
|
348
|
+
project_id: resolvedProjectId,
|
|
349
|
+
endpoints: Object.fromEntries(
|
|
350
|
+
endpoints.map(e => [e.name, { id: e.id, updated_at: e.updated_at }])
|
|
351
|
+
),
|
|
352
|
+
}
|
|
353
|
+
await writeFileEnsured(join(outDir, ".dypai", "state.json"), JSON.stringify(state, null, 2) + "\n")
|
|
354
|
+
|
|
355
|
+
// Auto-regenerate TS types for the frontend (src/dypai/). Non-fatal on failure.
|
|
356
|
+
let codegen
|
|
357
|
+
try { codegen = await regenerateTypes(outDir) }
|
|
358
|
+
catch (e) { codegen = { skipped: true, reason: e.message } }
|
|
359
|
+
|
|
360
|
+
// Compact overview — replaces what dypai_describe used to print, but
|
|
361
|
+
// derived from the data we already have in memory (zero extra queries).
|
|
362
|
+
// Lets the agent skip the "call describe then pull" dance.
|
|
363
|
+
const byGroup = (endpoints || []).reduce((acc, e) => {
|
|
364
|
+
const groupName = e.group_id ? (mapsCtx.groupIdToName[e.group_id] || "(unknown)") : "(no group)"
|
|
365
|
+
if (!acc[groupName]) acc[groupName] = []
|
|
366
|
+
const nodeCount = Array.isArray(e.workflow_code?.nodes) ? e.workflow_code.nodes.length : 0
|
|
367
|
+
acc[groupName].push({ name: e.name, method: e.method, nodes: nodeCount, is_tool: !!e.is_tool })
|
|
368
|
+
return acc
|
|
369
|
+
}, {})
|
|
370
|
+
const toolEndpoints = (endpoints || [])
|
|
371
|
+
.filter(e => e.is_tool)
|
|
372
|
+
.map(e => ({ name: e.name, description: e.tool_description || null }))
|
|
373
|
+
|
|
374
|
+
const overview = {
|
|
375
|
+
project: projectInfo ? {
|
|
376
|
+
id: projectInfo.id,
|
|
377
|
+
name: projectInfo.name,
|
|
378
|
+
plan: projectInfo.plan,
|
|
379
|
+
} : { id: resolvedProjectId || "(from token)" },
|
|
380
|
+
endpoints: {
|
|
381
|
+
total: (endpoints || []).length,
|
|
382
|
+
groups: Object.keys(byGroup).filter(g => g !== "(no group)").sort(),
|
|
383
|
+
by_group: byGroup,
|
|
384
|
+
tool_endpoints: toolEndpoints,
|
|
385
|
+
},
|
|
386
|
+
credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
|
|
387
|
+
realtime_policies: (realtimePolicies || []).length,
|
|
388
|
+
next_steps: (endpoints || []).length === 0
|
|
389
|
+
? ["Empty project. Create tables via execute_sql, then write dypai/endpoints/<name>.yaml and dypai_push."]
|
|
390
|
+
: ["Read dypai/schema.sql before writing queries.", "Edit YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
success: errors.length === 0,
|
|
395
|
+
endpoints: endpoints.length,
|
|
396
|
+
files_written: filesWritten.length,
|
|
397
|
+
output_dir: outDir,
|
|
398
|
+
out_dir_resolved_via: outDirSource,
|
|
399
|
+
overview,
|
|
400
|
+
codegen: codegen?.regenerated
|
|
401
|
+
? { output_dir: codegen.output_dir, files: codegen.files_written.length }
|
|
402
|
+
: (codegen?.reason || undefined),
|
|
403
|
+
errors: errors.length ? errors : undefined,
|
|
404
|
+
warning: suspiciousWarning || undefined,
|
|
405
|
+
hint: errors.length
|
|
406
|
+
? "Some endpoints failed to serialize. Check errors[] — usually malformed workflow_code."
|
|
407
|
+
: suspiciousWarning
|
|
408
|
+
? "Files were written but the path looks wrong. See `warning` above and re-run with an absolute out_dir."
|
|
409
|
+
: endpoints.length === 0
|
|
410
|
+
? "Empty project. Create tables with execute_sql, then write endpoints/<name>.yaml and dypai_push."
|
|
411
|
+
: undefined,
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
}
|