@dypai-ai/mcp 1.6.15 → 1.6.17

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.
@@ -0,0 +1,210 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { basename, dirname, join, resolve as resolvePath } from "path"
3
+ import YAML from "yaml"
4
+ import { deployFromSource } from "./deploy.js"
5
+ import { proxyToolCall } from "./proxy.js"
6
+ import { resolveOutDir } from "./sync/path-resolver.js"
7
+
8
+ function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
9
+ if (workspace_root) {
10
+ const workspaceRoot = resolvePath(workspace_root)
11
+ return { workspaceRoot, rootDir: resolvePath(workspaceRoot, "dypai"), source: "workspace_root" }
12
+ }
13
+ if (sourceDirectory) {
14
+ const workspaceRoot = resolvePath(sourceDirectory)
15
+ return { workspaceRoot, rootDir: resolvePath(workspaceRoot, "dypai"), source: "sourceDirectory" }
16
+ }
17
+ const resolved = resolveOutDir(root_dir || "./dypai")
18
+ const rootDir = resolved.path
19
+ const workspaceRoot = basename(rootDir) === "dypai" ? dirname(rootDir) : rootDir
20
+ return { workspaceRoot, rootDir, source: `root_dir:${resolved.source}` }
21
+ }
22
+
23
+ function readProjectIdFromConfig(rootDir) {
24
+ const configPath = join(rootDir, "dypai.config.yaml")
25
+ if (!existsSync(configPath)) return null
26
+ try {
27
+ const doc = YAML.parse(readFileSync(configPath, "utf8"))
28
+ return typeof doc?.project_id === "string" && doc.project_id.trim()
29
+ ? doc.project_id.trim()
30
+ : null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function draftCount(result) {
37
+ if (!result || result.error) return null
38
+ if (Number.isFinite(result.total)) return result.total
39
+ if (Array.isArray(result.drafts)) return result.drafts.length
40
+ if (Array.isArray(result.items)) return result.items.length
41
+ if (Array.isArray(result.resources)) return result.resources.length
42
+ return 0
43
+ }
44
+
45
+ function isSuccessful(result) {
46
+ return result && result.success !== false && !result.error
47
+ }
48
+
49
+ export const dypaiDeployProductionTool = {
50
+ name: "dypai_deploy_production",
51
+ description:
52
+ "PUBLISH PRODUCTION — make the saved DYPAI project live. Requires confirm:true. " +
53
+ "By default publishes/merges pending backend drafts first, then deploys the frontend to production. " +
54
+ "Use target:'backend' for backend-only Flow/Automation releases; use target:'frontend' only when backend drafts must stay pending. " +
55
+ "Use only after the user explicitly approves going live. For saving work without production, use dypai_push.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ project_id: {
60
+ type: "string",
61
+ description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
62
+ },
63
+ workspace_root: {
64
+ type: "string",
65
+ description: "Absolute project root containing package.json and dypai/. Optional when cwd/env can resolve it.",
66
+ },
67
+ sourceDirectory: {
68
+ type: "string",
69
+ description: "Alias for workspace_root. Temporary: production frontend deploy still uses local source until the API supports deploying directly from Studio HEAD.",
70
+ },
71
+ root_dir: {
72
+ type: "string",
73
+ description: "Backend dypai/ folder used to infer workspace/project. Default ./dypai.",
74
+ default: "./dypai",
75
+ },
76
+ force: {
77
+ type: "boolean",
78
+ description: "Frontend only. Re-send all files even if the local manifest says nothing changed.",
79
+ default: false,
80
+ },
81
+ target: {
82
+ type: "string",
83
+ enum: ["all", "backend", "frontend"],
84
+ description: "What to publish. all = backend drafts + frontend production. backend = publish only pending backend drafts. frontend = deploy only frontend source.",
85
+ default: "all",
86
+ },
87
+ confirm: {
88
+ type: "boolean",
89
+ description: "Required true. This publishes production/live.",
90
+ default: false,
91
+ },
92
+ },
93
+ required: ["confirm"],
94
+ },
95
+
96
+ async execute({ project_id, workspace_root, sourceDirectory, root_dir = "./dypai", force = false, target = "all", confirm = false } = {}) {
97
+ const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
98
+ const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
99
+ const publishTarget = ["all", "backend", "frontend"].includes(target) ? target : "all"
100
+ const publishBackend = publishTarget === "all" || publishTarget === "backend"
101
+ const deployFrontend = publishTarget === "all" || publishTarget === "frontend"
102
+
103
+ if (confirm !== true) {
104
+ return {
105
+ confirmation_required: true,
106
+ live_changed: false,
107
+ summary:
108
+ publishTarget === "backend"
109
+ ? "This will publish pending backend drafts to PRODUCTION without deploying frontend. Get explicit user approval, then call again with confirm:true."
110
+ : publishTarget === "frontend"
111
+ ? "This will deploy the frontend to PRODUCTION without publishing pending backend drafts. Get explicit user approval, then call again with confirm:true."
112
+ : "This will publish pending backend drafts and deploy the frontend to PRODUCTION. Get explicit user approval, then call again with confirm:true.",
113
+ next_call: {
114
+ tool: "dypai_deploy_production",
115
+ ...(targetProjectId ? { project_id: targetProjectId } : {}),
116
+ workspace_root: workspaceRoot,
117
+ ...(force ? { force: true } : {}),
118
+ ...(publishTarget !== "all" ? { target: publishTarget } : {}),
119
+ confirm: true,
120
+ },
121
+ }
122
+ }
123
+
124
+ if (!targetProjectId) {
125
+ return {
126
+ success: false,
127
+ error: "project_id is required. Pass project_id or run from a workspace containing dypai/dypai.config.yaml.",
128
+ workspace_root: workspaceRoot,
129
+ root_dir: rootDir,
130
+ resolved_via: source,
131
+ live_changed: false,
132
+ }
133
+ }
134
+
135
+ let pendingDrafts = 0
136
+ let backend = {
137
+ skipped: true,
138
+ reason: publishBackend ? "no_pending_drafts" : "target_frontend",
139
+ pending_drafts: 0,
140
+ }
141
+
142
+ if (publishBackend) {
143
+ const draftsList = await proxyToolCall("manage_drafts", {
144
+ project_id: targetProjectId,
145
+ operation: "list",
146
+ })
147
+ pendingDrafts = draftCount(draftsList)
148
+ if (pendingDrafts == null) {
149
+ return {
150
+ success: false,
151
+ phase: "list_backend_drafts",
152
+ target: publishTarget,
153
+ backend: draftsList,
154
+ frontend: { skipped: true, reason: "could_not_list_backend_drafts" },
155
+ live_changed: false,
156
+ }
157
+ }
158
+
159
+ if (pendingDrafts > 0) {
160
+ backend = await proxyToolCall("manage_drafts", {
161
+ project_id: targetProjectId,
162
+ operation: "publish",
163
+ confirm: true,
164
+ })
165
+ if (!isSuccessful(backend)) {
166
+ return {
167
+ success: false,
168
+ phase: "publish_backend_drafts",
169
+ target: publishTarget,
170
+ backend,
171
+ frontend: { skipped: true, reason: "backend_publish_failed" },
172
+ live_changed: false,
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ let frontend = {
179
+ skipped: true,
180
+ reason: deployFrontend ? "no_local_package_json" : "target_backend",
181
+ note: deployFrontend
182
+ ? "Temporary limitation: frontend production deploy currently needs local sourceDirectory/workspace_root. API deploy-from-Studio-HEAD is the next backend change."
183
+ : undefined,
184
+ }
185
+ if (deployFrontend && existsSync(join(workspaceRoot, "package.json"))) {
186
+ frontend = await deployFromSource({
187
+ sourceDirectory: workspaceRoot,
188
+ project_id: targetProjectId,
189
+ force: !!force,
190
+ target: "both",
191
+ })
192
+ }
193
+
194
+ const frontendOk = frontend.skipped === true || isSuccessful(frontend)
195
+ return {
196
+ success: frontendOk,
197
+ project_id: targetProjectId,
198
+ workspace_root: workspaceRoot,
199
+ target: publishTarget,
200
+ backend,
201
+ frontend,
202
+ backend_published: pendingDrafts > 0 && isSuccessful(backend),
203
+ frontend_deploy_queued: frontendOk && frontend.skipped !== true,
204
+ live_changed: frontendOk && (pendingDrafts > 0 || frontend.skipped !== true),
205
+ next_step: frontendOk && frontend.skipped !== true
206
+ ? "Production deploy was queued. Check the production URL/build status until the build reaches success or failure."
207
+ : undefined,
208
+ }
209
+ },
210
+ }
@@ -0,0 +1,221 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { basename, dirname, join, resolve as resolvePath } from "path"
3
+ import YAML from "yaml"
4
+ import { deployFromSource } from "./deploy.js"
5
+ import { dypaiPushTool as backendPushTool } from "./sync/push.js"
6
+ import { resolveOutDir } from "./sync/path-resolver.js"
7
+
8
+ function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
9
+ if (workspace_root) {
10
+ const workspaceRoot = resolvePath(workspace_root)
11
+ return {
12
+ workspaceRoot,
13
+ rootDir: resolvePath(workspaceRoot, "dypai"),
14
+ source: "workspace_root",
15
+ }
16
+ }
17
+
18
+ if (sourceDirectory) {
19
+ const workspaceRoot = resolvePath(sourceDirectory)
20
+ return {
21
+ workspaceRoot,
22
+ rootDir: resolvePath(workspaceRoot, "dypai"),
23
+ source: "sourceDirectory",
24
+ }
25
+ }
26
+
27
+ const resolved = resolveOutDir(root_dir || "./dypai")
28
+ const rootDir = resolved.path
29
+ const workspaceRoot = basename(rootDir) === "dypai" ? dirname(rootDir) : rootDir
30
+ return { workspaceRoot, rootDir, source: `root_dir:${resolved.source}` }
31
+ }
32
+
33
+ function isSuccessful(result) {
34
+ return result && result.success !== false && !result.error
35
+ }
36
+
37
+ function sanitizeLegacyShipText(value) {
38
+ if (typeof value === "string") {
39
+ return value
40
+ .replace(/\bmanage_frontend\b/g, "dypai_push")
41
+ .replace(/Frontend code is not affected[^.]*\./g, "Frontend source is saved by the project-level dypai_push wrapper.")
42
+ }
43
+ if (Array.isArray(value)) return value.map(sanitizeLegacyShipText)
44
+ if (value && typeof value === "object") {
45
+ return Object.fromEntries(
46
+ Object.entries(value).map(([key, nested]) => [key, sanitizeLegacyShipText(nested)]),
47
+ )
48
+ }
49
+ return value
50
+ }
51
+
52
+ function readProjectIdFromConfig(rootDir) {
53
+ const configPath = join(rootDir, "dypai.config.yaml")
54
+ if (!existsSync(configPath)) return null
55
+ try {
56
+ const doc = YAML.parse(readFileSync(configPath, "utf8"))
57
+ return typeof doc?.project_id === "string" && doc.project_id.trim()
58
+ ? doc.project_id.trim()
59
+ : null
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ export const dypaiPushProjectTool = {
66
+ name: "dypai_push",
67
+ description:
68
+ "SAVE CHANGES — push all local DYPAI project changes to Studio/DYPAI without publishing production. " +
69
+ "Backend files under dypai/ are staged as drafts. Frontend files under src/, public/, package.json, and versionable dypai/ source are saved to the Studio branch. " +
70
+ "Production is never changed by this tool. Use dypai_deploy_production(confirm:true) after explicit user approval to publish live.",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ project_id: {
75
+ type: "string",
76
+ description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
77
+ },
78
+ workspace_root: {
79
+ type: "string",
80
+ description: "Absolute project root containing package.json and/or dypai/. Optional; inferred from root_dir/cwd when omitted.",
81
+ },
82
+ sourceDirectory: {
83
+ type: "string",
84
+ description: "Alias for workspace_root, kept for compatibility with older frontend calls.",
85
+ },
86
+ root_dir: {
87
+ type: "string",
88
+ description: "Backend dypai/ folder. Default ./dypai. Prefer workspace_root for new calls.",
89
+ default: "./dypai",
90
+ },
91
+ delete_orphans: {
92
+ type: "boolean",
93
+ description: "Backend only. If true, endpoints in remote but missing locally get deleted as drafts. Default: false.",
94
+ default: false,
95
+ },
96
+ dry_run: {
97
+ type: "boolean",
98
+ description: "If true, computes backend plan and does not write backend or frontend.",
99
+ default: false,
100
+ },
101
+ force: {
102
+ type: "boolean",
103
+ description: "Backend conflict override and frontend full save. Use only when needed. Default: false.",
104
+ default: false,
105
+ },
106
+ skip_validation: {
107
+ type: "boolean",
108
+ description: "Backend only. Skip dypai_validate pre-flight. Default: false.",
109
+ default: false,
110
+ },
111
+ save_frontend: {
112
+ type: "boolean",
113
+ description: "If false, only pushes backend drafts. Default: true.",
114
+ default: true,
115
+ },
116
+ },
117
+ required: [],
118
+ },
119
+
120
+ async execute({
121
+ project_id,
122
+ workspace_root,
123
+ sourceDirectory,
124
+ root_dir = "./dypai",
125
+ delete_orphans = false,
126
+ dry_run = false,
127
+ force = false,
128
+ skip_validation = false,
129
+ save_frontend = true,
130
+ } = {}) {
131
+ const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
132
+ const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
133
+ const hasBackend = existsSync(rootDir)
134
+ const hasFrontend = existsSync(join(workspaceRoot, "package.json"))
135
+
136
+ if (!hasBackend && !hasFrontend) {
137
+ return {
138
+ success: false,
139
+ error: "No DYPAI project source found. Expected package.json and/or dypai/.",
140
+ workspace_root: workspaceRoot,
141
+ root_dir: rootDir,
142
+ resolved_via: source,
143
+ }
144
+ }
145
+ if (!targetProjectId) {
146
+ return {
147
+ success: false,
148
+ error: "project_id is required. Pass project_id or run from a workspace containing dypai/dypai.config.yaml.",
149
+ workspace_root: workspaceRoot,
150
+ root_dir: rootDir,
151
+ resolved_via: source,
152
+ }
153
+ }
154
+
155
+ let backend = {
156
+ skipped: true,
157
+ reason: hasBackend ? "dry_run_or_disabled" : "no_dypai_folder",
158
+ }
159
+
160
+ if (hasBackend) {
161
+ backend = sanitizeLegacyShipText(await backendPushTool.execute({
162
+ project_id: targetProjectId,
163
+ root_dir: rootDir,
164
+ delete_orphans,
165
+ dry_run,
166
+ force,
167
+ skip_validation,
168
+ }))
169
+ }
170
+
171
+ if (!isSuccessful(backend)) {
172
+ return {
173
+ success: false,
174
+ phase: "backend_push",
175
+ workspace_root: workspaceRoot,
176
+ root_dir: rootDir,
177
+ backend,
178
+ frontend: {
179
+ skipped: true,
180
+ reason: "backend_push_failed",
181
+ },
182
+ live_changed: false,
183
+ }
184
+ }
185
+
186
+ let frontend = {
187
+ skipped: true,
188
+ reason: dry_run
189
+ ? "dry_run"
190
+ : !save_frontend
191
+ ? "save_frontend_false"
192
+ : hasFrontend
193
+ ? "not_run"
194
+ : "no_package_json",
195
+ }
196
+
197
+ if (!dry_run && save_frontend && hasFrontend) {
198
+ frontend = await deployFromSource({
199
+ sourceDirectory: workspaceRoot,
200
+ project_id: targetProjectId,
201
+ force: !!force,
202
+ target: "studio",
203
+ })
204
+ }
205
+
206
+ const frontendOk = frontend.skipped === true || isSuccessful(frontend)
207
+ return {
208
+ success: frontendOk,
209
+ workspace_root: workspaceRoot,
210
+ root_dir: rootDir,
211
+ backend,
212
+ frontend,
213
+ backend_saved: hasBackend && isSuccessful(backend),
214
+ frontend_saved: hasFrontend && frontendOk && frontend.skipped !== true,
215
+ live_changed: false,
216
+ next_step: frontendOk
217
+ ? "Changes are saved to Studio/DYPAI. Test the preview. To publish live, use dypai_deploy_production(confirm:true) after explicit user approval."
218
+ : "Frontend save failed. Fix the frontend/source issue and run dypai_push again.",
219
+ }
220
+ },
221
+ }
@@ -129,7 +129,7 @@ export function validateSql(sql, opts = {}) {
129
129
  : schema === "storage"
130
130
  ? "To manage files, use manage_storage. Read-only SELECTs against storage.* are allowed."
131
131
  : schema === "system"
132
- ? "To manage endpoints, use dypai_push / manage_drafts / manage_users / manage_roles. Read-only SELECTs against system.* are allowed."
132
+ ? "To manage endpoints, use dypai_push / dypai_deploy_production / manage_users / manage_roles. Read-only SELECTs against system.* are allowed."
133
133
  : "Read-only SELECTs against this schema are allowed.",
134
134
  }
135
135
  }
@@ -1,26 +1,13 @@
1
- /**
2
- * dypai_diff — preview what dypai_push would change. Read-only, safe.
3
- *
4
- * The picture has THREE layers, not two:
5
- * - LOCAL : what's on disk in dypai/
6
- * - DRAFT : what's already been staged via prior pushes (system.config_drafts)
7
- * - LIVE : what the engine actually serves at <project_id>.dypai.dev
8
- *
9
- * The diff body reports LOCAL → EFFECTIVE_REMOTE (live + pending drafts) —
10
- * what `dypai_push` will actually queue. We additionally surface the DRAFT
11
- * layer metadata so the agent can spot overlap and publish/discard decisions.
12
- */
13
-
14
1
  import { resolve as resolvePath } from "path"
15
- import { fetchRemoteState, readLocalEffectiveState, readLocalStateSnapshot, readLocalConfig, computePlan, buildEffectiveRemoteState } from "./planner.js"
16
- import { proxyToolCall } from "../proxy.js"
2
+ import { readLocalConfig } from "./planner.js"
3
+ import { diffBackendSource } from "./source-diff.js"
17
4
 
18
5
  export const dypaiDiffTool = {
19
6
  name: "dypai_diff",
20
7
  description:
21
- "Compare the local ./dypai/ folder against the remote project state without applying anything. " +
22
- "Returns a plan: endpoints to create, update (with changed fields), delete, and unchanged. " +
23
- "Always call this before dypai_push to preview changes safely.",
8
+ "Fast source diff for the local ./dypai/ folder. " +
9
+ "It does not compile, call remote services, or stage changes. " +
10
+ "Use dypai_push to compile, validate, save backend drafts, and regenerate types.",
24
11
  inputSchema: {
25
12
  type: "object",
26
13
  properties: {
@@ -35,138 +22,49 @@ export const dypaiDiffTool = {
35
22
  },
36
23
  delete_orphans: {
37
24
  type: "boolean",
38
- description: "If true, endpoints present in remote but missing locally are queued for deletion. Default: false (safe).",
25
+ description: "Ignored by dypai_diff. Deletions are only applied by dypai_push when explicitly requested.",
39
26
  default: false,
40
27
  },
41
28
  },
42
29
  },
43
30
 
44
- async execute({ project_id, root_dir = "./dypai", delete_orphans = false } = {}) {
31
+ async execute({ project_id, root_dir = "./dypai" } = {}) {
45
32
  const rootDir = resolvePath(process.cwd(), root_dir)
46
33
 
47
34
  const config = await readLocalConfig(rootDir)
48
35
  const targetProjectId = project_id || config?.project_id || null
36
+ const diff = await diffBackendSource(rootDir)
49
37
 
50
- const [local, remote, stateSnapshot, draftsResult] = await Promise.all([
51
- readLocalEffectiveState(rootDir, targetProjectId),
52
- fetchRemoteState(targetProjectId),
53
- readLocalStateSnapshot(rootDir),
54
- // Pending drafts. Cheap on dev (always 0); on prod surfaces what's
55
- // already staged so the agent can reason about overlap with the local
56
- // change set. Old engines without the drafts API silently fall back.
57
- proxyToolCall("manage_drafts", targetProjectId
58
- ? { project_id: targetProjectId, operation: "list" }
59
- : { operation: "list" }
60
- ).catch(() => ({ total: 0, drafts: [], _unavailable: true })),
61
- ])
62
-
63
- if (local.errors.length) {
64
- return {
65
- success: false,
66
- phase: "read_local",
67
- errors: local.errors,
68
- hint: "Fix the YAML/file errors before running diff again.",
69
- }
70
- }
71
-
72
- if (!remote || !remote.mapsCtx) {
38
+ if (!diff.ok) {
73
39
  return {
74
40
  success: false,
75
- phase: "fetch_remote",
76
- error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
41
+ phase: "source_diff",
42
+ reason: diff.reason,
43
+ error: diff.error,
44
+ hint: "Fix the local dypai/.dypai/backend-baseline.json or run dypai_pull/sync again.",
77
45
  }
78
46
  }
79
47
 
80
- const effectiveRemote = buildEffectiveRemoteState(remote, draftsResult)
81
- const plan = computePlan(local, effectiveRemote, remote.mapsCtx, {
82
- deleteOrphans: delete_orphans,
83
- stateSnapshot,
84
- liveRemote: remote,
85
- })
86
-
87
- const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
88
- const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
89
- const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
90
-
91
- // ─── Drafts overlay ───────────────────────────────────────────────────
92
- // Build a `pending_drafts` view that tells the agent what's already
93
- // staged AND highlights the overlap with this diff's local-change set.
94
- // The overlap is the agent-relevant signal: same endpoint touched on
95
- // BOTH sides means pushing now will overwrite the existing draft.
96
- const draftItems = Array.isArray(draftsResult?.drafts) ? draftsResult.drafts : []
97
- const draftCount = draftsResult?.total || 0
98
-
99
- const localChangeNames = new Set([
100
- ...plan.create.map(p => p.name),
101
- ...plan.update.map(p => p.name),
102
- ...plan.delete.map(p => p.name),
103
- ])
104
-
105
- const overlap = draftItems
106
- .filter(d => d.resource_type === "endpoint" && localChangeNames.has(d.resource_name))
107
- .map(d => ({
108
- endpoint: d.resource_name,
109
- existing_draft_op: d.op,
110
- local_change_op: plan.create.find(p => p.name === d.resource_name) ? "create"
111
- : plan.update.find(p => p.name === d.resource_name) ? "update"
112
- : "delete",
113
- }))
114
-
115
- const pendingDrafts = draftCount > 0 ? {
116
- total: draftCount,
117
- counts_by_type: draftsResult?.counts_by_type || {},
118
- items: draftItems
119
- .map(d => ({
120
- resource_type: d.resource_type,
121
- resource_name: d.resource_name,
122
- op: d.op,
123
- }))
124
- .sort((a, b) => (a.resource_name || "").localeCompare(b.resource_name || "")),
125
- // Endpoints that are BOTH staged AND modified locally — pushing again
126
- // replaces the existing draft. Empty array = no overlap (clean signal).
127
- overlap_with_local: overlap,
128
- } : null
129
-
130
- // ─── Hint priority ────────────────────────────────────────────────────
131
- // 1. Conflicts and missing creds always win (block push).
132
- // 2. Overlap is next: explicitly tell the agent that re-push overwrites.
133
- // 3. Pending drafts (no overlap): suggest publishing or discarding before
134
- // stacking more on top.
135
- const hint = hasConflicts
136
- ? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
137
- : (plan.warnings || []).some(w => w.type === "missing_credential")
138
- ? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
139
- : overlap.length > 0
140
- ? `${overlap.length} endpoint(s) have BOTH a pending draft AND a local change — dypai_push will REPLACE the existing draft with the new version. Review pending_drafts.overlap_with_local before pushing.`
141
- : totalChanges === 0 && draftCount > 0
142
- ? `${draftCount} pending draft(s) — local matches what's already staged. Test with dypai_test_endpoint(mode:'draft'), then manage_drafts(operation:'publish', confirm:true) when ready.`
143
- : draftCount > 0 && totalChanges > 0
144
- ? `${draftCount} draft(s) already pending; this diff would queue ${totalChanges} more. Consider manage_drafts(operation:'publish'|'discard', confirm:true) first to keep the staged set focused.`
145
- : draftCount > 0
146
- ? `${draftCount} pending draft(s) — no local changes to push. Run manage_drafts(operation:'list') to review, then 'publish' (confirm:true) or 'discard' (confirm:true).`
147
- : undefined
48
+ const totalChanges = diff.summary.create + diff.summary.update + diff.summary.delete
148
49
 
149
50
  return {
150
51
  success: true,
52
+ mode: "source",
53
+ project_id: targetProjectId,
54
+ baseline: diff.baseline,
151
55
  summary: {
152
- create: plan.create.length,
153
- update: plan.update.length,
154
- delete: plan.delete.length,
155
- unchanged: plan.unchanged.length,
156
- orphans_ignored: plan.orphansIgnored?.length || 0,
157
- groups_create: plan.groups?.create?.length || 0,
158
- groups_delete: plan.groups?.delete?.length || 0,
159
- groups_unchanged: plan.groups?.unchanged?.length || 0,
160
- warnings: plan.warnings?.length || 0,
161
- pending_drafts: draftCount,
162
- drafts_overlap_with_local: overlap.length,
56
+ create: diff.summary.create,
57
+ update: diff.summary.update,
58
+ delete: diff.summary.delete,
59
+ unchanged: diff.summary.unchanged,
163
60
  },
164
- // Only include plan details when there are actual changes or warnings
165
- plan: (totalChanges > 0 || plan.warnings?.length) ? plan : undefined,
166
- // Always present so the agent knows the key exists. `null` = nothing
167
- // staged (cleaner signal than omitted-or-zero ambiguity).
168
- pending_drafts: pendingDrafts,
169
- hint,
61
+ source_changes: totalChanges > 0 ? diff.files : undefined,
62
+ requires_push: totalChanges > 0,
63
+ requires_compile: totalChanges > 0,
64
+ pending_drafts: null,
65
+ hint: totalChanges > 0
66
+ ? "Local backend source changed. Run dypai_push to compile, validate, save preview drafts, and regenerate types."
67
+ : "No local backend source changes. Nothing to push.",
170
68
  }
171
69
  },
172
70
  }
@@ -40,6 +40,15 @@ function parseMaybeJson(v) {
40
40
  try { return JSON.parse(v) } catch { return v }
41
41
  }
42
42
 
43
+ function isBackendSourcePath(path) {
44
+ if (typeof path !== "string") return false
45
+ return (
46
+ (path.startsWith("dypai/flows/") && /\.flow\.ts$/i.test(path)) ||
47
+ (path.startsWith("dypai/automations/") && /\.automation\.ts$/i.test(path)) ||
48
+ (path.startsWith("dypai/endpoints/") && /\.(ya?ml)$/i.test(path))
49
+ )
50
+ }
51
+
43
52
  function hydrateRow(row) {
44
53
  return {
45
54
  ...row,
@@ -453,6 +462,7 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
453
462
 
454
463
  const payloads = Array.isArray(bulkResult.data?.payloads) ? bulkResult.data.payloads : []
455
464
  const diagnostics = Array.isArray(bulkResult.data?.diagnostics) ? bulkResult.data.diagnostics : []
465
+ const rejected = Array.isArray(bulkResult.data?.rejected) ? bulkResult.data.rejected : []
456
466
  const byName = {}
457
467
  const shadowed = []
458
468
  const flowNames = new Set()
@@ -484,12 +494,21 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
484
494
  })
485
495
  }
486
496
 
497
+ for (const item of rejected) {
498
+ if (!isBackendSourcePath(item?.path)) continue
499
+ yamlState.errors.push({
500
+ file: item.path,
501
+ error: `Backend compiler rejected source file: ${item.reason || "rejected"}`,
502
+ rule: "backend_compiler_rejected_source",
503
+ })
504
+ }
505
+
487
506
  for (const [name, entry] of Object.entries(yamlState.byName)) {
488
507
  if (flowNames.has(name)) continue
489
508
  byName[name] = { ...entry, source: "legacy-yaml" }
490
509
  }
491
510
 
492
- return { byName, errors: yamlState.errors, shadowed, runnerWarning: null }
511
+ return { byName, errors: yamlState.errors, shadowed, runnerWarning: null, automations: bulkResult.data?.automations ?? null }
493
512
  }
494
513
 
495
514
  // ─── Normalization + diff ──────────────────────────────────────────────────