primitive-admin 1.1.0-alpha.65 → 1.1.0-alpha.66

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 (54) hide show
  1. package/README.md +4 -4
  2. package/assets/skill/skills/primitive-platform/SKILL.md +15 -7
  3. package/dist/bin/primitive.js +4 -3
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/apps.js +14 -5
  6. package/dist/src/commands/apps.js.map +1 -1
  7. package/dist/src/commands/env.d.ts +12 -1
  8. package/dist/src/commands/env.js +96 -21
  9. package/dist/src/commands/env.js.map +1 -1
  10. package/dist/src/commands/init.d.ts +1 -1
  11. package/dist/src/commands/init.js +78 -25
  12. package/dist/src/commands/init.js.map +1 -1
  13. package/dist/src/commands/sync-app-settings.d.ts +83 -55
  14. package/dist/src/commands/sync-app-settings.js +131 -200
  15. package/dist/src/commands/sync-app-settings.js.map +1 -1
  16. package/dist/src/commands/sync.d.ts +240 -28
  17. package/dist/src/commands/sync.js +2022 -309
  18. package/dist/src/commands/sync.js.map +1 -1
  19. package/dist/src/commands/workflows.js +14 -3
  20. package/dist/src/commands/workflows.js.map +1 -1
  21. package/dist/src/lib/app-settings-descriptor.d.ts +143 -15
  22. package/dist/src/lib/app-settings-descriptor.js +315 -28
  23. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  24. package/dist/src/lib/config-payload.js +14 -3
  25. package/dist/src/lib/config-payload.js.map +1 -1
  26. package/dist/src/lib/config-surface.d.ts +37 -1
  27. package/dist/src/lib/config-surface.js +118 -0
  28. package/dist/src/lib/config-surface.js.map +1 -1
  29. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  30. package/dist/src/lib/env-resolver-core.js +265 -0
  31. package/dist/src/lib/env-resolver-core.js.map +1 -0
  32. package/dist/src/lib/env-resolver.d.ts +28 -6
  33. package/dist/src/lib/env-resolver.js +44 -32
  34. package/dist/src/lib/env-resolver.js.map +1 -1
  35. package/dist/src/lib/generated-config-surfaces.d.ts +33 -0
  36. package/dist/src/lib/generated-config-surfaces.js +151 -49
  37. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  38. package/dist/src/lib/integration-request-config.d.ts +30 -0
  39. package/dist/src/lib/integration-request-config.js +145 -0
  40. package/dist/src/lib/integration-request-config.js.map +1 -0
  41. package/dist/src/lib/local-state.d.ts +55 -0
  42. package/dist/src/lib/local-state.js +167 -0
  43. package/dist/src/lib/local-state.js.map +1 -0
  44. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  45. package/dist/src/lib/server-text-normalization.js +90 -0
  46. package/dist/src/lib/server-text-normalization.js.map +1 -0
  47. package/dist/src/lib/sync-resource-types.d.ts +8 -0
  48. package/dist/src/lib/sync-resource-types.js +8 -0
  49. package/dist/src/lib/sync-resource-types.js.map +1 -1
  50. package/dist/src/lib/template.d.ts +27 -0
  51. package/dist/src/lib/template.js +46 -20
  52. package/dist/src/lib/template.js.map +1 -1
  53. package/dist/src/types/index.d.ts +31 -7
  54. package/package.json +2 -2
@@ -14,10 +14,12 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
14
14
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
15
15
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
16
16
  import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
17
+ import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request-config.js";
18
+ import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
17
19
  import { canonicalJsonStringify } from "../lib/canonical-json.js";
18
20
  import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
19
- import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
20
- import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, } from "./sync-app-settings.js";
21
+ import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
22
+ import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
21
23
  import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
22
24
  // #2645 — named config bodies live in `workflows/<key>.configs/<name>.toml`.
23
25
  import { workflowConfigsDir, serializeWorkflowConfigSidecar, parseWorkflowConfigSidecar, readWorkflowConfigSidecars, } from "../lib/workflow-config-sidecar.js";
@@ -27,11 +29,11 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
27
29
  // payload builder so the diff normalizer and the wire cannot disagree.
28
30
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
29
31
  // #2644 — the one definition per configuration object, vendored from the server.
30
- 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, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
32
+ 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, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
31
33
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
32
34
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
33
35
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
34
- import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
36
+ import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
35
37
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
36
38
  import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
37
39
  import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
@@ -1325,15 +1327,26 @@ export function buildWebhookPushPayload(key, tomlData, { mode = "update" } = {})
1325
1327
  },
1326
1328
  }),
1327
1329
  };
1328
- // Handle JSON fields
1329
- if (tomlData?.allowedIps?.cidrs) {
1330
- payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
1331
- }
1332
- if (tomlData?.inputMapping) {
1333
- payload.inputMapping = JSON.stringify(tomlData.inputMapping);
1334
- }
1335
- if (tomlData?.metadata) {
1336
- payload.metadata = JSON.stringify(tomlData.metadata);
1330
+ // The three JSON-carrying tables. TOML-owned like `description` above
1331
+ // (#1567), so on an UPDATE each is sent as value-or-`null`: sending nothing
1332
+ // for an absent table left the stored value in place, which the shared
1333
+ // comparison then reported as a difference forever — push planned the same
1334
+ // update on every run, the server changed nothing, and deleting a
1335
+ // `[inputMapping]` section could not be made to take effect. The server
1336
+ // stores an explicit `null` (`updates.<key> = payload.<key>`).
1337
+ //
1338
+ // A CREATE sends only what the file has: there is nothing to clear, and the
1339
+ // create handlers read an absent key as "no value" already.
1340
+ const jsonTables = [
1341
+ ["allowedIpCidrs", tomlData?.allowedIps?.cidrs],
1342
+ ["inputMapping", tomlData?.inputMapping],
1343
+ ["metadata", tomlData?.metadata],
1344
+ ];
1345
+ for (const [key, value] of jsonTables) {
1346
+ if (value)
1347
+ payload[key] = JSON.stringify(value);
1348
+ else if (mode === "update")
1349
+ payload[key] = null;
1337
1350
  }
1338
1351
  // Forward the declared `[verification.<scheme>]` table so scheme
1339
1352
  // configuration reaches the server. Without this the table is silently
@@ -1425,12 +1438,24 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
1425
1438
  },
1426
1439
  }),
1427
1440
  };
1428
- // Handle JSON fields
1429
- if (tomlData?.rootInput) {
1430
- payload.rootInput = JSON.stringify(tomlData.rootInput);
1431
- }
1432
- if (tomlData?.inputMapping) {
1433
- payload.inputMapping = JSON.stringify(tomlData.inputMapping);
1441
+ // The two JSON-carrying tables, TOML-owned like `description` (#1567): an
1442
+ // UPDATE sends value-or-`null`, so deleting a `[rootInput]` section clears it
1443
+ // instead of leaving a value the shared comparison reports as a difference
1444
+ // on every run that push cannot clear. The controller takes an explicit
1445
+ // `null` as the clear and validates anything else as JSON.
1446
+ //
1447
+ // A CREATE sends only what the file has: the create leg rejects a `null` as
1448
+ // invalid JSON, and there is nothing to clear on a trigger that does not
1449
+ // exist yet.
1450
+ const jsonTables = [
1451
+ ["rootInput", tomlData?.rootInput],
1452
+ ["inputMapping", tomlData?.inputMapping],
1453
+ ];
1454
+ for (const [key, value] of jsonTables) {
1455
+ if (value)
1456
+ payload[key] = JSON.stringify(value);
1457
+ else if (mode === "update")
1458
+ payload[key] = null;
1434
1459
  }
1435
1460
  return payload;
1436
1461
  }
@@ -1979,6 +2004,114 @@ export function unknownKeyPreflightTargets() {
1979
2004
  }
1980
2005
  return targets;
1981
2006
  }
2007
+ /**
2008
+ * The diff row a per-entity config file belongs to (#2880 criterion 1).
2009
+ *
2010
+ * `config diff` pairs a local file with a server entity by a key each type
2011
+ * derives its own way — the `[table].key` the file states, its identity field,
2012
+ * or the file name standing in for either. The validation reporter has to land
2013
+ * its message on THAT row, so the derivation lives here once rather than being
2014
+ * inferred from the file name and quietly missing a renamed file.
2015
+ */
2016
+ function localConfigRowKey(label, tomlData, file) {
2017
+ const fallback = basename(file, ".toml");
2018
+ const stated = (table, key) => tomlData?.[table]?.[key] || fallback;
2019
+ switch (label) {
2020
+ case "integration":
2021
+ return stated("integration", "key");
2022
+ case "webhook":
2023
+ return stated("webhook", "key");
2024
+ case "cron-trigger":
2025
+ return stated("cronTrigger", "key");
2026
+ case "blob-bucket":
2027
+ return stated("bucket", "key");
2028
+ case "prompt":
2029
+ return stated("prompt", "key");
2030
+ case "workflow":
2031
+ return stated("workflow", "key");
2032
+ case "email-template":
2033
+ return stated("template", "emailType");
2034
+ case "database-type-config":
2035
+ return stated("type", "databaseType");
2036
+ case "group-type-config":
2037
+ return stated("groupTypeConfig", "groupType");
2038
+ case "collection-type-config":
2039
+ return stated("collectionTypeConfig", "collectionType");
2040
+ case "metadata-category-config": {
2041
+ const section = tomlData?.metadataCategoryConfig || {};
2042
+ if (section.resourceType && section.category) {
2043
+ return `${section.resourceType}#${section.category}`;
2044
+ }
2045
+ // `resourceType.category.toml`, the filename fallback push and diff share.
2046
+ const dot = fallback.indexOf(".");
2047
+ return dot > 0
2048
+ ? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
2049
+ : fallback;
2050
+ }
2051
+ // Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
2052
+ // still updates the same entity, so the row is the file's.
2053
+ default:
2054
+ return fallback;
2055
+ }
2056
+ }
2057
+ /**
2058
+ * Every per-entity config file whose TOML `config push` would refuse (#2880
2059
+ * criterion 1, symmetry).
2060
+ *
2061
+ * One collector, two commands. Push aborts on the messages before issuing a
2062
+ * request; `config diff` renders them as their own rows, because the failure
2063
+ * this closes is a file diff calls Synced and push then rejects — the state the
2064
+ * issue's reporter hit after #2803 retired `[prompt].status`, where a local key
2065
+ * with no server counterpart is invisible to a comparison and fatal to a push.
2066
+ *
2067
+ * Three checks, all definition-driven: the document's table shape, keys no
2068
+ * definition declares (retired keys among them), and — since #2880 — values
2069
+ * whose spelling is not the declared type. A file that does not parse is left
2070
+ * to the per-type loop that reads it, which names the parse error itself.
2071
+ */
2072
+ export function collectConfigFileValidationErrors(configDir) {
2073
+ const found = [];
2074
+ for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
2075
+ const typeDir = join(configDir, dir);
2076
+ if (!existsSync(typeDir))
2077
+ continue;
2078
+ for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
2079
+ const filePath = join(typeDir, file);
2080
+ let tomlData;
2081
+ try {
2082
+ tomlData = parseTomlFile(filePath);
2083
+ }
2084
+ catch {
2085
+ continue;
2086
+ }
2087
+ const messages = [];
2088
+ // The document's own shape first: an unrecognized top-level table makes
2089
+ // every per-table check below read an absent table, so its silence would
2090
+ // be misleading.
2091
+ messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
2092
+ for (const table of tables) {
2093
+ const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
2094
+ if (unknownKeyError)
2095
+ messages.push(unknownKeyError);
2096
+ }
2097
+ // Declared types run over EVERY table of the surface, workflows included
2098
+ // — `tables` above is pruned for workflows because `validateWorkflowToml`
2099
+ // owns their unrecognized-key message, which is a different check.
2100
+ for (const table of surface.tables) {
2101
+ messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
2102
+ }
2103
+ if (messages.length === 0)
2104
+ continue;
2105
+ found.push({
2106
+ type: surface.label,
2107
+ key: localConfigRowKey(surface.label, tomlData, file),
2108
+ filePath,
2109
+ messages,
2110
+ });
2111
+ }
2112
+ }
2113
+ return found;
2114
+ }
1982
2115
  /**
1983
2116
  * Every `<key>.tests/*.toml` sidecar under a config directory.
1984
2117
  *
@@ -2822,16 +2955,25 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
2822
2955
  * A field's value as the comparison reads it — the VALUE, not its spelling
2823
2956
  * (#2869).
2824
2957
  *
2825
- * Only a declared `number` needs this, and only because the server genuinely
2826
- * accepts both spellings: `temperature` and `topP` are `StringField`s on the
2958
+ * Only a declared `number` the DEFINITION marks `dualEncoded` needs this
2959
+ * (#2880), and only because the server genuinely accepts both spellings:
2960
+ * `temperature` and `topP` are `StringField`s on the
2827
2961
  * model, stored as strings and returned parsed, so a file authored
2828
2962
  * `temperature = "0.2"` and a server holding `0.2` describe one value. Compared
2829
2963
  * as spellings it is a `Modified` no push can clear — push sends the string,
2830
2964
  * the server stores it, pull writes the number back, and the difference
2831
2965
  * returns — so the operator's only remedy is to overwrite the local file.
2832
2966
  *
2833
- * A string that is not a number is left exactly as it is: `temperature =
2834
- * "warm"` is a real difference from `0.2`, and one the server will reject.
2967
+ * Every OTHER declared number is strictly numeric: `maxTokens = "1024"` is not
2968
+ * the number the server holds, and normalizing it here would have both
2969
+ * commands agree on a value the author never wrote. It is a validation error
2970
+ * both commands report instead (`configDeclaredTypeErrors`), which is why the
2971
+ * carve-out is read from the definition rather than from `type === "number"`.
2972
+ *
2973
+ * A string that is not a number never reaches here: `temperature = "warm"` is
2974
+ * a field-named validation error from both commands (`spellsDecimalNumber` is
2975
+ * the grammar the declared-type validator admits a dual encoding by, so what
2976
+ * preflight accepts is exactly what this can equate).
2835
2977
  *
2836
2978
  * "Is a number" is decided by the grammar the SERVER reads the string with —
2837
2979
  * `parseFloat`, on the way back out — not by JavaScript's `Number`, which is
@@ -2841,15 +2983,14 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
2841
2983
  * So only what `parseFloat` consumes WHOLE — sign, digits, one dot, an
2842
2984
  * exponent — normalizes; anything else stays the string it is.
2843
2985
  */
2844
- const DECIMAL_NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
2845
- function comparableFieldValue(type, value) {
2846
- if (type !== "number" || typeof value !== "string")
2986
+ function comparableFieldValue(field, value) {
2987
+ if (field.type !== "number" || typeof value !== "string")
2988
+ return value;
2989
+ if (field.dualEncoded === undefined)
2847
2990
  return value;
2848
- const trimmed = value.trim();
2849
- if (!DECIMAL_NUMBER.test(trimmed))
2991
+ if (!spellsDecimalNumber(value))
2850
2992
  return value;
2851
- const asNumber = Number(trimmed);
2852
- return Number.isFinite(asNumber) ? asNumber : value;
2993
+ return Number(value.trim());
2853
2994
  }
2854
2995
  /**
2855
2996
  * `config diff`'s hash for one configuration object, over the field set its
@@ -2872,7 +3013,16 @@ function comparableFieldValue(type, value) {
2872
3013
  export function projectConfigForComparison(table, entity, extras = {}) {
2873
3014
  const projected = {};
2874
3015
  for (const field of table.fields) {
2875
- const value = comparableFieldValue(field.type, entity?.[field.field]);
3016
+ const raw = entity?.[field.field];
3017
+ // #2880 DSO-002 — a key the file does not carry describes the value the
3018
+ // SERVER materializes for it, so that is what the comparison reads. Both
3019
+ // sides run through here, and the server side always carries the value
3020
+ // already, so this only ever fills the absent side in. Without it a
3021
+ // hand-authored file that omits an optional defaulted field reports
3022
+ // `Modified` on every run against a server that agrees with it.
3023
+ const value = comparableFieldValue(field, raw === undefined && field.serverDefault !== undefined
3024
+ ? field.serverDefault
3025
+ : raw);
2876
3026
  if (value === undefined)
2877
3027
  continue;
2878
3028
  // #2731 — `emit: "whenSet"` says pull OMITS the key when the value is
@@ -3025,6 +3175,25 @@ function jsonFieldsForDiff(table, entity) {
3025
3175
  }
3026
3176
  return projected;
3027
3177
  }
3178
+ /**
3179
+ * One test-case reference as the comparison reads it (#2880 behavior 17).
3180
+ *
3181
+ * A reference reaches the projection in one of three states, and all three
3182
+ * describe the same thing on both sides once this is applied:
3183
+ *
3184
+ * - a portable NAME (`configName = "default"`), which a test file carries so
3185
+ * it survives a move between apps;
3186
+ * - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
3187
+ * pinned by id would differ forever from the file pull writes for it;
3188
+ * - a legacy ID that does NOT resolve, which stays the id, because pull writes
3189
+ * an unresolvable id straight back and comparing it away would hide a pin
3190
+ * the next push still sends.
3191
+ */
3192
+ function testCaseReference(value, idToName) {
3193
+ if (typeof value !== "string" || value === "")
3194
+ return undefined;
3195
+ return idToName?.get(value) ?? value;
3196
+ }
3028
3197
  const CONFIG_DIFF_SPECS = {
3029
3198
  "rule-set": {
3030
3199
  label: "rule-set",
@@ -3099,6 +3268,16 @@ const CONFIG_DIFF_SPECS = {
3099
3268
  // exactly as push derives it from the file name.
3100
3269
  parse: (doc) => buildWebhookPushPayload(doc?.webhook?.key ?? "", doc),
3101
3270
  serialize: (record) => serializeWebhook(record),
3271
+ // The text the create/update handlers rewrite on the way in (DSO-003):
3272
+ // `displayName` and `workflowKey` are trimmed, a blank description is
3273
+ // stored as none, and a signing secret is stored as the canonical
3274
+ // `{{secrets.KEY}}` form — so an authored `"{{ secrets.KEY }}"` is the same
3275
+ // reference the server returns rather than a row push re-applies forever.
3276
+ canonicalize: (entity) => canonicalizeServerTextFields(entity, {
3277
+ trimmed: ["displayName", "workflowKey"],
3278
+ optional: ["description"],
3279
+ secretReference: ["signingSecret"],
3280
+ }),
3102
3281
  extras: (entity) => ({
3103
3282
  // The four structural tables a webhook file also carries, compared as the
3104
3283
  // values they encode rather than as their transport spellings.
@@ -3111,6 +3290,21 @@ const CONFIG_DIFF_SPECS = {
3111
3290
  // webhook that holds none.
3112
3291
  config: entity.config ?? null,
3113
3292
  }),
3293
+ // The two present-only fields, matching what `buildWebhookPushPayload`
3294
+ // sends: a file with no `[verification]` section does not manage the stored
3295
+ // scheme configuration (a bare `[verification]` table is how such a file
3296
+ // clears it), and a file with no `signingSecret` line does not manage the
3297
+ // secret reference — pushing `null` for either would revoke verification
3298
+ // the file never mentioned. Compared as differences they were rows push
3299
+ // could never clear.
3300
+ unmanaged: (doc) => {
3301
+ const keys = [];
3302
+ if (doc?.verification === undefined)
3303
+ keys.push("config");
3304
+ if (doc?.webhook?.signingSecret === undefined)
3305
+ keys.push("signingSecret");
3306
+ return keys;
3307
+ },
3114
3308
  describe: (entity) => `webhook "${entity?.webhookKey}"`,
3115
3309
  },
3116
3310
  // #2748 — prompts join the registry comparator, the third type to arrive by
@@ -3163,6 +3357,148 @@ const CONFIG_DIFF_SPECS = {
3163
3357
  }),
3164
3358
  describe: (entity) => `prompt "${entity?.displayName}"`,
3165
3359
  },
3360
+ // #2880 — integrations join the registry comparator, the last of the flat
3361
+ // per-entity types to arrive by the route email templates (#2731 B2),
3362
+ // webhooks (#2757) and prompts (#2748) took: `config diff` reported key
3363
+ // existence only, so an edited `accessRule` or upstream `[requestConfig]`
3364
+ // read as Synced while push applied it from the file's byte hash.
3365
+ "integration": {
3366
+ label: "integration",
3367
+ table: INTEGRATION_TABLE,
3368
+ // The wire shape push sends. `integrationKey` is not part of the compared
3369
+ // surface — the two sides are paired on it before the comparison runs —
3370
+ // but the TOML spelling (`[integration].key`) is read here because it is
3371
+ // what `displayName` falls back to.
3372
+ parse: (doc) => buildIntegrationPushPayload(doc?.integration?.key ?? "", doc),
3373
+ serialize: (record) => serializeIntegration(record),
3374
+ // The upstream request definition is a top-level table the definition
3375
+ // classifies structural, so it is hashed beside the fields — as the value
3376
+ // the SERVER would store, not as the text the file holds (DSO-003).
3377
+ canonicalize: (entity) => {
3378
+ entity.requestConfig = canonicalizeIntegrationRequestConfig(entity.requestConfig ?? {});
3379
+ // The scalars the handler rewrites beside it: `displayName` is trimmed
3380
+ // and a blank `description` is stored as none.
3381
+ canonicalizeServerTextFields(entity, {
3382
+ trimmed: ["displayName"],
3383
+ optional: ["description"],
3384
+ });
3385
+ },
3386
+ extras: (entity) => ({ requestConfig: entity.requestConfig ?? {} }),
3387
+ describe: (entity) => `integration "${entity?.integrationKey}"`,
3388
+ },
3389
+ // #2880 — cron triggers, on the same route. `status` is server-owned
3390
+ // (#2645/#2803) and therefore absent from the table, so an operator's pause
3391
+ // is not drift the configuration can push away; `[rootInput]` and
3392
+ // `[inputMapping]` are structural tables stored as JSON strings, compared as
3393
+ // the values they encode.
3394
+ "cron-trigger": {
3395
+ label: "cron-trigger",
3396
+ table: CRON_TRIGGER_TABLE,
3397
+ parse: (doc) => buildCronTriggerPushPayload(doc?.cronTrigger?.key ?? "", doc),
3398
+ serialize: (record) => serializeCronTrigger(record),
3399
+ // `displayName` is trimmed on the way in and a blank description is stored
3400
+ // as none (DSO-003), so neither is a difference push could never settle.
3401
+ canonicalize: (entity) => canonicalizeServerTextFields(entity, {
3402
+ trimmed: ["displayName"],
3403
+ optional: ["description"],
3404
+ }),
3405
+ extras: (entity) => ({
3406
+ rootInput: jsonBlobForDiff(entity.rootInput),
3407
+ inputMapping: jsonBlobForDiff(entity.inputMapping),
3408
+ }),
3409
+ describe: (entity) => `cron trigger "${entity?.triggerKey}"`,
3410
+ },
3411
+ // #2880 — blob buckets. The parse is CREATE-mode on purpose: `ttlTier` is
3412
+ // `writableOn: ["create"]`, and a field left out of the projection cannot be
3413
+ // NAMED as the immutable difference it is — the update would PATCH
3414
+ // "successfully", change nothing, and re-report forever.
3415
+ "blob-bucket": {
3416
+ label: "blob-bucket",
3417
+ table: BLOB_BUCKET_TABLE,
3418
+ parse: (doc) => {
3419
+ const bucket = { ...(doc?.bucket ?? {}) };
3420
+ // #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
3421
+ // the wire and still authored in unmigrated files. Both spellings mean
3422
+ // the same access model, so they project the same way; without this an
3423
+ // unmigrated file reports a difference no push can clear (push sends the
3424
+ // legacy key and the server answers with `preset`).
3425
+ if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
3426
+ bucket.preset = bucket.accessPolicy;
3427
+ }
3428
+ return {
3429
+ bucketKey: doc?.bucket?.key ?? "",
3430
+ ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
3431
+ mode: "create",
3432
+ values: {
3433
+ name: (value) => value || doc?.bucket?.key || "",
3434
+ description: tomlOwned,
3435
+ },
3436
+ }),
3437
+ };
3438
+ },
3439
+ serialize: (record) => serializeBlobBucket(record),
3440
+ // `name` is trimmed on the way in and a blank description is stored as
3441
+ // none (DSO-003).
3442
+ canonicalize: (entity) => canonicalizeServerTextFields(entity, {
3443
+ trimmed: ["name"],
3444
+ optional: ["description"],
3445
+ }),
3446
+ // The three fields a bucket file leaves alone by omitting them, matching
3447
+ // what `buildBlobBucketUpdatePayload` sends (#2880 criterion 3). The server
3448
+ // refuses to leave a bucket nameless or without an access model, so none of
3449
+ // them is clearable and the update omits an absent key rather than nulling
3450
+ // it. Compared anyway, a bucket whose live name differs from its key — or
3451
+ // one whose access model the file does not state — is a `Modified` row and
3452
+ // an update that cannot carry the difference it reports, planned again on
3453
+ // every run. `ttlTier` is deliberately NOT here: it is immutable rather
3454
+ // than unmanaged, and naming it is the whole point of the create-mode parse
3455
+ // above.
3456
+ unmanaged: (doc) => {
3457
+ const bucket = doc?.bucket ?? {};
3458
+ const keys = [];
3459
+ if (!bucket.name)
3460
+ keys.push("name");
3461
+ if (!bucket.preset && !bucket.accessPolicy)
3462
+ keys.push("preset");
3463
+ if (!bucket.ruleSetId)
3464
+ keys.push("ruleSetId");
3465
+ return keys;
3466
+ },
3467
+ describe: (entity) => `blob bucket "${entity?.bucketKey}"`,
3468
+ },
3469
+ // #2880 — test cases. Their sidecars were compared by SLUG PRESENCE, so an
3470
+ // edited expectation read as Synced while push applied it from the file's
3471
+ // byte hash.
3472
+ //
3473
+ // The type's own difficulty is that a reference has two legal spellings — the
3474
+ // portable `configName` a test file carries so it survives a move between
3475
+ // apps, and the legacy `configId` pull still writes back when the name cannot
3476
+ // be resolved. They name the same thing, so they project the same way; the
3477
+ // `extra` context is what makes that possible (the id→name lookups are not in
3478
+ // the document).
3479
+ "test-case": {
3480
+ label: "test-case",
3481
+ table: TEST_CASE_TABLE,
3482
+ parse: (doc) => parseTestCaseToml(doc),
3483
+ serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
3484
+ // The server trims a test-case name on write (`src/admin-api.ts`), so a
3485
+ // padded one in the file is not a difference — it is what the server will
3486
+ // store (#2880 DSO-003).
3487
+ canonicalize: (entity) => {
3488
+ if (typeof entity.name === "string")
3489
+ entity.name = entity.name.trim();
3490
+ },
3491
+ extras: (entity, _doc, extra) => ({
3492
+ // These three OVERRIDE the definition's fields of the same name: the
3493
+ // value compared is the thing referred to, not the spelling that referred
3494
+ // to it. Precedence matches the push path exactly — a file carrying both
3495
+ // an id and a name is pushed on the id, so it is compared on the id.
3496
+ configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
3497
+ evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
3498
+ evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
3499
+ }),
3500
+ describe: (entity) => `test case "${entity?.name}"`,
3501
+ },
3166
3502
  "database-type-config": {
3167
3503
  label: "database-type-config",
3168
3504
  table: DATABASE_TYPE_TABLE,
@@ -3194,9 +3530,10 @@ const CONFIG_DIFF_SPECS = {
3194
3530
  };
3195
3531
  /**
3196
3532
  * Every type the shared comparator serves — the types `config diff` compares
3197
- * field-for-field, and that `config push` gates on the same projection wherever
3198
- * it has adopted the shared gate (#2731 B1/B2; webhooks joined the comparator
3199
- * in #2757 while push keeps its own byte-hash gate).
3533
+ * field-for-field, and that `config push` gates on the same projection
3534
+ * (#2731 B1/B2; webhooks joined the comparator in #2757 and their push joined
3535
+ * the same gate in #2880, so no registered type answers "changed?" from the
3536
+ * file's bytes any more).
3200
3537
  *
3201
3538
  * Published so the round-trip acceptance bar (#2731 B9) can require a fixture
3202
3539
  * per type instead of listing them a second time by hand: converting a type is
@@ -3219,20 +3556,82 @@ export function configDiffSpec(label) {
3219
3556
  * a push that needs to SAY what differs and a diff that only needs to know THAT
3220
3557
  * something differs read the same projection.
3221
3558
  */
3222
- export function projectLocalConfig(spec, parsedToml, maps) {
3223
- const entity = spec.parse(parsedToml);
3559
+ export function projectLocalConfig(spec, parsedToml, maps, extra) {
3560
+ const entity = spec.parse(parsedToml, extra);
3224
3561
  if (spec.ruleSetRef) {
3225
3562
  normalizeRuleSetRefForDiff(entity, maps.ruleSetNameToId, spec.describe(entity));
3226
3563
  }
3227
- return projectConfigForComparison(spec.table, entity, spec.extras?.(entity, parsedToml));
3564
+ // #2880 DSO-003 — before anything is hashed, so the extras below read the
3565
+ // canonical values too.
3566
+ spec.canonicalize?.(entity);
3567
+ return projectConfigForComparison(spec.table, entity, spec.extras?.(entity, parsedToml, extra));
3228
3568
  }
3229
3569
  /** The same projection for a SERVER entity — see `hashRemoteConfigForDiff`. */
3230
3570
  export function projectRemoteConfig(spec, record, maps, extra) {
3231
- return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
3571
+ return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
3572
+ }
3573
+ /**
3574
+ * BOTH sides of one comparison, with the file's unmanaged keys reconciled
3575
+ * (#2880) — the one place `spec.unmanaged` is honored, so `config diff` and
3576
+ * `config push` cannot read the same file differently.
3577
+ */
3578
+ export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
3579
+ const local = projectLocalConfig(spec, localParsed, maps, localExtra);
3580
+ const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
3581
+ for (const key of spec.unmanaged?.(localParsed) ?? []) {
3582
+ // Key-for-key, so an unmanaged field the server does not hold either is
3583
+ // absent on both sides rather than present-and-undefined on one.
3584
+ if (key in remote)
3585
+ local[key] = remote[key];
3586
+ else
3587
+ delete local[key];
3588
+ }
3589
+ return { local, remote };
3590
+ }
3591
+ /**
3592
+ * One `config diff` row's content verdict for a registered type (#2880).
3593
+ *
3594
+ * The three outcomes the per-type blocks were each spelling out by hand:
3595
+ * equal (`exists`), different (`modified`, framed as a preview of
3596
+ * `config pull`), and "could not tell" — a missing record or a comparison that
3597
+ * threw, which degrades THIS row and never the whole diff. Written once so a
3598
+ * type joining the comparator cannot accidentally report a fourth thing.
3599
+ *
3600
+ * The two `extra` arguments are the same split `decidePushForConfig` makes:
3601
+ * `extra` carries what only the SERVER side has (a database type's operation
3602
+ * rows), while `localExtra` is context BOTH sides read a value through — a test
3603
+ * case's id→name lookups. Giving the local side nothing was a silent
3604
+ * mistranslation: a sidecar pinned by a resolvable `configId` projected the id
3605
+ * while the server's projection resolved it to the name, so an untouched file
3606
+ * reported Modified in diff and drifted in push.
3607
+ */
3608
+ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
3609
+ ruleSetIdToName: new Map(),
3610
+ ruleSetNameToId: new Map(),
3611
+ }, extra, localExtra) {
3612
+ if (!localParsed || !remoteRecord) {
3613
+ return { status: "exists", hint: "content not compared (fetch failed)" };
3614
+ }
3615
+ try {
3616
+ const pair = projectConfigPair(spec, localParsed, remoteRecord, maps, extra ?? localExtra, localExtra);
3617
+ const remoteHash = computeExpandedContentHash(pair.remote);
3618
+ const localHash = computeExpandedContentHash(pair.local);
3619
+ if (remoteHash === localHash)
3620
+ return { status: "exists" };
3621
+ return {
3622
+ status: "modified",
3623
+ hint: "run `config pull` to rewrite the local TOML to match running state",
3624
+ };
3625
+ }
3626
+ catch {
3627
+ // A file the comparison cannot read is reported as not compared rather
3628
+ // than aborting the diff; `config push` names the fault precisely.
3629
+ return { status: "exists", hint: "content not compared (comparison failed)" };
3630
+ }
3232
3631
  }
3233
3632
  /** Hash a LOCAL config file's definition-projected field set (#2644). */
3234
- export function hashLocalConfigForDiff(spec, parsedToml, maps) {
3235
- return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps));
3633
+ export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
3634
+ return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
3236
3635
  }
3237
3636
  /**
3238
3637
  * Hash a SERVER entity the same way, by serializing it into the file `sync
@@ -3244,32 +3643,24 @@ export function hashLocalConfigForDiff(spec, parsedToml, maps) {
3244
3643
  * operations and subscriptions, which the list response does not include.
3245
3644
  */
3246
3645
  export function hashRemoteConfigForDiff(spec, record, maps, extra) {
3247
- return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
3646
+ return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
3647
+ }
3648
+ export function decideDegradedPush(input) {
3649
+ if (input.force)
3650
+ return { action: "send" };
3651
+ if (input.storedContentHash === undefined) {
3652
+ return { action: "decline", reason: "no-baseline" };
3653
+ }
3654
+ return input.storedContentHash === input.currentFileHash
3655
+ ? { action: "skip" }
3656
+ : { action: "send" };
3248
3657
  }
3249
- /**
3250
- * `config push`'s change decision for one configuration file (#2731 B1/B3/B4/B8).
3251
- *
3252
- * This replaces `shouldPushFile` for the converted types. The differences that
3253
- * matter, in order:
3254
- *
3255
- * - the comparison is `config diff`'s — the definition-projected field set on
3256
- * both sides — so "skip as unchanged" and "Synced" cannot disagree, and a
3257
- * comment-only or formatting-only edit is not a change;
3258
- * - the right-hand side is the LIVE entity, not the manifest, so out-of-band
3259
- * server drift is visible instead of being overwritten;
3260
- * - the manifest becomes a baseline that answers WHICH side moved, so a
3261
- * difference is attributed rather than assumed to be a local edit.
3262
- *
3263
- * Declining is a first-class outcome here. `drift`, `conflict` and `immutable`
3264
- * each carry the field diff their report prints, and nothing is applied.
3265
- */
3266
3658
  export function decidePushForConfig(input) {
3267
- const { spec, maps, localParsed, live, entry, localFileHash, force } = input;
3659
+ const { spec, maps, localParsed, live, entry, localFileHash, localExtra, force } = input;
3268
3660
  if (!live?.record)
3269
3661
  return { action: "create" };
3270
- const localProjected = projectLocalConfig(spec, localParsed, maps);
3662
+ const { local: localProjected, remote: remoteProjected } = projectConfigPair(spec, localParsed, live.record, maps, live.extra ?? localExtra, localExtra);
3271
3663
  const localHash = computeExpandedContentHash(localProjected);
3272
- const remoteProjected = projectRemoteConfig(spec, live.record, maps, live.extra);
3273
3664
  const remoteHash = computeExpandedContentHash(remoteProjected);
3274
3665
  const fields = diffProjectedRecords(localProjected, remoteProjected);
3275
3666
  // A difference in a field UPDATE does not accept would PATCH "successfully"
@@ -3312,6 +3703,37 @@ export function decidePushForConfig(input) {
3312
3703
  entry.modifiedAt === live.modifiedAt,
3313
3704
  },
3314
3705
  });
3706
+ // #2909 — the adopt, for a type whose push path adopts by key. `entry` is
3707
+ // read for ANY of its three signals, not just the baseline: a row carrying
3708
+ // only a legacy signal has been synced, and its unattributable difference is
3709
+ // the conflict below. This is the never-reconciled file alone.
3710
+ //
3711
+ // Deliberately the SIGNALS and not the row's existence: an `id`-only row is
3712
+ // what the 409 recovery seeds before its update lands (and a failed push
3713
+ // still saves), so it names the object an adopt found without recording that
3714
+ // any content was ever reconciled. There is nothing there to attribute a
3715
+ // move with, and refusing it would wedge that half-adopted slot in a
3716
+ // conflict no `config push` can clear — this same regression, one step in.
3717
+ const untracked = entry?.semanticHash === undefined &&
3718
+ entry?.contentHash === undefined &&
3719
+ entry?.modifiedAt === undefined;
3720
+ if (direction === "unknown-direction" &&
3721
+ input.adoptsUntrackedByKey &&
3722
+ untracked) {
3723
+ if (immutableFields.length > 0)
3724
+ return immutable;
3725
+ return {
3726
+ action: "update",
3727
+ direction: "adopt-untracked",
3728
+ localHash,
3729
+ remoteHash,
3730
+ // As for `local-edited`: the LIVE timestamp closes the window between the
3731
+ // reconcile's read and this write. The 409 path omits it because it never
3732
+ // read the record it adopts; this one just did.
3733
+ expectedModifiedAt: live.modifiedAt,
3734
+ fields,
3735
+ };
3736
+ }
3315
3737
  switch (direction) {
3316
3738
  case "unchanged":
3317
3739
  return { action: "skip", localHash, remoteHash };
@@ -4273,6 +4695,14 @@ export async function pullTestCasesForBlock(params) {
4273
4695
  modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
4274
4696
  attachments,
4275
4697
  contentHash: computeFileHash(tomlPath),
4698
+ // #2880 — the file and the server agree right now, so this is the
4699
+ // baseline the next push attributes a difference against.
4700
+ semanticHash: remoteSemanticBaseline({
4701
+ spec: configDiffSpec("test-case"),
4702
+ maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
4703
+ returned: tc,
4704
+ extra: { lookupMaps },
4705
+ }),
4276
4706
  };
4277
4707
  }
4278
4708
  // Clean up local files for remotely-deleted test cases
@@ -4352,7 +4782,7 @@ const TEN_MB = 10 * 1024 * 1024;
4352
4782
  * content hashes (#2769). A name the guard rejects is dropped here, before it
4353
4783
  * can be turned into an upload.
4354
4784
  */
4355
- function readLocalAttachments(attachDir, onUnsafe) {
4785
+ function readLocalAttachments(attachDir, onUnsafe = () => { }) {
4356
4786
  if (!existsSync(attachDir))
4357
4787
  return [];
4358
4788
  const files = [];
@@ -4432,6 +4862,25 @@ export async function pushTestCasesForBlock(params) {
4432
4862
  const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
4433
4863
  if (files.length === 0)
4434
4864
  return { skipped };
4865
+ // #2880 — the block's live cases, read once. They are what `config diff`
4866
+ // compares against and what `config pull` writes from, so push deciding from
4867
+ // anything else is how the two commands came to disagree. A failed listing
4868
+ // degrades this block to the byte-hash gate, named, rather than sending every
4869
+ // sidecar down the create path.
4870
+ const testCaseSpec = configDiffSpec("test-case");
4871
+ const testCaseExtra = { lookupMaps: params.lookupMaps };
4872
+ const listed = await listAllTestCases(client, appId, blockType, blockId);
4873
+ const liveOk = listed.ok;
4874
+ if (!liveOk) {
4875
+ warn(` ${blockKey} test cases: live state unavailable — falling back to last-sync content hashes`);
4876
+ }
4877
+ const paired = pairTestCases({
4878
+ blockType,
4879
+ blockKey,
4880
+ localSlugs: files.map((f) => basename(f, ".toml")),
4881
+ liveCases: liveOk ? listed.items : [],
4882
+ managed: syncState?.entities?.testCases,
4883
+ });
4435
4884
  for (const file of files) {
4436
4885
  const slug = basename(file, ".toml");
4437
4886
  const filePath = join(testsDir, file);
@@ -4514,15 +4963,89 @@ export async function pushTestCasesForBlock(params) {
4514
4963
  local: localAttachments,
4515
4964
  recorded: existingEntry,
4516
4965
  });
4517
- // Skip if file hasn't changed since last sync and no attachment bytes moved.
4518
- if (!params.options?.force &&
4519
- existingEntry?.id &&
4520
- !shouldPushFile(filePath, existingEntry.contentHash) &&
4521
- attachmentPlan.upload.length === 0) {
4522
- skipped++;
4523
- continue;
4966
+ // #2880 — the change decision is the comparison `config diff` makes,
4967
+ // against the case the server holds NOW. The old gate asked the manifest
4968
+ // whether the file's BYTES had moved, so a comment planned an update diff
4969
+ // called synced and server drift was skipped however far it had gone.
4970
+ //
4971
+ // An attachment still pending upload is a change whatever the text says:
4972
+ // the sidecar and its fixtures are one resource to push.
4973
+ const liveCase = paired.bySlug.get(slug);
4974
+ let gate;
4975
+ // The reference resolution above is what loads this block's configs, so the
4976
+ // lookups the comparison reads through are re-derived from it first.
4977
+ params.refreshLookupMaps?.();
4978
+ if (liveOk && liveCase) {
4979
+ gate = decidePushForConfig({
4980
+ spec: testCaseSpec,
4981
+ maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
4982
+ localParsed: tomlData,
4983
+ live: { record: liveCase, modifiedAt: liveCase.modifiedAt },
4984
+ entry: existingEntry,
4985
+ localFileHash: computeFileHash(filePath),
4986
+ localExtra: testCaseExtra,
4987
+ force: params.options?.force,
4988
+ });
4989
+ if (gate.action === "skip" && attachmentPlan.upload.length === 0) {
4990
+ skipped++;
4991
+ // A skip decided against live state refreshes the row, so the next run
4992
+ // classifies against a baseline rather than inferring from timestamps.
4993
+ //
4994
+ // CREATING the row when it is missing is the point: the case this skip
4995
+ // agreed with may have been adopted by slug, and identity is the
4996
+ // manifest id first. Recording only rows that already existed left an
4997
+ // adopted case unmanaged, so the next rename lost its id and paired as
4998
+ // local-only beside remote-only — the duplicate create this pairing
4999
+ // exists to prevent.
5000
+ if (syncState) {
5001
+ if (!syncState.entities.testCases)
5002
+ syncState.entities.testCases = {};
5003
+ const entry = (syncState.entities.testCases[stateKey] ??= {
5004
+ blockType,
5005
+ blockId,
5006
+ blockKey,
5007
+ slug,
5008
+ });
5009
+ entry.id = liveCase.testCaseId;
5010
+ entry.contentHash = computeFileHash(filePath);
5011
+ entry.semanticHash = gate.remoteHash;
5012
+ if (liveCase.modifiedAt)
5013
+ entry.modifiedAt = liveCase.modifiedAt;
5014
+ }
5015
+ continue;
5016
+ }
5017
+ if (gate.action === "drift" ||
5018
+ gate.action === "conflict" ||
5019
+ gate.action === "immutable") {
5020
+ params.declined?.("test-case", `${blockKey}/${slug}`, gate, existingEntry?.modifiedAt);
5021
+ continue;
5022
+ }
5023
+ }
5024
+ else if (!liveOk) {
5025
+ // Degraded: the byte gate this section has always fallen back to, and a
5026
+ // decline when there is no hash to gate on (#2880 criterion 7).
5027
+ const degraded = decideDegradedPush({
5028
+ force: params.options?.force,
5029
+ storedContentHash: existingEntry?.contentHash,
5030
+ currentFileHash: computeFileHash(filePath),
5031
+ });
5032
+ if (degraded.action === "skip" && attachmentPlan.upload.length === 0) {
5033
+ skipped++;
5034
+ continue;
5035
+ }
5036
+ if (degraded.action === "decline") {
5037
+ params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable" }, existingEntry?.modifiedAt);
5038
+ continue;
5039
+ }
4524
5040
  }
4525
- if (existingEntry?.id) {
5041
+ // Live state decides create-vs-update: a case the manifest never recorded
5042
+ // but the server has is adopted rather than duplicated (#2896's half of
5043
+ // this), and one the manifest names that the server no longer has is
5044
+ // re-created.
5045
+ const existingId = liveOk
5046
+ ? liveCase?.testCaseId
5047
+ : existingEntry?.id;
5048
+ if (existingId) {
4526
5049
  // Update existing test case. The body is FULL STATE: the handler only
4527
5050
  // touches keys it receives, so an omitted key retains its old value and
4528
5051
  // blanking a field in the file would never reach the server (#2769).
@@ -4542,11 +5065,30 @@ export async function pushTestCasesForBlock(params) {
4542
5065
  }
4543
5066
  else {
4544
5067
  try {
4545
- await client.updateTestCase(appId, blockType, blockId, existingEntry.id, updatePayload);
5068
+ const updated = await client.updateTestCase(appId, blockType, blockId, existingId, updatePayload);
4546
5069
  changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
4547
5070
  info(` Updated test case: ${blockKey}/${slug}`);
4548
- if (syncState?.entities?.testCases?.[stateKey]) {
4549
- syncState.entities.testCases[stateKey].contentHash = computeFileHash(filePath);
5071
+ if (syncState) {
5072
+ if (!syncState.entities.testCases)
5073
+ syncState.entities.testCases = {};
5074
+ const entry = (syncState.entities.testCases[stateKey] ??= {
5075
+ blockType,
5076
+ blockId,
5077
+ blockKey,
5078
+ slug,
5079
+ });
5080
+ entry.id = existingId;
5081
+ entry.contentHash = computeFileHash(filePath);
5082
+ if (updated?.modifiedAt)
5083
+ entry.modifiedAt = updated.modifiedAt;
5084
+ // #2880 — the baseline, from the record the server stored (it trims
5085
+ // the name on write), so the next run can attribute a difference.
5086
+ entry.semanticHash = remoteSemanticBaseline({
5087
+ spec: testCaseSpec,
5088
+ maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
5089
+ returned: updated ?? { ...liveCase, ...updatePayload },
5090
+ extra: testCaseExtra,
5091
+ });
4550
5092
  }
4551
5093
  }
4552
5094
  catch (err) {
@@ -4586,6 +5128,15 @@ export async function pushTestCasesForBlock(params) {
4586
5128
  slug,
4587
5129
  modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
4588
5130
  contentHash: computeFileHash(filePath),
5131
+ // #2880 — a create records its baseline too, from the stored
5132
+ // record: the handler trims the name, so a baseline taken from
5133
+ // the file would read that trim as a difference on the next run.
5134
+ semanticHash: remoteSemanticBaseline({
5135
+ spec: testCaseSpec,
5136
+ maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
5137
+ returned: created,
5138
+ extra: testCaseExtra,
5139
+ }),
4589
5140
  };
4590
5141
  }
4591
5142
  }
@@ -4602,7 +5153,7 @@ export async function pushTestCasesForBlock(params) {
4602
5153
  // plain push (sponsor ruling, 2026-08-18).
4603
5154
  if (dryRun)
4604
5155
  continue;
4605
- const testCaseId = existingEntry?.id || syncState?.entities?.testCases?.[stateKey]?.id;
5156
+ const testCaseId = existingId || syncState?.entities?.testCases?.[stateKey]?.id;
4606
5157
  if (!testCaseId)
4607
5158
  continue;
4608
5159
  for (const filename of attachmentPlan.upload) {
@@ -4647,32 +5198,101 @@ export async function pushTestCasesForBlock(params) {
4647
5198
  return { skipped };
4648
5199
  }
4649
5200
  /**
4650
- * Classify one block's sidecar against the server's test-case names (#2769).
5201
+ * Which live test case each sidecar manages (#2880 behavior 16).
5202
+ *
5203
+ * The manifest id comes FIRST. A test case's slug is derived from its name, so
5204
+ * matching on the slug alone made a rename — in either direction — read as a
5205
+ * new local file plus a removable remote case: diff showed two rows for one
5206
+ * case, and push created a duplicate. `entities.testCases[stateKey].id` is
5207
+ * stable across every rename, so it is the identity, and the slug is only how
5208
+ * a sidecar the manifest has never seen is ADOPTED.
4651
5209
  *
4652
- * Remote slugs are derived exactly the way pull writes them — same slugify,
4653
- * same collision suffixes, same order — because a `<slug>-2.toml` on disk IS
4654
- * the second colliding name and must not read as an unpushed local file.
5210
+ * Adoption derives its slugs exactly the way pull writes them — same slugify,
5211
+ * same collision suffixes, same listing order — so a `<slug>-2.toml` on disk IS
5212
+ * the second colliding name and is paired with it.
5213
+ *
5214
+ * One function for both commands, because a diff row and a push decision that
5215
+ * pair differently are two answers to the same question.
4655
5216
  */
4656
- export function classifyTestCaseSidecarDiff(params) {
5217
+ export function pairTestCases(input) {
5218
+ const bySlug = new Map();
5219
+ // Claimed by RECORD rather than by id: a row the server returned without one
5220
+ // still has to be paired or reported, never silently dropped from both sides.
5221
+ const claimed = new Set();
5222
+ const byId = new Map();
5223
+ for (const live of input.liveCases) {
5224
+ if (live?.testCaseId)
5225
+ byId.set(live.testCaseId, live);
5226
+ }
5227
+ const unpaired = [];
5228
+ for (const slug of input.localSlugs) {
5229
+ const managedId = input.managed?.[`${input.blockType}/${input.blockKey}/${slug}`]?.id;
5230
+ const live = managedId ? byId.get(managedId) : undefined;
5231
+ if (live) {
5232
+ bySlug.set(slug, live);
5233
+ claimed.add(live);
5234
+ }
5235
+ else {
5236
+ unpaired.push(slug);
5237
+ }
5238
+ }
5239
+ // The slug pull would write for each live case, in listing order, so the
5240
+ // collision suffixes line up with the files on disk.
4657
5241
  const usedSlugs = new Set();
4658
- const remoteSlugs = new Set(params.remoteNames.map((name) => resolveSlugCollisions(slugifyTestCaseName(name || "unnamed"), usedSlugs)));
4659
- const rows = [];
4660
- const local = new Set(params.localSlugs);
4661
- for (const slug of local) {
4662
- rows.push({ slug, status: remoteSlugs.has(slug) ? "exists" : LOCAL_ONLY_NEW });
5242
+ const slugOf = new Map();
5243
+ for (const live of input.liveCases) {
5244
+ slugOf.set(live, resolveSlugCollisions(slugifyTestCaseName(live?.name || "unnamed"), usedSlugs));
4663
5245
  }
4664
- for (const slug of remoteSlugs) {
4665
- if (!local.has(slug))
4666
- rows.push({ slug, status: "remote only" });
5246
+ const localOnly = [];
5247
+ for (const slug of unpaired) {
5248
+ const adopted = input.liveCases.find((live) => !claimed.has(live) && slugOf.get(live) === slug);
5249
+ if (adopted) {
5250
+ bySlug.set(slug, adopted);
5251
+ claimed.add(adopted);
5252
+ }
5253
+ else {
5254
+ localOnly.push(slug);
5255
+ }
4667
5256
  }
4668
- return rows;
5257
+ return {
5258
+ bySlug,
5259
+ localOnly,
5260
+ remoteOnly: input.liveCases.filter((live) => !claimed.has(live)),
5261
+ };
4669
5262
  }
4670
5263
  /**
4671
- * `config diff` for one block's test-case sidecar (#2769). A failed listing is
4672
- * an OUTCOME the caller reports as "not compared" — the old helper returned
4673
- * silently, so a block whose tests could not be fetched simply disappeared from
4674
- * a report that still read as exhaustive.
5264
+ * This block's own `configId`→`configName` pairs, for the comparison (#2880).
5265
+ *
5266
+ * A sidecar pins the config it exercises by NAME so it survives a move between
5267
+ * apps; the server answers with the id. Only the block's configs can equate the
5268
+ * two, and `config diff` reads them from the detail its per-type loops already
5269
+ * fetch — which covers prompts and nothing else. Without this, every script,
5270
+ * integration and workflow sidecar pinned to a config reported Modified
5271
+ * forever, and push read the same pair as server drift.
5272
+ *
5273
+ * One listing per block that has sidecars, and a failed one is silent: an
5274
+ * unresolved reference compares as the id both sides carry, which is the same
5275
+ * degradation `serializeTestCase` already applies.
4675
5276
  */
5277
+ async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
5278
+ try {
5279
+ const configs = blockType === "prompt"
5280
+ ? ((await client.getPrompt(appId, blockId))?.configs ?? [])
5281
+ : blockType === "workflow"
5282
+ ? ((await client.getWorkflow(appId, blockId))?.configs ?? [])
5283
+ : blockType === "script"
5284
+ ? (await client.listScriptConfigs(appId, blockId)).items
5285
+ : (await client.listIntegrationConfigs(appId, blockId)).items;
5286
+ for (const config of configs) {
5287
+ if (config?.configId && config?.configName) {
5288
+ configIdToName.set(config.configId, config.configName);
5289
+ }
5290
+ }
5291
+ }
5292
+ catch {
5293
+ // Non-fatal — see above.
5294
+ }
5295
+ }
4676
5296
  export async function compareTestCasesForBlock(params) {
4677
5297
  const listed = await listAllTestCases(params.client, params.appId, params.blockType, params.blockId);
4678
5298
  if (!listed.ok)
@@ -4683,17 +5303,97 @@ export async function compareTestCasesForBlock(params) {
4683
5303
  .filter((f) => f.endsWith(".toml"))
4684
5304
  .map((f) => basename(f, ".toml"))
4685
5305
  : [];
4686
- return {
4687
- ok: true,
4688
- rows: classifyTestCaseSidecarDiff({
4689
- remoteNames: listed.items.map((tc) => tc.name),
4690
- localSlugs,
4691
- }).map((row) => ({
4692
- blockType: params.blockType,
4693
- blockKey: params.blockKey,
4694
- ...row,
4695
- })),
4696
- };
5306
+ // #2880 — identity is the manifest id, so a rename is one Modified row rather
5307
+ // than a local-only file beside a remote-only case.
5308
+ const managed = params.syncState?.entities?.testCases;
5309
+ const paired = pairTestCases({
5310
+ blockType: params.blockType,
5311
+ blockKey: params.blockKey,
5312
+ localSlugs,
5313
+ liveCases: listed.items,
5314
+ managed,
5315
+ });
5316
+ const spec = configDiffSpec("test-case");
5317
+ const extra = { lookupMaps: params.lookupMaps };
5318
+ const rows = [];
5319
+ const base = { blockType: params.blockType, blockKey: params.blockKey };
5320
+ // The configs this block's sidecars pin by name (see `fillBlockConfigNames`).
5321
+ if (localSlugs.length > 0 && params.lookupMaps) {
5322
+ await fillBlockConfigNames(params.client, params.appId, params.blockType, params.blockId, params.lookupMaps.configIdToName);
5323
+ }
5324
+ for (const slug of localSlugs) {
5325
+ const live = paired.bySlug.get(slug);
5326
+ if (!live) {
5327
+ rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
5328
+ continue;
5329
+ }
5330
+ const filePath = join(testsDir, `${slug}.toml`);
5331
+ let row;
5332
+ try {
5333
+ const tomlData = parseTomlFile(filePath);
5334
+ // #2880 criterion 1 — a sidecar push's preflight would reject is not one
5335
+ // this command calls synced, and the message is the same one. The WHOLE
5336
+ // preflight, not just the sidecar's own rules: an unknown key or a
5337
+ // mistyped value aborts push too.
5338
+ const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
5339
+ if (errors.length > 0) {
5340
+ row = {
5341
+ ...base,
5342
+ slug,
5343
+ status: VALIDATION_ERROR,
5344
+ hint: errors.join("; "),
5345
+ };
5346
+ }
5347
+ else {
5348
+ const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
5349
+ // Both sides read a reference through the same lookups — see
5350
+ // `compareLocalToRemote`.
5351
+ extra);
5352
+ row = { ...base, slug, ...verdict };
5353
+ }
5354
+ }
5355
+ catch (err) {
5356
+ row = {
5357
+ ...base,
5358
+ slug,
5359
+ status: VALIDATION_ERROR,
5360
+ hint: `${filePath}: ${err?.message || String(err)}`,
5361
+ };
5362
+ }
5363
+ // #2880 behavior 19 — attachments are part of what push would do, read
5364
+ // from the SAME plan push executes. A sidecar whose text compares clean but
5365
+ // whose fixtures have not been uploaded is not Synced.
5366
+ const stateKey = `${params.blockType}/${params.blockKey}/${slug}`;
5367
+ const plan = planAttachmentPush({
5368
+ local: readLocalAttachments(join(testsDir, slug)),
5369
+ recorded: managed?.[stateKey],
5370
+ });
5371
+ if (plan.upload.length > 0) {
5372
+ row.attachmentUploads = plan.upload;
5373
+ if (row.status === "exists") {
5374
+ row.status = "modified";
5375
+ row.hint = `${plan.upload.length} attachment(s) to upload`;
5376
+ }
5377
+ }
5378
+ if (plan.removedRemotely.length > 0) {
5379
+ row.attachmentDeletions = plan.removedRemotely;
5380
+ row.hint = [
5381
+ row.hint,
5382
+ `${plan.removedRemotely.length} attachment(s) deleted locally — \`push --prune\` removes them server-side`,
5383
+ ]
5384
+ .filter(Boolean)
5385
+ .join("; ");
5386
+ }
5387
+ rows.push(row);
5388
+ }
5389
+ for (const live of paired.remoteOnly) {
5390
+ rows.push({
5391
+ ...base,
5392
+ slug: slugifyTestCaseName(live?.name || "unnamed"),
5393
+ status: "remote only",
5394
+ });
5395
+ }
5396
+ return { ok: true, rows };
4697
5397
  }
4698
5398
  /**
4699
5399
  * Shape errors in an authored test-case sidecar, for the push preflight
@@ -4730,6 +5430,29 @@ export function testCaseSidecarErrors(filePath, tomlData) {
4730
5430
  }
4731
5431
  return errors;
4732
5432
  }
5433
+ /**
5434
+ * EVERY reason `config push` refuses one test-case sidecar (#2880 criterion 1).
5435
+ *
5436
+ * The four checks the push preflight ran inline: the document's shape, the keys
5437
+ * the definition recognizes, the declared type of each value, and the sidecar's
5438
+ * own authoring rules (#2769). Collected in one place because `config diff`
5439
+ * runs the identical list — it used to run only the last of the four, so a
5440
+ * sidecar with an unknown key or a mistyped ordinary field was reported Synced
5441
+ * by the command whose whole promise is that a Synced file pushes.
5442
+ */
5443
+ export function testCaseSidecarPreflightErrors(filePath, tomlData) {
5444
+ const errors = [
5445
+ // The document's own shape first: an unrecognized top-level table makes
5446
+ // every per-table check below read an absent table, so its silence would
5447
+ // be misleading.
5448
+ ...configDocumentShapeErrors(filePath, tomlData, TEST_CASE_SURFACE),
5449
+ ];
5450
+ const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
5451
+ if (unknownKeyError)
5452
+ errors.push(unknownKeyError);
5453
+ errors.push(...configDeclaredTypeErrors(filePath, tomlData, TEST_CASE_TABLE), ...testCaseSidecarErrors(filePath, tomlData));
5454
+ return errors;
5455
+ }
4733
5456
  /** Whether a block's own authored file is still in the tree. */
4734
5457
  function blockFileExists(configDir, blockType, blockKey) {
4735
5458
  const resourceType = SYNC_RESOURCE_TYPES.find((t) => t.testBlockType === blockType);
@@ -5249,14 +5972,10 @@ export function registerConfigSyncCommands(sync) {
5249
5972
  warn(` ${message}`);
5250
5973
  }
5251
5974
  if (settings && pullsApp) {
5252
- // Write and warn in one place: a googleClientSecret the server
5253
- // withheld is not in the file, so pull time is where an operator
5254
- // finds out — a legacy literal from before reference-only still signs
5255
- // in (#2256), a malformed reference does not, and either one makes a
5256
- // push of this file fail.
5257
- writePulledAppSettings(configDir, settings, {
5258
- subject: " app.toml: googleClientSecret",
5259
- });
5975
+ // Every writable setting round-trips as of #2891 — the server
5976
+ // withholds nothing — so the pulled file is complete and pushable as
5977
+ // written, with no accompanying note about what it could not carry.
5978
+ writePulledAppSettings(configDir, settings);
5260
5979
  info(" Wrote app.toml");
5261
5980
  }
5262
5981
  // Write config vars (issue #1423). A single flat vars.toml at the
@@ -5318,6 +6037,12 @@ export function registerConfigSyncCommands(sync) {
5318
6037
  }
5319
6038
  // Write integrations
5320
6039
  const integrationEntities = {};
6040
+ // As for webhooks: no rule-set reference, and the pull-wide maps are
6041
+ // not filled until the rule-set leg, well after this one.
6042
+ const integrationPullMaps = {
6043
+ ruleSetIdToName: new Map(),
6044
+ ruleSetNameToId: new Map(),
6045
+ };
5321
6046
  for (const integration of integrations) {
5322
6047
  const filename = `${integration.integrationKey}.toml`;
5323
6048
  const filePath = join(configDir, "integrations", filename);
@@ -5326,6 +6051,9 @@ export function registerConfigSyncCommands(sync) {
5326
6051
  id: integration.integrationId,
5327
6052
  modifiedAt: integration.modifiedAt || new Date().toISOString(),
5328
6053
  contentHash: computeFileHash(filePath),
6054
+ // #2880 — the two sides agree right now, so this is the baseline
6055
+ // the next push classifies direction against.
6056
+ semanticHash: configSemanticHash("integration", filePath, integrationPullMaps),
5329
6057
  };
5330
6058
  info(` Wrote integrations/${filename}`);
5331
6059
  }
@@ -5341,6 +6069,14 @@ export function registerConfigSyncCommands(sync) {
5341
6069
  const webhooksDir = join(configDir, "webhooks");
5342
6070
  mkdirSync(webhooksDir, { recursive: true });
5343
6071
  const webhookEntities = {};
6072
+ // Webhooks carry no rule-set reference, so the shared projection's
6073
+ // resolution maps are inert for this type — the same empty pair the
6074
+ // diff path passes (the pull-wide maps are not filled until the
6075
+ // rule-set leg runs, well after this one).
6076
+ const webhookPullMaps = {
6077
+ ruleSetIdToName: new Map(),
6078
+ ruleSetNameToId: new Map(),
6079
+ };
5344
6080
  for (const webhook of webhooks) {
5345
6081
  const filename = `${webhook.webhookKey}.toml`;
5346
6082
  const filePath = join(webhooksDir, filename);
@@ -5349,6 +6085,9 @@ export function registerConfigSyncCommands(sync) {
5349
6085
  id: webhook.webhookId,
5350
6086
  modifiedAt: webhook.modifiedAt || new Date().toISOString(),
5351
6087
  contentHash: computeFileHash(filePath),
6088
+ // #2880 — the file and the server agree right now, so this is the
6089
+ // baseline the next push classifies direction against.
6090
+ semanticHash: configSemanticHash("webhook", filePath, webhookPullMaps),
5352
6091
  };
5353
6092
  // A stored value that is not a reference is never written to TOML, so
5354
6093
  // pull time is where an operator finds out: a legacy literal from
@@ -5389,6 +6128,12 @@ export function registerConfigSyncCommands(sync) {
5389
6128
  const cronTriggersDir = join(configDir, "cron-triggers");
5390
6129
  mkdirSync(cronTriggersDir, { recursive: true });
5391
6130
  const cronTriggerEntities = {};
6131
+ // No rule-set reference on either type, and the pull-wide maps are not
6132
+ // filled until the rule-set leg — see the webhook block above.
6133
+ const keylessPullMaps = {
6134
+ ruleSetIdToName: new Map(),
6135
+ ruleSetNameToId: new Map(),
6136
+ };
5392
6137
  for (const trigger of cronTriggerItems) {
5393
6138
  const filename = `${trigger.triggerKey}.toml`;
5394
6139
  const filePath = join(cronTriggersDir, filename);
@@ -5397,6 +6142,8 @@ export function registerConfigSyncCommands(sync) {
5397
6142
  id: trigger.triggerId,
5398
6143
  modifiedAt: trigger.modifiedAt || new Date().toISOString(),
5399
6144
  contentHash: computeFileHash(filePath),
6145
+ // #2880 — the baseline the next push classifies direction against.
6146
+ semanticHash: configSemanticHash("cron-trigger", filePath, keylessPullMaps),
5400
6147
  };
5401
6148
  }
5402
6149
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
@@ -5414,6 +6161,8 @@ export function registerConfigSyncCommands(sync) {
5414
6161
  id: bucket.bucketId,
5415
6162
  modifiedAt: bucket.modifiedAt || new Date().toISOString(),
5416
6163
  contentHash: computeFileHash(filePath),
6164
+ // #2880 — as for cron triggers.
6165
+ semanticHash: configSemanticHash("blob-bucket", filePath, keylessPullMaps),
5417
6166
  };
5418
6167
  }
5419
6168
  info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
@@ -6072,6 +6821,12 @@ export function registerConfigSyncCommands(sync) {
6072
6821
  ? {
6073
6822
  modifiedAt: new Date().toISOString(),
6074
6823
  contentHash: computeFileHash(join(configDir, "app.toml")),
6824
+ // #2880 — the file was just written FROM these settings, so
6825
+ // this is the state both sides agree on: the baseline the
6826
+ // next push classifies against. A pull also ends any
6827
+ // cross-app name exclusion, because the file it just wrote
6828
+ // states the target's own name.
6829
+ semanticHash: hashAppSettingsForDiff(settings),
6075
6830
  }
6076
6831
  : pullSelection
6077
6832
  ? priorEntities.app
@@ -6428,6 +7183,22 @@ export function registerConfigSyncCommands(sync) {
6428
7183
  drifted.push({ type, key });
6429
7184
  return;
6430
7185
  }
7186
+ // #2880 criterion 7 — neither side's state is known, so nothing is
7187
+ // written. Named as a failure rather than a skip: the file was NOT
7188
+ // pushed, and a run that reported it as skipped-unchanged would be
7189
+ // claiming a comparison it never made.
7190
+ if (outcome.action === "live-unavailable") {
7191
+ applyFailures.push({
7192
+ type,
7193
+ key,
7194
+ message: "live state could not be read and the manifest holds no " +
7195
+ "last-sync content hash for this file, so push cannot tell " +
7196
+ "which side moved. Nothing was written. Run `config pull` to " +
7197
+ "take the server's state, or `config push --force` to " +
7198
+ "overwrite it.",
7199
+ });
7200
+ return;
7201
+ }
6431
7202
  if (outcome.action === "immutable") {
6432
7203
  applyFailures.push({
6433
7204
  type,
@@ -6551,6 +7322,37 @@ export function registerConfigSyncCommands(sync) {
6551
7322
  const promptKeyToId = new Map();
6552
7323
  const promptConfigNameToId = new Map(); // key: "promptKey#configName"
6553
7324
  const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
7325
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
7326
+ const integrationConfigNameToId = new Map(); // "integrationKey#configName" → configId
7327
+ /**
7328
+ * The id→name direction the test-case comparison reads a reference
7329
+ * through (#2880). Filled from the name→id maps above before the
7330
+ * test-case legs run, and REFILLED as each block's configs are resolved:
7331
+ * the four maps are populated lazily, per block, as a case names one, so
7332
+ * a single fill before the legs start would leave every script and
7333
+ * integration reference — and every prompt this push had no reason to
7334
+ * visit — unresolved, which the comparison reads as server drift on a
7335
+ * file nobody touched.
7336
+ */
7337
+ const pushTestCaseLookupMaps = {
7338
+ configIdToName: new Map(),
7339
+ promptIdToKey: new Map(),
7340
+ };
7341
+ const fillTestCaseLookupMaps = () => {
7342
+ for (const [key, id] of promptKeyToId) {
7343
+ pushTestCaseLookupMaps.promptIdToKey.set(id, key);
7344
+ }
7345
+ for (const [scoped, id] of [
7346
+ ...promptConfigNameToId,
7347
+ ...workflowConfigNameToId,
7348
+ ...scriptConfigNameToId,
7349
+ ...integrationConfigNameToId,
7350
+ ]) {
7351
+ // The map is keyed `<blockKey>#<configName>`; the comparison names
7352
+ // the config, not the block.
7353
+ pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
7354
+ }
7355
+ };
6554
7356
  // ── Issue #976 (fix A): front-load ALL client-side TOML validation
6555
7357
  // before the first mutating call. Push is apply-as-you-go and processes
6556
7358
  // entities in a fixed sequence (cron triggers are created *before*
@@ -6698,7 +7500,13 @@ export function registerConfigSyncCommands(sync) {
6698
7500
  try {
6699
7501
  const parsedApp = parseTomlFile(preflightAppTomlPath);
6700
7502
  const appErrors = collectAppSettingsPushErrors(parsedApp, {
6701
- isCrossAppPush,
7503
+ // #2880 DSO-001 — the same exclusion the apply block computes, so
7504
+ // the preflight validates the body that will actually be sent.
7505
+ excludeName: appSettingsNameExcluded({
7506
+ isCrossAppPush: !!isCrossAppPush,
7507
+ recordedExcludedName: syncState?.entities?.app?.excludedName,
7508
+ fileName: parsedApp?.app?.name,
7509
+ }),
6702
7510
  });
6703
7511
  for (const e of appErrors) {
6704
7512
  preflightValidationErrors.push(` app.toml: ${e}`);
@@ -6741,38 +7549,18 @@ export function registerConfigSyncCommands(sync) {
6741
7549
  // preflight pass, so nothing is applied and no request is issued —
6742
7550
  // rather than being dropped on the way to the server and reading as a
6743
7551
  // successful push that changed nothing.
6744
- for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
6745
- const typeDir = join(configDir, dir);
6746
- if (!existsSync(typeDir))
6747
- continue;
6748
- for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
6749
- const filePath = join(typeDir, file);
6750
- let tomlData;
6751
- try {
6752
- tomlData = parseTomlFile(filePath);
6753
- }
6754
- catch {
6755
- // A file that does not parse is reported by the per-type loop
6756
- // with its own message; nothing to add here.
6757
- continue;
6758
- }
6759
- // The document's own shape first: an unrecognized top-level table
6760
- // makes every per-table check below read an absent table, so its
6761
- // silence would be misleading.
6762
- for (const shapeError of configDocumentShapeErrors(filePath, tomlData, surface)) {
6763
- preflightValidationErrors.push(` ${shapeError}`);
6764
- }
6765
- for (const table of tables) {
6766
- const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
6767
- if (unknownKeyError) {
6768
- preflightValidationErrors.push(` ${unknownKeyError}`);
6769
- }
6770
- }
7552
+ // #2880 — the same collector `config diff` runs, so the two commands
7553
+ // cannot disagree about which files are pushable.
7554
+ for (const rejected of collectConfigFileValidationErrors(configDir)) {
7555
+ for (const message of rejected.messages) {
7556
+ preflightValidationErrors.push(` ${message}`);
6771
7557
  }
6772
7558
  }
6773
7559
  // The `<key>.tests/*.toml` sidecars, which sit beside the block they
6774
7560
  // test rather than in a type directory of their own. Same posture,
6775
- // reached through the registry's `test-case` surface.
7561
+ // reached through the registry's `test-case` surface — and through the
7562
+ // same collector `config diff` runs per sidecar (#2880), so neither
7563
+ // command can refuse a file the other calls Synced.
6776
7564
  for (const filePath of testCaseTomlFiles(configDir)) {
6777
7565
  let tomlData;
6778
7566
  try {
@@ -6782,18 +7570,8 @@ export function registerConfigSyncCommands(sync) {
6782
7570
  // Reported by the per-block test-case loop with its own message.
6783
7571
  continue;
6784
7572
  }
6785
- for (const shapeError of configDocumentShapeErrors(filePath, tomlData, TEST_CASE_SURFACE)) {
6786
- preflightValidationErrors.push(` ${shapeError}`);
6787
- }
6788
- const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
6789
- if (unknownKeyError) {
6790
- preflightValidationErrors.push(` ${unknownKeyError}`);
6791
- }
6792
- // #2769 — with TOML the only authoring path, a sidecar the server
6793
- // would reject (no `name`) or whose JSON text does not parse fails
6794
- // here, before any test case is created or updated.
6795
- for (const shapeError of testCaseSidecarErrors(filePath, tomlData)) {
6796
- preflightValidationErrors.push(` ${shapeError}`);
7573
+ for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
7574
+ preflightValidationErrors.push(` ${message}`);
6797
7575
  }
6798
7576
  }
6799
7577
  if (preflightValidationErrors.length > 0) {
@@ -6938,36 +7716,188 @@ export function registerConfigSyncCommands(sync) {
6938
7716
  // else in the slot (issue #2645): a full push applies it, and
6939
7717
  // `--only app` applies it alone. The separate `settings push` command
6940
7718
  // that used to own this is gone.
7719
+ //
7720
+ // #2880 behaviors 10, 11 and 14 — this block used to answer "changed?"
7721
+ // from the file's BYTES and then PATCH only the keys the file spelled.
7722
+ // Both are gone. The comparison is the one `config diff` reports, over
7723
+ // the full writable descriptor set, and the payload is the whole file:
7724
+ // a key the file no longer carries is the clear (or the declared
7725
+ // default) push applies, exactly as it is for every other type (#1567).
6941
7726
  const appTomlPath = join(configDir, "app.toml");
6942
7727
  if (existsSync(appTomlPath) && (!pushSelection || pushSelection.app)) {
6943
- const appChanged = options.force || !syncState?.entities?.app?.contentHash ||
6944
- shouldPushFile(appTomlPath, syncState.entities.app.contentHash);
6945
- if (!appChanged) {
6946
- skippedCount++;
6947
- info(" Skipped app settings (unchanged)");
7728
+ const tomlData = parseTomlFile(appTomlPath);
7729
+ const appEntry = syncState?.entities?.app;
7730
+ const appFileHash = computeFileHash(appTomlPath);
7731
+ const appFileName = tomlData?.app?.name;
7732
+ const recordedExcludedName = appEntry?.excludedName;
7733
+ // DSO-001, the cross-app rule in three parts.
7734
+ //
7735
+ // `payloadExcludesName` — is `[app].name` out of the body? A
7736
+ // cross-app push never renames its target, and neither does a later
7737
+ // push of a file still stating the name that push left behind.
7738
+ //
7739
+ // `compareExcludesName` — are the two hashes computed without it?
7740
+ // While an exclusion RECORD exists they must be, because that is how
7741
+ // the baseline was stamped, and `classifyPushChange` can only answer
7742
+ // `local-edited` when the remote hash equals the baseline.
7743
+ //
7744
+ // `appNameEdited` — the file has moved away from the recorded name.
7745
+ // That is a local rename BY CONSTRUCTION, not by comparison: no
7746
+ // baseline hashed without `name` can ever attribute it.
7747
+ const payloadExcludesName = appSettingsNameExcluded({
7748
+ isCrossAppPush: !!isCrossAppPush,
7749
+ recordedExcludedName,
7750
+ fileName: appFileName,
7751
+ });
7752
+ const compareExcludesName = !!isCrossAppPush || recordedExcludedName !== undefined;
7753
+ const appNameEdited = !isCrossAppPush &&
7754
+ recordedExcludedName !== undefined &&
7755
+ !payloadExcludesName;
7756
+ // The whole-truth body. Errors (an unrecognized or retired key, a
7757
+ // type mismatch, a missing required key) already aborted the push in
7758
+ // the preflight pass above, so only warnings remain here.
7759
+ const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
7760
+ excludeName: payloadExcludesName,
7761
+ });
7762
+ for (const w of appWarnings) {
7763
+ warn(` ${w}`);
7764
+ }
7765
+ let appLiveOk = true;
7766
+ let liveSettings;
7767
+ try {
7768
+ liveSettings = await client.getAppSettings(resolvedAppId);
7769
+ }
7770
+ catch (err) {
7771
+ appLiveOk = false;
7772
+ warnLiveUnavailable("app-settings", err);
7773
+ }
7774
+ /** Record the state both sides are now known to be in. */
7775
+ const stampAppEntry = (baselineSettings, options) => {
7776
+ if (!syncState)
7777
+ return;
7778
+ syncState.entities.app = {
7779
+ modifiedAt: baselineSettings?.modifiedAt ||
7780
+ appEntry?.modifiedAt ||
7781
+ new Date().toISOString(),
7782
+ contentHash: appFileHash,
7783
+ semanticHash: hashAppSettingsForDiff(baselineSettings, {
7784
+ excludeName: options.recordExclusion !== undefined ||
7785
+ (options.keepExclusion && compareExcludesName),
7786
+ }),
7787
+ ...(options.recordExclusion !== undefined
7788
+ ? { excludedName: options.recordExclusion }
7789
+ : options.keepExclusion && recordedExcludedName !== undefined
7790
+ ? { excludedName: recordedExcludedName }
7791
+ : {}),
7792
+ };
7793
+ };
7794
+ let applyAppSettings = true;
7795
+ if (appLiveOk) {
7796
+ const compareOptions = { excludeName: compareExcludesName };
7797
+ const localHash = hashAppSettingsForDiff(appPayload, compareOptions);
7798
+ const remoteHash = hashAppSettingsForDiff(liveSettings ?? {}, compareOptions);
7799
+ if (options.force) {
7800
+ // `--force` is the operator saying "local wins": no direction
7801
+ // check, exactly as it is for every other type.
7802
+ }
7803
+ else if (isCrossAppPush) {
7804
+ // The target's settings are not this slot's history: there is no
7805
+ // baseline and none is inferable, and applying the source's
7806
+ // configuration is the whole point of the push. `name` is already
7807
+ // out of the body, so the one thing that could not be recovered
7808
+ // by a later `config pull` is not at risk.
7809
+ }
7810
+ else {
7811
+ const direction = classifyPushChange({
7812
+ localHash,
7813
+ remoteHash,
7814
+ baselineHash: appEntry?.semanticHash,
7815
+ legacy: {
7816
+ localBytesMatchStoredContentHash: appEntry?.contentHash !== undefined &&
7817
+ appEntry.contentHash === appFileHash,
7818
+ // Deliberately never true: `entities.app.modifiedAt` is
7819
+ // stamped from the LOCAL clock, so it proves nothing about
7820
+ // the server. A pre-upgrade manifest whose bytes have also
7821
+ // moved is therefore `unknown-direction` — a conflict, not a
7822
+ // blind overwrite of state nobody compared.
7823
+ liveModifiedAtMatchesStored: false,
7824
+ },
7825
+ });
7826
+ const fields = appSettingsFieldDiffs(appPayload, liveSettings ?? {}, compareOptions).map((d) => ({
7827
+ field: `[${d.section}].${d.key}`,
7828
+ local: d.local,
7829
+ server: d.remote,
7830
+ }));
7831
+ if (direction === "unchanged") {
7832
+ // A rename still has to go out: the comparison above cannot
7833
+ // see `name` while the exclusion record stands.
7834
+ if (!appNameEdited) {
7835
+ skippedCount++;
7836
+ info(" Skipped app settings (unchanged)");
7837
+ stampAppEntry(liveSettings, { keepExclusion: true });
7838
+ applyAppSettings = false;
7839
+ }
7840
+ }
7841
+ else if (direction === "server-drifted") {
7842
+ recordDeclined("app-settings", "app.toml", { action: "drift", localHash, remoteHash, fields }, appEntry?.modifiedAt);
7843
+ applyAppSettings = false;
7844
+ }
7845
+ else if (direction === "both-changed" ||
7846
+ direction === "unknown-direction") {
7847
+ recordDeclined("app-settings", "app.toml", {
7848
+ action: "conflict",
7849
+ direction,
7850
+ localHash,
7851
+ remoteHash,
7852
+ fields,
7853
+ serverModifiedAt: liveSettings?.modifiedAt,
7854
+ }, appEntry?.modifiedAt);
7855
+ applyAppSettings = false;
7856
+ }
7857
+ }
6948
7858
  }
6949
7859
  else {
6950
- const tomlData = parseTomlFile(appTomlPath);
6951
- // Definition-driven parse of every section ([app]/[auth]/[cors]/
6952
- // [invitations]) — issue #1033, projected from the vendored
6953
- // `app-settings` surface since #2644. `name` is stripped on
6954
- // cross-app push. Errors (an unrecognized key or table, a type
6955
- // mismatch) already aborted the push in the preflight pass above,
6956
- // so only warnings remain here.
6957
- const { settings, warnings: appWarnings } = parseTomlToAppSettings(tomlData, { isCrossAppPush });
6958
- for (const w of appWarnings) {
6959
- warn(` ${w}`);
7860
+ const degraded = decideDegradedPush({
7861
+ force: options.force,
7862
+ storedContentHash: appEntry?.contentHash,
7863
+ currentFileHash: appFileHash,
7864
+ });
7865
+ if (degraded.action === "skip") {
7866
+ skippedCount++;
7867
+ info(" Skipped app settings (unchanged)");
7868
+ applyAppSettings = false;
6960
7869
  }
7870
+ else if (degraded.action === "decline") {
7871
+ recordDeclined("app-settings", "app.toml", { action: "live-unavailable" }, appEntry?.modifiedAt);
7872
+ applyAppSettings = false;
7873
+ }
7874
+ }
7875
+ if (applyAppSettings) {
6961
7876
  changes.push({ type: "app", action: "update", key: "settings" });
6962
7877
  if (!options.dryRun) {
6963
- await client.updateAppSettings(resolvedAppId, settings);
7878
+ const updated = await client.updateAppSettings(resolvedAppId, appPayload);
6964
7879
  info(" Updated app settings");
6965
- // Store content hash after successful push
6966
- if (syncState) {
6967
- if (!syncState.entities.app) {
6968
- syncState.entities.app = { modifiedAt: new Date().toISOString() };
6969
- }
6970
- syncState.entities.app.contentHash = computeFileHash(appTomlPath);
7880
+ // Stamped from what the server RETURNED, not from the body sent:
7881
+ // `PUT /settings` normalizes (a trailing slash off `baseUrl`, a
7882
+ // lower-cased test-account email), so the payload is not what the
7883
+ // next comparison will read. The PUT echoes the full settings; if
7884
+ // a server ever answers with something else, re-read rather than
7885
+ // record a state nobody observed.
7886
+ const baselineSettings = updated && typeof updated === "object" && "mode" in updated
7887
+ ? updated
7888
+ : await client
7889
+ .getAppSettings(resolvedAppId)
7890
+ .catch(() => undefined);
7891
+ if (baselineSettings) {
7892
+ stampAppEntry(baselineSettings, {
7893
+ keepExclusion: false,
7894
+ // A cross-app apply is what CREATES the exclusion: the
7895
+ // target keeps its own name, and the file's name is recorded
7896
+ // so a later edit to that line reads as the rename it is.
7897
+ ...(isCrossAppPush && appFileName !== undefined
7898
+ ? { recordExclusion: String(appFileName) }
7899
+ : {}),
7900
+ });
6971
7901
  }
6972
7902
  }
6973
7903
  }
@@ -7190,13 +8120,26 @@ export function registerConfigSyncCommands(sync) {
7190
8120
  continue;
7191
8121
  }
7192
8122
  }
7193
- else if (!options.force &&
7194
- existingId &&
7195
- !shouldPushFile(filePath, entry?.contentHash)) {
7196
- // Fallback gate: the manifest byte hash, exactly as before.
7197
- skippedCount++;
7198
- ruleSetNameToId.set(ruleSetData.name, existingId);
7199
- continue;
8123
+ else {
8124
+ // Fallback gate: the manifest byte hash, and — since #2880 — a
8125
+ // decline rather than a blind write when there is no hash.
8126
+ const degraded = decideDegradedPush({
8127
+ force: options.force,
8128
+ storedContentHash: entry?.contentHash,
8129
+ currentFileHash: computeFileHash(filePath),
8130
+ });
8131
+ if (degraded.action === "skip") {
8132
+ skippedCount++;
8133
+ if (existingId)
8134
+ ruleSetNameToId.set(ruleSetData.name, existingId);
8135
+ continue;
8136
+ }
8137
+ if (degraded.action === "decline") {
8138
+ recordDeclined("rule-set", fileKey, { action: "live-unavailable" }, entry?.modifiedAt);
8139
+ if (existingId)
8140
+ ruleSetNameToId.set(ruleSetData.name, existingId);
8141
+ continue;
8142
+ }
7200
8143
  }
7201
8144
  if (existingId) {
7202
8145
  // Update existing rule set
@@ -7337,17 +8280,112 @@ export function registerConfigSyncCommands(sync) {
7337
8280
  const integrationsDir = join(configDir, "integrations");
7338
8281
  if (existsSync(integrationsDir)) {
7339
8282
  const files = selectFiles("integration", integrationsDir, readdirSync(integrationsDir).filter((f) => f.endsWith(".toml")));
8283
+ // #2880 — integrations join the live-state reconcile (#2731 B3), for
8284
+ // the reason webhooks did: diff compared presence while push gated on
8285
+ // the file's bytes, so a field edit was invisible to one command and
8286
+ // applied by the other, and a comment was a change to the other and
8287
+ // not the one.
8288
+ const integrationSpec = configDiffSpec("integration");
8289
+ const liveIntegrations = new Map();
8290
+ let integrationsLiveOk = true;
8291
+ if (files.length > 0) {
8292
+ try {
8293
+ const listed = await fetchAll((p) => client.listIntegrations(resolvedAppId, p));
8294
+ for (const i of listed) {
8295
+ if (i?.integrationKey)
8296
+ liveIntegrations.set(i.integrationKey, i);
8297
+ }
8298
+ }
8299
+ catch (err) {
8300
+ integrationsLiveOk = false;
8301
+ warnLiveUnavailable("integrations", err);
8302
+ }
8303
+ }
8304
+ /** The detail — `accessRule` and `requestConfig` are not on the list. */
8305
+ const refetchLiveIntegration = async (integrationId) => {
8306
+ const full = await client.getIntegration(resolvedAppId, integrationId);
8307
+ return full ? { record: full } : null;
8308
+ };
7340
8309
  for (const file of files) {
7341
8310
  const filePath = join(integrationsDir, file);
7342
8311
  const tomlData = parseTomlFile(filePath);
7343
- const integration = tomlData.integration || {};
7344
- const key = integration.key || basename(file, ".toml");
7345
- const existingId = syncState?.entities?.integrations?.[key]?.id;
7346
- // Skip if file hasn't changed since last sync
7347
- if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.integrations?.[key]?.contentHash)) {
7348
- skippedCount++;
7349
- continue;
7350
- }
8312
+ const key = tomlData.integration?.key || basename(file, ".toml");
8313
+ // The gate re-parses the DOCUMENT, so the key the file left to its
8314
+ // name has to be in it (see `withDerivedIdentity`).
8315
+ withDerivedIdentity(tomlData, "integration", { key });
8316
+ const existingEntry = syncState?.entities?.integrations?.[key];
8317
+ const liveListed = liveIntegrations.get(key);
8318
+ let integrationLiveOk = integrationsLiveOk;
8319
+ let liveIntegration = undefined;
8320
+ if (integrationLiveOk && liveListed?.integrationId) {
8321
+ try {
8322
+ liveIntegration = await client.getIntegration(resolvedAppId, liveListed.integrationId);
8323
+ }
8324
+ catch (err) {
8325
+ integrationLiveOk = false;
8326
+ warnLiveUnavailable(`integration ${key}`, err);
8327
+ }
8328
+ }
8329
+ let gate;
8330
+ if (integrationLiveOk) {
8331
+ gate = decidePushForConfig({
8332
+ spec: integrationSpec,
8333
+ maps: pushDiffMaps,
8334
+ localParsed: tomlData,
8335
+ live: liveIntegration
8336
+ ? {
8337
+ record: liveIntegration,
8338
+ modifiedAt: liveIntegration.modifiedAt,
8339
+ }
8340
+ : null,
8341
+ entry: existingEntry,
8342
+ localFileHash: computeFileHash(filePath),
8343
+ force: options.force,
8344
+ });
8345
+ if (gate.action === "skip") {
8346
+ skippedCount++;
8347
+ if (syncState && liveIntegration?.integrationId) {
8348
+ if (!syncState.entities.integrations) {
8349
+ syncState.entities.integrations = {};
8350
+ }
8351
+ syncState.entities.integrations[key] = {
8352
+ id: liveIntegration.integrationId,
8353
+ modifiedAt: liveIntegration.modifiedAt ||
8354
+ existingEntry?.modifiedAt ||
8355
+ new Date().toISOString(),
8356
+ contentHash: computeFileHash(filePath),
8357
+ semanticHash: gate.remoteHash,
8358
+ };
8359
+ }
8360
+ continue;
8361
+ }
8362
+ if (gate.action === "drift" ||
8363
+ gate.action === "conflict" ||
8364
+ gate.action === "immutable") {
8365
+ recordDeclined("integration", key, gate, existingEntry?.modifiedAt);
8366
+ continue;
8367
+ }
8368
+ }
8369
+ else {
8370
+ const degraded = decideDegradedPush({
8371
+ force: options.force,
8372
+ storedContentHash: existingEntry?.contentHash,
8373
+ currentFileHash: computeFileHash(filePath),
8374
+ });
8375
+ if (degraded.action === "skip") {
8376
+ skippedCount++;
8377
+ continue;
8378
+ }
8379
+ if (degraded.action === "decline") {
8380
+ recordDeclined("integration", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
8381
+ continue;
8382
+ }
8383
+ }
8384
+ const existingId = resolveExistingId({
8385
+ liveOk: integrationLiveOk,
8386
+ liveId: liveListed?.integrationId,
8387
+ manifestId: existingEntry?.id,
8388
+ });
7351
8389
  // Same body on the create, update, and adopt-then-update paths.
7352
8390
  const payload = buildIntegrationPushPayload(key, tomlData);
7353
8391
  // #2631 — `accessRule` is TOML-owned, so a file with no rule pushes
@@ -7361,26 +8399,36 @@ export function registerConfigSyncCommands(sync) {
7361
8399
  if (existingId) {
7362
8400
  changes.push({ type: "integration", action: "update", key });
7363
8401
  if (!options.dryRun) {
8402
+ // #2731 B4 — the guard is the LIVE timestamp the reconcile just
8403
+ // read (the gate carries it), not the manifest's.
7364
8404
  const expectedModifiedAt = options.force
7365
8405
  ? undefined
7366
- : syncState?.entities?.integrations?.[key]?.modifiedAt;
8406
+ : gate?.action === "update"
8407
+ ? gate.expectedModifiedAt
8408
+ : existingEntry?.modifiedAt;
7367
8409
  try {
7368
8410
  const updated = await client.updateIntegration(resolvedAppId, existingId, payload, expectedModifiedAt);
7369
8411
  info(` Updated integration: ${key}`);
7370
- // Update sync state with new modifiedAt
7371
- if (syncState?.entities?.integrations?.[key] && updated?.modifiedAt) {
7372
- syncState.entities.integrations[key].modifiedAt = updated.modifiedAt;
7373
- syncState.entities.integrations[key].contentHash = computeFileHash(filePath);
8412
+ if (syncState) {
8413
+ if (!syncState.entities.integrations) {
8414
+ syncState.entities.integrations = {};
8415
+ }
8416
+ syncState.entities.integrations[key] = {
8417
+ id: existingId,
8418
+ modifiedAt: updated?.modifiedAt ||
8419
+ existingEntry?.modifiedAt ||
8420
+ new Date().toISOString(),
8421
+ contentHash: computeFileHash(filePath),
8422
+ // From the entity the SERVER returned: the handler
8423
+ // canonicalizes `requestConfig`, so the payload sent is
8424
+ // not what the next comparison will see.
8425
+ semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existingId)),
8426
+ };
7374
8427
  }
7375
8428
  }
7376
8429
  catch (err) {
7377
8430
  if (err instanceof ConflictError) {
7378
- conflicts.push({
7379
- type: "integration",
7380
- key,
7381
- serverModifiedAt: err.serverModifiedAt,
7382
- localModifiedAt: expectedModifiedAt || "unknown",
7383
- });
8431
+ conflicts.push(await lateConflict("integration", key, err, expectedModifiedAt, () => refetchLiveIntegration(existingId), integrationSpec, tomlData));
7384
8432
  }
7385
8433
  else {
7386
8434
  throw wrapEntityError(err, "update", "integration", key);
@@ -7403,6 +8451,12 @@ export function registerConfigSyncCommands(sync) {
7403
8451
  id: created.integrationId,
7404
8452
  modifiedAt: created.modifiedAt,
7405
8453
  contentHash: computeFileHash(filePath),
8454
+ // #2880 — a create records its baseline too, read from
8455
+ // the stored entity: the handler materializes the
8456
+ // defaults and canonicalizes `requestConfig`, so a
8457
+ // baseline taken from the file would read those as a
8458
+ // difference on the very next run.
8459
+ semanticHash: await stampRemoteSemanticHash(integrationSpec, created, () => refetchLiveIntegration(created.integrationId)),
7406
8460
  };
7407
8461
  }
7408
8462
  }
@@ -7434,6 +8488,9 @@ export function registerConfigSyncCommands(sync) {
7434
8488
  existing.modifiedAt ||
7435
8489
  new Date().toISOString(),
7436
8490
  contentHash: computeFileHash(filePath),
8491
+ // #2880 — an adoption converges here, so it stamps the
8492
+ // baseline the next run classifies against.
8493
+ semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existing.integrationId)),
7437
8494
  };
7438
8495
  }
7439
8496
  }
@@ -7445,42 +8502,175 @@ export function registerConfigSyncCommands(sync) {
7445
8502
  const webhooksDir = join(configDir, "webhooks");
7446
8503
  if (existsSync(webhooksDir)) {
7447
8504
  const files = selectFiles("webhook", webhooksDir, readdirSync(webhooksDir).filter((f) => f.endsWith(".toml")));
8505
+ // #2880 — webhooks join the live-state reconcile (#2731 B3). #2757
8506
+ // gave the type diff's comparator and left push on the manifest byte
8507
+ // hash, so the two commands answered "changed?" from different
8508
+ // sources and disagreed in both directions: a comment moved the bytes
8509
+ // and push planned an update diff read as Synced, and a file whose
8510
+ // bytes had not moved was skipped however far its CONTENT had drifted
8511
+ // from the server, so a reported `Modified` could never be pushed
8512
+ // away. Both are the same fix — push decides from the projection diff
8513
+ // compares, against the entity diff compares it to.
8514
+ //
8515
+ // It also retires `modifiedAt` as a CONTENT signal wherever a
8516
+ // baseline exists, which is what stops a delivery (whose
8517
+ // `lastTriggeredAt` bumps `modifiedAt`) from reading as a permanent
8518
+ // conflict on an unedited file (#2894).
8519
+ const webhookSpec = configDiffSpec("webhook");
8520
+ // The list says WHICH webhooks the server has and under which id; the
8521
+ // detail is what `config pull` serializes from, so the comparison
8522
+ // fetches it per selected file. A failed list is NOT an empty list —
8523
+ // treating it as one would send every webhook down the create path —
8524
+ // so it degrades the section to the byte-hash gate, type named.
8525
+ const liveWebhooks = new Map();
8526
+ let webhooksLiveOk = true;
8527
+ if (files.length > 0) {
8528
+ try {
8529
+ const listed = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
8530
+ for (const w of listed) {
8531
+ if (w?.webhookKey)
8532
+ liveWebhooks.set(w.webhookKey, w);
8533
+ }
8534
+ }
8535
+ catch (err) {
8536
+ webhooksLiveOk = false;
8537
+ warnLiveUnavailable("webhooks", err);
8538
+ }
8539
+ }
8540
+ /** One webhook as the server holds it NOW — the post-apply baseline. */
8541
+ const refetchLiveWebhook = async (webhookId) => {
8542
+ const full = await client.getWebhook(resolvedAppId, webhookId);
8543
+ return full ? { record: full } : null;
8544
+ };
7448
8545
  for (const file of files) {
7449
8546
  const filePath = join(webhooksDir, file);
7450
8547
  const tomlData = parseTomlFile(filePath);
7451
- const webhook = tomlData.webhook || {};
7452
- const key = webhook.key || basename(file, ".toml");
7453
- const existingId = syncState?.entities?.webhooks?.[key]?.id;
7454
- if (!options.force && existingId &&
7455
- !shouldPushFile(filePath, syncState?.entities?.webhooks?.[key]?.contentHash)) {
7456
- skippedCount++;
7457
- continue;
8548
+ const key = tomlData.webhook?.key || basename(file, ".toml");
8549
+ // The gate re-parses the DOCUMENT, so the key the file left to its
8550
+ // name has to be in it (see `withDerivedIdentity`): `displayName`
8551
+ // falls back to the key, so a file stating neither would otherwise
8552
+ // project an empty name against the derived one the server holds.
8553
+ withDerivedIdentity(tomlData, "webhook", { key });
8554
+ const existingEntry = syncState?.entities?.webhooks?.[key];
8555
+ const liveListed = liveWebhooks.get(key);
8556
+ // A detail read that fails degrades THIS webhook to the byte-hash
8557
+ // gate, never the section — its neighbours still reconcile.
8558
+ let webhookLiveOk = webhooksLiveOk;
8559
+ let liveWebhook = undefined;
8560
+ if (webhookLiveOk && liveListed?.webhookId) {
8561
+ try {
8562
+ liveWebhook = await client.getWebhook(resolvedAppId, liveListed.webhookId);
8563
+ }
8564
+ catch (err) {
8565
+ webhookLiveOk = false;
8566
+ warnLiveUnavailable(`webhook ${key}`, err);
8567
+ }
7458
8568
  }
8569
+ let gate;
8570
+ if (webhookLiveOk) {
8571
+ gate = decidePushForConfig({
8572
+ spec: webhookSpec,
8573
+ // Webhooks carry no rule-set reference, so these maps are inert
8574
+ // for this type — shared with `stampRemoteSemanticHash` so the
8575
+ // gate and the baseline it compares against cannot diverge.
8576
+ maps: pushDiffMaps,
8577
+ localParsed: tomlData,
8578
+ live: liveWebhook
8579
+ ? { record: liveWebhook, modifiedAt: liveWebhook.modifiedAt }
8580
+ : null,
8581
+ entry: existingEntry,
8582
+ localFileHash: computeFileHash(filePath),
8583
+ force: options.force,
8584
+ });
8585
+ if (gate.action === "skip") {
8586
+ skippedCount++;
8587
+ if (syncState && liveWebhook?.webhookId) {
8588
+ if (!syncState.entities.webhooks)
8589
+ syncState.entities.webhooks = {};
8590
+ syncState.entities.webhooks[key] = {
8591
+ id: liveWebhook.webhookId,
8592
+ modifiedAt: liveWebhook.modifiedAt ||
8593
+ existingEntry?.modifiedAt ||
8594
+ new Date().toISOString(),
8595
+ contentHash: computeFileHash(filePath),
8596
+ // The two sides agree HERE, so this is the baseline the
8597
+ // next run classifies against — which is what stops a
8598
+ // comment-only edit from re-reporting forever.
8599
+ semanticHash: gate.remoteHash,
8600
+ };
8601
+ }
8602
+ continue;
8603
+ }
8604
+ if (gate.action === "drift" ||
8605
+ gate.action === "conflict" ||
8606
+ gate.action === "immutable") {
8607
+ recordDeclined("webhook", key, gate, existingEntry?.modifiedAt);
8608
+ continue;
8609
+ }
8610
+ }
8611
+ else {
8612
+ const degraded = decideDegradedPush({
8613
+ force: options.force,
8614
+ storedContentHash: existingEntry?.contentHash,
8615
+ currentFileHash: computeFileHash(filePath),
8616
+ });
8617
+ if (degraded.action === "skip") {
8618
+ skippedCount++;
8619
+ continue;
8620
+ }
8621
+ if (degraded.action === "decline") {
8622
+ recordDeclined("webhook", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
8623
+ continue;
8624
+ }
8625
+ }
8626
+ // Live state decides create-vs-update: a webhook the manifest never
8627
+ // recorded is adopted, and one the manifest still names but the
8628
+ // server no longer has goes to the create path (#1659).
8629
+ const existingId = resolveExistingId({
8630
+ liveOk: webhookLiveOk,
8631
+ liveId: liveListed?.webhookId,
8632
+ manifestId: existingEntry?.id,
8633
+ });
7459
8634
  // Same body on the create, update, and adopt-then-update paths,
7460
8635
  // built from the vendored definition (#2644).
7461
8636
  const payload = buildWebhookPushPayload(key, tomlData);
7462
8637
  if (existingId) {
7463
8638
  changes.push({ type: "webhook", action: "update", key });
7464
8639
  if (!options.dryRun) {
8640
+ // #2731 B4 — the guard is the LIVE timestamp the reconcile
8641
+ // fetch just read (the gate carries it), not the manifest's:
8642
+ // that closes the window between the read and this write
8643
+ // rather than re-reporting a difference we just looked at.
7465
8644
  const expectedModifiedAt = options.force
7466
8645
  ? undefined
7467
- : syncState?.entities?.webhooks?.[key]?.modifiedAt;
8646
+ : gate?.action === "update"
8647
+ ? gate.expectedModifiedAt
8648
+ : existingEntry?.modifiedAt;
7468
8649
  try {
7469
8650
  const updated = await client.updateWebhook(resolvedAppId, existingId, payload, expectedModifiedAt);
7470
8651
  info(` Updated webhook: ${key}`);
7471
- if (syncState?.entities?.webhooks?.[key] && updated?.modifiedAt) {
7472
- syncState.entities.webhooks[key].modifiedAt = updated.modifiedAt;
7473
- syncState.entities.webhooks[key].contentHash = computeFileHash(filePath);
8652
+ if (syncState) {
8653
+ if (!syncState.entities.webhooks)
8654
+ syncState.entities.webhooks = {};
8655
+ syncState.entities.webhooks[key] = {
8656
+ id: existingId,
8657
+ modifiedAt: updated?.modifiedAt ||
8658
+ existingEntry?.modifiedAt ||
8659
+ new Date().toISOString(),
8660
+ contentHash: computeFileHash(filePath),
8661
+ // Stamped from the RETURNED entity, not the payload sent:
8662
+ // the handler normalizes, so the payload is not what the
8663
+ // next comparison will see.
8664
+ semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existingId)),
8665
+ };
7474
8666
  }
7475
8667
  }
7476
8668
  catch (err) {
7477
8669
  if (err instanceof ConflictError) {
7478
- conflicts.push({
7479
- type: "webhook",
7480
- key,
7481
- serverModifiedAt: err.serverModifiedAt,
7482
- localModifiedAt: expectedModifiedAt || "unknown",
7483
- });
8670
+ // A late 409: the server moved between the reconcile fetch
8671
+ // and this write, so the gate's field diff describes a state
8672
+ // that is gone. Recompute against the current record.
8673
+ conflicts.push(await lateConflict("webhook", key, err, expectedModifiedAt, () => refetchLiveWebhook(existingId), webhookSpec, tomlData));
7484
8674
  }
7485
8675
  else {
7486
8676
  throw wrapEntityError(err, "update", "webhook", key);
@@ -7502,6 +8692,10 @@ export function registerConfigSyncCommands(sync) {
7502
8692
  id: created.webhookId,
7503
8693
  modifiedAt: created.modifiedAt,
7504
8694
  contentHash: computeFileHash(filePath),
8695
+ // #2880 — the baseline the next run classifies against,
8696
+ // read from what the server stored rather than what was
8697
+ // sent (the handler normalizes).
8698
+ semanticHash: await stampRemoteSemanticHash(webhookSpec, created, () => refetchLiveWebhook(created.webhookId)),
7505
8699
  };
7506
8700
  }
7507
8701
  }
@@ -7533,6 +8727,10 @@ export function registerConfigSyncCommands(sync) {
7533
8727
  existing.modifiedAt ||
7534
8728
  new Date().toISOString(),
7535
8729
  contentHash: computeFileHash(filePath),
8730
+ // #2880 — an adoption converges here, so it stamps the
8731
+ // baseline too; without it the next run has no direction
8732
+ // signal for a row this one just reconciled.
8733
+ semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existing.webhookId)),
7536
8734
  };
7537
8735
  }
7538
8736
  }
@@ -7544,17 +8742,96 @@ export function registerConfigSyncCommands(sync) {
7544
8742
  const cronTriggersDir = join(configDir, "cron-triggers");
7545
8743
  if (existsSync(cronTriggersDir)) {
7546
8744
  const files = selectFiles("cron-trigger", cronTriggersDir, readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml")));
8745
+ // #2880 — cron triggers join the live-state reconcile. The LIST row is
8746
+ // the whole record for this type (it is what `config pull`
8747
+ // serializes from), so the section needs no per-entity detail read.
8748
+ // The update endpoint takes no `expectedModifiedAt`, which is exactly
8749
+ // why the read-time direction classification matters here: it is the
8750
+ // only protection this type has ever had, and it was comparing bytes.
8751
+ const cronSpec = configDiffSpec("cron-trigger");
8752
+ const liveCronTriggers = new Map();
8753
+ let cronLiveOk = true;
8754
+ if (files.length > 0) {
8755
+ try {
8756
+ const listed = await client.listCronTriggers(resolvedAppId);
8757
+ for (const t of (listed?.items ?? [])) {
8758
+ if (t?.triggerKey)
8759
+ liveCronTriggers.set(t.triggerKey, t);
8760
+ }
8761
+ }
8762
+ catch (err) {
8763
+ cronLiveOk = false;
8764
+ warnLiveUnavailable("cron-triggers", err);
8765
+ }
8766
+ }
8767
+ const refetchLiveCronTrigger = async (triggerId) => {
8768
+ const full = await client.getCronTrigger(resolvedAppId, triggerId);
8769
+ return full ? { record: full } : null;
8770
+ };
7547
8771
  for (const file of files) {
7548
8772
  const filePath = join(cronTriggersDir, file);
7549
8773
  const tomlData = parseTomlFile(filePath);
7550
- const cronTrigger = tomlData.cronTrigger || {};
7551
- const key = cronTrigger.key || basename(file, ".toml");
7552
- const existingId = syncState?.entities?.cronTriggers?.[key]?.id;
7553
- if (!options.force && existingId &&
7554
- !shouldPushFile(filePath, syncState?.entities?.cronTriggers?.[key]?.contentHash)) {
7555
- skippedCount++;
7556
- continue;
8774
+ const key = tomlData.cronTrigger?.key || basename(file, ".toml");
8775
+ withDerivedIdentity(tomlData, "cronTrigger", { key });
8776
+ const existingEntry = syncState?.entities?.cronTriggers?.[key];
8777
+ const liveCron = liveCronTriggers.get(key);
8778
+ let gate;
8779
+ if (cronLiveOk) {
8780
+ gate = decidePushForConfig({
8781
+ spec: cronSpec,
8782
+ maps: pushDiffMaps,
8783
+ localParsed: tomlData,
8784
+ live: liveCron
8785
+ ? { record: liveCron, modifiedAt: liveCron.modifiedAt }
8786
+ : null,
8787
+ entry: existingEntry,
8788
+ localFileHash: computeFileHash(filePath),
8789
+ force: options.force,
8790
+ });
8791
+ if (gate.action === "skip") {
8792
+ skippedCount++;
8793
+ if (syncState && liveCron?.triggerId) {
8794
+ if (!syncState.entities.cronTriggers) {
8795
+ syncState.entities.cronTriggers = {};
8796
+ }
8797
+ syncState.entities.cronTriggers[key] = {
8798
+ id: liveCron.triggerId,
8799
+ modifiedAt: liveCron.modifiedAt ||
8800
+ existingEntry?.modifiedAt ||
8801
+ new Date().toISOString(),
8802
+ contentHash: computeFileHash(filePath),
8803
+ semanticHash: gate.remoteHash,
8804
+ };
8805
+ }
8806
+ continue;
8807
+ }
8808
+ if (gate.action === "drift" ||
8809
+ gate.action === "conflict" ||
8810
+ gate.action === "immutable") {
8811
+ recordDeclined("cron-trigger", key, gate, existingEntry?.modifiedAt);
8812
+ continue;
8813
+ }
8814
+ }
8815
+ else {
8816
+ const degraded = decideDegradedPush({
8817
+ force: options.force,
8818
+ storedContentHash: existingEntry?.contentHash,
8819
+ currentFileHash: computeFileHash(filePath),
8820
+ });
8821
+ if (degraded.action === "skip") {
8822
+ skippedCount++;
8823
+ continue;
8824
+ }
8825
+ if (degraded.action === "decline") {
8826
+ recordDeclined("cron-trigger", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
8827
+ continue;
8828
+ }
7557
8829
  }
8830
+ const existingId = resolveExistingId({
8831
+ liveOk: cronLiveOk,
8832
+ liveId: liveCron?.triggerId,
8833
+ manifestId: existingEntry?.id,
8834
+ });
7558
8835
  // Built from the vendored definition (#2644). The create body
7559
8836
  // omits `state` — the handler assigns it — which is what the
7560
8837
  // definition's `writableOn` records.
@@ -7570,9 +8847,20 @@ export function registerConfigSyncCommands(sync) {
7570
8847
  try {
7571
8848
  const updated = await client.updateCronTrigger(resolvedAppId, existingId, updatePayload);
7572
8849
  info(` Updated cron trigger: ${key}`);
7573
- if (syncState?.entities?.cronTriggers?.[key] && updated?.modifiedAt) {
7574
- syncState.entities.cronTriggers[key].modifiedAt = updated.modifiedAt;
7575
- syncState.entities.cronTriggers[key].contentHash = computeFileHash(filePath);
8850
+ if (syncState) {
8851
+ if (!syncState.entities.cronTriggers) {
8852
+ syncState.entities.cronTriggers = {};
8853
+ }
8854
+ syncState.entities.cronTriggers[key] = {
8855
+ id: existingId,
8856
+ modifiedAt: updated?.modifiedAt ||
8857
+ existingEntry?.modifiedAt ||
8858
+ new Date().toISOString(),
8859
+ contentHash: computeFileHash(filePath),
8860
+ // #2880 — from the entity the server returned: the
8861
+ // handler materializes `timezone`/`overlapPolicy`.
8862
+ semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(existingId)),
8863
+ };
7576
8864
  }
7577
8865
  }
7578
8866
  catch (err) {
@@ -7594,6 +8882,7 @@ export function registerConfigSyncCommands(sync) {
7594
8882
  id: created.triggerId,
7595
8883
  modifiedAt: created.modifiedAt,
7596
8884
  contentHash: computeFileHash(filePath),
8885
+ semanticHash: await stampRemoteSemanticHash(cronSpec, created, () => refetchLiveCronTrigger(created.triggerId)),
7597
8886
  };
7598
8887
  }
7599
8888
  }
@@ -7629,6 +8918,7 @@ export function registerConfigSyncCommands(sync) {
7629
8918
  id: adoptedId,
7630
8919
  modifiedAt: updated?.modifiedAt || new Date().toISOString(),
7631
8920
  contentHash: computeFileHash(filePath),
8921
+ semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(adoptedId)),
7632
8922
  };
7633
8923
  }
7634
8924
  }
@@ -7640,17 +8930,97 @@ export function registerConfigSyncCommands(sync) {
7640
8930
  const blobBucketsPushDir = join(configDir, "blob-buckets");
7641
8931
  if (existsSync(blobBucketsPushDir)) {
7642
8932
  const files = selectFiles("blob-bucket", blobBucketsPushDir, readdirSync(blobBucketsPushDir).filter((f) => f.endsWith(".toml")));
8933
+ // #2880 — blob buckets join the live-state reconcile. As for cron
8934
+ // triggers the list row IS the record, and the update takes no
8935
+ // concurrency token, so read-time classification is the protection.
8936
+ // `ttlTier` is create-only, so a local change to it surfaces as the
8937
+ // `immutable` outcome — named with its remedy instead of PATCHed into
8938
+ // a success that changes nothing — including under `--force`.
8939
+ const bucketSpec = configDiffSpec("blob-bucket");
8940
+ const liveBuckets = new Map();
8941
+ let bucketsLiveOk = true;
8942
+ if (files.length > 0) {
8943
+ try {
8944
+ const listed = await client.listBlobBuckets(resolvedAppId);
8945
+ for (const b of (listed?.items ?? [])) {
8946
+ if (b?.bucketKey)
8947
+ liveBuckets.set(b.bucketKey, b);
8948
+ }
8949
+ }
8950
+ catch (err) {
8951
+ bucketsLiveOk = false;
8952
+ warnLiveUnavailable("blob-buckets", err);
8953
+ }
8954
+ }
8955
+ const refetchLiveBucket = async (bucketId) => {
8956
+ const full = await client.getBlobBucket(resolvedAppId, bucketId);
8957
+ return full ? { record: full } : null;
8958
+ };
7643
8959
  for (const file of files) {
7644
8960
  const filePath = join(blobBucketsPushDir, file);
7645
8961
  const tomlData = parseTomlFile(filePath);
8962
+ const key = tomlData.bucket?.key || basename(file, ".toml");
8963
+ withDerivedIdentity(tomlData, "bucket", { key });
7646
8964
  const bucket = tomlData.bucket || {};
7647
- const key = bucket.key || basename(file, ".toml");
7648
- const existingId = syncState?.entities?.blobBuckets?.[key]?.id;
7649
- if (!options.force && existingId &&
7650
- !shouldPushFile(filePath, syncState?.entities?.blobBuckets?.[key]?.contentHash)) {
7651
- skippedCount++;
7652
- continue;
8965
+ const existingEntry = syncState?.entities?.blobBuckets?.[key];
8966
+ const liveBucket = liveBuckets.get(key);
8967
+ let gate;
8968
+ if (bucketsLiveOk) {
8969
+ gate = decidePushForConfig({
8970
+ spec: bucketSpec,
8971
+ maps: pushDiffMaps,
8972
+ localParsed: tomlData,
8973
+ live: liveBucket
8974
+ ? { record: liveBucket, modifiedAt: liveBucket.modifiedAt }
8975
+ : null,
8976
+ entry: existingEntry,
8977
+ localFileHash: computeFileHash(filePath),
8978
+ force: options.force,
8979
+ });
8980
+ if (gate.action === "skip") {
8981
+ skippedCount++;
8982
+ if (syncState && liveBucket?.bucketId) {
8983
+ if (!syncState.entities.blobBuckets) {
8984
+ syncState.entities.blobBuckets = {};
8985
+ }
8986
+ syncState.entities.blobBuckets[key] = {
8987
+ id: liveBucket.bucketId,
8988
+ modifiedAt: liveBucket.modifiedAt ||
8989
+ existingEntry?.modifiedAt ||
8990
+ new Date().toISOString(),
8991
+ contentHash: computeFileHash(filePath),
8992
+ semanticHash: gate.remoteHash,
8993
+ };
8994
+ }
8995
+ continue;
8996
+ }
8997
+ if (gate.action === "drift" ||
8998
+ gate.action === "conflict" ||
8999
+ gate.action === "immutable") {
9000
+ recordDeclined("blob-bucket", key, gate, existingEntry?.modifiedAt);
9001
+ continue;
9002
+ }
7653
9003
  }
9004
+ else {
9005
+ const degraded = decideDegradedPush({
9006
+ force: options.force,
9007
+ storedContentHash: existingEntry?.contentHash,
9008
+ currentFileHash: computeFileHash(filePath),
9009
+ });
9010
+ if (degraded.action === "skip") {
9011
+ skippedCount++;
9012
+ continue;
9013
+ }
9014
+ if (degraded.action === "decline") {
9015
+ recordDeclined("blob-bucket", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
9016
+ continue;
9017
+ }
9018
+ }
9019
+ const existingId = resolveExistingId({
9020
+ liveOk: bucketsLiveOk,
9021
+ liveId: liveBucket?.bucketId,
9022
+ manifestId: existingEntry?.id,
9023
+ });
7654
9024
  if (existingId) {
7655
9025
  // #1020 (D8): idempotently apply preset/ruleSet/name/description
7656
9026
  // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
@@ -7664,9 +9034,18 @@ export function registerConfigSyncCommands(sync) {
7664
9034
  // update, and only when this is not a dry run. A failed
7665
9035
  // update (or a dry run) must leave the file pending rather
7666
9036
  // than mark it in-sync and suppress it on the next push.
7667
- if (syncState?.entities?.blobBuckets?.[key]) {
7668
- syncState.entities.blobBuckets[key].contentHash =
7669
- computeFileHash(filePath);
9037
+ if (syncState) {
9038
+ if (!syncState.entities.blobBuckets) {
9039
+ syncState.entities.blobBuckets = {};
9040
+ }
9041
+ syncState.entities.blobBuckets[key] = {
9042
+ id: existingId,
9043
+ modifiedAt: existingEntry?.modifiedAt || new Date().toISOString(),
9044
+ contentHash: computeFileHash(filePath),
9045
+ // #2880 — the baseline, refetched because the PATCH
9046
+ // response is not the stored record for this type.
9047
+ semanticHash: await stampRemoteSemanticHash(bucketSpec, undefined, () => refetchLiveBucket(existingId)),
9048
+ };
7670
9049
  }
7671
9050
  }
7672
9051
  catch (err) {
@@ -7694,6 +9073,7 @@ export function registerConfigSyncCommands(sync) {
7694
9073
  id: created.bucketId,
7695
9074
  modifiedAt: created.modifiedAt || new Date().toISOString(),
7696
9075
  contentHash: computeFileHash(filePath),
9076
+ semanticHash: await stampRemoteSemanticHash(bucketSpec, created, () => refetchLiveBucket(created.bucketId)),
7697
9077
  };
7698
9078
  }
7699
9079
  }
@@ -7764,6 +9144,9 @@ export function registerConfigSyncCommands(sync) {
7764
9144
  existing.modifiedAt ||
7765
9145
  new Date().toISOString(),
7766
9146
  contentHash: computeFileHash(filePath),
9147
+ // #2880 — an adoption converges, so it stamps the
9148
+ // baseline the next run classifies against.
9149
+ semanticHash: await stampRemoteSemanticHash(bucketSpec, updated, () => refetchLiveBucket(existing.bucketId)),
7767
9150
  };
7768
9151
  }
7769
9152
  }
@@ -7881,6 +9264,15 @@ export function registerConfigSyncCommands(sync) {
7881
9264
  entry: existingEntry,
7882
9265
  localFileHash: computeFileHash(filePath),
7883
9266
  force: options.force,
9267
+ // #2909 — the prompt create path adopts an existing prompt by
9268
+ // `promptKey` on a 409 (#1006), so a prompt the manifest has
9269
+ // never recorded is that same adopt, reached one step earlier
9270
+ // because the reconcile above already read the server. Refusing
9271
+ // it here made the documented adopt unreachable: the CREATE the
9272
+ // guard recovers from is never sent when live state names the
9273
+ // prompt, so the push reported a conflict no `config push`
9274
+ // could clear.
9275
+ adoptsUntrackedByKey: true,
7884
9276
  });
7885
9277
  if (gate.action === "skip") {
7886
9278
  skippedCount++;
@@ -7917,14 +9309,26 @@ export function registerConfigSyncCommands(sync) {
7917
9309
  continue;
7918
9310
  }
7919
9311
  }
7920
- else if (!options.force &&
7921
- existingEntry?.id &&
7922
- !shouldPushFile(filePath, existingEntry.contentHash)) {
9312
+ else {
7923
9313
  // Degraded: the manifest byte-hash gate this section has always
7924
- // fallen back to when the server cannot be read.
7925
- skippedCount++;
7926
- promptKeyToId.set(key, existingEntry.id);
7927
- continue;
9314
+ // fallen back to when the server cannot be read — and, since
9315
+ // #2880, a decline when there is no baseline to gate on rather
9316
+ // than an unconditional write over unread server state.
9317
+ const degraded = decideDegradedPush({
9318
+ force: options.force,
9319
+ storedContentHash: existingEntry?.contentHash,
9320
+ currentFileHash: computeFileHash(filePath),
9321
+ });
9322
+ if (degraded.action === "skip") {
9323
+ skippedCount++;
9324
+ if (existingEntry?.id)
9325
+ promptKeyToId.set(key, existingEntry.id);
9326
+ continue;
9327
+ }
9328
+ if (degraded.action === "decline") {
9329
+ recordDeclined("prompt", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
9330
+ continue;
9331
+ }
7928
9332
  }
7929
9333
  // Live state decides create-vs-update: a prompt the manifest never
7930
9334
  // recorded is adopted, and one the manifest still names but the
@@ -8074,6 +9478,13 @@ export function registerConfigSyncCommands(sync) {
8074
9478
  changes.push({ type: "prompt", action: "update", key });
8075
9479
  promptKeyToId.set(key, existingId);
8076
9480
  if (!options.dryRun) {
9481
+ // Say so when this update is an ADOPT (#2909): the operator
9482
+ // never recorded this prompt, so a bare "Updated prompt" would
9483
+ // not tell them the push took over an object it found on the
9484
+ // server. Same sentence the 409 recovery prints.
9485
+ if (gate?.action === "update" && gate.direction === "adopt-untracked") {
9486
+ info(` Prompt already exists on server, adopting by key: ${key}`);
9487
+ }
8077
9488
  // A prompt adopted from live state has no manifest row yet, and
8078
9489
  // `applyPromptUpdate` stamps INTO that row — without it the
8079
9490
  // push would apply and record nothing, and the next run would
@@ -8466,16 +9877,25 @@ export function registerConfigSyncCommands(sync) {
8466
9877
  }
8467
9878
  : undefined;
8468
9879
  }
8469
- else if (!options.force) {
9880
+ else {
8470
9881
  // Fallback gate: the manifest byte hash. A script with no
8471
9882
  // recorded hash has no baseline at all and its apply is a blind
8472
9883
  // overwrite, so it FAILS CLOSED rather than pushing on a guess.
8473
- if (scriptEntry?.contentHash === undefined) {
8474
- warn(` transform ${name}: no last-sync content hash — skipped (run \`config pull\` to establish one, or --force to push anyway)`);
9884
+ // #2880 — through the shared decision, so every type declines
9885
+ // for the same reason in the same words.
9886
+ const degraded = decideDegradedPush({
9887
+ force: options.force,
9888
+ storedContentHash: scriptEntry?.contentHash,
9889
+ currentFileHash: computeFileHash(filePath),
9890
+ });
9891
+ if (degraded.action === "skip") {
9892
+ skippedCount++;
8475
9893
  continue;
8476
9894
  }
8477
- if (!shouldPushFile(filePath, scriptEntry.contentHash)) {
8478
- skippedCount++;
9895
+ if (degraded.action === "decline") {
9896
+ recordDeclined("transform", name, {
9897
+ action: "live-unavailable",
9898
+ });
8479
9899
  continue;
8480
9900
  }
8481
9901
  }
@@ -9225,13 +10645,22 @@ export function registerConfigSyncCommands(sync) {
9225
10645
  }
9226
10646
  }
9227
10647
  }
9228
- // Fallback gate: the manifest byte hash, unchanged from before.
9229
- if (!dbTypeGate &&
9230
- !options.force &&
9231
- existingEntry &&
9232
- !shouldPushFile(filePath, existingEntry.contentHash)) {
9233
- skippedCount++;
9234
- continue;
10648
+ // Fallback gate: the manifest byte hash — and, since #2880, a
10649
+ // decline when there is no hash to gate on.
10650
+ if (!dbTypeGate) {
10651
+ const degraded = decideDegradedPush({
10652
+ force: options.force,
10653
+ storedContentHash: existingEntry?.contentHash,
10654
+ currentFileHash: computeFileHash(filePath),
10655
+ });
10656
+ if (degraded.action === "skip") {
10657
+ skippedCount++;
10658
+ continue;
10659
+ }
10660
+ if (degraded.action === "decline") {
10661
+ recordDeclined("database-type-config", dbType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
10662
+ continue;
10663
+ }
9235
10664
  }
9236
10665
  // ── Issues #813 / #1336 / #2732: state what this push's operation
9237
10666
  // set will BE, then run a validate-first pass before any mutating
@@ -10379,9 +11808,22 @@ export function registerConfigSyncCommands(sync) {
10379
11808
  continue;
10380
11809
  }
10381
11810
  }
10382
- else if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
10383
- skippedCount++;
10384
- continue;
11811
+ else {
11812
+ // Fallback gate: the manifest byte hash — and, since #2880, a
11813
+ // decline rather than a blind write when there is no hash.
11814
+ const degraded = decideDegradedPush({
11815
+ force: options.force,
11816
+ storedContentHash: existingEntry?.contentHash,
11817
+ currentFileHash: computeFileHash(filePath),
11818
+ });
11819
+ if (degraded.action === "skip") {
11820
+ skippedCount++;
11821
+ continue;
11822
+ }
11823
+ if (degraded.action === "decline") {
11824
+ recordDeclined("group-type-config", groupType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
11825
+ continue;
11826
+ }
10385
11827
  }
10386
11828
  // Live state decides create-vs-update: a config the manifest never
10387
11829
  // recorded is adopted, and one the manifest still names but the
@@ -10576,9 +12018,22 @@ export function registerConfigSyncCommands(sync) {
10576
12018
  continue;
10577
12019
  }
10578
12020
  }
10579
- else if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
10580
- skippedCount++;
10581
- continue;
12021
+ else {
12022
+ // Fallback gate: the manifest byte hash — and, since #2880, a
12023
+ // decline rather than a blind write when there is no hash.
12024
+ const degraded = decideDegradedPush({
12025
+ force: options.force,
12026
+ storedContentHash: existingEntry?.contentHash,
12027
+ currentFileHash: computeFileHash(filePath),
12028
+ });
12029
+ if (degraded.action === "skip") {
12030
+ skippedCount++;
12031
+ continue;
12032
+ }
12033
+ if (degraded.action === "decline") {
12034
+ recordDeclined("collection-type-config", collectionType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
12035
+ continue;
12036
+ }
10582
12037
  }
10583
12038
  // Live state decides create-vs-update; see the group-type section.
10584
12039
  if (resolveExistingId({
@@ -10763,9 +12218,22 @@ export function registerConfigSyncCommands(sync) {
10763
12218
  continue;
10764
12219
  }
10765
12220
  }
10766
- else if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
10767
- skippedCount++;
10768
- continue;
12221
+ else {
12222
+ // Fallback gate: the manifest byte hash — and, since #2880, a
12223
+ // decline rather than a blind write when there is no hash.
12224
+ const degraded = decideDegradedPush({
12225
+ force: options.force,
12226
+ storedContentHash: existingEntry?.contentHash,
12227
+ currentFileHash: computeFileHash(filePath),
12228
+ });
12229
+ if (degraded.action === "skip") {
12230
+ skippedCount++;
12231
+ continue;
12232
+ }
12233
+ if (degraded.action === "decline") {
12234
+ recordDeclined("metadata-category-config", entityKey, { action: "live-unavailable" }, existingEntry?.modifiedAt);
12235
+ continue;
12236
+ }
10769
12237
  }
10770
12238
  const isCreate = !resolveExistingId({
10771
12239
  liveOk: metadataCategoryLiveOk,
@@ -10903,17 +12371,23 @@ export function registerConfigSyncCommands(sync) {
10903
12371
  continue;
10904
12372
  }
10905
12373
  }
10906
- else if (!options.force) {
12374
+ else {
10907
12375
  // Fallback: the byte-hash gate. A legacy entry has no
10908
12376
  // `contentHash` to compare against, and this type's apply is a
10909
12377
  // blind overwrite of whatever the server holds — so it FAILS
10910
- // CLOSED (warned and skipped) rather than upserting on a guess.
10911
- if (!existingEntry?.contentHash) {
10912
- warn(` email-template ${emailType}: no last-sync content hash — skipped (run \`config pull\` to establish one, or --force to push anyway)`);
12378
+ // CLOSED rather than upserting on a guess (#2880: through the
12379
+ // shared decision, in the same words as every other type).
12380
+ const degraded = decideDegradedPush({
12381
+ force: options.force,
12382
+ storedContentHash: existingEntry?.contentHash,
12383
+ currentFileHash: computeFileHash(filePath),
12384
+ });
12385
+ if (degraded.action === "skip") {
12386
+ skippedCount++;
10913
12387
  continue;
10914
12388
  }
10915
- if (!shouldPushFile(filePath, existingEntry.contentHash)) {
10916
- skippedCount++;
12389
+ if (degraded.action === "decline") {
12390
+ recordDeclined("email-template", emailType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
10917
12391
  continue;
10918
12392
  }
10919
12393
  }
@@ -10995,8 +12469,12 @@ export function registerConfigSyncCommands(sync) {
10995
12469
  }
10996
12470
  // Push test cases for every test-bearing block (#2769: integrations
10997
12471
  // joined the three that already had a sidecar).
10998
- const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
10999
- const integrationConfigNameToId = new Map(); // "integrationKey#configName" → configId
12472
+ //
12473
+ // #2880 — every block that could name a config has been pushed by now,
12474
+ // so the id→name direction the comparison reads references through has
12475
+ // everything this push learned. It is refilled per case as the lazy
12476
+ // per-block config lookups below resolve more of it.
12477
+ fillTestCaseLookupMaps();
11000
12478
  /** The id the last sync recorded for a block, whatever this push selected. */
11001
12479
  const recordedBlockId = (blockType, blockKey) => {
11002
12480
  const slot = blockType === "prompt"
@@ -11092,6 +12570,8 @@ export function registerConfigSyncCommands(sync) {
11092
12570
  client, appId: resolvedAppId, blockType: "prompt", blockId,
11093
12571
  blockKey: promptKey, configDir, syncState,
11094
12572
  dryRun: options.dryRun, changes, failures: applyFailures,
12573
+ declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
12574
+ refreshLookupMaps: fillTestCaseLookupMaps,
11095
12575
  resolutionMaps: pushMaps, options: { force: options.force },
11096
12576
  })).skipped;
11097
12577
  }
@@ -11108,6 +12588,8 @@ export function registerConfigSyncCommands(sync) {
11108
12588
  client, appId: resolvedAppId, blockType: "workflow", blockId,
11109
12589
  blockKey: workflowKey, configDir, syncState,
11110
12590
  dryRun: options.dryRun, changes, failures: applyFailures,
12591
+ declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
12592
+ refreshLookupMaps: fillTestCaseLookupMaps,
11111
12593
  resolutionMaps: pushMaps, options: { force: options.force },
11112
12594
  })).skipped;
11113
12595
  }
@@ -11134,6 +12616,8 @@ export function registerConfigSyncCommands(sync) {
11134
12616
  client, appId: resolvedAppId, blockType: "script", blockId,
11135
12617
  blockKey: scriptName, configDir, syncState,
11136
12618
  dryRun: options.dryRun, changes, failures: applyFailures,
12619
+ declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
12620
+ refreshLookupMaps: fillTestCaseLookupMaps,
11137
12621
  resolutionMaps: pushMaps, options: { force: options.force },
11138
12622
  })).skipped;
11139
12623
  }
@@ -11156,6 +12640,8 @@ export function registerConfigSyncCommands(sync) {
11156
12640
  client, appId: resolvedAppId, blockType: "integration", blockId,
11157
12641
  blockKey: integrationKey, configDir, syncState,
11158
12642
  dryRun: options.dryRun, changes, failures: applyFailures,
12643
+ declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
12644
+ refreshLookupMaps: fillTestCaseLookupMaps,
11159
12645
  resolutionMaps: pushMaps, options: { force: options.force },
11160
12646
  })).skipped;
11161
12647
  }
@@ -11681,6 +13167,10 @@ export function registerConfigSyncCommands(sync) {
11681
13167
  const localEmailTemplatesParsed = new Map();
11682
13168
  // #2757 — the same, for webhooks.
11683
13169
  const localWebhooksParsed = new Map();
13170
+ // #2880 — the same, for integrations, cron triggers and blob buckets.
13171
+ const localIntegrationsParsed = new Map();
13172
+ const localCronTriggersParsed = new Map();
13173
+ const localBlobBucketsParsed = new Map();
11684
13174
  // #2748 — the same, for prompts.
11685
13175
  const localPromptsParsed = new Map();
11686
13176
  // Parsed local TOML per key for the five content-aware config types.
@@ -11695,6 +13185,16 @@ export function registerConfigSyncCommands(sync) {
11695
13185
  const tomlData = parseTomlFile(join(integrationsDir, file));
11696
13186
  const key = tomlData.integration?.key || basename(file, ".toml");
11697
13187
  localIntegrations.add(key);
13188
+ // #2880 — the parsed file is kept so the row can be COMPARED. The
13189
+ // resolved key is injected back the way webhooks and prompts inject
13190
+ // theirs: `[integration].key` is optional (push falls back to the
13191
+ // file name) and the comparison reads it as `displayName`'s
13192
+ // fallback, so a file that omits it would otherwise report Modified
13193
+ // forever — even right after a successful push.
13194
+ if (!tomlData.integration)
13195
+ tomlData.integration = {};
13196
+ tomlData.integration.key = key;
13197
+ localIntegrationsParsed.set(key, tomlData);
11698
13198
  }
11699
13199
  }
11700
13200
  const webhooksDirPath = join(configDir, "webhooks");
@@ -11721,6 +13221,12 @@ export function registerConfigSyncCommands(sync) {
11721
13221
  const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
11722
13222
  const key = tomlData.cronTrigger?.key || basename(file, ".toml");
11723
13223
  localCronTriggers.add(key);
13224
+ // #2880 — kept parsed so the row can be COMPARED, with the resolved
13225
+ // key injected back the way every other type injects its identity.
13226
+ if (!tomlData.cronTrigger)
13227
+ tomlData.cronTrigger = {};
13228
+ tomlData.cronTrigger.key = key;
13229
+ localCronTriggersParsed.set(key, tomlData);
11724
13230
  }
11725
13231
  }
11726
13232
  const blobBucketsDiffDir = join(configDir, "blob-buckets");
@@ -11729,6 +13235,11 @@ export function registerConfigSyncCommands(sync) {
11729
13235
  const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
11730
13236
  const key = tomlData.bucket?.key || basename(file, ".toml");
11731
13237
  localBlobBuckets.add(key);
13238
+ // #2880 — as above.
13239
+ if (!tomlData.bucket)
13240
+ tomlData.bucket = {};
13241
+ tomlData.bucket.key = key;
13242
+ localBlobBucketsParsed.set(key, tomlData);
11732
13243
  }
11733
13244
  }
11734
13245
  const promptsDir = join(configDir, "prompts");
@@ -11923,22 +13434,57 @@ export function registerConfigSyncCommands(sync) {
11923
13434
  // left alone. Prior sync state is the discriminator, mirroring the
11924
13435
  // local-only split above.
11925
13436
  const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
11926
- // Integrations
13437
+ // Integrations — #2880: content-aware, mirroring the webhook block.
13438
+ //
13439
+ // Existence-only comparison meant an edited `accessRule` or upstream
13440
+ // `[requestConfig]` read as Synced while `config push` applied it from
13441
+ // the file's byte hash. The detail is fetched per integration because
13442
+ // `accessRule` and `requestConfig` are on the detail only, which is
13443
+ // also what `config pull` writes from; a per-integration fetch failure
13444
+ // degrades that ONE row to "content not compared".
13445
+ const integrationSpec = configDiffSpec("integration");
13446
+ // Integrations carry no rule-set reference, so the resolution maps the
13447
+ // shared projection takes are empty for this type.
13448
+ const integrationDiffMaps = {
13449
+ ruleSetIdToName: new Map(),
13450
+ ruleSetNameToId: new Map(),
13451
+ };
11927
13452
  const remoteIntegrationRecords = new Map(integrationItems.map((i) => [i.integrationKey, i]));
11928
13453
  for (const key of localIntegrations) {
11929
13454
  if (!remoteIntegrations.has(key)) {
11930
13455
  differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
13456
+ continue;
13457
+ }
13458
+ let status = "exists";
13459
+ let hint;
13460
+ const localParsed = localIntegrationsParsed.get(key);
13461
+ const integrationId = remoteIntegrationRecords.get(key)?.integrationId;
13462
+ const detailOutcome = await attemptFetch(async () => integrationId
13463
+ ? await client.getIntegration(resolvedAppId, integrationId)
13464
+ : null);
13465
+ if (!detailOutcome.ok || !detailOutcome.value || !localParsed) {
13466
+ hint = "content not compared (fetch failed)";
11931
13467
  }
11932
13468
  else {
11933
- // #2803 — availability is operator state, not drift, and it is the
11934
- // same annotation every carrying type gets.
11935
- differences.push({
11936
- type: "integration",
11937
- key,
11938
- status: "exists",
11939
- hint: outOfServiceHint(remoteIntegrationRecords.get(key)),
11940
- });
13469
+ try {
13470
+ const remoteHash = hashRemoteConfigForDiff(integrationSpec, detailOutcome.value, integrationDiffMaps);
13471
+ const localHash = hashLocalConfigForDiff(integrationSpec, localParsed, integrationDiffMaps);
13472
+ if (remoteHash !== localHash) {
13473
+ status = "modified";
13474
+ hint = "run `config pull` to rewrite the local TOML to match running state";
13475
+ }
13476
+ }
13477
+ catch {
13478
+ hint = "content not compared (comparison failed)";
13479
+ }
11941
13480
  }
13481
+ // #2803 — availability is operator state, not drift, and it is the
13482
+ // same annotation every carrying type gets, appended to whatever the
13483
+ // content comparison already had to say.
13484
+ hint = outOfServiceHint(detailOutcome.ok && detailOutcome.value
13485
+ ? detailOutcome.value
13486
+ : remoteIntegrationRecords.get(key), hint);
13487
+ differences.push({ type: "integration", key, status, hint });
11942
13488
  }
11943
13489
  for (const key of remoteIntegrations) {
11944
13490
  if (!localIntegrations.has(key)) {
@@ -11980,17 +13526,11 @@ export function registerConfigSyncCommands(sync) {
11980
13526
  hint = "content not compared (fetch failed)";
11981
13527
  }
11982
13528
  else {
11983
- try {
11984
- const remoteHash = hashRemoteConfigForDiff(webhookSpec, detailOutcome.value, webhookDiffMaps);
11985
- const localHash = hashLocalConfigForDiff(webhookSpec, localParsed, webhookDiffMaps);
11986
- if (remoteHash !== localHash) {
11987
- status = "modified";
11988
- hint = "run `config pull` to rewrite the local TOML to match running state";
11989
- }
11990
- }
11991
- catch {
11992
- hint = "content not compared (comparison failed)";
11993
- }
13529
+ // Through the shared verdict, so the file's unmanaged fields are
13530
+ // read here exactly as the push gate reads them (#2880).
13531
+ const verdict = compareLocalToRemote(webhookSpec, localParsed, detailOutcome.value, webhookDiffMaps);
13532
+ status = verdict.status;
13533
+ hint = verdict.hint;
11994
13534
  }
11995
13535
  // #2803 — a paused/archived webhook is operator state, appended to
11996
13536
  // whatever the content comparison already had to say.
@@ -12002,35 +13542,43 @@ export function registerConfigSyncCommands(sync) {
12002
13542
  differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
12003
13543
  }
12004
13544
  }
12005
- // Cron Triggers
13545
+ // Cron Triggers — #2880: content-aware. The LIST row is the whole
13546
+ // record for this type (it is what `config pull` serializes from), so
13547
+ // no second fetch is needed; a comparison that throws degrades that ONE
13548
+ // row rather than the diff.
13549
+ const cronSpec = configDiffSpec("cron-trigger");
12006
13550
  const remoteCronTriggerRecords = new Map(cronTriggerItemsDiff.map((t) => [t.triggerKey, t]));
12007
13551
  for (const key of localCronTriggers) {
12008
13552
  if (!remoteCronTriggers.has(key)) {
12009
13553
  differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
13554
+ continue;
12010
13555
  }
12011
- else {
12012
- // #2645 criterion 9 — a paused trigger is operator state, not drift.
12013
- differences.push({
12014
- type: "cron-trigger",
12015
- key,
12016
- status: "exists",
12017
- hint: outOfServiceHint(remoteCronTriggerRecords.get(key)),
12018
- });
12019
- }
13556
+ const { status, hint } = compareLocalToRemote(cronSpec, localCronTriggersParsed.get(key), remoteCronTriggerRecords.get(key));
13557
+ differences.push({
13558
+ type: "cron-trigger",
13559
+ key,
13560
+ status,
13561
+ // #2645 criterion 9 — a paused trigger is operator state, not
13562
+ // drift, appended to whatever the content comparison had to say.
13563
+ hint: outOfServiceHint(remoteCronTriggerRecords.get(key), hint),
13564
+ });
12020
13565
  }
12021
13566
  for (const key of remoteCronTriggers) {
12022
13567
  if (!localCronTriggers.has(key)) {
12023
13568
  differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
12024
13569
  }
12025
13570
  }
12026
- // Blob Buckets
13571
+ // Blob Buckets — #2880: the same, and the list row is the record here
13572
+ // too.
13573
+ const blobBucketSpec = configDiffSpec("blob-bucket");
13574
+ const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
12027
13575
  for (const key of localBlobBuckets) {
12028
13576
  if (!remoteBlobBuckets.has(key)) {
12029
13577
  differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
13578
+ continue;
12030
13579
  }
12031
- else {
12032
- differences.push({ type: "blob-bucket", key, status: "exists" });
12033
- }
13580
+ const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
13581
+ differences.push({ type: "blob-bucket", key, status, hint });
12034
13582
  }
12035
13583
  for (const key of remoteBlobBuckets) {
12036
13584
  if (!localBlobBuckets.has(key)) {
@@ -12048,6 +13596,22 @@ export function registerConfigSyncCommands(sync) {
12048
13596
  // fetches the detail `config pull` writes from. A failed fetch degrades
12049
13597
  // that ONE row to "content not compared", never the whole diff.
12050
13598
  const promptSpec = configDiffSpec("prompt");
13599
+ /**
13600
+ * The id→name lookups a test case's references are read through
13601
+ * (#2880). Filled from the prompt details this loop already fetches —
13602
+ * a second listing per block would double the diff's request count to
13603
+ * learn what is already on the wire.
13604
+ */
13605
+ const diffTestCaseLookupMaps = {
13606
+ configIdToName: new Map(),
13607
+ promptIdToKey: new Map(),
13608
+ };
13609
+ for (const p of promptItems) {
13610
+ const key = p?.promptKey;
13611
+ const id = p?.promptId;
13612
+ if (key && id)
13613
+ diffTestCaseLookupMaps.promptIdToKey.set(id, key);
13614
+ }
12051
13615
  // Prompts carry no rule-set reference, so the resolution maps the shared
12052
13616
  // projection takes are empty for this type.
12053
13617
  const promptDiffMaps = {
@@ -12069,6 +13633,13 @@ export function registerConfigSyncCommands(sync) {
12069
13633
  hint = "content not compared (fetch failed)";
12070
13634
  }
12071
13635
  else {
13636
+ // #2880 — the prompt's configs, for the test-case references that
13637
+ // name them.
13638
+ for (const config of detailOutcome.value.configs ?? []) {
13639
+ if (config?.configId && config?.configName) {
13640
+ diffTestCaseLookupMaps.configIdToName.set(config.configId, config.configName);
13641
+ }
13642
+ }
12072
13643
  try {
12073
13644
  const remoteHash = hashRemoteConfigForDiff(promptSpec, detailOutcome.value, promptDiffMaps);
12074
13645
  const localHash = hashLocalConfigForDiff(promptSpec, localParsed, promptDiffMaps);
@@ -12440,6 +14011,10 @@ export function registerConfigSyncCommands(sync) {
12440
14011
  blockId,
12441
14012
  blockKey,
12442
14013
  configDir,
14014
+ // #2880 — identity is the manifest id, and the attachment plan
14015
+ // reads the hashes the manifest recorded.
14016
+ syncState,
14017
+ lookupMaps: diffTestCaseLookupMaps,
12443
14018
  });
12444
14019
  if (!outcome.ok) {
12445
14020
  // A block whose listing failed is REPORTED, not skipped in silence.
@@ -12482,6 +14057,96 @@ export function registerConfigSyncCommands(sync) {
12482
14057
  await compareTestCases("integration", i.integrationId, key);
12483
14058
  }
12484
14059
  }
14060
+ // App settings — #2880 behavior 9, subsuming #2875.
14061
+ //
14062
+ // The whole-tree diff used not to compare `app.toml` AT ALL, so an
14063
+ // edited file reported every count as zero: "nothing to push" printed
14064
+ // over a push that would change the running app. `--only app` had the
14065
+ // per-field comparison the whole time; this is that same helper, on the
14066
+ // command an operator actually runs to ask whether anything differs.
14067
+ //
14068
+ // Compared over the FULL writable descriptor set, because push applies
14069
+ // the file as the whole truth (criterion 4): a key the file omits is
14070
+ // the clear push will send, not an absence of opinion. The settings are
14071
+ // fetched only when there is a local file to compare and the selection
14072
+ // asks for it, so an unselected `app.toml` costs no request.
14073
+ const appTomlPathForDiff = join(configDir, "app.toml");
14074
+ if (existsSync(appTomlPathForDiff) &&
14075
+ selectionIncludesApp(diffSelection)) {
14076
+ const appRow = (status, hint) => differences.push({
14077
+ type: "app-settings",
14078
+ key: "app.toml",
14079
+ status,
14080
+ hint,
14081
+ });
14082
+ let appTomlData;
14083
+ try {
14084
+ appTomlData = parseTomlFile(appTomlPathForDiff);
14085
+ }
14086
+ catch (err) {
14087
+ appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
14088
+ }
14089
+ if (appTomlData) {
14090
+ const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData);
14091
+ // The SAME validator push's preflight runs (criterion 1): a
14092
+ // declared-type mismatch, an unrecognized or retired key, and a
14093
+ // missing required key are reported here with push's own message
14094
+ // rather than compared past and called Synced.
14095
+ const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, { excludeName });
14096
+ if (appErrors.length > 0) {
14097
+ appRow(VALIDATION_ERROR, appErrors.join("; "));
14098
+ }
14099
+ else {
14100
+ const settingsOutcome = await attemptFetch(() => client.getAppSettings(resolvedAppId));
14101
+ if (!settingsOutcome.ok) {
14102
+ // #1485's rule, for this type: a failed read is not "matches".
14103
+ notCompared.push({
14104
+ type: "app-settings",
14105
+ reason: gateReason(settingsOutcome.error),
14106
+ });
14107
+ }
14108
+ else {
14109
+ const fieldDiffs = appSettingsFieldDiffs(localAppSettings, settingsOutcome.value ?? {}, { excludeName });
14110
+ if (fieldDiffs.length === 0) {
14111
+ appRow("exists");
14112
+ }
14113
+ else {
14114
+ appRow("modified", `${fieldDiffs.length} field(s) differ: ` +
14115
+ fieldDiffs
14116
+ .map((d) => `[${d.section}].${d.key}`)
14117
+ .join(", ") +
14118
+ " — `config diff --only app` shows the values");
14119
+ }
14120
+ }
14121
+ }
14122
+ }
14123
+ }
14124
+ // #2880 criterion 1 (symmetry) — a file `config push` would refuse is
14125
+ // not a file this command calls Synced.
14126
+ //
14127
+ // The comparison above can only report a difference it can SEE, and a
14128
+ // local key with no server counterpart is invisible to it: the
14129
+ // reporter's prompts still carrying the `[prompt].status` #2803 retired
14130
+ // compared clean field for field while push refused the whole tree. So
14131
+ // the same preflight push runs decides these rows, and its message —
14132
+ // verbatim, so the two commands read identically — replaces whatever
14133
+ // the comparison had to say about the file.
14134
+ for (const rejected of collectConfigFileValidationErrors(configDir)) {
14135
+ const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
14136
+ const hint = rejected.messages.join("; ");
14137
+ if (row) {
14138
+ row.status = VALIDATION_ERROR;
14139
+ row.hint = hint;
14140
+ }
14141
+ else {
14142
+ differences.push({
14143
+ type: rejected.type,
14144
+ key: rejected.key,
14145
+ status: VALIDATION_ERROR,
14146
+ hint,
14147
+ });
14148
+ }
14149
+ }
12485
14150
  // `--only` (issue #2645) reports just the objects the operator named.
12486
14151
  // The rows are filtered rather than the fetches: every leg keeps its own
12487
14152
  // failure handling, so a type whose listing failed is still reported as
@@ -12489,7 +14154,13 @@ export function registerConfigSyncCommands(sync) {
12489
14154
  if (diffSelection) {
12490
14155
  differences = differences.filter((row) => row.type === "var"
12491
14156
  ? diffSelection.varKeys.has(row.key)
12492
- : selectionIncludesKey(diffSelection, row.type, row.key));
14157
+ : // #2880 — `app.toml` is a root singleton, outside
14158
+ // `SYNC_RESOURCE_TYPES`, so it has its own selection predicate
14159
+ // (the row is only built when this is true, but the filter is
14160
+ // what makes that visible where selection is decided).
14161
+ row.type === "app-settings"
14162
+ ? selectionIncludesApp(diffSelection)
14163
+ : selectionIncludesKey(diffSelection, row.type, row.key));
12493
14164
  }
12494
14165
  divider();
12495
14166
  const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
@@ -12501,6 +14172,23 @@ export function registerConfigSyncCommands(sync) {
12501
14172
  const remoteOnlyUnmanaged = differences.filter((d) => d.status === REMOTE_ONLY_UNMANAGED);
12502
14173
  const modified = differences.filter((d) => d.status === "modified");
12503
14174
  const existing = differences.filter((d) => d.status === "exists");
14175
+ // #2880 — files push would refuse. Reported before anything else: no
14176
+ // other row's status is trustworthy while a file in the tree cannot be
14177
+ // pushed at all.
14178
+ const invalid = differences.filter((d) => d.status === VALIDATION_ERROR);
14179
+ // #2880 — the same messages `config push` aborts with, so a clean diff
14180
+ // can be read as "nothing to push" and a dirty one names the fix.
14181
+ if (invalid.length > 0) {
14182
+ warn("Invalid — `config push` will refuse these (not compared):");
14183
+ for (const d of invalid) {
14184
+ console.log(` ${chalk.red("✗")} ${d.type}: ${d.key}`);
14185
+ for (const message of (d.hint ?? "").split("; ")) {
14186
+ if (message)
14187
+ console.log(` ${message}`);
14188
+ }
14189
+ }
14190
+ console.log();
14191
+ }
12504
14192
  if (localOnly.length > 0) {
12505
14193
  info("Local only — new (will be created on push):");
12506
14194
  for (const d of localOnly) {
@@ -12571,17 +14259,35 @@ export function registerConfigSyncCommands(sync) {
12571
14259
  const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
12572
14260
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
12573
14261
  const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
12574
- if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
14262
+ // #2880 — sidecars whose CONTENT differs, and sidecars push would
14263
+ // refuse. Both used to read as "= test-case: …", because the comparison
14264
+ // stopped at whether the slug existed on both sides.
14265
+ const tcModified = testCaseDiffs.filter((d) => d.status === "modified");
14266
+ const tcInvalid = testCaseDiffs.filter((d) => d.status === VALIDATION_ERROR);
14267
+ if (tcLocalOnly.length > 0 ||
14268
+ tcRemoteOnly.length > 0 ||
14269
+ tcModified.length > 0 ||
14270
+ tcInvalid.length > 0) {
12575
14271
  console.log();
12576
14272
  info("Test Cases:");
14273
+ for (const d of tcInvalid) {
14274
+ console.log(` ${chalk.red("✗")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
14275
+ if (d.hint)
14276
+ console.log(` ${d.hint}`);
14277
+ }
12577
14278
  for (const d of tcLocalOnly) {
12578
14279
  console.log(` ${chalk.green("+")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
12579
14280
  }
12580
14281
  for (const d of tcRemoteOnly) {
12581
14282
  console.log(` ${chalk.red("-")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
12582
14283
  }
14284
+ for (const d of tcModified) {
14285
+ const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
14286
+ console.log(` ${chalk.yellow("~")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
14287
+ }
12583
14288
  for (const d of tcSynced) {
12584
- console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
14289
+ const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
14290
+ console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
12585
14291
  }
12586
14292
  }
12587
14293
  divider();
@@ -12591,13 +14297,20 @@ export function registerConfigSyncCommands(sync) {
12591
14297
  keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
12592
14298
  keyValue("Modified", modified.length);
12593
14299
  keyValue("Synced", existing.length);
14300
+ if (invalid.length > 0) {
14301
+ keyValue("Invalid (push would refuse)", invalid.length);
14302
+ }
12594
14303
  if (notCompared.length > 0) {
12595
14304
  keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
12596
14305
  }
12597
14306
  if (testCaseDiffs.length > 0) {
12598
14307
  keyValue("Test Cases (local only)", tcLocalOnly.length);
12599
14308
  keyValue("Test Cases (remote only)", tcRemoteOnly.length);
14309
+ keyValue("Test Cases (modified)", tcModified.length);
12600
14310
  keyValue("Test Cases (synced)", tcSynced.length);
14311
+ if (tcInvalid.length > 0) {
14312
+ keyValue("Test Cases (push would refuse)", tcInvalid.length);
14313
+ }
12601
14314
  }
12602
14315
  }
12603
14316
  catch (err) {