@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.
- 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/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/sync/endpoint-names.js +29 -0
- package/src/tools/sync/generate-types.js +236 -48
- package/src/tools/sync/planner.js +73 -19
- package/src/tools/sync/pull.js +49 -6
- package/src/tools/sync/push.js +237 -32
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
|
package/src/tools/sync/push.js
CHANGED
|
@@ -81,14 +81,16 @@ function isCheckpointTextPath(path) {
|
|
|
81
81
|
return BACKEND_SOURCE_CHECKPOINT_EXTS.has(extOfPath(path))
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
async function collectBackendSourceCheckpointFiles(rootDir) {
|
|
84
|
+
async function collectBackendSourceCheckpointFiles(rootDir, allowedPaths = null) {
|
|
85
85
|
const files = []
|
|
86
86
|
|
|
87
87
|
async function addFile(absPath, relPath) {
|
|
88
|
+
const sourcePath = `dypai/${relPath}`
|
|
89
|
+
if (allowedPaths && !allowedPaths.has(sourcePath)) return
|
|
88
90
|
if (!isCheckpointTextPath(relPath)) return
|
|
89
91
|
const content = await readFile(absPath, "utf8")
|
|
90
92
|
files.push({
|
|
91
|
-
path:
|
|
93
|
+
path: sourcePath,
|
|
92
94
|
content: Buffer.from(content, "utf8").toString("base64"),
|
|
93
95
|
})
|
|
94
96
|
}
|
|
@@ -165,7 +167,7 @@ function deletedBackendSourcePathsFromPlan(plan, remote) {
|
|
|
165
167
|
return [...deleted].sort()
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
async function checkpointBackendSource(projectId, rootDir, plan, remote) {
|
|
170
|
+
async function checkpointBackendSource(projectId, rootDir, plan, remote, allowedPaths = null) {
|
|
169
171
|
if (!projectId) {
|
|
170
172
|
return {
|
|
171
173
|
ok: false,
|
|
@@ -173,7 +175,7 @@ async function checkpointBackendSource(projectId, rootDir, plan, remote) {
|
|
|
173
175
|
reason: "project_id_missing",
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
|
-
const files = await collectBackendSourceCheckpointFiles(rootDir)
|
|
178
|
+
const files = await collectBackendSourceCheckpointFiles(rootDir, allowedPaths)
|
|
177
179
|
const deleted_paths = deletedBackendSourcePathsFromPlan(plan, remote)
|
|
178
180
|
if (!files.length && !deleted_paths.length) {
|
|
179
181
|
return {
|
|
@@ -264,6 +266,50 @@ function endpointPayload(row) {
|
|
|
264
266
|
return p
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Splits the "error" severity diagnostics from a `dypai_validate` run into
|
|
271
|
+
* project-level errors (no `.endpoint` — can't be scoped, must block the
|
|
272
|
+
* whole push) vs. per-endpoint errors (can be isolated so the rest of the
|
|
273
|
+
* push still goes through). Returns everything the caller needs to decide
|
|
274
|
+
* whether to block or to do a partial push.
|
|
275
|
+
*/
|
|
276
|
+
function partitionValidationErrors(diagnostics) {
|
|
277
|
+
const errorDiags = (diagnostics || []).filter((d) => d.severity === "error")
|
|
278
|
+
const projectLevel = errorDiags.filter((d) => !d.endpoint)
|
|
279
|
+
const erroringEndpoints = new Set(errorDiags.map((d) => d.endpoint).filter(Boolean))
|
|
280
|
+
return { errorDiags, projectLevel, erroringEndpoints }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Partial push: drops any `plan.create`/`plan.update` item whose endpoint
|
|
285
|
+
* name is in `erroringEndpoints` (mutates `plan` in place, matching the
|
|
286
|
+
* previous inline behavior) so it keeps its live version instead of being
|
|
287
|
+
* pushed. `plan.delete` is left untouched — a broken local flow shouldn't
|
|
288
|
+
* block removing an endpoint. Returns the `skipped_endpoints` summary (with
|
|
289
|
+
* up to 5 errors per endpoint drawn from `skippedDiagnostics`), or
|
|
290
|
+
* `undefined` when nothing was dropped.
|
|
291
|
+
*/
|
|
292
|
+
function dropErroringFromPlan(plan, erroringEndpoints, skippedDiagnostics) {
|
|
293
|
+
const skippedNames = []
|
|
294
|
+
if (erroringEndpoints.size) {
|
|
295
|
+
const drop = (arr) => (arr || []).filter((item) => {
|
|
296
|
+
if (erroringEndpoints.has(item.name)) { skippedNames.push(item.name); return false }
|
|
297
|
+
return true
|
|
298
|
+
})
|
|
299
|
+
plan.create = drop(plan.create)
|
|
300
|
+
plan.update = drop(plan.update)
|
|
301
|
+
}
|
|
302
|
+
return skippedNames.length
|
|
303
|
+
? [...new Set(skippedNames)].map((name) => ({
|
|
304
|
+
endpoint: name,
|
|
305
|
+
errors: skippedDiagnostics
|
|
306
|
+
.filter((d) => d.endpoint === name)
|
|
307
|
+
.slice(0, 5)
|
|
308
|
+
.map((d) => ({ rule: d.rule, message: d.message, file: d.file, loc: d.loc })),
|
|
309
|
+
}))
|
|
310
|
+
: undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
267
313
|
function splitBlockingConflicts(plan, conflicts) {
|
|
268
314
|
const touched = new Set([
|
|
269
315
|
...(plan.update || []).map((item) => item.name),
|
|
@@ -278,6 +324,68 @@ function splitBlockingConflicts(plan, conflicts) {
|
|
|
278
324
|
return { blocking, non_blocking }
|
|
279
325
|
}
|
|
280
326
|
|
|
327
|
+
function normalizeEndpointSelection(value) {
|
|
328
|
+
if (value == null) return null
|
|
329
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
330
|
+
throw new Error("only_endpoints must be a non-empty array of endpoint slugs")
|
|
331
|
+
}
|
|
332
|
+
const names = [...new Set(value.map((item) => typeof item === "string" ? item.trim() : ""))]
|
|
333
|
+
const invalid = names.filter((name) => !/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(name))
|
|
334
|
+
if (invalid.length) {
|
|
335
|
+
throw new Error(`only_endpoints contains invalid endpoint slug(s): ${invalid.join(", ")}`)
|
|
336
|
+
}
|
|
337
|
+
return names.sort()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function scopePlanToEndpoints(plan, selectedNames, local) {
|
|
341
|
+
if (!selectedNames) return plan
|
|
342
|
+
const selected = new Set(selectedNames)
|
|
343
|
+
const keepItem = (item) => selected.has(typeof item === "string" ? item : item?.name)
|
|
344
|
+
|
|
345
|
+
plan.create = (plan.create || []).filter(keepItem)
|
|
346
|
+
plan.update = (plan.update || []).filter(keepItem)
|
|
347
|
+
plan.delete = (plan.delete || []).filter(keepItem)
|
|
348
|
+
plan.unchanged = (plan.unchanged || []).filter(keepItem)
|
|
349
|
+
plan.orphansIgnored = (plan.orphansIgnored || []).filter((name) => selected.has(name))
|
|
350
|
+
|
|
351
|
+
const selectedGroups = new Set()
|
|
352
|
+
for (const name of selected) {
|
|
353
|
+
const entry = local?.byName?.[name]
|
|
354
|
+
const group = entry?.group || entry?.doc?.group || entry?.pushPayload?.group_name
|
|
355
|
+
if (group) selectedGroups.add(group)
|
|
356
|
+
}
|
|
357
|
+
plan.groups = {
|
|
358
|
+
create: (plan.groups?.create || []).filter((name) => selectedGroups.has(name)),
|
|
359
|
+
// Endpoint-scoped pushes never delete groups: group deletion is a
|
|
360
|
+
// project-wide reconciliation and could affect unselected endpoints.
|
|
361
|
+
delete: [],
|
|
362
|
+
unchanged: (plan.groups?.unchanged || []).filter((name) => selectedGroups.has(name)),
|
|
363
|
+
}
|
|
364
|
+
plan.selection = { only_endpoints: selectedNames }
|
|
365
|
+
return plan
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function backendSourcePathsForEndpointSelection(local, selectedNames) {
|
|
369
|
+
if (!selectedNames) return null
|
|
370
|
+
const paths = new Set()
|
|
371
|
+
for (const name of selectedNames) {
|
|
372
|
+
const entry = local?.byName?.[name]
|
|
373
|
+
if (!entry) continue
|
|
374
|
+
if (entry.source === "flow") {
|
|
375
|
+
const raw = entry.pushPayload?.workflow_source?.file || entry.file
|
|
376
|
+
if (typeof raw === "string" && raw) {
|
|
377
|
+
paths.add(raw.startsWith("dypai/") ? raw : `dypai/${raw}`)
|
|
378
|
+
}
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
if (entry.file) paths.add(`dypai/endpoints/${entry.file}`)
|
|
382
|
+
for (const refPath of Object.keys(entry.fileMap || {})) {
|
|
383
|
+
paths.add(refPath.startsWith("dypai/") ? refPath : `dypai/${refPath}`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return paths
|
|
387
|
+
}
|
|
388
|
+
|
|
281
389
|
/**
|
|
282
390
|
* Treat a remote tool response as a definite success only when it has at
|
|
283
391
|
* least one of the markers we expect from a real mutation. Anything else
|
|
@@ -329,20 +437,36 @@ async function applyCreate(canonicalRow, projectId) {
|
|
|
329
437
|
return assertMutationOK(res, "create", canonicalRow.name)
|
|
330
438
|
}
|
|
331
439
|
|
|
332
|
-
|
|
440
|
+
function endpointUpdatesForRemote(canonicalRow, expectedRevision = null) {
|
|
441
|
+
const updates = endpointPayload(canonicalRow)
|
|
442
|
+
if (expectedRevision != null) updates.expected_revision = Number(expectedRevision)
|
|
443
|
+
return updates
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function applyUpdate(canonicalRow, endpointId, projectId, expectedRevision = null) {
|
|
447
|
+
const updates = endpointUpdatesForRemote(canonicalRow, expectedRevision)
|
|
333
448
|
const res = await proxyToolCallWithRetry("update_endpoint", {
|
|
334
449
|
...(projectId ? { project_id: projectId } : {}),
|
|
335
450
|
endpoint_id: endpointId,
|
|
336
|
-
updates
|
|
451
|
+
updates,
|
|
337
452
|
})
|
|
338
453
|
return assertMutationOK(res, "update", canonicalRow.name)
|
|
339
454
|
}
|
|
340
455
|
|
|
341
|
-
|
|
342
|
-
|
|
456
|
+
function endpointDeleteArgs(endpointId, projectId, expectedRevision = null) {
|
|
457
|
+
return {
|
|
343
458
|
...(projectId ? { project_id: projectId } : {}),
|
|
344
459
|
endpoint_id: endpointId,
|
|
345
|
-
|
|
460
|
+
...(expectedRevision != null ? { expected_revision: Number(expectedRevision) } : {}),
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function applyDelete(endpointId, projectId, expectedRevision = null) {
|
|
465
|
+
const res = await proxyToolCallWithRetry("delete_endpoint", endpointDeleteArgs(
|
|
466
|
+
endpointId,
|
|
467
|
+
projectId,
|
|
468
|
+
expectedRevision,
|
|
469
|
+
))
|
|
346
470
|
return assertMutationOK(res, "delete", endpointId)
|
|
347
471
|
}
|
|
348
472
|
|
|
@@ -483,7 +607,9 @@ export const dypaiPushTool = {
|
|
|
483
607
|
"After validation, regenerates `dypai/types/endpoints.gen.ts` from effective Flow/YAML contracts (check the `types` field in the response). " +
|
|
484
608
|
"ALWAYS run dypai_diff first to preview what will be staged. " +
|
|
485
609
|
"The public dypai_push wrapper also saves frontend source to Studio. " +
|
|
486
|
-
"By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to stage their deletion as a draft as well."
|
|
610
|
+
"By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to stage their deletion as a draft as well. " +
|
|
611
|
+
"For endpoint-scoped work, pass only_endpoints: ['slug-a', 'slug-b']; unrelated endpoints, groups, realtime policies, automations, and source files are left untouched. " +
|
|
612
|
+
"PARTIAL PUSH: an endpoint whose flow has errors is SKIPPED (it keeps its live version) and the rest are pushed — one broken endpoint no longer blocks the whole push. Skipped ones are listed in `skipped_endpoints` (with their errors) and `partial_success` is set. A project-level error still blocks. Use skip_validation:true to push everything, errors included.",
|
|
487
613
|
inputSchema: {
|
|
488
614
|
type: "object",
|
|
489
615
|
properties: {
|
|
@@ -513,14 +639,32 @@ export const dypaiPushTool = {
|
|
|
513
639
|
},
|
|
514
640
|
skip_validation: {
|
|
515
641
|
type: "boolean",
|
|
516
|
-
description: "Skip the dypai_validate pre-flight check.
|
|
642
|
+
description: "Skip the dypai_validate pre-flight check and push EVERY endpoint, including ones with errors (no per-endpoint skipping). By default the push already applies the valid endpoints and only skips the erroring ones — so use this only when you know the validator is wrong. Default: false.",
|
|
517
643
|
default: false,
|
|
518
644
|
},
|
|
645
|
+
only_endpoints: {
|
|
646
|
+
type: "array",
|
|
647
|
+
minItems: 1,
|
|
648
|
+
uniqueItems: true,
|
|
649
|
+
items: { type: "string" },
|
|
650
|
+
description: "Optional explicit endpoint allowlist. Only these endpoint slugs may be created, updated, or deleted; project-wide realtime/automation reconciliation is skipped.",
|
|
651
|
+
},
|
|
519
652
|
},
|
|
520
653
|
},
|
|
521
654
|
|
|
522
|
-
async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false } = {}) {
|
|
655
|
+
async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false, only_endpoints = null } = {}) {
|
|
523
656
|
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
657
|
+
let selectedNames
|
|
658
|
+
try {
|
|
659
|
+
selectedNames = normalizeEndpointSelection(only_endpoints)
|
|
660
|
+
} catch (error) {
|
|
661
|
+
return {
|
|
662
|
+
success: false,
|
|
663
|
+
applied: false,
|
|
664
|
+
reason: "invalid_endpoint_selection",
|
|
665
|
+
error: error.message,
|
|
666
|
+
}
|
|
667
|
+
}
|
|
524
668
|
|
|
525
669
|
// Resolve the target project via dypai.config.yaml if the caller didn't
|
|
526
670
|
// pass an explicit project_id. If both are provided and disagree, refuse —
|
|
@@ -529,19 +673,30 @@ export const dypaiPushTool = {
|
|
|
529
673
|
const configProjectId = config?.project_id || null
|
|
530
674
|
const targetProjectId = project_id || configProjectId
|
|
531
675
|
|
|
532
|
-
// Pre-flight validation (lint).
|
|
676
|
+
// Pre-flight validation (lint). Partial push: endpoints whose flow has an
|
|
677
|
+
// error are SKIPPED (they keep their live version) and the rest is pushed —
|
|
678
|
+
// one broken endpoint no longer tanks the whole push. A project-level error
|
|
679
|
+
// (no endpoint to isolate it to) still blocks, since it can't be scoped out.
|
|
680
|
+
// skip_validation bypasses all of this and pushes everything.
|
|
681
|
+
let erroringEndpoints = new Set()
|
|
682
|
+
let skippedDiagnostics = []
|
|
533
683
|
if (!skip_validation && !dry_run) {
|
|
534
684
|
try {
|
|
535
685
|
const validation = await runValidation(rootDir, project_id || configProjectId)
|
|
536
686
|
if (!validation.success) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
687
|
+
const { errorDiags, projectLevel, erroringEndpoints: partitioned } = partitionValidationErrors(validation.diagnostics)
|
|
688
|
+
if (projectLevel.length) {
|
|
689
|
+
return {
|
|
690
|
+
success: false,
|
|
691
|
+
applied: false,
|
|
692
|
+
reason: "validation_failed",
|
|
693
|
+
summary: validation.summary,
|
|
694
|
+
diagnostics: projectLevel.slice(0, 20),
|
|
695
|
+
hint: `${projectLevel.length} project-level error(s) can't be isolated to a single endpoint. Fix them, or retry with skip_validation: true to override.`,
|
|
696
|
+
}
|
|
544
697
|
}
|
|
698
|
+
skippedDiagnostics = errorDiags
|
|
699
|
+
erroringEndpoints = partitioned
|
|
545
700
|
}
|
|
546
701
|
} catch (e) {
|
|
547
702
|
// Don't block the push on a validator crash — just warn
|
|
@@ -640,6 +795,30 @@ export const dypaiPushTool = {
|
|
|
640
795
|
stateSnapshot,
|
|
641
796
|
liveRemote: remote,
|
|
642
797
|
})
|
|
798
|
+
if (selectedNames) {
|
|
799
|
+
const unavailable = selectedNames.filter((name) => {
|
|
800
|
+
if (local.byName[name]) return false
|
|
801
|
+
return !(delete_orphans && effectiveRemote.byName?.[name])
|
|
802
|
+
})
|
|
803
|
+
if (unavailable.length) {
|
|
804
|
+
return {
|
|
805
|
+
success: false,
|
|
806
|
+
applied: false,
|
|
807
|
+
reason: "endpoint_selection_not_found",
|
|
808
|
+
unavailable_endpoints: unavailable,
|
|
809
|
+
hint: "Each only_endpoints slug must exist locally. A remote-only slug is accepted only with delete_orphans:true for an explicit deletion.",
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
scopePlanToEndpoints(plan, selectedNames, local)
|
|
813
|
+
}
|
|
814
|
+
const selectedSourcePaths = backendSourcePathsForEndpointSelection(local, selectedNames)
|
|
815
|
+
|
|
816
|
+
// Partial push: drop endpoints that failed validation from create/update so
|
|
817
|
+
// they aren't pushed (they keep their live version). Deletes stay — a broken
|
|
818
|
+
// local flow shouldn't block removing endpoints. skip_validation leaves
|
|
819
|
+
// erroringEndpoints empty, so nothing is dropped.
|
|
820
|
+
const skipped_endpoints = dropErroringFromPlan(plan, erroringEndpoints, skippedDiagnostics)
|
|
821
|
+
|
|
643
822
|
const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
|
|
644
823
|
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
645
824
|
|
|
@@ -660,11 +839,13 @@ export const dypaiPushTool = {
|
|
|
660
839
|
if (dry_run || totalChanges === 0) {
|
|
661
840
|
const sourceCheckpoint = dry_run
|
|
662
841
|
? null
|
|
663
|
-
: await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
|
|
842
|
+
: await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
|
|
664
843
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
665
844
|
const automationSync = dry_run
|
|
666
845
|
? { skipped: true, reason: "dry_run" }
|
|
667
|
-
:
|
|
846
|
+
: selectedNames
|
|
847
|
+
? { skipped: true, reason: "endpoint_scoped_push" }
|
|
848
|
+
: await syncAutomationMetadata(targetProjectId, local.automations)
|
|
668
849
|
const automationSyncHasFailed = automationSyncFailed(automationSync)
|
|
669
850
|
const sourceBaseline = !dry_run && !sourceCheckpointFailed
|
|
670
851
|
? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
|
|
@@ -676,14 +857,17 @@ export const dypaiPushTool = {
|
|
|
676
857
|
? "source_checkpoint_failed"
|
|
677
858
|
: automationSyncHasFailed
|
|
678
859
|
? "automation_sync_failed"
|
|
860
|
+
: skipped_endpoints && totalChanges === 0 ? "all_skipped"
|
|
679
861
|
: totalChanges === 0 ? "no_changes" : "dry_run",
|
|
680
862
|
summary: summaryFromPlan(plan),
|
|
863
|
+
skipped_endpoints,
|
|
681
864
|
plan,
|
|
682
865
|
non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
|
|
683
866
|
types: typesGenerated,
|
|
684
867
|
automation_sync: automationSync,
|
|
685
868
|
source_checkpoint: sourceCheckpoint || undefined,
|
|
686
869
|
source_baseline: sourceBaseline,
|
|
870
|
+
selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
|
|
687
871
|
hint: automationSyncHasFailed
|
|
688
872
|
? "Backend runtime/source was unchanged, but DYPAI could not sync automation setup metadata. Retry after the API is updated/restarted; file requirements may be missing until this succeeds."
|
|
689
873
|
: sourceCheckpointFailed
|
|
@@ -748,7 +932,7 @@ export const dypaiPushTool = {
|
|
|
748
932
|
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
749
933
|
const endpointId = remote.byName[item.name]?.id
|
|
750
934
|
const res = endpointId
|
|
751
|
-
? await applyUpdate(canonical, endpointId, targetProjectId)
|
|
935
|
+
? await applyUpdate(canonical, endpointId, targetProjectId, remote.byName[item.name]?.revision)
|
|
752
936
|
: await applyCreate(canonical, targetProjectId)
|
|
753
937
|
applied.updated.push({ name: item.name, changed_fields: item.changedFields })
|
|
754
938
|
if (isDraftResponse(res)) applied.updated_drafts.push(item.name)
|
|
@@ -766,7 +950,11 @@ export const dypaiPushTool = {
|
|
|
766
950
|
try {
|
|
767
951
|
const endpointId = remote.byName[item.name]?.id
|
|
768
952
|
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
769
|
-
const res = await applyDelete(
|
|
953
|
+
const res = await applyDelete(
|
|
954
|
+
endpointId,
|
|
955
|
+
targetProjectId,
|
|
956
|
+
remote.byName[item.name]?.revision,
|
|
957
|
+
)
|
|
770
958
|
applied.deleted.push(item.name)
|
|
771
959
|
if (isDraftResponse(res)) applied.deleted_drafts.push(item.name)
|
|
772
960
|
endpoint_results.push(endpointResult("delete", item.name, "applied", {
|
|
@@ -795,10 +983,14 @@ export const dypaiPushTool = {
|
|
|
795
983
|
|
|
796
984
|
// Also reconcile realtime policies if realtime.yaml exists
|
|
797
985
|
let realtime = null
|
|
798
|
-
|
|
799
|
-
realtime =
|
|
800
|
-
}
|
|
801
|
-
|
|
986
|
+
if (selectedNames) {
|
|
987
|
+
realtime = { skipped: true, reason: "endpoint_scoped_push" }
|
|
988
|
+
} else {
|
|
989
|
+
try {
|
|
990
|
+
realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
|
|
991
|
+
} catch (e) {
|
|
992
|
+
errors.push({ op: "realtime", error: e.message })
|
|
993
|
+
}
|
|
802
994
|
}
|
|
803
995
|
|
|
804
996
|
// Names of endpoints that actually changed this run — drives the follow-up
|
|
@@ -829,7 +1021,7 @@ export const dypaiPushTool = {
|
|
|
829
1021
|
const isDraftMode = draftCount > 0
|
|
830
1022
|
&& draftCount === endpointTotal + realtimeTotal
|
|
831
1023
|
const sourceCheckpoint = errors.length === 0
|
|
832
|
-
? await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
|
|
1024
|
+
? await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
|
|
833
1025
|
: {
|
|
834
1026
|
ok: true,
|
|
835
1027
|
skipped: true,
|
|
@@ -837,7 +1029,9 @@ export const dypaiPushTool = {
|
|
|
837
1029
|
}
|
|
838
1030
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
839
1031
|
const automationSync = errors.length === 0
|
|
840
|
-
?
|
|
1032
|
+
? selectedNames
|
|
1033
|
+
? { skipped: true, reason: "endpoint_scoped_push" }
|
|
1034
|
+
: await syncAutomationMetadata(targetProjectId, local.automations)
|
|
841
1035
|
: { skipped: true, reason: "push_had_errors" }
|
|
842
1036
|
const automationSyncHasFailed = automationSyncFailed(automationSync)
|
|
843
1037
|
const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
|
|
@@ -847,7 +1041,7 @@ export const dypaiPushTool = {
|
|
|
847
1041
|
return {
|
|
848
1042
|
success: errors.length === 0 && !sourceCheckpointFailed && !automationSyncHasFailed,
|
|
849
1043
|
applied: endpointTotal > 0 || (realtime && !realtime.skipped),
|
|
850
|
-
partial_success: partialSuccess || sourceCheckpointFailed || automationSyncHasFailed,
|
|
1044
|
+
partial_success: partialSuccess || sourceCheckpointFailed || automationSyncHasFailed || !!skipped_endpoints,
|
|
851
1045
|
reason: sourceCheckpointFailed
|
|
852
1046
|
? "source_checkpoint_failed"
|
|
853
1047
|
: automationSyncHasFailed
|
|
@@ -878,16 +1072,20 @@ export const dypaiPushTool = {
|
|
|
878
1072
|
},
|
|
879
1073
|
details: applied,
|
|
880
1074
|
endpoint_results,
|
|
1075
|
+
skipped_endpoints,
|
|
881
1076
|
non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
|
|
882
1077
|
errors: errors.length ? errors : undefined,
|
|
883
1078
|
types: typesGenerated,
|
|
884
1079
|
automation_sync: automationSync,
|
|
885
1080
|
source_checkpoint: sourceCheckpoint,
|
|
886
1081
|
source_baseline: sourceBaseline,
|
|
1082
|
+
selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
|
|
887
1083
|
// Only one next_step — and only when it's non-obvious. Drafts win
|
|
888
1084
|
// over the test suggestion because tests against live won't see
|
|
889
1085
|
// the change until the drafts are promoted.
|
|
890
|
-
next_step:
|
|
1086
|
+
next_step: skipped_endpoints
|
|
1087
|
+
? `⚠️ ${skipped_endpoints.length} endpoint(s) SKIPPED (they keep their live version) because their flow has errors: ${skipped_endpoints.map(s => s.endpoint).slice(0, 5).join(", ")}${skipped_endpoints.length > 5 ? "…" : ""}. Everything else was pushed. Fix the skipped ones (see skipped_endpoints[].errors) and push again — or use skip_validation:true to push them anyway.`
|
|
1088
|
+
: errors.length
|
|
891
1089
|
? partialSuccess
|
|
892
1090
|
? `${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 ? "…" : ""}`
|
|
893
1091
|
: "Fix the offending YAMLs and push again."
|
|
@@ -925,9 +1123,16 @@ function summaryFromPlan(plan) {
|
|
|
925
1123
|
export const __testing = {
|
|
926
1124
|
normalizeResponseCardinality,
|
|
927
1125
|
endpointPayload,
|
|
1126
|
+
endpointUpdatesForRemote,
|
|
1127
|
+
endpointDeleteArgs,
|
|
928
1128
|
splitBlockingConflicts,
|
|
929
1129
|
collectBackendSourceCheckpointFiles,
|
|
930
1130
|
deletedBackendSourcePathsFromPlan,
|
|
931
1131
|
syncAutomationMetadata,
|
|
1132
|
+
partitionValidationErrors,
|
|
1133
|
+
dropErroringFromPlan,
|
|
1134
|
+
normalizeEndpointSelection,
|
|
1135
|
+
scopePlanToEndpoints,
|
|
1136
|
+
backendSourcePathsForEndpointSelection,
|
|
932
1137
|
PUSH_CONCURRENCY,
|
|
933
1138
|
}
|