primitive-admin 1.2.0-alpha.6 → 1.2.0-alpha.8

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 (110) hide show
  1. package/README.md +9 -3
  2. package/assets/skill/skills/primitive-platform/SKILL.md +12 -6
  3. package/dist/src/commands/apps-children.d.ts +1 -1
  4. package/dist/src/commands/apps-children.js +172 -28
  5. package/dist/src/commands/apps-children.js.map +1 -1
  6. package/dist/src/commands/auth.js +147 -38
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/config.js +43 -4
  9. package/dist/src/commands/config.js.map +1 -1
  10. package/dist/src/commands/database-types.js +313 -13
  11. package/dist/src/commands/database-types.js.map +1 -1
  12. package/dist/src/commands/databases.js +143 -54
  13. package/dist/src/commands/databases.js.map +1 -1
  14. package/dist/src/commands/documents.js +70 -11
  15. package/dist/src/commands/documents.js.map +1 -1
  16. package/dist/src/commands/init.d.ts +7 -0
  17. package/dist/src/commands/init.js +9 -1
  18. package/dist/src/commands/init.js.map +1 -1
  19. package/dist/src/commands/llm.js +6 -9
  20. package/dist/src/commands/llm.js.map +1 -1
  21. package/dist/src/commands/settings.d.ts +9 -12
  22. package/dist/src/commands/settings.js +79 -31
  23. package/dist/src/commands/settings.js.map +1 -1
  24. package/dist/src/commands/sync-app-settings.d.ts +12 -2
  25. package/dist/src/commands/sync-app-settings.js +35 -10
  26. package/dist/src/commands/sync-app-settings.js.map +1 -1
  27. package/dist/src/commands/sync.d.ts +118 -6
  28. package/dist/src/commands/sync.js +638 -65
  29. package/dist/src/commands/sync.js.map +1 -1
  30. package/dist/src/lib/api-client.d.ts +25 -6
  31. package/dist/src/lib/api-client.js +106 -72
  32. package/dist/src/lib/api-client.js.map +1 -1
  33. package/dist/src/lib/app-settings-descriptor.d.ts +38 -0
  34. package/dist/src/lib/app-settings-descriptor.js +96 -4
  35. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  36. package/dist/src/lib/child-apps-local.d.ts +45 -21
  37. package/dist/src/lib/child-apps-local.js +68 -30
  38. package/dist/src/lib/child-apps-local.js.map +1 -1
  39. package/dist/src/lib/credentials-home.d.ts +51 -0
  40. package/dist/src/lib/credentials-home.js +169 -0
  41. package/dist/src/lib/credentials-home.js.map +1 -0
  42. package/dist/src/lib/credentials-lock.d.ts +80 -0
  43. package/dist/src/lib/credentials-lock.js +222 -0
  44. package/dist/src/lib/credentials-lock.js.map +1 -0
  45. package/dist/src/lib/credentials-store.d.ts +92 -5
  46. package/dist/src/lib/credentials-store.js +377 -53
  47. package/dist/src/lib/credentials-store.js.map +1 -1
  48. package/dist/src/lib/env-resolver-core.d.ts +1 -1
  49. package/dist/src/lib/env-resolver-core.js.map +1 -1
  50. package/dist/src/lib/function-bundle.d.ts +38 -18
  51. package/dist/src/lib/function-bundle.js +60 -35
  52. package/dist/src/lib/function-bundle.js.map +1 -1
  53. package/dist/src/lib/function-collect.d.ts +1 -1
  54. package/dist/src/lib/function-collect.js +4 -0
  55. package/dist/src/lib/function-collect.js.map +1 -1
  56. package/dist/src/lib/function-document-types.d.ts +5 -3
  57. package/dist/src/lib/function-document-types.js +10 -14
  58. package/dist/src/lib/function-document-types.js.map +1 -1
  59. package/dist/src/lib/function-source-roots.d.ts +67 -0
  60. package/dist/src/lib/function-source-roots.js +148 -0
  61. package/dist/src/lib/function-source-roots.js.map +1 -0
  62. package/dist/src/lib/function-sync.d.ts +27 -0
  63. package/dist/src/lib/function-sync.js +16 -3
  64. package/dist/src/lib/function-sync.js.map +1 -1
  65. package/dist/src/lib/function-triggers.d.ts +3 -15
  66. package/dist/src/lib/function-triggers.js +10 -72
  67. package/dist/src/lib/function-triggers.js.map +1 -1
  68. package/dist/src/lib/function-typecheck.d.ts +37 -2
  69. package/dist/src/lib/function-typecheck.js +210 -28
  70. package/dist/src/lib/function-typecheck.js.map +1 -1
  71. package/dist/src/lib/generated-config-surfaces.d.ts +148 -0
  72. package/dist/src/lib/generated-config-surfaces.js +229 -35
  73. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  74. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  75. package/dist/src/lib/generated-sdk-types.js +1 -1
  76. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  77. package/dist/src/lib/local-state.d.ts +21 -6
  78. package/dist/src/lib/local-state.js +78 -34
  79. package/dist/src/lib/local-state.js.map +1 -1
  80. package/dist/src/lib/login-scope-lines.d.ts +1 -1
  81. package/dist/src/lib/login-scope-lines.js +1 -1
  82. package/dist/src/lib/login-scope-lines.js.map +1 -1
  83. package/dist/src/lib/project-config.d.ts +7 -0
  84. package/dist/src/lib/project-config.js +22 -1
  85. package/dist/src/lib/project-config.js.map +1 -1
  86. package/dist/src/lib/push-failure-report.d.ts +45 -0
  87. package/dist/src/lib/push-failure-report.js +57 -0
  88. package/dist/src/lib/push-failure-report.js.map +1 -0
  89. package/dist/src/lib/root-import-decision.d.ts +9 -4
  90. package/dist/src/lib/root-import-decision.js +24 -3
  91. package/dist/src/lib/root-import-decision.js.map +1 -1
  92. package/dist/src/lib/scope-request.d.ts +8 -5
  93. package/dist/src/lib/scope-request.js +17 -8
  94. package/dist/src/lib/scope-request.js.map +1 -1
  95. package/dist/src/lib/sync-selectors.d.ts +5 -1
  96. package/dist/src/lib/sync-selectors.js +23 -13
  97. package/dist/src/lib/sync-selectors.js.map +1 -1
  98. package/dist/src/lib/transient-retry.d.ts +9 -0
  99. package/dist/src/lib/transient-retry.js +37 -0
  100. package/dist/src/lib/transient-retry.js.map +1 -1
  101. package/dist/src/lib/vars-override.d.ts +105 -0
  102. package/dist/src/lib/vars-override.js +143 -0
  103. package/dist/src/lib/vars-override.js.map +1 -0
  104. package/package.json +2 -2
  105. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  106. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  107. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  108. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  109. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  110. package/dist/src/lib/workflow-field-descriptor.js.map +0 -1
@@ -13,6 +13,9 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
13
13
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
14
14
  import { getCurrentAppId, getServerUrl } from "../lib/config.js";
15
15
  import { requireCurrentEnvironment } from "../lib/env-resolver.js";
16
+ import { ensureFileIgnored } from "../lib/local-state.js";
17
+ import { PROJECT_TREE_DIR } from "../lib/env-resolver-core.js";
18
+ import { cachedVarsNote, layerVars, planVarsOverridePull, varsOverridePath as resolveVarsOverridePath, varsOverrideSummaryLine, VARS_OVERRIDE_GITIGNORE_PATTERN, } from "../lib/vars-override.js";
16
19
  import { resolveSyncDir, resolveSnapshotsRoot, resolveSyncStateReadPath, resolveSyncStateWritePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
17
20
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
18
21
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
@@ -23,7 +26,7 @@ import { canonicalizeServerTextFields } from "../lib/server-text-normalization.j
23
26
  import { canonicalJsonStringify } from "../lib/canonical-json.js";
24
27
  import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
25
28
  import { newPullWriteTally, writePulledFile, } from "../lib/pull-write.js";
26
- import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
29
+ import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, settingsProtectionLevel, } from "../lib/app-settings-descriptor.js";
27
30
  import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
28
31
  import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
29
32
  // #2645 — named config bodies live in `workflows/<key>.configs/<name>.toml`.
@@ -48,12 +51,15 @@ import { AGENT_TEST_CASE_FIELDS, resolveAgentTestCase, } from "../lib/generated-
48
51
  // #3799 — the meaning-preserving schema translation, vendored the same way, so
49
52
  // the preflight refuses exactly the schemas the admin routes refuse.
50
53
  import { agentSchemaTranslationRefusals } from "../lib/generated-config-surfaces.js";
54
+ // #4441 — the rule for a change to a function an agent uses, vendored the same
55
+ // way, so the preflight refuses exactly the changes the function routes refuse.
56
+ import { agentFunctionChangeRefusals, agentFunctionUses, } from "../lib/generated-config-surfaces.js";
51
57
  // #3801 — the strict-output rule, vendored the same way, so the preflight
52
58
  // refuses exactly the strict configs the admin routes refuse.
53
59
  import { resolvePromptStrictOutput } from "../lib/generated-config-surfaces.js";
54
60
  // #3179 — the function's two halves: the header payloads and the envelope
55
61
  // `config push` sends, and the bytes `config pull` writes back.
56
- import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
62
+ import { applyFunctionPull, buildFunctionConfigPush, buildFunctionConfigPushDetailed, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
57
63
  import { newerVersionLine, pullNewerVersionLine, rollbackDiffHint, } from "../lib/function-versions.js";
58
64
  import { FunctionBuildError } from "../lib/function-bundle.js";
59
65
  // #3182 — the local half of capability validation. The grammar is the vendored
@@ -71,6 +77,7 @@ import { isFunctionDeclarationFile, resolveDocumentSchema, } from "../lib/functi
71
77
  // #3331 — and the compiler is actually asked: a push holds the code it ships
72
78
  // to the declarations it just generated.
73
79
  import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
80
+ import { resolveFunctionSourceRoots } from "../lib/function-source-roots.js";
74
81
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
75
82
  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";
76
83
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -82,6 +89,8 @@ export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } fr
82
89
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
83
90
  import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, trackedSelectorKeys, unmatchedSelectors, } from "../lib/sync-selectors.js";
84
91
  import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
92
+ import { pushFailureIsTransient, writePushFailureReport } from "../lib/push-failure-report.js";
93
+ import { isTransientRequestFailure } from "../lib/transient-retry.js";
85
94
  import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
86
95
  import chalk from "chalk";
87
96
  function ensureDir(dirPath) {
@@ -152,7 +161,7 @@ export function wrapEntityError(err, action, kind, key) {
152
161
  return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
153
162
  }
154
163
  // Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
155
- const wrapped = new Error(wrappedMessage);
164
+ const wrapped = new Error(wrappedMessage, { cause: err });
156
165
  return wrapped;
157
166
  }
158
167
  /**
@@ -570,6 +579,69 @@ export function planVarsPull(outcome, priorVars) {
570
579
  }
571
580
  return { write: true, content: serializeVars(outcome.vars), varEntities };
572
581
  }
582
+ /**
583
+ * The var leg of `config pull` on a machine-local environment (#4485): the
584
+ * server's vars go into the environment's `vars.<name>.override.toml`, and
585
+ * `vars.toml` — the shared tree's, which merges to main — is never written.
586
+ * Each pulled key (every key, or `selectedKeys`) is resolved against
587
+ * `vars.toml` by `planVarsOverridePull`. The file is created only when a key
588
+ * ends up in it; an existing one is rewritten, possibly to its comment-only
589
+ * state. The baseline entries (`varEntities`) are filled exactly as a pull on
590
+ * a committed environment fills them. A failed fetch writes nothing and keeps
591
+ * the prior entries.
592
+ */
593
+ function pullVarsIntoOverride(input) {
594
+ const { outcome, overridePath, configDir, env, selectedKeys, priorVars, varEntities } = input;
595
+ const overrideFile = basename(overridePath);
596
+ if (!outcome.ok) {
597
+ const fetchError = outcome.error;
598
+ Object.assign(varEntities, priorVars ?? {});
599
+ error(` Skipped ${overrideFile} — failed to fetch config vars: ${fetchError?.message ?? fetchError}. ` +
600
+ `Left the existing ${overrideFile} untouched.`);
601
+ return;
602
+ }
603
+ const readTable = (path) => {
604
+ if (!existsSync(path))
605
+ return {};
606
+ try {
607
+ return parseConfigToml(readFileSync(path, "utf-8"));
608
+ }
609
+ catch (err) {
610
+ error(` Skipped ${overrideFile} — could not parse ${basename(path)}: ${err?.message || String(err)}`);
611
+ return null;
612
+ }
613
+ };
614
+ const base = readTable(join(configDir, "vars.toml"));
615
+ const current = readTable(overridePath);
616
+ if (base === null || current === null) {
617
+ Object.assign(varEntities, priorVars ?? {});
618
+ return;
619
+ }
620
+ Object.assign(varEntities, selectedKeys
621
+ ? planScopedVarsPull(outcome.vars, selectedKeys, {}, priorVars).varEntities
622
+ : planVarsPull(outcome).varEntities);
623
+ const plan = planVarsOverridePull(outcome.vars, base, selectedKeys, current, env);
624
+ const keys = Object.keys(plan.vars).sort();
625
+ const exists = existsSync(overridePath);
626
+ if (!exists && keys.length === 0)
627
+ return;
628
+ if (!exists) {
629
+ const coverage = ensureFileIgnored(overridePath, { reason: "primitive config pull" });
630
+ if (coverage.status === "failed") {
631
+ warn(` Could not confirm ${overrideFile} is git-ignored: ${coverage.detail}. ` +
632
+ `Add "${VARS_OVERRIDE_GITIGNORE_PATTERN}" to your .gitignore — the file is machine-local and must never be committed.`);
633
+ }
634
+ }
635
+ const written = writePulledFile(overridePath, plan.content, input.tally, sameTomlAs(plan.content));
636
+ if (written)
637
+ info(` Wrote ${overrideFile} (${keys.length} var${keys.length === 1 ? "" : "s"})`);
638
+ if (keys.length > 0) {
639
+ info(` ${varsOverrideSummaryLine(overrideFile, {
640
+ overridingKeys: keys.filter((key) => key in base),
641
+ addedKeys: keys.filter((key) => !(key in base)),
642
+ })}`);
643
+ }
644
+ }
573
645
  /**
574
646
  * `config pull --only var/<KEY>` — the same plan, narrowed to the named keys
575
647
  * (issue #2645).
@@ -710,27 +782,28 @@ export function validateWebhookCapForPush(localKeys, remoteKeys) {
710
782
  export function validateVarsFile(parsedVars, options = {}) {
711
783
  const errors = [];
712
784
  const { onlyKeys } = options;
785
+ const fileName = options.fileName ?? "vars.toml";
713
786
  const keyCount = Object.keys(parsedVars).length;
714
- if (!onlyKeys && keyCount > MAX_VARS_PER_APP) {
715
- errors.push(`vars.toml declares ${keyCount} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${keyCount - MAX_VARS_PER_APP} entr${keyCount - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
787
+ if (!onlyKeys && !options.skipCountCap && keyCount > MAX_VARS_PER_APP) {
788
+ errors.push(`${fileName} declares ${keyCount} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${keyCount - MAX_VARS_PER_APP} entr${keyCount - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
716
789
  }
717
790
  for (const [key, rawValue] of Object.entries(parsedVars)) {
718
791
  if (onlyKeys && !onlyKeys.has(key))
719
792
  continue;
720
793
  if (!VARS_KEY_RE.test(key)) {
721
- errors.push(`Invalid var key "${key}" in vars.toml: must match ^[A-Z][A-Z0-9_]{0,63}$ (uppercase letters, digits, underscores; start with a letter; max 64 chars)`);
794
+ errors.push(`Invalid var key "${key}" in ${fileName}: must match ^[A-Z][A-Z0-9_]{0,63}$ (uppercase letters, digits, underscores; start with a letter; max 64 chars)`);
722
795
  continue;
723
796
  }
724
797
  if (typeof rawValue !== "string") {
725
- errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
798
+ errors.push(`Var "${key}" in ${fileName} must be a string value (got ${typeof rawValue}) — quote the value.`);
726
799
  continue;
727
800
  }
728
801
  const byteLength = new TextEncoder().encode(rawValue).length;
729
802
  if (byteLength === 0) {
730
- errors.push(`Var "${key}" in vars.toml must not be empty.`);
803
+ errors.push(`Var "${key}" in ${fileName} must not be empty.`);
731
804
  }
732
805
  else if (byteLength > MAX_VAR_VALUE_BYTES) {
733
- errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
806
+ errors.push(`Var "${key}" in ${fileName} exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
734
807
  }
735
808
  }
736
809
  return errors;
@@ -943,7 +1016,12 @@ export function countVarsAfterPush(remoteVars, plan) {
943
1016
  * local value). Pure and side-effect-free so it's unit-testable without a live
944
1017
  * server.
945
1018
  */
946
- export function diffVars(localVars, remoteVars) {
1019
+ export function diffVars(localVars, remoteVars,
1020
+ /**
1021
+ * The file `config pull` would write a server value into — a machine-local
1022
+ * environment's override file (#4485). Defaults to `vars.toml`.
1023
+ */
1024
+ pullTargetFile = "vars.toml") {
947
1025
  const rows = [];
948
1026
  for (const [key, localValue] of localVars) {
949
1027
  if (!remoteVars.has(key)) {
@@ -954,7 +1032,9 @@ export function diffVars(localVars, remoteVars) {
954
1032
  type: "var",
955
1033
  key,
956
1034
  status: "modified",
957
- hint: "run `config pull` to rewrite vars.toml to match the server value",
1035
+ hint: pullTargetFile === "vars.toml"
1036
+ ? "run `config pull` to rewrite vars.toml to match the server value"
1037
+ : `run \`config pull\` to write the server value into ${pullTargetFile}`,
958
1038
  });
959
1039
  }
960
1040
  else {
@@ -2483,7 +2563,7 @@ function localConfigWithServerIdentity(label, local, server, file) {
2483
2563
  * that being a visible change to this signature.
2484
2564
  */
2485
2565
  export async function runConfigPushPreflight(input) {
2486
- const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, } = input;
2566
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, varsOverridePath = null, } = input;
2487
2567
  /**
2488
2568
  * Narrow a type's file list to the `--only` selection — the same one-line
2489
2569
  * closure the action holds, rebuilt from the selection it passed in rather
@@ -2609,6 +2689,9 @@ export async function runConfigPushPreflight(input) {
2609
2689
  fileName: parsedApp?.app?.name,
2610
2690
  }),
2611
2691
  nameOptional: localEnvironment,
2692
+ // #4564 — the protection level follows the name on a machine-local
2693
+ // environment: out of the body, so out of what is validated.
2694
+ excludeProtection: localEnvironment,
2612
2695
  });
2613
2696
  for (const e of appErrors) {
2614
2697
  preflightValidationErrors.push(` app.toml: ${e}`);
@@ -2627,7 +2710,9 @@ export async function runConfigPushPreflight(input) {
2627
2710
  const preflightVarsTomlPath = join(configDir, "vars.toml");
2628
2711
  const varsSelected = selectionIncludesAnyVar(pushSelection);
2629
2712
  let preflightParsedVars = null;
2630
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
2713
+ let varsOverrideKeys = null;
2714
+ const overrideExists = !!varsOverridePath && existsSync(varsOverridePath);
2715
+ if (varsSelected && existsSync(preflightVarsTomlPath) && !overrideExists) {
2631
2716
  try {
2632
2717
  const parsed = parseTomlFile(preflightVarsTomlPath);
2633
2718
  // #2645 — under `--only var/<key>` only the selected entries are
@@ -2645,6 +2730,49 @@ export async function runConfigPushPreflight(input) {
2645
2730
  preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2646
2731
  }
2647
2732
  }
2733
+ else if (varsSelected && overrideExists) {
2734
+ // #4485 — a machine-local environment's own values, layered over
2735
+ // vars.toml. Each file is validated under its own name, and the count
2736
+ // cap is measured on the layered set: that is what the push sends.
2737
+ const overrideFile = basename(varsOverridePath);
2738
+ const onlyKeys = pushSelection ? pushSelection.varKeys : undefined;
2739
+ const tables = [];
2740
+ let parsedAll = true;
2741
+ for (const [path, fileName] of [
2742
+ [preflightVarsTomlPath, "vars.toml"],
2743
+ [varsOverridePath, overrideFile],
2744
+ ]) {
2745
+ if (!existsSync(path)) {
2746
+ tables.push(null);
2747
+ continue;
2748
+ }
2749
+ try {
2750
+ const parsed = parseTomlFile(path);
2751
+ for (const e of validateVarsFile(parsed, { onlyKeys, fileName, skipCountCap: true })) {
2752
+ preflightValidationErrors.push(` ${e}`);
2753
+ }
2754
+ tables.push(parsed);
2755
+ }
2756
+ catch (err) {
2757
+ parsedAll = false;
2758
+ preflightValidationErrors.push(` ${fileName}: ${err?.message || String(err)}`);
2759
+ }
2760
+ }
2761
+ if (parsedAll) {
2762
+ const [base, override] = tables;
2763
+ const layered = layerVars(base, override);
2764
+ const count = Object.keys(layered).length;
2765
+ if (!onlyKeys && count > MAX_VARS_PER_APP) {
2766
+ preflightValidationErrors.push(` vars.toml and ${overrideFile} layer to ${count} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${count - MAX_VARS_PER_APP} entr${count - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
2767
+ }
2768
+ const overrideKeys = Object.keys(override ?? {}).sort();
2769
+ varsOverrideKeys = {
2770
+ overridingKeys: overrideKeys.filter((key) => base !== null && key in base),
2771
+ addedKeys: overrideKeys.filter((key) => base === null || !(key in base)),
2772
+ };
2773
+ preflightParsedVars = layered;
2774
+ }
2775
+ }
2648
2776
  // #3182 — a function's capabilities, checked against the author's own
2649
2777
  // file before anything is applied. The grammar is purely local (and
2650
2778
  // since #3279 it keeps three kinds: `integration:<key>`,
@@ -2740,6 +2868,20 @@ export async function runConfigPushPreflight(input) {
2740
2868
  // #3798 — the functions an agent's references are checked against, read
2741
2869
  // once and only when a selected prompt is an agent that names one.
2742
2870
  let agentFunctions = null;
2871
+ // The app's functions, listed at most once for the #3798 and #4441 checks.
2872
+ // `null` when the listing failed.
2873
+ let liveFunctionRows;
2874
+ const liveFunctionListing = async () => {
2875
+ if (liveFunctionRows === undefined) {
2876
+ try {
2877
+ liveFunctionRows = (await client.listFunctions(resolvedAppId)).items;
2878
+ }
2879
+ catch {
2880
+ liveFunctionRows = null;
2881
+ }
2882
+ }
2883
+ return liveFunctionRows;
2884
+ };
2743
2885
  if (existsSync(preflightPromptsDir)) {
2744
2886
  for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2745
2887
  const filePath = join(preflightPromptsDir, file);
@@ -2757,17 +2899,10 @@ export async function runConfigPushPreflight(input) {
2757
2899
  if (promptKindOf(tomlData?.prompt) === "agent" &&
2758
2900
  agentDeclarationFunctionKeys(tomlData?.prompt?.agent).length > 0) {
2759
2901
  if (!agentFunctions) {
2760
- let live = null;
2761
- try {
2762
- live = (await client.listFunctions(resolvedAppId)).items;
2763
- }
2764
- catch {
2765
- live = null;
2766
- }
2767
2902
  agentFunctions = agentFunctionFactsForPush({
2768
2903
  configDir,
2769
2904
  pushSelection,
2770
- live,
2905
+ live: await liveFunctionListing(),
2771
2906
  });
2772
2907
  }
2773
2908
  for (const message of promptAgentFunctionErrors(filePath, tomlData, agentFunctions)) {
@@ -2776,6 +2911,88 @@ export async function runConfigPushPreflight(input) {
2776
2911
  }
2777
2912
  }
2778
2913
  }
2914
+ // #4441 — a function file this push CHANGES, checked against the live
2915
+ // agents that use it: refused here when an agent the push does not select
2916
+ // cannot take the change, and the agent files that must release a reference
2917
+ // first are moved ahead of the functions leg. Lists the app's prompts only
2918
+ // when a selected function file changes a live function.
2919
+ let agentFilesFirst = [];
2920
+ const preflightFunctionFilesDir = join(configDir, "functions");
2921
+ const selectedFunctionFiles = [];
2922
+ if (existsSync(preflightFunctionFilesDir)) {
2923
+ for (const file of selectFiles("function", preflightFunctionFilesDir, readdirSync(preflightFunctionFilesDir).filter(isFunctionDeclarationFile))) {
2924
+ let table;
2925
+ try {
2926
+ table = parseTomlFile(join(preflightFunctionFilesDir, file))?.function ?? {};
2927
+ }
2928
+ catch {
2929
+ // Reported by the function preflight with its own message.
2930
+ continue;
2931
+ }
2932
+ selectedFunctionFiles.push({ file, key: table.key || basename(file, ".toml") });
2933
+ }
2934
+ }
2935
+ if (selectedFunctionFiles.length > 0) {
2936
+ const liveRows = await liveFunctionListing();
2937
+ const live = liveAgentFunctionFacts(liveRows);
2938
+ const changed = functionChangePreflightKeys(selectedFunctionFiles, live);
2939
+ if (liveRows === null) {
2940
+ warn("The app's functions could not be listed, so the function files this " +
2941
+ "push changes were not checked against the agents that use them. The " +
2942
+ "server still refuses a change an agent cannot take.");
2943
+ }
2944
+ else if (changed.length > 0) {
2945
+ let prompts = null;
2946
+ const configsOf = new Map();
2947
+ try {
2948
+ prompts = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
2949
+ for (const agent of functionChangePreflightAgents(prompts, changed.map((entry) => entry.key))) {
2950
+ const { items } = await client.listPromptConfigs(resolvedAppId, agent.promptId);
2951
+ configsOf.set(agent.promptId, items.map((config) => ({
2952
+ configName: String(config?.configName ?? ""),
2953
+ provider: String(config?.provider ?? ""),
2954
+ status: String(config?.status ?? "active"),
2955
+ })));
2956
+ }
2957
+ }
2958
+ catch {
2959
+ prompts = null;
2960
+ }
2961
+ const selectedAgentFiles = [];
2962
+ if (existsSync(preflightPromptsDir)) {
2963
+ for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2964
+ let tomlData;
2965
+ try {
2966
+ tomlData = parseTomlFile(join(preflightPromptsDir, file));
2967
+ }
2968
+ catch {
2969
+ continue;
2970
+ }
2971
+ if (promptKindOf(tomlData?.prompt) !== "agent")
2972
+ continue;
2973
+ selectedAgentFiles.push({
2974
+ file,
2975
+ promptKey: tomlData?.prompt?.key || basename(file, ".toml"),
2976
+ tomlData,
2977
+ });
2978
+ }
2979
+ }
2980
+ const checked = functionChangePreflight({
2981
+ selectedFunctionFiles,
2982
+ live,
2983
+ pushed: agentFunctionFactsForPush({ configDir, pushSelection, live: liveRows }).facts,
2984
+ prompts,
2985
+ configsOf,
2986
+ selectedAgentFiles,
2987
+ });
2988
+ for (const message of checked.warnings)
2989
+ warn(message);
2990
+ for (const message of checked.errors) {
2991
+ preflightValidationErrors.push(` ${message}`);
2992
+ }
2993
+ agentFilesFirst = checked.agentFilesFirst;
2994
+ }
2995
+ }
2779
2996
  // #3798, #4262 — a prompt's sidecar against its prompt's kind: an agent's
2780
2997
  // case is checked by the agent test case's shape rule, and `[test.agent]`
2781
2998
  // on a chat or decisions prompt's case is refused, before any file is
@@ -2805,7 +3022,7 @@ export async function runConfigPushPreflight(input) {
2805
3022
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2806
3023
  preflightValidationErrors.join("\n"));
2807
3024
  }
2808
- return { parsedVars: preflightParsedVars };
3025
+ return { parsedVars: preflightParsedVars, agentFilesFirst, varsOverrideKeys };
2809
3026
  }
2810
3027
  /**
2811
3028
  * Every per-entity config file whose TOML `config push` would refuse (#2880
@@ -3037,21 +3254,9 @@ function declaresSchema(value) {
3037
3254
  * availability on every call).
3038
3255
  */
3039
3256
  export function agentFunctionFactsForPush(input) {
3040
- const facts = {};
3041
- for (const row of input.live ?? []) {
3042
- const key = String(row?.functionKey ?? "");
3043
- if (!key || row?.status === "archived")
3044
- continue;
3045
- facts[key] = {
3046
- hasInputSchema: declaresSchema(row.inputSchema),
3047
- hasOutputSchema: declaresSchema(row.outputSchema),
3048
- // #3799 — the schemas themselves, as stored (a JSON string from the API,
3049
- // an object from a tree file), so a server tool's two schemas can be
3050
- // translated for each of the agent's providers.
3051
- inputSchema: row.inputSchema,
3052
- outputSchema: row.outputSchema,
3053
- };
3054
- }
3257
+ const facts = {
3258
+ ...liveAgentFunctionFacts(input.live),
3259
+ };
3055
3260
  const functionsDir = join(input.configDir, "functions");
3056
3261
  if (existsSync(functionsDir)) {
3057
3262
  for (const file of filterFilesBySelection(input.pushSelection, "function", functionsDir, readdirSync(functionsDir).filter(isFunctionDeclarationFile))) {
@@ -3076,6 +3281,196 @@ export function agentFunctionFactsForPush(input) {
3076
3281
  }
3077
3282
  return { facts, liveListed: input.live !== null };
3078
3283
  }
3284
+ /**
3285
+ * The functions the app holds now, as the agent rules read them — #3798. A
3286
+ * live row that is archived is a tombstone, not a function, so it is absent.
3287
+ */
3288
+ export function liveAgentFunctionFacts(live) {
3289
+ const facts = {};
3290
+ for (const row of live ?? []) {
3291
+ const key = String(row?.functionKey ?? "");
3292
+ if (!key || row?.status === "archived")
3293
+ continue;
3294
+ facts[key] = {
3295
+ hasInputSchema: declaresSchema(row.inputSchema),
3296
+ hasOutputSchema: declaresSchema(row.outputSchema),
3297
+ // #3799 — the schemas themselves, as stored (a JSON string from the API,
3298
+ // an object from a tree file), so a server tool's two schemas can be
3299
+ // translated for each of the agent's providers.
3300
+ inputSchema: row.inputSchema,
3301
+ outputSchema: row.outputSchema,
3302
+ };
3303
+ }
3304
+ return facts;
3305
+ }
3306
+ /**
3307
+ * The selected function files that CHANGE a live function — #4441. A file
3308
+ * whose function is not live creates it, which no agent can depend on yet.
3309
+ * The push lists the app's prompts only when this is non-empty.
3310
+ */
3311
+ export function functionChangePreflightKeys(selectedFunctionFiles, live) {
3312
+ return selectedFunctionFiles.filter((entry) => Object.prototype.hasOwnProperty.call(live, entry.key) && live[entry.key] != null);
3313
+ }
3314
+ /**
3315
+ * The live, non-archived agent prompts that name at least one of `keys`.
3316
+ * Whose configs the push reads for `functionChangePreflight`.
3317
+ */
3318
+ export function functionChangePreflightAgents(prompts, keys) {
3319
+ return prompts.filter((prompt) => promptKindOf(prompt) === "agent" &&
3320
+ prompt.status !== "archived" &&
3321
+ agentDeclarationFunctionKeys(prompt.agent).some((key) => keys.includes(key)));
3322
+ }
3323
+ /** `tool "lookup"` for a tool's name, the path itself for a hook. */
3324
+ function functionUseLabels(uses) {
3325
+ return uses.map((use) => (use.includes(".") ? use : `tool "${use}"`)).join(", ");
3326
+ }
3327
+ /**
3328
+ * Would a function change this push makes break a live agent? — #4441.
3329
+ *
3330
+ * The same rule the function routes run (`agentFunctionChangeRefusals`), so a
3331
+ * change the preflight lets through is one the server accepts, run for every
3332
+ * selected file that changes a live function against every live non-archived
3333
+ * agent that names it, with every config of that agent:
3334
+ *
3335
+ * - an agent whose prompt file this push does NOT select would be broken by
3336
+ * the change: a preflight error naming the function file, the agent and its
3337
+ * uses, and the remedy (change the agent in the same push, or retire it);
3338
+ * - an agent whose file IS selected keeps the existing order (applied after
3339
+ * the functions leg) unless its LIVE declaration would be broken by the
3340
+ * change. Then the file must go first to release the reference, which it
3341
+ * can only do when it is valid against the functions the app holds NOW —
3342
+ * its references, the providers of its non-archived configs, and the
3343
+ * providers of the configs the server still holds at its prompt PATCH. When it
3344
+ * is, it joins `agentFilesFirst`; when it is not, the push cannot be
3345
+ * ordered in one run and the error names the sequence that can.
3346
+ *
3347
+ * `prompts: null` (the listing failed) checks nothing and says so once; the
3348
+ * server's refusal remains.
3349
+ */
3350
+ export function functionChangePreflight(input) {
3351
+ const result = {
3352
+ errors: [],
3353
+ warnings: [],
3354
+ agentFilesFirst: [],
3355
+ };
3356
+ const changed = functionChangePreflightKeys(input.selectedFunctionFiles, input.live);
3357
+ if (changed.length === 0)
3358
+ return result;
3359
+ if (input.prompts === null) {
3360
+ result.warnings.push("The app's prompts could not be listed, so the function files this push " +
3361
+ "changes were not checked against the agents that use them. The server " +
3362
+ "still refuses a change an agent cannot take.");
3363
+ return result;
3364
+ }
3365
+ const agents = functionChangePreflightAgents(input.prompts, changed.map((entry) => entry.key));
3366
+ for (const agent of agents) {
3367
+ const configs = input.configsOf.get(agent.promptId) ?? [];
3368
+ const named = agentDeclarationFunctionKeys(agent.agent);
3369
+ // Per changed function, so each refusal is attributed to its file.
3370
+ const broken = [];
3371
+ for (const entry of changed) {
3372
+ if (!named.includes(entry.key))
3373
+ continue;
3374
+ const refusals = agentFunctionChangeRefusals({
3375
+ declaration: agent.agent,
3376
+ configs,
3377
+ before: input.live,
3378
+ after: { ...input.live, [entry.key]: input.pushed[entry.key] ?? null },
3379
+ });
3380
+ if (refusals.length > 0)
3381
+ broken.push({ entry, refusals });
3382
+ }
3383
+ if (broken.length === 0)
3384
+ continue;
3385
+ const agentFile = input.selectedAgentFiles.find((candidate) => candidate.promptKey === agent.promptKey);
3386
+ if (!agentFile) {
3387
+ for (const { entry, refusals } of broken) {
3388
+ result.errors.push(`functions/${entry.file}: agent "${agent.promptKey}" ` +
3389
+ `(prompts/${agent.promptKey}.toml, not in this push) uses function ` +
3390
+ `"${entry.key}" as ${functionUseLabels(agentFunctionUses(agent.agent, entry.key))}, ` +
3391
+ `and this change would break it: ` +
3392
+ `${refusals.map((refusal) => refusal.message).join("; ")}. Change the ` +
3393
+ "agent in this same push so it no longer needs what the change " +
3394
+ "removes, or retire the agent.");
3395
+ }
3396
+ continue;
3397
+ }
3398
+ // The file must go first. Can it, against the functions the app holds now?
3399
+ // Its prompt PATCH is checked against the configs the server still holds
3400
+ // at that moment (CSO-4441-001): every non-archived one except those the
3401
+ // file retires, which the apply stages ahead of the PATCH. The file's own
3402
+ // config writes follow the PATCH and are checked against its entries.
3403
+ const fileAgent = agentFile.tomlData?.prompt?.agent;
3404
+ const blockers = promptAgentFunctionRefusalsAgainst(agentFile.tomlData, input.live, heldAtPromptPatch(agentFile.tomlData, configs));
3405
+ const blocking = broken.map(({ entry }) => `functions/${entry.file}`).join(", ");
3406
+ if (blockers.length === 0) {
3407
+ result.agentFilesFirst.push(agentFile.file);
3408
+ continue;
3409
+ }
3410
+ const created = agentDeclarationFunctionKeys(fileAgent).filter((key) => input.live[key] == null && input.pushed[key] != null);
3411
+ const lead = `prompts/${agentFile.file}: agent "${agent.promptKey}" must be applied ` +
3412
+ `before ${blocking}, because its live declaration cannot take that ` +
3413
+ "change";
3414
+ if (created.length > 0) {
3415
+ result.errors.push(`${lead}, but it names ${created.map((key) => `function "${key}"`).join(", ")}, ` +
3416
+ "which this push creates. Push the new function first, then rerun " +
3417
+ `this push: primitive config push --only ` +
3418
+ `${created.map((key) => `function/${key}`).join(",")}`);
3419
+ }
3420
+ else {
3421
+ result.errors.push(`${lead}, but it cannot be applied against the functions and configs ` +
3422
+ `the app holds now: ${blockers.map((refusal) => refusal.message).join("; ")}. ` +
3423
+ "Split the change: push the agent's change on its own first, without " +
3424
+ "what the live functions and configs cannot satisfy, then rerun this push.");
3425
+ }
3426
+ }
3427
+ return result;
3428
+ }
3429
+ /**
3430
+ * The function refusals an agent prompt file owes against `functions`: a
3431
+ * missing function or tool schema, and a server-tool schema one of the file's
3432
+ * non-archived configs cannot carry. The file-decidable refusals are left to
3433
+ * the collector that already reports them.
3434
+ */
3435
+ function promptAgentFunctionRefusalsAgainst(tomlData, functions, heldConfigs = []) {
3436
+ const agent = tomlData?.prompt?.agent;
3437
+ const resolved = resolveAgentDeclaration({ kind: "agent", agent, functions });
3438
+ const refusals = resolved.ok === false
3439
+ ? resolved.refusals.filter((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" ||
3440
+ refusal.code === "AGENT_TOOL_FUNCTION_SCHEMA_MISSING")
3441
+ : [];
3442
+ const seen = new Set();
3443
+ for (const configs of [
3444
+ promptFileConfigFacts(Array.isArray(tomlData?.configs) ? tomlData.configs : []),
3445
+ [...heldConfigs],
3446
+ ]) {
3447
+ const fileDecidable = new Set(agentSchemaTranslationRefusals({ declaration: agent, configs }).map((refusal) => refusal.message));
3448
+ for (const refusal of agentSchemaTranslationRefusals({
3449
+ declaration: agent,
3450
+ functions,
3451
+ configs,
3452
+ })) {
3453
+ if (fileDecidable.has(refusal.message) || seen.has(refusal.message))
3454
+ continue;
3455
+ seen.add(refusal.message);
3456
+ refusals.push(refusal);
3457
+ }
3458
+ }
3459
+ return refusals;
3460
+ }
3461
+ /**
3462
+ * The configs the server holds for an agent when a push's prompt PATCH
3463
+ * reaches it — #4441 (CSO-4441-001): every non-archived one but those the
3464
+ * file archives, which the apply stages ahead of the PATCH. Each keeps the
3465
+ * provider it has NOW; the file's change to it is sent after the PATCH.
3466
+ */
3467
+ function heldAtPromptPatch(tomlData, configs) {
3468
+ const retired = new Set(promptFileConfigFacts(Array.isArray(tomlData?.configs) ? tomlData.configs : [])
3469
+ .filter((entry) => entry.status.trim().toLowerCase() === "archived")
3470
+ .map((entry) => entry.configName));
3471
+ return configs.filter((config) => String(config.status ?? "active").trim().toLowerCase() !== "archived" &&
3472
+ !retired.has(config.configName));
3473
+ }
3079
3474
  /**
3080
3475
  * The function half of an agent's declaration — #3798: every tool, turn
3081
3476
  * context and history function that the functions this push believes
@@ -3130,9 +3525,10 @@ export function promptAgentFunctionErrors(filePath, tomlData, known) {
3130
3525
  * server functions that must exist when it is created, and the push applies
3131
3526
  * prompts before functions, so agent files go AFTER the server-functions leg;
3132
3527
  * every other file keeps its place. A file that does not parse stays first, so
3133
- * the leg reports it where it always has.
3528
+ * the leg reports it where it always has. #4441 — the agent files in
3529
+ * `agentFilesFirst` (`functionChangePreflight`) go before the leg instead.
3134
3530
  */
3135
- export function partitionPromptFilesForPush(promptsDir, files) {
3531
+ export function partitionPromptFilesForPush(promptsDir, files, agentFilesFirst = []) {
3136
3532
  const beforeFunctions = [];
3137
3533
  const afterFunctions = [];
3138
3534
  for (const file of files) {
@@ -3144,7 +3540,11 @@ export function partitionPromptFilesForPush(promptsDir, files) {
3144
3540
  beforeFunctions.push(file);
3145
3541
  continue;
3146
3542
  }
3147
- (kind === "agent" ? afterFunctions : beforeFunctions).push(file);
3543
+ // #4441 — an agent file the preflight found must release a function
3544
+ // reference before the functions leg changes that function.
3545
+ (kind === "agent" && !agentFilesFirst.includes(file)
3546
+ ? afterFunctions
3547
+ : beforeFunctions).push(file);
3148
3548
  }
3149
3549
  return { beforeFunctions, afterFunctions };
3150
3550
  }
@@ -6343,8 +6743,8 @@ export async function listAllTestCases(client, appId, blockType, blockId) {
6343
6743
  try {
6344
6744
  page = await client.listTestCases(appId, blockType, blockId, cursor ? { cursor } : {});
6345
6745
  }
6346
- catch {
6347
- return { ok: false };
6746
+ catch (error) {
6747
+ return { ok: false, error };
6348
6748
  }
6349
6749
  items.push(...(page.items || []));
6350
6750
  const next = page.nextCursor;
@@ -6821,6 +7221,7 @@ export async function pushTestCasesForBlock(params) {
6821
7221
  const testCaseExtra = { lookupMaps: params.lookupMaps };
6822
7222
  const listed = await listAllTestCases(client, appId, blockType, blockId);
6823
7223
  const liveOk = listed.ok;
7224
+ const liveReadTransient = "error" in listed && isTransientRequestFailure(listed.error);
6824
7225
  if (!liveOk) {
6825
7226
  warn(` ${blockKey} test cases: live state unavailable — falling back to last-sync content hashes`);
6826
7227
  }
@@ -7082,6 +7483,7 @@ export async function pushTestCasesForBlock(params) {
7082
7483
  type: "test-case",
7083
7484
  key: `${blockKey}/${slug}`,
7084
7485
  message,
7486
+ transient: isTransientRequestFailure(err),
7085
7487
  });
7086
7488
  }
7087
7489
  continue;
@@ -7129,7 +7531,7 @@ export async function pushTestCasesForBlock(params) {
7129
7531
  continue;
7130
7532
  }
7131
7533
  if (degraded.action === "decline") {
7132
- params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable" }, existingEntry?.modifiedAt);
7534
+ params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable", transient: liveReadTransient }, existingEntry?.modifiedAt);
7133
7535
  continue;
7134
7536
  }
7135
7537
  }
@@ -7192,7 +7594,12 @@ export async function pushTestCasesForBlock(params) {
7192
7594
  catch (err) {
7193
7595
  const message = err?.message || String(err);
7194
7596
  warn(` Failed to update test case ${blockKey}/${slug}: ${message}`);
7195
- failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
7597
+ failures.push({
7598
+ type: "test-case",
7599
+ key: `${blockKey}/${slug}`,
7600
+ message,
7601
+ transient: isTransientRequestFailure(err),
7602
+ });
7196
7603
  continue;
7197
7604
  }
7198
7605
  }
@@ -7266,7 +7673,12 @@ export async function pushTestCasesForBlock(params) {
7266
7673
  ? `${keyError} — rename ${file} to a path-safe file name`
7267
7674
  : serverMessage;
7268
7675
  warn(` Failed to create test case ${blockKey}/${slug}: ${message}`);
7269
- failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
7676
+ failures.push({
7677
+ type: "test-case",
7678
+ key: `${blockKey}/${slug}`,
7679
+ message,
7680
+ transient: isTransientRequestFailure(err),
7681
+ });
7270
7682
  continue;
7271
7683
  }
7272
7684
  }
@@ -7310,6 +7722,7 @@ export async function pushTestCasesForBlock(params) {
7310
7722
  type: "attachment",
7311
7723
  key: `${blockKey}/${slug}/${filename}`,
7312
7724
  message,
7725
+ transient: isTransientRequestFailure(err),
7313
7726
  });
7314
7727
  }
7315
7728
  }
@@ -7881,7 +8294,7 @@ export async function applyTestCasePrune(params) {
7881
8294
  catch (err) {
7882
8295
  const message = err?.message || String(err);
7883
8296
  warn(` Failed to delete attachment ${key}: ${message}`);
7884
- params.failures.push({ type: "attachment", key, message });
8297
+ params.failures.push({ type: "attachment", key, message, transient: isTransientRequestFailure(err) });
7885
8298
  }
7886
8299
  }
7887
8300
  for (const candidate of params.candidates) {
@@ -7916,7 +8329,7 @@ export async function applyTestCasePrune(params) {
7916
8329
  catch (err) {
7917
8330
  const message = err?.message || String(err);
7918
8331
  warn(` Failed to delete test case ${key}: ${message}`);
7919
- params.failures.push({ type: "test-case", key, message });
8332
+ params.failures.push({ type: "test-case", key, message, transient: isTransientRequestFailure(err) });
7920
8333
  }
7921
8334
  }
7922
8335
  return { deleted, deletedAttachments };
@@ -8020,6 +8433,8 @@ export function registerConfigSyncCommands(sync) {
8020
8433
  const resolvedAppId = getCurrentAppId();
8021
8434
  const configDir = resolveSyncDir();
8022
8435
  const pullsLocalEnvironment = requireCurrentEnvironment().local;
8436
+ // #4485 — null for a committed environment, which never reads one.
8437
+ const pullVarsOverridePath = resolveVarsOverridePath(configDir, requireCurrentEnvironment());
8023
8438
  if (!options.json) {
8024
8439
  info(`Using per-environment config directory: ${configDir}`);
8025
8440
  }
@@ -8312,7 +8727,27 @@ export function registerConfigSyncCommands(sync) {
8312
8727
  catch (varErr) {
8313
8728
  varsOutcome = { ok: false, error: varErr };
8314
8729
  }
8315
- if (pullSelection && varsOutcome.ok) {
8730
+ if (pullVarsOverridePath) {
8731
+ // #4485 — a machine-local environment never writes vars.toml: the
8732
+ // tree's file is the parent's. Its own values land in its override.
8733
+ if (pullSelection && varsOutcome.ok) {
8734
+ for (const v of varsOutcome.vars) {
8735
+ if (pullSelection.varKeys.has(v.key))
8736
+ matchedSelectors.add(`var/${v.key}`);
8737
+ }
8738
+ }
8739
+ pullVarsIntoOverride({
8740
+ outcome: varsOutcome,
8741
+ overridePath: pullVarsOverridePath,
8742
+ configDir,
8743
+ env: requireCurrentEnvironment(),
8744
+ selectedKeys: pullSelection ? pullSelection.varKeys : null,
8745
+ priorVars: priorEntities.vars,
8746
+ varEntities,
8747
+ tally: pullWrites,
8748
+ });
8749
+ }
8750
+ else if (pullSelection && varsOutcome.ok) {
8316
8751
  // Scoped: merge the named keys into the file that is already there
8317
8752
  // rather than re-emitting the whole var surface.
8318
8753
  for (const v of varsOutcome.vars) {
@@ -9239,6 +9674,7 @@ export function registerConfigSyncCommands(sync) {
9239
9674
  // tree's name and whose baseline never includes one.
9240
9675
  semanticHash: hashAppSettingsForDiff(settings, {
9241
9676
  excludeName: pullsLocalEnvironment,
9677
+ excludeProtection: pullsLocalEnvironment,
9242
9678
  }),
9243
9679
  }
9244
9680
  : pullSelection
@@ -9403,6 +9839,9 @@ What a push guarantees (two stages):
9403
9839
  // tree, so `[app].name` in it is that environment's name: never sent,
9404
9840
  // never compared, never hashed into this environment's baseline.
9405
9841
  const localEnvironment = requireCurrentEnvironment().local;
9842
+ // #4485 — a machine-local environment's own var values, layered over
9843
+ // vars.toml. Null for a committed environment, which never reads one.
9844
+ const pushVarsOverridePath = resolveVarsOverridePath(configDir, requireCurrentEnvironment());
9406
9845
  // Detect cross-app push: if pushing to a different app, clear entity IDs
9407
9846
  // so everything goes through the create path
9408
9847
  const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
@@ -9432,7 +9871,7 @@ What a push guarantees (two stages):
9432
9871
  // cross-app reset has to have happened first: it clears the entity ids, so
9433
9872
  // there is nothing for prune to retire and a removed key IS a typo again.
9434
9873
  if (pushSelection) {
9435
- const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }));
9874
+ const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }), pushVarsOverridePath);
9436
9875
  if (missing.length > 0) {
9437
9876
  error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
9438
9877
  process.exit(1);
@@ -9452,6 +9891,11 @@ What a push guarantees (two stages):
9452
9891
  try {
9453
9892
  const changes = [];
9454
9893
  let skippedCount = 0;
9894
+ let functionsWithUnchangedCode = 0;
9895
+ // #4485 — functions whose code axis this push evaluated; any other
9896
+ // function the server holds (left out by `--only`, or not in the tree)
9897
+ // also keeps its version, and with it the vars it has cached.
9898
+ const functionKeysEvaluated = new Set();
9455
9899
  const conflicts = [];
9456
9900
  // Issue #666: per-class buckets for schema-feature errors. Each entry
9457
9901
  // groups failures by the code so the end-of-run report can print
@@ -9636,7 +10080,13 @@ What a push guarantees (two stages):
9636
10080
  * never an unconditional write — with the type named so the operator
9637
10081
  * knows drift detection was not in force for it this run.
9638
10082
  */
10083
+ /** Whether each live read that failed this run was transient (#4491). */
10084
+ const liveReadFailures = [];
9639
10085
  const warnLiveUnavailable = (label, reason) => {
10086
+ // A string reason names a consequence ("rule sets unavailable") of a
10087
+ // read already recorded here.
10088
+ if (typeof reason !== "string")
10089
+ liveReadFailures.push(isTransientRequestFailure(reason));
9640
10090
  const message = reason instanceof Error ? reason.message : String(reason ?? "unknown");
9641
10091
  warn(` ${label}: live state unavailable (${message}) — falling back to last-sync content hashes`);
9642
10092
  };
@@ -9654,6 +10104,11 @@ What a push guarantees (two stages):
9654
10104
  applyFailures.push({
9655
10105
  type,
9656
10106
  key,
10107
+ // A rerun can read what this one could not only when the failed
10108
+ // read was transient; a decliner that does not say which read
10109
+ // failed is judged by every read that failed this run.
10110
+ transient: outcome.transient ??
10111
+ (liveReadFailures.length > 0 && liveReadFailures.every(Boolean)),
9657
10112
  message: "live state could not be read and the manifest holds no " +
9658
10113
  "last-sync content hash for this file, so push cannot tell " +
9659
10114
  "which side moved. Nothing was written. Run `config pull` to " +
@@ -9831,7 +10286,7 @@ What a push guarantees (two stages):
9831
10286
  // proved to be reached from it before anything below mutates. Nothing
9832
10287
  // about what it checks, the order it checks it, or what it prints
9833
10288
  // moved with it.
9834
- const { parsedVars: preflightParsedVars } = await runConfigPushPreflight({
10289
+ const { parsedVars: preflightParsedVars, agentFilesFirst: preflightAgentFilesFirst, varsOverrideKeys, } = await runConfigPushPreflight({
9835
10290
  configDir,
9836
10291
  resolvedAppId,
9837
10292
  pushSelection,
@@ -9840,7 +10295,12 @@ What a push guarantees (two stages):
9840
10295
  isCrossAppPush: !!isCrossAppPush,
9841
10296
  localEnvironment,
9842
10297
  client,
10298
+ varsOverridePath: pushVarsOverridePath,
9843
10299
  });
10300
+ const varsOverrideFile = varsOverrideKeys && pushVarsOverridePath ? basename(pushVarsOverridePath) : null;
10301
+ if (varsOverrideKeys && varsOverrideFile) {
10302
+ info(varsOverrideSummaryLine(varsOverrideFile, varsOverrideKeys));
10303
+ }
9844
10304
  // Re-derived, not returned: both are one-line functions of inputs the
9845
10305
  // entry point already took, and widening its return type to carry them
9846
10306
  // would make the vars apply leg depend on the preflight's internals.
@@ -9864,7 +10324,7 @@ What a push guarantees (two stages):
9864
10324
  // is a network call whose failure aborts the push, so making it for a
9865
10325
  // singleton nobody selected would let `--only workflow/x` fail over
9866
10326
  // vars.toml.
9867
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
10327
+ if (varsSelected && (existsSync(preflightVarsTomlPath) || varsOverrideFile)) {
9868
10328
  try {
9869
10329
  remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
9870
10330
  }
@@ -9873,7 +10333,7 @@ What a push guarantees (two stages):
9873
10333
  throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
9874
10334
  `concurrent edits (${err?.message || String(err)}) — no changes ` +
9875
10335
  `were applied. Re-run with --force to push without the ` +
9876
- `concurrency guard.`);
10336
+ `concurrency guard.`, { cause: err });
9877
10337
  }
9878
10338
  // --force: proceed without a snapshot; the guard is intentionally
9879
10339
  // bypassed and every local var is upserted as authoritative. The
@@ -9922,11 +10382,13 @@ What a push guarantees (two stages):
9922
10382
  const localKeys = new Set(Object.keys(preflightParsedVars));
9923
10383
  const plannedDeletes = new Set(varPlan.deletions);
9924
10384
  const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
10385
+ const pullTarget = varsOverrideFile ?? "vars.toml";
10386
+ const localFiles = varsOverrideFile ? `vars.toml or ${varsOverrideFile}` : "vars.toml";
9925
10387
  throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
9926
10388
  `exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
9927
10389
  `(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
9928
- `which are not in vars.toml) — no changes were applied. Run ` +
9929
- `\`config pull\` to bring the remote-only vars into vars.toml, or ` +
10390
+ `which are not in ${localFiles}) — no changes were applied. Run ` +
10391
+ `\`config pull\` to bring the remote-only vars into ${pullTarget}, or ` +
9930
10392
  `delete unneeded vars, then push again.`);
9931
10393
  }
9932
10394
  }
@@ -10038,6 +10500,9 @@ What a push guarantees (two stages):
10038
10500
  const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
10039
10501
  excludeName: payloadExcludesName,
10040
10502
  nameOptional: localEnvironment,
10503
+ // #4564 — on a machine-local environment the level is the
10504
+ // shared tree's, like the name, and never this child's.
10505
+ excludeProtection: localEnvironment,
10041
10506
  });
10042
10507
  for (const w of appWarnings) {
10043
10508
  warn(` ${w}`);
@@ -10067,6 +10532,7 @@ What a push guarantees (two stages):
10067
10532
  excludeName: localEnvironment ||
10068
10533
  options.recordExclusion !== undefined ||
10069
10534
  (options.keepExclusion && compareExcludesName),
10535
+ excludeProtection: localEnvironment,
10070
10536
  }),
10071
10537
  ...(options.recordExclusion !== undefined
10072
10538
  ? { excludedName: options.recordExclusion }
@@ -10077,8 +10543,17 @@ What a push guarantees (two stages):
10077
10543
  };
10078
10544
  let applyAppSettings = true;
10079
10545
  if (appLiveOk) {
10080
- const compareOptions = { excludeName: compareExcludesName };
10081
- const localHash = hashAppSettingsForDiff(appPayload, compareOptions);
10546
+ const compareOptions = {
10547
+ excludeName: compareExcludesName,
10548
+ excludeProtection: localEnvironment,
10549
+ };
10550
+ // The file's deprecated `protected` key reads against the live
10551
+ // level, as the server's write will (DSO-4564-001), so a change
10552
+ // made only through it is not skipped as unchanged.
10553
+ const localHash = hashAppSettingsForDiff(appPayload, {
10554
+ ...compareOptions,
10555
+ legacyProtectedAgainst: settingsProtectionLevel(liveSettings ?? {}),
10556
+ });
10082
10557
  const remoteHash = hashAppSettingsForDiff(liveSettings ?? {}, compareOptions);
10083
10558
  if (options.force) {
10084
10559
  // `--force` is the operator saying "local wins": no direction
@@ -10203,6 +10678,7 @@ What a push guarantees (two stages):
10203
10678
  // r-2 P1), and the plan itself was computed up-front so the final
10204
10679
  // server var count could be validated against the cap before any
10205
10680
  // mutation (review pass 5).
10681
+ const changedExistingVarKeys = [];
10206
10682
  if (varPlan) {
10207
10683
  skippedCount += varPlan.skippedCount;
10208
10684
  // Feed var conflicts into the shared accumulator — same convention as
@@ -10305,6 +10781,12 @@ What a push guarantees (two stages):
10305
10781
  contentHash: item.valueHash,
10306
10782
  };
10307
10783
  }
10784
+ // #4485 — a var that already existed and now holds another
10785
+ // value, for the cached-vars note after the function leg.
10786
+ const before = remoteVarsSnapshot.find((r) => r.key === item.key);
10787
+ if (before && before.value !== item.value) {
10788
+ changedExistingVarKeys.push(item.key);
10789
+ }
10308
10790
  }
10309
10791
  catch (err) {
10310
10792
  // A remote edit landed since our snapshot — report a conflict
@@ -12276,7 +12758,7 @@ What a push guarantees (two stages):
12276
12758
  let deferredAgentPromptFiles = [];
12277
12759
  if (existsSync(promptsDir)) {
12278
12760
  const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
12279
- const ordered = partitionPromptFilesForPush(promptsDir, files);
12761
+ const ordered = partitionPromptFilesForPush(promptsDir, files, preflightAgentFilesFirst);
12280
12762
  deferredAgentPromptFiles = ordered.afterFunctions;
12281
12763
  await pushPromptFiles(ordered.beforeFunctions);
12282
12764
  }
@@ -13361,7 +13843,9 @@ What a push guarantees (two stages):
13361
13843
  typeGateBlocked = true;
13362
13844
  }
13363
13845
  else {
13364
- throw wrapEntityError(err, "update", "database type", dbType);
13846
+ // A fresh type's failure here is its create dry-run's, so it
13847
+ // is named a create, as the apply path names it.
13848
+ throw wrapEntityError(err, typeExistsOnServer ? "update" : "create", "database type", dbType);
13365
13849
  }
13366
13850
  }
13367
13851
  // If the validate pass found a gate failure, record the change
@@ -13735,18 +14219,31 @@ What a push guarantees (two stages):
13735
14219
  // consulted per function, so one broken file refuses itself and
13736
14220
  // nothing else; `--no-typecheck` is the escape hatch, and an
13737
14221
  // unavailable compiler warns rather than failing the push.
14222
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
14223
+ // #4502 — the directories outside the tree that function code may
14224
+ // import from, resolved once for the push so a dropped entry is
14225
+ // named once rather than once per function. The bundler and the
14226
+ // checker are handed the same list, so they agree on what is shared.
14227
+ const functionSourceRoots = files.length > 0
14228
+ ? resolveFunctionSourceRoots(configDir)
14229
+ : { roots: [], warnings: [] };
14230
+ for (const warning of functionSourceRoots.warnings)
14231
+ warn(` ${warning}`);
14232
+ const sourceRoots = functionSourceRoots.roots.map((root) => root.absolute);
13738
14233
  let typecheckSetup = null;
13739
14234
  const functionTypechecker = () => {
13740
14235
  if (options.typecheck === false)
13741
14236
  return null;
13742
14237
  if (typecheckSetup === null) {
13743
- typecheckSetup = createFunctionTypechecker(configDir);
14238
+ typecheckSetup = createFunctionTypechecker(configDir, {
14239
+ sourceRoots,
14240
+ onWarning: (message) => warn(` ${message}`),
14241
+ });
13744
14242
  for (const warning of typecheckSetup.warnings)
13745
14243
  warn(` ${warning}`);
13746
14244
  }
13747
14245
  return typecheckSetup.checker;
13748
14246
  };
13749
- const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
13750
14247
  const functionSpec = configDiffSpec("function");
13751
14248
  const liveFunctions = new Map();
13752
14249
  let functionsLiveOk = true;
@@ -13807,13 +14304,15 @@ What a push guarantees (two stages):
13807
14304
  // Build first: nothing is sent for a function whose code does not
13808
14305
  // compile or whose imports leave the tree.
13809
14306
  let push;
14307
+ let projectSources;
13810
14308
  try {
13811
- push = await buildFunctionConfigPush({
14309
+ ({ push, projectSources } = await buildFunctionConfigPushDetailed({
13812
14310
  configDir,
13813
14311
  functionKey: key,
13814
14312
  filePath,
13815
14313
  tomlData,
13816
- });
14314
+ sourceRoots,
14315
+ }));
13817
14316
  }
13818
14317
  catch (err) {
13819
14318
  if (!(err instanceof FunctionBuildError))
@@ -13843,7 +14342,7 @@ What a push guarantees (two stages):
13843
14342
  // sent for it and whether or not this is a dry run.
13844
14343
  const typechecker = functionTypechecker();
13845
14344
  if (typechecker) {
13846
- const failure = typechecker.check(key, push.sources.map((source) => source.path));
14345
+ const failure = typechecker.check(key, push.sources.map((source) => source.path), projectSources);
13847
14346
  if (failure) {
13848
14347
  applyFailures.push({
13849
14348
  type: "function",
@@ -13933,6 +14432,11 @@ What a push guarantees (two stages):
13933
14432
  const codeUnchanged = !!liveFunction &&
13934
14433
  !!activeVersion &&
13935
14434
  String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
14435
+ // #4485 — the server makes no new version of it, so a running one
14436
+ // keeps the vars it has cached.
14437
+ functionKeysEvaluated.add(key);
14438
+ if (codeUnchanged)
14439
+ functionsWithUnchangedCode++;
13936
14440
  if (headerAction === "skip" && codeUnchanged) {
13937
14441
  skippedCount++;
13938
14442
  if (syncState && liveFunction?.functionId) {
@@ -14015,6 +14519,7 @@ What a push guarantees (two stages):
14015
14519
  type: "function",
14016
14520
  key,
14017
14521
  message: String(err?.message ?? err),
14522
+ transient: isTransientRequestFailure(err),
14018
14523
  });
14019
14524
  // A refused create, or a refused push to a row the server does
14020
14525
  // not hold live (an archived tombstone). A row this push created
@@ -14847,6 +15352,7 @@ What a push guarantees (two stages):
14847
15352
  type: "email-template",
14848
15353
  key: emailType,
14849
15354
  message: err?.message || String(err),
15355
+ transient: isTransientRequestFailure(err),
14850
15356
  });
14851
15357
  }
14852
15358
  }
@@ -15284,6 +15790,27 @@ What a push guarantees (two stages):
15284
15790
  }
15285
15791
  }
15286
15792
  }
15793
+ // #4485 (D4485-001) — an override changed a var some unchanged
15794
+ // function may already have read and cached. Informational only.
15795
+ if (varsOverrideFile && !options.dryRun && changedExistingVarKeys.length > 0) {
15796
+ let functionsKeepingVersion = functionsWithUnchangedCode;
15797
+ try {
15798
+ const listed = await client.listFunctions(resolvedAppId);
15799
+ for (const f of (listed?.items ?? [])) {
15800
+ if (f?.functionKey &&
15801
+ String(f.status ?? "active") !== "archived" &&
15802
+ !functionKeysEvaluated.has(f.functionKey)) {
15803
+ functionsKeepingVersion++;
15804
+ }
15805
+ }
15806
+ }
15807
+ catch {
15808
+ // Informational only: without the list, count what was evaluated.
15809
+ }
15810
+ const note = cachedVarsNote(changedExistingVarKeys, functionsKeepingVersion);
15811
+ if (note)
15812
+ warn(note);
15813
+ }
15287
15814
  divider();
15288
15815
  // Issue #813: a gate failure surfaced by the validate-first pass (which
15289
15816
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -15362,6 +15889,17 @@ What a push guarantees (two stages):
15362
15889
  else {
15363
15890
  error(failureSummary);
15364
15891
  }
15892
+ writePushFailureReport(pushFailureIsTransient({
15893
+ applyFailures,
15894
+ conflicts: conflicts.length,
15895
+ blockedDatabaseTypes: blockedDatabaseTypes.length,
15896
+ schemaErrors: schemaErrors.schemaRequired.length +
15897
+ schemaErrors.opRefs.length +
15898
+ schemaErrors.schemaBreaks.length +
15899
+ schemaErrors.uncheckableOps.length +
15900
+ schemaErrors.tomlParse.length +
15901
+ schemaErrors.opsExist.length,
15902
+ }));
15365
15903
  process.exit(1);
15366
15904
  }
15367
15905
  else {
@@ -15402,6 +15940,7 @@ What a push guarantees (two stages):
15402
15940
  // Print message + structured server-side validation details
15403
15941
  // (issue #684; now via the shared #1173 helper).
15404
15942
  printApiError(err);
15943
+ writePushFailureReport(isTransientRequestFailure(err));
15405
15944
  process.exit(1);
15406
15945
  }
15407
15946
  });
@@ -15824,6 +16363,36 @@ What a push guarantees (two stages):
15824
16363
  localVars.set(key, rawValue);
15825
16364
  }
15826
16365
  }
16366
+ // #4485 — on a machine-local environment, its own override layered
16367
+ // over vars.toml: the values its push would send.
16368
+ const diffEnv = requireCurrentEnvironment();
16369
+ const diffVarsOverridePath = resolveVarsOverridePath(configDir, diffEnv);
16370
+ let diffVarsOverride = null;
16371
+ if (diffVarsOverridePath && existsSync(diffVarsOverridePath)) {
16372
+ const parsed = parseTomlFile(diffVarsOverridePath);
16373
+ const baseKeys = new Set(existsSync(varsTomlPathDiff)
16374
+ ? Object.keys(parseTomlFile(varsTomlPathDiff))
16375
+ : []);
16376
+ const keys = Object.keys(parsed).sort();
16377
+ const overridingKeys = keys.filter((key) => baseKeys.has(key));
16378
+ const addedKeys = keys.filter((key) => !baseKeys.has(key));
16379
+ if (varsOutcomeDiff.ok) {
16380
+ for (const [key, rawValue] of Object.entries(parsed)) {
16381
+ if (typeof rawValue === "string")
16382
+ localVars.set(key, rawValue);
16383
+ }
16384
+ }
16385
+ diffVarsOverride = {
16386
+ file: [PROJECT_TREE_DIR, diffEnv.tree, basename(diffVarsOverridePath)].join("/"),
16387
+ keys,
16388
+ };
16389
+ if (!options.json) {
16390
+ info(varsOverrideSummaryLine(basename(diffVarsOverridePath), {
16391
+ overridingKeys,
16392
+ addedKeys,
16393
+ }));
16394
+ }
16395
+ }
15827
16396
  // Compare
15828
16397
  let differences = [];
15829
16398
  // Issue #1659: a local file with no server entity means one of two very
@@ -16384,7 +16953,9 @@ What a push guarantees (two stages):
16384
16953
  }
16385
16954
  }
16386
16955
  // Config vars — issue #1423 (see `diffVars`).
16387
- for (const row of diffVars(localVars, remoteVars)) {
16956
+ for (const row of diffVars(localVars, remoteVars,
16957
+ // A child's pull writes its override, created or not (#4485).
16958
+ diffVarsOverridePath ? basename(diffVarsOverridePath) : "vars.toml")) {
16388
16959
  differences.push(row);
16389
16960
  }
16390
16961
  // Database-config types (issue #1485). rule-sets, group/collection-type
@@ -16555,6 +17126,7 @@ What a push guarantees (two stages):
16555
17126
  const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, {
16556
17127
  excludeName,
16557
17128
  nameOptional: diffsLocalEnvironment,
17129
+ excludeProtection: diffsLocalEnvironment,
16558
17130
  });
16559
17131
  if (appErrors.length > 0) {
16560
17132
  appRow(VALIDATION_ERROR, appErrors.join("; "));
@@ -16569,7 +17141,7 @@ What a push guarantees (two stages):
16569
17141
  });
16570
17142
  }
16571
17143
  else {
16572
- const fieldDiffs = appSettingsFieldDiffs(localAppSettings, settingsOutcome.value ?? {}, { excludeName });
17144
+ const fieldDiffs = appSettingsFieldDiffs(localAppSettings, settingsOutcome.value ?? {}, { excludeName, excludeProtection: diffsLocalEnvironment });
16573
17145
  if (fieldDiffs.length === 0) {
16574
17146
  appRow("exists");
16575
17147
  }
@@ -16647,6 +17219,7 @@ What a push guarantees (two stages):
16647
17219
  differences,
16648
17220
  testCases: testCaseDiffs,
16649
17221
  notCompared,
17222
+ ...(diffVarsOverride ? { varsOverride: diffVarsOverride } : {}),
16650
17223
  }, null, 2));
16651
17224
  return;
16652
17225
  }