primitive-admin 1.2.0-alpha.5 → 1.2.0-alpha.7

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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/src/commands/apps-children.d.ts +1 -1
  3. package/dist/src/commands/apps-children.js +170 -28
  4. package/dist/src/commands/apps-children.js.map +1 -1
  5. package/dist/src/commands/auth.js +41 -6
  6. package/dist/src/commands/auth.js.map +1 -1
  7. package/dist/src/commands/config.js +43 -4
  8. package/dist/src/commands/config.js.map +1 -1
  9. package/dist/src/commands/databases.js +130 -53
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/init.d.ts +7 -0
  12. package/dist/src/commands/init.js +9 -1
  13. package/dist/src/commands/init.js.map +1 -1
  14. package/dist/src/commands/sync.d.ts +118 -6
  15. package/dist/src/commands/sync.js +594 -55
  16. package/dist/src/commands/sync.js.map +1 -1
  17. package/dist/src/lib/child-apps-local.d.ts +45 -21
  18. package/dist/src/lib/child-apps-local.js +68 -30
  19. package/dist/src/lib/child-apps-local.js.map +1 -1
  20. package/dist/src/lib/credentials-store.d.ts +38 -3
  21. package/dist/src/lib/credentials-store.js +175 -9
  22. package/dist/src/lib/credentials-store.js.map +1 -1
  23. package/dist/src/lib/function-collect.d.ts +1 -1
  24. package/dist/src/lib/function-collect.js +4 -0
  25. package/dist/src/lib/function-collect.js.map +1 -1
  26. package/dist/src/lib/generated-config-surfaces.d.ts +148 -0
  27. package/dist/src/lib/generated-config-surfaces.js +208 -25
  28. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  29. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  30. package/dist/src/lib/generated-sdk-types.js +1 -1
  31. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  32. package/dist/src/lib/local-state.d.ts +21 -6
  33. package/dist/src/lib/local-state.js +78 -34
  34. package/dist/src/lib/local-state.js.map +1 -1
  35. package/dist/src/lib/push-failure-report.d.ts +45 -0
  36. package/dist/src/lib/push-failure-report.js +57 -0
  37. package/dist/src/lib/push-failure-report.js.map +1 -0
  38. package/dist/src/lib/sync-selectors.d.ts +5 -1
  39. package/dist/src/lib/sync-selectors.js +23 -13
  40. package/dist/src/lib/sync-selectors.js.map +1 -1
  41. package/dist/src/lib/transient-retry.d.ts +9 -0
  42. package/dist/src/lib/transient-retry.js +37 -0
  43. package/dist/src/lib/transient-retry.js.map +1 -1
  44. package/dist/src/lib/vars-override.d.ts +105 -0
  45. package/dist/src/lib/vars-override.js +143 -0
  46. package/dist/src/lib/vars-override.js.map +1 -0
  47. package/package.json +2 -2
@@ -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";
@@ -48,6 +51,9 @@ 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";
@@ -82,6 +88,8 @@ export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } fr
82
88
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
83
89
  import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, trackedSelectorKeys, unmatchedSelectors, } from "../lib/sync-selectors.js";
84
90
  import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
91
+ import { pushFailureIsTransient, writePushFailureReport } from "../lib/push-failure-report.js";
92
+ import { isTransientRequestFailure } from "../lib/transient-retry.js";
85
93
  import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
86
94
  import chalk from "chalk";
87
95
  function ensureDir(dirPath) {
@@ -152,7 +160,7 @@ export function wrapEntityError(err, action, kind, key) {
152
160
  return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
153
161
  }
154
162
  // Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
155
- const wrapped = new Error(wrappedMessage);
163
+ const wrapped = new Error(wrappedMessage, { cause: err });
156
164
  return wrapped;
157
165
  }
158
166
  /**
@@ -570,6 +578,69 @@ export function planVarsPull(outcome, priorVars) {
570
578
  }
571
579
  return { write: true, content: serializeVars(outcome.vars), varEntities };
572
580
  }
581
+ /**
582
+ * The var leg of `config pull` on a machine-local environment (#4485): the
583
+ * server's vars go into the environment's `vars.<name>.override.toml`, and
584
+ * `vars.toml` — the shared tree's, which merges to main — is never written.
585
+ * Each pulled key (every key, or `selectedKeys`) is resolved against
586
+ * `vars.toml` by `planVarsOverridePull`. The file is created only when a key
587
+ * ends up in it; an existing one is rewritten, possibly to its comment-only
588
+ * state. The baseline entries (`varEntities`) are filled exactly as a pull on
589
+ * a committed environment fills them. A failed fetch writes nothing and keeps
590
+ * the prior entries.
591
+ */
592
+ function pullVarsIntoOverride(input) {
593
+ const { outcome, overridePath, configDir, env, selectedKeys, priorVars, varEntities } = input;
594
+ const overrideFile = basename(overridePath);
595
+ if (!outcome.ok) {
596
+ const fetchError = outcome.error;
597
+ Object.assign(varEntities, priorVars ?? {});
598
+ error(` Skipped ${overrideFile} — failed to fetch config vars: ${fetchError?.message ?? fetchError}. ` +
599
+ `Left the existing ${overrideFile} untouched.`);
600
+ return;
601
+ }
602
+ const readTable = (path) => {
603
+ if (!existsSync(path))
604
+ return {};
605
+ try {
606
+ return parseConfigToml(readFileSync(path, "utf-8"));
607
+ }
608
+ catch (err) {
609
+ error(` Skipped ${overrideFile} — could not parse ${basename(path)}: ${err?.message || String(err)}`);
610
+ return null;
611
+ }
612
+ };
613
+ const base = readTable(join(configDir, "vars.toml"));
614
+ const current = readTable(overridePath);
615
+ if (base === null || current === null) {
616
+ Object.assign(varEntities, priorVars ?? {});
617
+ return;
618
+ }
619
+ Object.assign(varEntities, selectedKeys
620
+ ? planScopedVarsPull(outcome.vars, selectedKeys, {}, priorVars).varEntities
621
+ : planVarsPull(outcome).varEntities);
622
+ const plan = planVarsOverridePull(outcome.vars, base, selectedKeys, current, env);
623
+ const keys = Object.keys(plan.vars).sort();
624
+ const exists = existsSync(overridePath);
625
+ if (!exists && keys.length === 0)
626
+ return;
627
+ if (!exists) {
628
+ const coverage = ensureFileIgnored(overridePath, { reason: "primitive config pull" });
629
+ if (coverage.status === "failed") {
630
+ warn(` Could not confirm ${overrideFile} is git-ignored: ${coverage.detail}. ` +
631
+ `Add "${VARS_OVERRIDE_GITIGNORE_PATTERN}" to your .gitignore — the file is machine-local and must never be committed.`);
632
+ }
633
+ }
634
+ const written = writePulledFile(overridePath, plan.content, input.tally, sameTomlAs(plan.content));
635
+ if (written)
636
+ info(` Wrote ${overrideFile} (${keys.length} var${keys.length === 1 ? "" : "s"})`);
637
+ if (keys.length > 0) {
638
+ info(` ${varsOverrideSummaryLine(overrideFile, {
639
+ overridingKeys: keys.filter((key) => key in base),
640
+ addedKeys: keys.filter((key) => !(key in base)),
641
+ })}`);
642
+ }
643
+ }
573
644
  /**
574
645
  * `config pull --only var/<KEY>` — the same plan, narrowed to the named keys
575
646
  * (issue #2645).
@@ -710,27 +781,28 @@ export function validateWebhookCapForPush(localKeys, remoteKeys) {
710
781
  export function validateVarsFile(parsedVars, options = {}) {
711
782
  const errors = [];
712
783
  const { onlyKeys } = options;
784
+ const fileName = options.fileName ?? "vars.toml";
713
785
  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"}.`);
786
+ if (!onlyKeys && !options.skipCountCap && keyCount > MAX_VARS_PER_APP) {
787
+ 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
788
  }
717
789
  for (const [key, rawValue] of Object.entries(parsedVars)) {
718
790
  if (onlyKeys && !onlyKeys.has(key))
719
791
  continue;
720
792
  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)`);
793
+ 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
794
  continue;
723
795
  }
724
796
  if (typeof rawValue !== "string") {
725
- errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
797
+ errors.push(`Var "${key}" in ${fileName} must be a string value (got ${typeof rawValue}) — quote the value.`);
726
798
  continue;
727
799
  }
728
800
  const byteLength = new TextEncoder().encode(rawValue).length;
729
801
  if (byteLength === 0) {
730
- errors.push(`Var "${key}" in vars.toml must not be empty.`);
802
+ errors.push(`Var "${key}" in ${fileName} must not be empty.`);
731
803
  }
732
804
  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}).`);
805
+ errors.push(`Var "${key}" in ${fileName} exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
734
806
  }
735
807
  }
736
808
  return errors;
@@ -943,7 +1015,12 @@ export function countVarsAfterPush(remoteVars, plan) {
943
1015
  * local value). Pure and side-effect-free so it's unit-testable without a live
944
1016
  * server.
945
1017
  */
946
- export function diffVars(localVars, remoteVars) {
1018
+ export function diffVars(localVars, remoteVars,
1019
+ /**
1020
+ * The file `config pull` would write a server value into — a machine-local
1021
+ * environment's override file (#4485). Defaults to `vars.toml`.
1022
+ */
1023
+ pullTargetFile = "vars.toml") {
947
1024
  const rows = [];
948
1025
  for (const [key, localValue] of localVars) {
949
1026
  if (!remoteVars.has(key)) {
@@ -954,7 +1031,9 @@ export function diffVars(localVars, remoteVars) {
954
1031
  type: "var",
955
1032
  key,
956
1033
  status: "modified",
957
- hint: "run `config pull` to rewrite vars.toml to match the server value",
1034
+ hint: pullTargetFile === "vars.toml"
1035
+ ? "run `config pull` to rewrite vars.toml to match the server value"
1036
+ : `run \`config pull\` to write the server value into ${pullTargetFile}`,
958
1037
  });
959
1038
  }
960
1039
  else {
@@ -2483,7 +2562,7 @@ function localConfigWithServerIdentity(label, local, server, file) {
2483
2562
  * that being a visible change to this signature.
2484
2563
  */
2485
2564
  export async function runConfigPushPreflight(input) {
2486
- const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, } = input;
2565
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, varsOverridePath = null, } = input;
2487
2566
  /**
2488
2567
  * Narrow a type's file list to the `--only` selection — the same one-line
2489
2568
  * closure the action holds, rebuilt from the selection it passed in rather
@@ -2627,7 +2706,9 @@ export async function runConfigPushPreflight(input) {
2627
2706
  const preflightVarsTomlPath = join(configDir, "vars.toml");
2628
2707
  const varsSelected = selectionIncludesAnyVar(pushSelection);
2629
2708
  let preflightParsedVars = null;
2630
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
2709
+ let varsOverrideKeys = null;
2710
+ const overrideExists = !!varsOverridePath && existsSync(varsOverridePath);
2711
+ if (varsSelected && existsSync(preflightVarsTomlPath) && !overrideExists) {
2631
2712
  try {
2632
2713
  const parsed = parseTomlFile(preflightVarsTomlPath);
2633
2714
  // #2645 — under `--only var/<key>` only the selected entries are
@@ -2645,6 +2726,49 @@ export async function runConfigPushPreflight(input) {
2645
2726
  preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2646
2727
  }
2647
2728
  }
2729
+ else if (varsSelected && overrideExists) {
2730
+ // #4485 — a machine-local environment's own values, layered over
2731
+ // vars.toml. Each file is validated under its own name, and the count
2732
+ // cap is measured on the layered set: that is what the push sends.
2733
+ const overrideFile = basename(varsOverridePath);
2734
+ const onlyKeys = pushSelection ? pushSelection.varKeys : undefined;
2735
+ const tables = [];
2736
+ let parsedAll = true;
2737
+ for (const [path, fileName] of [
2738
+ [preflightVarsTomlPath, "vars.toml"],
2739
+ [varsOverridePath, overrideFile],
2740
+ ]) {
2741
+ if (!existsSync(path)) {
2742
+ tables.push(null);
2743
+ continue;
2744
+ }
2745
+ try {
2746
+ const parsed = parseTomlFile(path);
2747
+ for (const e of validateVarsFile(parsed, { onlyKeys, fileName, skipCountCap: true })) {
2748
+ preflightValidationErrors.push(` ${e}`);
2749
+ }
2750
+ tables.push(parsed);
2751
+ }
2752
+ catch (err) {
2753
+ parsedAll = false;
2754
+ preflightValidationErrors.push(` ${fileName}: ${err?.message || String(err)}`);
2755
+ }
2756
+ }
2757
+ if (parsedAll) {
2758
+ const [base, override] = tables;
2759
+ const layered = layerVars(base, override);
2760
+ const count = Object.keys(layered).length;
2761
+ if (!onlyKeys && count > MAX_VARS_PER_APP) {
2762
+ 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"}.`);
2763
+ }
2764
+ const overrideKeys = Object.keys(override ?? {}).sort();
2765
+ varsOverrideKeys = {
2766
+ overridingKeys: overrideKeys.filter((key) => base !== null && key in base),
2767
+ addedKeys: overrideKeys.filter((key) => base === null || !(key in base)),
2768
+ };
2769
+ preflightParsedVars = layered;
2770
+ }
2771
+ }
2648
2772
  // #3182 — a function's capabilities, checked against the author's own
2649
2773
  // file before anything is applied. The grammar is purely local (and
2650
2774
  // since #3279 it keeps three kinds: `integration:<key>`,
@@ -2740,6 +2864,20 @@ export async function runConfigPushPreflight(input) {
2740
2864
  // #3798 — the functions an agent's references are checked against, read
2741
2865
  // once and only when a selected prompt is an agent that names one.
2742
2866
  let agentFunctions = null;
2867
+ // The app's functions, listed at most once for the #3798 and #4441 checks.
2868
+ // `null` when the listing failed.
2869
+ let liveFunctionRows;
2870
+ const liveFunctionListing = async () => {
2871
+ if (liveFunctionRows === undefined) {
2872
+ try {
2873
+ liveFunctionRows = (await client.listFunctions(resolvedAppId)).items;
2874
+ }
2875
+ catch {
2876
+ liveFunctionRows = null;
2877
+ }
2878
+ }
2879
+ return liveFunctionRows;
2880
+ };
2743
2881
  if (existsSync(preflightPromptsDir)) {
2744
2882
  for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2745
2883
  const filePath = join(preflightPromptsDir, file);
@@ -2757,17 +2895,10 @@ export async function runConfigPushPreflight(input) {
2757
2895
  if (promptKindOf(tomlData?.prompt) === "agent" &&
2758
2896
  agentDeclarationFunctionKeys(tomlData?.prompt?.agent).length > 0) {
2759
2897
  if (!agentFunctions) {
2760
- let live = null;
2761
- try {
2762
- live = (await client.listFunctions(resolvedAppId)).items;
2763
- }
2764
- catch {
2765
- live = null;
2766
- }
2767
2898
  agentFunctions = agentFunctionFactsForPush({
2768
2899
  configDir,
2769
2900
  pushSelection,
2770
- live,
2901
+ live: await liveFunctionListing(),
2771
2902
  });
2772
2903
  }
2773
2904
  for (const message of promptAgentFunctionErrors(filePath, tomlData, agentFunctions)) {
@@ -2776,6 +2907,88 @@ export async function runConfigPushPreflight(input) {
2776
2907
  }
2777
2908
  }
2778
2909
  }
2910
+ // #4441 — a function file this push CHANGES, checked against the live
2911
+ // agents that use it: refused here when an agent the push does not select
2912
+ // cannot take the change, and the agent files that must release a reference
2913
+ // first are moved ahead of the functions leg. Lists the app's prompts only
2914
+ // when a selected function file changes a live function.
2915
+ let agentFilesFirst = [];
2916
+ const preflightFunctionFilesDir = join(configDir, "functions");
2917
+ const selectedFunctionFiles = [];
2918
+ if (existsSync(preflightFunctionFilesDir)) {
2919
+ for (const file of selectFiles("function", preflightFunctionFilesDir, readdirSync(preflightFunctionFilesDir).filter(isFunctionDeclarationFile))) {
2920
+ let table;
2921
+ try {
2922
+ table = parseTomlFile(join(preflightFunctionFilesDir, file))?.function ?? {};
2923
+ }
2924
+ catch {
2925
+ // Reported by the function preflight with its own message.
2926
+ continue;
2927
+ }
2928
+ selectedFunctionFiles.push({ file, key: table.key || basename(file, ".toml") });
2929
+ }
2930
+ }
2931
+ if (selectedFunctionFiles.length > 0) {
2932
+ const liveRows = await liveFunctionListing();
2933
+ const live = liveAgentFunctionFacts(liveRows);
2934
+ const changed = functionChangePreflightKeys(selectedFunctionFiles, live);
2935
+ if (liveRows === null) {
2936
+ warn("The app's functions could not be listed, so the function files this " +
2937
+ "push changes were not checked against the agents that use them. The " +
2938
+ "server still refuses a change an agent cannot take.");
2939
+ }
2940
+ else if (changed.length > 0) {
2941
+ let prompts = null;
2942
+ const configsOf = new Map();
2943
+ try {
2944
+ prompts = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
2945
+ for (const agent of functionChangePreflightAgents(prompts, changed.map((entry) => entry.key))) {
2946
+ const { items } = await client.listPromptConfigs(resolvedAppId, agent.promptId);
2947
+ configsOf.set(agent.promptId, items.map((config) => ({
2948
+ configName: String(config?.configName ?? ""),
2949
+ provider: String(config?.provider ?? ""),
2950
+ status: String(config?.status ?? "active"),
2951
+ })));
2952
+ }
2953
+ }
2954
+ catch {
2955
+ prompts = null;
2956
+ }
2957
+ const selectedAgentFiles = [];
2958
+ if (existsSync(preflightPromptsDir)) {
2959
+ for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2960
+ let tomlData;
2961
+ try {
2962
+ tomlData = parseTomlFile(join(preflightPromptsDir, file));
2963
+ }
2964
+ catch {
2965
+ continue;
2966
+ }
2967
+ if (promptKindOf(tomlData?.prompt) !== "agent")
2968
+ continue;
2969
+ selectedAgentFiles.push({
2970
+ file,
2971
+ promptKey: tomlData?.prompt?.key || basename(file, ".toml"),
2972
+ tomlData,
2973
+ });
2974
+ }
2975
+ }
2976
+ const checked = functionChangePreflight({
2977
+ selectedFunctionFiles,
2978
+ live,
2979
+ pushed: agentFunctionFactsForPush({ configDir, pushSelection, live: liveRows }).facts,
2980
+ prompts,
2981
+ configsOf,
2982
+ selectedAgentFiles,
2983
+ });
2984
+ for (const message of checked.warnings)
2985
+ warn(message);
2986
+ for (const message of checked.errors) {
2987
+ preflightValidationErrors.push(` ${message}`);
2988
+ }
2989
+ agentFilesFirst = checked.agentFilesFirst;
2990
+ }
2991
+ }
2779
2992
  // #3798, #4262 — a prompt's sidecar against its prompt's kind: an agent's
2780
2993
  // case is checked by the agent test case's shape rule, and `[test.agent]`
2781
2994
  // on a chat or decisions prompt's case is refused, before any file is
@@ -2805,7 +3018,7 @@ export async function runConfigPushPreflight(input) {
2805
3018
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2806
3019
  preflightValidationErrors.join("\n"));
2807
3020
  }
2808
- return { parsedVars: preflightParsedVars };
3021
+ return { parsedVars: preflightParsedVars, agentFilesFirst, varsOverrideKeys };
2809
3022
  }
2810
3023
  /**
2811
3024
  * Every per-entity config file whose TOML `config push` would refuse (#2880
@@ -3037,21 +3250,9 @@ function declaresSchema(value) {
3037
3250
  * availability on every call).
3038
3251
  */
3039
3252
  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
- }
3253
+ const facts = {
3254
+ ...liveAgentFunctionFacts(input.live),
3255
+ };
3055
3256
  const functionsDir = join(input.configDir, "functions");
3056
3257
  if (existsSync(functionsDir)) {
3057
3258
  for (const file of filterFilesBySelection(input.pushSelection, "function", functionsDir, readdirSync(functionsDir).filter(isFunctionDeclarationFile))) {
@@ -3076,6 +3277,196 @@ export function agentFunctionFactsForPush(input) {
3076
3277
  }
3077
3278
  return { facts, liveListed: input.live !== null };
3078
3279
  }
3280
+ /**
3281
+ * The functions the app holds now, as the agent rules read them — #3798. A
3282
+ * live row that is archived is a tombstone, not a function, so it is absent.
3283
+ */
3284
+ export function liveAgentFunctionFacts(live) {
3285
+ const facts = {};
3286
+ for (const row of live ?? []) {
3287
+ const key = String(row?.functionKey ?? "");
3288
+ if (!key || row?.status === "archived")
3289
+ continue;
3290
+ facts[key] = {
3291
+ hasInputSchema: declaresSchema(row.inputSchema),
3292
+ hasOutputSchema: declaresSchema(row.outputSchema),
3293
+ // #3799 — the schemas themselves, as stored (a JSON string from the API,
3294
+ // an object from a tree file), so a server tool's two schemas can be
3295
+ // translated for each of the agent's providers.
3296
+ inputSchema: row.inputSchema,
3297
+ outputSchema: row.outputSchema,
3298
+ };
3299
+ }
3300
+ return facts;
3301
+ }
3302
+ /**
3303
+ * The selected function files that CHANGE a live function — #4441. A file
3304
+ * whose function is not live creates it, which no agent can depend on yet.
3305
+ * The push lists the app's prompts only when this is non-empty.
3306
+ */
3307
+ export function functionChangePreflightKeys(selectedFunctionFiles, live) {
3308
+ return selectedFunctionFiles.filter((entry) => Object.prototype.hasOwnProperty.call(live, entry.key) && live[entry.key] != null);
3309
+ }
3310
+ /**
3311
+ * The live, non-archived agent prompts that name at least one of `keys`.
3312
+ * Whose configs the push reads for `functionChangePreflight`.
3313
+ */
3314
+ export function functionChangePreflightAgents(prompts, keys) {
3315
+ return prompts.filter((prompt) => promptKindOf(prompt) === "agent" &&
3316
+ prompt.status !== "archived" &&
3317
+ agentDeclarationFunctionKeys(prompt.agent).some((key) => keys.includes(key)));
3318
+ }
3319
+ /** `tool "lookup"` for a tool's name, the path itself for a hook. */
3320
+ function functionUseLabels(uses) {
3321
+ return uses.map((use) => (use.includes(".") ? use : `tool "${use}"`)).join(", ");
3322
+ }
3323
+ /**
3324
+ * Would a function change this push makes break a live agent? — #4441.
3325
+ *
3326
+ * The same rule the function routes run (`agentFunctionChangeRefusals`), so a
3327
+ * change the preflight lets through is one the server accepts, run for every
3328
+ * selected file that changes a live function against every live non-archived
3329
+ * agent that names it, with every config of that agent:
3330
+ *
3331
+ * - an agent whose prompt file this push does NOT select would be broken by
3332
+ * the change: a preflight error naming the function file, the agent and its
3333
+ * uses, and the remedy (change the agent in the same push, or retire it);
3334
+ * - an agent whose file IS selected keeps the existing order (applied after
3335
+ * the functions leg) unless its LIVE declaration would be broken by the
3336
+ * change. Then the file must go first to release the reference, which it
3337
+ * can only do when it is valid against the functions the app holds NOW —
3338
+ * its references, the providers of its non-archived configs, and the
3339
+ * providers of the configs the server still holds at its prompt PATCH. When it
3340
+ * is, it joins `agentFilesFirst`; when it is not, the push cannot be
3341
+ * ordered in one run and the error names the sequence that can.
3342
+ *
3343
+ * `prompts: null` (the listing failed) checks nothing and says so once; the
3344
+ * server's refusal remains.
3345
+ */
3346
+ export function functionChangePreflight(input) {
3347
+ const result = {
3348
+ errors: [],
3349
+ warnings: [],
3350
+ agentFilesFirst: [],
3351
+ };
3352
+ const changed = functionChangePreflightKeys(input.selectedFunctionFiles, input.live);
3353
+ if (changed.length === 0)
3354
+ return result;
3355
+ if (input.prompts === null) {
3356
+ result.warnings.push("The app's prompts could not be listed, so the function files this push " +
3357
+ "changes were not checked against the agents that use them. The server " +
3358
+ "still refuses a change an agent cannot take.");
3359
+ return result;
3360
+ }
3361
+ const agents = functionChangePreflightAgents(input.prompts, changed.map((entry) => entry.key));
3362
+ for (const agent of agents) {
3363
+ const configs = input.configsOf.get(agent.promptId) ?? [];
3364
+ const named = agentDeclarationFunctionKeys(agent.agent);
3365
+ // Per changed function, so each refusal is attributed to its file.
3366
+ const broken = [];
3367
+ for (const entry of changed) {
3368
+ if (!named.includes(entry.key))
3369
+ continue;
3370
+ const refusals = agentFunctionChangeRefusals({
3371
+ declaration: agent.agent,
3372
+ configs,
3373
+ before: input.live,
3374
+ after: { ...input.live, [entry.key]: input.pushed[entry.key] ?? null },
3375
+ });
3376
+ if (refusals.length > 0)
3377
+ broken.push({ entry, refusals });
3378
+ }
3379
+ if (broken.length === 0)
3380
+ continue;
3381
+ const agentFile = input.selectedAgentFiles.find((candidate) => candidate.promptKey === agent.promptKey);
3382
+ if (!agentFile) {
3383
+ for (const { entry, refusals } of broken) {
3384
+ result.errors.push(`functions/${entry.file}: agent "${agent.promptKey}" ` +
3385
+ `(prompts/${agent.promptKey}.toml, not in this push) uses function ` +
3386
+ `"${entry.key}" as ${functionUseLabels(agentFunctionUses(agent.agent, entry.key))}, ` +
3387
+ `and this change would break it: ` +
3388
+ `${refusals.map((refusal) => refusal.message).join("; ")}. Change the ` +
3389
+ "agent in this same push so it no longer needs what the change " +
3390
+ "removes, or retire the agent.");
3391
+ }
3392
+ continue;
3393
+ }
3394
+ // The file must go first. Can it, against the functions the app holds now?
3395
+ // Its prompt PATCH is checked against the configs the server still holds
3396
+ // at that moment (CSO-4441-001): every non-archived one except those the
3397
+ // file retires, which the apply stages ahead of the PATCH. The file's own
3398
+ // config writes follow the PATCH and are checked against its entries.
3399
+ const fileAgent = agentFile.tomlData?.prompt?.agent;
3400
+ const blockers = promptAgentFunctionRefusalsAgainst(agentFile.tomlData, input.live, heldAtPromptPatch(agentFile.tomlData, configs));
3401
+ const blocking = broken.map(({ entry }) => `functions/${entry.file}`).join(", ");
3402
+ if (blockers.length === 0) {
3403
+ result.agentFilesFirst.push(agentFile.file);
3404
+ continue;
3405
+ }
3406
+ const created = agentDeclarationFunctionKeys(fileAgent).filter((key) => input.live[key] == null && input.pushed[key] != null);
3407
+ const lead = `prompts/${agentFile.file}: agent "${agent.promptKey}" must be applied ` +
3408
+ `before ${blocking}, because its live declaration cannot take that ` +
3409
+ "change";
3410
+ if (created.length > 0) {
3411
+ result.errors.push(`${lead}, but it names ${created.map((key) => `function "${key}"`).join(", ")}, ` +
3412
+ "which this push creates. Push the new function first, then rerun " +
3413
+ `this push: primitive config push --only ` +
3414
+ `${created.map((key) => `function/${key}`).join(",")}`);
3415
+ }
3416
+ else {
3417
+ result.errors.push(`${lead}, but it cannot be applied against the functions and configs ` +
3418
+ `the app holds now: ${blockers.map((refusal) => refusal.message).join("; ")}. ` +
3419
+ "Split the change: push the agent's change on its own first, without " +
3420
+ "what the live functions and configs cannot satisfy, then rerun this push.");
3421
+ }
3422
+ }
3423
+ return result;
3424
+ }
3425
+ /**
3426
+ * The function refusals an agent prompt file owes against `functions`: a
3427
+ * missing function or tool schema, and a server-tool schema one of the file's
3428
+ * non-archived configs cannot carry. The file-decidable refusals are left to
3429
+ * the collector that already reports them.
3430
+ */
3431
+ function promptAgentFunctionRefusalsAgainst(tomlData, functions, heldConfigs = []) {
3432
+ const agent = tomlData?.prompt?.agent;
3433
+ const resolved = resolveAgentDeclaration({ kind: "agent", agent, functions });
3434
+ const refusals = resolved.ok === false
3435
+ ? resolved.refusals.filter((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" ||
3436
+ refusal.code === "AGENT_TOOL_FUNCTION_SCHEMA_MISSING")
3437
+ : [];
3438
+ const seen = new Set();
3439
+ for (const configs of [
3440
+ promptFileConfigFacts(Array.isArray(tomlData?.configs) ? tomlData.configs : []),
3441
+ [...heldConfigs],
3442
+ ]) {
3443
+ const fileDecidable = new Set(agentSchemaTranslationRefusals({ declaration: agent, configs }).map((refusal) => refusal.message));
3444
+ for (const refusal of agentSchemaTranslationRefusals({
3445
+ declaration: agent,
3446
+ functions,
3447
+ configs,
3448
+ })) {
3449
+ if (fileDecidable.has(refusal.message) || seen.has(refusal.message))
3450
+ continue;
3451
+ seen.add(refusal.message);
3452
+ refusals.push(refusal);
3453
+ }
3454
+ }
3455
+ return refusals;
3456
+ }
3457
+ /**
3458
+ * The configs the server holds for an agent when a push's prompt PATCH
3459
+ * reaches it — #4441 (CSO-4441-001): every non-archived one but those the
3460
+ * file archives, which the apply stages ahead of the PATCH. Each keeps the
3461
+ * provider it has NOW; the file's change to it is sent after the PATCH.
3462
+ */
3463
+ function heldAtPromptPatch(tomlData, configs) {
3464
+ const retired = new Set(promptFileConfigFacts(Array.isArray(tomlData?.configs) ? tomlData.configs : [])
3465
+ .filter((entry) => entry.status.trim().toLowerCase() === "archived")
3466
+ .map((entry) => entry.configName));
3467
+ return configs.filter((config) => String(config.status ?? "active").trim().toLowerCase() !== "archived" &&
3468
+ !retired.has(config.configName));
3469
+ }
3079
3470
  /**
3080
3471
  * The function half of an agent's declaration — #3798: every tool, turn
3081
3472
  * context and history function that the functions this push believes
@@ -3130,9 +3521,10 @@ export function promptAgentFunctionErrors(filePath, tomlData, known) {
3130
3521
  * server functions that must exist when it is created, and the push applies
3131
3522
  * prompts before functions, so agent files go AFTER the server-functions leg;
3132
3523
  * every other file keeps its place. A file that does not parse stays first, so
3133
- * the leg reports it where it always has.
3524
+ * the leg reports it where it always has. #4441 — the agent files in
3525
+ * `agentFilesFirst` (`functionChangePreflight`) go before the leg instead.
3134
3526
  */
3135
- export function partitionPromptFilesForPush(promptsDir, files) {
3527
+ export function partitionPromptFilesForPush(promptsDir, files, agentFilesFirst = []) {
3136
3528
  const beforeFunctions = [];
3137
3529
  const afterFunctions = [];
3138
3530
  for (const file of files) {
@@ -3144,7 +3536,11 @@ export function partitionPromptFilesForPush(promptsDir, files) {
3144
3536
  beforeFunctions.push(file);
3145
3537
  continue;
3146
3538
  }
3147
- (kind === "agent" ? afterFunctions : beforeFunctions).push(file);
3539
+ // #4441 — an agent file the preflight found must release a function
3540
+ // reference before the functions leg changes that function.
3541
+ (kind === "agent" && !agentFilesFirst.includes(file)
3542
+ ? afterFunctions
3543
+ : beforeFunctions).push(file);
3148
3544
  }
3149
3545
  return { beforeFunctions, afterFunctions };
3150
3546
  }
@@ -6343,8 +6739,8 @@ export async function listAllTestCases(client, appId, blockType, blockId) {
6343
6739
  try {
6344
6740
  page = await client.listTestCases(appId, blockType, blockId, cursor ? { cursor } : {});
6345
6741
  }
6346
- catch {
6347
- return { ok: false };
6742
+ catch (error) {
6743
+ return { ok: false, error };
6348
6744
  }
6349
6745
  items.push(...(page.items || []));
6350
6746
  const next = page.nextCursor;
@@ -6821,6 +7217,7 @@ export async function pushTestCasesForBlock(params) {
6821
7217
  const testCaseExtra = { lookupMaps: params.lookupMaps };
6822
7218
  const listed = await listAllTestCases(client, appId, blockType, blockId);
6823
7219
  const liveOk = listed.ok;
7220
+ const liveReadTransient = "error" in listed && isTransientRequestFailure(listed.error);
6824
7221
  if (!liveOk) {
6825
7222
  warn(` ${blockKey} test cases: live state unavailable — falling back to last-sync content hashes`);
6826
7223
  }
@@ -7082,6 +7479,7 @@ export async function pushTestCasesForBlock(params) {
7082
7479
  type: "test-case",
7083
7480
  key: `${blockKey}/${slug}`,
7084
7481
  message,
7482
+ transient: isTransientRequestFailure(err),
7085
7483
  });
7086
7484
  }
7087
7485
  continue;
@@ -7129,7 +7527,7 @@ export async function pushTestCasesForBlock(params) {
7129
7527
  continue;
7130
7528
  }
7131
7529
  if (degraded.action === "decline") {
7132
- params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable" }, existingEntry?.modifiedAt);
7530
+ params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable", transient: liveReadTransient }, existingEntry?.modifiedAt);
7133
7531
  continue;
7134
7532
  }
7135
7533
  }
@@ -7192,7 +7590,12 @@ export async function pushTestCasesForBlock(params) {
7192
7590
  catch (err) {
7193
7591
  const message = err?.message || String(err);
7194
7592
  warn(` Failed to update test case ${blockKey}/${slug}: ${message}`);
7195
- failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
7593
+ failures.push({
7594
+ type: "test-case",
7595
+ key: `${blockKey}/${slug}`,
7596
+ message,
7597
+ transient: isTransientRequestFailure(err),
7598
+ });
7196
7599
  continue;
7197
7600
  }
7198
7601
  }
@@ -7266,7 +7669,12 @@ export async function pushTestCasesForBlock(params) {
7266
7669
  ? `${keyError} — rename ${file} to a path-safe file name`
7267
7670
  : serverMessage;
7268
7671
  warn(` Failed to create test case ${blockKey}/${slug}: ${message}`);
7269
- failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
7672
+ failures.push({
7673
+ type: "test-case",
7674
+ key: `${blockKey}/${slug}`,
7675
+ message,
7676
+ transient: isTransientRequestFailure(err),
7677
+ });
7270
7678
  continue;
7271
7679
  }
7272
7680
  }
@@ -7310,6 +7718,7 @@ export async function pushTestCasesForBlock(params) {
7310
7718
  type: "attachment",
7311
7719
  key: `${blockKey}/${slug}/${filename}`,
7312
7720
  message,
7721
+ transient: isTransientRequestFailure(err),
7313
7722
  });
7314
7723
  }
7315
7724
  }
@@ -7881,7 +8290,7 @@ export async function applyTestCasePrune(params) {
7881
8290
  catch (err) {
7882
8291
  const message = err?.message || String(err);
7883
8292
  warn(` Failed to delete attachment ${key}: ${message}`);
7884
- params.failures.push({ type: "attachment", key, message });
8293
+ params.failures.push({ type: "attachment", key, message, transient: isTransientRequestFailure(err) });
7885
8294
  }
7886
8295
  }
7887
8296
  for (const candidate of params.candidates) {
@@ -7916,7 +8325,7 @@ export async function applyTestCasePrune(params) {
7916
8325
  catch (err) {
7917
8326
  const message = err?.message || String(err);
7918
8327
  warn(` Failed to delete test case ${key}: ${message}`);
7919
- params.failures.push({ type: "test-case", key, message });
8328
+ params.failures.push({ type: "test-case", key, message, transient: isTransientRequestFailure(err) });
7920
8329
  }
7921
8330
  }
7922
8331
  return { deleted, deletedAttachments };
@@ -8020,6 +8429,8 @@ export function registerConfigSyncCommands(sync) {
8020
8429
  const resolvedAppId = getCurrentAppId();
8021
8430
  const configDir = resolveSyncDir();
8022
8431
  const pullsLocalEnvironment = requireCurrentEnvironment().local;
8432
+ // #4485 — null for a committed environment, which never reads one.
8433
+ const pullVarsOverridePath = resolveVarsOverridePath(configDir, requireCurrentEnvironment());
8023
8434
  if (!options.json) {
8024
8435
  info(`Using per-environment config directory: ${configDir}`);
8025
8436
  }
@@ -8312,7 +8723,27 @@ export function registerConfigSyncCommands(sync) {
8312
8723
  catch (varErr) {
8313
8724
  varsOutcome = { ok: false, error: varErr };
8314
8725
  }
8315
- if (pullSelection && varsOutcome.ok) {
8726
+ if (pullVarsOverridePath) {
8727
+ // #4485 — a machine-local environment never writes vars.toml: the
8728
+ // tree's file is the parent's. Its own values land in its override.
8729
+ if (pullSelection && varsOutcome.ok) {
8730
+ for (const v of varsOutcome.vars) {
8731
+ if (pullSelection.varKeys.has(v.key))
8732
+ matchedSelectors.add(`var/${v.key}`);
8733
+ }
8734
+ }
8735
+ pullVarsIntoOverride({
8736
+ outcome: varsOutcome,
8737
+ overridePath: pullVarsOverridePath,
8738
+ configDir,
8739
+ env: requireCurrentEnvironment(),
8740
+ selectedKeys: pullSelection ? pullSelection.varKeys : null,
8741
+ priorVars: priorEntities.vars,
8742
+ varEntities,
8743
+ tally: pullWrites,
8744
+ });
8745
+ }
8746
+ else if (pullSelection && varsOutcome.ok) {
8316
8747
  // Scoped: merge the named keys into the file that is already there
8317
8748
  // rather than re-emitting the whole var surface.
8318
8749
  for (const v of varsOutcome.vars) {
@@ -9403,6 +9834,9 @@ What a push guarantees (two stages):
9403
9834
  // tree, so `[app].name` in it is that environment's name: never sent,
9404
9835
  // never compared, never hashed into this environment's baseline.
9405
9836
  const localEnvironment = requireCurrentEnvironment().local;
9837
+ // #4485 — a machine-local environment's own var values, layered over
9838
+ // vars.toml. Null for a committed environment, which never reads one.
9839
+ const pushVarsOverridePath = resolveVarsOverridePath(configDir, requireCurrentEnvironment());
9406
9840
  // Detect cross-app push: if pushing to a different app, clear entity IDs
9407
9841
  // so everything goes through the create path
9408
9842
  const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
@@ -9432,7 +9866,7 @@ What a push guarantees (two stages):
9432
9866
  // cross-app reset has to have happened first: it clears the entity ids, so
9433
9867
  // there is nothing for prune to retire and a removed key IS a typo again.
9434
9868
  if (pushSelection) {
9435
- const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }));
9869
+ const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }), pushVarsOverridePath);
9436
9870
  if (missing.length > 0) {
9437
9871
  error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
9438
9872
  process.exit(1);
@@ -9452,6 +9886,11 @@ What a push guarantees (two stages):
9452
9886
  try {
9453
9887
  const changes = [];
9454
9888
  let skippedCount = 0;
9889
+ let functionsWithUnchangedCode = 0;
9890
+ // #4485 — functions whose code axis this push evaluated; any other
9891
+ // function the server holds (left out by `--only`, or not in the tree)
9892
+ // also keeps its version, and with it the vars it has cached.
9893
+ const functionKeysEvaluated = new Set();
9455
9894
  const conflicts = [];
9456
9895
  // Issue #666: per-class buckets for schema-feature errors. Each entry
9457
9896
  // groups failures by the code so the end-of-run report can print
@@ -9636,7 +10075,13 @@ What a push guarantees (two stages):
9636
10075
  * never an unconditional write — with the type named so the operator
9637
10076
  * knows drift detection was not in force for it this run.
9638
10077
  */
10078
+ /** Whether each live read that failed this run was transient (#4491). */
10079
+ const liveReadFailures = [];
9639
10080
  const warnLiveUnavailable = (label, reason) => {
10081
+ // A string reason names a consequence ("rule sets unavailable") of a
10082
+ // read already recorded here.
10083
+ if (typeof reason !== "string")
10084
+ liveReadFailures.push(isTransientRequestFailure(reason));
9640
10085
  const message = reason instanceof Error ? reason.message : String(reason ?? "unknown");
9641
10086
  warn(` ${label}: live state unavailable (${message}) — falling back to last-sync content hashes`);
9642
10087
  };
@@ -9654,6 +10099,11 @@ What a push guarantees (two stages):
9654
10099
  applyFailures.push({
9655
10100
  type,
9656
10101
  key,
10102
+ // A rerun can read what this one could not only when the failed
10103
+ // read was transient; a decliner that does not say which read
10104
+ // failed is judged by every read that failed this run.
10105
+ transient: outcome.transient ??
10106
+ (liveReadFailures.length > 0 && liveReadFailures.every(Boolean)),
9657
10107
  message: "live state could not be read and the manifest holds no " +
9658
10108
  "last-sync content hash for this file, so push cannot tell " +
9659
10109
  "which side moved. Nothing was written. Run `config pull` to " +
@@ -9831,7 +10281,7 @@ What a push guarantees (two stages):
9831
10281
  // proved to be reached from it before anything below mutates. Nothing
9832
10282
  // about what it checks, the order it checks it, or what it prints
9833
10283
  // moved with it.
9834
- const { parsedVars: preflightParsedVars } = await runConfigPushPreflight({
10284
+ const { parsedVars: preflightParsedVars, agentFilesFirst: preflightAgentFilesFirst, varsOverrideKeys, } = await runConfigPushPreflight({
9835
10285
  configDir,
9836
10286
  resolvedAppId,
9837
10287
  pushSelection,
@@ -9840,7 +10290,12 @@ What a push guarantees (two stages):
9840
10290
  isCrossAppPush: !!isCrossAppPush,
9841
10291
  localEnvironment,
9842
10292
  client,
10293
+ varsOverridePath: pushVarsOverridePath,
9843
10294
  });
10295
+ const varsOverrideFile = varsOverrideKeys && pushVarsOverridePath ? basename(pushVarsOverridePath) : null;
10296
+ if (varsOverrideKeys && varsOverrideFile) {
10297
+ info(varsOverrideSummaryLine(varsOverrideFile, varsOverrideKeys));
10298
+ }
9844
10299
  // Re-derived, not returned: both are one-line functions of inputs the
9845
10300
  // entry point already took, and widening its return type to carry them
9846
10301
  // would make the vars apply leg depend on the preflight's internals.
@@ -9864,7 +10319,7 @@ What a push guarantees (two stages):
9864
10319
  // is a network call whose failure aborts the push, so making it for a
9865
10320
  // singleton nobody selected would let `--only workflow/x` fail over
9866
10321
  // vars.toml.
9867
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
10322
+ if (varsSelected && (existsSync(preflightVarsTomlPath) || varsOverrideFile)) {
9868
10323
  try {
9869
10324
  remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
9870
10325
  }
@@ -9873,7 +10328,7 @@ What a push guarantees (two stages):
9873
10328
  throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
9874
10329
  `concurrent edits (${err?.message || String(err)}) — no changes ` +
9875
10330
  `were applied. Re-run with --force to push without the ` +
9876
- `concurrency guard.`);
10331
+ `concurrency guard.`, { cause: err });
9877
10332
  }
9878
10333
  // --force: proceed without a snapshot; the guard is intentionally
9879
10334
  // bypassed and every local var is upserted as authoritative. The
@@ -9922,11 +10377,13 @@ What a push guarantees (two stages):
9922
10377
  const localKeys = new Set(Object.keys(preflightParsedVars));
9923
10378
  const plannedDeletes = new Set(varPlan.deletions);
9924
10379
  const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
10380
+ const pullTarget = varsOverrideFile ?? "vars.toml";
10381
+ const localFiles = varsOverrideFile ? `vars.toml or ${varsOverrideFile}` : "vars.toml";
9925
10382
  throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
9926
10383
  `exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
9927
10384
  `(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 ` +
10385
+ `which are not in ${localFiles}) — no changes were applied. Run ` +
10386
+ `\`config pull\` to bring the remote-only vars into ${pullTarget}, or ` +
9930
10387
  `delete unneeded vars, then push again.`);
9931
10388
  }
9932
10389
  }
@@ -10203,6 +10660,7 @@ What a push guarantees (two stages):
10203
10660
  // r-2 P1), and the plan itself was computed up-front so the final
10204
10661
  // server var count could be validated against the cap before any
10205
10662
  // mutation (review pass 5).
10663
+ const changedExistingVarKeys = [];
10206
10664
  if (varPlan) {
10207
10665
  skippedCount += varPlan.skippedCount;
10208
10666
  // Feed var conflicts into the shared accumulator — same convention as
@@ -10305,6 +10763,12 @@ What a push guarantees (two stages):
10305
10763
  contentHash: item.valueHash,
10306
10764
  };
10307
10765
  }
10766
+ // #4485 — a var that already existed and now holds another
10767
+ // value, for the cached-vars note after the function leg.
10768
+ const before = remoteVarsSnapshot.find((r) => r.key === item.key);
10769
+ if (before && before.value !== item.value) {
10770
+ changedExistingVarKeys.push(item.key);
10771
+ }
10308
10772
  }
10309
10773
  catch (err) {
10310
10774
  // A remote edit landed since our snapshot — report a conflict
@@ -12276,7 +12740,7 @@ What a push guarantees (two stages):
12276
12740
  let deferredAgentPromptFiles = [];
12277
12741
  if (existsSync(promptsDir)) {
12278
12742
  const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
12279
- const ordered = partitionPromptFilesForPush(promptsDir, files);
12743
+ const ordered = partitionPromptFilesForPush(promptsDir, files, preflightAgentFilesFirst);
12280
12744
  deferredAgentPromptFiles = ordered.afterFunctions;
12281
12745
  await pushPromptFiles(ordered.beforeFunctions);
12282
12746
  }
@@ -13361,7 +13825,9 @@ What a push guarantees (two stages):
13361
13825
  typeGateBlocked = true;
13362
13826
  }
13363
13827
  else {
13364
- throw wrapEntityError(err, "update", "database type", dbType);
13828
+ // A fresh type's failure here is its create dry-run's, so it
13829
+ // is named a create, as the apply path names it.
13830
+ throw wrapEntityError(err, typeExistsOnServer ? "update" : "create", "database type", dbType);
13365
13831
  }
13366
13832
  }
13367
13833
  // If the validate pass found a gate failure, record the change
@@ -13933,6 +14399,11 @@ What a push guarantees (two stages):
13933
14399
  const codeUnchanged = !!liveFunction &&
13934
14400
  !!activeVersion &&
13935
14401
  String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
14402
+ // #4485 — the server makes no new version of it, so a running one
14403
+ // keeps the vars it has cached.
14404
+ functionKeysEvaluated.add(key);
14405
+ if (codeUnchanged)
14406
+ functionsWithUnchangedCode++;
13936
14407
  if (headerAction === "skip" && codeUnchanged) {
13937
14408
  skippedCount++;
13938
14409
  if (syncState && liveFunction?.functionId) {
@@ -14015,6 +14486,7 @@ What a push guarantees (two stages):
14015
14486
  type: "function",
14016
14487
  key,
14017
14488
  message: String(err?.message ?? err),
14489
+ transient: isTransientRequestFailure(err),
14018
14490
  });
14019
14491
  // A refused create, or a refused push to a row the server does
14020
14492
  // not hold live (an archived tombstone). A row this push created
@@ -14847,6 +15319,7 @@ What a push guarantees (two stages):
14847
15319
  type: "email-template",
14848
15320
  key: emailType,
14849
15321
  message: err?.message || String(err),
15322
+ transient: isTransientRequestFailure(err),
14850
15323
  });
14851
15324
  }
14852
15325
  }
@@ -15284,6 +15757,27 @@ What a push guarantees (two stages):
15284
15757
  }
15285
15758
  }
15286
15759
  }
15760
+ // #4485 (D4485-001) — an override changed a var some unchanged
15761
+ // function may already have read and cached. Informational only.
15762
+ if (varsOverrideFile && !options.dryRun && changedExistingVarKeys.length > 0) {
15763
+ let functionsKeepingVersion = functionsWithUnchangedCode;
15764
+ try {
15765
+ const listed = await client.listFunctions(resolvedAppId);
15766
+ for (const f of (listed?.items ?? [])) {
15767
+ if (f?.functionKey &&
15768
+ String(f.status ?? "active") !== "archived" &&
15769
+ !functionKeysEvaluated.has(f.functionKey)) {
15770
+ functionsKeepingVersion++;
15771
+ }
15772
+ }
15773
+ }
15774
+ catch {
15775
+ // Informational only: without the list, count what was evaluated.
15776
+ }
15777
+ const note = cachedVarsNote(changedExistingVarKeys, functionsKeepingVersion);
15778
+ if (note)
15779
+ warn(note);
15780
+ }
15287
15781
  divider();
15288
15782
  // Issue #813: a gate failure surfaced by the validate-first pass (which
15289
15783
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -15362,6 +15856,17 @@ What a push guarantees (two stages):
15362
15856
  else {
15363
15857
  error(failureSummary);
15364
15858
  }
15859
+ writePushFailureReport(pushFailureIsTransient({
15860
+ applyFailures,
15861
+ conflicts: conflicts.length,
15862
+ blockedDatabaseTypes: blockedDatabaseTypes.length,
15863
+ schemaErrors: schemaErrors.schemaRequired.length +
15864
+ schemaErrors.opRefs.length +
15865
+ schemaErrors.schemaBreaks.length +
15866
+ schemaErrors.uncheckableOps.length +
15867
+ schemaErrors.tomlParse.length +
15868
+ schemaErrors.opsExist.length,
15869
+ }));
15365
15870
  process.exit(1);
15366
15871
  }
15367
15872
  else {
@@ -15402,6 +15907,7 @@ What a push guarantees (two stages):
15402
15907
  // Print message + structured server-side validation details
15403
15908
  // (issue #684; now via the shared #1173 helper).
15404
15909
  printApiError(err);
15910
+ writePushFailureReport(isTransientRequestFailure(err));
15405
15911
  process.exit(1);
15406
15912
  }
15407
15913
  });
@@ -15824,6 +16330,36 @@ What a push guarantees (two stages):
15824
16330
  localVars.set(key, rawValue);
15825
16331
  }
15826
16332
  }
16333
+ // #4485 — on a machine-local environment, its own override layered
16334
+ // over vars.toml: the values its push would send.
16335
+ const diffEnv = requireCurrentEnvironment();
16336
+ const diffVarsOverridePath = resolveVarsOverridePath(configDir, diffEnv);
16337
+ let diffVarsOverride = null;
16338
+ if (diffVarsOverridePath && existsSync(diffVarsOverridePath)) {
16339
+ const parsed = parseTomlFile(diffVarsOverridePath);
16340
+ const baseKeys = new Set(existsSync(varsTomlPathDiff)
16341
+ ? Object.keys(parseTomlFile(varsTomlPathDiff))
16342
+ : []);
16343
+ const keys = Object.keys(parsed).sort();
16344
+ const overridingKeys = keys.filter((key) => baseKeys.has(key));
16345
+ const addedKeys = keys.filter((key) => !baseKeys.has(key));
16346
+ if (varsOutcomeDiff.ok) {
16347
+ for (const [key, rawValue] of Object.entries(parsed)) {
16348
+ if (typeof rawValue === "string")
16349
+ localVars.set(key, rawValue);
16350
+ }
16351
+ }
16352
+ diffVarsOverride = {
16353
+ file: [PROJECT_TREE_DIR, diffEnv.tree, basename(diffVarsOverridePath)].join("/"),
16354
+ keys,
16355
+ };
16356
+ if (!options.json) {
16357
+ info(varsOverrideSummaryLine(basename(diffVarsOverridePath), {
16358
+ overridingKeys,
16359
+ addedKeys,
16360
+ }));
16361
+ }
16362
+ }
15827
16363
  // Compare
15828
16364
  let differences = [];
15829
16365
  // Issue #1659: a local file with no server entity means one of two very
@@ -16384,7 +16920,9 @@ What a push guarantees (two stages):
16384
16920
  }
16385
16921
  }
16386
16922
  // Config vars — issue #1423 (see `diffVars`).
16387
- for (const row of diffVars(localVars, remoteVars)) {
16923
+ for (const row of diffVars(localVars, remoteVars,
16924
+ // A child's pull writes its override, created or not (#4485).
16925
+ diffVarsOverridePath ? basename(diffVarsOverridePath) : "vars.toml")) {
16388
16926
  differences.push(row);
16389
16927
  }
16390
16928
  // Database-config types (issue #1485). rule-sets, group/collection-type
@@ -16647,6 +17185,7 @@ What a push guarantees (two stages):
16647
17185
  differences,
16648
17186
  testCases: testCaseDiffs,
16649
17187
  notCompared,
17188
+ ...(diffVarsOverride ? { varsOverride: diffVarsOverride } : {}),
16650
17189
  }, null, 2));
16651
17190
  return;
16652
17191
  }