@dypai-ai/mcp 1.7.2 → 1.7.4

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.
@@ -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,21 +532,13 @@ 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
  }
513
539
  if (shadowedMeta) shadowed.push({ name: payload.name, shadowed: shadowedMeta })
514
540
  }
515
541
 
516
- for (const diagnostic of diagnostics) {
517
- if (diagnostic?.severity !== "error") continue
518
- yamlState.errors.push({
519
- file: diagnostic.file || diagnostic.endpoint,
520
- error: diagnostic.message || "Failed to build flow push payload",
521
- rule: diagnostic.rule || "flow_push_payload_failed",
522
- })
523
- }
524
-
525
542
  for (const item of rejected) {
526
543
  if (!isBackendSourcePath(item?.path)) continue
527
544
  yamlState.errors.push({
@@ -536,7 +553,14 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
536
553
  byName[name] = { ...entry, source: "legacy-yaml" }
537
554
  }
538
555
 
539
- return { byName, errors: yamlState.errors, shadowed, runnerWarning: null, automations: bulkResult.data?.automations ?? null }
556
+ return {
557
+ byName,
558
+ errors: yamlState.errors,
559
+ shadowed,
560
+ runnerWarning: null,
561
+ compilerDiagnostics: diagnostics,
562
+ automations: bulkResult.data?.automations ?? null,
563
+ }
540
564
  }
541
565
 
542
566
  // ─── Normalization + diff ──────────────────────────────────────────────────
@@ -703,19 +727,36 @@ function findMissingCredentials(doc, credNameToId) {
703
727
  * was pulled at time T and its remote updated_at is now > T, someone modified
704
728
  * it out-of-band since the pull. Report these so the user can pull first.
705
729
  */
706
- function detectConflicts(stateSnapshot, remoteByName) {
730
+ export function detectConflicts(stateSnapshot, remoteByName) {
707
731
  if (!stateSnapshot?.endpoints) return []
708
732
  const conflicts = []
733
+ for (const [name, unresolved] of Object.entries(stateSnapshot.unresolved_endpoints || {})) {
734
+ if (!remoteByName[name]) continue
735
+ conflicts.push({
736
+ endpoint: name,
737
+ snapshot_at: null,
738
+ remote_updated_at: remoteByName[name]?.updated_at || unresolved.remote_updated_at,
739
+ remote_revision: remoteByName[name]?.revision || unresolved.remote_revision,
740
+ reason: unresolved.reason || "local_source_not_reconciled",
741
+ local_file: unresolved.file,
742
+ })
743
+ }
709
744
  for (const [name, snap] of Object.entries(stateSnapshot.endpoints)) {
745
+ if (stateSnapshot.unresolved_endpoints?.[name]) continue
710
746
  const remote = remoteByName[name]
711
747
  if (!remote) continue
712
748
  const snapT = snap.updated_at ? new Date(snap.updated_at).getTime() : 0
713
749
  const remoteT = remote.updated_at ? new Date(remote.updated_at).getTime() : 0
714
- if (remoteT > snapT + 1000) { // 1s tolerance for clock skew
750
+ const snapRevision = Number(snap.revision || 0)
751
+ const remoteRevision = Number(remote.revision || 0)
752
+ const revisionChanged = snapRevision > 0 && remoteRevision > 0 && remoteRevision !== snapRevision
753
+ if (revisionChanged || remoteT > snapT + 1000) { // timestamp fallback for old snapshots
715
754
  conflicts.push({
716
755
  endpoint: name,
717
756
  snapshot_at: snap.updated_at,
718
757
  remote_updated_at: remote.updated_at,
758
+ snapshot_revision: snapRevision || undefined,
759
+ remote_revision: remoteRevision || undefined,
719
760
  })
720
761
  }
721
762
  }
@@ -729,6 +770,17 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
729
770
  const plan = { create: [], update: [], delete: [], unchanged: [] }
730
771
  const warnings = []
731
772
 
773
+ const endpointNameCollisions = findEndpointNameCollisions(Object.keys(remote?.byName || {}))
774
+ if (endpointNameCollisions.length) {
775
+ warnings.push({
776
+ type: "canonical_endpoint_name_collisions",
777
+ count: endpointNameCollisions.length,
778
+ examples: endpointNameCollisions.slice(0, 25),
779
+ truncated: endpointNameCollisions.length > 25,
780
+ hint: "Legacy underscore and canonical hyphen slugs coexist remotely. They are separate APIs; audit callers before deleting either form.",
781
+ })
782
+ }
783
+
732
784
  // ── Groups plan ──────────────────────────────────────────────────────
733
785
  // Groups are 100% folder-driven: each first-level subfolder in endpoints/
734
786
  // IS a group. The remote is made to match the local folder structure
@@ -737,7 +789,7 @@ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, sta
737
789
  // group is re-created automatically. Zero friction.
738
790
  const localGroups = new Set()
739
791
  for (const entry of Object.values(local.byName)) {
740
- const g = entry?.doc?.group
792
+ const g = entry?.group || entry?.doc?.group || entry?.pushPayload?.group_name
741
793
  if (g) localGroups.add(g)
742
794
  }
743
795
  const remoteGroupNames = new Set(Object.keys(mapsCtx.groupNameToId || {}))
@@ -21,6 +21,7 @@ import { proxyToolCall } from "../proxy.js"
21
21
  import { api } from "../../api.js"
22
22
  import { serializeEndpoint } from "./codec.js"
23
23
  import { dumpPublicSchema } from "./schema-dump.js"
24
+ import { findEndpointNameCollisions } from "./endpoint-names.js"
24
25
  // Codegen was removed from v1 — the agent reads dypai/schema.sql + endpoint YAMLs
25
26
  // directly instead of relying on auto-generated TS types. The codegen.js file
26
27
  // still lives on disk in case we resurface it later with a leaner design.
@@ -660,6 +661,10 @@ export function classifyFlowSourceWrite({ existingContent, remoteContent, clean
660
661
  return { action: "write" }
661
662
  }
662
663
 
664
+ export function shouldSnapshotFlowSourceWrite(writeDecision) {
665
+ return writeDecision?.action === "write"
666
+ }
667
+
663
668
  function parseMaybeJson(v) {
664
669
  if (v == null || typeof v !== "string") return v
665
670
  try { return JSON.parse(v) } catch { return v }
@@ -698,14 +703,23 @@ function renderYaml(doc) {
698
703
  *
699
704
  * Exported for unit testing.
700
705
  */
701
- export function buildStateSnapshot({ endpoints, successfullyPulledIds, projectId, now = new Date() }) {
706
+ export function buildStateSnapshot({ endpoints, successfullyPulledIds, unresolvedEndpoints = [], projectId, now = new Date() }) {
702
707
  return {
703
708
  pulled_at: now.toISOString(),
704
709
  project_id: projectId,
705
710
  endpoints: Object.fromEntries(
706
711
  endpoints
707
712
  .filter(e => successfullyPulledIds.has(e.id))
708
- .map(e => [e.name, { id: e.id, updated_at: e.updated_at }])
713
+ .map(e => [e.name, { id: e.id, updated_at: e.updated_at, revision: e.revision }])
714
+ ),
715
+ unresolved_endpoints: Object.fromEntries(
716
+ unresolvedEndpoints.map((item) => [item.endpoint, {
717
+ id: item.id,
718
+ remote_updated_at: item.remote_updated_at,
719
+ remote_revision: item.remote_revision,
720
+ file: item.file,
721
+ reason: item.reason || "local_source_differs",
722
+ }]),
709
723
  ),
710
724
  }
711
725
  }
@@ -787,7 +801,7 @@ export const dypaiPullTool = {
787
801
  execSql(project_id, `
788
802
  SELECT id, name, method, description, workflow_code, workflow, workflow_source,
789
803
  input, output, response_cardinality, allowed_roles, is_tool, tool_description,
790
- group_id, is_active, updated_at
804
+ group_id, is_active, updated_at, revision
791
805
  FROM system.endpoints
792
806
  ORDER BY name
793
807
  `),
@@ -917,6 +931,7 @@ export const dypaiPullTool = {
917
931
  // conflict). Tracked by row id so endpoint renames at the same name
918
932
  // can't accidentally inherit a stale snapshot.
919
933
  const successfullyPulled = new Set()
934
+ const keptLocalEndpoints = []
920
935
 
921
936
  for (const rawRow of endpoints) {
922
937
  const row = hydrateRow(rawRow)
@@ -944,19 +959,29 @@ export const dypaiPullTool = {
944
959
  })
945
960
  if (writeDecision.action === "keep-local") {
946
961
  filesWritten.push(`(kept local ${relFlowPath})`)
962
+ keptLocalEndpoints.push({
963
+ endpoint: row.name,
964
+ id: row.id,
965
+ remote_updated_at: row.updated_at,
966
+ remote_revision: row.revision,
967
+ file: relFlowPath,
968
+ reason: "local_source_differs",
969
+ })
947
970
  errors.push({
948
971
  endpoint: row.name,
949
972
  error:
950
973
  `Local ${relFlowPath} differs from persisted DYPAI source and was NOT overwritten. ` +
951
974
  `Review/commit/stash local changes, or rerun dypai_pull with clean:true if you intentionally want the remote source.`,
952
975
  })
953
- successfullyPulled.add(row.id)
976
+ // state.json is a baseline of bytes actually pulled from remote.
977
+ // Recording the remote timestamp while retaining stale local
978
+ // bytes would silence the next push conflict.
954
979
  continue
955
980
  }
956
981
 
957
982
  await writeFileEnsured(flowPath, flowContent)
958
983
  filesWritten.push(relFlowPath)
959
- successfullyPulled.add(row.id)
984
+ if (shouldSnapshotFlowSourceWrite(writeDecision)) successfullyPulled.add(row.id)
960
985
  continue
961
986
  }
962
987
 
@@ -1063,6 +1088,7 @@ export const dypaiPullTool = {
1063
1088
  const state = buildStateSnapshot({
1064
1089
  endpoints,
1065
1090
  successfullyPulledIds: successfullyPulled,
1091
+ unresolvedEndpoints: keptLocalEndpoints,
1066
1092
  projectId: resolvedProjectId,
1067
1093
  })
1068
1094
  await writeFileEnsured(join(outDir, ".dypai", "state.json"), JSON.stringify(state, null, 2) + "\n")
@@ -1085,6 +1111,7 @@ export const dypaiPullTool = {
1085
1111
  const inactiveEndpoints = (endpoints || [])
1086
1112
  .filter(e => e.is_active === false)
1087
1113
  .map(e => e.name)
1114
+ const endpointNameCollisions = findEndpointNameCollisions(endpoints)
1088
1115
 
1089
1116
  // `environment` is an internal flag we still surface in `overview.project`
1090
1117
  // for diagnostics / dashboard parity, but the agent-facing copy uses the
@@ -1142,6 +1169,11 @@ export const dypaiPullTool = {
1142
1169
  by_group: byGroup,
1143
1170
  tool_endpoints: toolEndpoints,
1144
1171
  inactive_endpoints: inactiveEndpoints.length > 0 ? inactiveEndpoints : undefined,
1172
+ canonical_name_collisions: endpointNameCollisions.length > 0 ? {
1173
+ count: endpointNameCollisions.length,
1174
+ items: endpointNameCollisions.slice(0, 50),
1175
+ truncated: endpointNameCollisions.length > 50,
1176
+ } : undefined,
1145
1177
  },
1146
1178
  credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
1147
1179
  realtime_policies: (realtimePolicies || []).length,
@@ -1163,6 +1195,9 @@ export const dypaiPullTool = {
1163
1195
  success: errors.length === 0,
1164
1196
  endpoints: endpoints.length,
1165
1197
  files_written: filesWritten.length,
1198
+ files_kept_local: keptLocalEndpoints.length,
1199
+ kept_local_endpoints: keptLocalEndpoints.length ? keptLocalEndpoints : undefined,
1200
+ name_collisions: endpointNameCollisions.length,
1166
1201
  output_dir: outDir,
1167
1202
  out_dir_resolved_via: outDirSource,
1168
1203
  // Surface count at top level too — agents that ignore `overview` (e.g.
@@ -1171,8 +1206,16 @@ export const dypaiPullTool = {
1171
1206
  overview,
1172
1207
  errors: errors.length ? errors : undefined,
1173
1208
  warning: suspiciousWarning || undefined,
1209
+ safety_warnings: endpointNameCollisions.length ? [{
1210
+ type: "canonical_endpoint_name_collisions",
1211
+ count: endpointNameCollisions.length,
1212
+ examples: endpointNameCollisions.slice(0, 10),
1213
+ hint: "Legacy underscore and canonical hyphen slugs coexist. Treat them as separate live APIs; audit traffic and migrate/delete deliberately.",
1214
+ }] : undefined,
1174
1215
  hint: errors.length
1175
- ? "Some endpoints failed to serialize. Check errors[] — usually malformed workflow_code."
1216
+ ? keptLocalEndpoints.length
1217
+ ? `${keptLocalEndpoints.length} local Flow file(s) differed and were kept. They were excluded from .dypai/state.json, so a later push will continue to block on remote drift. Review the files or rerun with clean:true only when remote is intentionally authoritative.`
1218
+ : "Some endpoints failed to serialize. Check errors[] — usually malformed workflow_code."
1176
1219
  : suspiciousWarning
1177
1220
  ? "Files were written but the path looks wrong. See `warning` above and re-run with an absolute out_dir."
1178
1221
  : draftsTotal > 0