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.
- package/README.md +1 -1
- package/dist/src/commands/apps-children.d.ts +1 -1
- package/dist/src/commands/apps-children.js +170 -28
- package/dist/src/commands/apps-children.js.map +1 -1
- package/dist/src/commands/auth.js +41 -6
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/config.js +43 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/databases.js +130 -53
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/init.d.ts +7 -0
- package/dist/src/commands/init.js +9 -1
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/sync.d.ts +118 -6
- package/dist/src/commands/sync.js +594 -55
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/lib/child-apps-local.d.ts +45 -21
- package/dist/src/lib/child-apps-local.js +68 -30
- package/dist/src/lib/child-apps-local.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +38 -3
- package/dist/src/lib/credentials-store.js +175 -9
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +1 -1
- package/dist/src/lib/function-collect.js +4 -0
- package/dist/src/lib/function-collect.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +148 -0
- package/dist/src/lib/generated-config-surfaces.js +208 -25
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +1 -1
- package/dist/src/lib/generated-sdk-types.js +1 -1
- package/dist/src/lib/generated-sdk-types.js.map +1 -1
- package/dist/src/lib/local-state.d.ts +21 -6
- package/dist/src/lib/local-state.js +78 -34
- package/dist/src/lib/local-state.js.map +1 -1
- package/dist/src/lib/push-failure-report.d.ts +45 -0
- package/dist/src/lib/push-failure-report.js +57 -0
- package/dist/src/lib/push-failure-report.js.map +1 -0
- package/dist/src/lib/sync-selectors.d.ts +5 -1
- package/dist/src/lib/sync-selectors.js +23 -13
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/dist/src/lib/transient-retry.d.ts +9 -0
- package/dist/src/lib/transient-retry.js +37 -0
- package/dist/src/lib/transient-retry.js.map +1 -1
- package/dist/src/lib/vars-override.d.ts +105 -0
- package/dist/src/lib/vars-override.js +143 -0
- package/dist/src/lib/vars-override.js.map +1 -0
- 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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3042
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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 (
|
|
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
|
|
9929
|
-
`\`config pull\` to bring the remote-only vars into
|
|
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
|
-
|
|
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
|
}
|