primitive-admin 1.0.55 → 1.0.57

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.
Files changed (92) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +230 -2
  2. package/dist/bin/primitive.js +1 -1
  3. package/dist/src/commands/database-types.js +4 -4
  4. package/dist/src/commands/database-types.js.map +1 -1
  5. package/dist/src/commands/databases.js +5 -5
  6. package/dist/src/commands/databases.js.map +1 -1
  7. package/dist/src/commands/documents.js +2 -2
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/guides.d.ts +15 -12
  10. package/dist/src/commands/guides.js +22 -15
  11. package/dist/src/commands/guides.js.map +1 -1
  12. package/dist/src/commands/metadata.js +48 -3
  13. package/dist/src/commands/metadata.js.map +1 -1
  14. package/dist/src/commands/scripts.d.ts +18 -0
  15. package/dist/src/commands/scripts.js +59 -5
  16. package/dist/src/commands/scripts.js.map +1 -1
  17. package/dist/src/commands/sync-app-settings.d.ts +14 -4
  18. package/dist/src/commands/sync-app-settings.js +48 -8
  19. package/dist/src/commands/sync-app-settings.js.map +1 -1
  20. package/dist/src/commands/sync.d.ts +100 -0
  21. package/dist/src/commands/sync.js +1578 -402
  22. package/dist/src/commands/sync.js.map +1 -1
  23. package/dist/src/commands/workflows.js +23 -6
  24. package/dist/src/commands/workflows.js.map +1 -1
  25. package/dist/src/lib/api-client.d.ts +27 -1
  26. package/dist/src/lib/api-client.js +25 -2
  27. package/dist/src/lib/api-client.js.map +1 -1
  28. package/dist/src/lib/app-settings-descriptor.js +4 -0
  29. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  30. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +21 -0
  31. package/dist/src/lib/codegen-shared/generatedFiles.js +40 -0
  32. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  33. package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
  34. package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
  35. package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
  36. package/dist/src/lib/db-codegen/dbGenerator.js +8 -5
  37. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  38. package/dist/src/lib/db-codegen/dbNaming.d.ts +5 -0
  39. package/dist/src/lib/db-codegen/dbNaming.js +8 -2
  40. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  41. package/dist/src/lib/db-codegen/dbTemplates.d.ts +17 -2
  42. package/dist/src/lib/db-codegen/dbTemplates.js +85 -40
  43. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  44. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +13 -22
  45. package/dist/src/lib/db-codegen/dbTsTypes.js +33 -28
  46. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  47. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +11 -0
  48. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +11 -0
  49. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -1
  50. package/dist/src/lib/generated-allowlist.js +7 -0
  51. package/dist/src/lib/generated-allowlist.js.map +1 -1
  52. package/dist/src/lib/platform-owned.d.ts +37 -0
  53. package/dist/src/lib/platform-owned.js +46 -0
  54. package/dist/src/lib/platform-owned.js.map +1 -0
  55. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +32 -0
  56. package/dist/src/lib/swift-codegen/dbGenerator.js +338 -36
  57. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  58. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +1 -1
  59. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +1 -1
  60. package/dist/src/lib/swift-codegen/generator.d.ts +1 -0
  61. package/dist/src/lib/swift-codegen/generator.js +91 -3
  62. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  63. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +90 -0
  64. package/dist/src/lib/swift-codegen/siblingSymbols.js +152 -0
  65. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
  66. package/dist/src/lib/sync-resource-types.d.ts +222 -10
  67. package/dist/src/lib/sync-resource-types.js +407 -20
  68. package/dist/src/lib/sync-resource-types.js.map +1 -1
  69. package/dist/src/lib/template.d.ts +1 -1
  70. package/dist/src/lib/template.js +6 -3
  71. package/dist/src/lib/template.js.map +1 -1
  72. package/dist/src/lib/toml-metadata-config.js +18 -0
  73. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  74. package/dist/src/lib/workflow-apply.js +12 -2
  75. package/dist/src/lib/workflow-apply.js.map +1 -1
  76. package/dist/src/lib/workflow-codegen/generator.d.ts +9 -0
  77. package/dist/src/lib/workflow-codegen/generator.js +126 -55
  78. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  79. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -8
  80. package/dist/src/lib/workflow-codegen/invokerIR.js +8 -8
  81. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  82. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +17 -1
  83. package/dist/src/lib/workflow-codegen/schemaToTs.js +59 -38
  84. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -1
  85. package/dist/src/lib/workflow-payload.d.ts +19 -0
  86. package/dist/src/lib/workflow-payload.js +21 -0
  87. package/dist/src/lib/workflow-payload.js.map +1 -1
  88. package/dist/src/lib/workflow-toml-validator.d.ts +30 -0
  89. package/dist/src/lib/workflow-toml-validator.js +206 -0
  90. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  91. package/dist/src/types/index.d.ts +1 -0
  92. package/package.json +2 -1
@@ -7,20 +7,21 @@ import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOpera
7
7
  import { canEmitNative } from "../lib/toml-native-form.js";
8
8
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
9
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
+ import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
10
11
  import { getServerUrl, resolveAppId } from "../lib/config.js";
11
12
  import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
12
13
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
13
- import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
14
+ import { validateWorkflowToml, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
14
15
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
15
16
  import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
16
17
  import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
17
18
  import { serializeAppSettings, collectAppSettingsPushErrors, } from "./sync-app-settings.js";
18
19
  import { applyWorkflowBody } from "../lib/workflow-apply.js";
19
- import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
20
+ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../lib/workflow-payload.js";
20
21
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
21
- import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, ruleSetFileKey, } from "../lib/sync-resource-types.js";
22
+ import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
22
23
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
23
- import { confirmPrompt } from "../lib/confirm-prompt.js";
24
+ import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
24
25
  import chalk from "chalk";
25
26
  function ensureDir(dirPath) {
26
27
  if (!existsSync(dirPath)) {
@@ -711,12 +712,13 @@ function serializeBlobBucket(bucket) {
711
712
  * Build the PATCH payload for a blob-bucket update from local TOML.
712
713
  *
713
714
  * EXACT extraction of the inline builder shared by the existing-update and
714
- * 409-adopt branches — do NOT "clean up" the truthiness checks. The server
715
- * treats `preset` and `accessPolicy` as mutually exclusive, only clears
716
- * `ruleSetId` when a preset/accessPolicy is also present, and nulls
717
- * `description` only when the key is explicitly present with a falsy value.
718
- * `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
719
- * must go through this helper so their field sets never drift.
715
+ * 409-adopt branches — do NOT "clean up" the truthiness checks on the access
716
+ * model. The server treats `preset` and `accessPolicy` as mutually exclusive,
717
+ * only clears `ruleSetId` when a preset/accessPolicy is also present, and
718
+ * refuses to leave a bucket with no access model or a blank name — so those
719
+ * fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
720
+ * sent. Both call sites must go through this helper so their field sets never
721
+ * drift.
720
722
  */
721
723
  function buildBlobBucketUpdatePayload(bucket) {
722
724
  const updatePayload = {};
@@ -728,8 +730,11 @@ function buildBlobBucketUpdatePayload(bucket) {
728
730
  updatePayload.ruleSetId = bucket.ruleSetId;
729
731
  if (bucket.name)
730
732
  updatePayload.name = bucket.name;
731
- if (bucket.description !== undefined)
732
- updatePayload.description = bucket.description || null;
733
+ // Issue #1567: `description` is TOML-owned — always send it as value-or-`null`
734
+ // so removing the line from the TOML clears it server-side (the server nulls
735
+ // `description` on an explicit `null`). Previously it was only sent when the
736
+ // key was present, so an omitted description preserved the stale value.
737
+ updatePayload.description = bucket.description ?? null;
733
738
  return updatePayload;
734
739
  }
735
740
  function serializePrompt(prompt) {
@@ -856,7 +861,17 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
856
861
  // Determine which steps to use (prefer config steps if non-empty, else draft steps)
857
862
  const configSteps = activeConfig?.steps;
858
863
  const draftSteps = draft?.steps;
859
- const steps = (configSteps && configSteps.length > 0) ? configSteps : (draftSteps || []);
864
+ const useConfigSteps = configSteps && configSteps.length > 0;
865
+ const steps = useConfigSteps ? configSteps : (draftSteps || []);
866
+ // #1614 — the named `expr.*` definitions travel with the steps payload, so
867
+ // pull emits them from the same source (config vs draft) the steps came from,
868
+ // as a top-level `[expr.cel]` table for the semantic round-trip.
869
+ const selectedExpressions = useConfigSteps
870
+ ? activeConfig?.expressions
871
+ : draft?.expressions;
872
+ const expressionsCel = selectedExpressions?.cel && typeof selectedExpressions.cel === "object"
873
+ ? selectedExpressions.cel
874
+ : undefined;
860
875
  const data = {
861
876
  workflow: {
862
877
  key: workflow.workflowKey,
@@ -912,6 +927,11 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
912
927
  // `secrets`, merged the same way the collection/database type configs do so
913
928
  // a workflow's `md`/`secrets` declarations round-trip through pull → push.
914
929
  ...serializeDeclaredAccessManifest(workflow.metadataManifest),
930
+ // #1614 — emit `[expr.cel]` named-guard definitions (only when present) so a
931
+ // pull → push cycle round-trips them semantically.
932
+ ...(expressionsCel && Object.keys(expressionsCel).length > 0
933
+ ? { expr: { cel: expressionsCel } }
934
+ : {}),
915
935
  };
916
936
  return stringifyConfigToml(data);
917
937
  }
@@ -952,7 +972,7 @@ function serializeEmailTemplate(template) {
952
972
  }
953
973
  return stringifyConfigToml(data);
954
974
  }
955
- function serializeRuleSet(ruleSet) {
975
+ export function serializeRuleSet(ruleSet) {
956
976
  const data = {
957
977
  ruleSet: {
958
978
  name: ruleSet.name,
@@ -997,6 +1017,83 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
997
1017
  };
998
1018
  return stringifyConfigToml(data);
999
1019
  }
1020
+ /**
1021
+ * Issue #1567 — the TOML-owned scalar fields of a database-type config.
1022
+ *
1023
+ * These are fields the `database-types/*.toml` file OWNS: the local file is the
1024
+ * source of truth, so removing one from the TOML must clear it server-side
1025
+ * (config-as-code), not silently preserve the stale value. `schema` is NOT in
1026
+ * this list — it keeps its own `hasSchema` prior-state discriminator, since a
1027
+ * schema is a large sub-tree, not a scalar.
1028
+ */
1029
+ export const DB_TYPE_OWNED_SCALARS = [
1030
+ "ruleSetId",
1031
+ "triggers",
1032
+ "metadataAccess",
1033
+ "defaultAccess",
1034
+ "autoPopulatedFields",
1035
+ "timestamps",
1036
+ "metadataManifest",
1037
+ ];
1038
+ // User-facing TOML key names for the "Cleared <field>" push output. The wire
1039
+ // field differs from the key the user writes for two of them.
1040
+ const DB_TYPE_FIELD_LABELS = {
1041
+ metadataAccess: "celContextAccess",
1042
+ ruleSetId: "ruleSetName",
1043
+ };
1044
+ /** The user-facing TOML key for a wire field (for push output). */
1045
+ export function dbTypeFieldLabel(key) {
1046
+ return DB_TYPE_FIELD_LABELS[key] || key;
1047
+ }
1048
+ /**
1049
+ * True when a value carries real content. null/undefined, blank/whitespace
1050
+ * strings, and empty objects/arrays all count as "no value" — the same
1051
+ * normalization the server applies (`value || null`) when it decides whether a
1052
+ * field is set.
1053
+ */
1054
+ export function hasMeaningfulValue(v) {
1055
+ if (v === null || v === undefined)
1056
+ return false;
1057
+ if (typeof v === "string")
1058
+ return v.trim().length > 0;
1059
+ if (Array.isArray(v))
1060
+ return v.length > 0;
1061
+ if (typeof v === "object")
1062
+ return Object.keys(v).length > 0;
1063
+ return true;
1064
+ }
1065
+ /**
1066
+ * Issue #1567 — always-send owned db-type scalars as value-or-`null`.
1067
+ *
1068
+ * The server PATCH endpoint distinguishes an omitted key (preserve) from an
1069
+ * explicit `null` (clear), so sending every owned scalar as `value ?? null`
1070
+ * converges the server to the local TOML: a field removed from the file lands
1071
+ * as `null` and is cleared. Matches the always-send pattern the sibling
1072
+ * `collection-type-config` push already uses.
1073
+ */
1074
+ export function buildOwnedScalarUpdate(typeConfig) {
1075
+ const u = {};
1076
+ for (const key of DB_TYPE_OWNED_SCALARS) {
1077
+ u[key] = typeConfig[key] ?? null;
1078
+ }
1079
+ return u;
1080
+ }
1081
+ /**
1082
+ * Issue #1567 — the owned scalars this push genuinely clears: the outgoing
1083
+ * value is empty but the server currently holds a real value (a non-null →
1084
+ * null transition). Used for the "Cleared <field>" report so removing an
1085
+ * access gate is never silent, and to decide whether an otherwise
1086
+ * type-field-less push still needs a type-config PATCH. Returns wire field
1087
+ * names (map with `dbTypeFieldLabel` for output). `serverConfig` is the live
1088
+ * GET; without it (fresh type or a failed fetch) nothing is reported cleared.
1089
+ */
1090
+ export function ownedScalarsBeingCleared(updateData, serverConfig) {
1091
+ if (!serverConfig)
1092
+ return [];
1093
+ return DB_TYPE_OWNED_SCALARS.filter((key) => key in updateData &&
1094
+ !hasMeaningfulValue(updateData[key]) &&
1095
+ hasMeaningfulValue(serverConfig[key]));
1096
+ }
1000
1097
  export function parseDatabaseTypeToml(tomlData) {
1001
1098
  const typeSection = tomlData.type || {};
1002
1099
  const typeConfig = {
@@ -1010,11 +1107,14 @@ export function parseDatabaseTypeToml(tomlData) {
1010
1107
  typeConfig.ruleSetId = typeSection.ruleSetId;
1011
1108
  }
1012
1109
  // Accept both `celContextAccess` (new user-facing key) and `metadataAccess`
1013
- // (legacy). The newer key wins when both appear.
1014
- if (typeSection.celContextAccess) {
1110
+ // (legacy). The newer key wins when both appear. Issue #1567: use a
1111
+ // defined-check, not a truthy guard, so `celContextAccess = ""` is honored as
1112
+ // an explicit clear (empty string → server nulls it) rather than falling
1113
+ // through to the legacy key or being dropped.
1114
+ if (typeSection.celContextAccess !== undefined) {
1015
1115
  typeConfig.metadataAccess = typeSection.celContextAccess;
1016
1116
  }
1017
- else if (typeSection.metadataAccess) {
1117
+ else if (typeSection.metadataAccess !== undefined) {
1018
1118
  typeConfig.metadataAccess = typeSection.metadataAccess;
1019
1119
  }
1020
1120
  if (typeSection.defaultAccess !== undefined) {
@@ -1081,7 +1181,7 @@ export function parseDatabaseTypeToml(tomlData) {
1081
1181
  const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
1082
1182
  return { typeConfig, operations, subscriptions };
1083
1183
  }
1084
- function parseRuleSetToml(tomlData) {
1184
+ export function parseRuleSetToml(tomlData) {
1085
1185
  const ruleSetSection = tomlData.ruleSet || {};
1086
1186
  return {
1087
1187
  name: ruleSetSection.name,
@@ -1141,6 +1241,102 @@ export function parseCollectionTypeConfigToml(tomlData) {
1141
1241
  }
1142
1242
  return result;
1143
1243
  }
1244
+ // ── Content-aware `sync diff` for the five database-config resource types ──
1245
+ // (issue #1485)
1246
+ //
1247
+ // database-types, rule-sets, group-type-configs, collection-type-configs and
1248
+ // metadata-category-configs were absent from `sync diff` entirely, so an edit
1249
+ // to any of them read as "nothing differs" until `sync push` applied it. These
1250
+ // helpers give diff the same content-aware comparison the workflow and
1251
+ // transform blocks already have: the server entity is run through the SAME pull
1252
+ // serializer that writes the local file, and BOTH sides are then parsed with
1253
+ // the SAME push parser before hashing.
1254
+ //
1255
+ // Parsing through the push parser — rather than hashing the raw parsed TOML —
1256
+ // is what makes the comparison encoding-independent, and is why the four known
1257
+ // false-diff traps do not fire: a legacy JSON-string operation and the native
1258
+ // `[operations.definition]` table the server emits normalize to one shape
1259
+ // (`normalizeOperationFromToml`); a `ruleSetName` reference and its legacy
1260
+ // `ruleSetId` resolve to the same id (`normalizeRuleSetRefForDiff`); an omitted
1261
+ // `autoAddCreator` picks up the same default the server serializes
1262
+ // (`parseGroupTypeConfigToml`); and a metadata-category's identity is read from
1263
+ // the parsed `[metadataCategoryConfig]` content, not the filename. Hashing raw
1264
+ // parsed TOML would show a false `Modified` for each of those equivalent forms.
1265
+ /**
1266
+ * Resolve a parsed entity's rule-set reference to a stable id for hashing.
1267
+ * `parseGroupTypeConfigToml` / `parseCollectionTypeConfigToml` /
1268
+ * `parseDatabaseTypeToml` leave either `_ruleSetName` (key-based) or a legacy
1269
+ * `ruleSetId` on the entity; collapsing both to `ruleSetId` here means the two
1270
+ * encodings of the same reference hash equal. `throwOnMissing: false` so an
1271
+ * unresolvable name degrades gracefully rather than aborting the diff.
1272
+ *
1273
+ * When the name does NOT resolve — a typo, or a rule set added locally in the
1274
+ * same tree but not yet on the server — `resolveRuleSetReference` deletes
1275
+ * `_ruleSetName` WITHOUT setting `ruleSetId`, leaving the entity with no
1276
+ * reference at all. If the remote config also currently has no rule set the two
1277
+ * hashes would then be equal and diff would report `Synced`, silently hiding a
1278
+ * real pending change (push will either reject the typo or create the rule set
1279
+ * and update the config). So we preserve the unresolved name in the hashed
1280
+ * value under a distinct key, ensuring the pending change surfaces as
1281
+ * `Modified` instead of `Synced`.
1282
+ */
1283
+ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
1284
+ const referencedName = entity._ruleSetName;
1285
+ const wasResolvable = referencedName !== undefined && ruleSetNameToId.has(referencedName);
1286
+ resolveRuleSetReference(entity, ruleSetNameToId, label, {
1287
+ throwOnMissing: false,
1288
+ });
1289
+ if (referencedName !== undefined && !wasResolvable) {
1290
+ entity._unresolvedRuleSetName = referencedName;
1291
+ }
1292
+ }
1293
+ export function hashLocalRuleSetForDiff(parsedToml) {
1294
+ return computeExpandedContentHash(parseRuleSetToml(parsedToml));
1295
+ }
1296
+ export function hashRemoteRuleSetForDiff(ruleSet) {
1297
+ return computeExpandedContentHash(parseRuleSetToml(parseConfigToml(serializeRuleSet(ruleSet))));
1298
+ }
1299
+ export function hashLocalGroupTypeConfigForDiff(parsedToml, ruleSetNameToId) {
1300
+ const entity = parseGroupTypeConfigToml(parsedToml);
1301
+ normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `group type config "${entity.groupType}"`);
1302
+ return computeExpandedContentHash(entity);
1303
+ }
1304
+ export function hashRemoteGroupTypeConfigForDiff(config, ruleSetIdToName, ruleSetNameToId) {
1305
+ const entity = parseGroupTypeConfigToml(parseConfigToml(serializeGroupTypeConfig(config, ruleSetIdToName)));
1306
+ normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `group type config "${entity.groupType}"`);
1307
+ return computeExpandedContentHash(entity);
1308
+ }
1309
+ export function hashLocalCollectionTypeConfigForDiff(parsedToml, ruleSetNameToId) {
1310
+ const entity = parseCollectionTypeConfigToml(parsedToml);
1311
+ normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `collection type config "${entity.collectionType}"`);
1312
+ return computeExpandedContentHash(entity);
1313
+ }
1314
+ export function hashRemoteCollectionTypeConfigForDiff(config, ruleSetIdToName, ruleSetNameToId) {
1315
+ const entity = parseCollectionTypeConfigToml(parseConfigToml(serializeCollectionTypeConfig(config, ruleSetIdToName)));
1316
+ normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `collection type config "${entity.collectionType}"`);
1317
+ return computeExpandedContentHash(entity);
1318
+ }
1319
+ export function hashLocalDatabaseTypeForDiff(parsedToml, ruleSetNameToId) {
1320
+ const { typeConfig, operations, subscriptions } = parseDatabaseTypeToml(parsedToml);
1321
+ normalizeRuleSetRefForDiff(typeConfig, ruleSetNameToId, `database type "${typeConfig.databaseType}"`);
1322
+ return computeExpandedContentHash({ typeConfig, operations, subscriptions });
1323
+ }
1324
+ export function hashRemoteDatabaseTypeForDiff(typeConfig, operations, subscriptions, ruleSetIdToName, ruleSetNameToId) {
1325
+ const serialized = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, { defaultForm: "native", subscriptions });
1326
+ const parsed = parseDatabaseTypeToml(parseConfigToml(serialized));
1327
+ normalizeRuleSetRefForDiff(parsed.typeConfig, ruleSetNameToId, `database type "${parsed.typeConfig.databaseType}"`);
1328
+ return computeExpandedContentHash({
1329
+ typeConfig: parsed.typeConfig,
1330
+ operations: parsed.operations,
1331
+ subscriptions: parsed.subscriptions,
1332
+ });
1333
+ }
1334
+ export function hashLocalMetadataCategoryForDiff(parsedToml) {
1335
+ return computeExpandedContentHash(parseMetadataCategoryToml(parsedToml));
1336
+ }
1337
+ export function hashRemoteMetadataCategoryForDiff(config) {
1338
+ return computeExpandedContentHash(parseMetadataCategoryToml(parseConfigToml(serializeMetadataCategoryConfig(config))));
1339
+ }
1144
1340
  // Parsing helpers
1145
1341
  //
1146
1342
  // All 20+ TOML parse sites in this file route through `parseTomlFile()`.
@@ -1148,7 +1344,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
1148
1344
  // prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
1149
1345
  // expander is a no-op: if the parsed result has no `include` key, it returns
1150
1346
  // the original object untouched.
1151
- function parseTomlFile(filePath) {
1347
+ export function parseTomlFile(filePath) {
1152
1348
  const content = readFileSync(filePath, "utf-8");
1153
1349
  const parsed = parseConfigToml(content);
1154
1350
  return expandWorkflowTomlData(parsed, filePath);
@@ -1170,6 +1366,83 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
1170
1366
  } while (cursor);
1171
1367
  return all;
1172
1368
  }
1369
+ /**
1370
+ * Issue #976 / #1006: shared 409 → adopt-by-key recovery for `sync push`
1371
+ * create paths. When a CREATE hits a per-app unique-key constraint, the resource
1372
+ * is already on the server but missing from local sync state: orphaned by a
1373
+ * prior push that aborted before recording it, a mid-apply crash, or an
1374
+ * out-of-band create with the same key. Rather than hard-fail forever, this
1375
+ * helper looks the resource up by its unique key and hands the matched item
1376
+ * back so the caller can re-issue the create as an UPDATE and converge.
1377
+ *
1378
+ * The conflict surfaces inconsistently across create routes, so detection is
1379
+ * deliberately broad (see `isConflict` below):
1380
+ * - a 409 status (webhooks, cron triggers, the standard case);
1381
+ * - an "already exists" message — some routes surface the 409 as HTTP 400,
1382
+ * e.g. workflows;
1383
+ * - a "must be unique" message — routes with no explicit conflict catch let
1384
+ * dynamo-bao's per-app unique-constraint error propagate verbatim (e.g. rule
1385
+ * sets, whose create route has no 409 translation). This matches the
1386
+ * server's own admin-api convention (`src/admin-api.ts`), which treats
1387
+ * "must be unique" as a duplicate-key conflict.
1388
+ * Any other error is NOT a conflict and re-throws unchanged (see below).
1389
+ *
1390
+ * Safety (load-bearing): exact-key match only. The `lookup` is app-scoped, so a
1391
+ * key match is also an ownership match — an unrelated resource is never
1392
+ * overwritten. The three non-adopt outcomes each surface a clear error and do
1393
+ * NOT touch the server:
1394
+ * - a non-409 create error is re-wrapped via `wrapEntityError` and re-thrown
1395
+ * (no lookup is attempted);
1396
+ * - a lookup failure throws a clear "already exists but could not be adopted
1397
+ * (lookup failed: …)";
1398
+ * - a 409 with no matching key re-throws the ORIGINAL create error rather
1399
+ * than silently proceeding.
1400
+ *
1401
+ * Pagination is the caller's concern: `lookup` must return the COMPLETE
1402
+ * candidate set. For cursor-based lists (webhooks, integrations, prompts,
1403
+ * workflows) wrap the list call in `fetchAll`; the cron-triggers route already
1404
+ * drains every page server-side (#1668) so its flat `{ items }` is complete.
1405
+ * The UPDATE re-issue and sync-state stamping stay with the caller because they
1406
+ * differ per entity.
1407
+ *
1408
+ * @returns the matched existing item (guaranteed non-null on return).
1409
+ */
1410
+ export async function adoptByKeyOnCreate409(opts) {
1411
+ const { err, kind, key, lookup, matchKey } = opts;
1412
+ const msg = String(err?.message ?? err);
1413
+ // A per-app unique-key conflict, surfaced any of the ways create routes report
1414
+ // it (409 status / "already exists" / dynamo-bao's "must be unique"). Anything
1415
+ // else is a genuine create failure and must not trigger an adopt.
1416
+ const isConflict = err?.statusCode === 409 ||
1417
+ msg.includes("already exists") ||
1418
+ msg.includes("must be unique");
1419
+ if (!isConflict) {
1420
+ // Not an adoptable conflict — surface the original create failure.
1421
+ throw wrapEntityError(err, "create", kind, key);
1422
+ }
1423
+ const label = kind.charAt(0).toUpperCase() + kind.slice(1);
1424
+ info(` ${label} already exists on server, adopting by key: ${key}`);
1425
+ let items;
1426
+ try {
1427
+ items = await lookup();
1428
+ }
1429
+ catch (lookupErr) {
1430
+ // A lookup failure after a 409 means we cannot adopt — surface it clearly
1431
+ // rather than swallowing it (a bare throw or silent catch loops
1432
+ // create→409 on every subsequent push).
1433
+ throw wrapEntityError(new Error(`${kind} "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", kind, key);
1434
+ }
1435
+ // Exact-key match only. The list endpoint is app-scoped, so a key match is
1436
+ // also an ownership match — require exact equality and never overwrite an
1437
+ // unrelated resource.
1438
+ const existing = (items || []).find((item) => matchKey(item));
1439
+ if (!existing) {
1440
+ // 409 but no matching key found on the server — surface the original
1441
+ // create error rather than silently overwriting an unrelated resource.
1442
+ throw wrapEntityError(err, "create", kind, key);
1443
+ }
1444
+ return existing;
1445
+ }
1173
1446
  /**
1174
1447
  * Resolve a key-based rule set name reference to an ID.
1175
1448
  * Throws if the name cannot be resolved and throwOnMissing is true.
@@ -1799,10 +2072,19 @@ Directory Structure:
1799
2072
  const emailTemplatesResult = emailTemplatesOutcome.ok
1800
2073
  ? emailTemplatesOutcome.value
1801
2074
  : { templates: [] };
2075
+ // #1660 — drop platform-owned workflows (the reserved `__internal.*`
2076
+ // namespace) BEFORE the per-item detail fetch below. They are not app
2077
+ // config: the platform creates and owns them, their exported TOML is
2078
+ // hollow and doesn't round-trip, and `sync push` would abort trying to
2079
+ // activate a configuration-less workflow. Filtering here means pull
2080
+ // writes no file for them and wastes no `getWorkflow`/`getWorkflowConfig`
2081
+ // call. Prefers the server's `platformOwned` flag, falls back to the key
2082
+ // prefix for older servers (edge case 1).
2083
+ const appWorkflowItems = workflowItems.filter((w) => !isPlatformOwnedWorkflow(w));
1802
2084
  // Fetch details for each entity
1803
2085
  const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1804
2086
  const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
1805
- const workflows = await Promise.all(workflowItems.map(async (w) => {
2087
+ const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
1806
2088
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
1807
2089
  // Fetch active config with steps if available
1808
2090
  const activeConfigId = workflowData.workflow?.activeConfigId;
@@ -2084,6 +2366,33 @@ Directory Structure:
2084
2366
  };
2085
2367
  info(` Wrote workflows/${filename}`);
2086
2368
  }
2369
+ // #1660 — remove any pre-fix stray `workflows/__internal.*.toml` a prior
2370
+ // (buggy) pull exported. Match on the RESOLVED `[workflow].key`, never on
2371
+ // filename alone, so an app-authored file is never touched even if it is
2372
+ // coincidentally named oddly (edge case 4). We don't write these files
2373
+ // anymore, so a leftover would otherwise show as permanent "Remote only"
2374
+ // diff noise (#1659) and re-abort `sync push`.
2375
+ const workflowsPullDir = join(configDir, "workflows");
2376
+ if (existsSync(workflowsPullDir)) {
2377
+ for (const file of readdirSync(workflowsPullDir).filter((f) => f.endsWith(".toml"))) {
2378
+ const strayPath = join(workflowsPullDir, file);
2379
+ let resolvedKey;
2380
+ try {
2381
+ const parsed = parseTomlFile(strayPath);
2382
+ resolvedKey = parsed.workflow?.key || basename(file, ".toml");
2383
+ }
2384
+ catch {
2385
+ // Unparseable file — fall back to the filename stem so a hollow
2386
+ // `__internal.*.toml` is still recognized and cleaned up.
2387
+ resolvedKey = basename(file, ".toml");
2388
+ }
2389
+ if (isPlatformOwnedWorkflowKey(resolvedKey)) {
2390
+ unlinkSync(strayPath);
2391
+ delete workflowEntities[resolvedKey];
2392
+ info(` Removed workflows/${file} — platform-owned workflow "${resolvedKey}" (managed by the platform)`);
2393
+ }
2394
+ }
2395
+ }
2087
2396
  // Pull transforms (Rhai scripts) — issue #892, slice 7 + codex
2088
2397
  // follow-up on PR #893. Mirrors the prompts pull pattern: list
2089
2398
  // server-side `Script` rows, write each body to
@@ -2305,7 +2614,10 @@ Directory Structure:
2305
2614
  integrations: { ok: true, serverKeys: integrationItems.map((i) => i.integrationKey) },
2306
2615
  webhooks: { ok: true, serverKeys: webhookItems.map((w) => w.webhookKey) },
2307
2616
  prompts: { ok: true, serverKeys: promptItems.map((p) => p.promptKey) },
2308
- workflows: { ok: true, serverKeys: workflowItems.map((w) => w.workflowKey) },
2617
+ // #1660 use the platform-owned-filtered set so prune reconciliation
2618
+ // never treats a platform-owned workflow as a server key that a local
2619
+ // file should exist for (we intentionally write none).
2620
+ workflows: { ok: true, serverKeys: appWorkflowItems.map((w) => w.workflowKey) },
2309
2621
  cronTriggers: cronTriggersOutcome.ok
2310
2622
  ? { ok: true, serverKeys: cronTriggerItems.map((t) => t.triggerKey) }
2311
2623
  : { ok: false },
@@ -2522,6 +2834,8 @@ Directory Structure:
2522
2834
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
2523
2835
  .option("--dry-run", "Show what would be changed without applying")
2524
2836
  .option("--force", "Overwrite remote even if modified since last pull")
2837
+ .option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
2838
+ .option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
2525
2839
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
2526
2840
  .action(async (appId, options) => {
2527
2841
  const resolvedAppId = resolveAppId(appId, options);
@@ -2628,6 +2942,13 @@ Directory Structure:
2628
2942
  }
2629
2943
  }
2630
2944
  }
2945
+ // #1767: the "convergence trap" — this gate checks the operations
2946
+ // as currently registered, and a push that changes an operation and
2947
+ // its schema together skips both, so fixing the operation locally
2948
+ // isn't enough. Spell out the two-step recovery.
2949
+ info(`If you also changed these operations in this push, their updates were skipped along with the schema. ` +
2950
+ `Push the operation fix first: temporarily remove the ${chalk.cyan("[models.*]")} block for this type, ` +
2951
+ `run ${chalk.cyan("sync")} (the operation updates apply), then restore the schema block and sync again.`);
2631
2952
  console.log();
2632
2953
  }
2633
2954
  if (schemaErrors.uncheckableOps.length > 0) {
@@ -2710,6 +3031,19 @@ Directory Structure:
2710
3031
  const filePath = join(preflightWorkflowsDir, file);
2711
3032
  try {
2712
3033
  const tomlData = parseTomlFile(filePath);
3034
+ // #1660 — skip platform-owned workflows (the reserved
3035
+ // `__internal.*` namespace) BEFORE validating, mirroring the
3036
+ // apply loop below. `sync push` validates every workflow TOML
3037
+ // here, up-front, before reaching that skip — so without this
3038
+ // second skip a stale/hollow `__internal.*.toml` with an invalid
3039
+ // `steps` shape would abort the whole push in preflight. Resolve
3040
+ // the key the same way the apply loop does (`[workflow].key`,
3041
+ // falling back to the filename) and match on the resolved key,
3042
+ // never the filename alone, so a renamed file is still caught.
3043
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
3044
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
3045
+ continue;
3046
+ }
2713
3047
  const tomlErrors = validateWorkflowToml(tomlData);
2714
3048
  if (tomlErrors.length > 0) {
2715
3049
  preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
@@ -2756,6 +3090,21 @@ Directory Structure:
2756
3090
  for (const e of validation.errors) {
2757
3091
  preflightValidationErrors.push(` ${formatIssue(e)}`);
2758
3092
  }
3093
+ // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
3094
+ // / `metadataAccess` gate on a database type config is a single CEL
3095
+ // expression that gates BOTH read and update of the database CEL
3096
+ // context — granting read implicitly grants update. Steer operators
3097
+ // to resource metadata categories (separate readRule/writeRule).
3098
+ // This is advisory only: the push continues and the runtime gate is
3099
+ // unchanged. It fires only when the deprecated key is actually
3100
+ // present, so metadata-category-only configs never trip it.
3101
+ const typeSection = tomlData.type || {};
3102
+ if (typeSection.celContextAccess || typeSection.metadataAccess) {
3103
+ const usedKey = typeSection.celContextAccess
3104
+ ? "celContextAccess"
3105
+ : "metadataAccess";
3106
+ warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
3107
+ }
2759
3108
  }
2760
3109
  }
2761
3110
  // Validate app.toml settings up-front (issue #1033). A hand-added
@@ -3080,31 +3429,74 @@ Directory Structure:
3080
3429
  // Create new rule set
3081
3430
  changes.push({ type: "rule-set", action: "create", key: fileKey });
3082
3431
  if (!options.dryRun) {
3083
- let created;
3432
+ let ruleSetId;
3433
+ let ruleSetModifiedAt;
3084
3434
  try {
3085
- created = await client.createRuleSet(resolvedAppId, {
3435
+ const created = await client.createRuleSet(resolvedAppId, {
3086
3436
  name: ruleSetData.name,
3087
3437
  resourceType: ruleSetData.resourceType,
3088
3438
  rules: ruleSetData.rules,
3089
3439
  description: ruleSetData.description,
3090
3440
  });
3441
+ info(` Created rule set: ${fileKey}`);
3442
+ ruleSetId = created.ruleSetId;
3443
+ ruleSetModifiedAt = created.modifiedAt || new Date().toISOString();
3091
3444
  }
3092
3445
  catch (err) {
3093
- throw wrapEntityError(err, "create", "rule set", fileKey);
3446
+ // #1006: idempotent create — adopt-by-key on 409. A rule set
3447
+ // can be orphaned on the server (a prior push aborted before
3448
+ // recording it in sync state, or an out-of-band create). On
3449
+ // retry this CREATE hits the `ruleSetNamePerApp` unique
3450
+ // constraint, so adoption matches on the server key `name`
3451
+ // (NOT the sync-state fileKey). `listRuleSets` returns a raw
3452
+ // array on the app-scoped app-API route (no `{ items }`, no
3453
+ // cursor), so a name match is also an ownership match. Non-409
3454
+ // errors, lookup failures, and no-match 409s all throw (see
3455
+ // adoptByKeyOnCreate409). `key` stays `fileKey` so non-409
3456
+ // errors wrap identically to before; `matchKey` does the real
3457
+ // name match.
3458
+ const existing = await adoptByKeyOnCreate409({
3459
+ err,
3460
+ kind: "rule set",
3461
+ key: fileKey,
3462
+ lookup: async () => await client.listRuleSets(resolvedAppId),
3463
+ matchKey: (rs) => rs?.name === ruleSetData.name,
3464
+ });
3465
+ // resourceType is immutable and cannot be reconciled by an
3466
+ // UPDATE (updateRuleSet's payload has no resourceType field).
3467
+ // Adoption matches only on `name`, so a same-name rule set on
3468
+ // a different resource type would be silently stamped as
3469
+ // synced while the server stays on the old type. Fail loudly
3470
+ // instead — mirrors the blob-bucket ttlTier-immutability guard.
3471
+ if (ruleSetData.resourceType &&
3472
+ existing.resourceType &&
3473
+ ruleSetData.resourceType !== existing.resourceType) {
3474
+ throw wrapEntityError(new Error(`rule set "${fileKey}" resourceType mismatch: local TOML has "${ruleSetData.resourceType}" but the server rule set "${ruleSetData.name}" has "${existing.resourceType}". resourceType is immutable; align the TOML with the server (or rename/recreate the rule set) before pushing.`), "update", "rule set", fileKey);
3475
+ }
3476
+ const updated = await client.updateRuleSet(resolvedAppId, existing.ruleSetId, {
3477
+ name: ruleSetData.name,
3478
+ description: ruleSetData.description,
3479
+ rules: ruleSetData.rules,
3480
+ });
3481
+ info(` Adopted + updated rule set: ${fileKey}`);
3482
+ ruleSetId = existing.ruleSetId;
3483
+ ruleSetModifiedAt =
3484
+ updated?.modifiedAt ||
3485
+ existing.modifiedAt ||
3486
+ new Date().toISOString();
3094
3487
  }
3095
- info(` Created rule set: ${fileKey}`);
3096
- if (syncState && created?.ruleSetId) {
3488
+ if (syncState && ruleSetId) {
3097
3489
  if (!syncState.entities.ruleSets) {
3098
3490
  syncState.entities.ruleSets = {};
3099
3491
  }
3100
3492
  syncState.entities.ruleSets[fileKey] = {
3101
- id: created.ruleSetId,
3102
- modifiedAt: created.modifiedAt || new Date().toISOString(),
3493
+ id: ruleSetId,
3494
+ modifiedAt: ruleSetModifiedAt,
3103
3495
  contentHash: computeFileHash(filePath),
3104
3496
  };
3105
3497
  }
3106
3498
  // Track name→ID for cross-reference resolution
3107
- ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
3499
+ ruleSetNameToId.set(ruleSetData.name, ruleSetId);
3108
3500
  }
3109
3501
  else {
3110
3502
  // In dry-run mode, use a placeholder so dependent entities can resolve
@@ -3131,7 +3523,14 @@ Directory Structure:
3131
3523
  const payload = {
3132
3524
  integrationKey: key,
3133
3525
  displayName: integration.displayName || key,
3134
- description: integration.description,
3526
+ // Issue #1567: `description` is TOML-owned — send `?? null` so
3527
+ // removing it from the TOML clears it server-side (the server
3528
+ // nulls `description` on an explicit `null`). A bare
3529
+ // `integration.description` is `undefined` on absence and dropped
3530
+ // by `JSON.stringify`, so the server kept the stale value.
3531
+ // (`timeoutMs`/`status` are defaulted/enum server-side — `null` is
3532
+ // not a valid clear for them, so they stay as-is.)
3533
+ description: integration.description ?? null,
3135
3534
  requestConfig: tomlData.requestConfig || {},
3136
3535
  timeoutMs: integration.timeoutMs,
3137
3536
  status: integration.status,
@@ -3185,7 +3584,35 @@ Directory Structure:
3185
3584
  }
3186
3585
  }
3187
3586
  catch (err) {
3188
- throw wrapEntityError(err, "create", "integration", key);
3587
+ // #1006: idempotent create adopt-by-key on 409. An
3588
+ // integration orphaned on the server (a prior push aborted
3589
+ // before recording it, or an out-of-band create) hits the
3590
+ // `integrationKeyPerApp` unique constraint on retry. Adopt by
3591
+ // `integrationKey` and re-issue as an UPDATE so the push
3592
+ // converges. `listIntegrations` is cursor-paginated → drain
3593
+ // fully with fetchAll. Non-409 errors, lookup failures, and
3594
+ // no-match 409s all throw (see adoptByKeyOnCreate409).
3595
+ const existing = await adoptByKeyOnCreate409({
3596
+ err,
3597
+ kind: "integration",
3598
+ key,
3599
+ lookup: () => fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
3600
+ matchKey: (i) => i?.integrationKey === key,
3601
+ });
3602
+ const updated = await client.updateIntegration(resolvedAppId, existing.integrationId, payload);
3603
+ info(` Adopted + updated integration: ${key}`);
3604
+ if (syncState) {
3605
+ if (!syncState.entities.integrations) {
3606
+ syncState.entities.integrations = {};
3607
+ }
3608
+ syncState.entities.integrations[key] = {
3609
+ id: existing.integrationId,
3610
+ modifiedAt: updated?.modifiedAt ||
3611
+ existing.modifiedAt ||
3612
+ new Date().toISOString(),
3613
+ contentHash: computeFileHash(filePath),
3614
+ };
3615
+ }
3189
3616
  }
3190
3617
  }
3191
3618
  }
@@ -3209,7 +3636,12 @@ Directory Structure:
3209
3636
  const payload = {
3210
3637
  webhookKey: key,
3211
3638
  displayName: webhook.displayName || key,
3212
- description: webhook.description,
3639
+ // Issue #1567: `description` is TOML-owned — send `?? null` so
3640
+ // removing it from the TOML clears it on the server (the PATCH
3641
+ // nulls `description` on an explicit `null`). A bare
3642
+ // `webhook.description` is `undefined` on absence and dropped by
3643
+ // JSON.stringify, so the stale value would otherwise persist.
3644
+ description: webhook.description ?? null,
3213
3645
  workflowKey: webhook.workflowKey,
3214
3646
  verificationScheme: webhook.verificationScheme,
3215
3647
  status: webhook.status,
@@ -3283,7 +3715,35 @@ Directory Structure:
3283
3715
  }
3284
3716
  }
3285
3717
  catch (err) {
3286
- throw wrapEntityError(err, "create", "webhook", key);
3718
+ // #1006: idempotent create adopt-by-key on 409. A webhook
3719
+ // orphaned on the server (a prior push aborted before
3720
+ // recording it, or an out-of-band create) hits the
3721
+ // `webhookKeyPerApp` unique constraint on retry. Adopt by
3722
+ // `webhookKey` and re-issue as an UPDATE so the push
3723
+ // converges. `listWebhooks` is cursor-paginated → drain fully
3724
+ // with fetchAll. Non-409 errors, lookup failures, and no-match
3725
+ // 409s all throw (see adoptByKeyOnCreate409).
3726
+ const existing = await adoptByKeyOnCreate409({
3727
+ err,
3728
+ kind: "webhook",
3729
+ key,
3730
+ lookup: () => fetchAll((p) => client.listWebhooks(resolvedAppId, p)),
3731
+ matchKey: (w) => w?.webhookKey === key,
3732
+ });
3733
+ const updated = await client.updateWebhook(resolvedAppId, existing.webhookId, payload);
3734
+ info(` Adopted + updated webhook: ${key}`);
3735
+ if (syncState) {
3736
+ if (!syncState.entities.webhooks) {
3737
+ syncState.entities.webhooks = {};
3738
+ }
3739
+ syncState.entities.webhooks[key] = {
3740
+ id: existing.webhookId,
3741
+ modifiedAt: updated?.modifiedAt ||
3742
+ existing.modifiedAt ||
3743
+ new Date().toISOString(),
3744
+ contentHash: computeFileHash(filePath),
3745
+ };
3746
+ }
3287
3747
  }
3288
3748
  }
3289
3749
  }
@@ -3307,7 +3767,14 @@ Directory Structure:
3307
3767
  const payload = {
3308
3768
  triggerKey: key,
3309
3769
  displayName: cronTrigger.displayName || key,
3310
- description: cronTrigger.description,
3770
+ // Issue #1567: `description` is TOML-owned — send `?? null` so
3771
+ // removing it from the TOML clears it server-side (the server
3772
+ // nulls `description` on an explicit `null`). A bare
3773
+ // `cronTrigger.description` is `undefined` on absence and dropped
3774
+ // by `JSON.stringify`, so the server kept the stale value.
3775
+ // (`timezone`/`overlapPolicy`/`state` are defaulted/enum
3776
+ // server-side — `null` is not a valid clear for them.)
3777
+ description: cronTrigger.description ?? null,
3311
3778
  cron: cronTrigger.cron,
3312
3779
  timezone: cronTrigger.timezone,
3313
3780
  workflowKey: cronTrigger.workflowKey,
@@ -3355,57 +3822,38 @@ Directory Structure:
3355
3822
  }
3356
3823
  }
3357
3824
  catch (err) {
3358
- // Issue #976 (fix B): idempotent create — adopt-by-key on 409.
3359
- // A cron trigger can be orphaned on the server (created by a
3360
- // prior push that aborted before recording it in sync state,
3361
- // a mid-apply crash, or an out-of-band create with the same
3362
- // key). On retry this CREATE path then hits the
3363
- // `triggerKeyPerApp` unique constraint and the server returns
3364
- // 409 "A cron trigger with this key already exists". Rather
3365
- // than hard-fail forever, look up the existing trigger by key
3366
- // (the list endpoint is app-scoped, so every item is owned by
3367
- // this app), verify the SAME triggerKey, adopt its id into
3368
- // sync state, and re-issue as an UPDATE so the push converges.
3369
- const msg = String(err?.message || err);
3370
- const is409 = err?.statusCode === 409 || msg.includes("already exists");
3371
- if (is409) {
3372
- info(` Cron trigger already exists on server, adopting by key: ${key}`);
3373
- let adoptedId;
3374
- try {
3375
- const { items } = await client.listCronTriggers(resolvedAppId);
3376
- // Verify same key + app ownership: the list endpoint only
3377
- // returns triggers for `resolvedAppId`, so a triggerKey
3378
- // match is also an ownership match. Never overwrite an
3379
- // unrelated resource require an exact key equality.
3380
- const existing = (items || []).find((t) => t?.triggerKey === key);
3381
- if (existing?.triggerId) {
3382
- adoptedId = existing.triggerId;
3383
- }
3384
- }
3385
- catch (lookupErr) {
3386
- throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
3387
- }
3388
- if (!adoptedId) {
3389
- // 409 but no matching key found on the server — surface
3390
- // the original error rather than silently overwriting.
3391
- throw wrapEntityError(err, "create", "cron trigger", key);
3392
- }
3393
- // Switch to UPDATE on the adopted trigger.
3394
- const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
3395
- info(` Adopted + updated cron trigger: ${key}`);
3396
- if (syncState) {
3397
- if (!syncState.entities.cronTriggers) {
3398
- syncState.entities.cronTriggers = {};
3399
- }
3400
- syncState.entities.cronTriggers[key] = {
3401
- id: adoptedId,
3402
- modifiedAt: updated?.modifiedAt || new Date().toISOString(),
3403
- contentHash: computeFileHash(filePath),
3404
- };
3825
+ // Issue #976 (fix B) / #1006: idempotent create — adopt-by-key
3826
+ // on 409. A cron trigger can be orphaned on the server (a prior
3827
+ // push that aborted before recording it in sync state, a
3828
+ // mid-apply crash, or an out-of-band create with the same key).
3829
+ // On retry this CREATE hits the `triggerKeyPerApp` unique
3830
+ // constraint and the server returns 409 "already exists". The
3831
+ // shared helper looks the trigger up by key, verifies exact-key
3832
+ // (== owned, since the list is app-scoped) ownership, and hands
3833
+ // it back; we then re-issue as an UPDATE so the push converges.
3834
+ // Non-409 errors, lookup failures, and no-match 409s all throw
3835
+ // (see adoptByKeyOnCreate409). listCronTriggers drains every
3836
+ // page server-side (#1668), so its flat items list is complete.
3837
+ const existing = await adoptByKeyOnCreate409({
3838
+ err,
3839
+ kind: "cron trigger",
3840
+ key,
3841
+ lookup: async () => (await client.listCronTriggers(resolvedAppId)).items || [],
3842
+ matchKey: (t) => t?.triggerKey === key,
3843
+ });
3844
+ const adoptedId = existing.triggerId;
3845
+ // Switch to UPDATE on the adopted trigger.
3846
+ const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
3847
+ info(` Adopted + updated cron trigger: ${key}`);
3848
+ if (syncState) {
3849
+ if (!syncState.entities.cronTriggers) {
3850
+ syncState.entities.cronTriggers = {};
3405
3851
  }
3406
- }
3407
- else {
3408
- throw wrapEntityError(err, "create", "cron trigger", key);
3852
+ syncState.entities.cronTriggers[key] = {
3853
+ id: adoptedId,
3854
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
3855
+ contentHash: computeFileHash(filePath),
3856
+ };
3409
3857
  }
3410
3858
  }
3411
3859
  }
@@ -3483,92 +3931,73 @@ Directory Structure:
3483
3931
  }
3484
3932
  }
3485
3933
  catch (err) {
3486
- const msg = String(err?.message || err);
3487
- if (msg.includes("already exists") || err.statusCode === 409) {
3488
- // #1459: adopt-by-key on 409. A blob bucket can already
3489
- // exist on the server (an out-of-band create, or a prior
3490
- // push that aborted before recording it in sync state). The
3491
- // old handler stamped the LOCAL file's contentHash WITHOUT
3492
- // applying the local config, so a divergent bucket was
3493
- // silently marked in-sync and never reconciled. Mirror the
3494
- // cron/workflow adopt paths: look up the bucket by key,
3495
- // re-issue the local payload as an UPDATE, and stamp the
3496
- // hash ONLY after the update succeeds.
3497
- info(` Blob bucket already exists on server, adopting by key: ${key}`);
3498
- let existing;
3934
+ // #1006: adopt-by-key on 409 via the shared
3935
+ // `adoptByKeyOnCreate409` helper (Phase 3 blob buckets onto
3936
+ // the shared core). A blob bucket can already exist on the
3937
+ // server (an out-of-band create, or a prior push that aborted
3938
+ // before recording it in sync state). The helper detects the
3939
+ // conflict (409 or an "already exists" message), lists the
3940
+ // app's buckets, and returns the one whose `bucketKey` matches
3941
+ // exactly so an unrelated bucket is never adopted. Its three
3942
+ // non-adopt outcomes each surface a clear error and never
3943
+ // touch the server: a non-409 create error re-wraps via
3944
+ // `wrapEntityError` (no lookup); a lookup failure throws
3945
+ // "already exists but could not be adopted (lookup failed: )"
3946
+ // (no longer swallowed as the pre-#1459 code did); a 409 with
3947
+ // no matching key re-throws the ORIGINAL create error.
3948
+ //
3949
+ // Matching by `bucketKey` through `listBlobBuckets` (not
3950
+ // `getBlobBucket`, which resolves the identifier by `bucketId`
3951
+ // BEFORE `bucketKey`) also removes the id-collision hazard the
3952
+ // old inline handler had to guard against: a ULID-shaped local
3953
+ // key can no longer resolve to an unrelated bucket's id,
3954
+ // because adoption only ever matches on `bucketKey`.
3955
+ const existing = await adoptByKeyOnCreate409({
3956
+ err,
3957
+ kind: "blob bucket",
3958
+ key,
3959
+ lookup: async () => (await client.listBlobBuckets(resolvedAppId)).items || [],
3960
+ matchKey: (b) => b?.bucketKey === key,
3961
+ });
3962
+ // ttlTier is immutable and cannot be reconciled by an UPDATE.
3963
+ // A local TOML whose retention tier diverges from the server
3964
+ // must fail loudly (sponsor decision) rather than be silently
3965
+ // blessed while the cosmetic fields converge.
3966
+ if (bucket.ttlTier &&
3967
+ existing.ttlTier &&
3968
+ bucket.ttlTier !== existing.ttlTier) {
3969
+ throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
3970
+ }
3971
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
3972
+ let updated;
3973
+ if (Object.keys(updatePayload).length > 0) {
3974
+ // Surface an update failure via wrapEntityError (no stamp) —
3975
+ // matches the cron/workflow adopt paths.
3499
3976
  try {
3500
- existing = await client.getBlobBucket(resolvedAppId, key);
3501
- }
3502
- catch (lookupErr) {
3503
- // A lookup failure after a 409 means we cannot adopt —
3504
- // surface it instead of swallowing it (the old silent
3505
- // `catch {}` left no sync-state entry, looping
3506
- // create→409 on every push).
3507
- throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
3508
- }
3509
- if (!existing?.bucketId) {
3510
- // 409 but no matching bucket found — surface the original
3511
- // create error rather than silently proceeding.
3512
- throw wrapEntityError(err, "create", "blob bucket", key);
3513
- }
3514
- // Verify the resolved bucket is actually the one we asked
3515
- // for. `getBlobBucket` resolves the identifier by primary id
3516
- // (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
3517
- // a ULID-shaped string (the key regex permits it). If the
3518
- // local key collides with an unrelated bucket's bucketId,
3519
- // the lookup returns that unrelated bucket — patching and
3520
- // stamping it would overwrite the wrong resource. Require an
3521
- // exact key match, matching the cron/workflow adopt paths'
3522
- // exact-key equality (they list + filter by key for the same
3523
- // reason). Surface the mismatch via wrapEntityError; do NOT
3524
- // patch or stamp.
3525
- if (existing.bucketKey !== key) {
3526
- throw wrapEntityError(new Error(`blob bucket "${key}" already exists, but the server resolved a different bucket (bucketKey "${existing.bucketKey}", bucketId "${existing.bucketId}"); refusing to adopt. This happens when the local key collides with an unrelated bucket's id — rename the local bucket key so it does not match an existing bucket id.`), "create", "blob bucket", key);
3977
+ updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3527
3978
  }
3528
- // ttlTier is immutable and cannot be reconciled by an
3529
- // UPDATE. A local TOML whose retention tier diverges from
3530
- // the server must fail loudly (sponsor decision) rather
3531
- // than be silently blessed while the cosmetic fields
3532
- // converge.
3533
- if (bucket.ttlTier &&
3534
- existing.ttlTier &&
3535
- bucket.ttlTier !== existing.ttlTier) {
3536
- throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
3537
- }
3538
- const updatePayload = buildBlobBucketUpdatePayload(bucket);
3539
- let updated;
3540
- if (Object.keys(updatePayload).length > 0) {
3541
- // Surface an update failure via wrapEntityError (no
3542
- // stamp) — matches the cron adopt path.
3543
- try {
3544
- updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3545
- }
3546
- catch (updateErr) {
3547
- throw wrapEntityError(updateErr, "update", "blob bucket", key);
3548
- }
3549
- info(` Adopted + updated blob bucket: ${key}`);
3550
- }
3551
- else {
3552
- // No mutable fields in the local TOML — the server
3553
- // rejects an empty update (400). Adopt the existing
3554
- // bucket without a PATCH.
3555
- info(` Adopted blob bucket (no updatable fields): ${key}`);
3556
- }
3557
- if (syncState) {
3558
- if (!syncState.entities.blobBuckets) {
3559
- syncState.entities.blobBuckets = {};
3560
- }
3561
- syncState.entities.blobBuckets[key] = {
3562
- id: existing.bucketId,
3563
- modifiedAt: updated?.modifiedAt ||
3564
- existing.modifiedAt ||
3565
- new Date().toISOString(),
3566
- contentHash: computeFileHash(filePath),
3567
- };
3979
+ catch (updateErr) {
3980
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
3568
3981
  }
3982
+ info(` Adopted + updated blob bucket: ${key}`);
3569
3983
  }
3570
3984
  else {
3571
- throw wrapEntityError(err, "create", "blob bucket", key);
3985
+ // No mutable fields in the local TOML — the server rejects
3986
+ // an empty update (400). Adopt the existing bucket without a
3987
+ // PATCH.
3988
+ info(` Adopted blob bucket (no updatable fields): ${key}`);
3989
+ }
3990
+ if (syncState) {
3991
+ if (!syncState.entities.blobBuckets) {
3992
+ syncState.entities.blobBuckets = {};
3993
+ }
3994
+ syncState.entities.blobBuckets[key] = {
3995
+ id: existing.bucketId,
3996
+ modifiedAt: updated?.modifiedAt ||
3997
+ existing.modifiedAt ||
3998
+ new Date().toISOString(),
3999
+ contentHash: computeFileHash(filePath),
4000
+ };
3572
4001
  }
3573
4002
  }
3574
4003
  }
@@ -3602,6 +4031,88 @@ Directory Structure:
3602
4031
  }
3603
4032
  continue;
3604
4033
  }
4034
+ // Shared update body for both the in-manifest update branch and the
4035
+ // adopt-by-key recovery branch (#1006). Re-issues the prompt as an
4036
+ // UPDATE and syncs its configs (update existing by name, create
4037
+ // missing) so the post-create prompt-config creation still runs
4038
+ // after adoption. Both call sites MUST run it identically, so it
4039
+ // lives here as a single closure rather than being duplicated
4040
+ // (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
4041
+ // sync-state entity record before calling this so the stamp lands.
4042
+ const applyPromptUpdate = async (promptId, expectedModifiedAt) => {
4043
+ const updated = await client.updatePrompt(resolvedAppId, promptId, {
4044
+ displayName: prompt.displayName,
4045
+ // Issue #1567: `description` and `inputSchema` are TOML-owned
4046
+ // — send `?? null` so removing either from the TOML clears it
4047
+ // server-side (the server nulls both on an explicit `null`).
4048
+ // Bare values are `undefined` on absence and dropped by
4049
+ // `JSON.stringify`, so the server kept the stale values.
4050
+ // (`status` is a defaulted enum — not clearable via `null`.)
4051
+ description: prompt.description ?? null,
4052
+ inputSchema: prompt.inputSchema ?? null,
4053
+ status: prompt.status,
4054
+ }, expectedModifiedAt);
4055
+ info(` Updated prompt: ${key}`);
4056
+ // Update sync state with new modifiedAt
4057
+ if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
4058
+ syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
4059
+ syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
4060
+ }
4061
+ // Fetch full prompt to get config name→ID mappings
4062
+ // (updatePrompt response doesn't include configs)
4063
+ const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
4064
+ if (fullPrompt?.configs) {
4065
+ for (const config of fullPrompt.configs) {
4066
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
4067
+ }
4068
+ }
4069
+ // Update existing prompt configs with TOML values
4070
+ if (fullPrompt?.configs && configs.length > 0) {
4071
+ for (const tomlConfig of configs) {
4072
+ const configName = tomlConfig.name;
4073
+ if (!configName)
4074
+ continue;
4075
+ const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
4076
+ if (serverConfig) {
4077
+ // Update existing config
4078
+ await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, {
4079
+ // Issue #1567: `description` is TOML-owned — send
4080
+ // `?? null` so removing it from a `[[config]]` block
4081
+ // clears it on the server (the PATCH nulls `description`
4082
+ // on an explicit `null`, preserves on omit). A bare
4083
+ // `tomlConfig.description` is `undefined` on absence and
4084
+ // dropped by JSON.stringify, so the stale value persists.
4085
+ description: tomlConfig.description ?? null,
4086
+ provider: tomlConfig.provider,
4087
+ model: tomlConfig.model,
4088
+ systemPrompt: tomlConfig.systemPrompt,
4089
+ userPromptTemplate: tomlConfig.userPromptTemplate,
4090
+ temperature: tomlConfig.temperature,
4091
+ maxTokens: tomlConfig.maxTokens,
4092
+ outputFormat: tomlConfig.outputFormat,
4093
+ });
4094
+ }
4095
+ else {
4096
+ // Create new config that doesn't exist on server yet
4097
+ const newConfig = await client.createPromptConfig(resolvedAppId, promptId, {
4098
+ configName,
4099
+ description: tomlConfig.description,
4100
+ provider: tomlConfig.provider || "openrouter",
4101
+ model: tomlConfig.model || "google/gemini-2.0-flash-001",
4102
+ systemPrompt: tomlConfig.systemPrompt,
4103
+ userPromptTemplate: tomlConfig.userPromptTemplate,
4104
+ temperature: tomlConfig.temperature,
4105
+ maxTokens: tomlConfig.maxTokens,
4106
+ outputFormat: tomlConfig.outputFormat,
4107
+ });
4108
+ if (newConfig?.configId) {
4109
+ promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
4110
+ }
4111
+ }
4112
+ }
4113
+ info(` Synced ${configs.length} config(s) for prompt: ${key}`);
4114
+ }
4115
+ };
3605
4116
  if (existingId) {
3606
4117
  // Update existing prompt
3607
4118
  changes.push({ type: "prompt", action: "update", key });
@@ -3611,66 +4122,7 @@ Directory Structure:
3611
4122
  ? undefined
3612
4123
  : syncState?.entities?.prompts?.[key]?.modifiedAt;
3613
4124
  try {
3614
- const updated = await client.updatePrompt(resolvedAppId, existingId, {
3615
- displayName: prompt.displayName,
3616
- description: prompt.description,
3617
- inputSchema: prompt.inputSchema,
3618
- status: prompt.status,
3619
- }, expectedModifiedAt);
3620
- info(` Updated prompt: ${key}`);
3621
- // Update sync state with new modifiedAt
3622
- if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
3623
- syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
3624
- syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
3625
- }
3626
- // Fetch full prompt to get config name→ID mappings
3627
- // (updatePrompt response doesn't include configs)
3628
- const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
3629
- if (fullPrompt?.configs) {
3630
- for (const config of fullPrompt.configs) {
3631
- promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
3632
- }
3633
- }
3634
- // Update existing prompt configs with TOML values
3635
- if (fullPrompt?.configs && configs.length > 0) {
3636
- for (const tomlConfig of configs) {
3637
- const configName = tomlConfig.name;
3638
- if (!configName)
3639
- continue;
3640
- const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
3641
- if (serverConfig) {
3642
- // Update existing config
3643
- await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
3644
- description: tomlConfig.description,
3645
- provider: tomlConfig.provider,
3646
- model: tomlConfig.model,
3647
- systemPrompt: tomlConfig.systemPrompt,
3648
- userPromptTemplate: tomlConfig.userPromptTemplate,
3649
- temperature: tomlConfig.temperature,
3650
- maxTokens: tomlConfig.maxTokens,
3651
- outputFormat: tomlConfig.outputFormat,
3652
- });
3653
- }
3654
- else {
3655
- // Create new config that doesn't exist on server yet
3656
- const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
3657
- configName,
3658
- description: tomlConfig.description,
3659
- provider: tomlConfig.provider || "openrouter",
3660
- model: tomlConfig.model || "google/gemini-2.0-flash-001",
3661
- systemPrompt: tomlConfig.systemPrompt,
3662
- userPromptTemplate: tomlConfig.userPromptTemplate,
3663
- temperature: tomlConfig.temperature,
3664
- maxTokens: tomlConfig.maxTokens,
3665
- outputFormat: tomlConfig.outputFormat,
3666
- });
3667
- if (newConfig?.configId) {
3668
- promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
3669
- }
3670
- }
3671
- }
3672
- info(` Synced ${configs.length} config(s) for prompt: ${key}`);
3673
- }
4125
+ await applyPromptUpdate(existingId, expectedModifiedAt);
3674
4126
  }
3675
4127
  catch (err) {
3676
4128
  if (err instanceof ConflictError) {
@@ -3692,8 +4144,19 @@ Directory Structure:
3692
4144
  const firstConfig = configs[0] || {};
3693
4145
  changes.push({ type: "prompt", action: "create", key });
3694
4146
  if (!options.dryRun) {
4147
+ // The adopt-by-key recovery must cover ONLY the createPrompt
4148
+ // call. If createPrompt succeeds and a SUBSEQUENT step (an
4149
+ // extra prompt-config create, or an activation) returns a
4150
+ // 409/"must be unique" — e.g. duplicate `[[configs]]` names —
4151
+ // that is a genuine invalid-config error, NOT a prompt-create
4152
+ // conflict. Adopting on it would find the just-created prompt,
4153
+ // run the update path, and stamp the file hash, swallowing the
4154
+ // real error. So the post-create config work runs OUTSIDE the
4155
+ // catch and its errors surface unchanged.
4156
+ let created = null;
4157
+ let adopted = false;
3695
4158
  try {
3696
- const created = await client.createPrompt(resolvedAppId, {
4159
+ created = await client.createPrompt(resolvedAppId, {
3697
4160
  promptKey: key,
3698
4161
  displayName: prompt.displayName || key,
3699
4162
  description: prompt.description,
@@ -3707,17 +4170,73 @@ Directory Structure:
3707
4170
  inputSchema: prompt.inputSchema,
3708
4171
  });
3709
4172
  info(` Created prompt: ${key}`);
3710
- // Add new entity to sync state
3711
- if (syncState && created?.promptId && created?.modifiedAt) {
4173
+ }
4174
+ catch (err) {
4175
+ // #1006: idempotent create — adopt-by-key on 409. A prompt
4176
+ // orphaned on the server (a prior push aborted before
4177
+ // recording it, or an out-of-band create) hits the
4178
+ // `promptKeyPerApp` unique constraint on retry. Adopt by
4179
+ // `promptKey` and re-issue as an UPDATE via the shared
4180
+ // `applyPromptUpdate` so the base fields AND the configs
4181
+ // (the post-create prompt-config creation) still land after
4182
+ // adoption. `listPrompts` is cursor-paginated → drain fully
4183
+ // with fetchAll. Non-409 errors, lookup failures, and no-match
4184
+ // 409s all throw (see adoptByKeyOnCreate409).
4185
+ const existing = await adoptByKeyOnCreate409({
4186
+ err,
4187
+ kind: "prompt",
4188
+ key,
4189
+ lookup: () => fetchAll((p) => client.listPrompts(resolvedAppId, p)),
4190
+ matchKey: (p) => p?.promptKey === key,
4191
+ });
4192
+ adopted = true;
4193
+ const adoptedId = existing.promptId;
4194
+ // Seed the sync-state entity record so the shared update body
4195
+ // can stamp modifiedAt/contentHash into it (sync state has no
4196
+ // entry for an orphaned prompt), and track the key→ID map.
4197
+ if (syncState) {
3712
4198
  if (!syncState.entities.prompts) {
3713
4199
  syncState.entities.prompts = {};
3714
4200
  }
3715
- syncState.entities.prompts[key] = {
3716
- id: created.promptId,
3717
- modifiedAt: created.modifiedAt,
3718
- contentHash: computeFileHash(filePath),
3719
- };
4201
+ if (!syncState.entities.prompts[key]) {
4202
+ syncState.entities.prompts[key] = { id: adoptedId };
4203
+ }
4204
+ else {
4205
+ syncState.entities.prompts[key].id = adoptedId;
4206
+ }
3720
4207
  }
4208
+ promptKeyToId.set(key, adoptedId);
4209
+ // First adopt has no stored modifiedAt → omit
4210
+ // expectedModifiedAt to force the update (matches the cron +
4211
+ // workflow adopt paths). A ConflictError here is still
4212
+ // adoptable-resilience scope, so collect it; other errors
4213
+ // surface. `applyPromptUpdate` reconciles ALL configs on the
4214
+ // adopt path, so the create-path extra-config loop below is
4215
+ // intentionally skipped when `adopted`.
4216
+ try {
4217
+ await applyPromptUpdate(adoptedId, undefined);
4218
+ info(` Adopted + updated prompt: ${key}`);
4219
+ }
4220
+ catch (updErr) {
4221
+ if (updErr instanceof ConflictError) {
4222
+ conflicts.push({
4223
+ type: "prompt",
4224
+ key,
4225
+ serverModifiedAt: updErr.serverModifiedAt,
4226
+ localModifiedAt: "unknown",
4227
+ });
4228
+ }
4229
+ else {
4230
+ throw wrapEntityError(updErr, "update", "prompt", key);
4231
+ }
4232
+ }
4233
+ }
4234
+ // Post-create config work — runs ONLY for a real create, never
4235
+ // for an adopt. A 409/"must be unique" from an extra config
4236
+ // create or activation here is NOT wrapped by the adopt
4237
+ // recovery above, so a genuine invalid-config error surfaces
4238
+ // unchanged instead of being mis-treated as a prompt conflict.
4239
+ if (!adopted && created) {
3721
4240
  // Track prompt key→ID and config name→ID
3722
4241
  if (created?.promptId) {
3723
4242
  promptKeyToId.set(key, created.promptId);
@@ -3753,9 +4272,22 @@ Directory Structure:
3753
4272
  }
3754
4273
  info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
3755
4274
  }
3756
- }
3757
- catch (err) {
3758
- throw wrapEntityError(err, "create", "prompt", key);
4275
+ // Stamp sync state ONLY after all create work (base prompt +
4276
+ // every extra config) succeeded. If an extra-config create
4277
+ // above threw, we never reach here, so the file hash is not
4278
+ // recorded and the save-on-failure path leaves the entity
4279
+ // unstamped — a future push retries instead of skipping the
4280
+ // unresolved divergence.
4281
+ if (syncState && created?.promptId && created?.modifiedAt) {
4282
+ if (!syncState.entities.prompts) {
4283
+ syncState.entities.prompts = {};
4284
+ }
4285
+ syncState.entities.prompts[key] = {
4286
+ id: created.promptId,
4287
+ modifiedAt: created.modifiedAt,
4288
+ contentHash: computeFileHash(filePath),
4289
+ };
4290
+ }
3759
4291
  }
3760
4292
  }
3761
4293
  }
@@ -3900,6 +4432,19 @@ Directory Structure:
3900
4432
  for (const file of files) {
3901
4433
  const filePath = join(workflowsDir, file);
3902
4434
  const tomlData = parseTomlFile(filePath);
4435
+ // #1660 — skip platform-owned workflows (the reserved `__internal.*`
4436
+ // namespace). Resolve the key FIRST and skip BEFORE the workflow
4437
+ // TOML validation below, so a hollow exported `__internal.*.toml`
4438
+ // (steps = [], no active config) is skipped, not failed by
4439
+ // `validateWorkflowToml`, and does not abort the whole push trying
4440
+ // to activate a configuration-less workflow. Match on the resolved
4441
+ // `[workflow].key` (not filename) so a renamed file is still caught
4442
+ // (edge case 2).
4443
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
4444
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
4445
+ info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
4446
+ continue;
4447
+ }
3903
4448
  // Issue #685: reject misnested headers (e.g.
3904
4449
  // [steps.<id>.request]) before pushing. The runtime silently
3905
4450
  // ignores fields outside the allowlist, so this is the only
@@ -3931,13 +4476,39 @@ Directory Structure:
3931
4476
  throw new Error(`${filePath}: ${workflowManifestError}`);
3932
4477
  }
3933
4478
  workflow.metadataManifest = workflowManifest;
4479
+ // #1614 — parse the top-level `[expr.cel]` named-guard definitions
4480
+ // (structural, like steps/metadataManifest) and validate them
4481
+ // client-side (parse / undeclared / cycle) before pushing, so a bad
4482
+ // definition fails fast with the same diagnostics the server returns.
4483
+ const workflowExpressions = parseWorkflowExpressionsToml(tomlData);
4484
+ // #1614 — validate the RAW `[expr.cel]` table shape first, so a
4485
+ // non-string body (e.g. `ready = true`) fails fast instead of being
4486
+ // silently dropped by parseWorkflowExpressionsToml.
4487
+ const exprErrors = [
4488
+ ...validateWorkflowExpressionsRawToml(tomlData),
4489
+ ...validateWorkflowExpressionsToml(steps, workflowExpressions),
4490
+ ];
4491
+ if (exprErrors.length > 0) {
4492
+ throw new Error(`${filePath}: workflow expressions are invalid:\n - ${exprErrors.join("\n - ")}`);
4493
+ }
4494
+ workflow.expressions = workflowExpressions;
3934
4495
  const existingId = syncState?.entities?.workflows?.[key]?.id;
3935
4496
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
3936
4497
  // Skip if file hasn't changed since last sync. Use the expanded
3937
4498
  // content hash (post fragment splice) so that edits to included
3938
4499
  // `workflow-fragments/*.toml` files invalidate the push-skip cache
3939
4500
  // for any workflow that references them. See `computeExpandedContentHash`.
3940
- if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
4501
+ //
4502
+ // #1684: hash a FRESH `parseTomlFile(filePath)` here, not the
4503
+ // in-scope `tomlData`. The `metadataManifest` injection above
4504
+ // (#1304) mutated `tomlData.workflow`, so hashing `tomlData` would
4505
+ // include the injected `metadataManifest` key and never match the
4506
+ // stored hash — the workflow would re-push on every `sync push`.
4507
+ // Pull stores the hash of the pristine parse
4508
+ // (`computeExpandedContentHash(parseTomlFile(filePath))`, see the
4509
+ // pull path), so re-parsing the same file keeps push symmetric with
4510
+ // pull and lets the content-hash round-trip.
4511
+ if (!options.force && existingId && !shouldPushExpandedFile(parseTomlFile(filePath), syncState?.entities?.workflows?.[key]?.contentHash)) {
3941
4512
  skippedCount++;
3942
4513
  // Only fetch config name→ID mappings if test cases exist for this workflow
3943
4514
  const workflowTestsDir = getTestsDir(configDir, "workflow", key);
@@ -3980,7 +4551,15 @@ Directory Structure:
3980
4551
  // the entry exists for both call sites.
3981
4552
  if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
3982
4553
  syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
3983
- syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
4554
+ // #1684: store the hash of the pristine `parseTomlFile(filePath)`,
4555
+ // not the in-scope `tomlData`. The `metadataManifest` injection
4556
+ // (#1304) mutated `tomlData.workflow`, so hashing `tomlData` here
4557
+ // would persist the mutated hash while the push-skip check (above)
4558
+ // and pull-store (:2502) both hash the pristine parse — the
4559
+ // workflow would then re-push on every subsequent `sync push`
4560
+ // until the next `sync pull`. Re-parsing keeps store symmetric
4561
+ // with skip and pull so the content-hash round-trips.
4562
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(parseTomlFile(filePath));
3984
4563
  }
3985
4564
  // `applyWorkflowBody` already fetched the full workflow; reuse it
3986
4565
  // for the config name→ID mappings (the PATCH response omits configs).
@@ -4039,6 +4618,8 @@ Directory Structure:
4039
4618
  // #1304 (P-C): declared-access manifest (absent → undefined,
4040
4619
  // server default null).
4041
4620
  metadataManifest: workflow.metadataManifest ?? undefined,
4621
+ // #1614 — named `expr.*` definitions (absent → undefined).
4622
+ expressions: workflow.expressions ?? undefined,
4042
4623
  });
4043
4624
  info(` Created workflow: ${key}`);
4044
4625
  // Add new entity to sync state (including activeConfigId)
@@ -4050,7 +4631,11 @@ Directory Structure:
4050
4631
  id: created.workflow.workflowId,
4051
4632
  modifiedAt: created.workflow.modifiedAt,
4052
4633
  activeConfigId: created.workflow.activeConfigId,
4053
- contentHash: computeExpandedContentHash(tomlData),
4634
+ // #1684: hash the pristine `parseTomlFile(filePath)`, not the
4635
+ // `metadataManifest`-mutated `tomlData` (see the update-store
4636
+ // note above), so a just-created workflow is skipped on the
4637
+ // next `sync push` instead of re-pushing every time.
4638
+ contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
4054
4639
  };
4055
4640
  }
4056
4641
  // Track config name→ID mappings
@@ -4069,6 +4654,11 @@ Directory Structure:
4069
4654
  configName: extraConfig.name || `config-${i + 1}`,
4070
4655
  description: extraConfig.description,
4071
4656
  steps,
4657
+ // #1614 — the extra configs reuse the same
4658
+ // guard-referencing `steps`, so forward the workflow's
4659
+ // named `expr.*` definitions too; otherwise the server
4660
+ // 400s on the undeclared `expr.<name>` mid-push.
4661
+ expressions: workflow.expressions ?? undefined,
4072
4662
  });
4073
4663
  if (extraCreated?.configId) {
4074
4664
  const configName = extraConfig.name || `config-${i + 1}`;
@@ -4087,49 +4677,31 @@ Directory Structure:
4087
4677
  }
4088
4678
  }
4089
4679
  catch (err) {
4090
- // Issue #1174: idempotent create — adopt-by-key on conflict.
4091
- // A workflow can exist on the server but be absent from local
4680
+ // Issue #1174 / #1006: idempotent create — adopt-by-key on
4681
+ // conflict, via the shared `adoptByKeyOnCreate409` helper. A
4682
+ // workflow can exist on the server but be absent from local
4092
4683
  // sync state — e.g. created out-of-band via the admin API
4093
4684
  // (#971), or orphaned by a prior push that aborted before
4094
4685
  // recording it. On retry this CREATE path hits the
4095
- // `workflowKeyPerApp` unique constraint and the server
4096
- // returns "workflowKey already exists" (HTTP 400; cron uses
4097
- // 409). Rather than abort the whole push, look up the
4098
- // existing workflow by key (the list endpoint is app-scoped,
4099
- // so every item is owned by this app), verify the SAME key,
4100
- // adopt its id into sync state, and re-issue as an UPDATE so
4101
- // the push converges. Mirrors the cron-trigger recovery at
4102
- // ~sync.ts:2627. Unexpected errors still surface via
4103
- // `wrapEntityError`, matching the db-type precedent.
4104
- const msg = String(err?.message || err);
4105
- const isConflict = err?.statusCode === 409 || msg.includes("already exists");
4106
- if (!isConflict) {
4107
- throw wrapEntityError(err, "create", "workflow", key);
4108
- }
4109
- info(` Workflow already exists on server, adopting by key: ${key}`);
4110
- let adoptedId;
4111
- try {
4112
- // Use fetchAll — listWorkflows is paginated, so a single
4113
- // page could miss the by-key match on a large app.
4114
- const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
4115
- // Verify same key + app ownership: the list endpoint only
4116
- // returns workflows for `resolvedAppId`, so a workflowKey
4117
- // match is also an ownership match. Never overwrite an
4118
- // unrelated resource — require exact key equality.
4119
- const existing = (items || []).find((w) => w?.workflowKey === key);
4120
- if (existing?.workflowId) {
4121
- adoptedId = existing.workflowId;
4122
- }
4123
- }
4124
- catch (lookupErr) {
4125
- throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
4126
- }
4127
- if (!adoptedId) {
4128
- // "already exists" but no matching key found on the server —
4129
- // surface the original error rather than silently
4130
- // overwriting an unrelated workflow.
4131
- throw wrapEntityError(err, "create", "workflow", key);
4132
- }
4686
+ // `workflowKeyPerApp` unique constraint and the server returns
4687
+ // "workflowKey already exists" (HTTP 400; cron uses 409).
4688
+ // Rather than abort the whole push, look up the existing
4689
+ // workflow by key (the list endpoint is app-scoped, so a
4690
+ // workflowKey match is also an ownership match never
4691
+ // overwrite an unrelated resource), adopt its id into sync
4692
+ // state, and re-issue as an UPDATE so the push converges.
4693
+ // `listWorkflows` is paginated drain fully with fetchAll.
4694
+ // Non-409 errors, lookup failures, and no-match 409s all throw
4695
+ // (see adoptByKeyOnCreate409). Mirrors the cron-trigger
4696
+ // recovery at ~sync.ts:2627.
4697
+ const existing = await adoptByKeyOnCreate409({
4698
+ err,
4699
+ kind: "workflow",
4700
+ key,
4701
+ lookup: () => fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
4702
+ matchKey: (w) => w?.workflowKey === key,
4703
+ });
4704
+ const adoptedId = existing.workflowId;
4133
4705
  // Out-of-manifest adopt: warn (info-level), matching the cron
4134
4706
  // adopt line, and seed the sync-state entity record so the
4135
4707
  // shared update body can write modifiedAt/contentHash into it.
@@ -4288,30 +4860,19 @@ Directory Structure:
4288
4860
  // Build the type-level `updateData` (same logic as the apply path
4289
4861
  // below) so the validate pass and the apply pass agree on whether
4290
4862
  // a schema PATCH would run and what it carries.
4863
+ //
4864
+ // Issue #1567: the owned scalar fields are now ALWAYS sent as
4865
+ // value-or-`null` (via `buildOwnedScalarUpdate`) rather than only
4866
+ // when the TOML declares them. The server preserves a field only
4867
+ // when the key is absent from the PATCH body, so the previous
4868
+ // `if ("x" in typeConfig)` guards meant a field removed from the
4869
+ // TOML silently kept its stale server value. Always-send converges
4870
+ // the server to the local file (config-as-code): a removed field
4871
+ // lands as `null` and is cleared. `schema` keeps its own
4872
+ // `hasSchema` prior-state discriminator (it is a sub-tree, not a
4873
+ // scalar).
4291
4874
  const computeTypeUpdateData = () => {
4292
- const u = {};
4293
- if ("ruleSetId" in typeConfig)
4294
- u.ruleSetId = typeConfig.ruleSetId || null;
4295
- if ("triggers" in typeConfig)
4296
- u.triggers = typeConfig.triggers || null;
4297
- if ("metadataAccess" in typeConfig)
4298
- u.metadataAccess = typeConfig.metadataAccess || null;
4299
- if ("defaultAccess" in typeConfig)
4300
- u.defaultAccess = typeConfig.defaultAccess || null;
4301
- if ("autoPopulatedFields" in typeConfig) {
4302
- u.autoPopulatedFields = typeConfig.autoPopulatedFields || null;
4303
- }
4304
- if ("timestamps" in typeConfig) {
4305
- u.timestamps = typeConfig.timestamps || null;
4306
- }
4307
- // Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
4308
- // only sets `metadataManifest` when the TOML declares a `[metadata]`
4309
- // / `secrets` block, so this forwards a set/update; clearing a
4310
- // manifest by removing the block is a follow-on (mirrors the
4311
- // schema-deletion tracking).
4312
- if ("metadataManifest" in typeConfig) {
4313
- u.metadataManifest = typeConfig.metadataManifest || null;
4314
- }
4875
+ const u = buildOwnedScalarUpdate(typeConfig);
4315
4876
  const localHasSchema = typeof typeConfig.schema === "string" &&
4316
4877
  typeConfig.schema.trim().length > 0;
4317
4878
  if (localHasSchema) {
@@ -4322,6 +4883,43 @@ Directory Structure:
4322
4883
  }
4323
4884
  return u;
4324
4885
  };
4886
+ // Issue #1567: fetch the live server config once per changed
4887
+ // existing type. Sync state stores no scalar VALUES (only hashes /
4888
+ // modifiedAt), so a genuine non-null → null clear can only be
4889
+ // detected against live state. We use it for two things: (a) report
4890
+ // "Cleared <field>" only on real transitions (never for every
4891
+ // `null` sent — that noise would bury the access-gate line), and
4892
+ // (b) decide whether a push whose only type-level effect is a clear
4893
+ // still needs the type-config PATCH. A fresh type has nothing to
4894
+ // clear; a failed fetch degrades to "report nothing cleared".
4895
+ let serverTypeConfig = null;
4896
+ if (existingEntry) {
4897
+ try {
4898
+ serverTypeConfig = await client.getDatabaseTypeConfig(resolvedAppId, dbType);
4899
+ }
4900
+ catch {
4901
+ serverTypeConfig = null;
4902
+ }
4903
+ }
4904
+ // The owned scalars this push clears (non-null server → null local).
4905
+ const clearedOwnedFields = ownedScalarsBeingCleared(buildOwnedScalarUpdate(typeConfig), serverTypeConfig);
4906
+ // Does this push change any TYPE-LEVEL field? True when the TOML
4907
+ // declares an owned scalar (matches the pre-#1567 PATCH trigger), a
4908
+ // schema set/delete is in the payload, or an owned scalar is being
4909
+ // cleared. When false, the type-config PATCH is skipped entirely so
4910
+ // an operations-only edit does not spuriously report the type as
4911
+ // "Updated" (issue #369 regression) and no idle clear-report fires.
4912
+ const declaresOwnedField = DB_TYPE_OWNED_SCALARS.some((k) => k in typeConfig);
4913
+ const schemaInUpdateData = "schema" in computeTypeUpdateData();
4914
+ const typeLevelChange = declaresOwnedField ||
4915
+ schemaInUpdateData ||
4916
+ clearedOwnedFields.length > 0;
4917
+ // Emit one "Cleared <field> on <type>" line per genuine clear.
4918
+ const reportClearedFields = (prefix) => {
4919
+ for (const key of clearedOwnedFields) {
4920
+ info(` ${prefix}${dbTypeFieldLabel(key)} on ${dbType}`);
4921
+ }
4922
+ };
4325
4923
  // Validate-first pass (decision 4a): run every gate for this type
4326
4924
  // BEFORE issuing any mutating call. If a gate fails, the whole
4327
4925
  // type's mutating calls (op creates/updates/deletes AND the schema
@@ -4341,11 +4939,16 @@ Directory Structure:
4341
4939
  try {
4342
4940
  // 1. Schema-edit gate (type-config PATCH dry-run) — only when the
4343
4941
  // type already exists on the server and we'd actually PATCH a
4344
- // schema-relevant field. A fresh type (no existingEntry) has no
4942
+ // type-level field. A fresh type (no existingEntry) has no
4345
4943
  // ops to break, so there's nothing to gate yet.
4346
- if (existingEntry) {
4944
+ // Issue #1567: `computeTypeUpdateData` now always returns the
4945
+ // owned scalars, so gate on `typeLevelChange` (a genuine
4946
+ // declared/schema/clear change) rather than "payload
4947
+ // non-empty" — an operations-only edit must not run the
4948
+ // type-config dry-run at all.
4949
+ if (existingEntry && typeLevelChange) {
4347
4950
  const validateUpdateData = computeTypeUpdateData();
4348
- if (Object.keys(validateUpdateData).length > 0) {
4951
+ {
4349
4952
  await client.updateDatabaseTypeConfig(resolvedAppId, dbType, pendingOpDeletes.length > 0
4350
4953
  ? { ...validateUpdateData, pendingOpDeletes, finalOpNames }
4351
4954
  : validateUpdateData, validateExpectedModifiedAt, {
@@ -4548,15 +5151,11 @@ Directory Structure:
4548
5151
  // labels (so dry-run still shows what WOULD change) but skip ALL
4549
5152
  // mutating calls for this type.
4550
5153
  if (typeGateBlocked) {
4551
- const wouldUpdate = "ruleSetId" in typeConfig ||
4552
- "triggers" in typeConfig ||
4553
- "metadataAccess" in typeConfig ||
4554
- "defaultAccess" in typeConfig ||
4555
- "autoPopulatedFields" in typeConfig ||
4556
- "timestamps" in typeConfig ||
4557
- "schema" in typeConfig;
4558
5154
  if (existingEntry) {
4559
- if (wouldUpdate) {
5155
+ // Issue #1567: a genuine type-level change (declared field,
5156
+ // schema set/delete, or an owned-scalar clear) — same predicate
5157
+ // the apply path gates the PATCH on.
5158
+ if (typeLevelChange) {
4560
5159
  changes.push({ type: "database-type", action: "update", key: dbType });
4561
5160
  }
4562
5161
  }
@@ -4577,10 +5176,15 @@ Directory Structure:
4577
5176
  continue;
4578
5177
  }
4579
5178
  if (existingEntry) {
4580
- // Update existing type config — only if there are type-level fields to update.
4581
- // Operations are handled separately below, so skipping the PATCH here when
4582
- // updateData is empty avoids sending an empty body (which the server rejects
4583
- // with HTTP 400).
5179
+ // Update existing type config — only when there is a genuine
5180
+ // type-level change. Operations are handled separately below.
5181
+ // Issue #369: skipping the PATCH on an operations-only edit avoids
5182
+ // sending an empty body (which the server rejects with HTTP 400)
5183
+ // and a spurious "Updated database type". Issue #1567: the owned
5184
+ // scalars are always in `updateData` now (value-or-`null`), so the
5185
+ // skip decision moved from "payload non-empty" to the
5186
+ // `typeLevelChange` predicate (a declared field, a schema
5187
+ // set/delete, or an owned-scalar clear).
4584
5188
  if (!options.dryRun) {
4585
5189
  const expectedModifiedAt = options.force
4586
5190
  ? undefined
@@ -4590,11 +5194,8 @@ Directory Structure:
4590
5194
  // one (a set/update), OR when the server had one at last
4591
5195
  // sync and the local file no longer does (a deletion —
4592
5196
  // `schema: null` clears it server-side; codex review gap on
4593
- // PR #766). When the type never had a schema and still
4594
- // doesn't, omit it so an operations-only edit doesn't
4595
- // register as an empty type-level update (issue #369). Built
4596
- // by the shared helper so the validate pass and apply pass
4597
- // agree (issue #813).
5197
+ // PR #766). Built by the shared helper so the validate pass
5198
+ // and apply pass agree (issue #813).
4598
5199
  const updateData = computeTypeUpdateData();
4599
5200
  // Issue #813 (2B): forward the ops being deleted in this push
4600
5201
  // so the schema-edit / OPS_EXIST gates evaluate against the
@@ -4608,13 +5209,17 @@ Directory Structure:
4608
5209
  // exclusion (see derivation above).
4609
5210
  updateData.finalOpNames = finalOpNames;
4610
5211
  }
4611
- if (Object.keys(updateData).filter((k) => k !== "pendingOpDeletes" && k !== "finalOpNames").length > 0) {
5212
+ if (typeLevelChange) {
4612
5213
  changes.push({ type: "database-type", action: "update", key: dbType });
4613
5214
  const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
4614
5215
  dryRun: false,
4615
5216
  acceptWarnings: !!options.acceptWarnings,
4616
5217
  });
4617
5218
  info(` Updated database type: ${dbType}`);
5219
+ // Issue #1567: name each owned scalar this push cleared, so
5220
+ // removing an access gate (celContextAccess / defaultAccess)
5221
+ // is never silent.
5222
+ reportClearedFields("Cleared ");
4618
5223
  printStaleInstanceWarning(updated);
4619
5224
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
4620
5225
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
@@ -4674,17 +5279,12 @@ Directory Structure:
4674
5279
  }
4675
5280
  }
4676
5281
  else {
4677
- // In dry-run mode, still report the change iff we would actually PATCH.
4678
- const wouldUpdate = "ruleSetId" in typeConfig ||
4679
- "triggers" in typeConfig ||
4680
- "metadataAccess" in typeConfig ||
4681
- "defaultAccess" in typeConfig ||
4682
- "autoPopulatedFields" in typeConfig ||
4683
- "timestamps" in typeConfig ||
4684
- "schema" in typeConfig ||
4685
- "metadataManifest" in typeConfig;
4686
- if (wouldUpdate) {
5282
+ // In dry-run mode, still report the change iff we would
5283
+ // actually PATCH (issue #1567: the same `typeLevelChange`
5284
+ // predicate the apply path uses), and preview the clears.
5285
+ if (typeLevelChange) {
4687
5286
  changes.push({ type: "database-type", action: "update", key: dbType });
5287
+ reportClearedFields("Would clear ");
4688
5288
  }
4689
5289
  }
4690
5290
  }
@@ -5172,7 +5772,15 @@ Directory Structure:
5172
5772
  : existingEntry.modifiedAt;
5173
5773
  try {
5174
5774
  const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
5175
- ruleSetId: configData.ruleSetId,
5775
+ // Issue #1567: send `?? null`, not a bare
5776
+ // `configData.ruleSetId`. When the TOML drops the rule-set
5777
+ // reference the parse leaves `ruleSetId` undefined, which
5778
+ // `JSON.stringify` omits from the body — so the server saw
5779
+ // no key and preserved the stale rule set. `null` sends an
5780
+ // explicit clear (server: `"ruleSetId" in body` → null).
5781
+ ruleSetId: configData.ruleSetId ?? null,
5782
+ // `autoAddCreator` already converges: the parse defaults it
5783
+ // to `true` when absent, so it is always sent explicitly.
5176
5784
  autoAddCreator: configData.autoAddCreator,
5177
5785
  // Issue #1350: null clears the manifest server-side when
5178
5786
  // the TOML drops the `[metadata]` block (matches the
@@ -5225,7 +5833,53 @@ Directory Structure:
5225
5833
  }
5226
5834
  }
5227
5835
  catch (err) {
5228
- throw wrapEntityError(err, "create", "group type config", groupType);
5836
+ // #1851: idempotent create adopt-by-key on 409, matching the
5837
+ // seven other synced types (rule sets, integrations, webhooks,
5838
+ // cron triggers, blob buckets, prompts, workflows). A group
5839
+ // type config can already exist on the server (created
5840
+ // out-of-band via the console or an ad-hoc CLI call, or a prior
5841
+ // push that aborted before recording it in sync state). Without
5842
+ // this, that CREATE re-hits the server's 409 "Config for this
5843
+ // group type already exists"
5844
+ // (group-type-config-controller.ts) on every subsequent push
5845
+ // and the operator has to hand-edit `.primitive-sync.json` to
5846
+ // recover. The shared helper detects the conflict (409 status /
5847
+ // "already exists" message), lists the app's configs — the list
5848
+ // route drains every page server-side (#1668), so the candidate
5849
+ // set is complete — and returns the one whose `groupType`
5850
+ // matches EXACTLY (the app-scoped list means a key match is an
5851
+ // ownership match; an unrelated config is never adopted). Its
5852
+ // non-adopt outcomes each throw a clear error and never touch
5853
+ // the server. Unlike blob buckets (ttlTier), a group type
5854
+ // config has no immutable field, so a plain adopt-and-update
5855
+ // converges. We re-issue as an UPDATE with the same `?? null`
5856
+ // clear semantics as the existing-update branch above, so the
5857
+ // adopted config converges to the local TOML.
5858
+ await adoptByKeyOnCreate409({
5859
+ err,
5860
+ kind: "group type config",
5861
+ key: groupType,
5862
+ lookup: async () => await client.listGroupTypeConfigs(resolvedAppId),
5863
+ matchKey: (c) => c?.groupType === groupType,
5864
+ });
5865
+ // No expectedModifiedAt: the config was not in sync state, so
5866
+ // there is no baseline to guard against — mirror the `--force`
5867
+ // update path, which also omits it.
5868
+ const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
5869
+ ruleSetId: configData.ruleSetId ?? null,
5870
+ autoAddCreator: configData.autoAddCreator,
5871
+ metadataManifest: configData.metadataManifest ?? null,
5872
+ });
5873
+ info(` Adopted + updated group type config: ${groupType}`);
5874
+ if (syncState) {
5875
+ if (!syncState.entities.groupTypeConfigs) {
5876
+ syncState.entities.groupTypeConfigs = {};
5877
+ }
5878
+ syncState.entities.groupTypeConfigs[groupType] = {
5879
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
5880
+ contentHash: computeFileHash(filePath),
5881
+ };
5882
+ }
5229
5883
  }
5230
5884
  }
5231
5885
  }
@@ -5305,7 +5959,41 @@ Directory Structure:
5305
5959
  }
5306
5960
  }
5307
5961
  catch (err) {
5308
- throw wrapEntityError(err, "create", "collection type config", collectionType);
5962
+ // #1851: idempotent create adopt-by-key on 409, the same
5963
+ // pattern as group type configs above. The server returns 409
5964
+ // "Config for this collection type already exists"
5965
+ // (collection-type-config-controller.ts) when a config for this
5966
+ // type already exists (out-of-band create, or a prior push that
5967
+ // aborted before recording it in sync state). The shared helper
5968
+ // lists the app's configs (complete — the list route drains all
5969
+ // pages, #1668) and returns the exact `collectionType` match; an
5970
+ // unrelated config is never adopted. Collection type configs
5971
+ // have no immutable field, so a plain adopt-and-update
5972
+ // converges. Re-issue as an UPDATE with the `?? null` clear
5973
+ // semantics of the existing-update branch.
5974
+ await adoptByKeyOnCreate409({
5975
+ err,
5976
+ kind: "collection type config",
5977
+ key: collectionType,
5978
+ lookup: async () => await client.listCollectionTypeConfigs(resolvedAppId),
5979
+ matchKey: (c) => c?.collectionType === collectionType,
5980
+ });
5981
+ // No expectedModifiedAt — the config was not in sync state, so
5982
+ // there is no baseline to guard against (mirrors `--force`).
5983
+ const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
5984
+ ruleSetId: configData.ruleSetId ?? null,
5985
+ metadataManifest: configData.metadataManifest ?? null,
5986
+ });
5987
+ info(` Adopted + updated collection type config: ${collectionType}`);
5988
+ if (syncState) {
5989
+ if (!syncState.entities.collectionTypeConfigs) {
5990
+ syncState.entities.collectionTypeConfigs = {};
5991
+ }
5992
+ syncState.entities.collectionTypeConfigs[collectionType] = {
5993
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
5994
+ contentHash: computeFileHash(filePath),
5995
+ };
5996
+ }
5309
5997
  }
5310
5998
  }
5311
5999
  }
@@ -5467,6 +6155,189 @@ Directory Structure:
5467
6155
  skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
5468
6156
  }
5469
6157
  }
6158
+ // ------------------------------------------------------------------
6159
+ // Prune pass — server-side deletion of managed entities whose local
6160
+ // TOML the operator removed since the last sync (issue #1655). Opt-in:
6161
+ // a push WITHOUT `--prune` deletes nothing and makes no extra server
6162
+ // calls. Each candidate is confirmed by a point read (detail-fetch by
6163
+ // id/key, immune to listing truncation) that also carries a drift check
6164
+ // — delete only when the live `modifiedAt` matches the one recorded at
6165
+ // last sync, unless `--force`. One batch confirmation gates the deletes.
6166
+ // ------------------------------------------------------------------
6167
+ if (options.prune) {
6168
+ const dropPruneState = (spec, key) => {
6169
+ if (!syncState?.entities)
6170
+ return;
6171
+ const slot = syncState.entities[spec.resourceType.stateSlot];
6172
+ if (slot)
6173
+ delete slot[key];
6174
+ if (spec.resourceType.testBlockType) {
6175
+ removePrunedSidecar({
6176
+ configDir,
6177
+ resourceType: spec.resourceType,
6178
+ key,
6179
+ testCaseEntities: syncState.entities.testCases,
6180
+ logger: (m) => info(m),
6181
+ });
6182
+ }
6183
+ };
6184
+ const toDelete = [];
6185
+ const alreadyAbsent = [];
6186
+ const skipped = [];
6187
+ for (const spec of PUSH_PRUNE_SPECS) {
6188
+ const priorEntities = syncState?.entities?.[spec.resourceType.stateSlot];
6189
+ const candidates = collectPushPruneCandidates({
6190
+ spec,
6191
+ configDir,
6192
+ priorEntities,
6193
+ });
6194
+ for (const { key, entry } of candidates) {
6195
+ const address = spec.addressOf(key, entry);
6196
+ // Behavior 4: skip the point read for a candidate we could not
6197
+ // delete anyway (no id in state, or no delete surface yet #1426).
6198
+ if (!address || !spec.deleteEntity) {
6199
+ const decision = decidePushPruneCandidate({
6200
+ spec,
6201
+ address,
6202
+ priorModifiedAt: entry?.modifiedAt,
6203
+ fetch: { ok: false },
6204
+ force: !!options.force,
6205
+ });
6206
+ skipped.push({ spec, key, entry, address, decision });
6207
+ continue;
6208
+ }
6209
+ // Point read: confirm live presence + drift. Fail closed — any
6210
+ // non-404 error leaves the entity and its state slot untouched.
6211
+ let fetchOutcome;
6212
+ try {
6213
+ const detail = await spec.fetchDetail(client, resolvedAppId, address);
6214
+ fetchOutcome = { ok: true, detail };
6215
+ }
6216
+ catch (err) {
6217
+ if (err instanceof ApiError && err.statusCode === 404) {
6218
+ fetchOutcome = { ok: false, status: 404 };
6219
+ }
6220
+ else {
6221
+ fetchOutcome = { ok: false };
6222
+ }
6223
+ }
6224
+ const decision = decidePushPruneCandidate({
6225
+ spec,
6226
+ address,
6227
+ priorModifiedAt: entry?.modifiedAt,
6228
+ fetch: fetchOutcome,
6229
+ force: !!options.force,
6230
+ });
6231
+ const item = { spec, key, entry, address, decision };
6232
+ if (decision.action === "delete")
6233
+ toDelete.push(item);
6234
+ else if (decision.action === "drop-state")
6235
+ alreadyAbsent.push(item);
6236
+ else
6237
+ skipped.push(item);
6238
+ }
6239
+ }
6240
+ const totalPlanned = toDelete.length + alreadyAbsent.length + skipped.length;
6241
+ if (totalPlanned > 0) {
6242
+ console.log();
6243
+ info("Prune plan (local TOML removed since last sync):");
6244
+ for (const item of toDelete) {
6245
+ console.log(` ${chalk.red("will delete")} ${item.spec.resourceType.label}: ${item.key}`);
6246
+ }
6247
+ for (const item of alreadyAbsent) {
6248
+ console.log(` ${chalk.dim("already absent")} ${item.spec.resourceType.label}: ${item.key}`);
6249
+ }
6250
+ for (const item of skipped) {
6251
+ const reason = item.decision.action === "skip" ? item.decision.reason : "skip";
6252
+ const label = reason === "drift"
6253
+ ? "skipped — modified on server since last sync (use --force)"
6254
+ : reason === "unconfirmed"
6255
+ ? "skipped — could not confirm (fetch failed)"
6256
+ : reason === "no-id"
6257
+ ? "skipped — no id in sync state"
6258
+ : reason === "unsupported"
6259
+ ? "skipped — no delete surface yet (#1426)"
6260
+ : "skipped";
6261
+ console.log(` ${chalk.yellow(label)} ${item.spec.resourceType.label}: ${item.key}`);
6262
+ }
6263
+ }
6264
+ if (options.dryRun) {
6265
+ // Behavior 6: report the plan, mutate nothing (no delete calls, no
6266
+ // state or filesystem change — not even the already-absent cleanup).
6267
+ for (const item of toDelete) {
6268
+ changes.push({
6269
+ type: item.spec.resourceType.label,
6270
+ action: "delete",
6271
+ key: item.key,
6272
+ });
6273
+ }
6274
+ }
6275
+ else {
6276
+ // Behavior 5: one batch confirmation for the destructive deletes.
6277
+ // No deletes to confirm → apply the already-absent bookkeeping only.
6278
+ let proceed = true;
6279
+ if (toDelete.length > 0 && !options.yes) {
6280
+ try {
6281
+ proceed = await confirmPrompt(`Delete ${toDelete.length} remote entity(ies)? This cannot be undone.`);
6282
+ }
6283
+ catch (err) {
6284
+ if (err instanceof ConfirmPromptError) {
6285
+ error(err.message);
6286
+ process.exit(1);
6287
+ }
6288
+ throw err;
6289
+ }
6290
+ }
6291
+ if (!proceed) {
6292
+ // Declining leaves every entity AND every prior-state slot intact.
6293
+ info("Prune cancelled — no entities were deleted.");
6294
+ }
6295
+ else {
6296
+ let prunedCount = 0;
6297
+ let blockedCount = 0;
6298
+ // Entities already gone server-side: drop their stale state (2).
6299
+ for (const item of alreadyAbsent) {
6300
+ dropPruneState(item.spec, item.key);
6301
+ info(` ${item.spec.resourceType.label} ${item.key} already absent — cleared from sync state`);
6302
+ }
6303
+ for (const item of toDelete) {
6304
+ try {
6305
+ await item.spec.deleteEntity(client, resolvedAppId, item.address);
6306
+ dropPruneState(item.spec, item.key);
6307
+ changes.push({
6308
+ type: item.spec.resourceType.label,
6309
+ action: "delete",
6310
+ key: item.key,
6311
+ });
6312
+ prunedCount++;
6313
+ info(` Deleted ${item.spec.resourceType.label}: ${item.key}`);
6314
+ }
6315
+ catch (err) {
6316
+ if (err instanceof ConflictError ||
6317
+ (err instanceof ApiError && err.statusCode === 409)) {
6318
+ // Behavior 8: still referenced — report, keep state, and
6319
+ // continue deleting the rest (partial success + re-run).
6320
+ blockedCount++;
6321
+ warn(` Blocked — ${item.spec.resourceType.label} ${item.key} is still referenced: ${err instanceof Error ? err.message : String(err)}`);
6322
+ }
6323
+ else if (err instanceof ApiError && err.statusCode === 404) {
6324
+ // Raced to 404 between the point read and the delete: the
6325
+ // desired (deleted) state is met — treat as success.
6326
+ dropPruneState(item.spec, item.key);
6327
+ info(` ${item.spec.resourceType.label} ${item.key} already absent (nothing to delete)`);
6328
+ }
6329
+ else {
6330
+ blockedCount++;
6331
+ warn(` Skipped — could not delete ${item.spec.resourceType.label} ${item.key}: ${err instanceof Error ? err.message : String(err)}`);
6332
+ }
6333
+ }
6334
+ }
6335
+ if (totalPlanned > 0) {
6336
+ info(`Prune: ${prunedCount} deleted, ${alreadyAbsent.length} already absent, ${skipped.length + blockedCount} skipped.`);
6337
+ }
6338
+ }
6339
+ }
6340
+ }
5470
6341
  divider();
5471
6342
  // Issue #813: a gate failure surfaced by the validate-first pass (which
5472
6343
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -5585,33 +6456,106 @@ Directory Structure:
5585
6456
  const syncState = loadSyncState(configDir);
5586
6457
  info(`Comparing local configuration with app ${resolvedAppId}...`);
5587
6458
  try {
5588
- // Fetch remote state
5589
- const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
5590
- fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
5591
- fetchAll((p) => client.listPrompts(resolvedAppId, p)),
5592
- fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
5593
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
6459
+ // Fetch remote state. Every list flows through the same ok/error gate
6460
+ // (issue #1485): a failed fetch must NOT be read as "the app has none of
6461
+ // these" that would print false `local only` rows and let the summary
6462
+ // read as exhaustive while a whole type went uncompared. A failed type
6463
+ // is recorded in `notCompared`, skipped entirely (no local reads, no
6464
+ // rows), and surfaced as an explicit `not compared:` summary line so the
6465
+ // roll-up can never be misread. This replaces the older mix of
6466
+ // `.catch(() => [])` (silent empty) and un-caught `fetchAll` (whole-diff
6467
+ // abort) with one uniform, honest degradation.
6468
+ const notCompared = [];
6469
+ const gateReason = (err) => err && err.statusCode === 404
6470
+ ? "server does not support"
6471
+ : "remote fetch failed";
6472
+ const gate = (outcome, label, empty) => {
6473
+ if (outcome.ok)
6474
+ return outcome.value;
6475
+ notCompared.push({ type: label, reason: gateReason(outcome.error) });
6476
+ return empty;
6477
+ };
6478
+ const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
6479
+ attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
6480
+ attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
6481
+ attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
6482
+ attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
6483
+ attemptFetch(() => fetchAll((p) => client.listWebhooks(resolvedAppId, p))),
6484
+ attemptFetch(() => client.listCronTriggers(resolvedAppId)),
6485
+ attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
6486
+ attemptFetch(() => client.listScripts(resolvedAppId)),
6487
+ attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
6488
+ attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
6489
+ attemptFetch(() => client.listRuleSets(resolvedAppId)),
6490
+ attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
6491
+ attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
6492
+ attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
5594
6493
  ]);
5595
- const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
5596
- let cronTriggerItemsDiff = [];
5597
- try {
5598
- const cronResult = await client.listCronTriggers(resolvedAppId);
5599
- cronTriggerItemsDiff = cronResult.items || [];
5600
- }
5601
- catch {
5602
- // Cron triggers may not be available on older servers
5603
- }
5604
- const blobBucketsDiffResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
6494
+ const integrationItems = gate(integrationsOutcome, "integrations", []);
6495
+ const promptItems = gate(promptsOutcome, "prompts", []);
6496
+ const workflowItems = gate(workflowsOutcome, "workflows", []);
6497
+ const emailTemplatesResult = gate(emailTemplatesOutcomeDiff, "email-templates", { templates: [] });
6498
+ const webhookItems = gate(webhooksOutcome, "webhooks", []);
6499
+ const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
6500
+ const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
6501
+ const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
6502
+ const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
6503
+ const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-types", []);
6504
+ const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
6505
+ const groupTypeConfigsDiff = gate(groupTypeConfigsOutcomeDiff, "group-type-configs", []);
6506
+ const collectionTypeConfigsDiff = gate(collectionTypeConfigsOutcomeDiff, "collection-type-configs", []);
6507
+ const metadataCategoryConfigsDiff = gate(metadataCategoryConfigsOutcomeDiff, "metadata-category-configs", []);
6508
+ // ruleSetId ⇄ name maps, built once from the rule-set listing, drive the
6509
+ // content-aware comparison of the three rule-set-referencing types
6510
+ // (database-types, group/collection-type-configs). If the rule-set list
6511
+ // itself failed, the maps are empty and those types degrade to
6512
+ // "content not compared" rather than risk a false `Modified` from an
6513
+ // unresolvable reference (see `ruleSetsOk` below).
6514
+ const ruleSetsArr = Array.isArray(ruleSetsDiff) ? ruleSetsDiff : [];
6515
+ const ruleSetIdToNameDiff = new Map();
6516
+ const ruleSetNameToIdDiff = new Map();
6517
+ for (const rs of ruleSetsArr) {
6518
+ ruleSetIdToNameDiff.set(rs.ruleSetId, rs.name);
6519
+ ruleSetNameToIdDiff.set(rs.name, rs.ruleSetId);
6520
+ }
6521
+ const ruleSetsOk = ruleSetsOutcomeDiff.ok;
5605
6522
  const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
5606
6523
  const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
5607
6524
  const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
5608
6525
  const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
5609
6526
  const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
5610
- const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
6527
+ // #1660 exclude platform-owned workflows (reserved `__internal.*`)
6528
+ // from the remote set so `diff` never reports them (they are not app
6529
+ // config). The local-file set below applies the same exclusion, so a
6530
+ // platform-owned workflow appears in none of synced/modified/remote-only/
6531
+ // local-only. Prefers the server flag, falls back to the key prefix.
6532
+ const remoteWorkflows = new Set(workflowItems
6533
+ .filter((w) => !isPlatformOwnedWorkflow(w))
6534
+ .map((w) => w.workflowKey));
5611
6535
  const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
5612
6536
  .filter((t) => t.hasOverride)
5613
6537
  .map((t) => t.emailType));
5614
- // Get local files
6538
+ // Remote maps keyed the SAME way the sync-state slot is (so local-only /
6539
+ // remote-only classification lines up), value = the server entity for
6540
+ // content comparison.
6541
+ const remoteDatabaseTypes = new Map((Array.isArray(databaseTypeConfigsDiff) ? databaseTypeConfigsDiff : [])
6542
+ .filter((c) => c?.databaseType)
6543
+ .map((c) => [c.databaseType, c]));
6544
+ const remoteRuleSets = new Map(ruleSetsArr
6545
+ .filter((rs) => rs?.name || rs?.ruleSetId)
6546
+ .map((rs) => [ruleSetFileKey(rs), rs]));
6547
+ const remoteGroupTypeConfigs = new Map((Array.isArray(groupTypeConfigsDiff) ? groupTypeConfigsDiff : [])
6548
+ .filter((c) => c?.groupType)
6549
+ .map((c) => [c.groupType, c]));
6550
+ const remoteCollectionTypeConfigs = new Map((Array.isArray(collectionTypeConfigsDiff) ? collectionTypeConfigsDiff : [])
6551
+ .filter((c) => c?.collectionType)
6552
+ .map((c) => [c.collectionType, c]));
6553
+ const remoteMetadataCategoryConfigs = new Map((Array.isArray(metadataCategoryConfigsDiff) ? metadataCategoryConfigsDiff : [])
6554
+ .filter((c) => c?.resourceType && c?.category)
6555
+ .map((c) => [`${c.resourceType}#${c.category}`, c]));
6556
+ // Get local files. Each read is guarded by its type's fetch outcome: a
6557
+ // type whose remote fetch failed is not read locally at all, so it never
6558
+ // produces `local only` rows — it is reported once under `not compared:`.
5615
6559
  const localIntegrations = new Set();
5616
6560
  const localWebhooks = new Set();
5617
6561
  const localCronTriggers = new Set();
@@ -5619,8 +6563,14 @@ Directory Structure:
5619
6563
  const localPrompts = new Set();
5620
6564
  const localWorkflows = new Set();
5621
6565
  const localEmailTemplates = new Set();
6566
+ // Parsed local TOML per key for the five content-aware config types.
6567
+ const localDatabaseTypesParsed = new Map();
6568
+ const localRuleSetsParsed = new Map();
6569
+ const localGroupTypeConfigsParsed = new Map();
6570
+ const localCollectionTypeConfigsParsed = new Map();
6571
+ const localMetadataCategoryParsed = new Map();
5622
6572
  const integrationsDir = join(configDir, "integrations");
5623
- if (existsSync(integrationsDir)) {
6573
+ if (integrationsOutcome.ok && existsSync(integrationsDir)) {
5624
6574
  for (const file of readdirSync(integrationsDir).filter((f) => f.endsWith(".toml"))) {
5625
6575
  const tomlData = parseTomlFile(join(integrationsDir, file));
5626
6576
  const key = tomlData.integration?.key || basename(file, ".toml");
@@ -5628,7 +6578,7 @@ Directory Structure:
5628
6578
  }
5629
6579
  }
5630
6580
  const webhooksDirPath = join(configDir, "webhooks");
5631
- if (existsSync(webhooksDirPath)) {
6581
+ if (webhooksOutcome.ok && existsSync(webhooksDirPath)) {
5632
6582
  for (const file of readdirSync(webhooksDirPath).filter((f) => f.endsWith(".toml"))) {
5633
6583
  const tomlData = parseTomlFile(join(webhooksDirPath, file));
5634
6584
  const key = tomlData.webhook?.key || basename(file, ".toml");
@@ -5636,7 +6586,7 @@ Directory Structure:
5636
6586
  }
5637
6587
  }
5638
6588
  const cronTriggersDirPath = join(configDir, "cron-triggers");
5639
- if (existsSync(cronTriggersDirPath)) {
6589
+ if (cronTriggersOutcomeDiff.ok && existsSync(cronTriggersDirPath)) {
5640
6590
  for (const file of readdirSync(cronTriggersDirPath).filter((f) => f.endsWith(".toml"))) {
5641
6591
  const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
5642
6592
  const key = tomlData.cronTrigger?.key || basename(file, ".toml");
@@ -5644,7 +6594,7 @@ Directory Structure:
5644
6594
  }
5645
6595
  }
5646
6596
  const blobBucketsDiffDir = join(configDir, "blob-buckets");
5647
- if (existsSync(blobBucketsDiffDir)) {
6597
+ if (blobBucketsOutcomeDiff.ok && existsSync(blobBucketsDiffDir)) {
5648
6598
  for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
5649
6599
  const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
5650
6600
  const key = tomlData.bucket?.key || basename(file, ".toml");
@@ -5652,77 +6602,163 @@ Directory Structure:
5652
6602
  }
5653
6603
  }
5654
6604
  const promptsDir = join(configDir, "prompts");
5655
- if (existsSync(promptsDir)) {
6605
+ if (promptsOutcome.ok && existsSync(promptsDir)) {
5656
6606
  for (const file of readdirSync(promptsDir).filter((f) => f.endsWith(".toml"))) {
5657
6607
  const tomlData = parseTomlFile(join(promptsDir, file));
5658
6608
  const key = tomlData.prompt?.key || basename(file, ".toml");
5659
6609
  localPrompts.add(key);
5660
6610
  }
5661
6611
  }
6612
+ // Database-config types (issue #1485). Read each type's local dir only
6613
+ // when its remote fetch succeeded, and capture the parsed TOML per key
6614
+ // so the content comparison below can hash it without a second read.
6615
+ //
6616
+ // When the identity field is omitted and derived from the filename
6617
+ // (the fallback `sync push` supports — see ~sync.ts:5196 for
6618
+ // database-types, :6176 group, :6267 collection, :6358 metadata), the
6619
+ // derived identity is injected back into the parsed TOML BEFORE it is
6620
+ // stored. Otherwise the content hash would carry `databaseType: undefined`
6621
+ // (etc.) while the remote entity carries the real identity, and diff would
6622
+ // report `Modified` forever — even right after a successful push.
6623
+ const databaseTypesDir = join(configDir, "database-types");
6624
+ if (databaseTypeConfigsOutcomeDiff.ok && existsSync(databaseTypesDir)) {
6625
+ for (const file of readdirSync(databaseTypesDir).filter((f) => f.endsWith(".toml"))) {
6626
+ const tomlData = parseTomlFile(join(databaseTypesDir, file));
6627
+ const key = tomlData.type?.databaseType || basename(file, ".toml");
6628
+ if (!tomlData.type)
6629
+ tomlData.type = {};
6630
+ tomlData.type.databaseType = key;
6631
+ localDatabaseTypesParsed.set(key, tomlData);
6632
+ }
6633
+ }
6634
+ const ruleSetsDir = join(configDir, "rule-sets");
6635
+ if (ruleSetsOutcomeDiff.ok && existsSync(ruleSetsDir)) {
6636
+ for (const file of readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"))) {
6637
+ const tomlData = parseTomlFile(join(ruleSetsDir, file));
6638
+ // Key by the FILENAME, exactly as `sync push` does (it looks the
6639
+ // sync-state entry up by `basename(file)` and PATCHes that id). If
6640
+ // an existing pulled rule set is renamed by editing `ruleSet.name`
6641
+ // without renaming the file, push still updates the same entity, so
6642
+ // diff must match it to the same remote entity — one Modified row —
6643
+ // rather than rekey by the new `name` (which would read as a new
6644
+ // local rule set PLUS a prunable remote one). The renamed `name`
6645
+ // still differs in the hashed content, so the row is `Modified`.
6646
+ const key = basename(file, ".toml");
6647
+ localRuleSetsParsed.set(key, tomlData);
6648
+ }
6649
+ }
6650
+ const groupTypeConfigsDir = join(configDir, "group-type-configs");
6651
+ if (groupTypeConfigsOutcomeDiff.ok && existsSync(groupTypeConfigsDir)) {
6652
+ for (const file of readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"))) {
6653
+ const tomlData = parseTomlFile(join(groupTypeConfigsDir, file));
6654
+ const key = tomlData.groupTypeConfig?.groupType || basename(file, ".toml");
6655
+ if (!tomlData.groupTypeConfig)
6656
+ tomlData.groupTypeConfig = {};
6657
+ tomlData.groupTypeConfig.groupType = key;
6658
+ localGroupTypeConfigsParsed.set(key, tomlData);
6659
+ }
6660
+ }
6661
+ const collectionTypeConfigsDir = join(configDir, "collection-type-configs");
6662
+ if (collectionTypeConfigsOutcomeDiff.ok && existsSync(collectionTypeConfigsDir)) {
6663
+ for (const file of readdirSync(collectionTypeConfigsDir).filter((f) => f.endsWith(".toml"))) {
6664
+ const tomlData = parseTomlFile(join(collectionTypeConfigsDir, file));
6665
+ const key = tomlData.collectionTypeConfig?.collectionType || basename(file, ".toml");
6666
+ if (!tomlData.collectionTypeConfig)
6667
+ tomlData.collectionTypeConfig = {};
6668
+ tomlData.collectionTypeConfig.collectionType = key;
6669
+ localCollectionTypeConfigsParsed.set(key, tomlData);
6670
+ }
6671
+ }
6672
+ // metadata-category key is the `resourceType#category` pair read from the
6673
+ // parsed `[metadataCategoryConfig]` content (matching push), NOT the
6674
+ // filename — a renamed file still matches its server entity.
6675
+ const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
6676
+ if (metadataCategoryConfigsOutcomeDiff.ok && existsSync(metadataCategoryConfigsDir)) {
6677
+ for (const file of readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"))) {
6678
+ const tomlData = parseTomlFile(join(metadataCategoryConfigsDir, file));
6679
+ const section = tomlData.metadataCategoryConfig || {};
6680
+ let key;
6681
+ if (section.resourceType && section.category) {
6682
+ key = `${section.resourceType}#${section.category}`;
6683
+ }
6684
+ else {
6685
+ // Derive the `resourceType#category` identity from the filename
6686
+ // (`resourceType.category.toml`) and inject it back into the
6687
+ // parsed section — matching push's fallback (~sync.ts:6358) — so
6688
+ // the content hash carries the identity instead of `undefined`
6689
+ // and does not report `Modified` after a successful push.
6690
+ const base = basename(file, ".toml");
6691
+ const dot = base.indexOf(".");
6692
+ if (dot > 0) {
6693
+ const resourceType = base.slice(0, dot);
6694
+ const category = base.slice(dot + 1);
6695
+ key = `${resourceType}#${category}`;
6696
+ if (!tomlData.metadataCategoryConfig) {
6697
+ tomlData.metadataCategoryConfig = {};
6698
+ }
6699
+ tomlData.metadataCategoryConfig.resourceType =
6700
+ section.resourceType || resourceType;
6701
+ tomlData.metadataCategoryConfig.category =
6702
+ section.category || category;
6703
+ }
6704
+ else {
6705
+ key = base;
6706
+ }
6707
+ }
6708
+ localMetadataCategoryParsed.set(key, tomlData);
6709
+ }
6710
+ }
5662
6711
  // #1175: capture the parsed (fragment-expanded) local workflow TOML
5663
6712
  // per key so the content-aware comparison below can hash it without a
5664
6713
  // second read.
5665
6714
  const localWorkflowParsed = new Map();
5666
6715
  const workflowsDir = join(configDir, "workflows");
5667
- if (existsSync(workflowsDir)) {
6716
+ if (workflowsOutcome.ok && existsSync(workflowsDir)) {
5668
6717
  for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
5669
6718
  const tomlData = parseTomlFile(join(workflowsDir, file));
5670
6719
  const key = tomlData.workflow?.key || basename(file, ".toml");
6720
+ // #1660 — a stray local `__internal.*.toml` is platform-owned, not
6721
+ // app config; keep it out of the local set (matched on resolved key,
6722
+ // edge case 2) so `diff` reports it in no set. `sync pull` cleans up
6723
+ // the file; `sync push` skips it.
6724
+ if (isPlatformOwnedWorkflowKey(key))
6725
+ continue;
5671
6726
  localWorkflows.add(key);
5672
6727
  localWorkflowParsed.set(key, tomlData);
5673
6728
  }
5674
6729
  }
5675
6730
  const emailTemplatesDirPath = join(configDir, "email-templates");
5676
- if (existsSync(emailTemplatesDirPath)) {
6731
+ if (emailTemplatesOutcomeDiff.ok && existsSync(emailTemplatesDirPath)) {
5677
6732
  for (const file of readdirSync(emailTemplatesDirPath).filter((f) => f.endsWith(".toml"))) {
5678
6733
  const tomlData = parseTomlFile(join(emailTemplatesDirPath, file));
5679
6734
  const emailType = tomlData.template?.emailType || basename(file, ".toml");
5680
6735
  localEmailTemplates.add(emailType);
5681
6736
  }
5682
6737
  }
5683
- // Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
5684
- // ignored scripts entirely, so a body that drifted between the local
5685
- // `.rhai` and the server's active config silently read as Synced. We
5686
- // list the server scripts (header only) and read the local file
5687
- // contents so the comparison below can hash both sides.
5688
- let scriptItemsDiff = [];
5689
- try {
5690
- const scriptsResult = await client.listScripts(resolvedAppId);
5691
- scriptItemsDiff = scriptsResult.items || [];
5692
- }
5693
- catch {
5694
- // Older server without the scripts route — treat as no scripts.
5695
- }
6738
+ // Transforms (Rhai scripts) — issue #1196. Content-aware comparison
6739
+ // against the server's active-config body. The server list was fetched
6740
+ // through the ok/error gate above (`scriptItemsDiff`); the local `.rhai`
6741
+ // dir is read only when that fetch succeeded.
5696
6742
  const remoteScripts = new Map(scriptItemsDiff
5697
6743
  .filter((s) => s?.name)
5698
6744
  .map((s) => [s.name, s]));
5699
6745
  const localScripts = new Map();
5700
6746
  const transformsDirPath = join(configDir, "transforms");
5701
- if (existsSync(transformsDirPath)) {
6747
+ if (scriptsOutcomeDiff.ok && existsSync(transformsDirPath)) {
5702
6748
  for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
5703
6749
  const name = basename(file, ".rhai");
5704
6750
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
5705
6751
  }
5706
6752
  }
5707
- // Config vars — issue #1423. Before this, `sync diff` ignored vars
5708
- // entirely, so an add/remove/value-drift between the local `vars.toml`
5709
- // and the server silently read as no difference right before a push
5710
- // would change or unset them. Fetch the remote vars (value included —
5711
- // vars are non-secret) and read the local `vars.toml` so the comparison
5712
- // below can report added/removed/modified vars.
5713
- let remoteVarItemsDiff = [];
5714
- try {
5715
- remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
5716
- }
5717
- catch {
5718
- // Older server without the vars route — treat as no remote vars.
5719
- }
6753
+ // Config vars — issue #1423. The remote vars were fetched through the
6754
+ // ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
6755
+ // read only when that fetch succeeded.
5720
6756
  const remoteVars = new Map(remoteVarItemsDiff
5721
6757
  .filter((v) => typeof v?.key === "string")
5722
6758
  .map((v) => [v.key, String(v.value ?? "")]));
5723
6759
  const localVars = new Map();
5724
6760
  const varsTomlPathDiff = join(configDir, "vars.toml");
5725
- if (existsSync(varsTomlPathDiff)) {
6761
+ if (varsOutcomeDiff.ok && existsSync(varsTomlPathDiff)) {
5726
6762
  const parsed = parseTomlFile(varsTomlPathDiff);
5727
6763
  for (const [key, rawValue] of Object.entries(parsed)) {
5728
6764
  if (typeof rawValue === "string")
@@ -5739,6 +6775,13 @@ Directory Structure:
5739
6775
  // is the point. Prior sync state is the discriminator, exactly as it is
5740
6776
  // for the pull-side prune.
5741
6777
  const localOnlyStatus = (slot, key) => classifyLocalOnly(syncState?.entities, slot, key);
6778
+ // Issue #1655: a server entity with no local file also means two
6779
+ // different things. If a prior pull managed the key, the operator
6780
+ // removed its local TOML and `push --prune` will DELETE the remote
6781
+ // entity; if it was never synced, it was authored server-side and is
6782
+ // left alone. Prior sync state is the discriminator, mirroring the
6783
+ // local-only split above.
6784
+ const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
5742
6785
  // Integrations
5743
6786
  for (const key of localIntegrations) {
5744
6787
  if (!remoteIntegrations.has(key)) {
@@ -5750,7 +6793,7 @@ Directory Structure:
5750
6793
  }
5751
6794
  for (const key of remoteIntegrations) {
5752
6795
  if (!localIntegrations.has(key)) {
5753
- differences.push({ type: "integration", key, status: "remote only" });
6796
+ differences.push({ type: "integration", key, status: remoteOnlyStatus("integrations", key) });
5754
6797
  }
5755
6798
  }
5756
6799
  // Webhooks
@@ -5764,7 +6807,7 @@ Directory Structure:
5764
6807
  }
5765
6808
  for (const key of remoteWebhooks) {
5766
6809
  if (!localWebhooks.has(key)) {
5767
- differences.push({ type: "webhook", key, status: "remote only" });
6810
+ differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
5768
6811
  }
5769
6812
  }
5770
6813
  // Cron Triggers
@@ -5778,7 +6821,7 @@ Directory Structure:
5778
6821
  }
5779
6822
  for (const key of remoteCronTriggers) {
5780
6823
  if (!localCronTriggers.has(key)) {
5781
- differences.push({ type: "cron-trigger", key, status: "remote only" });
6824
+ differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
5782
6825
  }
5783
6826
  }
5784
6827
  // Blob Buckets
@@ -5792,7 +6835,7 @@ Directory Structure:
5792
6835
  }
5793
6836
  for (const key of remoteBlobBuckets) {
5794
6837
  if (!localBlobBuckets.has(key)) {
5795
- differences.push({ type: "blob-bucket", key, status: "remote only" });
6838
+ differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
5796
6839
  }
5797
6840
  }
5798
6841
  // Prompts
@@ -5806,7 +6849,7 @@ Directory Structure:
5806
6849
  }
5807
6850
  for (const key of remotePrompts) {
5808
6851
  if (!localPrompts.has(key)) {
5809
- differences.push({ type: "prompt", key, status: "remote only" });
6852
+ differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
5810
6853
  }
5811
6854
  }
5812
6855
  // Workflows — #1175: content-aware comparison.
@@ -5874,8 +6917,17 @@ Directory Structure:
5874
6917
  differences.push({ type: "workflow", key, status, hint });
5875
6918
  }
5876
6919
  for (const key of remoteWorkflows) {
6920
+ // Platform-owned internal workflows (`__internal.*`) are never part of
6921
+ // an app's config tree, so they must not show as `remote only` (the
6922
+ // reporter's `Remote only: 1 · __internal.reindex-instances`). Removing
6923
+ // them from every synced surface is #1660's job; until it lands, skip
6924
+ // the prefix here so this diff's coverage extension doesn't surface a
6925
+ // spurious remote-only row. When #1660 lands it replaces this with its
6926
+ // `isPlatformOwnedEntity` predicate.
6927
+ if (key && key.startsWith("__internal."))
6928
+ continue;
5877
6929
  if (!localWorkflows.has(key)) {
5878
- differences.push({ type: "workflow", key, status: "remote only" });
6930
+ differences.push({ type: "workflow", key, status: remoteOnlyStatus("workflows", key) });
5879
6931
  }
5880
6932
  }
5881
6933
  // Email Templates
@@ -5889,7 +6941,7 @@ Directory Structure:
5889
6941
  }
5890
6942
  for (const key of remoteEmailTemplates) {
5891
6943
  if (!localEmailTemplates.has(key)) {
5892
- differences.push({ type: "email-template", key, status: "remote only" });
6944
+ differences.push({ type: "email-template", key, status: remoteOnlyStatus("emailTemplates", key) });
5893
6945
  }
5894
6946
  }
5895
6947
  // Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
@@ -5938,13 +6990,106 @@ Directory Structure:
5938
6990
  }
5939
6991
  for (const name of remoteScripts.keys()) {
5940
6992
  if (!localScripts.has(name)) {
5941
- differences.push({ type: "transform", key: name, status: "remote only" });
6993
+ differences.push({ type: "transform", key: name, status: remoteOnlyStatus("scripts", name) });
5942
6994
  }
5943
6995
  }
5944
6996
  // Config vars — issue #1423 (see `diffVars`).
5945
6997
  for (const row of diffVars(localVars, remoteVars)) {
5946
6998
  differences.push(row);
5947
6999
  }
7000
+ // Database-config types (issue #1485). rule-sets, group/collection-type
7001
+ // and metadata-category configs carry their full entity in the list
7002
+ // response (pull serializes straight from it), so content is compared
7003
+ // directly; database-types need a per-type ops/subscriptions fetch (see
7004
+ // below). Content comparison is a preview of `sync pull` (pull would
7005
+ // rewrite the local TOML to match running state), exactly like the
7006
+ // workflow/transform blocks.
7007
+ const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, hashLocal, hashRemote, contentComparable) => {
7008
+ for (const [key, parsed] of localParsed) {
7009
+ if (!remoteEntities.has(key)) {
7010
+ differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
7011
+ continue;
7012
+ }
7013
+ let status = "exists";
7014
+ let hint;
7015
+ if (!contentComparable) {
7016
+ // Rule-set list failed, so a `ruleSetName` reference can't be
7017
+ // resolved on either side — decline the content comparison rather
7018
+ // than risk a false `Modified`.
7019
+ hint = "content not compared (rule sets unavailable)";
7020
+ }
7021
+ else {
7022
+ try {
7023
+ if (hashLocal(parsed) !== hashRemote(remoteEntities.get(key))) {
7024
+ status = "modified";
7025
+ hint = "run `sync pull` to rewrite the local TOML to match running state";
7026
+ }
7027
+ }
7028
+ catch {
7029
+ // A malformed local file must not abort the whole diff.
7030
+ hint = "content not compared (comparison failed)";
7031
+ }
7032
+ }
7033
+ differences.push({ type: typeLabel, key, status, hint });
7034
+ }
7035
+ for (const key of remoteEntities.keys()) {
7036
+ if (!localParsed.has(key)) {
7037
+ differences.push({ type: typeLabel, key, status: remoteOnlyStatus(slot, key) });
7038
+ }
7039
+ }
7040
+ };
7041
+ compareConfigType("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, (p) => hashLocalRuleSetForDiff(p), (e) => hashRemoteRuleSetForDiff(e), true);
7042
+ compareConfigType("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, (p) => hashLocalGroupTypeConfigForDiff(p, ruleSetNameToIdDiff), (e) => hashRemoteGroupTypeConfigForDiff(e, ruleSetIdToNameDiff, ruleSetNameToIdDiff), ruleSetsOk);
7043
+ compareConfigType("collection-type-config", "collectionTypeConfigs", localCollectionTypeConfigsParsed, remoteCollectionTypeConfigs, (p) => hashLocalCollectionTypeConfigForDiff(p, ruleSetNameToIdDiff), (e) => hashRemoteCollectionTypeConfigForDiff(e, ruleSetIdToNameDiff, ruleSetNameToIdDiff), ruleSetsOk);
7044
+ compareConfigType("metadata-category-config", "metadataCategoryConfigs", localMetadataCategoryParsed, remoteMetadataCategoryConfigs, (p) => hashLocalMetadataCategoryForDiff(p), (e) => hashRemoteMetadataCategoryForDiff(e), true);
7045
+ // database-types: the list carries only the type header, so operations
7046
+ // and subscriptions are fetched per type (mirroring pull) before the
7047
+ // content comparison. A per-type fetch failure degrades that one row to
7048
+ // "content not compared" rather than aborting the whole diff (#1485
7049
+ // edge case). Both operations AND subscriptions are included so an
7050
+ // op-only or subscription-only change is not falsely reported as Synced.
7051
+ for (const [key, parsed] of localDatabaseTypesParsed) {
7052
+ const remote = remoteDatabaseTypes.get(key);
7053
+ if (!remote) {
7054
+ differences.push({ type: "database-type", key, status: localOnlyStatus("databaseTypes", key) });
7055
+ continue;
7056
+ }
7057
+ let status = "exists";
7058
+ let hint;
7059
+ if (!ruleSetsOk) {
7060
+ hint = "content not compared (rule sets unavailable)";
7061
+ }
7062
+ else {
7063
+ const [opsOutcome, subsOutcome] = await Promise.all([
7064
+ attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, key)),
7065
+ attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, key)),
7066
+ ]);
7067
+ if (!opsOutcome.ok || !subsOutcome.ok) {
7068
+ hint = "content not compared (fetch failed)";
7069
+ }
7070
+ else {
7071
+ try {
7072
+ const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
7073
+ const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
7074
+ const remoteHash = hashRemoteDatabaseTypeForDiff(remote, operations, subscriptions, ruleSetIdToNameDiff, ruleSetNameToIdDiff);
7075
+ const localHash = hashLocalDatabaseTypeForDiff(parsed, ruleSetNameToIdDiff);
7076
+ if (remoteHash !== localHash) {
7077
+ status = "modified";
7078
+ hint = "run `sync pull` to rewrite the local TOML to match running state";
7079
+ }
7080
+ }
7081
+ catch {
7082
+ hint = "content not compared (comparison failed)";
7083
+ }
7084
+ }
7085
+ }
7086
+ differences.push({ type: "database-type", key, status, hint });
7087
+ }
7088
+ for (const key of remoteDatabaseTypes.keys()) {
7089
+ if (!localDatabaseTypesParsed.has(key)) {
7090
+ differences.push({ type: "database-type", key, status: remoteOnlyStatus("databaseTypes", key) });
7091
+ }
7092
+ }
5948
7093
  // Compare test cases for synced prompts and workflows
5949
7094
  const testCaseDiffs = [];
5950
7095
  // Helper to compare test cases for a block
@@ -5995,7 +7140,11 @@ Directory Structure:
5995
7140
  divider();
5996
7141
  const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
5997
7142
  const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
5998
- const remoteOnly = differences.filter((d) => d.status === "remote only");
7143
+ // #1655: a server entity with no local file, split by whether a prior
7144
+ // pull managed it. Managed → `push --prune` will delete it; unmanaged
7145
+ // (never synced, authored server-side) → left alone.
7146
+ const remoteOnlyWillDelete = differences.filter((d) => d.status === REMOTE_ONLY_WILL_DELETE);
7147
+ const remoteOnlyUnmanaged = differences.filter((d) => d.status === REMOTE_ONLY_UNMANAGED);
5999
7148
  const modified = differences.filter((d) => d.status === "modified");
6000
7149
  const existing = differences.filter((d) => d.status === "exists");
6001
7150
  if (localOnly.length > 0) {
@@ -6016,13 +7165,23 @@ Directory Structure:
6016
7165
  }
6017
7166
  console.log();
6018
7167
  }
6019
- if (remoteOnly.length > 0) {
6020
- warn("Remote only (not in local config):");
6021
- for (const d of remoteOnly) {
7168
+ // #1655: managed entities whose local file is gone — `push --prune`
7169
+ // deletes these (the git-committed file deletion is the intent record).
7170
+ if (remoteOnlyWillDelete.length > 0) {
7171
+ warn("Remote only — managed (will delete with `push --prune`):");
7172
+ for (const d of remoteOnlyWillDelete) {
6022
7173
  console.log(` ${chalk.red("-")} ${d.type}: ${d.key}`);
6023
7174
  }
6024
7175
  console.log();
6025
7176
  }
7177
+ // #1655: server entities that were never synced — push leaves them be.
7178
+ if (remoteOnlyUnmanaged.length > 0) {
7179
+ info("Remote only — unmanaged (left alone, never synced):");
7180
+ for (const d of remoteOnlyUnmanaged) {
7181
+ console.log(` ${chalk.dim("-")} ${d.type}: ${d.key}`);
7182
+ }
7183
+ console.log();
7184
+ }
6026
7185
  // #1175: workflows whose deployed content differs from the local TOML.
6027
7186
  // Framed as a preview of `sync pull` — pull would rewrite these files
6028
7187
  // to match what's actually running (NOT "push would send").
@@ -6041,6 +7200,19 @@ Directory Structure:
6041
7200
  console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
6042
7201
  }
6043
7202
  }
7203
+ // #1485: types whose remote list could not be fetched are excluded from
7204
+ // the comparison entirely. Printing them explicitly is what keeps the
7205
+ // roll-up below from being misread as exhaustive — with every list
7206
+ // fetch succeeding this section is empty and every push-handled type
7207
+ // appears in the itemized sections or the counts; otherwise the excluded
7208
+ // types are named here rather than silently dropped.
7209
+ if (notCompared.length > 0) {
7210
+ console.log();
7211
+ warn("Not compared (excluded from the counts below):");
7212
+ for (const nc of notCompared) {
7213
+ console.log(` ${chalk.yellow("?")} ${nc.type} (${nc.reason})`);
7214
+ }
7215
+ }
6044
7216
  // Show test case differences
6045
7217
  const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
6046
7218
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
@@ -6061,9 +7233,13 @@ Directory Structure:
6061
7233
  divider();
6062
7234
  keyValue("Local only (new)", localOnly.length);
6063
7235
  keyValue("Local only (absent from export)", localOnlyAbsent.length);
6064
- keyValue("Remote only", remoteOnly.length);
7236
+ keyValue("Remote only (will delete with --prune)", remoteOnlyWillDelete.length);
7237
+ keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
6065
7238
  keyValue("Modified", modified.length);
6066
7239
  keyValue("Synced", existing.length);
7240
+ if (notCompared.length > 0) {
7241
+ keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
7242
+ }
6067
7243
  if (testCaseDiffs.length > 0) {
6068
7244
  keyValue("Test Cases (local only)", tcLocalOnly.length);
6069
7245
  keyValue("Test Cases (remote only)", tcRemoteOnly.length);