@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.
@@ -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
@@ -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: `dypai/${relPath}`,
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
- async function applyUpdate(canonicalRow, endpointId, projectId) {
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: endpointPayload(canonicalRow),
451
+ updates,
337
452
  })
338
453
  return assertMutationOK(res, "update", canonicalRow.name)
339
454
  }
340
455
 
341
- async function applyDelete(endpointId, projectId) {
342
- const res = await proxyToolCallWithRetry("delete_endpoint", {
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. Use only when you know the validator is wrong. Default: false.",
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). Blocks the push unless skip_validation is set.
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
- return {
538
- success: false,
539
- applied: false,
540
- reason: "validation_failed",
541
- summary: validation.summary,
542
- diagnostics: validation.diagnostics.filter(d => d.severity === "error").slice(0, 20),
543
- hint: `${validation.summary.errors} error(s) would cause runtime failures. Fix them, or retry with skip_validation: true to override.`,
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
- : await syncAutomationMetadata(targetProjectId, local.automations)
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(endpointId, targetProjectId)
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
- try {
799
- realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
800
- } catch (e) {
801
- errors.push({ op: "realtime", error: e.message })
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
- ? await syncAutomationMetadata(targetProjectId, local.automations)
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: errors.length
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
  }