@dypai-ai/mcp 1.4.1 → 1.4.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -103,7 +103,7 @@ const REMOTE_TOOLS = [
103
103
  // ── Project ───────────────────────────────────────────────────────────────
104
104
  { name: "list_projects", description: "Lists all projects you have access to across your organizations. Returns project id, name, description, organization, subscription plan, and status. Use this as the first step to discover which projects are available, then pass project_id to other tools.", inputSchema: { type: "object", properties: { organization_id: { type: "string", description: "Optional. Filter projects by organization UUID." } }, required: [] } },
105
105
  { name: "get_project", description: "Gets detailed information about a specific project. Returns project name, description, organization, plan, status, engine URL, frontend slug, and timestamps.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: ["project_id"] } },
106
- { name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, and hosting. Provisioning takes ~1 minute.\n\nIMPORTANT: before calling this, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types (clinic, gym, waitlist, SaaS, e-commerce, landing, etc.). Only create a blank project if nothing matches — starting blank costs the user hours of boilerplate.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'waitlist', 'blank'). Always call search_project_templates first to find the best match for what the user asked. Only omit this / use 'blank' when nothing fits." } }, required: ["name"] } },
106
+ { name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, GitHub repo, and frontend hosting. BLOCKS by default until provisioning finishes (~60s typical, 120s max) — when it returns, the project_id is ready to use with execute_sql, endpoint tools, etc. Pass wait_until_ready:false for batch flows.\n\nName collision: if another project in the same org already uses the name (case-insensitive), returns {error:'name_taken', existing_project_id, suggestions:[...]}. Pick a different name or use the existing project.\n\nIMPORTANT: before calling, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types. Only create a blank project if nothing matches.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'waitlist', 'blank'). Always call search_project_templates first to find the best match." }, wait_until_ready: { type: "boolean", description: "If true (default), blocks until provisioning completes and the project is ready for all operations. If false, returns immediately with status='provisioning' caller must poll get_project before using.", default: true } }, required: ["name"] } },
107
107
  { name: "get_app_credentials", description: "Lists available credentials in the current application. Returns API keys, anon key, service role key, and engine URL needed for SDK configuration.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: [] } },
108
108
 
109
109
  // ── Database ──────────────────────────────────────────────────────────────
@@ -531,7 +531,7 @@ Any endpoint can be flagged \`tool: true\` to be callable by \`agent\` nodes. Ho
531
531
  │ └── create-order.test.yaml
532
532
  └── .dypai/ ← local cache, gitignored — DO NOT touch
533
533
  ├── deploy-manifest.json ← SHA hashes from last deploy (delta engine)
534
- └── media-manifest.json ← media files uploaded to R2
534
+ └── media-manifest.json ← media files uploaded to the storage bucket
535
535
  \`\`\`
536
536
 
537
537
  ### Where to put what
@@ -576,7 +576,7 @@ query_file: /absolute/path/sql/get-orders.sql
576
576
 
577
577
  - \`/api/v0/<endpoint_name>\` — HTTP endpoints
578
578
  - \`/api/v0/webhooks/<endpoint_name>\` — webhook endpoints (different path prefix)
579
- - \`/public/<path>\` — media served from R2 (auto-populated on deploy; see "Frontend deploy")
579
+ - \`/public/<path>\` — media served from the storage bucket (auto-populated on deploy; see "Frontend deploy")
580
580
  - \`https://<project_id>.dypai.app\` — the engine base URL (what the SDK points to)
581
581
 
582
582
  ## Endpoint YAML skeleton (top-level fields)
@@ -785,7 +785,7 @@ Pre-configured at \`src/lib/dypai.ts\`. Every method returns \`{ data, error }\`
785
785
  - **Credentials not created** → workflow fails with "credential not found". Check \`get_app_credentials\` before referencing one in a node. Create in dashboard (not via MCP yet).
786
786
  - **Binary files in \`dypai/code/\`** → only text code files here. Binary assets go to the frontend \`public/\` or to a bucket.
787
787
  - **\`dypai_push\` without \`dypai_validate\`** → pushing a broken workflow. Always validate first.
788
- - **Frontend dev server + R2 media** → media files are auto-uploaded to R2 on deploy but \`vite dev\` doesn't proxy to R2. Run \`manage_frontend(sync)\` first to pull media to disk.
788
+ - **Frontend dev server + remote media** → media files are auto-uploaded to the storage bucket on deploy but \`vite dev\` doesn't proxy to it. Run \`manage_frontend(sync)\` first to pull media to disk.
789
789
 
790
790
  ## Frontend
791
791
 
@@ -821,7 +821,7 @@ The deploy is delta by default: only files changed since last deploy are sent. L
821
821
  - \`manage_domain\` — custom domains. \`add\` returns the CNAME to configure. \`verify\` forces DNS/SSL recheck. → \`search_docs("manage domain")\`.
822
822
  - \`manage_users\` / \`manage_roles\` — app-level RBAC (users via better-auth).
823
823
  - \`manage_schedules\` / \`manage_webhooks\` — pause/resume/history. To change the DEFINITION, edit the endpoint YAML and push.
824
- - \`manage_storage\` — buckets + objects. \`upload_file\` reads local path, signs URL, PUTs direct to R2, registers. Max 100MB/file. → \`search_docs("file storage")\`.
824
+ - \`manage_storage\` — buckets + objects. \`upload_file\` reads local path, signs URL, PUTs direct to the storage bucket, registers. Max 100MB/file. → \`search_docs("file storage")\`.
825
825
 
826
826
  ## Deep docs — search_docs topic map
827
827
 
@@ -897,7 +897,7 @@ async function handleRequest(msg) {
897
897
  let finalArgs = withProjectContext(toolDef, args || {})
898
898
 
899
899
  // manage_storage.upload_file is orchestrated LOCALLY (filesystem →
900
- // sign → direct PUT to R2 → verify). It's listed under manage_storage
900
+ // sign → direct PUT to the bucket → verify). It's listed under manage_storage
901
901
  // so the agent sees one unified tool, but the binary never travels
902
902
  // through the MCP transport — that would saturate the remote pod on
903
903
  // large files. See tools/storage.js for the 3-step flow.
@@ -10,7 +10,7 @@
10
10
  * the frontend SDK:
11
11
  *
12
12
  * 1. Ask the remote MCP → API for a signed PUT URL (op: sign_upload).
13
- * 2. PUT the file binary DIRECTLY to R2 from the local machine (no infra hop).
13
+ * 2. PUT the file binary DIRECTLY to the storage bucket from the local machine (no infra hop).
14
14
  * 3. Ask the remote MCP → API to register the object (op: verify_upload).
15
15
  *
16
16
  * Net effect: the agent just calls `manage_storage(operation:"upload_file",
@@ -34,7 +34,7 @@ import { updateMediaManifest } from "./deploy.js"
34
34
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
35
35
 
36
36
  // Extensions we know how to MIME-type without shelling out. Everything else
37
- // falls back to application/octet-stream, which R2 accepts fine — the browser
37
+ // falls back to application/octet-stream, which the bucket accepts fine — the browser
38
38
  // just won't preview it inline.
39
39
  const MIME_BY_EXT = {
40
40
  // Images
@@ -169,7 +169,7 @@ export async function uploadFile({
169
169
  return { success: false, error: "sign_upload returned an invalid payload (no upload_url or file_path)." }
170
170
  }
171
171
 
172
- // ── Step 2: direct PUT to R2 ────────────────────────────────────────────
172
+ // ── Step 2: direct PUT to the bucket ────────────────────────────────────
173
173
  let buf
174
174
  try {
175
175
  buf = readFileSync(local_path)
@@ -190,11 +190,11 @@ export async function uploadFile({
190
190
  const detail = await safeReadBody(res)
191
191
  return {
192
192
  success: false,
193
- error: `R2 PUT failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
193
+ error: `Storage upload failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
194
194
  }
195
195
  }
196
196
  } catch (e) {
197
- return { success: false, error: `Direct upload to R2 failed: ${e.message}` }
197
+ return { success: false, error: `Direct upload to storage failed: ${e.message}` }
198
198
  }
199
199
 
200
200
  // ── Step 3: verify_upload ───────────────────────────────────────────────
@@ -213,7 +213,7 @@ export async function uploadFile({
213
213
  } catch (e) {
214
214
  return {
215
215
  success: false,
216
- error: `verify_upload failed (the file WAS uploaded to R2 but not registered): ${e.message}. You can retry the upload — verify_upload is idempotent on (bucket, name).`,
216
+ error: `verify_upload failed (the file WAS uploaded to storage but not registered): ${e.message}. You can retry the upload — verify_upload is idempotent on (bucket, name).`,
217
217
  }
218
218
  }
219
219
 
@@ -12,7 +12,12 @@
12
12
  * "doc" is the YAML-ready declarative object.
13
13
  */
14
14
 
15
- import { pullNodeParams, pushNodeParams } from "./transforms.js"
15
+ import {
16
+ pullNodeParams,
17
+ pushNodeParams,
18
+ SQL_INLINE_MAX_CHARS,
19
+ PROMPT_INLINE_MAX_CHARS,
20
+ } from "./transforms.js"
16
21
 
17
22
  // ─── Triggers (execution_config.triggers ↔ doc.trigger) ─────────────────────
18
23
 
@@ -23,14 +28,23 @@ function triggersToYaml(triggers = {}) {
23
28
  const telegramEnabled = triggers.telegram?.enabled
24
29
  for (const [kind, cfg] of Object.entries(triggers)) {
25
30
  if (!cfg) continue
31
+ // Strip `enabled` from every block — it's an engine-internal flag. The
32
+ // presence of the key in the YAML already means "enabled". Everything else
33
+ // (auth_mode, path, cron, timezone, payload, stripe_webhook,
34
+ // hmac_secret_env, methods, …) MUST round-trip unchanged — without this,
35
+ // webhook signature verification and schedule payloads silently disappear
36
+ // on pull → push.
26
37
  if (kind === "http_api" && cfg.enabled !== false) {
27
- out.http_api = { auth_mode: cfg.auth_mode || "jwt" }
38
+ const { enabled, ...rest } = cfg
39
+ out.http_api = { auth_mode: rest.auth_mode || "jwt", ...rest }
28
40
  } else if (kind === "webhook" && cfg.enabled && !telegramEnabled) {
29
- out.webhook = { path: cfg.path || "" }
41
+ const { enabled, ...rest } = cfg
42
+ // Ensure `path` is always present (default empty) but keep every other
43
+ // field the engine stored (methods, stripe_webhook, hmac_secret_env, etc.).
44
+ out.webhook = { path: rest.path || "", ...rest }
30
45
  } else if (kind === "schedule" && cfg.enabled) {
31
- const s = { cron: cfg.cron }
32
- if (cfg.timezone) s.timezone = cfg.timezone
33
- out.schedule = s
46
+ const { enabled, ...rest } = cfg
47
+ out.schedule = { ...rest }
34
48
  } else if (kind === "manual" && cfg.enabled) {
35
49
  out.manual = true
36
50
  } else if (kind === "telegram" && cfg.enabled) {
@@ -54,15 +68,19 @@ function triggersToDb(trigger = {}) {
54
68
  const httpApi = trigger.http_api === true ? {} : trigger.http_api
55
69
  const hasNonHttpTrigger = trigger.webhook || trigger.schedule || trigger.manual || trigger.telegram
56
70
  if (httpApi) {
57
- const authMode = httpApi.auth_mode || httpApi.mode || "jwt"
58
- out.http_api = { enabled: true, auth_mode: authMode }
71
+ // Preserve all extra http_api fields (methods, rate_limit, etc.) while
72
+ // normalizing auth_mode / mode into auth_mode.
73
+ const { mode, auth_mode, ...rest } = httpApi
74
+ out.http_api = { enabled: true, auth_mode: auth_mode || mode || "jwt", ...rest }
59
75
  } else if (hasNonHttpTrigger) {
60
76
  // Non-HTTP trigger: disable http_api
61
77
  out.http_api = { enabled: false, auth_mode: "jwt" }
62
78
  }
63
79
  if (trigger.webhook) {
64
80
  const wh = trigger.webhook === true ? {} : trigger.webhook
65
- out.webhook = { path: wh.path || "", enabled: true }
81
+ // Preserve all webhook fields (methods, stripe_webhook, hmac_secret_env,
82
+ // auth_mode, etc.) — previously only `path` survived push.
83
+ out.webhook = { path: wh.path || "", ...wh, enabled: true }
66
84
  }
67
85
  if (trigger.schedule) {
68
86
  out.schedule = { ...trigger.schedule, enabled: true }
@@ -84,13 +102,18 @@ function triggersToDb(trigger = {}) {
84
102
  // ─── Edges (source/target ↔ from/to) ────────────────────────────────────────
85
103
 
86
104
  function edgesToYaml(edges = []) {
87
- return edges.map(e => {
105
+ // Pull-side: be symmetric with edgesToDb which throws on missing source/target.
106
+ // Instead of silently producing `{ from: undefined, to: undefined }`, we skip
107
+ // malformed edges entirely. This shouldn't happen with a healthy engine but
108
+ // protects the YAML from half-broken entries that would confuse diff/push.
109
+ return (edges || []).map(e => {
110
+ if (!e || typeof e !== "object" || !e.source || !e.target) return null
88
111
  const out = { from: e.source, to: e.target }
89
112
  if (e.condition) out.condition = e.condition
90
113
  if (e.source_handle) out.source_handle = e.source_handle
91
114
  if (e.target_handle) out.target_handle = e.target_handle
92
115
  return out
93
- })
116
+ }).filter(Boolean)
94
117
  }
95
118
 
96
119
  function edgesToDb(edges = []) {
@@ -127,12 +150,24 @@ export function serializeEndpoint(row, mapsCtx) {
127
150
  const extractedFiles = []
128
151
  const emitFile = (path, content) => extractedFiles.push({ path, content })
129
152
 
130
- // Pre-count nodes that produce extracted files — used to decide flat vs dotted filenames
153
+ // Pre-count nodes that produce extracted files — used to decide flat vs
154
+ // dotted filenames. Only count nodes whose content will actually be
155
+ // extracted (i.e. exceeds the inline threshold). Short content stays
156
+ // inline in the YAML and doesn't contribute to the count, so files don't
157
+ // get spurious `.node-id` suffixes when there's only one actual file.
158
+ // Thresholds are imported from transforms.js to keep the two sides in sync.
131
159
  const rawNodes = wf.nodes || []
132
- const sqlNodeCount = rawNodes.filter(n => n.node_type === "dypai_database" && n.parameters?.query).length
133
- const promptNodeCount = rawNodes.filter(n => n.node_type === "agent" && n.parameters?.system_prompt).length
160
+ const sqlNodeCount = rawNodes.filter(n =>
161
+ n.node_type === "dypai_database" &&
162
+ n.parameters?.query && n.parameters.query.length > SQL_INLINE_MAX_CHARS
163
+ ).length
164
+ const promptNodeCount = rawNodes.filter(n =>
165
+ n.node_type === "agent" &&
166
+ n.parameters?.system_prompt && n.parameters.system_prompt.length > PROMPT_INLINE_MAX_CHARS
167
+ ).length
134
168
  const codeNodeCount = rawNodes.filter(n =>
135
- (n.node_type === "javascript_code" || n.node_type === "python_code") && n.parameters?.code
169
+ (n.node_type === "javascript_code" || n.node_type === "python_code") &&
170
+ n.parameters?.code && n.parameters.code.length > SQL_INLINE_MAX_CHARS
136
171
  ).length
137
172
 
138
173
  const nodes = rawNodes.map(node => {
@@ -65,7 +65,8 @@ export const dypaiDiffTool = {
65
65
  stateSnapshot,
66
66
  })
67
67
 
68
- const totalChanges = plan.create.length + plan.update.length + plan.delete.length
68
+ const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
69
+ const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
69
70
  const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
70
71
 
71
72
  return {
@@ -76,6 +77,9 @@ export const dypaiDiffTool = {
76
77
  delete: plan.delete.length,
77
78
  unchanged: plan.unchanged.length,
78
79
  orphans_ignored: plan.orphansIgnored?.length || 0,
80
+ groups_create: plan.groups?.create?.length || 0,
81
+ groups_delete: plan.groups?.delete?.length || 0,
82
+ groups_unchanged: plan.groups?.unchanged?.length || 0,
79
83
  warnings: plan.warnings?.length || 0,
80
84
  },
81
85
  // Only include plan details when there are actual changes or warnings
@@ -389,6 +389,24 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
389
389
  const plan = { create: [], update: [], delete: [], unchanged: [] }
390
390
  const warnings = []
391
391
 
392
+ // ── Groups plan ──────────────────────────────────────────────────────
393
+ // Groups are 100% folder-driven: each first-level subfolder in endpoints/
394
+ // IS a group. The remote is made to match the local folder structure
395
+ // unconditionally — new folders create groups, disappeared folders delete
396
+ // them. No flag, no opt-in. If a user later re-creates the folder, the
397
+ // group is re-created automatically. Zero friction.
398
+ const localGroups = new Set()
399
+ for (const entry of Object.values(local.byName)) {
400
+ const g = entry?.doc?.group
401
+ if (g) localGroups.add(g)
402
+ }
403
+ const remoteGroupNames = new Set(Object.keys(mapsCtx.groupNameToId || {}))
404
+ plan.groups = {
405
+ create: [...localGroups].filter(g => !remoteGroupNames.has(g)).sort(),
406
+ delete: [...remoteGroupNames].filter(g => !localGroups.has(g)).sort(),
407
+ unchanged: [...localGroups].filter(g => remoteGroupNames.has(g)).sort(),
408
+ }
409
+
392
410
  for (const name of Object.keys(local.byName)) {
393
411
  const entry = local.byName[name]
394
412
  const localCanonical = localToComparable(entry, mapsCtx)
@@ -109,6 +109,46 @@ async function applyDelete(endpointId, projectId) {
109
109
  return assertMutationOK(res, "delete", endpointId)
110
110
  }
111
111
 
112
+ // ─── Group apply helpers ───────────────────────────────────────────────────
113
+ //
114
+ // Groups are fully folder-driven: create missing, delete orphans. The push
115
+ // flow calls these BEFORE endpoint creates (so new endpoints can reference
116
+ // freshly-created groups) and AFTER endpoint deletes (so groups are only
117
+ // orphan once their endpoints are gone).
118
+
119
+ async function applyGroupCreate(name, projectId, mapsCtx) {
120
+ const res = await proxyToolCall("manage_endpoint_groups", {
121
+ ...(projectId ? { project_id: projectId } : {}),
122
+ operation: "create",
123
+ name,
124
+ })
125
+ // Response shape: { group_id | id, name, ... }. Accept both.
126
+ const id = res?.group_id || res?.id
127
+ if (!id) {
128
+ throw new Error(`manage_endpoint_groups(create) did not return a group id for '${name}': ${JSON.stringify(res).slice(0, 200)}`)
129
+ }
130
+ // Update the in-memory map so subsequent endpoint resolutions use the new id.
131
+ mapsCtx.groupNameToId[name] = id
132
+ mapsCtx.groupIdToName[id] = name
133
+ return id
134
+ }
135
+
136
+ async function applyGroupDelete(name, projectId, mapsCtx) {
137
+ const id = mapsCtx.groupNameToId[name]
138
+ if (!id) {
139
+ throw new Error(`cannot delete group '${name}': no id in remote map (already gone?)`)
140
+ }
141
+ const res = await proxyToolCall("manage_endpoint_groups", {
142
+ ...(projectId ? { project_id: projectId } : {}),
143
+ operation: "delete",
144
+ group_id: id,
145
+ })
146
+ assertMutationOK(res, "delete group", name)
147
+ delete mapsCtx.groupNameToId[name]
148
+ delete mapsCtx.groupIdToName[id]
149
+ return id
150
+ }
151
+
112
152
  // ─── Realtime policies sync ────────────────────────────────────────────────
113
153
 
114
154
  /**
@@ -320,7 +360,8 @@ export const dypaiPushTool = {
320
360
  deleteOrphans: delete_orphans,
321
361
  stateSnapshot,
322
362
  })
323
- const totalChanges = plan.create.length + plan.update.length + plan.delete.length
363
+ const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
364
+ const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
324
365
 
325
366
  // Block push on conflicts unless forced
326
367
  const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
@@ -344,13 +385,32 @@ export const dypaiPushTool = {
344
385
  }
345
386
  }
346
387
 
347
- // Apply in order: creates → updates → deletes.
348
- // Within each phase we run with bounded concurrency (5) so pushing 50
349
- // endpoints doesn't take 50 * 200ms = 10s — drops to ~2s on typical RTT.
388
+ // Apply in order:
389
+ // 1. Create missing groups (so endpoint creates can reference them)
390
+ // 2. Create endpoints
391
+ // 3. Update endpoints
392
+ // 4. Delete endpoints
393
+ // 5. Delete orphan groups (only once their endpoints are gone)
394
+ //
395
+ // Within each endpoint phase we run with bounded concurrency (5) so
396
+ // pushing 50 endpoints doesn't take 50 * 200ms = 10s — drops to ~2s.
397
+ // Group operations are sequential because they're fast (a handful of
398
+ // groups typically) and we need mapsCtx mutations to be race-free.
350
399
  const CONCURRENCY = 5
351
- const applied = { created: [], updated: [], deleted: [] }
400
+ const applied = { created: [], updated: [], deleted: [], groups_created: [], groups_deleted: [] }
352
401
  const errors = []
353
402
 
403
+ // Phase 1 — create missing groups BEFORE any endpoint is created,
404
+ // so endpoint creates can resolve their group_id correctly.
405
+ for (const groupName of plan.groups?.create || []) {
406
+ try {
407
+ await applyGroupCreate(groupName, targetProjectId, remote.mapsCtx)
408
+ applied.groups_created.push(groupName)
409
+ } catch (e) {
410
+ errors.push({ op: "create_group", group: groupName, error: e.message })
411
+ }
412
+ }
413
+
354
414
  await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
355
415
  try {
356
416
  const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
@@ -384,6 +444,21 @@ export const dypaiPushTool = {
384
444
  }
385
445
  })
386
446
 
447
+ // Phase 5 — delete orphan groups AFTER endpoint deletes, so a group
448
+ // is only orphan once all its endpoints are gone. This runs only when
449
+ // delete_orphans=true OR when the group has no endpoints on either
450
+ // side (safe case). We always try: the remote API will refuse to
451
+ // delete a group that still has endpoints, which is the correct
452
+ // safety net.
453
+ for (const groupName of plan.groups?.delete || []) {
454
+ try {
455
+ await applyGroupDelete(groupName, targetProjectId, remote.mapsCtx)
456
+ applied.groups_deleted.push(groupName)
457
+ } catch (e) {
458
+ errors.push({ op: "delete_group", group: groupName, error: e.message })
459
+ }
460
+ }
461
+
387
462
  // Also reconcile realtime policies if realtime.yaml exists
388
463
  let realtime = null
389
464
  try {
@@ -407,6 +482,8 @@ export const dypaiPushTool = {
407
482
  updated: applied.updated.length,
408
483
  deleted: applied.deleted.length,
409
484
  unchanged: plan.unchanged.length,
485
+ groups_created: applied.groups_created.length,
486
+ groups_deleted: applied.groups_deleted.length,
410
487
  errors: errors.length,
411
488
  realtime: realtime || { skipped: true, reason: "no realtime.yaml" },
412
489
  },
@@ -434,5 +511,8 @@ function summaryFromPlan(plan) {
434
511
  delete: plan.delete.length,
435
512
  unchanged: plan.unchanged.length,
436
513
  orphans_ignored: plan.orphansIgnored?.length || 0,
514
+ groups_create: plan.groups?.create?.length || 0,
515
+ groups_delete: plan.groups?.delete?.length || 0,
516
+ groups_unchanged: plan.groups?.unchanged?.length || 0,
437
517
  }
438
518
  }
@@ -25,8 +25,9 @@
25
25
 
26
26
  // Only extract truly large content. Below these thresholds, SQL and prompts
27
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
28
+ // Exported so codec.js can use the same cutoffs when deciding file naming.
29
+ export const SQL_INLINE_MAX_CHARS = 500
30
+ export const PROMPT_INLINE_MAX_CHARS = 800
30
31
 
31
32
  const shouldInlineSql = (q) => !q || q.length <= SQL_INLINE_MAX_CHARS
32
33
 
@@ -36,16 +37,23 @@ export const NODE_FIELD_TRANSFORMS = [
36
37
  {
37
38
  name: "credential",
38
39
  pull(params, ctx) {
39
- if (params.credential_id && ctx.credIdToName[params.credential_id]) {
40
- return { credential: ctx.credIdToName[params.credential_id] }
41
- }
42
- return {}
40
+ if (!params.credential_id) return {}
41
+ const name = ctx.credIdToName[params.credential_id]
42
+ if (name) return { credential: name }
43
+ // Credential UUID doesn't resolve to a known name (deleted cred, or map
44
+ // not loaded). Preserve the UUID as-is so the reference isn't silently
45
+ // lost on the next push — the agent or user can see there's a stale
46
+ // reference to fix.
47
+ return { credential: params.credential_id }
43
48
  },
44
49
  push(params, ctx) {
45
- if (params.credential && ctx.credNameToId[params.credential]) {
46
- return { credential_id: ctx.credNameToId[params.credential] }
47
- }
48
- return {}
50
+ if (!params.credential) return {}
51
+ const id = ctx.credNameToId[params.credential]
52
+ if (id) return { credential_id: id }
53
+ // Name didn't resolve — could be a UUID the user wrote directly, or a
54
+ // stale reference. Pass through; the engine validates on execute and
55
+ // returns a clean error.
56
+ return { credential_id: params.credential }
49
57
  },
50
58
  pullConsumes: ["credential_id"],
51
59
  pushConsumes: ["credential"],