@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.
- package/README.md +150 -0
- package/package.json +1 -1
- package/src/generated/serverInstructions.js +3 -3
- package/src/index.js +10 -15
- package/src/lib/cloudBackendCompiler.js +4 -1
- package/src/searchDocsFilter.js +19 -8
- package/src/toolProfiles.js +11 -8
- package/src/tools/project-deploy-production.js +7 -8
- package/src/tools/project-push.js +17 -3
- package/src/tools/proxy.js +16 -6
- package/src/tools/sync/endpoint-names.js +29 -0
- package/src/tools/sync/generate-types.js +236 -48
- package/src/tools/sync/planner.js +81 -29
- package/src/tools/sync/pull.js +49 -6
- package/src/tools/sync/push.js +269 -41
|
@@ -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
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 || {}))
|
package/src/tools/sync/pull.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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
|