@dypai-ai/mcp 1.7.1 → 1.7.3

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.
@@ -8,6 +8,7 @@ export const DEFAULT_MCP_PROFILE = "local";
8
8
 
9
9
  const STUDIO_WORKER_TOOLS = [
10
10
  "bulk_upsert",
11
+ "dypai_generate_types",
11
12
  "dypai_test_endpoint",
12
13
  "dypai_validate",
13
14
  "execute_sql",
@@ -247,15 +248,17 @@ export function assertStudioInstructionsSanitized(instructions, { allowDebugTool
247
248
  throw new Error("Studio instructions must describe orchestrator sync/build/validate");
248
249
  }
249
250
 
250
- const withoutNegatedTypegen = instructions.replace(
251
- /endpoint typescript types are handled by studio[^\n]*/gi,
252
- "",
253
- );
254
- if (/\bdypai_generate_types\b/.test(withoutNegatedTypegen)) {
255
- throw new Error("Studio instructions must not mention dypai_generate_types");
251
+ if (!/\bdypai_generate_types\b/.test(instructions)) {
252
+ throw new Error("Studio instructions must mention dypai_generate_types");
253
+ }
254
+ if (!/\bendpoints\.gen\.ts\b/.test(instructions)) {
255
+ throw new Error("Studio instructions must mention endpoints.gen.ts");
256
+ }
257
+ if (!/dypai_validate[\s\S]{0,240}dypai_generate_types|dypai_generate_types[\s\S]{0,240}dypai_validate/i.test(instructions)) {
258
+ throw new Error("Studio instructions must describe validate then generate types");
256
259
  }
257
- if (/\bendpoints\.gen\.ts\b/.test(withoutNegatedTypegen)) {
258
- throw new Error("Studio instructions must not mention endpoints.gen.ts");
260
+ if (!/read[^\n]*endpoints\.gen\.ts|read `dypai\/types\//i.test(instructions)) {
261
+ throw new Error("Studio instructions must tell the agent to read generated endpoint types");
259
262
  }
260
263
 
261
264
  }
@@ -4,7 +4,6 @@ import YAML from "yaml"
4
4
  import { deployFromSource } from "./deploy.js"
5
5
  import { proxyToolCall } from "./proxy.js"
6
6
  import { resolveOutDir } from "./sync/path-resolver.js"
7
- import { refreshLocalStateSnapshot } from "./sync/planner.js"
8
7
 
9
8
  function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
10
9
  if (workspace_root) {
@@ -173,13 +172,13 @@ export const dypaiDeployProductionTool = {
173
172
  live_changed: false,
174
173
  }
175
174
  }
176
- try {
177
- await refreshLocalStateSnapshot({ projectId: targetProjectId, rootDir })
178
- } catch (refreshError) {
179
- backend = {
180
- ...backend,
181
- state_baseline_refresh_warning: refreshError.message,
182
- }
175
+ backend = {
176
+ ...backend,
177
+ state_baseline_refresh: {
178
+ skipped: true,
179
+ reason: "publish_does_not_prove_local_source_ownership",
180
+ hint: "Run dypai_pull before editing a just-published endpoint again. Automatic project-wide refresh is disabled to preserve multi-user conflict detection.",
181
+ },
183
182
  }
184
183
  }
185
184
  }
@@ -65,8 +65,9 @@ function readProjectIdFromConfig(rootDir) {
65
65
  export const dypaiPushProjectTool = {
66
66
  name: "dypai_push",
67
67
  description:
68
- "SAVE CHANGES — push all local DYPAI project changes to Studio/DYPAI without publishing production. " +
68
+ "SAVE CHANGES — push local DYPAI project changes to Studio/DYPAI without publishing production. " +
69
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
+ "Use only_endpoints for an isolated backend push; endpoint-scoped pushes intentionally skip frontend saves and unrelated backend resources. " +
70
71
  "Production is never changed by this tool. Use dypai_deploy_production(confirm:true) after explicit user approval to publish live.",
71
72
  inputSchema: {
72
73
  type: "object",
@@ -113,6 +114,13 @@ export const dypaiPushProjectTool = {
113
114
  description: "If false, only pushes backend drafts. Default: true.",
114
115
  default: true,
115
116
  },
117
+ only_endpoints: {
118
+ type: "array",
119
+ minItems: 1,
120
+ uniqueItems: true,
121
+ items: { type: "string" },
122
+ description: "Explicit backend endpoint allowlist. When set, unrelated backend resources and the frontend save are skipped.",
123
+ },
116
124
  },
117
125
  required: [],
118
126
  },
@@ -127,6 +135,7 @@ export const dypaiPushProjectTool = {
127
135
  force = false,
128
136
  skip_validation = false,
129
137
  save_frontend = true,
138
+ only_endpoints = null,
130
139
  } = {}) {
131
140
  const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
132
141
  const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
@@ -165,6 +174,7 @@ export const dypaiPushProjectTool = {
165
174
  dry_run,
166
175
  force,
167
176
  skip_validation,
177
+ only_endpoints,
168
178
  }))
169
179
  }
170
180
 
@@ -187,6 +197,8 @@ export const dypaiPushProjectTool = {
187
197
  skipped: true,
188
198
  reason: dry_run
189
199
  ? "dry_run"
200
+ : only_endpoints
201
+ ? "endpoint_scoped_push"
190
202
  : !save_frontend
191
203
  ? "save_frontend_false"
192
204
  : hasFrontend
@@ -194,7 +206,7 @@ export const dypaiPushProjectTool = {
194
206
  : "no_package_json",
195
207
  }
196
208
 
197
- if (!dry_run && save_frontend && hasFrontend) {
209
+ if (!dry_run && !only_endpoints && save_frontend && hasFrontend) {
198
210
  frontend = await deployFromSource({
199
211
  sourceDirectory: workspaceRoot,
200
212
  project_id: targetProjectId,
@@ -214,7 +226,9 @@ export const dypaiPushProjectTool = {
214
226
  frontend_saved: hasFrontend && frontendOk && frontend.skipped !== true,
215
227
  live_changed: false,
216
228
  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."
229
+ ? only_endpoints
230
+ ? `Selected backend endpoint drafts are saved. Test them, then publish only the same allowlist with manage_drafts(operation:'publish', resource_names:${JSON.stringify(only_endpoints)}, confirm:true) after explicit approval.`
231
+ : "Changes are saved to Studio/DYPAI. Test the preview. To publish live, use dypai_deploy_production(confirm:true) after explicit user approval."
218
232
  : "Frontend save failed. Fix the frontend/source issue and run dypai_push again.",
219
233
  }
220
234
  },
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Normalize endpoint names only for migration diagnostics.
3
+ *
4
+ * Underscores and hyphens remain distinct endpoint identities at runtime.
5
+ * This normalized value must never be used for reads or mutations; it only
6
+ * surfaces legacy pairs such as `create_task` + `create-task`.
7
+ */
8
+ export function migrationCanonicalEndpointName(name) {
9
+ return typeof name === "string" ? name.replace(/_/g, "-") : ""
10
+ }
11
+
12
+ export function findEndpointNameCollisions(endpointsOrNames) {
13
+ const buckets = new Map()
14
+ for (const item of endpointsOrNames || []) {
15
+ const name = typeof item === "string" ? item : item?.name
16
+ if (!name) continue
17
+ const canonicalName = migrationCanonicalEndpointName(name)
18
+ if (!buckets.has(canonicalName)) buckets.set(canonicalName, new Set())
19
+ buckets.get(canonicalName).add(name)
20
+ }
21
+
22
+ return [...buckets.entries()]
23
+ .map(([canonical_name, names]) => ({
24
+ canonical_name,
25
+ endpoints: [...names].sort(),
26
+ }))
27
+ .filter((item) => item.endpoints.length > 1)
28
+ .sort((a, b) => a.canonical_name.localeCompare(b.canonical_name))
29
+ }
@@ -2,13 +2,94 @@
2
2
  * Regenerate dypai/types/endpoints.gen.ts from effective Flow/YAML contracts.
3
3
  */
4
4
 
5
+ import { createHash } from "node:crypto"
6
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"
5
7
  import { mkdir, writeFile } from "fs/promises"
6
- import { dirname, join, resolve as resolvePath } from "path"
8
+ import { dirname, isAbsolute, join, resolve as resolvePath, sep } from "path"
7
9
  import { runEffectiveWorkflows } from "../../lib/effective-workflows-runner.js"
10
+ import { resolveAndGuard } from "./path-resolver.js"
11
+ import { isStudioProfile, resolveMcpProfile } from "../../toolProfiles.js"
8
12
 
9
- export async function runGenerateEndpointTypes(rootDir, projectId = null) {
13
+ function assertUnderRoot(root, target) {
14
+ const resolvedRoot = resolvePath(root)
15
+ const resolvedTarget = resolvePath(target)
16
+ if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(resolvedRoot + sep)) {
17
+ throw new Error("Path escapes workspace root")
18
+ }
19
+ }
20
+
21
+ function assertNoSymlinkPath(root, target) {
22
+ const resolvedRoot = resolvePath(root)
23
+ const resolvedTarget = resolvePath(target)
24
+ assertUnderRoot(resolvedRoot, resolvedTarget)
25
+ if (!existsSync(resolvedRoot)) {
26
+ throw new Error("Workspace root does not exist")
27
+ }
28
+
29
+ const realRoot = realpathSync(resolvedRoot)
30
+ let cursor = resolvedTarget
31
+ while (true) {
32
+ if (existsSync(cursor)) {
33
+ if (lstatSync(cursor).isSymbolicLink()) {
34
+ throw new Error(`Symlink paths are not allowed for generated artifacts: ${cursor}`)
35
+ }
36
+ assertUnderRoot(realRoot, realpathSync(cursor))
37
+ }
38
+ if (cursor === resolvedRoot) break
39
+ const parent = dirname(cursor)
40
+ if (
41
+ parent === cursor
42
+ || (parent !== resolvedRoot && !parent.startsWith(resolvedRoot + sep))
43
+ ) {
44
+ throw new Error("Path escapes workspace root")
45
+ }
46
+ cursor = parent
47
+ }
48
+ }
49
+
50
+ function hashSnapshotFiles(files) {
51
+ const parts = (files || [])
52
+ .map((file) => `${file.path}\0${file.content}`)
53
+ .sort((a, b) => a.localeCompare(b))
54
+ return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16)
55
+ }
56
+
57
+ async function writeGeneratedArtifacts(resolvedRoot, payload) {
58
+ const outDir = join(resolvedRoot, "types")
59
+ assertNoSymlinkPath(resolvedRoot, outDir)
60
+ await mkdir(outDir, { recursive: true })
61
+ const typesPath = join(outDir, "endpoints.gen.ts")
62
+ const contractPath = join(outDir, "endpoints.contract.json")
63
+ assertNoSymlinkPath(resolvedRoot, typesPath)
64
+ assertNoSymlinkPath(resolvedRoot, contractPath)
65
+
66
+ const writes = []
67
+ if (payload.typesContent) {
68
+ writes.push({ path: typesPath, content: payload.typesContent })
69
+ }
70
+ if (payload.contractJsonContent) {
71
+ writes.push({ path: contractPath, content: payload.contractJsonContent })
72
+ }
73
+
74
+ const writtenPaths = []
75
+ const unchangedPaths = []
76
+
77
+ for (const item of writes) {
78
+ const current = existsSync(item.path) ? readFileSync(item.path, "utf8") : null
79
+ if (current === item.content) {
80
+ unchangedPaths.push(item.path)
81
+ continue
82
+ }
83
+ await writeFile(item.path, item.content, "utf8")
84
+ writtenPaths.push(item.path)
85
+ }
86
+
87
+ return { typesPath, contractPath, writtenPaths, unchangedPaths }
88
+ }
89
+
90
+ export async function runGenerateEndpointTypes(rootDir, projectId = null, options = {}) {
10
91
  const resolvedRoot = resolvePath(rootDir)
11
- const result = await runEffectiveWorkflows(resolvedRoot, projectId, ["generate-types"])
92
+ const result = await runEffectiveWorkflows(resolvedRoot, projectId, ["generate-types"], options.deps)
12
93
 
13
94
  if (result.unavailable) {
14
95
  return {
@@ -35,36 +116,150 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
35
116
  }
36
117
  }
37
118
 
38
- if (payload.typesContent && payload.contractJsonContent) {
39
- const outDir = join(resolvedRoot, "types")
40
- await mkdir(outDir, { recursive: true })
41
- const typesPath = join(outDir, "endpoints.gen.ts")
42
- const contractPath = join(outDir, "endpoints.contract.json")
43
- await writeFile(typesPath, payload.typesContent, "utf8")
44
- await writeFile(contractPath, payload.contractJsonContent, "utf8")
119
+ const errorCount = payload.errorCount ?? 0
120
+ const warningCount = payload.warningCount ?? 0
121
+ const diagnosticCount = payload.diagnosticCount ?? 0
122
+ const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : []
123
+ const endpoints = payload.endpoints || []
124
+ const endpointCount = payload.endpointCount ?? 0
125
+ const snapshotHash = Array.isArray(payload.files)
126
+ ? hashSnapshotFiles(payload.files)
127
+ : options.snapshotHash ?? null
128
+
129
+ if (
130
+ typeof payload.typesContent !== "string"
131
+ || !payload.typesContent.trim()
132
+ || typeof payload.contractJsonContent !== "string"
133
+ || !payload.contractJsonContent.trim()
134
+ ) {
45
135
  return {
46
- ok: true,
47
- endpointCount: payload.endpointCount ?? 0,
48
- typesPath,
49
- contractPath,
50
- endpoints: payload.endpoints || [],
51
- diagnosticCount: payload.diagnosticCount ?? 0,
52
- errorCount: payload.errorCount ?? 0,
53
- warningCount: payload.warningCount ?? 0,
54
- diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
136
+ ok: false,
137
+ error: "generate-types returned an incomplete artifact payload",
55
138
  }
56
139
  }
57
140
 
141
+ try {
142
+ const contract = JSON.parse(payload.contractJsonContent)
143
+ if (!contract || typeof contract !== "object" || Array.isArray(contract)) {
144
+ throw new Error("contract JSON must be an object")
145
+ }
146
+ } catch (error) {
147
+ return {
148
+ ok: false,
149
+ error: `generate-types returned invalid contract JSON: ${error?.message || String(error)}`,
150
+ }
151
+ }
152
+
153
+ const { typesPath, contractPath, writtenPaths, unchangedPaths } = await writeGeneratedArtifacts(
154
+ resolvedRoot,
155
+ payload,
156
+ )
58
157
  return {
59
158
  ok: true,
60
- endpointCount: payload.endpointCount ?? 0,
61
- typesPath: payload.typesPath,
62
- contractPath: payload.contractPath,
63
- endpoints: payload.endpoints || [],
64
- diagnosticCount: payload.diagnosticCount ?? 0,
65
- errorCount: payload.errorCount ?? 0,
66
- warningCount: payload.warningCount ?? 0,
67
- diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
159
+ endpointCount,
160
+ typesPath,
161
+ contractPath,
162
+ endpoints,
163
+ diagnosticCount,
164
+ errorCount,
165
+ warningCount,
166
+ diagnostics,
167
+ contractReady: errorCount === 0,
168
+ writtenPaths,
169
+ unchangedPaths,
170
+ snapshotHash,
171
+ cached: result.cached === true,
172
+ }
173
+ }
174
+
175
+ function resolveGenerateTypesRoot(root_dir = "./dypai") {
176
+ const profile = resolveMcpProfile(process.env)
177
+ if (isStudioProfile(profile)) {
178
+ const workspaceRoot = process.env.DYPAI_WORKSPACE_ROOT
179
+ if (!workspaceRoot) {
180
+ return {
181
+ ok: false,
182
+ error: {
183
+ success: false,
184
+ generated: false,
185
+ error: "Studio worker requires DYPAI_WORKSPACE_ROOT for endpoint type generation.",
186
+ },
187
+ }
188
+ }
189
+
190
+ const resolvedWorkspace = resolvePath(workspaceRoot)
191
+ const resolvedDypai = resolvePath(resolvedWorkspace, "dypai")
192
+ const suppliedPath = isAbsolute(root_dir)
193
+ ? resolvePath(root_dir)
194
+ : resolvePath(resolvedWorkspace, root_dir)
195
+ if (suppliedPath !== resolvedDypai) {
196
+ return {
197
+ ok: false,
198
+ error: {
199
+ success: false,
200
+ generated: false,
201
+ error: "Studio endpoint type generation is restricted to the canonical dypai/ directory.",
202
+ resolved_to: suppliedPath,
203
+ required_root: resolvedDypai,
204
+ },
205
+ }
206
+ }
207
+
208
+ try {
209
+ assertNoSymlinkPath(resolvedWorkspace, resolvedDypai)
210
+ } catch (error) {
211
+ return {
212
+ ok: false,
213
+ error: {
214
+ success: false,
215
+ generated: false,
216
+ error: "Studio dypai/ must be a real directory inside the workspace (symlinks are not allowed).",
217
+ detail: error?.message || String(error),
218
+ resolved_to: resolvedDypai,
219
+ workspace_root: resolvedWorkspace,
220
+ },
221
+ }
222
+ }
223
+
224
+ return { ok: true, path: resolvedDypai, source: "env:DYPAI_WORKSPACE_ROOT" }
225
+ }
226
+
227
+ const guarded = resolveAndGuard(root_dir, { tool: "dypai_generate_types", arg_name: "root_dir" })
228
+ if (!guarded.ok) {
229
+ return guarded
230
+ }
231
+
232
+ return { ok: true, path: guarded.path, source: guarded.source }
233
+ }
234
+
235
+ function buildSuccessResponse(outcome) {
236
+ const generated = outcome.writtenPaths.length > 0
237
+ const refreshed = generated || outcome.unchangedPaths.length > 0
238
+ return {
239
+ success: true,
240
+ generated: refreshed,
241
+ files_written: outcome.writtenPaths.map((path) => path.replace(/^.*\/dypai\//, "dypai/")),
242
+ files_unchanged: outcome.unchangedPaths.map((path) => path.replace(/^.*\/dypai\//, "dypai/")),
243
+ contract_ready: outcome.contractReady,
244
+ endpoint_count: outcome.endpointCount,
245
+ types_path: "dypai/types/endpoints.gen.ts",
246
+ contract_path: "dypai/types/endpoints.contract.json",
247
+ endpoints: outcome.endpoints.slice(0, 50),
248
+ snapshot_hash: outcome.snapshotHash ?? undefined,
249
+ cached: outcome.cached === true,
250
+ diagnostics_summary: outcome.diagnosticCount
251
+ ? {
252
+ total: outcome.diagnosticCount,
253
+ errors: outcome.errorCount,
254
+ warnings: outcome.warningCount,
255
+ sample: outcome.diagnostics,
256
+ }
257
+ : undefined,
258
+ hint: outcome.contractReady
259
+ ? outcome.endpointCount
260
+ ? `Types refreshed for ${outcome.endpointCount} endpoint(s). Read dypai/types/endpoints.gen.ts before wiring frontend.`
261
+ : "No effective endpoints found — types file is empty."
262
+ : "Types were written but backend validation reported errors. Fix backend issues, run dypai_validate, then call dypai_generate_types again before frontend work.",
68
263
  }
69
264
  }
70
265
 
@@ -73,7 +268,8 @@ export const dypaiGenerateTypesTool = {
73
268
  description:
74
269
  "Regenerate frontend endpoint types from the local dypai/ backend (Flow IR + legacy YAML). " +
75
270
  "Writes dypai/types/endpoints.gen.ts and dypai/types/endpoints.contract.json. " +
76
- "Call after editing dypai/flows/*.flow.ts or endpoint YAML, before frontend work. " +
271
+ "Call after editing dypai/flows/*.flow.ts or endpoint YAML and after dypai_validate, before frontend work. " +
272
+ "In Studio worker mode, call this after backend contract edits and before wiring src/. " +
77
273
  "Also runs automatically during dypai_push after validation.",
78
274
  inputSchema: {
79
275
  type: "object",
@@ -91,9 +287,14 @@ export const dypaiGenerateTypesTool = {
91
287
  },
92
288
 
93
289
  async execute({ project_id, root_dir = "./dypai" } = {}) {
290
+ const resolved = resolveGenerateTypesRoot(root_dir)
291
+ if (!resolved.ok) {
292
+ return resolved.error
293
+ }
294
+
94
295
  const { readLocalConfig } = await import("./planner.js")
95
296
  const { getEnvBoundProjectId } = await import("../project-context.js")
96
- const rootDir = resolvePath(process.cwd(), root_dir)
297
+ const rootDir = resolved.path
97
298
  const config = await readLocalConfig(rootDir)
98
299
  const projectId = project_id || getEnvBoundProjectId() || config?.project_id || null
99
300
  const outcome = await runGenerateEndpointTypes(rootDir, projectId)
@@ -101,6 +302,7 @@ export const dypaiGenerateTypesTool = {
101
302
  return {
102
303
  success: false,
103
304
  generated: false,
305
+ contract_ready: false,
104
306
  reason: outcome.reason,
105
307
  hint: outcome.warning,
106
308
  }
@@ -109,32 +311,18 @@ export const dypaiGenerateTypesTool = {
109
311
  return {
110
312
  success: false,
111
313
  generated: false,
314
+ contract_ready: false,
112
315
  error: outcome.error,
113
316
  stderr: outcome.stderr,
114
317
  }
115
318
  }
116
- return {
117
- success: true,
118
- generated: true,
119
- endpoint_count: outcome.endpointCount,
120
- types_path: "dypai/types/endpoints.gen.ts",
121
- contract_path: "dypai/types/endpoints.contract.json",
122
- endpoints: outcome.endpoints.slice(0, 50),
123
- diagnostics_summary: outcome.diagnosticCount
124
- ? {
125
- total: outcome.diagnosticCount,
126
- errors: outcome.errorCount,
127
- warnings: outcome.warningCount,
128
- sample: outcome.diagnostics,
129
- }
130
- : undefined,
131
- hint: outcome.endpointCount
132
- ? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts.${outcome.errorCount ? " Backend errors were found; run dypai_validate before trusting frontend call sites." : ""}`
133
- : "No effective endpoints found — types file is empty.",
134
- }
319
+ return buildSuccessResponse(outcome)
135
320
  },
136
321
  }
137
322
 
138
323
  export const __testing = {
139
324
  runGenerateEndpointTypes,
325
+ resolveGenerateTypesRoot,
326
+ buildSuccessResponse,
327
+ writeGeneratedArtifacts,
140
328
  }
@@ -16,6 +16,7 @@ import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
16
16
  import {
17
17
  runEffectiveWorkflows,
18
18
  } from "../../lib/effective-workflows-runner.js"
19
+ import { findEndpointNameCollisions } from "./endpoint-names.js"
19
20
 
20
21
  const ENDPOINT_NAME_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
21
22
 
@@ -71,27 +72,51 @@ export async function readLocalStateSnapshot(rootDir) {
71
72
  }
72
73
  }
73
74
 
75
+ export function mergeLocalStateSnapshot({ currentState, rows, endpointResults, projectId, now = new Date() }) {
76
+ const compatibleState = currentState?.project_id === projectId ? currentState : null
77
+ const endpoints = { ...(compatibleState?.endpoints || {}) }
78
+ const unresolvedEndpoints = { ...(compatibleState?.unresolved_endpoints || {}) }
79
+ const rowsByName = Object.fromEntries((rows || []).map((row) => [row.name, row]))
80
+
81
+ for (const result of endpointResults || []) {
82
+ if (result?.resource_type && result.resource_type !== "endpoint") continue
83
+ const name = result?.name
84
+ if (!name) continue
85
+ if (result.action === "delete") {
86
+ delete endpoints[name]
87
+ delete unresolvedEndpoints[name]
88
+ continue
89
+ }
90
+ const row = rowsByName[name]
91
+ if (!row) continue
92
+ endpoints[name] = { id: row.id, updated_at: row.updated_at, revision: row.revision }
93
+ delete unresolvedEndpoints[name]
94
+ }
95
+
96
+ return {
97
+ pulled_at: compatibleState?.pulled_at || null,
98
+ refreshed_at: now.toISOString(),
99
+ project_id: projectId,
100
+ endpoints,
101
+ unresolved_endpoints: unresolvedEndpoints,
102
+ }
103
+ }
104
+
74
105
  /**
75
- * Refresh .dypai/state.json from live remote endpoint timestamps.
76
- * Call after publish/deploy so the next push does not see phantom conflicts.
106
+ * Merge only the endpoints that a publish response proves were promoted.
107
+ * A project-wide timestamp refresh is unsafe after a scoped push: unrelated
108
+ * local files may still be stale and must keep their previous/unresolved
109
+ * baseline so the next push can block correctly.
77
110
  */
78
- export async function refreshLocalStateSnapshot({ projectId, rootDir }) {
79
- if (!projectId || !rootDir) return null
111
+ export async function refreshLocalStateSnapshot({ projectId, rootDir, endpointResults = [] }) {
112
+ if (!projectId || !rootDir || !endpointResults.length) return null
80
113
  const rows = await execSql(projectId, `
81
- SELECT id, name, updated_at
114
+ SELECT id, name, updated_at, revision
82
115
  FROM system.endpoints
83
116
  ORDER BY name
84
117
  `)
85
- const successfullyPulledIds = new Set(rows.map((row) => row.id))
86
- const state = {
87
- pulled_at: new Date().toISOString(),
88
- project_id: projectId,
89
- endpoints: Object.fromEntries(
90
- rows
91
- .filter((row) => successfullyPulledIds.has(row.id))
92
- .map((row) => [row.name, { id: row.id, updated_at: row.updated_at }]),
93
- ),
94
- }
118
+ const currentState = await readLocalStateSnapshot(rootDir)
119
+ const state = mergeLocalStateSnapshot({ currentState, rows, endpointResults, projectId })
95
120
  const statePath = join(rootDir, ".dypai", "state.json")
96
121
  await mkdir(join(rootDir, ".dypai"), { recursive: true })
97
122
  await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8")
@@ -166,7 +191,7 @@ export async function fetchRemoteState(projectId) {
166
191
  const endpoints = await execSql(projectId, `
167
192
  SELECT id, name, method, description, workflow_code, input, output,
168
193
  allowed_roles, is_tool, tool_description, group_id, is_active,
169
- response_cardinality, updated_at
194
+ response_cardinality, updated_at, revision
170
195
  FROM system.endpoints
171
196
  ORDER BY name
172
197
  `)
@@ -507,6 +532,7 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
507
532
  byName[payload.name] = {
508
533
  source: "flow",
509
534
  file,
535
+ group: payload.group_name || null,
510
536
  pushPayload: payload,
511
537
  ...(shadowedMeta ? { shadowed: shadowedMeta } : {}),
512
538
  }
@@ -703,19 +729,36 @@ function findMissingCredentials(doc, credNameToId) {
703
729
  * was pulled at time T and its remote updated_at is now > T, someone modified
704
730
  * it out-of-band since the pull. Report these so the user can pull first.
705
731
  */
706
- function detectConflicts(stateSnapshot, remoteByName) {
732
+ export function detectConflicts(stateSnapshot, remoteByName) {
707
733
  if (!stateSnapshot?.endpoints) return []
708
734
  const conflicts = []
735
+ for (const [name, unresolved] of Object.entries(stateSnapshot.unresolved_endpoints || {})) {
736
+ if (!remoteByName[name]) continue
737
+ conflicts.push({
738
+ endpoint: name,
739
+ snapshot_at: null,
740
+ remote_updated_at: remoteByName[name]?.updated_at || unresolved.remote_updated_at,
741
+ remote_revision: remoteByName[name]?.revision || unresolved.remote_revision,
742
+ reason: unresolved.reason || "local_source_not_reconciled",
743
+ local_file: unresolved.file,
744
+ })
745
+ }
709
746
  for (const [name, snap] of Object.entries(stateSnapshot.endpoints)) {
747
+ if (stateSnapshot.unresolved_endpoints?.[name]) continue
710
748
  const remote = remoteByName[name]
711
749
  if (!remote) continue
712
750
  const snapT = snap.updated_at ? new Date(snap.updated_at).getTime() : 0
713
751
  const remoteT = remote.updated_at ? new Date(remote.updated_at).getTime() : 0
714
- if (remoteT > snapT + 1000) { // 1s tolerance for clock skew
752
+ const snapRevision = Number(snap.revision || 0)
753
+ const remoteRevision = Number(remote.revision || 0)
754
+ const revisionChanged = snapRevision > 0 && remoteRevision > 0 && remoteRevision !== snapRevision
755
+ if (revisionChanged || remoteT > snapT + 1000) { // timestamp fallback for old snapshots
715
756
  conflicts.push({
716
757
  endpoint: name,
717
758
  snapshot_at: snap.updated_at,
718
759
  remote_updated_at: remote.updated_at,
760
+ snapshot_revision: snapRevision || undefined,
761
+ remote_revision: remoteRevision || undefined,
719
762
  })
720
763
  }
721
764
  }
@@ -729,6 +772,17 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
729
772
  const plan = { create: [], update: [], delete: [], unchanged: [] }
730
773
  const warnings = []
731
774
 
775
+ const endpointNameCollisions = findEndpointNameCollisions(Object.keys(remote?.byName || {}))
776
+ if (endpointNameCollisions.length) {
777
+ warnings.push({
778
+ type: "canonical_endpoint_name_collisions",
779
+ count: endpointNameCollisions.length,
780
+ examples: endpointNameCollisions.slice(0, 25),
781
+ truncated: endpointNameCollisions.length > 25,
782
+ hint: "Legacy underscore and canonical hyphen slugs coexist remotely. They are separate APIs; audit callers before deleting either form.",
783
+ })
784
+ }
785
+
732
786
  // ── Groups plan ──────────────────────────────────────────────────────
733
787
  // Groups are 100% folder-driven: each first-level subfolder in endpoints/
734
788
  // IS a group. The remote is made to match the local folder structure
@@ -737,7 +791,7 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
737
791
  // group is re-created automatically. Zero friction.
738
792
  const localGroups = new Set()
739
793
  for (const entry of Object.values(local.byName)) {
740
- const g = entry?.doc?.group
794
+ const g = entry?.group || entry?.doc?.group || entry?.pushPayload?.group_name
741
795
  if (g) localGroups.add(g)
742
796
  }
743
797
  const remoteGroupNames = new Set(Object.keys(mapsCtx.groupNameToId || {}))