@dypai-ai/mcp 1.7.2 → 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 +204 -41
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
|
|
|
@@ -484,6 +608,7 @@ export const dypaiPushTool = {
|
|
|
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
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. " +
|
|
487
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.",
|
|
488
613
|
inputSchema: {
|
|
489
614
|
type: "object",
|
|
@@ -517,11 +642,29 @@ export const dypaiPushTool = {
|
|
|
517
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.",
|
|
518
643
|
default: false,
|
|
519
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
|
+
},
|
|
520
652
|
},
|
|
521
653
|
},
|
|
522
654
|
|
|
523
|
-
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 } = {}) {
|
|
524
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
|
+
}
|
|
525
668
|
|
|
526
669
|
// Resolve the target project via dypai.config.yaml if the caller didn't
|
|
527
670
|
// pass an explicit project_id. If both are provided and disagree, refuse —
|
|
@@ -541,8 +684,7 @@ export const dypaiPushTool = {
|
|
|
541
684
|
try {
|
|
542
685
|
const validation = await runValidation(rootDir, project_id || configProjectId)
|
|
543
686
|
if (!validation.success) {
|
|
544
|
-
const errorDiags = (validation.diagnostics
|
|
545
|
-
const projectLevel = errorDiags.filter(d => !d.endpoint)
|
|
687
|
+
const { errorDiags, projectLevel, erroringEndpoints: partitioned } = partitionValidationErrors(validation.diagnostics)
|
|
546
688
|
if (projectLevel.length) {
|
|
547
689
|
return {
|
|
548
690
|
success: false,
|
|
@@ -554,7 +696,7 @@ export const dypaiPushTool = {
|
|
|
554
696
|
}
|
|
555
697
|
}
|
|
556
698
|
skippedDiagnostics = errorDiags
|
|
557
|
-
erroringEndpoints =
|
|
699
|
+
erroringEndpoints = partitioned
|
|
558
700
|
}
|
|
559
701
|
} catch (e) {
|
|
560
702
|
// Don't block the push on a validator crash — just warn
|
|
@@ -653,29 +795,29 @@ export const dypaiPushTool = {
|
|
|
653
795
|
stateSnapshot,
|
|
654
796
|
liveRemote: remote,
|
|
655
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)
|
|
656
815
|
|
|
657
816
|
// Partial push: drop endpoints that failed validation from create/update so
|
|
658
817
|
// they aren't pushed (they keep their live version). Deletes stay — a broken
|
|
659
818
|
// local flow shouldn't block removing endpoints. skip_validation leaves
|
|
660
819
|
// erroringEndpoints empty, so nothing is dropped.
|
|
661
|
-
const
|
|
662
|
-
if (erroringEndpoints.size) {
|
|
663
|
-
const drop = (arr) => (arr || []).filter((item) => {
|
|
664
|
-
if (erroringEndpoints.has(item.name)) { skippedNames.push(item.name); return false }
|
|
665
|
-
return true
|
|
666
|
-
})
|
|
667
|
-
plan.create = drop(plan.create)
|
|
668
|
-
plan.update = drop(plan.update)
|
|
669
|
-
}
|
|
670
|
-
const skipped_endpoints = skippedNames.length
|
|
671
|
-
? [...new Set(skippedNames)].map((name) => ({
|
|
672
|
-
endpoint: name,
|
|
673
|
-
errors: skippedDiagnostics
|
|
674
|
-
.filter((d) => d.endpoint === name)
|
|
675
|
-
.slice(0, 5)
|
|
676
|
-
.map((d) => ({ rule: d.rule, message: d.message, file: d.file, loc: d.loc })),
|
|
677
|
-
}))
|
|
678
|
-
: undefined
|
|
820
|
+
const skipped_endpoints = dropErroringFromPlan(plan, erroringEndpoints, skippedDiagnostics)
|
|
679
821
|
|
|
680
822
|
const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
|
|
681
823
|
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
@@ -697,11 +839,13 @@ export const dypaiPushTool = {
|
|
|
697
839
|
if (dry_run || totalChanges === 0) {
|
|
698
840
|
const sourceCheckpoint = dry_run
|
|
699
841
|
? null
|
|
700
|
-
: await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
|
|
842
|
+
: await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
|
|
701
843
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
702
844
|
const automationSync = dry_run
|
|
703
845
|
? { skipped: true, reason: "dry_run" }
|
|
704
|
-
:
|
|
846
|
+
: selectedNames
|
|
847
|
+
? { skipped: true, reason: "endpoint_scoped_push" }
|
|
848
|
+
: await syncAutomationMetadata(targetProjectId, local.automations)
|
|
705
849
|
const automationSyncHasFailed = automationSyncFailed(automationSync)
|
|
706
850
|
const sourceBaseline = !dry_run && !sourceCheckpointFailed
|
|
707
851
|
? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
|
|
@@ -723,6 +867,7 @@ export const dypaiPushTool = {
|
|
|
723
867
|
automation_sync: automationSync,
|
|
724
868
|
source_checkpoint: sourceCheckpoint || undefined,
|
|
725
869
|
source_baseline: sourceBaseline,
|
|
870
|
+
selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
|
|
726
871
|
hint: automationSyncHasFailed
|
|
727
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."
|
|
728
873
|
: sourceCheckpointFailed
|
|
@@ -787,7 +932,7 @@ export const dypaiPushTool = {
|
|
|
787
932
|
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
788
933
|
const endpointId = remote.byName[item.name]?.id
|
|
789
934
|
const res = endpointId
|
|
790
|
-
? await applyUpdate(canonical, endpointId, targetProjectId)
|
|
935
|
+
? await applyUpdate(canonical, endpointId, targetProjectId, remote.byName[item.name]?.revision)
|
|
791
936
|
: await applyCreate(canonical, targetProjectId)
|
|
792
937
|
applied.updated.push({ name: item.name, changed_fields: item.changedFields })
|
|
793
938
|
if (isDraftResponse(res)) applied.updated_drafts.push(item.name)
|
|
@@ -805,7 +950,11 @@ export const dypaiPushTool = {
|
|
|
805
950
|
try {
|
|
806
951
|
const endpointId = remote.byName[item.name]?.id
|
|
807
952
|
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
808
|
-
const res = await applyDelete(
|
|
953
|
+
const res = await applyDelete(
|
|
954
|
+
endpointId,
|
|
955
|
+
targetProjectId,
|
|
956
|
+
remote.byName[item.name]?.revision,
|
|
957
|
+
)
|
|
809
958
|
applied.deleted.push(item.name)
|
|
810
959
|
if (isDraftResponse(res)) applied.deleted_drafts.push(item.name)
|
|
811
960
|
endpoint_results.push(endpointResult("delete", item.name, "applied", {
|
|
@@ -834,10 +983,14 @@ export const dypaiPushTool = {
|
|
|
834
983
|
|
|
835
984
|
// Also reconcile realtime policies if realtime.yaml exists
|
|
836
985
|
let realtime = null
|
|
837
|
-
|
|
838
|
-
realtime =
|
|
839
|
-
}
|
|
840
|
-
|
|
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
|
+
}
|
|
841
994
|
}
|
|
842
995
|
|
|
843
996
|
// Names of endpoints that actually changed this run — drives the follow-up
|
|
@@ -868,7 +1021,7 @@ export const dypaiPushTool = {
|
|
|
868
1021
|
const isDraftMode = draftCount > 0
|
|
869
1022
|
&& draftCount === endpointTotal + realtimeTotal
|
|
870
1023
|
const sourceCheckpoint = errors.length === 0
|
|
871
|
-
? await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
|
|
1024
|
+
? await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
|
|
872
1025
|
: {
|
|
873
1026
|
ok: true,
|
|
874
1027
|
skipped: true,
|
|
@@ -876,7 +1029,9 @@ export const dypaiPushTool = {
|
|
|
876
1029
|
}
|
|
877
1030
|
const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
|
|
878
1031
|
const automationSync = errors.length === 0
|
|
879
|
-
?
|
|
1032
|
+
? selectedNames
|
|
1033
|
+
? { skipped: true, reason: "endpoint_scoped_push" }
|
|
1034
|
+
: await syncAutomationMetadata(targetProjectId, local.automations)
|
|
880
1035
|
: { skipped: true, reason: "push_had_errors" }
|
|
881
1036
|
const automationSyncHasFailed = automationSyncFailed(automationSync)
|
|
882
1037
|
const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
|
|
@@ -924,6 +1079,7 @@ export const dypaiPushTool = {
|
|
|
924
1079
|
automation_sync: automationSync,
|
|
925
1080
|
source_checkpoint: sourceCheckpoint,
|
|
926
1081
|
source_baseline: sourceBaseline,
|
|
1082
|
+
selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
|
|
927
1083
|
// Only one next_step — and only when it's non-obvious. Drafts win
|
|
928
1084
|
// over the test suggestion because tests against live won't see
|
|
929
1085
|
// the change until the drafts are promoted.
|
|
@@ -967,9 +1123,16 @@ function summaryFromPlan(plan) {
|
|
|
967
1123
|
export const __testing = {
|
|
968
1124
|
normalizeResponseCardinality,
|
|
969
1125
|
endpointPayload,
|
|
1126
|
+
endpointUpdatesForRemote,
|
|
1127
|
+
endpointDeleteArgs,
|
|
970
1128
|
splitBlockingConflicts,
|
|
971
1129
|
collectBackendSourceCheckpointFiles,
|
|
972
1130
|
deletedBackendSourcePathsFromPlan,
|
|
973
1131
|
syncAutomationMetadata,
|
|
1132
|
+
partitionValidationErrors,
|
|
1133
|
+
dropErroringFromPlan,
|
|
1134
|
+
normalizeEndpointSelection,
|
|
1135
|
+
scopePlanToEndpoints,
|
|
1136
|
+
backendSourcePathsForEndpointSelection,
|
|
974
1137
|
PUSH_CONCURRENCY,
|
|
975
1138
|
}
|