@dypai-ai/mcp 1.6.16 → 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.
@@ -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 backend change(s) — local matches what's already saved. Test with dypai_test_endpoint(mode:'draft'), then use dypai_deploy_production(confirm:true) when the user approves going live.`
143
- : draftCount > 0 && totalChanges > 0
144
- ? `${draftCount} backend change(s) already pending; this diff would save ${totalChanges} more. Keep testing in preview, or use dypai_deploy_production(confirm:true) after explicit approval.`
145
- : draftCount > 0
146
- ? `${draftCount} pending backend change(s) — no local changes to push. Test with dypai_test_endpoint(mode:'draft'), then use dypai_deploy_production(confirm:true) after explicit approval.`
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 ──────────────────────────────────────────────────
@@ -84,7 +84,7 @@ function suspiciousPathWarning(resolvedPath, source) {
84
84
 
85
85
  // Subfolders that are always created so the layout is predictable. An agent
86
86
  // never has to check "does this folder exist?" before writing a new SQL/prompt/JS file.
87
- const CANONICAL_SUBDIRS = ["endpoints", "flows", "sql", "prompts", "code", "migrations"]
87
+ const CANONICAL_SUBDIRS = ["endpoints", "flows", "automations", "sql", "prompts", "code", "migrations"]
88
88
 
89
89
  const README_CONTENT = `# dypai/
90
90
 
@@ -97,6 +97,8 @@ Declarative snapshot of your DYPAI project's backend.
97
97
  Subfolders represent endpoint groups, e.g. \`endpoints/Admin/foo.yaml\` → group "Admin".
98
98
  - \`flows/\` — TypeScript flow definitions (\`*.flow.ts\`). **Authoritative for new work.**
99
99
  When a flow exists for an endpoint name, it wins over legacy YAML at validate/push/test time.
100
+ - \`automations/\` — TypeScript automation definitions (\`*.automation.ts\`) for scheduled/webhook business processes.
101
+ Automations compile to normal backend workflows, but also declare setup requirements, notifications, and history metadata for the organization Automations page.
100
102
  - \`capability-catalog.json\` — grouped capability catalog (synced by \`dypai_pull\`).
101
103
  - \`capability-brief.md\` — compact capability index for agents.
102
104
  - \`node-catalog.json\` — every node_type with input/output schemas.
@@ -108,7 +110,7 @@ Declarative snapshot of your DYPAI project's backend.
108
110
  ## Workflow
109
111
 
110
112
  1. \`dypai_pull\` to snapshot the remote state into this folder
111
- 2. Edit \`flows/*.flow.ts\` (preferred) or legacy YAML, SQL, prompts, code
113
+ 2. Edit \`flows/*.flow.ts\` for callable endpoints, \`automations/*.automation.ts\` for scheduled/webhook processes, or legacy YAML, SQL, prompts, code
112
114
  3. \`dypai_validate\` / \`dypai_test_endpoint\` before push
113
115
  4. \`dypai_diff\` to preview changes
114
116
  5. \`dypai_push\` to apply to the remote
@@ -523,17 +525,36 @@ function isFlowTsSource(workflowSource) {
523
525
  return workflowSource?.kind === "flow-ts"
524
526
  }
525
527
 
528
+ function isAutomationTsSource(workflowSource) {
529
+ return workflowSource?.kind === "automation-ts"
530
+ }
531
+
532
+ function isEditableTsSource(workflowSource) {
533
+ return isFlowTsSource(workflowSource) || isAutomationTsSource(workflowSource)
534
+ }
535
+
526
536
  function isFlowSourcePath(path) {
527
537
  return typeof path === "string" && path.startsWith("dypai/flows/") && /\.flow\.ts$/i.test(path)
528
538
  }
529
539
 
530
- export function normalizeFlowSourceFile(workflowSource, endpointName) {
540
+ function isAutomationSourcePath(path) {
541
+ return typeof path === "string" && path.startsWith("dypai/automations/") && /\.automation\.ts$/i.test(path)
542
+ }
543
+
544
+ export function normalizeEditableSourceFile(workflowSource, endpointName) {
531
545
  const raw = typeof workflowSource?.file === "string" ? workflowSource.file.trim().replace(/\\/g, "/") : ""
532
546
  if (isFlowSourcePath(raw)) return raw
547
+ if (isAutomationSourcePath(raw)) return raw
533
548
  if (raw.startsWith("flows/") && /\.flow\.ts$/i.test(raw)) return `dypai/${raw}`
549
+ if (raw.startsWith("automations/") && /\.automation\.ts$/i.test(raw)) return `dypai/${raw}`
550
+ if (isAutomationTsSource(workflowSource)) return `dypai/automations/${endpointName}.automation.ts`
534
551
  return `dypai/flows/${endpointName}.flow.ts`
535
552
  }
536
553
 
554
+ export function normalizeFlowSourceFile(workflowSource, endpointName) {
555
+ return normalizeEditableSourceFile(workflowSource, endpointName)
556
+ }
557
+
537
558
  function flowSourceRelPath(sourceFile) {
538
559
  return sourceFile.replace(/^dypai\//, "")
539
560
  }
@@ -553,7 +574,7 @@ export function buildPersistedFlowSourceMap(sourcePayload) {
553
574
  const byPath = new Map()
554
575
  for (const entry of files) {
555
576
  const path = typeof entry?.path === "string" ? entry.path.trim().replace(/\\/g, "/") : ""
556
- if (!isFlowSourcePath(path)) continue
577
+ if (!isFlowSourcePath(path) && !isAutomationSourcePath(path)) continue
557
578
  const decoded = decodeSourceFileContent(entry)
558
579
  if (decoded == null) continue
559
580
  byPath.set(path, decoded)
@@ -607,13 +628,15 @@ async function removeEndpointYamlFiles(outDir, name, groupName = null) {
607
628
  function renderMissingFlowStub(row, sourceFile, sourceFetch) {
608
629
  const refLine = sourceFetch?.ref ? `# Checked Git source ref: ${sourceFetch.ref}\n` : ""
609
630
  const errorLine = sourceFetch?.error ? `# Source fetch error: ${sourceFetch.error}\n` : ""
631
+ const sourceKind = sourceFile.endsWith(".automation.ts") ? "AUTOMATION" : "FLOW"
632
+ const extension = sourceFile.endsWith(".automation.ts") ? ".automation.ts" : ".flow.ts"
610
633
  return (
611
- `# MISSING AUTHORITATIVE FLOW SOURCE\n` +
612
- `# Remote endpoint '${row.name}' is marked as Flow-authored, but dypai_pull could not find:\n` +
634
+ `# MISSING AUTHORITATIVE ${sourceKind} SOURCE\n` +
635
+ `# Remote endpoint '${row.name}' is marked as ${sourceKind.toLowerCase()}-authored, but dypai_pull could not find:\n` +
613
636
  `# ${sourceFile}\n` +
614
637
  refLine +
615
638
  errorLine +
616
- `# Do not edit this YAML stub. Restore or sync the .flow.ts source, then rerun dypai_pull.\n\n` +
639
+ `# Do not edit this YAML stub. Restore or sync the ${extension} source, then rerun dypai_pull.\n\n` +
617
640
  `# name: ${row.name}\n` +
618
641
  `# method: ${row.method || "POST"}\n`
619
642
  )
@@ -901,9 +924,9 @@ export const dypaiPullTool = {
901
924
  row.workflow_source = parseMaybeJson(row.workflow_source)
902
925
  try {
903
926
  const workflowSource = row.workflow_source
904
- if (isFlowTsSource(workflowSource)) {
927
+ if (isEditableTsSource(workflowSource)) {
905
928
  const groupName = row.group_id ? (mapsCtx.groupIdToName[row.group_id] || null) : null
906
- const sourceFile = normalizeFlowSourceFile(workflowSource, row.name)
929
+ const sourceFile = normalizeEditableSourceFile(workflowSource, row.name)
907
930
  const relFlowPath = flowSourceRelPath(sourceFile)
908
931
  const flowContent = persistedFlowSources.byPath.get(sourceFile)
909
932
  // Drop stale YAML/stubs from a previous pull. Any YAML next to a real
@@ -945,8 +968,8 @@ export const dypaiPullTool = {
945
968
  errors.push({
946
969
  endpoint: row.name,
947
970
  error:
948
- `Endpoint is Flow-authored but ${sourceFile} was not found in persisted project source. ` +
949
- `Restore/sync the .flow.ts source, then rerun dypai_pull.`,
971
+ `Endpoint is source-authored but ${sourceFile} was not found in persisted project source. ` +
972
+ `Restore/sync the editable TypeScript source, then rerun dypai_pull.`,
950
973
  })
951
974
  continue
952
975
  }
@@ -1129,11 +1152,11 @@ export const dypaiPullTool = {
1129
1152
  next_steps: pendingDrafts
1130
1153
  ? [
1131
1154
  `${draftsTotal} pending draft(s). Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft'), then publish or discard.`,
1132
- "After resolving drafts, edit Flow in dypai/flows/ (preferred) or legacy YAML in dypai/endpoints/, then dypai_diff → dypai_push to stage more.",
1155
+ "After resolving drafts, edit Flow in dypai/flows/, Automations in dypai/automations/, or legacy YAML in dypai/endpoints/, then dypai_diff → dypai_push to stage more.",
1133
1156
  ]
1134
1157
  : (endpoints || []).length === 0
1135
- ? ["Empty project. Create tables via execute_sql, then write dypai/flows/<name>.flow.ts and dypai_push."]
1136
- : ["Read dypai/schema.sql before writing queries.", "Edit Flow in dypai/flows/ (preferred) or legacy YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
1158
+ ? ["Empty project. Create tables via execute_sql, then write dypai/flows/<name>.flow.ts for callable endpoints or dypai/automations/<name>.automation.ts for scheduled/webhook processes, then dypai_push."]
1159
+ : ["Read dypai/schema.sql before writing queries.", "Edit Flow in dypai/flows/, Automations in dypai/automations/, or legacy YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
1137
1160
  }
1138
1161
 
1139
1162
  return {
@@ -1155,7 +1178,7 @@ export const dypaiPullTool = {
1155
1178
  : draftsTotal > 0
1156
1179
  ? `${draftsTotal} pending draft(s) on this project — see overview.pending_drafts and decide publish vs discard before pushing more.`
1157
1180
  : endpoints.length === 0
1158
- ? "Empty project. Create tables with execute_sql, then write flows/<name>.flow.ts and dypai_push."
1181
+ ? "Empty project. Create tables with execute_sql, then write flows/<name>.flow.ts or automations/<name>.automation.ts and dypai_push."
1159
1182
  : undefined,
1160
1183
  }
1161
1184
  },
@@ -25,6 +25,7 @@ import {
25
25
  } from "./planner.js"
26
26
  import { runValidation } from "./validate.js"
27
27
  import { runGenerateEndpointTypes } from "./generate-types.js"
28
+ import { writeBackendSourceBaseline } from "./source-diff.js"
28
29
  // Codegen removed from v1 — see pull.js note.
29
30
  // import { regenerateTypes } from "../codegen.js"
30
31
 
@@ -34,7 +35,7 @@ const VALID_RESPONSE_CARDINALITY = new Set(["single", "many", "zero_or_one"])
34
35
  const PUSH_CONCURRENCY = Math.max(1, Number(process.env.DYPAI_PUSH_CONCURRENCY || 1))
35
36
  const PUSH_RETRY_ATTEMPTS = Math.max(1, Number(process.env.DYPAI_PUSH_RETRY_ATTEMPTS || 3))
36
37
  const PUSH_RETRY_DELAY_MS = Math.max(100, Number(process.env.DYPAI_PUSH_RETRY_DELAY_MS || 500))
37
- const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "endpoints", "migrations", "types", "lib"]
38
+ const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "automations", "endpoints", "migrations", "types", "lib"]
38
39
  const BACKEND_SOURCE_CHECKPOINT_EXACT = ["schema.sql", "realtime.yaml"]
39
40
  const BACKEND_SOURCE_CHECKPOINT_EXTS = new Set([
40
41
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
@@ -136,6 +137,8 @@ function normalizeWorkflowSourceFile(row) {
136
137
  const raw = typeof source?.file === "string" ? source.file.trim().replace(/\\/g, "/") : ""
137
138
  if (raw.startsWith("dypai/flows/") && raw.endsWith(".flow.ts")) return raw
138
139
  if (raw.startsWith("flows/") && raw.endsWith(".flow.ts")) return `dypai/${raw}`
140
+ if (raw.startsWith("dypai/automations/") && raw.endsWith(".automation.ts")) return raw
141
+ if (raw.startsWith("automations/") && raw.endsWith(".automation.ts")) return `dypai/${raw}`
139
142
  return null
140
143
  }
141
144
 
@@ -195,6 +198,29 @@ async function checkpointBackendSource(projectId, rootDir, plan, remote) {
195
198
  }
196
199
  }
197
200
 
201
+ async function refreshBackendSourceBaseline(projectId, rootDir, sourceCheckpoint) {
202
+ if (sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true) {
203
+ return {
204
+ ok: false,
205
+ skipped: true,
206
+ reason: "source_checkpoint_failed",
207
+ }
208
+ }
209
+ try {
210
+ const baseline = await writeBackendSourceBaseline(rootDir, projectId)
211
+ return {
212
+ ok: true,
213
+ files: Array.isArray(baseline.files) ? baseline.files.length : 0,
214
+ path: "dypai/.dypai/backend-baseline.json",
215
+ }
216
+ } catch (error) {
217
+ return {
218
+ ok: false,
219
+ error: error?.message || String(error),
220
+ }
221
+ }
222
+ }
223
+
198
224
  /**
199
225
  * Run an async worker over an array with bounded concurrency. Workers never
200
226
  * throw (they handle their own errors via the push errors[] accumulator),
@@ -275,7 +301,7 @@ function assertMutationOK(result, op, name) {
275
301
  * Returns true when the API staged the change as a draft (default behavior)
276
302
  * instead of applying it directly to live. Both the draft branch and the
277
303
  * direct-write branch are valid successful outcomes — drafts just need to
278
- * be reported back to the user so they know to run dypai_deploy_production(confirm:true).
304
+ * be reported back to the user so they know to publish the drafts explicitly.
279
305
  */
280
306
  function isDraftResponse(result) {
281
307
  return result && typeof result === "object" && result.applied_to === "draft"
@@ -439,7 +465,7 @@ export const dypaiPushTool = {
439
465
  },
440
466
  dry_run: {
441
467
  type: "boolean",
442
- description: "If true, compute the plan and return it without applying. Same as dypai_diff.",
468
+ description: "If true, compile and compute the backend plan without applying or regenerating types. For a fast source-only preview use dypai_diff.",
443
469
  default: false,
444
470
  },
445
471
  force: {
@@ -499,11 +525,13 @@ export const dypaiPushTool = {
499
525
  }
500
526
  }
501
527
 
502
- let typesGenerated = null
503
- try {
504
- typesGenerated = await runGenerateEndpointTypes(rootDir, targetProjectId)
505
- } catch (e) {
506
- typesGenerated = { ok: false, error: e.message }
528
+ let typesGenerated = dry_run ? { ok: true, skipped: true, reason: "dry_run" } : null
529
+ if (!dry_run) {
530
+ try {
531
+ typesGenerated = await runGenerateEndpointTypes(rootDir, targetProjectId)
532
+ } catch (e) {
533
+ typesGenerated = { ok: false, error: e.message }
534
+ }
507
535
  }
508
536
 
509
537
  let local
@@ -594,6 +622,9 @@ export const dypaiPushTool = {
594
622
  ? null
595
623
  : await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
596
624
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
625
+ const sourceBaseline = !dry_run && !sourceCheckpointFailed
626
+ ? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
627
+ : undefined
597
628
  return {
598
629
  success: !sourceCheckpointFailed,
599
630
  applied: false,
@@ -602,6 +633,7 @@ export const dypaiPushTool = {
602
633
  plan,
603
634
  types: typesGenerated,
604
635
  source_checkpoint: sourceCheckpoint || undefined,
636
+ source_baseline: sourceBaseline,
605
637
  hint: sourceCheckpointFailed
606
638
  ? "Backend runtime was unchanged, but DYPAI could not save the local dypai/ source to the Studio branch. Retry dypai_push once the GitHub/source checkpoint issue is resolved."
607
639
  : undefined,
@@ -725,7 +757,7 @@ export const dypaiPushTool = {
725
757
  ]
726
758
 
727
759
  // How many ops landed in draft state. When `draftCount > 0` the user
728
- // must run dypai_deploy_production(confirm:true) to expose the changes
760
+ // must publish drafts explicitly to expose the changes
729
761
  // on live; tests against live won't see them yet. Realtime drafts are
730
762
  // also counted — the API returns `applied_to: "draft"` and a
731
763
  // `drafts_queued` array when realtime policies were staged.
@@ -752,6 +784,9 @@ export const dypaiPushTool = {
752
784
  reason: "push_had_errors",
753
785
  }
754
786
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
787
+ const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
788
+ ? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
789
+ : undefined
755
790
 
756
791
  return {
757
792
  success: errors.length === 0 && !sourceCheckpointFailed,
@@ -786,6 +821,7 @@ export const dypaiPushTool = {
786
821
  errors: errors.length ? errors : undefined,
787
822
  types: typesGenerated,
788
823
  source_checkpoint: sourceCheckpoint,
824
+ source_baseline: sourceBaseline,
789
825
  // Only one next_step — and only when it's non-obvious. Drafts win
790
826
  // over the test suggestion because tests against live won't see
791
827
  // the change until the drafts are promoted.
@@ -794,7 +830,7 @@ export const dypaiPushTool = {
794
830
  ? `${endpointTotal} endpoint(s) saved, ${failedTotal} failed. Fix failed endpoints and push again. Failed: ${errors.slice(0, 3).map((item) => item.endpoint || item.group || item.op).join(", ")}${errors.length > 3 ? "…" : ""}`
795
831
  : "Fix the offending YAMLs and push again."
796
832
  : draftCount > 0
797
- ? `${draftCount} backend change(s) saved to preview — they're not live yet. Verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') or ask the user to test the preview. Publish with dypai_deploy_production(confirm:true) ONLY after explicit approval.`
833
+ ? `${draftCount} backend change(s) saved as drafts — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') when useful, then publish with manage_drafts(operation:'publish', confirm:true) or dypai_deploy_production(target:'backend', confirm:true) ONLY after explicit approval.`
798
834
  : changedNames.length
799
835
  ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
800
836
  : undefined,