primitive-admin 1.2.0-alpha.0 → 1.2.0-alpha.2

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 (137) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/primitive.js +22 -24
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/agent-sessions.d.ts +20 -0
  5. package/dist/src/commands/agent-sessions.js +275 -0
  6. package/dist/src/commands/agent-sessions.js.map +1 -0
  7. package/dist/src/commands/apps-children.d.ts +54 -0
  8. package/dist/src/commands/apps-children.js +578 -0
  9. package/dist/src/commands/apps-children.js.map +1 -0
  10. package/dist/src/commands/apps.js +88 -9
  11. package/dist/src/commands/apps.js.map +1 -1
  12. package/dist/src/commands/auth-sessions.d.ts +3 -3
  13. package/dist/src/commands/auth-sessions.js +117 -23
  14. package/dist/src/commands/auth-sessions.js.map +1 -1
  15. package/dist/src/commands/auth.js +248 -15
  16. package/dist/src/commands/auth.js.map +1 -1
  17. package/dist/src/commands/connections.js +1 -5
  18. package/dist/src/commands/connections.js.map +1 -1
  19. package/dist/src/commands/databases.js +7 -109
  20. package/dist/src/commands/databases.js.map +1 -1
  21. package/dist/src/commands/documents.d.ts +29 -0
  22. package/dist/src/commands/documents.js +676 -191
  23. package/dist/src/commands/documents.js.map +1 -1
  24. package/dist/src/commands/env.d.ts +9 -1
  25. package/dist/src/commands/env.js +158 -45
  26. package/dist/src/commands/env.js.map +1 -1
  27. package/dist/src/commands/functions.js +7 -1
  28. package/dist/src/commands/functions.js.map +1 -1
  29. package/dist/src/commands/init.js +2 -1
  30. package/dist/src/commands/init.js.map +1 -1
  31. package/dist/src/commands/integrations.js +14 -2
  32. package/dist/src/commands/integrations.js.map +1 -1
  33. package/dist/src/commands/prompts.js +4 -0
  34. package/dist/src/commands/prompts.js.map +1 -1
  35. package/dist/src/commands/scripts.js +1 -1
  36. package/dist/src/commands/scripts.js.map +1 -1
  37. package/dist/src/commands/sessions.js +3 -8
  38. package/dist/src/commands/sessions.js.map +1 -1
  39. package/dist/src/commands/sync-app-settings.d.ts +25 -7
  40. package/dist/src/commands/sync-app-settings.js +81 -20
  41. package/dist/src/commands/sync-app-settings.js.map +1 -1
  42. package/dist/src/commands/sync.d.ts +183 -12
  43. package/dist/src/commands/sync.js +974 -452
  44. package/dist/src/commands/sync.js.map +1 -1
  45. package/dist/src/commands/tokens.js +1 -6
  46. package/dist/src/commands/tokens.js.map +1 -1
  47. package/dist/src/commands/users.js +77 -2
  48. package/dist/src/commands/users.js.map +1 -1
  49. package/dist/src/lib/api-client.d.ts +161 -35
  50. package/dist/src/lib/api-client.js +210 -86
  51. package/dist/src/lib/api-client.js.map +1 -1
  52. package/dist/src/lib/app-settings-descriptor.d.ts +9 -1
  53. package/dist/src/lib/app-settings-descriptor.js +22 -10
  54. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  55. package/dist/src/lib/auth-flow.d.ts +41 -1
  56. package/dist/src/lib/auth-flow.js +45 -16
  57. package/dist/src/lib/auth-flow.js.map +1 -1
  58. package/dist/src/lib/child-apps-local.d.ts +184 -0
  59. package/dist/src/lib/child-apps-local.js +282 -0
  60. package/dist/src/lib/child-apps-local.js.map +1 -0
  61. package/dist/src/lib/ci-session-request.d.ts +22 -0
  62. package/dist/src/lib/ci-session-request.js +51 -0
  63. package/dist/src/lib/ci-session-request.js.map +1 -0
  64. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +4 -1
  65. package/dist/src/lib/codegen-shared/generatedFiles.js +19 -13
  66. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  67. package/dist/src/lib/config-object-descriptor.js +5 -5
  68. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  69. package/dist/src/lib/config-payload.js +2 -3
  70. package/dist/src/lib/config-payload.js.map +1 -1
  71. package/dist/src/lib/config-surface.d.ts +2 -1
  72. package/dist/src/lib/config-surface.js +7 -28
  73. package/dist/src/lib/config-surface.js.map +1 -1
  74. package/dist/src/lib/credentials-store.d.ts +17 -2
  75. package/dist/src/lib/credentials-store.js +72 -6
  76. package/dist/src/lib/credentials-store.js.map +1 -1
  77. package/dist/src/lib/document-export-lookups.d.ts +31 -0
  78. package/dist/src/lib/document-export-lookups.js +53 -0
  79. package/dist/src/lib/document-export-lookups.js.map +1 -0
  80. package/dist/src/lib/document-export-permissions.d.ts +7 -5
  81. package/dist/src/lib/document-export-permissions.js +7 -5
  82. package/dist/src/lib/document-export-permissions.js.map +1 -1
  83. package/dist/src/lib/env-resolver-core.d.ts +69 -1
  84. package/dist/src/lib/env-resolver-core.js +137 -20
  85. package/dist/src/lib/env-resolver-core.js.map +1 -1
  86. package/dist/src/lib/env-resolver.d.ts +14 -2
  87. package/dist/src/lib/env-resolver.js +32 -10
  88. package/dist/src/lib/env-resolver.js.map +1 -1
  89. package/dist/src/lib/function-run.d.ts +4 -1
  90. package/dist/src/lib/function-run.js +24 -4
  91. package/dist/src/lib/function-run.js.map +1 -1
  92. package/dist/src/lib/function-sync.d.ts +5 -2
  93. package/dist/src/lib/function-sync.js +7 -4
  94. package/dist/src/lib/function-sync.js.map +1 -1
  95. package/dist/src/lib/generated-config-surfaces.d.ts +49 -50
  96. package/dist/src/lib/generated-config-surfaces.js +283 -166
  97. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  98. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  99. package/dist/src/lib/generated-sdk-types.js +1 -1
  100. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  101. package/dist/src/lib/init-adopt.js +12 -3
  102. package/dist/src/lib/init-adopt.js.map +1 -1
  103. package/dist/src/lib/local-state.d.ts +17 -1
  104. package/dist/src/lib/local-state.js +94 -15
  105. package/dist/src/lib/local-state.js.map +1 -1
  106. package/dist/src/lib/local-test-cases.d.ts +5 -2
  107. package/dist/src/lib/local-test-cases.js +7 -4
  108. package/dist/src/lib/local-test-cases.js.map +1 -1
  109. package/dist/src/lib/log-inspection.d.ts +25 -0
  110. package/dist/src/lib/log-inspection.js +24 -0
  111. package/dist/src/lib/log-inspection.js.map +1 -1
  112. package/dist/src/lib/logout-admin-session.d.ts +2 -1
  113. package/dist/src/lib/logout-admin-session.js +7 -1
  114. package/dist/src/lib/logout-admin-session.js.map +1 -1
  115. package/dist/src/lib/pull-write.d.ts +31 -0
  116. package/dist/src/lib/pull-write.js +49 -0
  117. package/dist/src/lib/pull-write.js.map +1 -0
  118. package/dist/src/lib/root-import-decision.d.ts +59 -0
  119. package/dist/src/lib/root-import-decision.js +95 -0
  120. package/dist/src/lib/root-import-decision.js.map +1 -0
  121. package/dist/src/lib/scope-request.d.ts +69 -0
  122. package/dist/src/lib/scope-request.js +188 -0
  123. package/dist/src/lib/scope-request.js.map +1 -0
  124. package/dist/src/lib/session-id.d.ts +9 -0
  125. package/dist/src/lib/session-id.js +28 -0
  126. package/dist/src/lib/session-id.js.map +1 -0
  127. package/dist/src/lib/snapshots.d.ts +17 -1
  128. package/dist/src/lib/snapshots.js +50 -2
  129. package/dist/src/lib/snapshots.js.map +1 -1
  130. package/dist/src/lib/sync-paths.d.ts +35 -0
  131. package/dist/src/lib/sync-paths.js +57 -5
  132. package/dist/src/lib/sync-paths.js.map +1 -1
  133. package/dist/src/lib/transient-retry.d.ts +34 -0
  134. package/dist/src/lib/transient-retry.js +57 -0
  135. package/dist/src/lib/transient-retry.js.map +1 -0
  136. package/dist/src/types/index.d.ts +21 -4
  137. package/package.json +4 -3
@@ -13,7 +13,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
13
13
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
14
14
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
15
15
  import { getCurrentAppId, getServerUrl } from "../lib/config.js";
16
- import { resolveSyncDir, resolveSnapshotsRoot, syncStatePath, syncStateReadPath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
16
+ import { requireCurrentEnvironment } from "../lib/env-resolver.js";
17
+ import { resolveSyncDir, resolveSnapshotsRoot, resolveSyncStateReadPath, resolveSyncStateWritePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
17
18
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
18
19
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
19
20
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -22,6 +23,7 @@ import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request
22
23
  import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
23
24
  import { canonicalJsonStringify } from "../lib/canonical-json.js";
24
25
  import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
26
+ import { newPullWriteTally, writePulledFile, } from "../lib/pull-write.js";
25
27
  import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
26
28
  import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
27
29
  import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
@@ -33,12 +35,12 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
33
35
  // payload builder so the diff normalizer and the wire cannot disagree.
34
36
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
35
37
  // #2644 — the one definition per configuration object, vendored from the server.
36
- import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
38
+ import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
37
39
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
38
40
  import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
39
41
  // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
40
42
  // preflight refuses exactly what the admin routes refuse.
41
- import { hoistDeprecatedChatKeys, promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
43
+ import { promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
42
44
  // #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
43
45
  import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
44
46
  // #3799 — the meaning-preserving schema translation, vendored the same way, so
@@ -98,8 +100,13 @@ function printStaleInstanceWarning(updated) {
98
100
  warn(` ${w.message}`);
99
101
  }
100
102
  }
101
- function loadSyncState(configDir) {
102
- const syncFile = syncStateReadPath(configDir);
103
+ /**
104
+ * The current environment's baseline. Every reader goes through
105
+ * `resolveSyncStateReadPath`, so a machine-local environment reads its own
106
+ * machine-local baseline and never the committed tree's (#4117).
107
+ */
108
+ function loadSyncState() {
109
+ const syncFile = resolveSyncStateReadPath();
103
110
  if (!existsSync(syncFile)) {
104
111
  return null;
105
112
  }
@@ -110,8 +117,8 @@ function loadSyncState(configDir) {
110
117
  return null;
111
118
  }
112
119
  }
113
- function saveSyncState(configDir, state) {
114
- const syncFile = syncStatePath(configDir);
120
+ function saveSyncState(state) {
121
+ const syncFile = resolveSyncStateWritePath();
115
122
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
116
123
  }
117
124
  /**
@@ -449,6 +456,30 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs, logger, opti
449
456
  const parsed = parseConfigToml(serialized);
450
457
  return hashWorkflowTomlForDiff(parsed, undefined, options);
451
458
  }
459
+ /**
460
+ * Whether a workflow file already describes the workflow `remoteToml`
461
+ * serializes (#4160), by `config diff`'s comparison: the normalized hash, with
462
+ * activation compared only when the file names a configuration.
463
+ *
464
+ * Three shapes never match, because pull has work to do on them: a file with
465
+ * `[[include]]` fragments (`reconcileWorkflowIncludes` owns those), one still
466
+ * carrying the retired inline `[[configs]]` list, and any other file push
467
+ * refuses (`pullMustRewrite`).
468
+ */
469
+ export function sameWorkflowAs(workflowKey, remoteToml) {
470
+ return (localContent) => {
471
+ const local = parseConfigToml(localContent);
472
+ if ("include" in local || "configs" in local)
473
+ return false;
474
+ if (pullMustRewrite("workflow", local))
475
+ return false;
476
+ const compareActivation = Boolean(authoredActiveConfigName(local));
477
+ return (hashWorkflowTomlForDiff(local, workflowKey, { compareActivation }) ===
478
+ hashWorkflowTomlForDiff(parseConfigToml(remoteToml), undefined, {
479
+ compareActivation,
480
+ }));
481
+ };
482
+ }
452
483
  /**
453
484
  * Build the [auth] block of app.toml from server settings (pull direction).
454
485
  *
@@ -1600,20 +1631,32 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
1600
1631
  }
1601
1632
  return payload;
1602
1633
  }
1603
- export function serializeBlobBucket(bucket, logger) {
1634
+ export function serializeBlobBucket(bucket, ruleSetIdToName = new Map(), logger) {
1635
+ const file = `blob-buckets/${bucket?.bucketKey}.toml`;
1604
1636
  // #2644 — the `[bucket]` key set comes from the vendored definition.
1605
- warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE, `blob-buckets/${bucket?.bucketKey}.toml`, logger);
1637
+ warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE, file, logger);
1638
+ // #4116 — the rule set is written by NAME, which every app can resolve. An
1639
+ // id the listing cannot name is reported and left out, never written: an id
1640
+ // in the file is the spelling a copied tree cannot resolve (the #3998 rule).
1641
+ const ruleSetName = bucket?.ruleSetId
1642
+ ? ruleSetIdToName.get(bucket.ruleSetId)
1643
+ : undefined;
1644
+ if (bucket?.ruleSetId && !ruleSetName) {
1645
+ logger?.(`${file}: rule set ${bucket.ruleSetId} is not in this app's rule-set listing; ` +
1646
+ "no ruleSetName written (an orphaned or unlisted rule set denies access closed)");
1647
+ }
1606
1648
  const data = {
1607
1649
  bucket: {
1608
1650
  key: bucket.bucketKey,
1609
1651
  ...projectRecordToToml(BLOB_BUCKET_TABLE, bucket, {
1610
- // #1020: `preset` is the honest key; a custom bucket carries ruleSetId
1611
- // instead. Omit preset for custom buckets (preset === "custom").
1652
+ // #1020: `preset` is the honest key; a custom bucket carries its rule
1653
+ // set instead. Omit preset for custom buckets (preset === "custom").
1612
1654
  preset: (record) => record.preset && record.preset !== "custom"
1613
1655
  ? record.preset
1614
1656
  : record.ruleSetId
1615
1657
  ? undefined
1616
1658
  : record.preset,
1659
+ ruleSetId: () => ruleSetName,
1617
1660
  }),
1618
1661
  },
1619
1662
  };
@@ -1624,13 +1667,80 @@ export function serializeBlobBucket(bucket, logger) {
1624
1667
  });
1625
1668
  return stringifyConfigToml(data);
1626
1669
  }
1670
+ /**
1671
+ * The entity a `blob-buckets/<key>.toml` describes (#4116) — the parse
1672
+ * `config push` builds its bodies from and `config diff` compares.
1673
+ *
1674
+ * Read in CREATE mode, so the immutable `ttlTier` stays nameable. The rule set
1675
+ * is left as `_ruleSetName` for `resolveRuleSetReference`; the deprecated
1676
+ * `ruleSetId` spelling is forwarded as the id it already is. A file carrying
1677
+ * both is refused by the preflight (`blobBucketFileErrors`) before this runs.
1678
+ *
1679
+ * An omitted `name` stays omitted: the update leaves the server's name alone,
1680
+ * and only the create body falls back to the key.
1681
+ */
1682
+ export function parseBlobBucketToml(doc) {
1683
+ const section = doc?.bucket ?? {};
1684
+ const entity = dropUndefined({
1685
+ bucketKey: section.key ?? "",
1686
+ ...buildPayloadFromToml(BLOB_BUCKET_TABLE, section, {
1687
+ mode: "create",
1688
+ values: {
1689
+ name: (value) => value || undefined,
1690
+ description: tomlOwned,
1691
+ },
1692
+ }),
1693
+ });
1694
+ // The builder read the `ruleSetName` value onto the `ruleSetId` field.
1695
+ delete entity.ruleSetId;
1696
+ if (typeof section.ruleSetName === "string" && section.ruleSetName.trim() !== "") {
1697
+ // Trimmed, as `applyRuleSetReference` does for the type configs.
1698
+ entity._ruleSetName = section.ruleSetName.trim();
1699
+ }
1700
+ else if (typeof section.ruleSetId === "string" && section.ruleSetId !== "") {
1701
+ entity.ruleSetId = section.ruleSetId;
1702
+ }
1703
+ return entity;
1704
+ }
1705
+ /**
1706
+ * The advisory `config push` prints for a bucket file still naming its rule set
1707
+ * by id (#4116). The key is deprecated, not retired: the file pushes.
1708
+ */
1709
+ export function blobBucketDeprecatedKeyWarning(filePath, tomlData) {
1710
+ const bucket = tomlData?.bucket;
1711
+ if (!bucket || bucket.ruleSetId === undefined)
1712
+ return null;
1713
+ // Beside `ruleSetName` it is a refusal instead (`blobBucketFileErrors`).
1714
+ if (bucket.ruleSetName !== undefined)
1715
+ return null;
1716
+ return (`${filePath}: bucket.ruleSetId is deprecated — a rule-set id belongs to one app, ` +
1717
+ `so a copied tree cannot resolve it; write ruleSetName = "<name>" ` +
1718
+ "(config pull rewrites the file). Retiring the key is a later change.");
1719
+ }
1720
+ /** A bucket file's own refusals, decided from the file alone (#4116). */
1721
+ export function blobBucketFileErrors(filePath, tomlData) {
1722
+ const bucket = tomlData?.bucket;
1723
+ if (bucket && bucket.ruleSetId !== undefined && bucket.ruleSetName !== undefined) {
1724
+ return [`${filePath}: bucket.ruleSetId is set beside bucket.ruleSetName; keep ruleSetName`];
1725
+ }
1726
+ // The deprecated spelling is a TOML-only key, so the declared-type check
1727
+ // reads only `ruleSetName`; the old key keeps the field's type under its
1728
+ // own name, or a mistyped id would be dropped from the body unreported.
1729
+ const field = BLOB_BUCKET_TABLE.fields.find((f) => f.field === "ruleSetId");
1730
+ if (!field)
1731
+ return [];
1732
+ return configDeclaredTypeErrors(filePath, tomlData, {
1733
+ ...BLOB_BUCKET_TABLE,
1734
+ fields: [{ ...field, tomlKey: "ruleSetId" }],
1735
+ });
1736
+ }
1627
1737
  /**
1628
1738
  * Build the PATCH payload for a blob-bucket update from local TOML.
1629
1739
  *
1630
1740
  * EXACT extraction of the inline builder shared by the existing-update and
1631
1741
  * 409-adopt branches — do NOT "clean up" the truthiness checks on the access
1632
- * model. The server treats `preset` and `accessPolicy` as mutually exclusive,
1633
- * only clears `ruleSetId` when a preset/accessPolicy is also present, and
1742
+ * model. The server treats `preset` and `ruleSetId` as mutually exclusive,
1743
+ * only clears `ruleSetId` when a preset is also present, and
1634
1744
  * refuses to leave a bucket with no access model or a blank name — so those
1635
1745
  * fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
1636
1746
  * sent. Both call sites must go through this helper so their field sets never
@@ -1639,7 +1749,9 @@ export function serializeBlobBucket(bucket, logger) {
1639
1749
  export function buildBlobBucketUpdatePayload(bucket) {
1640
1750
  // The access model's truthiness checks are the SERVER's rule and stay
1641
1751
  // verbatim; the definition contributes the field list, not the semantics.
1642
- const updatePayload = buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
1752
+ // #4116 — reads the parsed ENTITY (`parseBlobBucketToml`, its rule set
1753
+ // resolved), whose field names are the wire keys.
1754
+ const updatePayload = projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
1643
1755
  mode: "update",
1644
1756
  values: {
1645
1757
  // A preset and a rule set are mutually exclusive per request, and a
@@ -1655,12 +1767,6 @@ export function buildBlobBucketUpdatePayload(bucket) {
1655
1767
  description: tomlOwned,
1656
1768
  },
1657
1769
  });
1658
- // `accessPolicy` is the pre-#1020 spelling of `preset`, declared in the
1659
- // definition's `tomlOnlyKeys`: an unmigrated file still pushes its access
1660
- // model, and the server takes at most one of the two.
1661
- if (!updatePayload.preset && bucket?.accessPolicy) {
1662
- updatePayload.accessPolicy = bucket.accessPolicy;
1663
- }
1664
1770
  for (const key of Object.keys(updatePayload)) {
1665
1771
  if (updatePayload[key] === undefined)
1666
1772
  delete updatePayload[key];
@@ -1743,7 +1849,8 @@ export function promptConfigPayload(tomlConfig, options = {}) {
1743
1849
  : { description: tomlConfig.description ?? null, ...shared };
1744
1850
  }
1745
1851
  /**
1746
- * Build the POST body for a blob-bucket create from local TOML.
1852
+ * Build the POST body for a blob-bucket create from the parsed entity
1853
+ * (`parseBlobBucketToml`, its rule set resolved).
1747
1854
  *
1748
1855
  * `ttlTier` is here and absent from the update body because the definition says
1749
1856
  * so (`writableOn: ["create"]`) — the retention tier is fixed at creation.
@@ -1751,7 +1858,7 @@ export function promptConfigPayload(tomlConfig, options = {}) {
1751
1858
  export function buildBlobBucketCreatePayload(key, bucket) {
1752
1859
  const payload = {
1753
1860
  bucketKey: key,
1754
- ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
1861
+ ...projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
1755
1862
  mode: "create",
1756
1863
  values: {
1757
1864
  name: (value) => value || key,
@@ -1761,9 +1868,6 @@ export function buildBlobBucketCreatePayload(key, bucket) {
1761
1868
  },
1762
1869
  }),
1763
1870
  };
1764
- if (!payload.preset && bucket?.accessPolicy) {
1765
- payload.accessPolicy = bucket.accessPolicy;
1766
- }
1767
1871
  for (const field of Object.keys(payload)) {
1768
1872
  if (payload[field] === undefined)
1769
1873
  delete payload[field];
@@ -2068,15 +2172,10 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
2068
2172
  * differ from the one the server has always received.
2069
2173
  */
2070
2174
  const tomlOwned = (value) => (create ? value : (value ?? null));
2071
- // #3626 §4 — the nine chat keys moved under `[configs.chat]`, and the flat
2072
- // spelling is accepted as DEPRECATED. Hoisting here rather than at each call
2073
- // site means push, `config diff` and the create-seed builder all read a file
2074
- // the same way, so an unmigrated file and its pulled rewrite compare Synced.
2075
- // A key set BOTH ways is refused in preflight
2076
- // (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
2077
- // standing so nothing here has to guess.
2078
- const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
2079
- return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
2175
+ // #3626 — the chat keys are read from `[configs.chat]` only. Their flat
2176
+ // spelling is retired (#3642) and refused in preflight, so a root chat key
2177
+ // never reaches this builder as a value.
2178
+ return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
2080
2179
  mode,
2081
2180
  values: {
2082
2181
  provider: (value) => (create ? value || "openrouter" : value),
@@ -2202,16 +2301,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2202
2301
  // once beside the definitions (`src/config-surface/retired-keys.ts`), not the
2203
2302
  // generic "check the spelling, or upgrade the CLI" hint, which is exactly
2204
2303
  // backwards for it. The server's create/update handlers read the same table.
2304
+ // #3642 — a spelling retired from TOML only (a flat chat key under
2305
+ // `[[configs]]`) is answered from the same module, before the misplaced-key
2306
+ // line below could send the author to the block without saying why.
2205
2307
  const prefix = table.tomlPath.join(".");
2206
2308
  const retiredHere = [...unknown]
2207
- .filter((k) => retiredConfigKey(prefix, k))
2309
+ .filter((k) => retiredTomlKey(prefix, k))
2208
2310
  .sort();
2209
2311
  const trulyUnknown = [...unknown]
2210
- .filter((k) => !retiredConfigKey(prefix, k))
2312
+ .filter((k) => !retiredTomlKey(prefix, k))
2211
2313
  .sort();
2212
2314
  const messages = [];
2213
2315
  for (const key of retiredHere) {
2214
- messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
2316
+ messages.push(`${filePath}: ${prefix}.${key} — ${retiredTomlKey(prefix, key).toml}`);
2215
2317
  }
2216
2318
  // #3626, SO3626-013 — a key the definition DOES declare, written at the
2217
2319
  // table root when it is authored under a group. Before grouping,
@@ -2309,49 +2411,72 @@ export function unknownKeyPreflightTargets() {
2309
2411
  */
2310
2412
  function localConfigRowKey(label, tomlData, file) {
2311
2413
  const fallback = basename(file, ".toml");
2312
- const stated = (table, key) => tomlData?.[table]?.[key] || fallback;
2313
- switch (label) {
2314
- case "integration":
2315
- return stated("integration", "key");
2316
- case "webhook":
2317
- return stated("webhook", "key");
2318
- case "cron-trigger":
2319
- return stated("cronTrigger", "key");
2320
- case "blob-bucket":
2321
- return stated("bucket", "key");
2322
- case "prompt":
2323
- return stated("prompt", "key");
2324
- case "workflow":
2325
- return stated("workflow", "key");
2326
- // #3320 — the declared key, like the function preflight collector, the
2327
- // apply loop and `--only`. A renamed file is one object, so both
2328
- // collectors' messages land on the one comparison row.
2329
- case "function":
2330
- return stated("function", "key");
2331
- case "email-template":
2332
- return stated("template", "emailType");
2333
- case "database-type-config":
2334
- return stated("type", "databaseType");
2335
- case "group-type-config":
2336
- return stated("groupTypeConfig", "groupType");
2337
- case "collection-type-config":
2338
- return stated("collectionTypeConfig", "collectionType");
2339
- case "metadata-category-config": {
2340
- const section = tomlData?.metadataCategoryConfig || {};
2341
- if (section.resourceType && section.category) {
2342
- return `${section.resourceType}#${section.category}`;
2343
- }
2344
- // `resourceType.category.toml`, the filename fallback push and diff share.
2345
- const dot = fallback.indexOf(".");
2346
- return dot > 0
2347
- ? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
2348
- : fallback;
2414
+ const identity = CONFIG_ROW_IDENTITY[label];
2415
+ if (label === "metadata-category-config") {
2416
+ const section = tomlData?.metadataCategoryConfig || {};
2417
+ if (section.resourceType && section.category) {
2418
+ return `${section.resourceType}#${section.category}`;
2349
2419
  }
2350
- // Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
2351
- // still updates the same entity, so the row is the file's.
2352
- default:
2353
- return fallback;
2420
+ // `resourceType.category.toml`, the filename fallback push and diff share.
2421
+ const dot = fallback.indexOf(".");
2422
+ return dot > 0
2423
+ ? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
2424
+ : fallback;
2425
+ }
2426
+ // Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
2427
+ // still updates the same entity, so the row is the file's.
2428
+ if (!identity)
2429
+ return fallback;
2430
+ const [table, key] = identity[0];
2431
+ return tomlData?.[table]?.[key] || fallback;
2432
+ }
2433
+ /**
2434
+ * The `[table].key` fields that name the object a config file describes, per
2435
+ * type — what `localConfigRowKey` reads before falling back to the file name.
2436
+ * A type absent here (rule sets, test cases) is identified by its file name
2437
+ * alone.
2438
+ */
2439
+ const CONFIG_ROW_IDENTITY = {
2440
+ integration: [["integration", "key"]],
2441
+ webhook: [["webhook", "key"]],
2442
+ "cron-trigger": [["cronTrigger", "key"]],
2443
+ "blob-bucket": [["bucket", "key"]],
2444
+ prompt: [["prompt", "key"]],
2445
+ workflow: [["workflow", "key"]],
2446
+ // #3320 — the declared key, like the function preflight collector, the
2447
+ // apply loop and `--only`. A renamed file is one object, so both
2448
+ // collectors' messages land on the one comparison row.
2449
+ function: [["function", "key"]],
2450
+ "email-template": [["template", "emailType"]],
2451
+ "database-type-config": [["type", "databaseType"]],
2452
+ "group-type-config": [["groupTypeConfig", "groupType"]],
2453
+ "collection-type-config": [["collectionTypeConfig", "collectionType"]],
2454
+ "metadata-category-config": [
2455
+ ["metadataCategoryConfig", "resourceType"],
2456
+ ["metadataCategoryConfig", "category"],
2457
+ ],
2458
+ };
2459
+ /**
2460
+ * The local file as `config diff` compares it against the server object pull
2461
+ * writes to `file` (#4160), or `null` when the file names a different object.
2462
+ *
2463
+ * Diff pairs a file with a server object by the identity the file states, or
2464
+ * its file name when it states none, and injects that identity before
2465
+ * comparing. So a file whose stated key is another object's is not this
2466
+ * object's file whatever its other values say, and a file that leaves its
2467
+ * identity to the file name is compared with the identity filled in.
2468
+ */
2469
+ function localConfigWithServerIdentity(label, local, server, file) {
2470
+ if (localConfigRowKey(label, local, file) !== localConfigRowKey(label, server, file)) {
2471
+ return null;
2354
2472
  }
2473
+ const resolved = { ...local };
2474
+ for (const [table, key] of CONFIG_ROW_IDENTITY[label] ?? []) {
2475
+ if (server?.[table]?.[key] === undefined)
2476
+ continue;
2477
+ resolved[table] = { ...(resolved[table] ?? {}), [key]: server[table][key] };
2478
+ }
2479
+ return resolved;
2355
2480
  }
2356
2481
  /**
2357
2482
  * `config push`'s preflight — every check the authored files decide on their
@@ -2382,7 +2507,7 @@ function localConfigRowKey(label, tomlData, file) {
2382
2507
  * that being a visible change to this signature.
2383
2508
  */
2384
2509
  export async function runConfigPushPreflight(input) {
2385
- const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
2510
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, } = input;
2386
2511
  /**
2387
2512
  * Narrow a type's file list to the `--only` selection — the same one-line
2388
2513
  * closure the action holds, rebuilt from the selection it passed in rather
@@ -2523,6 +2648,24 @@ export async function runConfigPushPreflight(input) {
2523
2648
  }
2524
2649
  }
2525
2650
  }
2651
+ // #4116 — the deprecated `ruleSetId` spelling in a bucket file. Advisory
2652
+ // only: the file pushes. Bounded by `--only`, like the notice above.
2653
+ const preflightBucketsDir = join(configDir, "blob-buckets");
2654
+ if (existsSync(preflightBucketsDir)) {
2655
+ for (const file of selectFiles("blob-bucket", preflightBucketsDir, readdirSync(preflightBucketsDir).filter((f) => f.endsWith(".toml")))) {
2656
+ let tomlData;
2657
+ try {
2658
+ tomlData = parseTomlFile(join(preflightBucketsDir, file));
2659
+ }
2660
+ catch {
2661
+ // A file that does not parse is reported by the collector below.
2662
+ continue;
2663
+ }
2664
+ const advice = blobBucketDeprecatedKeyWarning(`blob-buckets/${file}`, tomlData);
2665
+ if (advice)
2666
+ warn(` ${advice}`);
2667
+ }
2668
+ }
2526
2669
  // Validate app.toml settings up-front (issue #1033). A mistyped value
2527
2670
  // is a hard error that must abort BEFORE any mutation — same
2528
2671
  // fail-before-mutate contract as the vars preflight below. The apply
@@ -2539,10 +2682,12 @@ export async function runConfigPushPreflight(input) {
2539
2682
  // #2880 DSO-001 — the same exclusion the apply block computes, so
2540
2683
  // the preflight validates the body that will actually be sent.
2541
2684
  excludeName: appSettingsNameExcluded({
2685
+ localEnvironment,
2542
2686
  isCrossAppPush: !!isCrossAppPush,
2543
2687
  recordedExcludedName: syncState?.entities?.app?.excludedName,
2544
2688
  fileName: parsedApp?.app?.name,
2545
2689
  }),
2690
+ nameOptional: localEnvironment,
2546
2691
  });
2547
2692
  for (const e of appErrors) {
2548
2693
  preflightValidationErrors.push(` app.toml: ${e}`);
@@ -2680,11 +2825,9 @@ export async function runConfigPushPreflight(input) {
2680
2825
  preflightValidationErrors.push(` ${message}`);
2681
2826
  }
2682
2827
  }
2683
- // #3626 §4 — one advisory line per prompt file still written in the flat
2684
- // chat spelling, beside the `celContextAccess` notice above. The push
2685
- // continues and the keys are applied; `config pull` rewrites the file, so a
2686
- // single pull-then-push clears the notice. Printed even under `--dry-run`,
2687
- // which never reaches the apply — the notice is about the FILE.
2828
+ // #3798 — the prompt files this push selects, read once more for the check
2829
+ // that needs app state: an agent's functions must exist. Bounded by `--only`,
2830
+ // and run before anything is applied.
2688
2831
  const preflightPromptsDir = join(configDir, "prompts");
2689
2832
  // #3798 — the functions an agent's references are checked against, read
2690
2833
  // once and only when a selected prompt is an agent that names one.
@@ -2700,9 +2843,6 @@ export async function runConfigPushPreflight(input) {
2700
2843
  // A file that does not parse is reported by the collector above.
2701
2844
  continue;
2702
2845
  }
2703
- const notice = promptFlatChatKeyWarning(filePath, tomlData);
2704
- if (notice)
2705
- warn(` ${notice}`);
2706
2846
  // #3798 — an agent's tool, turn-context and history functions must
2707
2847
  // exist, and a tool's must declare both schemas, before anything is
2708
2848
  // applied. Bounded by `--only` like the rest of this loop.
@@ -2775,22 +2915,16 @@ export async function runConfigPushPreflight(input) {
2775
2915
  /**
2776
2916
  * The blocks one `[[configs]]` entry carries that its prompt's kind does not
2777
2917
  * name — #3798. An agent's entry may carry only `[configs.agent]`; a chat or
2778
- * decisions entry may not carry it. `movedFlat` are the deprecated flat chat
2779
- * keys the hoist moved under `chat`, named as the author wrote them.
2918
+ * decisions entry may not carry it.
2780
2919
  *
2781
2920
  * Only the blocks the other arms of `resolvePromptKind` cannot see are checked
2782
2921
  * here: a decisions block on a chat entry, and chat keys on a decisions entry,
2783
2922
  * stay the resolver's refusals with their #3626 wording.
2784
2923
  */
2785
- function promptEntryBlockErrors(kind, entry, movedFlat) {
2924
+ function promptEntryBlockErrors(kind, entry) {
2786
2925
  const has = (block) => entry != null && typeof entry === "object" && entry[block] !== undefined;
2787
2926
  const errors = [];
2788
2927
  if (kind === "agent") {
2789
- if (movedFlat.length > 0) {
2790
- errors.push(`sets ${movedFlat.join(", ")} flat — the deprecated [configs.chat] ` +
2791
- 'spelling — but prompt kind is "agent", so it may carry only a ' +
2792
- "[configs.agent] block; write them under [configs.agent]");
2793
- }
2794
2928
  for (const block of ["chat", "decisions"]) {
2795
2929
  if (!has(block))
2796
2930
  continue;
@@ -2810,10 +2944,9 @@ function promptEntryBlockErrors(kind, entry, movedFlat) {
2810
2944
  /**
2811
2945
  * A prompt file's KIND errors — #3626, decidable from the file alone.
2812
2946
  *
2813
- * Three refusals, all of them the same rule seen from different angles: a key
2814
- * set both flat and under `[configs.chat]` (the hoist will not guess), a
2815
- * `[[configs]]` entry carrying the block of the OTHER kind, and a malformed
2816
- * decisions declaration. The last two come from `resolvePromptKind`, the same
2947
+ * Two refusals, the same rule seen from different angles: a `[[configs]]`
2948
+ * entry carrying the block of the OTHER kind, and a malformed decisions
2949
+ * declaration. Both come from `resolvePromptKind`, the same
2817
2950
  * vendored function the admin routes validate with, so the CLI's refusal and
2818
2951
  * the server's cannot disagree — and this one lands in the preflight, before
2819
2952
  * the first mutating call.
@@ -2868,27 +3001,28 @@ export function promptKindFileErrors(filePath, tomlData) {
2868
3001
  errors.push(`${filePath}: ${refusal.message}`);
2869
3002
  }
2870
3003
  }
2871
- for (const [index, entry] of entries.entries()) {
2872
- const hoisted = hoistDeprecatedChatKeys(entry);
2873
- for (const key of hoisted.conflicts) {
2874
- errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
2875
- `[configs.chat]; keep the [configs.chat] spelling`);
2876
- }
3004
+ for (const [index, rawEntry] of entries.entries()) {
3005
+ // #3642 — the blocks are read as the file wrote them. A chat key at the
3006
+ // entry's root is the retired flat spelling, which the unknown-key check
3007
+ // refuses on its own; it is not a value any rule here reads.
3008
+ const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry)
3009
+ ? rawEntry
3010
+ : {};
2877
3011
  // #3798 — the block rule for three kinds, stated per entry: an entry may
2878
3012
  // carry only the block its prompt's kind names. An agent's `[configs.agent]`
2879
3013
  // holds keys chat also holds, so a stray block would otherwise be read (or
2880
3014
  // ignored) in silence by the payload builder.
2881
- const blockErrors = promptEntryBlockErrors(fileKind, entry, hoisted.moved).map((message) => `${filePath}: configs[${index}] ${message}`);
3015
+ const blockErrors = promptEntryBlockErrors(fileKind, entry).map((message) => `${filePath}: configs[${index}] ${message}`);
2882
3016
  if (blockErrors.length > 0) {
2883
3017
  errors.push(...blockErrors);
2884
3018
  continue;
2885
3019
  }
2886
3020
  if (fileKind === "agent") {
2887
- const agent = hoisted.entry.agent ?? {};
3021
+ const agent = entry.agent ?? {};
2888
3022
  const resolution = resolvePromptKind({
2889
3023
  kind: "agent",
2890
- provider: hoisted.entry.provider,
2891
- model: hoisted.entry.model,
3024
+ provider: entry.provider,
3025
+ model: entry.model,
2892
3026
  config: {
2893
3027
  systemPrompt: agent.systemPrompt,
2894
3028
  temperature: agent.temperature,
@@ -2906,8 +3040,8 @@ export function promptKindFileErrors(filePath, tomlData) {
2906
3040
  }
2907
3041
  continue;
2908
3042
  }
2909
- const chat = hoisted.entry.chat ?? {};
2910
- const decisions = hoisted.entry.decisions ?? {};
3043
+ const chat = entry.chat ?? {};
3044
+ const decisions = entry.decisions ?? {};
2911
3045
  // #3626 — the FIRST entry of a chat prompt carries the create seed's
2912
3046
  // placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
2913
3047
  // file names no template, which is how a prompt authored without one has
@@ -2927,8 +3061,8 @@ export function promptKindFileErrors(filePath, tomlData) {
2927
3061
  : chat.userPromptTemplate;
2928
3062
  const resolution = resolvePromptKind({
2929
3063
  kind: tomlData?.prompt?.kind,
2930
- provider: hoisted.entry.provider,
2931
- model: hoisted.entry.model,
3064
+ provider: entry.provider,
3065
+ model: entry.model,
2932
3066
  config: {
2933
3067
  systemPrompt: chat.systemPrompt,
2934
3068
  userPromptTemplate,
@@ -2953,12 +3087,12 @@ export function promptKindFileErrors(filePath, tomlData) {
2953
3087
  // against what the server holds.
2954
3088
  if (resolution.kind === "chat") {
2955
3089
  const strict = resolvePromptStrictOutput({
2956
- provider: hoisted.entry.provider,
3090
+ provider: entry.provider,
2957
3091
  strictOutput: chat.strictOutput,
2958
3092
  outputFormat: chat.outputFormat,
2959
3093
  configOutputSchema: chat.outputSchema,
2960
3094
  promptOutputSchema: tomlData?.prompt?.outputSchema,
2961
- status: hoisted.entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
3095
+ status: entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
2962
3096
  });
2963
3097
  if (strict.ok === false) {
2964
3098
  errors.push(`${filePath}: configs[${index}] — ${strict.error} (${strict.code})`);
@@ -3174,26 +3308,64 @@ export function promptKindImmutableOutcome(storedKind, localDoc) {
3174
3308
  };
3175
3309
  }
3176
3310
  /**
3177
- * The one deprecation notice a prompt file using the FLAT chat spelling earns
3178
- * — #3626 §4, beside the `celContextAccess` notice's precedent.
3311
+ * The file-decidable refusals `config push` gives one config file — the
3312
+ * per-file body of `collectConfigFileValidationErrors`, shared with `config
3313
+ * pull` (#4160), which never keeps a local file push would refuse.
3179
3314
  *
3180
- * Advisory: the push continues and the keys are applied. `null` when the file
3181
- * is already written in the grouped spelling, so a migrated tree is silent.
3315
+ * `tables` are the tables whose unrecognized keys are checked here (none for a
3316
+ * workflow, whose own validator owns that message); declared types run over
3317
+ * every table of the surface.
3182
3318
  */
3183
- export function promptFlatChatKeyWarning(filePath, tomlData) {
3184
- const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
3185
- const moved = new Set();
3186
- for (const entry of entries) {
3187
- for (const key of hoistDeprecatedChatKeys(entry).moved)
3188
- moved.add(key);
3319
+ export function configFileValidationMessages(filePath, tomlData, surface, tables) {
3320
+ const messages = [];
3321
+ // The document's own shape first: an unrecognized top-level table makes
3322
+ // every per-table check below read an absent table, so its silence would
3323
+ // be misleading.
3324
+ messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
3325
+ for (const table of tables) {
3326
+ const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
3327
+ if (unknownKeyError)
3328
+ messages.push(unknownKeyError);
3189
3329
  }
3190
- if (moved.size === 0)
3191
- return null;
3192
- const named = [...moved].sort().join(", ");
3193
- return (`${filePath}: ${named} ${moved.size === 1 ? "is" : "are"} written flat ` +
3194
- "under [[configs]]; this spelling is deprecated — move them under " +
3195
- "[configs.chat] (`config pull` rewrites the file). Rejecting the flat " +
3196
- "spelling is tracked in #3642.");
3330
+ // Declared types run over EVERY table of the surface, workflows included
3331
+ // — `tables` above is pruned for workflows because `validateWorkflowToml`
3332
+ // owns their unrecognized-key message, which is a different check.
3333
+ for (const table of surface.tables) {
3334
+ messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
3335
+ }
3336
+ // #3626 — the prompt kind's own file-decidable refusals, run by the same
3337
+ // collector so `config push` and `config diff` cannot disagree about
3338
+ // which prompt files are pushable.
3339
+ if (surface.label === "prompt") {
3340
+ messages.push(...promptKindFileErrors(filePath, tomlData));
3341
+ }
3342
+ // #4116 — a bucket naming its rule set both ways.
3343
+ if (surface.label === "blob-bucket") {
3344
+ messages.push(...blobBucketFileErrors(filePath, tomlData));
3345
+ }
3346
+ return messages;
3347
+ }
3348
+ /**
3349
+ * Whether `config pull` must rewrite a local file whatever it says (#4160):
3350
+ * `config push` would refuse it, or it spells something in a deprecated form
3351
+ * pull exists to migrate (a bucket naming its rule set by id). Keeping such a
3352
+ * file because its VALUES match would leave the tree unpushable, or the
3353
+ * deprecation unresolved, after a pull that reported success.
3354
+ */
3355
+ export function pullMustRewrite(label, tomlData) {
3356
+ // A test-case sidecar is not one of the preflight's per-directory targets;
3357
+ // push refuses it through its own collector.
3358
+ if (label === "test-case") {
3359
+ return testCaseSidecarPreflightErrors("", tomlData).length > 0;
3360
+ }
3361
+ const target = unknownKeyPreflightTargets().find((t) => t.surface.label === label);
3362
+ if (target) {
3363
+ const tables = label === "workflow" ? [WORKFLOW_TABLE] : target.tables;
3364
+ if (configFileValidationMessages("", tomlData, target.surface, tables).length > 0) {
3365
+ return true;
3366
+ }
3367
+ }
3368
+ return (label === "blob-bucket" && blobBucketDeprecatedKeyWarning("", tomlData) !== null);
3197
3369
  }
3198
3370
  export function collectConfigFileValidationErrors(configDir) {
3199
3371
  const found = [];
@@ -3214,28 +3386,7 @@ export function collectConfigFileValidationErrors(configDir) {
3214
3386
  catch {
3215
3387
  continue;
3216
3388
  }
3217
- const messages = [];
3218
- // The document's own shape first: an unrecognized top-level table makes
3219
- // every per-table check below read an absent table, so its silence would
3220
- // be misleading.
3221
- messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
3222
- for (const table of tables) {
3223
- const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
3224
- if (unknownKeyError)
3225
- messages.push(unknownKeyError);
3226
- }
3227
- // Declared types run over EVERY table of the surface, workflows included
3228
- // — `tables` above is pruned for workflows because `validateWorkflowToml`
3229
- // owns their unrecognized-key message, which is a different check.
3230
- for (const table of surface.tables) {
3231
- messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
3232
- }
3233
- // #3626 — the prompt kind's own file-decidable refusals, run by the same
3234
- // collector so `config push` and `config diff` cannot disagree about
3235
- // which prompt files are pushable.
3236
- if (surface.label === "prompt") {
3237
- messages.push(...promptKindFileErrors(filePath, tomlData));
3238
- }
3389
+ const messages = configFileValidationMessages(filePath, tomlData, surface, tables);
3239
3390
  if (messages.length === 0)
3240
3391
  continue;
3241
3392
  found.push({
@@ -4535,16 +4686,16 @@ function jsonFieldsForDiff(table, entity) {
4535
4686
  /**
4536
4687
  * One test-case reference as the comparison reads it (#2880 behavior 17).
4537
4688
  *
4538
- * A reference reaches the projection in one of three states, and all three
4539
- * describe the same thing on both sides once this is applied:
4689
+ * A reference reaches the projection as the value in its NAME slot, in one of
4690
+ * two states, and both describe the same thing on both sides once this is
4691
+ * applied:
4540
4692
  *
4541
- * - a portable NAME (`configName = "default"`), which a test file carries so
4542
- * it survives a move between apps;
4543
- * - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
4544
- * pinned by id would differ forever from the file pull writes for it;
4545
- * - a legacy ID that does NOT resolve, which stays the id, because pull writes
4546
- * an unresolvable id straight back and comparing it away would hide a pin
4547
- * the next push still sends.
4693
+ * - a portable NAME (`configName = "default"`) — what a test file carries,
4694
+ * and what the server's id renders as when the lookups resolve it;
4695
+ * - a live ID the lookups could NOT resolve, which the comparison's rendering
4696
+ * (`serializeTestCaseForComparison`) carries in the name slot and which
4697
+ * stays the id here, so a case pinned to a deleted or unlistable config is
4698
+ * a difference against an unpinned file rather than hidden (#3998).
4548
4699
  */
4549
4700
  function testCaseReference(value, idToName) {
4550
4701
  if (typeof value !== "string" || value === "")
@@ -4819,28 +4970,14 @@ const CONFIG_DIFF_SPECS = {
4819
4970
  "blob-bucket": {
4820
4971
  label: "blob-bucket",
4821
4972
  table: BLOB_BUCKET_TABLE,
4822
- parse: (doc) => {
4823
- const bucket = { ...(doc?.bucket ?? {}) };
4824
- // #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
4825
- // the wire and still authored in unmigrated files. Both spellings mean
4826
- // the same access model, so they project the same way; without this an
4827
- // unmigrated file reports a difference no push can clear (push sends the
4828
- // legacy key and the server answers with `preset`).
4829
- if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
4830
- bucket.preset = bucket.accessPolicy;
4831
- }
4832
- return {
4833
- bucketKey: doc?.bucket?.key ?? "",
4834
- ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
4835
- mode: "create",
4836
- values: {
4837
- name: (value) => value || doc?.bucket?.key || "",
4838
- description: tomlOwned,
4839
- },
4840
- }),
4841
- };
4842
- },
4843
- serialize: (record) => serializeBlobBucket(record),
4973
+ parse: (doc) => parseBlobBucketToml(doc),
4974
+ // #4116 — the rule set is compared by the thing referred to: the record's
4975
+ // id is rendered as its name, and both sides resolve the name back.
4976
+ serialize: (record, maps) => serializeBlobBucket(record, maps.ruleSetIdToName),
4977
+ extras: (entity) => ({
4978
+ _unresolvedRuleSetName: entity._unresolvedRuleSetName,
4979
+ }),
4980
+ ruleSetRef: true,
4844
4981
  // `name` is trimmed on the way in and a blank description is stored as
4845
4982
  // none (DSO-003).
4846
4983
  canonicalize: (entity) => canonicalizeServerTextFields(entity, {
@@ -4862,9 +4999,14 @@ const CONFIG_DIFF_SPECS = {
4862
4999
  const keys = [];
4863
5000
  if (!bucket.name)
4864
5001
  keys.push("name");
4865
- if (!bucket.preset && !bucket.accessPolicy)
5002
+ if (!bucket.preset)
4866
5003
  keys.push("preset");
4867
- if (!bucket.ruleSetId)
5004
+ // Either spelling manages the rule set (#4116) — read as the parse reads
5005
+ // it, so a blank name is no reference rather than a difference the
5006
+ // update cannot carry.
5007
+ const named = typeof bucket.ruleSetName === "string" && bucket.ruleSetName.trim() !== "";
5008
+ const legacy = typeof bucket.ruleSetId === "string" && bucket.ruleSetId !== "";
5009
+ if (!named && !legacy)
4868
5010
  keys.push("ruleSetId");
4869
5011
  return keys;
4870
5012
  },
@@ -4874,17 +5016,15 @@ const CONFIG_DIFF_SPECS = {
4874
5016
  // edited expectation read as Synced while push applied it from the file's
4875
5017
  // byte hash.
4876
5018
  //
4877
- // The type's own difficulty is that a reference has two legal spellings — the
4878
- // portable `configName` a test file carries so it survives a move between
4879
- // apps, and the legacy `configId` pull still writes back when the name cannot
4880
- // be resolved. They name the same thing, so they project the same way; the
4881
- // `extra` context is what makes that possible (the id→name lookups are not in
4882
- // the document).
5019
+ // The type's own difficulty is that a reference is a NAME in the file and an
5020
+ // id on the server. The comparison renders the server's id through the
5021
+ // id→name lookups (which are not in the document — the `extra` context
5022
+ // carries them), so both sides project the thing referred to.
4883
5023
  "test-case": {
4884
5024
  label: "test-case",
4885
5025
  table: TEST_CASE_TABLE,
4886
5026
  parse: (doc) => parseTestCaseToml(doc),
4887
- serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
5027
+ serialize: (record, _maps, extra) => serializeTestCaseForComparison(record, extra?.lookupMaps),
4888
5028
  // The server trims a test-case name on write (`src/admin-api.ts`), so a
4889
5029
  // padded one in the file is not a difference — it is what the server will
4890
5030
  // store (#2880 DSO-003).
@@ -4894,12 +5034,11 @@ const CONFIG_DIFF_SPECS = {
4894
5034
  },
4895
5035
  extras: (entity, _doc, extra) => ({
4896
5036
  // These three OVERRIDE the definition's fields of the same name: the
4897
- // value compared is the thing referred to, not the spelling that referred
4898
- // to it. Precedence matches the push path exactly — a file carrying both
4899
- // an id and a name is pushed on the id, so it is compared on the id.
4900
- configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
4901
- evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
4902
- evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
5037
+ // value compared is the thing referred to, read from the name the file
5038
+ // (or the comparison's rendering of the server record) carries.
5039
+ configId: testCaseReference(entity._configName, extra?.lookupMaps?.configIdToName),
5040
+ evaluatorPromptId: testCaseReference(entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
5041
+ evaluatorConfigId: testCaseReference(entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
4903
5042
  }),
4904
5043
  describe: (entity) => `test case "${entity?.name}"`,
4905
5044
  },
@@ -4984,6 +5123,15 @@ export function projectRemoteConfig(spec, record, maps, extra) {
4984
5123
  export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
4985
5124
  const local = projectLocalConfig(spec, localParsed, maps, localExtra);
4986
5125
  const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
5126
+ reconcileUnmanagedFields(spec, localParsed, local, remote);
5127
+ return { local, remote };
5128
+ }
5129
+ /**
5130
+ * Take the server's value for every field the local file leaves unmanaged
5131
+ * (#2880), on `local` in place — the reconciliation `projectConfigPair` makes,
5132
+ * shared with `config pull`'s keep-or-write comparison (#4160).
5133
+ */
5134
+ function reconcileUnmanagedFields(spec, localParsed, local, remote) {
4987
5135
  for (const key of spec.unmanaged?.(localParsed) ?? []) {
4988
5136
  // Key-for-key, so an unmanaged field the server does not hold either is
4989
5137
  // absent on both sides rather than present-and-undefined on one.
@@ -4992,7 +5140,20 @@ export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra,
4992
5140
  else
4993
5141
  delete local[key];
4994
5142
  }
4995
- return { local, remote };
5143
+ }
5144
+ /**
5145
+ * One `config diff` row's verdict once both sides exist (#4116, DSO-4116-003).
5146
+ *
5147
+ * A type whose files name a rule set cannot be compared without the app's
5148
+ * rule-set listing: an unresolvable reference would read as a difference that
5149
+ * is not there. So when the listing failed the row says so rather than
5150
+ * comparing; otherwise `compare` decides.
5151
+ */
5152
+ export function configDiffRowVerdict(parsed, remote, compare, contentComparable) {
5153
+ if (!contentComparable) {
5154
+ return { status: "exists", hint: "content not compared (rule sets unavailable)" };
5155
+ }
5156
+ return compare(parsed, remote);
4996
5157
  }
4997
5158
  /**
4998
5159
  * One `config diff` row's content verdict for a registered type (#2880).
@@ -5506,6 +5667,59 @@ export function configSemanticHash(label, filePath, maps) {
5506
5667
  return undefined;
5507
5668
  }
5508
5669
  }
5670
+ /**
5671
+ * Whether a local config file says what the file `config pull` would write
5672
+ * says (#4160) — the definition-projected field set `config diff` compares, so
5673
+ * a file diff calls Synced keeps its comments, key order and explicit defaults
5674
+ * through a pull.
5675
+ *
5676
+ * Both sides go through the same projection with the same context, so
5677
+ * `extra` is the context a reference is read through (a test case's lookups),
5678
+ * never server-only rows. As in diff, the file is paired by the identity it
5679
+ * states (or `filePath`'s name), and the fields it leaves unmanaged take the
5680
+ * server's values — so a kept file's baseline is the server's projection
5681
+ * (`pulledSemanticHash`), never the file's own.
5682
+ */
5683
+ export function sameConfigAs(label, serverContent, filePath, maps, extra) {
5684
+ const spec = configDiffSpec(label);
5685
+ return (localContent) => {
5686
+ const parsed = parseConfigToml(localContent);
5687
+ if (pullMustRewrite(label, parsed))
5688
+ return false;
5689
+ const server = parseConfigToml(serverContent);
5690
+ const local = localConfigWithServerIdentity(label, parsed, server, filePath);
5691
+ if (local === null)
5692
+ return false;
5693
+ const localProjected = projectLocalConfig(spec, local, maps, extra);
5694
+ const serverProjected = projectLocalConfig(spec, server, maps, extra);
5695
+ reconcileUnmanagedFields(spec, local, localProjected, serverProjected);
5696
+ return (computeExpandedContentHash(localProjected) ===
5697
+ computeExpandedContentHash(serverProjected));
5698
+ };
5699
+ }
5700
+ /**
5701
+ * The semantic baseline `config pull` stamps for a config file (#4160): the
5702
+ * projection of what the server serializes, whether the file was written or
5703
+ * kept. A kept file can leave unmanaged fields or its identity to the server,
5704
+ * so hashing it would record a baseline the server never had.
5705
+ */
5706
+ export function pulledSemanticHash(label, serverContent, maps) {
5707
+ try {
5708
+ return hashLocalConfigForDiff(configDiffSpec(label), parseConfigToml(serverContent), maps);
5709
+ }
5710
+ catch {
5711
+ return undefined;
5712
+ }
5713
+ }
5714
+ /**
5715
+ * Whether a local TOML file parses to the same document as `serverContent`
5716
+ * (#4160): the comparison for `vars.toml`, which has no field definition
5717
+ * behind it, so only comments, key order and spelling are free.
5718
+ */
5719
+ export function sameTomlAs(serverContent) {
5720
+ return (localContent) => canonicalJsonStringify(parseConfigToml(localContent)) ===
5721
+ canonicalJsonStringify(parseConfigToml(serverContent));
5722
+ }
5509
5723
  /**
5510
5724
  * The sync state `config push` writes into (#2731 B6, closing #2374).
5511
5725
  *
@@ -5718,49 +5932,81 @@ export function getTestsDir(configDir, blockType, blockKey) {
5718
5932
  function blockSelectionLabel(blockType) {
5719
5933
  return blockType === "script" ? "transform" : blockType;
5720
5934
  }
5721
- export function serializeTestCase(testCase, lookupMaps, options = {}) {
5722
- // #2644 — name any server key this CLI version does not know rather than
5723
- // dropping it silently on the way into the file. Test cases are a registered
5724
- // surface; their sidecar shape is the only thing that sets them apart.
5725
- warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
5726
- // Resolve ID references to key-based references
5727
- const configName = testCase.configId && lookupMaps?.configIdToName
5728
- ? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
5729
- const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
5730
- ? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
5731
- const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
5732
- ? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
5935
+ /**
5936
+ * The `[test]` table for one server record, and the references it could not
5937
+ * name (#3998).
5938
+ *
5939
+ * A sidecar authors its references by NAME only — the id spellings are retired
5940
+ * — so a reference whose id has no lookup entry has no honest spelling in a
5941
+ * file. `unresolvedSpelling` says what goes in the name slot for one: `"empty"`
5942
+ * for the file pull writes (and the caller decides whether to write it at all),
5943
+ * `"raw-id"` for the comparison's rendering, which is parsed and never written.
5944
+ */
5945
+ function testCaseSidecarTable(testCase, lookupMaps, unresolvedSpelling) {
5946
+ const unresolved = [];
5947
+ const reference = (field, tomlKey, idToName) => {
5948
+ const id = testCase?.[field];
5949
+ if (typeof id !== "string" || id === "")
5950
+ return "";
5951
+ const name = idToName?.get(id);
5952
+ if (name)
5953
+ return name;
5954
+ unresolved.push({ tomlKey, field, id });
5955
+ return unresolvedSpelling === "raw-id" ? id : "";
5956
+ };
5957
+ const configName = reference("configId", "configName", lookupMaps?.configIdToName);
5958
+ const evaluatorPromptKey = reference("evaluatorPromptId", "evaluatorPromptKey", lookupMaps?.promptIdToKey);
5959
+ // The judge's config belongs to the evaluator prompt, not to this sidecar's
5960
+ // block; config ids are app-unique, so the app-wide map names it.
5961
+ const evaluatorConfigName = reference("evaluatorConfigId", "evaluatorConfigName", lookupMaps?.configIdToName);
5733
5962
  // #2644 criterion 1 — the `[test]` key set comes from the vendored
5734
5963
  // definition, so a field added there is emitted by pull with no edit here.
5735
5964
  // The three reference fields carry the resolved NAME (a test file is portable
5736
5965
  // across apps); the rest keep the empty-string / "{}" / "[]" spellings a test
5737
5966
  // file has always used for "not set".
5738
- const data = {
5739
- test: projectRecordToToml(TEST_CASE_TABLE, testCase, {
5740
- name: (record) => record.name || "",
5741
- description: (record) => record.description || "",
5742
- inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
5743
- configId: () => configName,
5744
- evaluatorPromptId: () => evaluatorPromptKey,
5745
- evaluatorConfigId: () => evaluatorConfigName,
5746
- expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5747
- expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5748
- expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5749
- }),
5750
- };
5751
- // #2769 — a reference the id→name lookup could not resolve (an unknown id, or
5752
- // a config listing that failed on this pull) is written back in the legacy id
5753
- // spelling the parser still accepts, NOT as an empty name. Rewriting a pinned
5754
- // test as unpinned would push the pin away on the next `config push`.
5755
- if (testCase.configId && !configName)
5756
- data.test.configId = testCase.configId;
5757
- if (testCase.evaluatorPromptId && !evaluatorPromptKey) {
5758
- data.test.evaluatorPromptId = testCase.evaluatorPromptId;
5759
- }
5760
- if (testCase.evaluatorConfigId && !evaluatorConfigName) {
5761
- data.test.evaluatorConfigId = testCase.evaluatorConfigId;
5762
- }
5763
- return stringifyConfigToml(data);
5967
+ const test = projectRecordToToml(TEST_CASE_TABLE, testCase, {
5968
+ name: (record) => record.name || "",
5969
+ description: (record) => record.description || "",
5970
+ inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
5971
+ configId: () => configName,
5972
+ evaluatorPromptId: () => evaluatorPromptKey,
5973
+ evaluatorConfigId: () => evaluatorConfigName,
5974
+ expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5975
+ expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5976
+ expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5977
+ });
5978
+ return { test, unresolved };
5979
+ }
5980
+ /**
5981
+ * The sidecar `config pull` writes for one server record. It never emits an id
5982
+ * key (#3998): a reference the lookups cannot name is written as an empty name
5983
+ * and reported through `onUnresolved`, and `pullTestCasesForBlock` declines to
5984
+ * write such a case rather than unpin it on disk.
5985
+ */
5986
+ export function serializeTestCase(testCase, lookupMaps, options = {}) {
5987
+ // #2644 — name any server key this CLI version does not know rather than
5988
+ // dropping it silently on the way into the file. Test cases are a registered
5989
+ // surface; their sidecar shape is the only thing that sets them apart.
5990
+ warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
5991
+ const { test, unresolved } = testCaseSidecarTable(testCase, lookupMaps, "empty");
5992
+ if (unresolved.length > 0)
5993
+ options.onUnresolved?.(unresolved);
5994
+ return stringifyConfigToml({ test });
5995
+ }
5996
+ /**
5997
+ * The rendering of a server record that the comparison parses (#3998) — never
5998
+ * written to disk.
5999
+ *
6000
+ * It differs from the file pull writes in one place: a reference the lookups
6001
+ * cannot name carries its raw id in the NAME slot. A live case pinned to a
6002
+ * deleted or unlistable configuration must stay a difference against an
6003
+ * unpinned file; projecting it as "no reference" would read the pair as Synced,
6004
+ * and the next content edit would push `configId: null` and clear the pin in
6005
+ * silence.
6006
+ */
6007
+ export function serializeTestCaseForComparison(testCase, lookupMaps) {
6008
+ const { test } = testCaseSidecarTable(testCase, lookupMaps, "raw-id");
6009
+ return stringifyConfigToml({ test });
5764
6010
  }
5765
6011
  /**
5766
6012
  * The TOML spelling of a JSON-valued test-case field (#2769).
@@ -5810,20 +6056,22 @@ export function parseTestCaseToml(tomlData) {
5810
6056
  description: (value) => (value ? value : undefined),
5811
6057
  // Always sent — the server requires it, and `{}` is the empty spelling.
5812
6058
  inputVariables: (value) => parseJsonText(value, ["{}"]) ?? {},
5813
- // The legacy ID-based spellings stay accepted (declared in the
5814
- // definition's `tomlOnlyKeys`); the name-based ones become the `_`
5815
- // markers below, which the push path resolves against the live app.
5816
- configId: (_value, _mode, toml) => toml.configId || undefined,
5817
- evaluatorPromptId: (_value, _mode, toml) => toml.evaluatorPromptId || undefined,
5818
- evaluatorConfigId: (_value, _mode, toml) => toml.evaluatorConfigId || undefined,
6059
+ // A reference is authored by name only: the names become the `_`
6060
+ // markers below, which the push path resolves against the live app. The
6061
+ // id spellings are retired (#3998) and refused by the preflight, so
6062
+ // nothing is read into the wire field here — and these producers must
6063
+ // stay, because without one `buildPayloadFromToml` would forward the
6064
+ // NAME (`configName`) under the id field.
6065
+ configId: () => undefined,
6066
+ evaluatorPromptId: () => undefined,
6067
+ evaluatorConfigId: () => undefined,
5819
6068
  expectedOutputPattern: (value) => (value ? value : undefined),
5820
6069
  expectedOutputContains: (value) => parseJsonText(value, ["[]"]),
5821
6070
  expectedJsonSubset: (value) => parseJsonText(value, ["{}"]),
5822
6071
  },
5823
6072
  });
5824
6073
  // An omitted field must be absent from the body, not present-and-undefined:
5825
- // the push path tests `!testPayload.configId` and the server's unknown-key
5826
- // check reads the body's keys.
6074
+ // the server's unknown-key check reads the body's keys.
5827
6075
  for (const key of Object.keys(payload)) {
5828
6076
  if (payload[key] === undefined)
5829
6077
  delete payload[key];
@@ -5877,7 +6125,9 @@ export async function pullScripts(client, appId, configDir, logger = () => { },
5877
6125
  * `config pull --only` (issue #2645): which transform names this pull may
5878
6126
  * write. Defaults to every one the server lists.
5879
6127
  */
5880
- selects = () => true) {
6128
+ selects = () => true,
6129
+ /** Counts each transform as written or left unchanged (#4160). */
6130
+ writes) {
5881
6131
  const transformsDir = join(configDir, "transforms");
5882
6132
  ensureDir(transformsDir);
5883
6133
  const scriptEntities = {};
@@ -5930,7 +6180,9 @@ selects = () => true) {
5930
6180
  }
5931
6181
  const filename = `${name}.rhai`;
5932
6182
  const filePath = join(transformsDir, filename);
5933
- writeFileSync(filePath, activeConfig.body);
6183
+ // A transform's comparator is its bytes, so only identical bytes keep the
6184
+ // file (#4160).
6185
+ const fileWritten = writePulledFile(filePath, activeConfig.body, writes);
5934
6186
  scriptEntities[name] = {
5935
6187
  id: script.scriptId,
5936
6188
  modifiedAt: script.modifiedAt || new Date().toISOString(),
@@ -5943,7 +6195,8 @@ selects = () => true) {
5943
6195
  semanticHash: computeScriptBodyHash(activeConfig.body),
5944
6196
  };
5945
6197
  written += 1;
5946
- logger(` Wrote transforms/${filename}`);
6198
+ if (fileWritten)
6199
+ logger(` Wrote transforms/${filename}`);
5947
6200
  }
5948
6201
  const serverKeys = items.map((s) => s?.name).filter(Boolean);
5949
6202
  const presence = detailsOk
@@ -6019,7 +6272,9 @@ export function pushedFunctionBaseline(input) {
6019
6272
  * normalizes outside the tree, and the refusals are reported rather than
6020
6273
  * swallowed.
6021
6274
  */
6022
- export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
6275
+ export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true,
6276
+ /** Counts each function file as written or left unchanged (#4160). */
6277
+ writes) {
6023
6278
  const functionsDir = join(configDir, "functions");
6024
6279
  ensureDir(functionsDir);
6025
6280
  const functionEntities = {};
@@ -6062,12 +6317,17 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6062
6317
  // The logger carries #1446's one warning: a schema holding a `null`
6063
6318
  // anywhere cannot be a native table, so it is written as a JSON string
6064
6319
  // and the operator is told which key that happened to.
6065
- writeFileSync(filePath, serializeFunction(record, undefined, logger));
6320
+ const content = serializeFunction(record, undefined, logger);
6321
+ const fileWritten = writePulledFile(filePath, content, writes, sameConfigAs("function", content, filePath, {
6322
+ ruleSetIdToName: new Map(),
6323
+ ruleSetNameToId: new Map(),
6324
+ }));
6066
6325
  entity.contentHash = computeFileHash(filePath);
6067
6326
  entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
6068
6327
  functionEntities[key] = entity;
6069
6328
  written += 1;
6070
- logger(` Wrote functions/${key}.toml (no version pushed yet)`);
6329
+ if (fileWritten)
6330
+ logger(` Wrote functions/${key}.toml (no version pushed yet)`);
6071
6331
  continue;
6072
6332
  }
6073
6333
  let active;
@@ -6123,7 +6383,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6123
6383
  // this is what says so rather than editing a developer's file in silence.
6124
6384
  for (const note of plan.notes)
6125
6385
  logger(` ${note}`);
6126
- const applied = applyFunctionPull(plan);
6386
+ const applied = applyFunctionPull(plan, writes);
6127
6387
  for (const refusal of applied.refusals)
6128
6388
  logger(` ${refusal}`);
6129
6389
  entity.contentHash = computeFileHash(filePath);
@@ -6161,7 +6421,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6161
6421
  });
6162
6422
  for (const warning of schemaPlan.warnings)
6163
6423
  logger(` ${warning}`);
6164
- const schemaApplied = applyFunctionPull(schemaPlan);
6424
+ const schemaApplied = applyFunctionPull(schemaPlan, writes);
6165
6425
  for (const refusal of schemaApplied.refusals)
6166
6426
  logger(` ${refusal}`);
6167
6427
  const presence = detailsOk
@@ -6176,6 +6436,35 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6176
6436
  items,
6177
6437
  };
6178
6438
  }
6439
+ /**
6440
+ * The id→name lookups `config pull` writes test-case references through
6441
+ * (#3998).
6442
+ *
6443
+ * The prompt half is built from EVERY prompt's detail — the pull fetches them
6444
+ * all before `--only` filters the list — so a judge prompt outside the
6445
+ * selection still names. Configurations come from the pulled blocks (a case
6446
+ * pins one of its own block's) and from every prompt (a judge's config is the
6447
+ * judge prompt's). An entry without both an id and a name is skipped.
6448
+ */
6449
+ export function testCaseLookupMapsForPull(input) {
6450
+ const configIdToName = new Map();
6451
+ const promptIdToKey = new Map();
6452
+ const addConfig = (config) => {
6453
+ if (config?.configId && config?.configName) {
6454
+ configIdToName.set(config.configId, config.configName);
6455
+ }
6456
+ };
6457
+ for (const prompt of input.prompts) {
6458
+ if (prompt?.promptId && prompt?.promptKey) {
6459
+ promptIdToKey.set(prompt.promptId, prompt.promptKey);
6460
+ }
6461
+ for (const config of prompt?.configs ?? [])
6462
+ addConfig(config);
6463
+ }
6464
+ for (const config of input.configs)
6465
+ addConfig(config);
6466
+ return { configIdToName, promptIdToKey };
6467
+ }
6179
6468
  /**
6180
6469
  * Every test case a block has, draining the cursor (#2769).
6181
6470
  *
@@ -6228,6 +6517,27 @@ export function carryForwardTestCaseEntities(params) {
6228
6517
  }
6229
6518
  return carried;
6230
6519
  }
6520
+ /**
6521
+ * Whether two sidecar names in `testsDir` are one file on disk (#3998).
6522
+ *
6523
+ * Names that differ only by case are one file on the case-insensitive
6524
+ * filesystems most of these trees live on and two on the rest (#2896), so the
6525
+ * answer comes from the filesystem itself: the same device and inode.
6526
+ */
6527
+ function sameTestCaseFile(testsDir, a, b) {
6528
+ if (a === b)
6529
+ return true;
6530
+ if (normalizeTestCaseKey(a) !== normalizeTestCaseKey(b))
6531
+ return false;
6532
+ try {
6533
+ const left = statSync(join(testsDir, `${a}.toml`));
6534
+ const right = statSync(join(testsDir, `${b}.toml`));
6535
+ return left.dev === right.dev && left.ino === right.ino;
6536
+ }
6537
+ catch {
6538
+ return false;
6539
+ }
6540
+ }
6231
6541
  /**
6232
6542
  * Write one block's test cases into its `<key>.tests/` sidecar and record them
6233
6543
  * in sync state. Exported so the unit tests can drive it against a stubbed
@@ -6243,10 +6553,9 @@ export async function pullTestCasesForBlock(params) {
6243
6553
  const testsDir = getTestsDir(configDir, blockType, blockKey);
6244
6554
  // A block with no cases and no sidecar is not a block whose sidecar is empty:
6245
6555
  // creating the directory here would commit an empty folder Git cannot carry.
6246
- if (testCases.length === 0 && !existsSync(testsDir))
6247
- return { ok: true, count: 0 };
6248
- if (testCases.length > 0)
6249
- ensureDir(testsDir);
6556
+ if (testCases.length === 0 && !existsSync(testsDir)) {
6557
+ return { ok: true, count: 0, unwritten: [] };
6558
+ }
6250
6559
  const pulledSlugs = new Set();
6251
6560
  // #2896 — a keyed case is written to the file its key names, so pull stops
6252
6561
  // renaming what push authored: the repro's `full-refresh-takes-all-history`
@@ -6256,15 +6565,35 @@ export async function pullTestCasesForBlock(params) {
6256
6565
  // The key is server data becoming a path: the containment check an
6257
6566
  // attachment's filename gets, so nothing lands outside the sidecar dir.
6258
6567
  (name) => attachmentWritePath(testsDir, `${name}.toml`) !== null);
6568
+ // #3998 — render every case before anything is written. A reference the
6569
+ // lookups cannot name has no honest spelling in a sidecar (the id keys are
6570
+ // retired, and an empty name would unpin the case on the next push), so such
6571
+ // a case is NOT written: its file, fixtures and record stay as they were.
6572
+ const writable = [];
6573
+ const skipped = [];
6259
6574
  for (const tc of testCases) {
6260
6575
  const slug = fileNames.get(tc);
6576
+ let references = [];
6577
+ const toml = serializeTestCase(tc, lookupMaps, {
6578
+ file: join(testsDir, `${slug}.toml`),
6579
+ logger: (message) => log(message),
6580
+ onUnresolved: (refs) => {
6581
+ references = refs;
6582
+ },
6583
+ });
6584
+ if (references.length > 0)
6585
+ skipped.push({ tc, slug, references });
6586
+ else
6587
+ writable.push({ tc, slug, toml });
6588
+ }
6589
+ if (writable.length > 0)
6590
+ ensureDir(testsDir);
6591
+ const writtenSlugs = new Map(writable.map((w) => [w.slug, w.tc]));
6592
+ for (const { tc, slug, toml } of writable) {
6261
6593
  pulledSlugs.add(slug);
6262
6594
  // Write test case TOML
6263
6595
  const tomlPath = join(testsDir, `${slug}.toml`);
6264
- writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps, {
6265
- file: tomlPath,
6266
- logger: (message) => log(message),
6267
- }));
6596
+ writePulledFile(tomlPath, toml, params.writes, sameConfigAs("test-case", toml, tomlPath, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, { lookupMaps }));
6268
6597
  // Attachments: the sidecar directory is the authored set, so pull makes it
6269
6598
  // match the server — downloading what is there, recording each file's
6270
6599
  // content hash (so push can see a byte change under an unchanged name), and
@@ -6288,7 +6617,8 @@ export async function pullTestCasesForBlock(params) {
6288
6617
  }
6289
6618
  try {
6290
6619
  const downloaded = await client.downloadTestCaseAttachment(appId, blockType, blockId, tc.testCaseId, att.filename);
6291
- writeFileSync(destination, downloaded.data);
6620
+ // An attachment's comparator is its bytes (#4160).
6621
+ writePulledFile(destination, downloaded.data, params.writes);
6292
6622
  (attachments ??= {})[att.filename] = computeFileHash(destination);
6293
6623
  }
6294
6624
  catch (err) {
@@ -6356,12 +6686,79 @@ export async function pullTestCasesForBlock(params) {
6356
6686
  }),
6357
6687
  };
6358
6688
  }
6689
+ // #3998 — a skipped case is found by its ID in the prior state, not by the
6690
+ // file name it would get now: the server can rename a case's key between
6691
+ // pulls, and the old file is the one holding the pin. Every such file is
6692
+ // kept out of the cleanup below and every such record carried forward under
6693
+ // its existing key — unless a case written above now owns that file, whose
6694
+ // write wins it (the key-to-file rule of #2896). "That file" is decided by
6695
+ // the filesystem: `Alpha` and `alpha` are one file where names fold case.
6696
+ const unwritten = [];
6697
+ const writtenOwner = (candidate) => writtenSlugs.get(candidate) ??
6698
+ writable.find((w) => sameTestCaseFile(testsDir, w.slug, candidate))?.tc;
6699
+ for (const { tc, slug, references } of skipped) {
6700
+ const priorEntries = Object.entries(params.priorTestCaseEntities ?? {}).filter(([, entry]) => entry?.blockType === blockType &&
6701
+ entry?.blockKey === blockKey &&
6702
+ entry?.id === tc.testCaseId);
6703
+ const slugs = new Set([slug]);
6704
+ for (const [stateKey, entry] of priorEntries) {
6705
+ slugs.add(entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1));
6706
+ }
6707
+ const preservedSlugs = [];
6708
+ const superseded = new Set();
6709
+ for (const candidate of slugs) {
6710
+ const owner = writtenOwner(candidate);
6711
+ if (owner) {
6712
+ superseded.add(candidate);
6713
+ warn(` ${blockType}: ${blockKey}/${candidate}.toml now holds test case ` +
6714
+ `"${owner?.name ?? ""}" — the prior sidecar of unwritten test case ` +
6715
+ `"${tc?.name ?? ""}" at that path was superseded`);
6716
+ continue;
6717
+ }
6718
+ pulledSlugs.add(candidate);
6719
+ if (existsSync(join(testsDir, `${candidate}.toml`))) {
6720
+ preservedSlugs.push(candidate);
6721
+ }
6722
+ }
6723
+ for (const [stateKey, entry] of priorEntries) {
6724
+ const entrySlug = entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1);
6725
+ if (superseded.has(entrySlug))
6726
+ continue;
6727
+ if (testCaseEntities[stateKey])
6728
+ continue;
6729
+ testCaseEntities[stateKey] = entry;
6730
+ }
6731
+ const what = references
6732
+ .map((ref) => `${ref.tomlKey} (id ${ref.id})`)
6733
+ .join(", ");
6734
+ const kept = preservedSlugs.length > 0
6735
+ ? `kept ${preservedSlugs.map((s) => `${s}.toml`).join(", ")} as it was`
6736
+ : "no sidecar exists for it";
6737
+ warn(` Test case not written for ${blockType}: ${blockKey}/${slug} ` +
6738
+ `("${tc?.name ?? ""}"): ${what} could not be resolved to a name — ` +
6739
+ `${kept}, so the pin is not lost. The configuration or prompt may have ` +
6740
+ "been deleted (re-pin the case on the server), or its listing failed " +
6741
+ "on this pull (run `config pull` again).");
6742
+ unwritten.push({
6743
+ blockType,
6744
+ blockKey,
6745
+ slug,
6746
+ name: tc?.name ?? "",
6747
+ testCaseId: tc?.testCaseId,
6748
+ references,
6749
+ preservedSlugs,
6750
+ });
6751
+ }
6359
6752
  // Clean up local files for remotely-deleted test cases
6360
6753
  if (existsSync(testsDir)) {
6361
6754
  const localFiles = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
6362
6755
  for (const file of localFiles) {
6363
6756
  const localSlug = basename(file, ".toml");
6364
- if (!pulledSlugs.has(localSlug)) {
6757
+ // A listing keeps the casing a file was created with, so the name a case
6758
+ // was just written to can come back as `Alpha.toml` — the same file.
6759
+ const pulled = pulledSlugs.has(localSlug) ||
6760
+ [...pulledSlugs].some((slug) => sameTestCaseFile(testsDir, slug, localSlug));
6761
+ if (!pulled) {
6365
6762
  // Remove orphaned TOML
6366
6763
  unlinkSync(join(testsDir, file));
6367
6764
  // Remove orphaned attachment dir if exists
@@ -6381,7 +6778,7 @@ export async function pullTestCasesForBlock(params) {
6381
6778
  }
6382
6779
  }
6383
6780
  }
6384
- return { ok: true, count: testCases.length };
6781
+ return { ok: true, count: writable.length, unwritten };
6385
6782
  }
6386
6783
  /**
6387
6784
  * One pull leg: every block of a type the pull selected (#2769).
@@ -6395,6 +6792,7 @@ export async function pullTestCasesForBlocks(params) {
6395
6792
  const log = params.logger ?? ((message) => info(message));
6396
6793
  let count = 0;
6397
6794
  const skippedBlocks = [];
6795
+ const unwritten = [];
6398
6796
  for (const block of params.blocks) {
6399
6797
  const outcome = await pullTestCasesForBlock({
6400
6798
  client: params.client,
@@ -6406,6 +6804,7 @@ export async function pullTestCasesForBlocks(params) {
6406
6804
  testCaseEntities: params.testCaseEntities,
6407
6805
  priorTestCaseEntities: params.priorTestCaseEntities,
6408
6806
  lookupMaps: params.lookupMaps,
6807
+ writes: params.writes,
6409
6808
  });
6410
6809
  if (!outcome.ok) {
6411
6810
  skippedBlocks.push(block.key);
@@ -6422,9 +6821,14 @@ export async function pullTestCasesForBlocks(params) {
6422
6821
  if (outcome.count > 0) {
6423
6822
  log(` Wrote ${outcome.count} test case(s) for ${params.blockType}: ${block.key}`);
6424
6823
  }
6824
+ if (outcome.unwritten.length > 0) {
6825
+ log(` ${outcome.unwritten.length} test case(s) not written for ` +
6826
+ `${params.blockType}: ${block.key} (unresolved references; see above)`);
6827
+ }
6425
6828
  count += outcome.count;
6829
+ unwritten.push(...outcome.unwritten);
6426
6830
  }
6427
- return { count, skippedBlocks };
6831
+ return { count, skippedBlocks, unwritten };
6428
6832
  }
6429
6833
  /** The 10 MB per-attachment cap the server enforces. */
6430
6834
  const TEN_MB = 10 * 1024 * 1024;
@@ -6616,9 +7020,20 @@ export async function pushTestCasesForBlock(params) {
6616
7020
  continue;
6617
7021
  }
6618
7022
  const tomlData = parseTomlFile(filePath);
7023
+ // #3998 — the command's preflight refuses a sidecar it cannot push, but
7024
+ // this leg is exported and the parser no longer reads the retired id keys:
7025
+ // a file reaching it unchecked would be pushed UNPINNED in silence. The
7026
+ // same per-loop defense the workflow leg keeps (#976).
7027
+ const sidecarErrors = testCaseSidecarPreflightErrors(filePath, tomlData);
7028
+ if (sidecarErrors.length > 0) {
7029
+ const message = sidecarErrors.join("; ");
7030
+ warn(` Failed to push test case ${blockKey}/${slug}: ${message}`);
7031
+ failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
7032
+ continue;
7033
+ }
6619
7034
  const testPayload = parseTestCaseToml(tomlData);
6620
- // Resolve key-based references to IDs. An authored name that does not
6621
- // resolve is a FAILURE: silently dropping it would push the test case with
7035
+ // Resolve the authored names to IDs — a name is the only spelling a
7036
+ // sidecar has (#3998). An authored name that does not resolve is a FAILURE: silently dropping it would push the test case with
6622
7037
  // no pin, quietly changing what the case tests (#2769). The exception is a
6623
7038
  // preview of a block this push has not created yet — its configs cannot
6624
7039
  // exist to be named, so a dry run reports the reference as pending rather
@@ -6639,7 +7054,7 @@ export async function pushTestCasesForBlock(params) {
6639
7054
  delete testPayload._configName;
6640
7055
  delete testPayload._evaluatorPromptKey;
6641
7056
  delete testPayload._evaluatorConfigName;
6642
- if (configName && !testPayload.configId) {
7057
+ if (configName) {
6643
7058
  const configMap = blockType === "prompt"
6644
7059
  ? resolutionMaps?.promptConfigNameToId
6645
7060
  : blockType === "script"
@@ -6657,14 +7072,14 @@ export async function pushTestCasesForBlock(params) {
6657
7072
  else
6658
7073
  noteUnresolved(`configName "${configName}"`, blockType, blockKey);
6659
7074
  }
6660
- if (evaluatorPromptKey && !testPayload.evaluatorPromptId) {
7075
+ if (evaluatorPromptKey) {
6661
7076
  const resolvedId = resolutionMaps?.promptKeyToId.get(evaluatorPromptKey);
6662
7077
  if (resolvedId)
6663
7078
  testPayload.evaluatorPromptId = resolvedId;
6664
7079
  else
6665
7080
  noteUnresolved(`evaluatorPromptKey "${evaluatorPromptKey}"`, "prompt", evaluatorPromptKey);
6666
7081
  }
6667
- if (evaluatorConfigName && !testPayload.evaluatorConfigId) {
7082
+ if (evaluatorConfigName) {
6668
7083
  let resolvedId = evaluatorPromptKey
6669
7084
  ? resolutionMaps?.promptConfigNameToId.get(`${evaluatorPromptKey}#${evaluatorConfigName}`)
6670
7085
  : undefined;
@@ -7245,9 +7660,10 @@ export function pairTestCases(input) {
7245
7660
  * integration and workflow sidecar pinned to a config reported Modified
7246
7661
  * forever, and push read the same pair as server drift.
7247
7662
  *
7248
- * One listing per block that has sidecars, and a failed one is silent: an
7249
- * unresolved reference compares as the id both sides carry, which is the same
7250
- * degradation `serializeTestCase` already applies.
7663
+ * One listing per block that has sidecars, and a failed one is silent: the
7664
+ * live side's unresolved reference then compares as its raw id
7665
+ * (`serializeTestCaseForComparison`), so the row reads Modified rather than
7666
+ * hiding a pin.
7251
7667
  */
7252
7668
  async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
7253
7669
  try {
@@ -7305,10 +7721,6 @@ export async function compareTestCasesForBlock(params) {
7305
7721
  });
7306
7722
  for (const slug of localSlugs) {
7307
7723
  const live = paired.bySlug.get(slug);
7308
- if (!live) {
7309
- rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7310
- continue;
7311
- }
7312
7724
  const filePath = join(testsDir, `${slug}.toml`);
7313
7725
  let row;
7314
7726
  try {
@@ -7316,7 +7728,9 @@ export async function compareTestCasesForBlock(params) {
7316
7728
  // #2880 criterion 1 — a sidecar push's preflight would reject is not one
7317
7729
  // this command calls synced, and the message is the same one. The WHOLE
7318
7730
  // preflight, not just the sidecar's own rules: an unknown key or a
7319
- // mistyped value aborts push too.
7731
+ // mistyped value aborts push too. #3998 — and an UNPAIRED sidecar is
7732
+ // checked before it is called local-only, so a new file push would refuse
7733
+ // (a retired id key) is reported with the guidance, not as a create.
7320
7734
  const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
7321
7735
  if (errors.length > 0) {
7322
7736
  row = {
@@ -7326,6 +7740,10 @@ export async function compareTestCasesForBlock(params) {
7326
7740
  hint: errors.join("; "),
7327
7741
  };
7328
7742
  }
7743
+ else if (!live) {
7744
+ rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7745
+ continue;
7746
+ }
7329
7747
  else {
7330
7748
  const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
7331
7749
  // Both sides read a reference through the same lookups — see
@@ -7342,6 +7760,12 @@ export async function compareTestCasesForBlock(params) {
7342
7760
  hint: `${filePath}: ${err?.message || String(err)}`,
7343
7761
  };
7344
7762
  }
7763
+ // An unpaired file push would refuse has no attachment plan, as a
7764
+ // local-only one has none.
7765
+ if (!live) {
7766
+ rows.push(row);
7767
+ continue;
7768
+ }
7345
7769
  // #2880 behavior 19 — attachments are part of what push would do, read
7346
7770
  // from the SAME plan push executes. A sidecar whose text compares clean but
7347
7771
  // whose fixtures have not been uploaded is not Synced.
@@ -7705,7 +8129,7 @@ export function registerConfigSyncCommands(sync) {
7705
8129
  lastSyncedAt: new Date().toISOString(),
7706
8130
  entities: {},
7707
8131
  };
7708
- saveSyncState(configDir, state);
8132
+ saveSyncState(state);
7709
8133
  success(`Initialized config directory: ${configDir}`);
7710
8134
  info("Run 'primitive config pull' to download current configuration.");
7711
8135
  });
@@ -7724,6 +8148,7 @@ export function registerConfigSyncCommands(sync) {
7724
8148
  .action(async (options) => {
7725
8149
  const resolvedAppId = getCurrentAppId();
7726
8150
  const configDir = resolveSyncDir();
8151
+ const pullsLocalEnvironment = requireCurrentEnvironment().local;
7727
8152
  if (!options.json) {
7728
8153
  info(`Using per-environment config directory: ${configDir}`);
7729
8154
  }
@@ -7817,7 +8242,11 @@ export function registerConfigSyncCommands(sync) {
7817
8242
  const appWorkflowItems = selectPull("workflow", workflowItems.filter((w) => !isPlatformOwnedWorkflow(w)), (w) => w.workflowKey);
7818
8243
  // Fetch details for each entity
7819
8244
  const integrations = selectPull("integration", await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId))), (i) => i.integrationKey);
7820
- const prompts = selectPull("prompt", await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId))), (p) => p.promptKey);
8245
+ // #3998 — every prompt's detail is kept, not just the selection's: a
8246
+ // test case of a selected block may be judged by a prompt `--only`
8247
+ // left out, and its sidecar names that prompt by key.
8248
+ const promptDetails = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
8249
+ const prompts = selectPull("prompt", promptDetails, (p) => p.promptKey);
7821
8250
  const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
7822
8251
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
7823
8252
  // #2645 — fetch EVERY config's body, not just the active one. The
@@ -7926,7 +8355,11 @@ export function registerConfigSyncCommands(sync) {
7926
8355
  // sync dir (nothing to back up). Prune to 28 days after success.
7927
8356
  try {
7928
8357
  const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
7929
- const snapshot = createSnapshot(configDir, snapshotsRoot);
8358
+ // A machine-local environment's baseline is its own file, not the
8359
+ // shared tree's (#4117): that is the one the snapshot carries.
8360
+ const snapshot = createSnapshot(configDir, snapshotsRoot, {
8361
+ baselinePath: pullsLocalEnvironment ? resolveSyncStateReadPath() : undefined,
8362
+ });
7930
8363
  if (snapshot) {
7931
8364
  info(` Snapshot: ${snapshot.path}`);
7932
8365
  pruneSnapshots(snapshotsRoot, 28);
@@ -7955,11 +8388,15 @@ export function registerConfigSyncCommands(sync) {
7955
8388
  ensureDir(join(configDir, "collection-type-configs"));
7956
8389
  ensureDir(join(configDir, "metadata-category-configs"));
7957
8390
  ensureDir(join(configDir, "email-templates"));
8391
+ // #4160 — every pulled file goes through `writePulledFile`, which
8392
+ // leaves a file alone when it already says what the server says, and
8393
+ // counts both outcomes for the summary below.
8394
+ const pullWrites = newPullWriteTally();
7958
8395
  // The prior sync state is the record of which local files a previous
7959
8396
  // pull wrote — the discriminator the prune pass keys off (#1659). Read
7960
8397
  // it now: `saveSyncState` at the end of the pull overwrites it, and the
7961
8398
  // files themselves are about to be rewritten.
7962
- const priorState = loadSyncState(configDir);
8399
+ const priorState = loadSyncState();
7963
8400
  const priorEntities = priorState?.entities ?? {};
7964
8401
  // Write app settings. Under `--only`, `app` has to be named — a pull
7965
8402
  // scoped to one workflow must not overwrite app.toml, which is what
@@ -7986,8 +8423,16 @@ export function registerConfigSyncCommands(sync) {
7986
8423
  // Every writable setting round-trips as of #2891 — the server
7987
8424
  // withholds nothing — so the pulled file is complete and pushable as
7988
8425
  // written, with no accompanying note about what it could not carry.
7989
- writePulledAppSettings(configDir, settings);
7990
- info(" Wrote app.toml");
8426
+ // On a machine-local environment the tree's name is the shared
8427
+ // tree's, and a pull never writes the child's over it (#4117).
8428
+ const written = writePulledAppSettings(configDir, settings, {
8429
+ keepStatedName: pullsLocalEnvironment,
8430
+ tally: pullWrites,
8431
+ });
8432
+ if (written.written)
8433
+ info(" Wrote app.toml");
8434
+ if (written.note)
8435
+ warn(` ${written.note}`);
7991
8436
  }
7992
8437
  // Write config vars (issue #1423). A single flat vars.toml at the
7993
8438
  // sync-dir root (like app.toml), holding the per-environment non-secret
@@ -8027,17 +8472,21 @@ export function registerConfigSyncCommands(sync) {
8027
8472
  ? Object.fromEntries(Object.entries(parseConfigToml(readFileSync(varsTomlPathPull, "utf-8"))).map(([k, v]) => [k, String(v ?? "")]))
8028
8473
  : {};
8029
8474
  const scoped = planScopedVarsPull(varsOutcome.vars, pullSelection.varKeys, localVars, priorEntities.vars);
8030
- writeFileSync(varsTomlPathPull, scoped.content);
8475
+ const varsWritten = writePulledFile(varsTomlPathPull, scoped.content, pullWrites, sameTomlAs(scoped.content));
8031
8476
  Object.assign(varEntities, scoped.varEntities);
8032
- info(` Wrote vars.toml (${pullSelection.varKeys.size} selected var(s))`);
8477
+ if (varsWritten) {
8478
+ info(` Wrote vars.toml (${pullSelection.varKeys.size} selected var(s))`);
8479
+ }
8033
8480
  }
8034
8481
  else {
8035
8482
  const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
8036
8483
  Object.assign(varEntities, varsPlan.varEntities);
8037
8484
  if (varsPlan.write && varsPlan.content !== null) {
8038
- writeFileSync(varsTomlPathPull, varsPlan.content);
8485
+ const varsWritten = writePulledFile(varsTomlPathPull, varsPlan.content, pullWrites, sameTomlAs(varsPlan.content));
8039
8486
  const count = varsOutcome.vars.length;
8040
- info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
8487
+ if (varsWritten) {
8488
+ info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
8489
+ }
8041
8490
  }
8042
8491
  else {
8043
8492
  const err = varsOutcome.error;
@@ -8057,16 +8506,18 @@ export function registerConfigSyncCommands(sync) {
8057
8506
  for (const integration of integrations) {
8058
8507
  const filename = `${integration.integrationKey}.toml`;
8059
8508
  const filePath = join(configDir, "integrations", filename);
8060
- writeFileSync(filePath, serializeIntegration(integration, (msg) => info(` ${msg}`)));
8509
+ const content = serializeIntegration(integration, (msg) => info(` ${msg}`));
8510
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("integration", content, filePath, integrationPullMaps));
8061
8511
  integrationEntities[integration.integrationKey] = {
8062
8512
  id: integration.integrationId,
8063
8513
  modifiedAt: integration.modifiedAt || new Date().toISOString(),
8064
8514
  contentHash: computeFileHash(filePath),
8065
8515
  // #2880 — the two sides agree right now, so this is the baseline
8066
8516
  // the next push classifies direction against.
8067
- semanticHash: configSemanticHash("integration", filePath, integrationPullMaps),
8517
+ semanticHash: pulledSemanticHash("integration", content, integrationPullMaps),
8068
8518
  };
8069
- info(` Wrote integrations/${filename}`);
8519
+ if (written)
8520
+ info(` Wrote integrations/${filename}`);
8070
8521
  }
8071
8522
  // Pull webhooks. #2803 — an ARCHIVED webhook is retired, not
8072
8523
  // configuration, so it is not exported: writing a tombstone into the
@@ -8091,14 +8542,15 @@ export function registerConfigSyncCommands(sync) {
8091
8542
  for (const webhook of webhooks) {
8092
8543
  const filename = `${webhook.webhookKey}.toml`;
8093
8544
  const filePath = join(webhooksDir, filename);
8094
- writeFileSync(filePath, serializeWebhook(webhook, (msg) => info(` ${msg}`)));
8545
+ const content = serializeWebhook(webhook, (msg) => info(` ${msg}`));
8546
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("webhook", content, filePath, webhookPullMaps));
8095
8547
  webhookEntities[webhook.webhookKey] = {
8096
8548
  id: webhook.webhookId,
8097
8549
  modifiedAt: webhook.modifiedAt || new Date().toISOString(),
8098
8550
  contentHash: computeFileHash(filePath),
8099
8551
  // #2880 — the file and the server agree right now, so this is the
8100
8552
  // baseline the next push classifies direction against.
8101
- semanticHash: configSemanticHash("webhook", filePath, webhookPullMaps),
8553
+ semanticHash: pulledSemanticHash("webhook", content, webhookPullMaps),
8102
8554
  };
8103
8555
  // A stored value that is not a reference is never written to TOML, so
8104
8556
  // pull time is where an operator finds out: a legacy literal from
@@ -8131,6 +8583,24 @@ export function registerConfigSyncCommands(sync) {
8131
8583
  }
8132
8584
  }
8133
8585
  info(` Pulled ${webhooks.length} webhook(s)`);
8586
+ // #2731 B4 — pull is one of the two moments local and server provably
8587
+ // agree, so it stamps the semantic baseline beside the byte hash (push
8588
+ // stamps the other one, from the entity the server returned). The
8589
+ // rule-set maps the shared projection resolves references through are
8590
+ // filled here, before the first type that names a rule set (blob
8591
+ // buckets, #4116), from the FULL listing rather than the `--only`
8592
+ // selection: a scoped pull of a bucket must still name its rule set.
8593
+ const ruleSetIdToName = new Map();
8594
+ const ruleSetNameToIdPull = new Map();
8595
+ const pullDiffMaps = {
8596
+ ruleSetIdToName,
8597
+ ruleSetNameToId: ruleSetNameToIdPull,
8598
+ };
8599
+ for (const ruleSet of Array.isArray(ruleSetsResult) ? ruleSetsResult : []) {
8600
+ ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
8601
+ if (ruleSet.name)
8602
+ ruleSetNameToIdPull.set(ruleSet.name, ruleSet.ruleSetId);
8603
+ }
8134
8604
  // Pull cron triggers. An older server without the route (or any other
8135
8605
  // failure) leaves the type unlisted — tolerated as before, but recorded
8136
8606
  // so prune skips it rather than reading it as "no cron triggers".
@@ -8139,8 +8609,7 @@ export function registerConfigSyncCommands(sync) {
8139
8609
  const cronTriggersDir = join(configDir, "cron-triggers");
8140
8610
  mkdirSync(cronTriggersDir, { recursive: true });
8141
8611
  const cronTriggerEntities = {};
8142
- // No rule-set reference on either type, and the pull-wide maps are not
8143
- // filled until the rule-set leg — see the webhook block above.
8612
+ // No rule-set reference on a cron trigger — see the webhook block above.
8144
8613
  const keylessPullMaps = {
8145
8614
  ruleSetIdToName: new Map(),
8146
8615
  ruleSetNameToId: new Map(),
@@ -8148,13 +8617,14 @@ export function registerConfigSyncCommands(sync) {
8148
8617
  for (const trigger of cronTriggerItems) {
8149
8618
  const filename = `${trigger.triggerKey}.toml`;
8150
8619
  const filePath = join(cronTriggersDir, filename);
8151
- writeFileSync(filePath, serializeCronTrigger(trigger, (msg) => info(` ${msg}`)));
8620
+ const content = serializeCronTrigger(trigger, (msg) => info(` ${msg}`));
8621
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("cron-trigger", content, filePath, keylessPullMaps));
8152
8622
  cronTriggerEntities[trigger.triggerKey] = {
8153
8623
  id: trigger.triggerId,
8154
8624
  modifiedAt: trigger.modifiedAt || new Date().toISOString(),
8155
8625
  contentHash: computeFileHash(filePath),
8156
8626
  // #2880 — the baseline the next push classifies direction against.
8157
- semanticHash: configSemanticHash("cron-trigger", filePath, keylessPullMaps),
8627
+ semanticHash: pulledSemanticHash("cron-trigger", content, keylessPullMaps),
8158
8628
  };
8159
8629
  }
8160
8630
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
@@ -8164,31 +8634,50 @@ export function registerConfigSyncCommands(sync) {
8164
8634
  const blobBucketsDir = join(configDir, "blob-buckets");
8165
8635
  mkdirSync(blobBucketsDir, { recursive: true });
8166
8636
  const blobBucketEntities = {};
8637
+ // #4116 — a bound bucket's file names its rule set, which only the
8638
+ // rule-set listing can supply. When that listing failed, the bucket's
8639
+ // file and sync-state entry are kept as they are rather than rewritten
8640
+ // without the name.
8641
+ const unwrittenBlobBucketKeys = [];
8167
8642
  for (const bucket of blobBucketItems) {
8643
+ if (!ruleSetsOutcome.ok && bucket.ruleSetId) {
8644
+ unwrittenBlobBucketKeys.push(bucket.bucketKey);
8645
+ continue;
8646
+ }
8168
8647
  const filename = `${bucket.bucketKey}.toml`;
8169
8648
  const filePath = join(blobBucketsDir, filename);
8170
- writeFileSync(filePath, serializeBlobBucket(bucket, (msg) => info(` ${msg}`)));
8649
+ const content = serializeBlobBucket(bucket, ruleSetIdToName, (msg) => info(` ${msg}`));
8650
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("blob-bucket", content, filePath, pullDiffMaps));
8171
8651
  blobBucketEntities[bucket.bucketKey] = {
8172
8652
  id: bucket.bucketId,
8173
8653
  modifiedAt: bucket.modifiedAt || new Date().toISOString(),
8174
8654
  contentHash: computeFileHash(filePath),
8175
- // #2880 — as for cron triggers.
8176
- semanticHash: configSemanticHash("blob-bucket", filePath, keylessPullMaps),
8655
+ // #2880 — the baseline the next push classifies direction against.
8656
+ semanticHash: pulledSemanticHash("blob-bucket", content, pullDiffMaps),
8177
8657
  };
8178
8658
  }
8179
- info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
8659
+ info(` Pulled ${blobBucketItems.length - unwrittenBlobBucketKeys.length} blob bucket(s)`);
8660
+ if (unwrittenBlobBucketKeys.length > 0) {
8661
+ warn(`${unwrittenBlobBucketKeys.length} blob bucket(s) not written — the rule-set ` +
8662
+ `listing failed, so their ruleSetName is unknown; existing files kept ` +
8663
+ `(${unwrittenBlobBucketKeys.join(", ")})`);
8664
+ }
8180
8665
  // Write prompts
8181
8666
  const promptEntities = {};
8182
8667
  for (const prompt of prompts) {
8183
8668
  const filename = `${prompt.promptKey}.toml`;
8184
8669
  const filePath = join(configDir, "prompts", filename);
8185
- writeFileSync(filePath, serializePrompt(prompt, (msg) => info(` ${msg}`)));
8670
+ const content = serializePrompt(prompt, (msg) => info(` ${msg}`));
8671
+ const written = writePulledFile(filePath, content, pullWrites,
8672
+ // Prompts carry no rule-set reference.
8673
+ sameConfigAs("prompt", content, filePath, keylessPullMaps));
8186
8674
  promptEntities[prompt.promptKey] = {
8187
8675
  id: prompt.promptId,
8188
8676
  modifiedAt: prompt.modifiedAt || new Date().toISOString(),
8189
8677
  contentHash: computeFileHash(filePath),
8190
8678
  };
8191
- info(` Wrote prompts/${filename}`);
8679
+ if (written)
8680
+ info(` Wrote prompts/${filename}`);
8192
8681
  }
8193
8682
  // Write workflows
8194
8683
  const workflowEntities = {};
@@ -8214,13 +8703,21 @@ export function registerConfigSyncCommands(sync) {
8214
8703
  if (reconciled.message) {
8215
8704
  info(` ${reconciled.message}`);
8216
8705
  }
8706
+ let workflowWritten = false;
8217
8707
  if (reconciled.action === "write-merged") {
8218
8708
  writeFileSync(filePath, reconciled.content);
8709
+ pullWrites.written += 1;
8710
+ workflowWritten = true;
8219
8711
  }
8220
8712
  else if (reconciled.action === "write-expanded") {
8221
- writeFileSync(filePath, remoteToml);
8713
+ // #4160 — a file with no fragments that already describes the
8714
+ // running workflow keeps its bytes.
8715
+ workflowWritten = writePulledFile(filePath, remoteToml, pullWrites, sameWorkflowAs(workflow.workflowKey, remoteToml));
8716
+ }
8717
+ else {
8718
+ pullWrites.unchanged += 1;
8222
8719
  }
8223
- if (reconciled.action !== "skip") {
8720
+ if (workflowWritten) {
8224
8721
  info(` Wrote workflows/${filename}`);
8225
8722
  }
8226
8723
  // #2645 — the workflow's OTHER named configs, one file each. The
@@ -8232,8 +8729,11 @@ export function registerConfigSyncCommands(sync) {
8232
8729
  const sidecarDir = workflowConfigsDir(configDir, workflow.workflowKey);
8233
8730
  mkdirSync(sidecarDir, { recursive: true });
8234
8731
  for (const sidecar of sidecars) {
8235
- writeFileSync(join(sidecarDir, `${sidecar.name}.toml`), sidecar.content);
8236
- info(` Wrote workflows/${workflow.workflowKey}.configs/${sidecar.name}.toml`);
8732
+ const sidecarWritten = writePulledFile(join(sidecarDir, `${sidecar.name}.toml`), sidecar.content, pullWrites, (local) => hashWorkflowConfigSidecar(parseConfigToml(local)) ===
8733
+ hashWorkflowConfigSidecar(parseConfigToml(sidecar.content)));
8734
+ if (sidecarWritten) {
8735
+ info(` Wrote workflows/${workflow.workflowKey}.configs/${sidecar.name}.toml`);
8736
+ }
8237
8737
  }
8238
8738
  }
8239
8739
  // A sidecar for a config the server no longer has is a file that
@@ -8300,7 +8800,7 @@ export function registerConfigSyncCommands(sync) {
8300
8800
  return false;
8301
8801
  matchedSelectors.add(`transform/${name}`);
8302
8802
  return true;
8303
- });
8803
+ }, pullWrites);
8304
8804
  if (pulledScriptsCount > 0) {
8305
8805
  info(` Pulled ${pulledScriptsCount} transform(s)`);
8306
8806
  }
@@ -8314,79 +8814,49 @@ export function registerConfigSyncCommands(sync) {
8314
8814
  return false;
8315
8815
  matchedSelectors.add(`function/${key}`);
8316
8816
  return true;
8317
- });
8817
+ }, pullWrites);
8318
8818
  if (pulledFunctionsCount > 0) {
8319
8819
  info(` Pulled ${pulledFunctionsCount} function(s)`);
8320
8820
  }
8321
- // Build lookup maps for test case serialization
8322
- const configIdToName = new Map();
8323
- const promptIdToKey = new Map();
8324
- for (const prompt of prompts) {
8325
- promptIdToKey.set(prompt.promptId, prompt.promptKey);
8326
- if (prompt.configs) {
8327
- for (const config of prompt.configs) {
8328
- configIdToName.set(config.configId, config.configName);
8329
- }
8330
- }
8331
- }
8332
- // Also include workflow config names
8333
- for (const { configs } of workflows) {
8334
- if (configs) {
8335
- for (const config of configs) {
8336
- configIdToName.set(config.configId, config.configName);
8337
- }
8338
- }
8339
- }
8340
- // Include script config names so a pinned script test case
8341
- // round-trips its `configName` reference (the same way prompt/workflow
8342
- // configs above do). One extra `configs` fetch per pulled script.
8343
- for (const [name, entity] of Object.entries(scriptEntities)) {
8821
+ // Lookup maps for test case serialization (#3998): every prompt's
8822
+ // detail, so a judge prompt outside an `--only` selection still names,
8823
+ // and the configurations of the pulled blocks — a case pins one of its
8824
+ // own block's. A reference these cannot resolve is reported by
8825
+ // `pullTestCasesForBlock`, and its sidecar is not written.
8826
+ const blockConfigs = [];
8827
+ for (const { configs } of workflows)
8828
+ blockConfigs.push(...(configs ?? []));
8829
+ // One `configs` fetch per pulled script and integration. A failed one
8830
+ // is non-fatal: the references it would have named are reported.
8831
+ for (const entity of Object.values(scriptEntities)) {
8344
8832
  try {
8345
- const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
8346
- for (const config of scriptConfigs) {
8347
- configIdToName.set(config.configId, config.configName);
8348
- }
8833
+ const { items } = await client.listScriptConfigs(resolvedAppId, entity.id);
8834
+ blockConfigs.push(...items);
8349
8835
  }
8350
8836
  catch {
8351
- // Non-fatal: a script test case pinned to a config serializes with
8352
- // its legacy id spelling instead of a name (#2769), so the pin
8353
- // survives. The header/body still pulled fine — `name` retained.
8354
- void name;
8837
+ // Non-fatal — see above.
8355
8838
  }
8356
8839
  }
8357
- // Integration config names, for the same reason (#2769). A failed fetch
8358
- // is non-fatal here too: `serializeTestCase` keeps the pin as `configId`
8359
- // rather than writing an empty reference.
8360
8840
  for (const integration of integrations) {
8361
8841
  try {
8362
- const { items: integrationConfigs } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8363
- for (const config of integrationConfigs) {
8364
- configIdToName.set(config.configId, config.configName);
8365
- }
8842
+ const { items } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8843
+ blockConfigs.push(...items);
8366
8844
  }
8367
8845
  catch {
8368
8846
  // Non-fatal — see above.
8369
8847
  }
8370
8848
  }
8371
- const testCaseLookupMaps = { configIdToName, promptIdToKey };
8372
- // #2731 B4 — pull is one of the two moments local and server provably
8373
- // agree, so it stamps the semantic baseline beside the byte hash (push
8374
- // stamps the other one, from the entity the server returned). The
8375
- // rule-set maps the shared projection resolves references through are
8376
- // declared here and filled below, before the first type that needs
8377
- // them is written.
8378
- const ruleSetIdToName = new Map();
8379
- const ruleSetNameToIdPull = new Map();
8380
- const pullDiffMaps = {
8381
- ruleSetIdToName,
8382
- ruleSetNameToId: ruleSetNameToIdPull,
8383
- };
8849
+ const testCaseLookupMaps = testCaseLookupMapsForPull({
8850
+ prompts: promptDetails,
8851
+ configs: blockConfigs,
8852
+ });
8384
8853
  // Write email templates (use detailed emailTemplates, not summaries)
8385
8854
  const emailTemplateEntities = {};
8386
8855
  for (const template of emailTemplates) {
8387
8856
  const filename = `${template.emailType}.toml`;
8388
8857
  const templatePath = join(configDir, "email-templates", filename);
8389
- writeFileSync(templatePath, serializeEmailTemplate(template, (msg) => info(` ${msg}`)));
8858
+ const content = serializeEmailTemplate(template, (msg) => info(` ${msg}`));
8859
+ const written = writePulledFile(templatePath, content, pullWrites, sameConfigAs("email-template", content, templatePath, pullDiffMaps));
8390
8860
  emailTemplateEntities[template.emailType] = {
8391
8861
  id: template.emailType,
8392
8862
  modifiedAt: template.override?.modifiedAt || new Date().toISOString(),
@@ -8394,9 +8864,10 @@ export function registerConfigSyncCommands(sync) {
8394
8864
  // live-fetch fallback gates on, and an entry without one fails
8395
8865
  // closed rather than being blind-upserted.
8396
8866
  contentHash: computeFileHash(templatePath),
8397
- semanticHash: configSemanticHash("email-template", templatePath, pullDiffMaps),
8867
+ semanticHash: pulledSemanticHash("email-template", content, pullDiffMaps),
8398
8868
  };
8399
- info(` Wrote email-templates/${filename}`);
8869
+ if (written)
8870
+ info(` Wrote email-templates/${filename}`);
8400
8871
  }
8401
8872
  // Email-template files for removed overrides used to be cleaned up here
8402
8873
  // by a hand-coded loop — the only type that pruned at all (#1659). It is
@@ -8408,6 +8879,7 @@ export function registerConfigSyncCommands(sync) {
8408
8879
  const testCaseEntities = {};
8409
8880
  const priorTestCaseEntities = priorEntities.testCases;
8410
8881
  let totalTestCases = 0;
8882
+ let unwrittenTestCases = 0;
8411
8883
  for (const [blockType, blocks] of [
8412
8884
  [
8413
8885
  "prompt",
@@ -8437,7 +8909,7 @@ export function registerConfigSyncCommands(sync) {
8437
8909
  })),
8438
8910
  ],
8439
8911
  ]) {
8440
- const { count } = await pullTestCasesForBlocks({
8912
+ const { count, unwritten } = await pullTestCasesForBlocks({
8441
8913
  client,
8442
8914
  appId: resolvedAppId,
8443
8915
  configDir,
@@ -8447,16 +8919,22 @@ export function registerConfigSyncCommands(sync) {
8447
8919
  priorTestCaseEntities,
8448
8920
  lookupMaps: testCaseLookupMaps,
8449
8921
  logger: (message) => info(message),
8922
+ writes: pullWrites,
8450
8923
  });
8451
8924
  totalTestCases += count;
8925
+ unwrittenTestCases += unwritten.length;
8452
8926
  }
8453
- // Build ruleSetId → name map for database types and group type configs
8454
- const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
8455
- for (const ruleSet of ruleSets) {
8456
- ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
8457
- if (ruleSet.name)
8458
- ruleSetNameToIdPull.set(ruleSet.name, ruleSet.ruleSetId);
8927
+ // #3998 — a case pull could not name a reference for is not written, so
8928
+ // its pin is never lost; the count is repeated here so it is not missed
8929
+ // among the per-block lines. Exit status is unchanged, as for every
8930
+ // other partial fetch this command reports.
8931
+ if (unwrittenTestCases > 0) {
8932
+ warn(`${unwrittenTestCases} test case(s) not written — references could not be resolved; see above`);
8459
8933
  }
8934
+ // The rule-set files this pull writes. The reference maps above are
8935
+ // filled from the full listing instead, so a scoped pull of a type that
8936
+ // names a rule set still names it (#4116, DSO-4116-001).
8937
+ const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
8460
8938
  // Write database types
8461
8939
  const databaseTypeEntities = {};
8462
8940
  for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
@@ -8468,14 +8946,15 @@ export function registerConfigSyncCommands(sync) {
8468
8946
  const hints = existsSync(filePath)
8469
8947
  ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
8470
8948
  : undefined;
8471
- writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8949
+ const content = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8472
8950
  hints,
8473
8951
  defaultForm: "native",
8474
8952
  logger: (msg) => info(` ${msg}`),
8475
8953
  // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
8476
8954
  // cycle round-trips subscriptions without a spurious diff.
8477
8955
  subscriptions,
8478
- }));
8956
+ });
8957
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("database-type-config", content, filePath, pullDiffMaps));
8479
8958
  const opsEntities = {};
8480
8959
  for (const op of operations) {
8481
8960
  opsEntities[op.name] = {
@@ -8499,11 +8978,12 @@ export function registerConfigSyncCommands(sync) {
8499
8978
  operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
8500
8979
  subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
8501
8980
  contentHash: computeFileHash(filePath),
8502
- semanticHash: configSemanticHash("database-type-config", filePath, pullDiffMaps),
8981
+ semanticHash: pulledSemanticHash("database-type-config", content, pullDiffMaps),
8503
8982
  hasSchema: typeof typeConfig.schema === "string" &&
8504
8983
  typeConfig.schema.trim().length > 0,
8505
8984
  };
8506
- info(` Wrote database-type-configs/${filename}`);
8985
+ if (written)
8986
+ info(` Wrote database-type-configs/${filename}`);
8507
8987
  }
8508
8988
  // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
8509
8989
  // they are not in the envelope, and a fresh clone must end up with the
@@ -8526,14 +9006,16 @@ export function registerConfigSyncCommands(sync) {
8526
9006
  const fileKey = ruleSetFileKey(ruleSet);
8527
9007
  const filename = `${fileKey}.toml`;
8528
9008
  const filePath = join(configDir, "rule-sets", filename);
8529
- writeFileSync(filePath, serializeRuleSet(ruleSet, (msg) => info(` ${msg}`)));
9009
+ const content = serializeRuleSet(ruleSet, (msg) => info(` ${msg}`));
9010
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("rule-set", content, filePath, pullDiffMaps));
8530
9011
  ruleSetEntities[fileKey] = {
8531
9012
  id: ruleSet.ruleSetId,
8532
9013
  modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
8533
9014
  contentHash: computeFileHash(filePath),
8534
- semanticHash: configSemanticHash("rule-set", filePath, pullDiffMaps),
9015
+ semanticHash: pulledSemanticHash("rule-set", content, pullDiffMaps),
8535
9016
  };
8536
- info(` Wrote rule-sets/${filename}`);
9017
+ if (written)
9018
+ info(` Wrote rule-sets/${filename}`);
8537
9019
  }
8538
9020
  // Write group type configs
8539
9021
  const groupTypeConfigEntities = {};
@@ -8541,13 +9023,15 @@ export function registerConfigSyncCommands(sync) {
8541
9023
  for (const config of groupTypeConfigs) {
8542
9024
  const filename = `${config.groupType}.toml`;
8543
9025
  const filePath = join(configDir, "group-type-configs", filename);
8544
- writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`)));
9026
+ const content = serializeGroupTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
9027
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("group-type-config", content, filePath, pullDiffMaps));
8545
9028
  groupTypeConfigEntities[config.groupType] = {
8546
9029
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8547
9030
  contentHash: computeFileHash(filePath),
8548
- semanticHash: configSemanticHash("group-type-config", filePath, pullDiffMaps),
9031
+ semanticHash: pulledSemanticHash("group-type-config", content, pullDiffMaps),
8549
9032
  };
8550
- info(` Wrote group-type-configs/${filename}`);
9033
+ if (written)
9034
+ info(` Wrote group-type-configs/${filename}`);
8551
9035
  }
8552
9036
  // Write collection type configs
8553
9037
  const collectionTypeConfigEntities = {};
@@ -8555,13 +9039,15 @@ export function registerConfigSyncCommands(sync) {
8555
9039
  for (const config of collectionTypeConfigs) {
8556
9040
  const filename = `${config.collectionType}.toml`;
8557
9041
  const filePath = join(configDir, "collection-type-configs", filename);
8558
- writeFileSync(filePath, serializeCollectionTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`)));
9042
+ const content = serializeCollectionTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
9043
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("collection-type-config", content, filePath, pullDiffMaps));
8559
9044
  collectionTypeConfigEntities[config.collectionType] = {
8560
9045
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8561
9046
  contentHash: computeFileHash(filePath),
8562
- semanticHash: configSemanticHash("collection-type-config", filePath, pullDiffMaps),
9047
+ semanticHash: pulledSemanticHash("collection-type-config", content, pullDiffMaps),
8563
9048
  };
8564
- info(` Wrote collection-type-configs/${filename}`);
9049
+ if (written)
9050
+ info(` Wrote collection-type-configs/${filename}`);
8565
9051
  }
8566
9052
  // Write metadata category configs (issue #1304, P-B). One file per
8567
9053
  // `{resourceType, category}`; the entity key is the `resourceType#category`
@@ -8571,13 +9057,15 @@ export function registerConfigSyncCommands(sync) {
8571
9057
  for (const config of metadataCategoryConfigs) {
8572
9058
  const filename = `${config.resourceType}.${config.category}.toml`;
8573
9059
  const filePath = join(configDir, "metadata-category-configs", filename);
8574
- writeFileSync(filePath, serializeMetadataCategoryConfig(config, (msg) => info(` ${msg}`)));
9060
+ const content = serializeMetadataCategoryConfig(config, (msg) => info(` ${msg}`));
9061
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("metadata-category-config", content, filePath, pullDiffMaps));
8575
9062
  metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
8576
9063
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8577
9064
  contentHash: computeFileHash(filePath),
8578
- semanticHash: configSemanticHash("metadata-category-config", filePath, pullDiffMaps),
9065
+ semanticHash: pulledSemanticHash("metadata-category-config", content, pullDiffMaps),
8579
9066
  };
8580
- info(` Wrote metadata-category-configs/${filename}`);
9067
+ if (written)
9068
+ info(` Wrote metadata-category-configs/${filename}`);
8581
9069
  }
8582
9070
  // ── Prune reconciliation (issue #1659) ───────────────────────────────
8583
9071
  //
@@ -8727,6 +9215,7 @@ export function registerConfigSyncCommands(sync) {
8727
9215
  preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
8728
9216
  ? emailTemplateOverrides.map((t) => t.emailType)
8729
9217
  : []);
9218
+ preserveUnwrittenEntries(blobBucketEntities, priorEntities.blobBuckets, unwrittenBlobBucketKeys);
8730
9219
  const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
8731
9220
  const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
8732
9221
  const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
@@ -8924,8 +9413,12 @@ export function registerConfigSyncCommands(sync) {
8924
9413
  // this is the state both sides agree on: the baseline the
8925
9414
  // next push classifies against. A pull also ends any
8926
9415
  // cross-app name exclusion, because the file it just wrote
8927
- // states the target's own name.
8928
- semanticHash: hashAppSettingsForDiff(settings),
9416
+ // states the target's own name — except on a
9417
+ // machine-local environment, whose file states the shared
9418
+ // tree's name and whose baseline never includes one.
9419
+ semanticHash: hashAppSettingsForDiff(settings, {
9420
+ excludeName: pullsLocalEnvironment,
9421
+ }),
8929
9422
  }
8930
9423
  : pullSelection
8931
9424
  ? priorEntities.app
@@ -8952,7 +9445,7 @@ export function registerConfigSyncCommands(sync) {
8952
9445
  : undefined,
8953
9446
  },
8954
9447
  };
8955
- saveSyncState(configDir, state);
9448
+ saveSyncState(state);
8956
9449
  divider();
8957
9450
  success(`Pulled configuration to ${configDir}`);
8958
9451
  keyValue("Config Vars", Object.keys(varEntities).length);
@@ -8966,12 +9459,17 @@ export function registerConfigSyncCommands(sync) {
8966
9459
  keyValue("Functions", pulledFunctionsCount);
8967
9460
  keyValue("Email Templates", emailTemplates.length);
8968
9461
  keyValue("Test Cases", totalTestCases);
9462
+ if (unwrittenTestCases > 0) {
9463
+ keyValue("Test Cases not written", unwrittenTestCases);
9464
+ }
8969
9465
  keyValue("Database Types", databaseTypesWithOps.length);
8970
9466
  keyValue("Rule Sets", ruleSets.length);
8971
9467
  keyValue("Group Type Configs", groupTypeConfigs.length);
8972
9468
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
8973
9469
  keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
8974
9470
  keyValue("Pruned", prunedCount);
9471
+ keyValue("Files written", pullWrites.written);
9472
+ keyValue("Files unchanged", pullWrites.unchanged);
8975
9473
  // A selector the server had nothing for wrote nothing. Say so and fail:
8976
9474
  // otherwise a typo pulls an empty set and reports success, and the
8977
9475
  // operator reads "Pulled configuration" as "my object is now in the
@@ -9079,7 +9577,11 @@ What a push guarantees (two stages):
9079
9577
  // state write holds on the first push and the end-of-run save persists
9080
9578
  // the ids. Without it a successful create-push recorded nothing and the
9081
9579
  // next push re-entered every create path.
9082
- let syncState = ensurePushSyncState(loadSyncState(configDir), resolvedAppId, getServerUrl());
9580
+ let syncState = ensurePushSyncState(loadSyncState(), resolvedAppId, getServerUrl());
9581
+ // A machine-local environment (#4117) shares a committed environment's
9582
+ // tree, so `[app].name` in it is that environment's name: never sent,
9583
+ // never compared, never hashed into this environment's baseline.
9584
+ const localEnvironment = requireCurrentEnvironment().local;
9083
9585
  // Detect cross-app push: if pushing to a different app, clear entity IDs
9084
9586
  // so everything goes through the create path
9085
9587
  const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
@@ -9515,6 +10017,7 @@ What a push guarantees (two stages):
9515
10017
  syncState,
9516
10018
  force: !!options.force,
9517
10019
  isCrossAppPush: !!isCrossAppPush,
10020
+ localEnvironment,
9518
10021
  client,
9519
10022
  });
9520
10023
  // Re-derived, not returned: both are one-line functions of inputs the
@@ -9691,13 +10194,21 @@ What a push guarantees (two stages):
9691
10194
  // `appNameEdited` — the file has moved away from the recorded name.
9692
10195
  // That is a local rename BY CONSTRUCTION, not by comparison: no
9693
10196
  // baseline hashed without `name` can ever attribute it.
10197
+ //
10198
+ // On a machine-local environment (#4117) all three are settled
10199
+ // before they start: the name is the shared tree's, so it is out of
10200
+ // the body and the comparison, and no edit to it is this app's.
9694
10201
  const payloadExcludesName = appSettingsNameExcluded({
10202
+ localEnvironment,
9695
10203
  isCrossAppPush: !!isCrossAppPush,
9696
10204
  recordedExcludedName,
9697
10205
  fileName: appFileName,
9698
10206
  });
9699
- const compareExcludesName = !!isCrossAppPush || recordedExcludedName !== undefined;
9700
- const appNameEdited = !isCrossAppPush &&
10207
+ const compareExcludesName = localEnvironment ||
10208
+ !!isCrossAppPush ||
10209
+ recordedExcludedName !== undefined;
10210
+ const appNameEdited = !localEnvironment &&
10211
+ !isCrossAppPush &&
9701
10212
  recordedExcludedName !== undefined &&
9702
10213
  !payloadExcludesName;
9703
10214
  // The whole-truth body. Errors (an unrecognized or retired key, a
@@ -9705,6 +10216,7 @@ What a push guarantees (two stages):
9705
10216
  // the preflight pass above, so only warnings remain here.
9706
10217
  const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
9707
10218
  excludeName: payloadExcludesName,
10219
+ nameOptional: localEnvironment,
9708
10220
  });
9709
10221
  for (const w of appWarnings) {
9710
10222
  warn(` ${w}`);
@@ -9727,8 +10239,12 @@ What a push guarantees (two stages):
9727
10239
  appEntry?.modifiedAt ||
9728
10240
  new Date().toISOString(),
9729
10241
  contentHash: appFileHash,
10242
+ // Every stamp on a machine-local environment is hashed without
10243
+ // the name, or the next comparison — which never sees it —
10244
+ // could not match the baseline (#4117).
9730
10245
  semanticHash: hashAppSettingsForDiff(baselineSettings, {
9731
- excludeName: options.recordExclusion !== undefined ||
10246
+ excludeName: localEnvironment ||
10247
+ options.recordExclusion !== undefined ||
9732
10248
  (options.keepExclusion && compareExcludesName),
9733
10249
  }),
9734
10250
  ...(options.recordExclusion !== undefined
@@ -10936,16 +11452,25 @@ What a push guarantees (two stages):
10936
11452
  const liveBuckets = new Map();
10937
11453
  let bucketsLiveOk = true;
10938
11454
  if (files.length > 0) {
10939
- try {
10940
- const listed = await client.listBlobBuckets(resolvedAppId);
10941
- for (const b of (listed?.items ?? [])) {
10942
- if (b?.bucketKey)
10943
- liveBuckets.set(b.bucketKey, b);
10944
- }
11455
+ // #4116 — a bound bucket's rule set is compared by name, so the
11456
+ // comparison needs the rule-set listing; without it the leg
11457
+ // degrades as the type configs do.
11458
+ bucketsLiveOk = (await loadLiveRuleSets()).ok;
11459
+ if (!bucketsLiveOk) {
11460
+ warnLiveUnavailable("blob-buckets", "rule sets unavailable");
10945
11461
  }
10946
- catch (err) {
10947
- bucketsLiveOk = false;
10948
- warnLiveUnavailable("blob-buckets", err);
11462
+ else {
11463
+ try {
11464
+ const listed = await client.listBlobBuckets(resolvedAppId);
11465
+ for (const b of (listed?.items ?? [])) {
11466
+ if (b?.bucketKey)
11467
+ liveBuckets.set(b.bucketKey, b);
11468
+ }
11469
+ }
11470
+ catch (err) {
11471
+ bucketsLiveOk = false;
11472
+ warnLiveUnavailable("blob-buckets", err);
11473
+ }
10949
11474
  }
10950
11475
  }
10951
11476
  const refetchLiveBucket = async (bucketId) => {
@@ -10957,7 +11482,13 @@ What a push guarantees (two stages):
10957
11482
  const tomlData = parseTomlFile(filePath);
10958
11483
  const key = tomlData.bucket?.key || basename(file, ".toml");
10959
11484
  withDerivedIdentity(tomlData, "bucket", { key });
10960
- const bucket = tomlData.bucket || {};
11485
+ // #4116 — `ruleSetName` resolves to THIS app's rule set before any
11486
+ // request; a name that resolves nowhere fails here, so a bucket is
11487
+ // never created or updated without its rule set.
11488
+ const bucket = parseBlobBucketToml(tomlData);
11489
+ resolveRuleSetReference(bucket, ruleSetNameToId, `blob bucket "${key}"`, {
11490
+ hint: ruleSetSelectionHint,
11491
+ });
10961
11492
  const existingEntry = syncState?.entities?.blobBuckets?.[key];
10962
11493
  const liveBucket = liveBuckets.get(key);
10963
11494
  let gate;
@@ -11428,7 +11959,7 @@ What a push guarantees (two stages):
11428
11959
  const serverConfig = liveServerConfig(tomlConfig.name);
11429
11960
  if (!serverConfig || serverConfig.strictOutput !== true)
11430
11961
  continue;
11431
- const chat = hoistDeprecatedChatKeys(tomlConfig).entry.chat ?? {};
11962
+ const chat = tomlConfig.chat ?? {};
11432
11963
  const retired = String(tomlConfig?.status ?? "").trim().toLowerCase() ===
11433
11964
  "archived";
11434
11965
  // An entry that stays strict AND live is refused in preflight
@@ -15662,7 +16193,7 @@ What a push guarantees (two stages):
15662
16193
  // Update sync state for non-conflicting changes
15663
16194
  if (syncState) {
15664
16195
  syncState.lastSyncedAt = new Date().toISOString();
15665
- saveSyncState(configDir, syncState);
16196
+ saveSyncState(syncState);
15666
16197
  }
15667
16198
  const successCount = countAppliedChanges({
15668
16199
  changes,
@@ -15693,7 +16224,7 @@ What a push guarantees (two stages):
15693
16224
  // Update sync state after push
15694
16225
  if (syncState) {
15695
16226
  syncState.lastSyncedAt = new Date().toISOString();
15696
- saveSyncState(configDir, syncState);
16227
+ saveSyncState(syncState);
15697
16228
  }
15698
16229
  reportDrift();
15699
16230
  success(formatPushSummary({
@@ -15718,7 +16249,7 @@ What a push guarantees (two stages):
15718
16249
  if (syncState && recordedProgress && !options.dryRun) {
15719
16250
  try {
15720
16251
  syncState.lastSyncedAt = new Date().toISOString();
15721
- saveSyncState(configDir, syncState);
16252
+ saveSyncState(syncState);
15722
16253
  }
15723
16254
  catch {
15724
16255
  // Don't mask the original error
@@ -15751,7 +16282,7 @@ What a push guarantees (two stages):
15751
16282
  error(`Config directory not found: ${configDir}`);
15752
16283
  process.exit(1);
15753
16284
  }
15754
- const syncState = loadSyncState(configDir);
16285
+ const syncState = loadSyncState();
15755
16286
  // `--only` scopes the comparison the same way it scopes push and pull.
15756
16287
  const diffSelection = readSelection(options);
15757
16288
  // `--only app` on its own IS the retired `settings diff`: it reports the
@@ -15837,7 +16368,6 @@ What a push guarantees (two stages):
15837
16368
  const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
15838
16369
  const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
15839
16370
  const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
15840
- const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
15841
16371
  const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
15842
16372
  // #1660 — exclude platform-owned workflows (reserved `__internal.*`)
15843
16373
  // from the remote set so `diff` never reports them (they are not app
@@ -15874,7 +16404,6 @@ What a push guarantees (two stages):
15874
16404
  const localIntegrations = new Set();
15875
16405
  const localWebhooks = new Set();
15876
16406
  const localCronTriggers = new Set();
15877
- const localBlobBuckets = new Set();
15878
16407
  const localPrompts = new Set();
15879
16408
  const localWorkflows = new Set();
15880
16409
  const localEmailTemplates = new Set();
@@ -15950,7 +16479,6 @@ What a push guarantees (two stages):
15950
16479
  for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
15951
16480
  const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
15952
16481
  const key = tomlData.bucket?.key || basename(file, ".toml");
15953
- localBlobBuckets.add(key);
15954
16482
  // #2880 — as above.
15955
16483
  if (!tomlData.bucket)
15956
16484
  tomlData.bucket = {};
@@ -16303,23 +16831,6 @@ What a push guarantees (two stages):
16303
16831
  differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
16304
16832
  }
16305
16833
  }
16306
- // Blob Buckets — #2880: the same, and the list row is the record here
16307
- // too.
16308
- const blobBucketSpec = configDiffSpec("blob-bucket");
16309
- const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
16310
- for (const key of localBlobBuckets) {
16311
- if (!remoteBlobBuckets.has(key)) {
16312
- differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
16313
- continue;
16314
- }
16315
- const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
16316
- differences.push({ type: "blob-bucket", key, status, hint });
16317
- }
16318
- for (const key of remoteBlobBuckets) {
16319
- if (!localBlobBuckets.has(key)) {
16320
- differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
16321
- }
16322
- }
16323
16834
  // Prompts — #2748: content-aware comparison through the shared registry
16324
16835
  // spec, for the reason webhooks got one in #2757. Existence-only
16325
16836
  // reporting meant a prompt's `accessRule` (and every other `[prompt]`
@@ -16745,20 +17256,12 @@ What a push guarantees (two stages):
16745
17256
  differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
16746
17257
  continue;
16747
17258
  }
16748
- let status = "exists";
16749
- let hint;
16750
- if (!contentComparable) {
16751
- // Rule-set list failed, so a `ruleSetName` reference can't be
16752
- // resolved on either side — decline the content comparison rather
16753
- // than risk a false `Modified`.
16754
- hint = "content not compared (rule sets unavailable)";
16755
- }
16756
- else {
16757
- // The shared verdict names the differing field (#3267) and
16758
- // degrades a malformed local file to "not compared" rather than
16759
- // aborting the whole diff.
16760
- ({ status, hint } = compare(parsed, remoteEntities.get(key)));
16761
- }
17259
+ // Rule-set list failed, so a `ruleSetName` reference can't be
17260
+ // resolved on either side — decline the content comparison rather
17261
+ // than risk a false `Modified`. Otherwise the shared verdict names
17262
+ // the differing field (#3267) and degrades a malformed local file
17263
+ // to "not compared" rather than aborting the whole diff.
17264
+ const { status, hint } = configDiffRowVerdict(parsed, remoteEntities.get(key), compare, contentComparable);
16762
17265
  differences.push({ type: typeLabel, key, status, hint });
16763
17266
  }
16764
17267
  for (const key of remoteEntities.keys()) {
@@ -16782,6 +17285,9 @@ What a push guarantees (two stages):
16782
17285
  compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
16783
17286
  compareByDefinition("collection-type-config", "collectionTypeConfigs", localCollectionTypeConfigsParsed, remoteCollectionTypeConfigs, ruleSetsOk);
16784
17287
  compareByDefinition("metadata-category-config", "metadataCategoryConfigs", localMetadataCategoryParsed, remoteMetadataCategoryConfigs, true);
17288
+ // Blob buckets — #2880: the list row is the record. #4116: a bucket
17289
+ // names its rule set, so its rows take the type configs' gate.
17290
+ compareByDefinition("blob-bucket", "blobBuckets", localBlobBucketsParsed, new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b])), blobBucketsOutcomeDiff.ok && ruleSetsOk);
16785
17291
  // database-type-configs: the list carries only the type header, so operations
16786
17292
  // and subscriptions are fetched per type (mirroring pull) before the
16787
17293
  // content comparison. A per-type fetch failure degrades that one row to
@@ -16912,12 +17418,16 @@ What a push guarantees (two stages):
16912
17418
  appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
16913
17419
  }
16914
17420
  if (appTomlData) {
16915
- const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData);
17421
+ const diffsLocalEnvironment = requireCurrentEnvironment().local;
17422
+ const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData, diffsLocalEnvironment);
16916
17423
  // The SAME validator push's preflight runs (criterion 1): a
16917
17424
  // declared-type mismatch, an unrecognized or retired key, and a
16918
17425
  // missing required key are reported here with push's own message
16919
17426
  // rather than compared past and called Synced.
16920
- const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, { excludeName });
17427
+ const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, {
17428
+ excludeName,
17429
+ nameOptional: diffsLocalEnvironment,
17430
+ });
16921
17431
  if (appErrors.length > 0) {
16922
17432
  appRow(VALIDATION_ERROR, appErrors.join("; "));
16923
17433
  }
@@ -17322,11 +17832,11 @@ What a push guarantees (two stages):
17322
17832
  writeFileSync(filePath, rewritten);
17323
17833
  info(`Migrated database-type-configs/${file}`);
17324
17834
  // Update content hash in sync state if we have an entry for it.
17325
- const syncState = loadSyncState(configDir);
17835
+ const syncState = loadSyncState();
17326
17836
  const dbType = typeConfig.databaseType || basename(file, ".toml");
17327
17837
  if (syncState?.entities?.databaseTypes?.[dbType]) {
17328
17838
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
17329
- saveSyncState(configDir, syncState);
17839
+ saveSyncState(syncState);
17330
17840
  }
17331
17841
  }
17332
17842
  migratedCount++;
@@ -17354,11 +17864,11 @@ What a push guarantees (two stages):
17354
17864
  writeFileSync(filePath, rewritten);
17355
17865
  info(`Migrated workflows/${file}`);
17356
17866
  // Update the stored content hash so a subsequent push sees a no-op.
17357
- const syncState = loadSyncState(configDir);
17867
+ const syncState = loadSyncState();
17358
17868
  if (syncState?.entities?.workflows?.[workflowKey]) {
17359
17869
  syncState.entities.workflows[workflowKey].contentHash =
17360
17870
  computeWorkflowContentHash(configDir, workflowKey, parseTomlFile(filePath));
17361
- saveSyncState(configDir, syncState);
17871
+ saveSyncState(syncState);
17362
17872
  }
17363
17873
  }
17364
17874
  migratedCount++;
@@ -17496,14 +18006,26 @@ What a push guarantees (two stages):
17496
18006
  return;
17497
18007
  }
17498
18008
  }
18009
+ // A machine-local environment's baseline lives outside the shared tree
18010
+ // (#4117): the snapshot's baseline goes back there, and the tree's own
18011
+ // baseline — the committed environment's — is left exactly as it is.
18012
+ const baselinePath = requireCurrentEnvironment().local
18013
+ ? resolveSyncStateWritePath()
18014
+ : undefined;
17499
18015
  try {
17500
- restoreSnapshot(target.path, configDir);
18016
+ restoreSnapshot(target.path, configDir, { baselinePath });
17501
18017
  }
17502
18018
  catch (err) {
17503
18019
  error(`Restore failed: ${err?.message ?? err}`);
17504
18020
  process.exit(1);
17505
18021
  }
17506
- success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
18022
+ if (baselinePath) {
18023
+ success(`Restored snapshot ${target.id} into ${configDir} and this environment's baseline into ${baselinePath}.`);
18024
+ info(`The committed baseline in ${configDir} belongs to the shared tree's environment and was left as it was.`);
18025
+ }
18026
+ else {
18027
+ success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
18028
+ }
17507
18029
  info("Run 'primitive config diff' to inspect the restored state versus the server.");
17508
18030
  });
17509
18031
  }