@dypai-ai/mcp 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,85 @@ 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
+
313
+ function skippedEndpointsFromDiagnostics(diagnostics) {
314
+ const names = [...new Set(
315
+ (diagnostics || [])
316
+ .filter((item) => item?.severity === "error" && item.endpoint)
317
+ .map((item) => item.endpoint),
318
+ )]
319
+ return names.length
320
+ ? names.map((name) => ({
321
+ endpoint: name,
322
+ errors: diagnostics
323
+ .filter((item) => item.endpoint === name && item.severity === "error")
324
+ .slice(0, 5)
325
+ .map((item) => ({
326
+ rule: item.rule,
327
+ message: item.message,
328
+ file: item.file,
329
+ loc: item.loc,
330
+ })),
331
+ }))
332
+ : undefined
333
+ }
334
+
335
+ function mergeSkippedEndpointSummaries(...groups) {
336
+ const merged = new Map()
337
+ for (const group of groups) {
338
+ for (const item of group || []) {
339
+ const current = merged.get(item.endpoint)
340
+ merged.set(item.endpoint, current
341
+ ? { ...current, errors: [...(current.errors || []), ...(item.errors || [])].slice(0, 5) }
342
+ : item)
343
+ }
344
+ }
345
+ return merged.size ? [...merged.values()] : undefined
346
+ }
347
+
267
348
  function splitBlockingConflicts(plan, conflicts) {
268
349
  const touched = new Set([
269
350
  ...(plan.update || []).map((item) => item.name),
@@ -278,6 +359,68 @@ function splitBlockingConflicts(plan, conflicts) {
278
359
  return { blocking, non_blocking }
279
360
  }
280
361
 
362
+ function normalizeEndpointSelection(value) {
363
+ if (value == null) return null
364
+ if (!Array.isArray(value) || value.length === 0) {
365
+ throw new Error("only_endpoints must be a non-empty array of endpoint slugs")
366
+ }
367
+ const names = [...new Set(value.map((item) => typeof item === "string" ? item.trim() : ""))]
368
+ const invalid = names.filter((name) => !/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(name))
369
+ if (invalid.length) {
370
+ throw new Error(`only_endpoints contains invalid endpoint slug(s): ${invalid.join(", ")}`)
371
+ }
372
+ return names.sort()
373
+ }
374
+
375
+ function scopePlanToEndpoints(plan, selectedNames, local) {
376
+ if (!selectedNames) return plan
377
+ const selected = new Set(selectedNames)
378
+ const keepItem = (item) => selected.has(typeof item === "string" ? item : item?.name)
379
+
380
+ plan.create = (plan.create || []).filter(keepItem)
381
+ plan.update = (plan.update || []).filter(keepItem)
382
+ plan.delete = (plan.delete || []).filter(keepItem)
383
+ plan.unchanged = (plan.unchanged || []).filter(keepItem)
384
+ plan.orphansIgnored = (plan.orphansIgnored || []).filter((name) => selected.has(name))
385
+
386
+ const selectedGroups = new Set()
387
+ for (const name of selected) {
388
+ const entry = local?.byName?.[name]
389
+ const group = entry?.group || entry?.doc?.group || entry?.pushPayload?.group_name
390
+ if (group) selectedGroups.add(group)
391
+ }
392
+ plan.groups = {
393
+ create: (plan.groups?.create || []).filter((name) => selectedGroups.has(name)),
394
+ // Endpoint-scoped pushes never delete groups: group deletion is a
395
+ // project-wide reconciliation and could affect unselected endpoints.
396
+ delete: [],
397
+ unchanged: (plan.groups?.unchanged || []).filter((name) => selectedGroups.has(name)),
398
+ }
399
+ plan.selection = { only_endpoints: selectedNames }
400
+ return plan
401
+ }
402
+
403
+ function backendSourcePathsForEndpointSelection(local, selectedNames) {
404
+ if (!selectedNames) return null
405
+ const paths = new Set()
406
+ for (const name of selectedNames) {
407
+ const entry = local?.byName?.[name]
408
+ if (!entry) continue
409
+ if (entry.source === "flow") {
410
+ const raw = entry.pushPayload?.workflow_source?.file || entry.file
411
+ if (typeof raw === "string" && raw) {
412
+ paths.add(raw.startsWith("dypai/") ? raw : `dypai/${raw}`)
413
+ }
414
+ continue
415
+ }
416
+ if (entry.file) paths.add(`dypai/endpoints/${entry.file}`)
417
+ for (const refPath of Object.keys(entry.fileMap || {})) {
418
+ paths.add(refPath.startsWith("dypai/") ? refPath : `dypai/${refPath}`)
419
+ }
420
+ }
421
+ return paths
422
+ }
423
+
281
424
  /**
282
425
  * Treat a remote tool response as a definite success only when it has at
283
426
  * least one of the markers we expect from a real mutation. Anything else
@@ -329,20 +472,36 @@ async function applyCreate(canonicalRow, projectId) {
329
472
  return assertMutationOK(res, "create", canonicalRow.name)
330
473
  }
331
474
 
332
- async function applyUpdate(canonicalRow, endpointId, projectId) {
475
+ function endpointUpdatesForRemote(canonicalRow, expectedRevision = null) {
476
+ const updates = endpointPayload(canonicalRow)
477
+ if (expectedRevision != null) updates.expected_revision = Number(expectedRevision)
478
+ return updates
479
+ }
480
+
481
+ async function applyUpdate(canonicalRow, endpointId, projectId, expectedRevision = null) {
482
+ const updates = endpointUpdatesForRemote(canonicalRow, expectedRevision)
333
483
  const res = await proxyToolCallWithRetry("update_endpoint", {
334
484
  ...(projectId ? { project_id: projectId } : {}),
335
485
  endpoint_id: endpointId,
336
- updates: endpointPayload(canonicalRow),
486
+ updates,
337
487
  })
338
488
  return assertMutationOK(res, "update", canonicalRow.name)
339
489
  }
340
490
 
341
- async function applyDelete(endpointId, projectId) {
342
- const res = await proxyToolCallWithRetry("delete_endpoint", {
491
+ function endpointDeleteArgs(endpointId, projectId, expectedRevision = null) {
492
+ return {
343
493
  ...(projectId ? { project_id: projectId } : {}),
344
494
  endpoint_id: endpointId,
345
- })
495
+ ...(expectedRevision != null ? { expected_revision: Number(expectedRevision) } : {}),
496
+ }
497
+ }
498
+
499
+ async function applyDelete(endpointId, projectId, expectedRevision = null) {
500
+ const res = await proxyToolCallWithRetry("delete_endpoint", endpointDeleteArgs(
501
+ endpointId,
502
+ projectId,
503
+ expectedRevision,
504
+ ))
346
505
  return assertMutationOK(res, "delete", endpointId)
347
506
  }
348
507
 
@@ -484,6 +643,7 @@ export const dypaiPushTool = {
484
643
  "ALWAYS run dypai_diff first to preview what will be staged. " +
485
644
  "The public dypai_push wrapper also saves frontend source to Studio. " +
486
645
  "By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to stage their deletion as a draft as well. " +
646
+ "For endpoint-scoped work, pass only_endpoints: ['slug-a', 'slug-b']; unrelated endpoints, groups, realtime policies, automations, and source files are left untouched. " +
487
647
  "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
648
  inputSchema: {
489
649
  type: "object",
@@ -517,11 +677,29 @@ export const dypaiPushTool = {
517
677
  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
678
  default: false,
519
679
  },
680
+ only_endpoints: {
681
+ type: "array",
682
+ minItems: 1,
683
+ uniqueItems: true,
684
+ items: { type: "string" },
685
+ description: "Optional explicit endpoint allowlist. Only these endpoint slugs may be created, updated, or deleted; project-wide realtime/automation reconciliation is skipped.",
686
+ },
520
687
  },
521
688
  },
522
689
 
523
- async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false } = {}) {
690
+ async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false, only_endpoints = null } = {}) {
524
691
  const rootDir = resolvePath(process.cwd(), root_dir)
692
+ let selectedNames
693
+ try {
694
+ selectedNames = normalizeEndpointSelection(only_endpoints)
695
+ } catch (error) {
696
+ return {
697
+ success: false,
698
+ applied: false,
699
+ reason: "invalid_endpoint_selection",
700
+ error: error.message,
701
+ }
702
+ }
525
703
 
526
704
  // Resolve the target project via dypai.config.yaml if the caller didn't
527
705
  // pass an explicit project_id. If both are provided and disagree, refuse —
@@ -541,8 +719,7 @@ export const dypaiPushTool = {
541
719
  try {
542
720
  const validation = await runValidation(rootDir, project_id || configProjectId)
543
721
  if (!validation.success) {
544
- const errorDiags = (validation.diagnostics || []).filter(d => d.severity === "error")
545
- const projectLevel = errorDiags.filter(d => !d.endpoint)
722
+ const { errorDiags, projectLevel, erroringEndpoints: partitioned } = partitionValidationErrors(validation.diagnostics)
546
723
  if (projectLevel.length) {
547
724
  return {
548
725
  success: false,
@@ -554,7 +731,7 @@ export const dypaiPushTool = {
554
731
  }
555
732
  }
556
733
  skippedDiagnostics = errorDiags
557
- erroringEndpoints = new Set(errorDiags.map(d => d.endpoint).filter(Boolean))
734
+ erroringEndpoints = partitioned
558
735
  }
559
736
  } catch (e) {
560
737
  // Don't block the push on a validator crash — just warn
@@ -599,6 +776,20 @@ export const dypaiPushTool = {
599
776
  error: e.message,
600
777
  }
601
778
  }
779
+ const compilerValidation = partitionValidationErrors(local.compilerDiagnostics)
780
+ if (compilerValidation.projectLevel.length) {
781
+ return {
782
+ success: false,
783
+ applied: false,
784
+ reason: "compiler_validation_failed",
785
+ diagnostics: compilerValidation.projectLevel.slice(0, 20),
786
+ hint: "The compiler reported project-level errors that cannot be isolated to one endpoint.",
787
+ }
788
+ }
789
+ for (const endpoint of compilerValidation.erroringEndpoints) {
790
+ erroringEndpoints.add(endpoint)
791
+ }
792
+ skippedDiagnostics.push(...compilerValidation.errorDiags)
602
793
  try {
603
794
  remote = await fetchRemoteState(targetProjectId)
604
795
  } catch (e) {
@@ -653,29 +844,32 @@ export const dypaiPushTool = {
653
844
  stateSnapshot,
654
845
  liveRemote: remote,
655
846
  })
847
+ if (selectedNames) {
848
+ const unavailable = selectedNames.filter((name) => {
849
+ if (local.byName[name]) return false
850
+ return !(delete_orphans && effectiveRemote.byName?.[name])
851
+ })
852
+ if (unavailable.length) {
853
+ return {
854
+ success: false,
855
+ applied: false,
856
+ reason: "endpoint_selection_not_found",
857
+ unavailable_endpoints: unavailable,
858
+ hint: "Each only_endpoints slug must exist locally. A remote-only slug is accepted only with delete_orphans:true for an explicit deletion.",
859
+ }
860
+ }
861
+ scopePlanToEndpoints(plan, selectedNames, local)
862
+ }
863
+ const selectedSourcePaths = backendSourcePathsForEndpointSelection(local, selectedNames)
656
864
 
657
865
  // Partial push: drop endpoints that failed validation from create/update so
658
866
  // they aren't pushed (they keep their live version). Deletes stay — a broken
659
867
  // local flow shouldn't block removing endpoints. skip_validation leaves
660
868
  // erroringEndpoints empty, so nothing is dropped.
661
- const skippedNames = []
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
869
+ const skipped_endpoints = mergeSkippedEndpointSummaries(
870
+ dropErroringFromPlan(plan, erroringEndpoints, skippedDiagnostics),
871
+ skippedEndpointsFromDiagnostics(compilerValidation.errorDiags),
872
+ )
679
873
 
680
874
  const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
681
875
  const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
@@ -688,8 +882,13 @@ export const dypaiPushTool = {
688
882
  success: false,
689
883
  applied: false,
690
884
  reason: "conflicts_detected",
885
+ summary: summaryFromPlan(plan),
691
886
  conflicts: conflictSplit.blocking,
692
887
  non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
888
+ skipped_endpoints,
889
+ compiler_diagnostics: local.compilerDiagnostics?.length
890
+ ? local.compilerDiagnostics.slice(0, 50)
891
+ : undefined,
693
892
  hint: "Remote changes overlap endpoints this push would update/delete. Run dypai_pull to refresh local state, resolve the overlap, then push again. To override, pass force=true.",
694
893
  }
695
894
  }
@@ -697,11 +896,13 @@ export const dypaiPushTool = {
697
896
  if (dry_run || totalChanges === 0) {
698
897
  const sourceCheckpoint = dry_run
699
898
  ? null
700
- : await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
899
+ : await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
701
900
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
702
901
  const automationSync = dry_run
703
902
  ? { skipped: true, reason: "dry_run" }
704
- : await syncAutomationMetadata(targetProjectId, local.automations)
903
+ : selectedNames
904
+ ? { skipped: true, reason: "endpoint_scoped_push" }
905
+ : await syncAutomationMetadata(targetProjectId, local.automations)
705
906
  const automationSyncHasFailed = automationSyncFailed(automationSync)
706
907
  const sourceBaseline = !dry_run && !sourceCheckpointFailed
707
908
  ? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
@@ -717,12 +918,16 @@ export const dypaiPushTool = {
717
918
  : totalChanges === 0 ? "no_changes" : "dry_run",
718
919
  summary: summaryFromPlan(plan),
719
920
  skipped_endpoints,
921
+ compiler_diagnostics: local.compilerDiagnostics?.length
922
+ ? local.compilerDiagnostics.slice(0, 50)
923
+ : undefined,
720
924
  plan,
721
925
  non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
722
926
  types: typesGenerated,
723
927
  automation_sync: automationSync,
724
928
  source_checkpoint: sourceCheckpoint || undefined,
725
929
  source_baseline: sourceBaseline,
930
+ selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
726
931
  hint: automationSyncHasFailed
727
932
  ? "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
933
  : sourceCheckpointFailed
@@ -787,7 +992,7 @@ export const dypaiPushTool = {
787
992
  const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
788
993
  const endpointId = remote.byName[item.name]?.id
789
994
  const res = endpointId
790
- ? await applyUpdate(canonical, endpointId, targetProjectId)
995
+ ? await applyUpdate(canonical, endpointId, targetProjectId, remote.byName[item.name]?.revision)
791
996
  : await applyCreate(canonical, targetProjectId)
792
997
  applied.updated.push({ name: item.name, changed_fields: item.changedFields })
793
998
  if (isDraftResponse(res)) applied.updated_drafts.push(item.name)
@@ -805,7 +1010,11 @@ export const dypaiPushTool = {
805
1010
  try {
806
1011
  const endpointId = remote.byName[item.name]?.id
807
1012
  if (!endpointId) throw new Error("endpoint_id missing from remote state")
808
- const res = await applyDelete(endpointId, targetProjectId)
1013
+ const res = await applyDelete(
1014
+ endpointId,
1015
+ targetProjectId,
1016
+ remote.byName[item.name]?.revision,
1017
+ )
809
1018
  applied.deleted.push(item.name)
810
1019
  if (isDraftResponse(res)) applied.deleted_drafts.push(item.name)
811
1020
  endpoint_results.push(endpointResult("delete", item.name, "applied", {
@@ -834,10 +1043,14 @@ export const dypaiPushTool = {
834
1043
 
835
1044
  // Also reconcile realtime policies if realtime.yaml exists
836
1045
  let realtime = null
837
- try {
838
- realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
839
- } catch (e) {
840
- errors.push({ op: "realtime", error: e.message })
1046
+ if (selectedNames) {
1047
+ realtime = { skipped: true, reason: "endpoint_scoped_push" }
1048
+ } else {
1049
+ try {
1050
+ realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
1051
+ } catch (e) {
1052
+ errors.push({ op: "realtime", error: e.message })
1053
+ }
841
1054
  }
842
1055
 
843
1056
  // Names of endpoints that actually changed this run — drives the follow-up
@@ -868,7 +1081,7 @@ export const dypaiPushTool = {
868
1081
  const isDraftMode = draftCount > 0
869
1082
  && draftCount === endpointTotal + realtimeTotal
870
1083
  const sourceCheckpoint = errors.length === 0
871
- ? await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
1084
+ ? await checkpointBackendSource(targetProjectId, rootDir, plan, remote, selectedSourcePaths)
872
1085
  : {
873
1086
  ok: true,
874
1087
  skipped: true,
@@ -876,7 +1089,9 @@ export const dypaiPushTool = {
876
1089
  }
877
1090
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
878
1091
  const automationSync = errors.length === 0
879
- ? await syncAutomationMetadata(targetProjectId, local.automations)
1092
+ ? selectedNames
1093
+ ? { skipped: true, reason: "endpoint_scoped_push" }
1094
+ : await syncAutomationMetadata(targetProjectId, local.automations)
880
1095
  : { skipped: true, reason: "push_had_errors" }
881
1096
  const automationSyncHasFailed = automationSyncFailed(automationSync)
882
1097
  const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
@@ -918,12 +1133,16 @@ export const dypaiPushTool = {
918
1133
  details: applied,
919
1134
  endpoint_results,
920
1135
  skipped_endpoints,
1136
+ compiler_diagnostics: local.compilerDiagnostics?.length
1137
+ ? local.compilerDiagnostics.slice(0, 50)
1138
+ : undefined,
921
1139
  non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
922
1140
  errors: errors.length ? errors : undefined,
923
1141
  types: typesGenerated,
924
1142
  automation_sync: automationSync,
925
1143
  source_checkpoint: sourceCheckpoint,
926
1144
  source_baseline: sourceBaseline,
1145
+ selection: selectedNames ? { only_endpoints: selectedNames } : undefined,
927
1146
  // Only one next_step — and only when it's non-obvious. Drafts win
928
1147
  // over the test suggestion because tests against live won't see
929
1148
  // the change until the drafts are promoted.
@@ -967,9 +1186,18 @@ function summaryFromPlan(plan) {
967
1186
  export const __testing = {
968
1187
  normalizeResponseCardinality,
969
1188
  endpointPayload,
1189
+ endpointUpdatesForRemote,
1190
+ endpointDeleteArgs,
970
1191
  splitBlockingConflicts,
971
1192
  collectBackendSourceCheckpointFiles,
972
1193
  deletedBackendSourcePathsFromPlan,
973
1194
  syncAutomationMetadata,
1195
+ partitionValidationErrors,
1196
+ dropErroringFromPlan,
1197
+ skippedEndpointsFromDiagnostics,
1198
+ mergeSkippedEndpointSummaries,
1199
+ normalizeEndpointSelection,
1200
+ scopePlanToEndpoints,
1201
+ backendSourcePathsForEndpointSelection,
974
1202
  PUSH_CONCURRENCY,
975
1203
  }