primitive-admin 1.1.0-alpha.65 → 1.1.0-alpha.67
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 +6 -7
- package/assets/skill/skills/primitive-platform/SKILL.md +28 -9
- package/dist/bin/primitive.js +4 -3
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/apps.js +14 -5
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/config.js +11 -11
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/databases.js +15 -3
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/email-templates.js +5 -5
- package/dist/src/commands/env.d.ts +12 -1
- package/dist/src/commands/env.js +98 -25
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/init.d.ts +9 -1
- package/dist/src/commands/init.js +810 -218
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/prompts.js +5 -2
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/sync-app-settings.d.ts +83 -55
- package/dist/src/commands/sync-app-settings.js +154 -200
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +362 -29
- package/dist/src/commands/sync.js +2734 -351
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +19 -5
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +21 -1
- package/dist/src/lib/api-client.js +23 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +150 -17
- package/dist/src/lib/app-settings-descriptor.js +344 -31
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +4 -1
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-payload.js +14 -3
- package/dist/src/lib/config-payload.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +37 -1
- package/dist/src/lib/config-surface.js +118 -0
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +147 -0
- package/dist/src/lib/env-resolver-core.js +265 -0
- package/dist/src/lib/env-resolver-core.js.map +1 -0
- package/dist/src/lib/env-resolver.d.ts +28 -6
- package/dist/src/lib/env-resolver.js +44 -32
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +71 -0
- package/dist/src/lib/generated-config-surfaces.js +264 -69
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/init-adopt.d.ts +16 -0
- package/dist/src/lib/init-adopt.js +34 -0
- package/dist/src/lib/init-adopt.js.map +1 -0
- package/dist/src/lib/init-assets.d.ts +39 -0
- package/dist/src/lib/init-assets.js +97 -0
- package/dist/src/lib/init-assets.js.map +1 -0
- package/dist/src/lib/init-config.d.ts +21 -4
- package/dist/src/lib/init-config.js +45 -8
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +80 -0
- package/dist/src/lib/init-plan.js +95 -0
- package/dist/src/lib/init-plan.js.map +1 -0
- package/dist/src/lib/init-production-env.d.ts +48 -0
- package/dist/src/lib/init-production-env.js +59 -0
- package/dist/src/lib/init-production-env.js.map +1 -0
- package/dist/src/lib/init-schema.d.ts +74 -0
- package/dist/src/lib/init-schema.js +358 -0
- package/dist/src/lib/init-schema.js.map +1 -0
- package/dist/src/lib/init-xcode.d.ts +33 -0
- package/dist/src/lib/init-xcode.js +114 -0
- package/dist/src/lib/init-xcode.js.map +1 -0
- package/dist/src/lib/integration-request-config.d.ts +30 -0
- package/dist/src/lib/integration-request-config.js +145 -0
- package/dist/src/lib/integration-request-config.js.map +1 -0
- package/dist/src/lib/local-state.d.ts +55 -0
- package/dist/src/lib/local-state.js +167 -0
- package/dist/src/lib/local-state.js.map +1 -0
- package/dist/src/lib/platform-owned.d.ts +12 -0
- package/dist/src/lib/platform-owned.js +21 -0
- package/dist/src/lib/platform-owned.js.map +1 -1
- package/dist/src/lib/project-config.js +2 -4
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/resolve-init-server.d.ts +0 -7
- package/dist/src/lib/resolve-init-server.js +2 -19
- package/dist/src/lib/resolve-init-server.js.map +1 -1
- package/dist/src/lib/resolve-platform.d.ts +43 -14
- package/dist/src/lib/resolve-platform.js +74 -12
- package/dist/src/lib/resolve-platform.js.map +1 -1
- package/dist/src/lib/server-text-normalization.d.ts +51 -0
- package/dist/src/lib/server-text-normalization.js +90 -0
- package/dist/src/lib/server-text-normalization.js.map +1 -0
- package/dist/src/lib/server-url.d.ts +22 -0
- package/dist/src/lib/server-url.js +33 -0
- package/dist/src/lib/server-url.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +8 -0
- package/dist/src/lib/sync-resource-types.js +19 -2
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/sync-selectors.js +4 -4
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/dist/src/lib/template.d.ts +50 -1
- package/dist/src/lib/template.js +59 -23
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/test-case-keys.d.ts +29 -0
- package/dist/src/lib/test-case-keys.js +55 -0
- package/dist/src/lib/test-case-keys.js.map +1 -0
- package/dist/src/types/index.d.ts +40 -8
- package/package.json +2 -2
|
@@ -14,10 +14,12 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
|
|
|
14
14
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
15
15
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
16
16
|
import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
|
|
17
|
+
import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request-config.js";
|
|
18
|
+
import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
|
|
17
19
|
import { canonicalJsonStringify } from "../lib/canonical-json.js";
|
|
18
20
|
import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
|
|
19
|
-
import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
|
|
20
|
-
import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, } from "./sync-app-settings.js";
|
|
21
|
+
import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
|
|
22
|
+
import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
|
|
21
23
|
import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
|
|
22
24
|
// #2645 — named config bodies live in `workflows/<key>.configs/<name>.toml`.
|
|
23
25
|
import { workflowConfigsDir, serializeWorkflowConfigSidecar, parseWorkflowConfigSidecar, readWorkflowConfigSidecars, } from "../lib/workflow-config-sidecar.js";
|
|
@@ -27,11 +29,12 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
|
|
|
27
29
|
// payload builder so the diff normalizer and the wire cannot disagree.
|
|
28
30
|
import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
|
|
29
31
|
// #2644 — the one definition per configuration object, vendored from the server.
|
|
30
|
-
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
32
|
+
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
31
33
|
import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
|
|
32
34
|
import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
|
|
33
35
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
34
|
-
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
36
|
+
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
37
|
+
import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
|
|
35
38
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
36
39
|
import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
|
|
37
40
|
import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
|
|
@@ -1076,6 +1079,41 @@ export function archivedTombstoneEntries(items, select, managedKeys) {
|
|
|
1076
1079
|
}
|
|
1077
1080
|
return entries;
|
|
1078
1081
|
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Fold one type's tombstone entries into the sync state this pull is building
|
|
1084
|
+
* (#2803, corrected by #2887).
|
|
1085
|
+
*
|
|
1086
|
+
* Two rules, and the second one is the fix. A tombstone is recorded only for a
|
|
1087
|
+
* key this pull did NOT export (an exported key is a live row and its own
|
|
1088
|
+
* record) and only when the selection covers it. And a recorded key is marked
|
|
1089
|
+
* as MATCHED: archived rows are dropped before `selectPull` runs, so the
|
|
1090
|
+
* selector that named one never reached the set that decides whether the pull
|
|
1091
|
+
* found what it was asked for. `config pull --only workflow/<key>` against an
|
|
1092
|
+
* archived managed row therefore removed the file, saved the tombstone, and
|
|
1093
|
+
* then exited 1 saying "the server has no workflow/<key>" — a failure report
|
|
1094
|
+
* about a mutation that had already succeeded, which is the worst thing an
|
|
1095
|
+
* automation can be told. The fix is generic, so integrations and webhooks
|
|
1096
|
+
* (which had the bug first) get it too.
|
|
1097
|
+
*
|
|
1098
|
+
* Mutates `built` and `matchedSelectors` and returns the keys it recorded, so
|
|
1099
|
+
* the caller can report each one. Pure otherwise, and unit-testable.
|
|
1100
|
+
*/
|
|
1101
|
+
export function recordArchivedTombstones(built, tombstones, context) {
|
|
1102
|
+
const recorded = [];
|
|
1103
|
+
for (const [key, entry] of Object.entries(tombstones)) {
|
|
1104
|
+
if (key in built)
|
|
1105
|
+
continue;
|
|
1106
|
+
if (!selectionIncludesKey(context.selection, context.label, key))
|
|
1107
|
+
continue;
|
|
1108
|
+
built[key] = entry;
|
|
1109
|
+
// Only under a selection: without one there is nothing to report against.
|
|
1110
|
+
if (context.selection) {
|
|
1111
|
+
context.matchedSelectors.add(`${context.label}/${key}`);
|
|
1112
|
+
}
|
|
1113
|
+
recorded.push(key);
|
|
1114
|
+
}
|
|
1115
|
+
return recorded;
|
|
1116
|
+
}
|
|
1079
1117
|
/**
|
|
1080
1118
|
* The `config diff` annotation for an object an operator took out of service
|
|
1081
1119
|
* (issue #2645, criterion 9; #2803).
|
|
@@ -1325,15 +1363,26 @@ export function buildWebhookPushPayload(key, tomlData, { mode = "update" } = {})
|
|
|
1325
1363
|
},
|
|
1326
1364
|
}),
|
|
1327
1365
|
};
|
|
1328
|
-
//
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1366
|
+
// The three JSON-carrying tables. TOML-owned like `description` above
|
|
1367
|
+
// (#1567), so on an UPDATE each is sent as value-or-`null`: sending nothing
|
|
1368
|
+
// for an absent table left the stored value in place, which the shared
|
|
1369
|
+
// comparison then reported as a difference forever — push planned the same
|
|
1370
|
+
// update on every run, the server changed nothing, and deleting a
|
|
1371
|
+
// `[inputMapping]` section could not be made to take effect. The server
|
|
1372
|
+
// stores an explicit `null` (`updates.<key> = payload.<key>`).
|
|
1373
|
+
//
|
|
1374
|
+
// A CREATE sends only what the file has: there is nothing to clear, and the
|
|
1375
|
+
// create handlers read an absent key as "no value" already.
|
|
1376
|
+
const jsonTables = [
|
|
1377
|
+
["allowedIpCidrs", tomlData?.allowedIps?.cidrs],
|
|
1378
|
+
["inputMapping", tomlData?.inputMapping],
|
|
1379
|
+
["metadata", tomlData?.metadata],
|
|
1380
|
+
];
|
|
1381
|
+
for (const [key, value] of jsonTables) {
|
|
1382
|
+
if (value)
|
|
1383
|
+
payload[key] = JSON.stringify(value);
|
|
1384
|
+
else if (mode === "update")
|
|
1385
|
+
payload[key] = null;
|
|
1337
1386
|
}
|
|
1338
1387
|
// Forward the declared `[verification.<scheme>]` table so scheme
|
|
1339
1388
|
// configuration reaches the server. Without this the table is silently
|
|
@@ -1425,12 +1474,24 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
|
|
|
1425
1474
|
},
|
|
1426
1475
|
}),
|
|
1427
1476
|
};
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1477
|
+
// The two JSON-carrying tables, TOML-owned like `description` (#1567): an
|
|
1478
|
+
// UPDATE sends value-or-`null`, so deleting a `[rootInput]` section clears it
|
|
1479
|
+
// instead of leaving a value the shared comparison reports as a difference
|
|
1480
|
+
// on every run that push cannot clear. The controller takes an explicit
|
|
1481
|
+
// `null` as the clear and validates anything else as JSON.
|
|
1482
|
+
//
|
|
1483
|
+
// A CREATE sends only what the file has: the create leg rejects a `null` as
|
|
1484
|
+
// invalid JSON, and there is nothing to clear on a trigger that does not
|
|
1485
|
+
// exist yet.
|
|
1486
|
+
const jsonTables = [
|
|
1487
|
+
["rootInput", tomlData?.rootInput],
|
|
1488
|
+
["inputMapping", tomlData?.inputMapping],
|
|
1489
|
+
];
|
|
1490
|
+
for (const [key, value] of jsonTables) {
|
|
1491
|
+
if (value)
|
|
1492
|
+
payload[key] = JSON.stringify(value);
|
|
1493
|
+
else if (mode === "update")
|
|
1494
|
+
payload[key] = null;
|
|
1434
1495
|
}
|
|
1435
1496
|
return payload;
|
|
1436
1497
|
}
|
|
@@ -1979,6 +2040,114 @@ export function unknownKeyPreflightTargets() {
|
|
|
1979
2040
|
}
|
|
1980
2041
|
return targets;
|
|
1981
2042
|
}
|
|
2043
|
+
/**
|
|
2044
|
+
* The diff row a per-entity config file belongs to (#2880 criterion 1).
|
|
2045
|
+
*
|
|
2046
|
+
* `config diff` pairs a local file with a server entity by a key each type
|
|
2047
|
+
* derives its own way — the `[table].key` the file states, its identity field,
|
|
2048
|
+
* or the file name standing in for either. The validation reporter has to land
|
|
2049
|
+
* its message on THAT row, so the derivation lives here once rather than being
|
|
2050
|
+
* inferred from the file name and quietly missing a renamed file.
|
|
2051
|
+
*/
|
|
2052
|
+
function localConfigRowKey(label, tomlData, file) {
|
|
2053
|
+
const fallback = basename(file, ".toml");
|
|
2054
|
+
const stated = (table, key) => tomlData?.[table]?.[key] || fallback;
|
|
2055
|
+
switch (label) {
|
|
2056
|
+
case "integration":
|
|
2057
|
+
return stated("integration", "key");
|
|
2058
|
+
case "webhook":
|
|
2059
|
+
return stated("webhook", "key");
|
|
2060
|
+
case "cron-trigger":
|
|
2061
|
+
return stated("cronTrigger", "key");
|
|
2062
|
+
case "blob-bucket":
|
|
2063
|
+
return stated("bucket", "key");
|
|
2064
|
+
case "prompt":
|
|
2065
|
+
return stated("prompt", "key");
|
|
2066
|
+
case "workflow":
|
|
2067
|
+
return stated("workflow", "key");
|
|
2068
|
+
case "email-template":
|
|
2069
|
+
return stated("template", "emailType");
|
|
2070
|
+
case "database-type-config":
|
|
2071
|
+
return stated("type", "databaseType");
|
|
2072
|
+
case "group-type-config":
|
|
2073
|
+
return stated("groupTypeConfig", "groupType");
|
|
2074
|
+
case "collection-type-config":
|
|
2075
|
+
return stated("collectionTypeConfig", "collectionType");
|
|
2076
|
+
case "metadata-category-config": {
|
|
2077
|
+
const section = tomlData?.metadataCategoryConfig || {};
|
|
2078
|
+
if (section.resourceType && section.category) {
|
|
2079
|
+
return `${section.resourceType}#${section.category}`;
|
|
2080
|
+
}
|
|
2081
|
+
// `resourceType.category.toml`, the filename fallback push and diff share.
|
|
2082
|
+
const dot = fallback.indexOf(".");
|
|
2083
|
+
return dot > 0
|
|
2084
|
+
? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
|
|
2085
|
+
: fallback;
|
|
2086
|
+
}
|
|
2087
|
+
// Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
|
|
2088
|
+
// still updates the same entity, so the row is the file's.
|
|
2089
|
+
default:
|
|
2090
|
+
return fallback;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Every per-entity config file whose TOML `config push` would refuse (#2880
|
|
2095
|
+
* criterion 1, symmetry).
|
|
2096
|
+
*
|
|
2097
|
+
* One collector, two commands. Push aborts on the messages before issuing a
|
|
2098
|
+
* request; `config diff` renders them as their own rows, because the failure
|
|
2099
|
+
* this closes is a file diff calls Synced and push then rejects — the state the
|
|
2100
|
+
* issue's reporter hit after #2803 retired `[prompt].status`, where a local key
|
|
2101
|
+
* with no server counterpart is invisible to a comparison and fatal to a push.
|
|
2102
|
+
*
|
|
2103
|
+
* Three checks, all definition-driven: the document's table shape, keys no
|
|
2104
|
+
* definition declares (retired keys among them), and — since #2880 — values
|
|
2105
|
+
* whose spelling is not the declared type. A file that does not parse is left
|
|
2106
|
+
* to the per-type loop that reads it, which names the parse error itself.
|
|
2107
|
+
*/
|
|
2108
|
+
export function collectConfigFileValidationErrors(configDir) {
|
|
2109
|
+
const found = [];
|
|
2110
|
+
for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
|
|
2111
|
+
const typeDir = join(configDir, dir);
|
|
2112
|
+
if (!existsSync(typeDir))
|
|
2113
|
+
continue;
|
|
2114
|
+
for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
|
|
2115
|
+
const filePath = join(typeDir, file);
|
|
2116
|
+
let tomlData;
|
|
2117
|
+
try {
|
|
2118
|
+
tomlData = parseTomlFile(filePath);
|
|
2119
|
+
}
|
|
2120
|
+
catch {
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
const messages = [];
|
|
2124
|
+
// The document's own shape first: an unrecognized top-level table makes
|
|
2125
|
+
// every per-table check below read an absent table, so its silence would
|
|
2126
|
+
// be misleading.
|
|
2127
|
+
messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
|
|
2128
|
+
for (const table of tables) {
|
|
2129
|
+
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
2130
|
+
if (unknownKeyError)
|
|
2131
|
+
messages.push(unknownKeyError);
|
|
2132
|
+
}
|
|
2133
|
+
// Declared types run over EVERY table of the surface, workflows included
|
|
2134
|
+
// — `tables` above is pruned for workflows because `validateWorkflowToml`
|
|
2135
|
+
// owns their unrecognized-key message, which is a different check.
|
|
2136
|
+
for (const table of surface.tables) {
|
|
2137
|
+
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
2138
|
+
}
|
|
2139
|
+
if (messages.length === 0)
|
|
2140
|
+
continue;
|
|
2141
|
+
found.push({
|
|
2142
|
+
type: surface.label,
|
|
2143
|
+
key: localConfigRowKey(surface.label, tomlData, file),
|
|
2144
|
+
filePath,
|
|
2145
|
+
messages,
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return found;
|
|
2150
|
+
}
|
|
1982
2151
|
/**
|
|
1983
2152
|
* Every `<key>.tests/*.toml` sidecar under a config directory.
|
|
1984
2153
|
*
|
|
@@ -2822,16 +2991,25 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
|
|
|
2822
2991
|
* A field's value as the comparison reads it — the VALUE, not its spelling
|
|
2823
2992
|
* (#2869).
|
|
2824
2993
|
*
|
|
2825
|
-
* Only a declared `number`
|
|
2826
|
-
*
|
|
2994
|
+
* Only a declared `number` the DEFINITION marks `dualEncoded` needs this
|
|
2995
|
+
* (#2880), and only because the server genuinely accepts both spellings:
|
|
2996
|
+
* `temperature` and `topP` are `StringField`s on the
|
|
2827
2997
|
* model, stored as strings and returned parsed, so a file authored
|
|
2828
2998
|
* `temperature = "0.2"` and a server holding `0.2` describe one value. Compared
|
|
2829
2999
|
* as spellings it is a `Modified` no push can clear — push sends the string,
|
|
2830
3000
|
* the server stores it, pull writes the number back, and the difference
|
|
2831
3001
|
* returns — so the operator's only remedy is to overwrite the local file.
|
|
2832
3002
|
*
|
|
2833
|
-
*
|
|
2834
|
-
*
|
|
3003
|
+
* Every OTHER declared number is strictly numeric: `maxTokens = "1024"` is not
|
|
3004
|
+
* the number the server holds, and normalizing it here would have both
|
|
3005
|
+
* commands agree on a value the author never wrote. It is a validation error
|
|
3006
|
+
* both commands report instead (`configDeclaredTypeErrors`), which is why the
|
|
3007
|
+
* carve-out is read from the definition rather than from `type === "number"`.
|
|
3008
|
+
*
|
|
3009
|
+
* A string that is not a number never reaches here: `temperature = "warm"` is
|
|
3010
|
+
* a field-named validation error from both commands (`spellsDecimalNumber` is
|
|
3011
|
+
* the grammar the declared-type validator admits a dual encoding by, so what
|
|
3012
|
+
* preflight accepts is exactly what this can equate).
|
|
2835
3013
|
*
|
|
2836
3014
|
* "Is a number" is decided by the grammar the SERVER reads the string with —
|
|
2837
3015
|
* `parseFloat`, on the way back out — not by JavaScript's `Number`, which is
|
|
@@ -2841,15 +3019,14 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
|
|
|
2841
3019
|
* So only what `parseFloat` consumes WHOLE — sign, digits, one dot, an
|
|
2842
3020
|
* exponent — normalizes; anything else stays the string it is.
|
|
2843
3021
|
*/
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
if (type !== "number" || typeof value !== "string")
|
|
3022
|
+
function comparableFieldValue(field, value) {
|
|
3023
|
+
if (field.type !== "number" || typeof value !== "string")
|
|
2847
3024
|
return value;
|
|
2848
|
-
|
|
2849
|
-
if (!DECIMAL_NUMBER.test(trimmed))
|
|
3025
|
+
if (field.dualEncoded === undefined)
|
|
2850
3026
|
return value;
|
|
2851
|
-
|
|
2852
|
-
|
|
3027
|
+
if (!spellsDecimalNumber(value))
|
|
3028
|
+
return value;
|
|
3029
|
+
return Number(value.trim());
|
|
2853
3030
|
}
|
|
2854
3031
|
/**
|
|
2855
3032
|
* `config diff`'s hash for one configuration object, over the field set its
|
|
@@ -2872,7 +3049,16 @@ function comparableFieldValue(type, value) {
|
|
|
2872
3049
|
export function projectConfigForComparison(table, entity, extras = {}) {
|
|
2873
3050
|
const projected = {};
|
|
2874
3051
|
for (const field of table.fields) {
|
|
2875
|
-
const
|
|
3052
|
+
const raw = entity?.[field.field];
|
|
3053
|
+
// #2880 DSO-002 — a key the file does not carry describes the value the
|
|
3054
|
+
// SERVER materializes for it, so that is what the comparison reads. Both
|
|
3055
|
+
// sides run through here, and the server side always carries the value
|
|
3056
|
+
// already, so this only ever fills the absent side in. Without it a
|
|
3057
|
+
// hand-authored file that omits an optional defaulted field reports
|
|
3058
|
+
// `Modified` on every run against a server that agrees with it.
|
|
3059
|
+
const value = comparableFieldValue(field, raw === undefined && field.serverDefault !== undefined
|
|
3060
|
+
? field.serverDefault
|
|
3061
|
+
: raw);
|
|
2876
3062
|
if (value === undefined)
|
|
2877
3063
|
continue;
|
|
2878
3064
|
// #2731 — `emit: "whenSet"` says pull OMITS the key when the value is
|
|
@@ -3025,6 +3211,25 @@ function jsonFieldsForDiff(table, entity) {
|
|
|
3025
3211
|
}
|
|
3026
3212
|
return projected;
|
|
3027
3213
|
}
|
|
3214
|
+
/**
|
|
3215
|
+
* One test-case reference as the comparison reads it (#2880 behavior 17).
|
|
3216
|
+
*
|
|
3217
|
+
* A reference reaches the projection in one of three states, and all three
|
|
3218
|
+
* describe the same thing on both sides once this is applied:
|
|
3219
|
+
*
|
|
3220
|
+
* - a portable NAME (`configName = "default"`), which a test file carries so
|
|
3221
|
+
* it survives a move between apps;
|
|
3222
|
+
* - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
|
|
3223
|
+
* pinned by id would differ forever from the file pull writes for it;
|
|
3224
|
+
* - a legacy ID that does NOT resolve, which stays the id, because pull writes
|
|
3225
|
+
* an unresolvable id straight back and comparing it away would hide a pin
|
|
3226
|
+
* the next push still sends.
|
|
3227
|
+
*/
|
|
3228
|
+
function testCaseReference(value, idToName) {
|
|
3229
|
+
if (typeof value !== "string" || value === "")
|
|
3230
|
+
return undefined;
|
|
3231
|
+
return idToName?.get(value) ?? value;
|
|
3232
|
+
}
|
|
3028
3233
|
const CONFIG_DIFF_SPECS = {
|
|
3029
3234
|
"rule-set": {
|
|
3030
3235
|
label: "rule-set",
|
|
@@ -3099,6 +3304,16 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3099
3304
|
// exactly as push derives it from the file name.
|
|
3100
3305
|
parse: (doc) => buildWebhookPushPayload(doc?.webhook?.key ?? "", doc),
|
|
3101
3306
|
serialize: (record) => serializeWebhook(record),
|
|
3307
|
+
// The text the create/update handlers rewrite on the way in (DSO-003):
|
|
3308
|
+
// `displayName` and `workflowKey` are trimmed, a blank description is
|
|
3309
|
+
// stored as none, and a signing secret is stored as the canonical
|
|
3310
|
+
// `{{secrets.KEY}}` form — so an authored `"{{ secrets.KEY }}"` is the same
|
|
3311
|
+
// reference the server returns rather than a row push re-applies forever.
|
|
3312
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3313
|
+
trimmed: ["displayName", "workflowKey"],
|
|
3314
|
+
optional: ["description"],
|
|
3315
|
+
secretReference: ["signingSecret"],
|
|
3316
|
+
}),
|
|
3102
3317
|
extras: (entity) => ({
|
|
3103
3318
|
// The four structural tables a webhook file also carries, compared as the
|
|
3104
3319
|
// values they encode rather than as their transport spellings.
|
|
@@ -3111,6 +3326,21 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3111
3326
|
// webhook that holds none.
|
|
3112
3327
|
config: entity.config ?? null,
|
|
3113
3328
|
}),
|
|
3329
|
+
// The two present-only fields, matching what `buildWebhookPushPayload`
|
|
3330
|
+
// sends: a file with no `[verification]` section does not manage the stored
|
|
3331
|
+
// scheme configuration (a bare `[verification]` table is how such a file
|
|
3332
|
+
// clears it), and a file with no `signingSecret` line does not manage the
|
|
3333
|
+
// secret reference — pushing `null` for either would revoke verification
|
|
3334
|
+
// the file never mentioned. Compared as differences they were rows push
|
|
3335
|
+
// could never clear.
|
|
3336
|
+
unmanaged: (doc) => {
|
|
3337
|
+
const keys = [];
|
|
3338
|
+
if (doc?.verification === undefined)
|
|
3339
|
+
keys.push("config");
|
|
3340
|
+
if (doc?.webhook?.signingSecret === undefined)
|
|
3341
|
+
keys.push("signingSecret");
|
|
3342
|
+
return keys;
|
|
3343
|
+
},
|
|
3114
3344
|
describe: (entity) => `webhook "${entity?.webhookKey}"`,
|
|
3115
3345
|
},
|
|
3116
3346
|
// #2748 — prompts join the registry comparator, the third type to arrive by
|
|
@@ -3163,6 +3393,148 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3163
3393
|
}),
|
|
3164
3394
|
describe: (entity) => `prompt "${entity?.displayName}"`,
|
|
3165
3395
|
},
|
|
3396
|
+
// #2880 — integrations join the registry comparator, the last of the flat
|
|
3397
|
+
// per-entity types to arrive by the route email templates (#2731 B2),
|
|
3398
|
+
// webhooks (#2757) and prompts (#2748) took: `config diff` reported key
|
|
3399
|
+
// existence only, so an edited `accessRule` or upstream `[requestConfig]`
|
|
3400
|
+
// read as Synced while push applied it from the file's byte hash.
|
|
3401
|
+
"integration": {
|
|
3402
|
+
label: "integration",
|
|
3403
|
+
table: INTEGRATION_TABLE,
|
|
3404
|
+
// The wire shape push sends. `integrationKey` is not part of the compared
|
|
3405
|
+
// surface — the two sides are paired on it before the comparison runs —
|
|
3406
|
+
// but the TOML spelling (`[integration].key`) is read here because it is
|
|
3407
|
+
// what `displayName` falls back to.
|
|
3408
|
+
parse: (doc) => buildIntegrationPushPayload(doc?.integration?.key ?? "", doc),
|
|
3409
|
+
serialize: (record) => serializeIntegration(record),
|
|
3410
|
+
// The upstream request definition is a top-level table the definition
|
|
3411
|
+
// classifies structural, so it is hashed beside the fields — as the value
|
|
3412
|
+
// the SERVER would store, not as the text the file holds (DSO-003).
|
|
3413
|
+
canonicalize: (entity) => {
|
|
3414
|
+
entity.requestConfig = canonicalizeIntegrationRequestConfig(entity.requestConfig ?? {});
|
|
3415
|
+
// The scalars the handler rewrites beside it: `displayName` is trimmed
|
|
3416
|
+
// and a blank `description` is stored as none.
|
|
3417
|
+
canonicalizeServerTextFields(entity, {
|
|
3418
|
+
trimmed: ["displayName"],
|
|
3419
|
+
optional: ["description"],
|
|
3420
|
+
});
|
|
3421
|
+
},
|
|
3422
|
+
extras: (entity) => ({ requestConfig: entity.requestConfig ?? {} }),
|
|
3423
|
+
describe: (entity) => `integration "${entity?.integrationKey}"`,
|
|
3424
|
+
},
|
|
3425
|
+
// #2880 — cron triggers, on the same route. `status` is server-owned
|
|
3426
|
+
// (#2645/#2803) and therefore absent from the table, so an operator's pause
|
|
3427
|
+
// is not drift the configuration can push away; `[rootInput]` and
|
|
3428
|
+
// `[inputMapping]` are structural tables stored as JSON strings, compared as
|
|
3429
|
+
// the values they encode.
|
|
3430
|
+
"cron-trigger": {
|
|
3431
|
+
label: "cron-trigger",
|
|
3432
|
+
table: CRON_TRIGGER_TABLE,
|
|
3433
|
+
parse: (doc) => buildCronTriggerPushPayload(doc?.cronTrigger?.key ?? "", doc),
|
|
3434
|
+
serialize: (record) => serializeCronTrigger(record),
|
|
3435
|
+
// `displayName` is trimmed on the way in and a blank description is stored
|
|
3436
|
+
// as none (DSO-003), so neither is a difference push could never settle.
|
|
3437
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3438
|
+
trimmed: ["displayName"],
|
|
3439
|
+
optional: ["description"],
|
|
3440
|
+
}),
|
|
3441
|
+
extras: (entity) => ({
|
|
3442
|
+
rootInput: jsonBlobForDiff(entity.rootInput),
|
|
3443
|
+
inputMapping: jsonBlobForDiff(entity.inputMapping),
|
|
3444
|
+
}),
|
|
3445
|
+
describe: (entity) => `cron trigger "${entity?.triggerKey}"`,
|
|
3446
|
+
},
|
|
3447
|
+
// #2880 — blob buckets. The parse is CREATE-mode on purpose: `ttlTier` is
|
|
3448
|
+
// `writableOn: ["create"]`, and a field left out of the projection cannot be
|
|
3449
|
+
// NAMED as the immutable difference it is — the update would PATCH
|
|
3450
|
+
// "successfully", change nothing, and re-report forever.
|
|
3451
|
+
"blob-bucket": {
|
|
3452
|
+
label: "blob-bucket",
|
|
3453
|
+
table: BLOB_BUCKET_TABLE,
|
|
3454
|
+
parse: (doc) => {
|
|
3455
|
+
const bucket = { ...(doc?.bucket ?? {}) };
|
|
3456
|
+
// #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
|
|
3457
|
+
// the wire and still authored in unmigrated files. Both spellings mean
|
|
3458
|
+
// the same access model, so they project the same way; without this an
|
|
3459
|
+
// unmigrated file reports a difference no push can clear (push sends the
|
|
3460
|
+
// legacy key and the server answers with `preset`).
|
|
3461
|
+
if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
|
|
3462
|
+
bucket.preset = bucket.accessPolicy;
|
|
3463
|
+
}
|
|
3464
|
+
return {
|
|
3465
|
+
bucketKey: doc?.bucket?.key ?? "",
|
|
3466
|
+
...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
|
|
3467
|
+
mode: "create",
|
|
3468
|
+
values: {
|
|
3469
|
+
name: (value) => value || doc?.bucket?.key || "",
|
|
3470
|
+
description: tomlOwned,
|
|
3471
|
+
},
|
|
3472
|
+
}),
|
|
3473
|
+
};
|
|
3474
|
+
},
|
|
3475
|
+
serialize: (record) => serializeBlobBucket(record),
|
|
3476
|
+
// `name` is trimmed on the way in and a blank description is stored as
|
|
3477
|
+
// none (DSO-003).
|
|
3478
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3479
|
+
trimmed: ["name"],
|
|
3480
|
+
optional: ["description"],
|
|
3481
|
+
}),
|
|
3482
|
+
// The three fields a bucket file leaves alone by omitting them, matching
|
|
3483
|
+
// what `buildBlobBucketUpdatePayload` sends (#2880 criterion 3). The server
|
|
3484
|
+
// refuses to leave a bucket nameless or without an access model, so none of
|
|
3485
|
+
// them is clearable and the update omits an absent key rather than nulling
|
|
3486
|
+
// it. Compared anyway, a bucket whose live name differs from its key — or
|
|
3487
|
+
// one whose access model the file does not state — is a `Modified` row and
|
|
3488
|
+
// an update that cannot carry the difference it reports, planned again on
|
|
3489
|
+
// every run. `ttlTier` is deliberately NOT here: it is immutable rather
|
|
3490
|
+
// than unmanaged, and naming it is the whole point of the create-mode parse
|
|
3491
|
+
// above.
|
|
3492
|
+
unmanaged: (doc) => {
|
|
3493
|
+
const bucket = doc?.bucket ?? {};
|
|
3494
|
+
const keys = [];
|
|
3495
|
+
if (!bucket.name)
|
|
3496
|
+
keys.push("name");
|
|
3497
|
+
if (!bucket.preset && !bucket.accessPolicy)
|
|
3498
|
+
keys.push("preset");
|
|
3499
|
+
if (!bucket.ruleSetId)
|
|
3500
|
+
keys.push("ruleSetId");
|
|
3501
|
+
return keys;
|
|
3502
|
+
},
|
|
3503
|
+
describe: (entity) => `blob bucket "${entity?.bucketKey}"`,
|
|
3504
|
+
},
|
|
3505
|
+
// #2880 — test cases. Their sidecars were compared by SLUG PRESENCE, so an
|
|
3506
|
+
// edited expectation read as Synced while push applied it from the file's
|
|
3507
|
+
// byte hash.
|
|
3508
|
+
//
|
|
3509
|
+
// The type's own difficulty is that a reference has two legal spellings — the
|
|
3510
|
+
// portable `configName` a test file carries so it survives a move between
|
|
3511
|
+
// apps, and the legacy `configId` pull still writes back when the name cannot
|
|
3512
|
+
// be resolved. They name the same thing, so they project the same way; the
|
|
3513
|
+
// `extra` context is what makes that possible (the id→name lookups are not in
|
|
3514
|
+
// the document).
|
|
3515
|
+
"test-case": {
|
|
3516
|
+
label: "test-case",
|
|
3517
|
+
table: TEST_CASE_TABLE,
|
|
3518
|
+
parse: (doc) => parseTestCaseToml(doc),
|
|
3519
|
+
serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
|
|
3520
|
+
// The server trims a test-case name on write (`src/admin-api.ts`), so a
|
|
3521
|
+
// padded one in the file is not a difference — it is what the server will
|
|
3522
|
+
// store (#2880 DSO-003).
|
|
3523
|
+
canonicalize: (entity) => {
|
|
3524
|
+
if (typeof entity.name === "string")
|
|
3525
|
+
entity.name = entity.name.trim();
|
|
3526
|
+
},
|
|
3527
|
+
extras: (entity, _doc, extra) => ({
|
|
3528
|
+
// These three OVERRIDE the definition's fields of the same name: the
|
|
3529
|
+
// value compared is the thing referred to, not the spelling that referred
|
|
3530
|
+
// to it. Precedence matches the push path exactly — a file carrying both
|
|
3531
|
+
// an id and a name is pushed on the id, so it is compared on the id.
|
|
3532
|
+
configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
|
|
3533
|
+
evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
|
|
3534
|
+
evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
|
|
3535
|
+
}),
|
|
3536
|
+
describe: (entity) => `test case "${entity?.name}"`,
|
|
3537
|
+
},
|
|
3166
3538
|
"database-type-config": {
|
|
3167
3539
|
label: "database-type-config",
|
|
3168
3540
|
table: DATABASE_TYPE_TABLE,
|
|
@@ -3194,9 +3566,10 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3194
3566
|
};
|
|
3195
3567
|
/**
|
|
3196
3568
|
* Every type the shared comparator serves — the types `config diff` compares
|
|
3197
|
-
* field-for-field, and that `config push` gates on the same projection
|
|
3198
|
-
*
|
|
3199
|
-
* in #
|
|
3569
|
+
* field-for-field, and that `config push` gates on the same projection
|
|
3570
|
+
* (#2731 B1/B2; webhooks joined the comparator in #2757 and their push joined
|
|
3571
|
+
* the same gate in #2880, so no registered type answers "changed?" from the
|
|
3572
|
+
* file's bytes any more).
|
|
3200
3573
|
*
|
|
3201
3574
|
* Published so the round-trip acceptance bar (#2731 B9) can require a fixture
|
|
3202
3575
|
* per type instead of listing them a second time by hand: converting a type is
|
|
@@ -3219,20 +3592,82 @@ export function configDiffSpec(label) {
|
|
|
3219
3592
|
* a push that needs to SAY what differs and a diff that only needs to know THAT
|
|
3220
3593
|
* something differs read the same projection.
|
|
3221
3594
|
*/
|
|
3222
|
-
export function projectLocalConfig(spec, parsedToml, maps) {
|
|
3223
|
-
const entity = spec.parse(parsedToml);
|
|
3595
|
+
export function projectLocalConfig(spec, parsedToml, maps, extra) {
|
|
3596
|
+
const entity = spec.parse(parsedToml, extra);
|
|
3224
3597
|
if (spec.ruleSetRef) {
|
|
3225
3598
|
normalizeRuleSetRefForDiff(entity, maps.ruleSetNameToId, spec.describe(entity));
|
|
3226
3599
|
}
|
|
3227
|
-
|
|
3600
|
+
// #2880 DSO-003 — before anything is hashed, so the extras below read the
|
|
3601
|
+
// canonical values too.
|
|
3602
|
+
spec.canonicalize?.(entity);
|
|
3603
|
+
return projectConfigForComparison(spec.table, entity, spec.extras?.(entity, parsedToml, extra));
|
|
3228
3604
|
}
|
|
3229
3605
|
/** The same projection for a SERVER entity — see `hashRemoteConfigForDiff`. */
|
|
3230
3606
|
export function projectRemoteConfig(spec, record, maps, extra) {
|
|
3231
|
-
return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
|
|
3607
|
+
return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* BOTH sides of one comparison, with the file's unmanaged keys reconciled
|
|
3611
|
+
* (#2880) — the one place `spec.unmanaged` is honored, so `config diff` and
|
|
3612
|
+
* `config push` cannot read the same file differently.
|
|
3613
|
+
*/
|
|
3614
|
+
export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
|
|
3615
|
+
const local = projectLocalConfig(spec, localParsed, maps, localExtra);
|
|
3616
|
+
const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
|
|
3617
|
+
for (const key of spec.unmanaged?.(localParsed) ?? []) {
|
|
3618
|
+
// Key-for-key, so an unmanaged field the server does not hold either is
|
|
3619
|
+
// absent on both sides rather than present-and-undefined on one.
|
|
3620
|
+
if (key in remote)
|
|
3621
|
+
local[key] = remote[key];
|
|
3622
|
+
else
|
|
3623
|
+
delete local[key];
|
|
3624
|
+
}
|
|
3625
|
+
return { local, remote };
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* One `config diff` row's content verdict for a registered type (#2880).
|
|
3629
|
+
*
|
|
3630
|
+
* The three outcomes the per-type blocks were each spelling out by hand:
|
|
3631
|
+
* equal (`exists`), different (`modified`, framed as a preview of
|
|
3632
|
+
* `config pull`), and "could not tell" — a missing record or a comparison that
|
|
3633
|
+
* threw, which degrades THIS row and never the whole diff. Written once so a
|
|
3634
|
+
* type joining the comparator cannot accidentally report a fourth thing.
|
|
3635
|
+
*
|
|
3636
|
+
* The two `extra` arguments are the same split `decidePushForConfig` makes:
|
|
3637
|
+
* `extra` carries what only the SERVER side has (a database type's operation
|
|
3638
|
+
* rows), while `localExtra` is context BOTH sides read a value through — a test
|
|
3639
|
+
* case's id→name lookups. Giving the local side nothing was a silent
|
|
3640
|
+
* mistranslation: a sidecar pinned by a resolvable `configId` projected the id
|
|
3641
|
+
* while the server's projection resolved it to the name, so an untouched file
|
|
3642
|
+
* reported Modified in diff and drifted in push.
|
|
3643
|
+
*/
|
|
3644
|
+
export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
3645
|
+
ruleSetIdToName: new Map(),
|
|
3646
|
+
ruleSetNameToId: new Map(),
|
|
3647
|
+
}, extra, localExtra) {
|
|
3648
|
+
if (!localParsed || !remoteRecord) {
|
|
3649
|
+
return { status: "exists", hint: "content not compared (fetch failed)" };
|
|
3650
|
+
}
|
|
3651
|
+
try {
|
|
3652
|
+
const pair = projectConfigPair(spec, localParsed, remoteRecord, maps, extra ?? localExtra, localExtra);
|
|
3653
|
+
const remoteHash = computeExpandedContentHash(pair.remote);
|
|
3654
|
+
const localHash = computeExpandedContentHash(pair.local);
|
|
3655
|
+
if (remoteHash === localHash)
|
|
3656
|
+
return { status: "exists" };
|
|
3657
|
+
return {
|
|
3658
|
+
status: "modified",
|
|
3659
|
+
hint: "run `config pull` to rewrite the local TOML to match running state",
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
catch {
|
|
3663
|
+
// A file the comparison cannot read is reported as not compared rather
|
|
3664
|
+
// than aborting the diff; `config push` names the fault precisely.
|
|
3665
|
+
return { status: "exists", hint: "content not compared (comparison failed)" };
|
|
3666
|
+
}
|
|
3232
3667
|
}
|
|
3233
3668
|
/** Hash a LOCAL config file's definition-projected field set (#2644). */
|
|
3234
|
-
export function hashLocalConfigForDiff(spec, parsedToml, maps) {
|
|
3235
|
-
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps));
|
|
3669
|
+
export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
|
|
3670
|
+
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
|
|
3236
3671
|
}
|
|
3237
3672
|
/**
|
|
3238
3673
|
* Hash a SERVER entity the same way, by serializing it into the file `sync
|
|
@@ -3244,32 +3679,24 @@ export function hashLocalConfigForDiff(spec, parsedToml, maps) {
|
|
|
3244
3679
|
* operations and subscriptions, which the list response does not include.
|
|
3245
3680
|
*/
|
|
3246
3681
|
export function hashRemoteConfigForDiff(spec, record, maps, extra) {
|
|
3247
|
-
return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
|
|
3682
|
+
return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
|
|
3683
|
+
}
|
|
3684
|
+
export function decideDegradedPush(input) {
|
|
3685
|
+
if (input.force)
|
|
3686
|
+
return { action: "send" };
|
|
3687
|
+
if (input.storedContentHash === undefined) {
|
|
3688
|
+
return { action: "decline", reason: "no-baseline" };
|
|
3689
|
+
}
|
|
3690
|
+
return input.storedContentHash === input.currentFileHash
|
|
3691
|
+
? { action: "skip" }
|
|
3692
|
+
: { action: "send" };
|
|
3248
3693
|
}
|
|
3249
|
-
/**
|
|
3250
|
-
* `config push`'s change decision for one configuration file (#2731 B1/B3/B4/B8).
|
|
3251
|
-
*
|
|
3252
|
-
* This replaces `shouldPushFile` for the converted types. The differences that
|
|
3253
|
-
* matter, in order:
|
|
3254
|
-
*
|
|
3255
|
-
* - the comparison is `config diff`'s — the definition-projected field set on
|
|
3256
|
-
* both sides — so "skip as unchanged" and "Synced" cannot disagree, and a
|
|
3257
|
-
* comment-only or formatting-only edit is not a change;
|
|
3258
|
-
* - the right-hand side is the LIVE entity, not the manifest, so out-of-band
|
|
3259
|
-
* server drift is visible instead of being overwritten;
|
|
3260
|
-
* - the manifest becomes a baseline that answers WHICH side moved, so a
|
|
3261
|
-
* difference is attributed rather than assumed to be a local edit.
|
|
3262
|
-
*
|
|
3263
|
-
* Declining is a first-class outcome here. `drift`, `conflict` and `immutable`
|
|
3264
|
-
* each carry the field diff their report prints, and nothing is applied.
|
|
3265
|
-
*/
|
|
3266
3694
|
export function decidePushForConfig(input) {
|
|
3267
|
-
const { spec, maps, localParsed, live, entry, localFileHash, force } = input;
|
|
3695
|
+
const { spec, maps, localParsed, live, entry, localFileHash, localExtra, force } = input;
|
|
3268
3696
|
if (!live?.record)
|
|
3269
3697
|
return { action: "create" };
|
|
3270
|
-
const localProjected =
|
|
3698
|
+
const { local: localProjected, remote: remoteProjected } = projectConfigPair(spec, localParsed, live.record, maps, live.extra ?? localExtra, localExtra);
|
|
3271
3699
|
const localHash = computeExpandedContentHash(localProjected);
|
|
3272
|
-
const remoteProjected = projectRemoteConfig(spec, live.record, maps, live.extra);
|
|
3273
3700
|
const remoteHash = computeExpandedContentHash(remoteProjected);
|
|
3274
3701
|
const fields = diffProjectedRecords(localProjected, remoteProjected);
|
|
3275
3702
|
// A difference in a field UPDATE does not accept would PATCH "successfully"
|
|
@@ -3312,6 +3739,37 @@ export function decidePushForConfig(input) {
|
|
|
3312
3739
|
entry.modifiedAt === live.modifiedAt,
|
|
3313
3740
|
},
|
|
3314
3741
|
});
|
|
3742
|
+
// #2909 — the adopt, for a type whose push path adopts by key. `entry` is
|
|
3743
|
+
// read for ANY of its three signals, not just the baseline: a row carrying
|
|
3744
|
+
// only a legacy signal has been synced, and its unattributable difference is
|
|
3745
|
+
// the conflict below. This is the never-reconciled file alone.
|
|
3746
|
+
//
|
|
3747
|
+
// Deliberately the SIGNALS and not the row's existence: an `id`-only row is
|
|
3748
|
+
// what the 409 recovery seeds before its update lands (and a failed push
|
|
3749
|
+
// still saves), so it names the object an adopt found without recording that
|
|
3750
|
+
// any content was ever reconciled. There is nothing there to attribute a
|
|
3751
|
+
// move with, and refusing it would wedge that half-adopted slot in a
|
|
3752
|
+
// conflict no `config push` can clear — this same regression, one step in.
|
|
3753
|
+
const untracked = entry?.semanticHash === undefined &&
|
|
3754
|
+
entry?.contentHash === undefined &&
|
|
3755
|
+
entry?.modifiedAt === undefined;
|
|
3756
|
+
if (direction === "unknown-direction" &&
|
|
3757
|
+
input.adoptsUntrackedByKey &&
|
|
3758
|
+
untracked) {
|
|
3759
|
+
if (immutableFields.length > 0)
|
|
3760
|
+
return immutable;
|
|
3761
|
+
return {
|
|
3762
|
+
action: "update",
|
|
3763
|
+
direction: "adopt-untracked",
|
|
3764
|
+
localHash,
|
|
3765
|
+
remoteHash,
|
|
3766
|
+
// As for `local-edited`: the LIVE timestamp closes the window between the
|
|
3767
|
+
// reconcile's read and this write. The 409 path omits it because it never
|
|
3768
|
+
// read the record it adopts; this one just did.
|
|
3769
|
+
expectedModifiedAt: live.modifiedAt,
|
|
3770
|
+
fields,
|
|
3771
|
+
};
|
|
3772
|
+
}
|
|
3315
3773
|
switch (direction) {
|
|
3316
3774
|
case "unchanged":
|
|
3317
3775
|
return { action: "skip", localHash, remoteHash };
|
|
@@ -3842,17 +4300,27 @@ export function slugifyTestCaseName(name) {
|
|
|
3842
4300
|
.replace(/^-+|-+$/g, "")
|
|
3843
4301
|
|| "test";
|
|
3844
4302
|
}
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
4303
|
+
/**
|
|
4304
|
+
* The name to use for `slug`, given the names already taken.
|
|
4305
|
+
*
|
|
4306
|
+
* `occupy` decides what "already taken" means, and `usedSlugs` holds names in
|
|
4307
|
+
* that form (#2896): a file name is occupied CASE-INSENSITIVELY on the
|
|
4308
|
+
* filesystems most of these trees live on, so `Foo` and `foo` are one file even
|
|
4309
|
+
* though they are two strings. The returned name keeps its own casing — only
|
|
4310
|
+
* the occupancy check is normalized.
|
|
4311
|
+
*/
|
|
4312
|
+
export function resolveSlugCollisions(slug, usedSlugs, occupy = (name) => name) {
|
|
4313
|
+
const taken = (candidate) => usedSlugs.has(occupy(candidate));
|
|
4314
|
+
if (!taken(slug)) {
|
|
4315
|
+
usedSlugs.add(occupy(slug));
|
|
3848
4316
|
return slug;
|
|
3849
4317
|
}
|
|
3850
4318
|
let counter = 2;
|
|
3851
|
-
while (
|
|
4319
|
+
while (taken(`${slug}-${counter}`)) {
|
|
3852
4320
|
counter++;
|
|
3853
4321
|
}
|
|
3854
4322
|
const resolved = `${slug}-${counter}`;
|
|
3855
|
-
usedSlugs.add(resolved);
|
|
4323
|
+
usedSlugs.add(occupy(resolved));
|
|
3856
4324
|
return resolved;
|
|
3857
4325
|
}
|
|
3858
4326
|
export function getTestsDir(configDir, blockType, blockKey) {
|
|
@@ -4180,11 +4648,17 @@ export async function pullTestCasesForBlock(params) {
|
|
|
4180
4648
|
return { ok: true, count: 0 };
|
|
4181
4649
|
if (testCases.length > 0)
|
|
4182
4650
|
ensureDir(testsDir);
|
|
4183
|
-
const usedSlugs = new Set();
|
|
4184
4651
|
const pulledSlugs = new Set();
|
|
4652
|
+
// #2896 — a keyed case is written to the file its key names, so pull stops
|
|
4653
|
+
// renaming what push authored: the repro's `full-refresh-takes-all-history`
|
|
4654
|
+
// came back as the slug of its NAME, and the tree never converged. An
|
|
4655
|
+
// unkeyed (pre-#2896) case keeps the slug naming it has always had.
|
|
4656
|
+
const fileNames = testCaseFileBasenames(testCases, (live, reason) => warn(` Test case "${live?.name ?? ""}" on ${blockKey}: ${reason} — writing it under its name instead`),
|
|
4657
|
+
// The key is server data becoming a path: the containment check an
|
|
4658
|
+
// attachment's filename gets, so nothing lands outside the sidecar dir.
|
|
4659
|
+
(name) => attachmentWritePath(testsDir, `${name}.toml`) !== null);
|
|
4185
4660
|
for (const tc of testCases) {
|
|
4186
|
-
const
|
|
4187
|
-
const slug = resolveSlugCollisions(baseSlug, usedSlugs);
|
|
4661
|
+
const slug = fileNames.get(tc);
|
|
4188
4662
|
pulledSlugs.add(slug);
|
|
4189
4663
|
// Write test case TOML
|
|
4190
4664
|
const tomlPath = join(testsDir, `${slug}.toml`);
|
|
@@ -4273,6 +4747,14 @@ export async function pullTestCasesForBlock(params) {
|
|
|
4273
4747
|
modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
|
|
4274
4748
|
attachments,
|
|
4275
4749
|
contentHash: computeFileHash(tomlPath),
|
|
4750
|
+
// #2880 — the file and the server agree right now, so this is the
|
|
4751
|
+
// baseline the next push attributes a difference against.
|
|
4752
|
+
semanticHash: remoteSemanticBaseline({
|
|
4753
|
+
spec: configDiffSpec("test-case"),
|
|
4754
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
4755
|
+
returned: tc,
|
|
4756
|
+
extra: { lookupMaps },
|
|
4757
|
+
}),
|
|
4276
4758
|
};
|
|
4277
4759
|
}
|
|
4278
4760
|
// Clean up local files for remotely-deleted test cases
|
|
@@ -4352,7 +4834,7 @@ const TEN_MB = 10 * 1024 * 1024;
|
|
|
4352
4834
|
* content hashes (#2769). A name the guard rejects is dropped here, before it
|
|
4353
4835
|
* can be turned into an upload.
|
|
4354
4836
|
*/
|
|
4355
|
-
function readLocalAttachments(attachDir, onUnsafe) {
|
|
4837
|
+
function readLocalAttachments(attachDir, onUnsafe = () => { }) {
|
|
4356
4838
|
if (!existsSync(attachDir))
|
|
4357
4839
|
return [];
|
|
4358
4840
|
const files = [];
|
|
@@ -4416,6 +4898,39 @@ export function planAttachmentPush(params) {
|
|
|
4416
4898
|
removedRemotely: recordedAttachmentNames(params.recorded).filter((filename) => !localNames.has(filename)),
|
|
4417
4899
|
};
|
|
4418
4900
|
}
|
|
4901
|
+
/** Whether a server rejected a body key it does not recognize (#2644's 400). */
|
|
4902
|
+
function rejectsUnknownRequestKey(error, key) {
|
|
4903
|
+
const message = String(error?.message ?? error ?? "");
|
|
4904
|
+
return (/unrecognized request body key/i.test(message) &&
|
|
4905
|
+
new RegExp(`\\b${key}\\b`).test(message));
|
|
4906
|
+
}
|
|
4907
|
+
/**
|
|
4908
|
+
* Create a test case carrying the identity its file name asserts (#2896).
|
|
4909
|
+
*
|
|
4910
|
+
* The one case protocol detection cannot answer is a create into an EMPTY
|
|
4911
|
+
* block: there is no listed record to read `key` off. Push is optimistic there,
|
|
4912
|
+
* and an older server's 400 is the answer — retried once without the key, and
|
|
4913
|
+
* named, because a case created without one will duplicate on the next clone.
|
|
4914
|
+
*/
|
|
4915
|
+
export async function createTestCaseWithIdentity(params) {
|
|
4916
|
+
const { client, appId, blockType, blockId, payload, key } = params;
|
|
4917
|
+
if (key === undefined) {
|
|
4918
|
+
return client.createTestCase(appId, blockType, blockId, payload);
|
|
4919
|
+
}
|
|
4920
|
+
try {
|
|
4921
|
+
return await client.createTestCase(appId, blockType, blockId, {
|
|
4922
|
+
...payload,
|
|
4923
|
+
key,
|
|
4924
|
+
});
|
|
4925
|
+
}
|
|
4926
|
+
catch (err) {
|
|
4927
|
+
if (!rejectsUnknownRequestKey(err, "key"))
|
|
4928
|
+
throw err;
|
|
4929
|
+
(params.logger ?? warn)(" test cases on this server have no identity key — a fresh clone " +
|
|
4930
|
+
"cannot reconcile them; upgrade the server");
|
|
4931
|
+
return client.createTestCase(appId, blockType, blockId, payload);
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4419
4934
|
/**
|
|
4420
4935
|
* Push one block's authored test-case sidecar. Exported so the unit tests can
|
|
4421
4936
|
* drive it against a stubbed client on a temp directory.
|
|
@@ -4432,9 +4947,75 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4432
4947
|
const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
|
|
4433
4948
|
if (files.length === 0)
|
|
4434
4949
|
return { skipped };
|
|
4950
|
+
// #2880 — the block's live cases, read once. They are what `config diff`
|
|
4951
|
+
// compares against and what `config pull` writes from, so push deciding from
|
|
4952
|
+
// anything else is how the two commands came to disagree. A failed listing
|
|
4953
|
+
// degrades this block to the byte-hash gate, named, rather than sending every
|
|
4954
|
+
// sidecar down the create path.
|
|
4955
|
+
const testCaseSpec = configDiffSpec("test-case");
|
|
4956
|
+
const testCaseExtra = { lookupMaps: params.lookupMaps };
|
|
4957
|
+
const listed = await listAllTestCases(client, appId, blockType, blockId);
|
|
4958
|
+
const liveOk = listed.ok;
|
|
4959
|
+
if (!liveOk) {
|
|
4960
|
+
warn(` ${blockKey} test cases: live state unavailable — falling back to last-sync content hashes`);
|
|
4961
|
+
}
|
|
4962
|
+
// #2896 — whether this server carries identity keys at all. Every record a
|
|
4963
|
+
// key-supporting server lists has the property (null included), so its
|
|
4964
|
+
// presence is the protocol signal. An EMPTY listing cannot answer, and
|
|
4965
|
+
// "unknown" is a third answer rather than a guess: push is optimistic with
|
|
4966
|
+
// the key it sends, but it must not REFUSE a file over a rule only a keyed
|
|
4967
|
+
// server has, since against a pre-key server that file has always pushed.
|
|
4968
|
+
const keySupport = !liveOk
|
|
4969
|
+
? "no"
|
|
4970
|
+
: listed.items.some((live) => live && typeof live === "object" && "key" in live)
|
|
4971
|
+
? "yes"
|
|
4972
|
+
: listed.items.length === 0
|
|
4973
|
+
? "unknown"
|
|
4974
|
+
: "no";
|
|
4975
|
+
const serverSupportsKeys = keySupport === "yes";
|
|
4976
|
+
params.refreshLookupMaps?.();
|
|
4977
|
+
const paired = pairTestCases({
|
|
4978
|
+
blockType,
|
|
4979
|
+
blockKey,
|
|
4980
|
+
localSlugs: files.map((f) => basename(f, ".toml")),
|
|
4981
|
+
liveCases: liveOk ? listed.items : [],
|
|
4982
|
+
managed: syncState?.entities?.testCases,
|
|
4983
|
+
serverSupportsKeys,
|
|
4984
|
+
contentMatches: testCaseContentMatcher({
|
|
4985
|
+
testsDir,
|
|
4986
|
+
spec: testCaseSpec,
|
|
4987
|
+
extra: testCaseExtra,
|
|
4988
|
+
}),
|
|
4989
|
+
});
|
|
4990
|
+
// #2896 AC8 — an unpaired file in a block that still holds unclaimed UNKEYED
|
|
4991
|
+
// cases could be any one of them under another name, and creating it is how
|
|
4992
|
+
// this issue's duplicates were made. Refusing is the same posture
|
|
4993
|
+
// `decideDegradedPush` takes when neither side's state is known.
|
|
4994
|
+
const unmatchedLegacy = serverSupportsKeys && paired.remoteOnly.some((live) => !live?.key);
|
|
4435
4995
|
for (const file of files) {
|
|
4436
4996
|
const slug = basename(file, ".toml");
|
|
4437
4997
|
const filePath = join(testsDir, file);
|
|
4998
|
+
// #2896 AC6 — on a server that has keys, a basename that cannot BE one
|
|
4999
|
+
// would be pushed without any identity and duplicate itself on every fresh
|
|
5000
|
+
// clone from then on. So this file's push fails here, named and before any
|
|
5001
|
+
// server call for it, rather than half-succeeding. Against a pre-key server
|
|
5002
|
+
// there is no key to violate and the file pushes exactly as it always did —
|
|
5003
|
+
// which is why an UNDETERMINED server (an empty block) does not refuse the
|
|
5004
|
+
// file: the create carries the key optimistically and the server's answer
|
|
5005
|
+
// decides, either the unknown-key retry or the rename instruction below.
|
|
5006
|
+
const keyError = keySupport === "no" ? null : testCaseKeyError(slug);
|
|
5007
|
+
if (keyError && keySupport === "yes") {
|
|
5008
|
+
const message = `${keyError} — rename ${file} to a path-safe file name`;
|
|
5009
|
+
warn(` Failed to push test case ${blockKey}/${slug}: ${message}`);
|
|
5010
|
+
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
|
|
5011
|
+
continue;
|
|
5012
|
+
}
|
|
5013
|
+
const refusal = paired.refused.get(slug);
|
|
5014
|
+
if (refusal) {
|
|
5015
|
+
warn(` Failed to push test case ${blockKey}/${slug}: ${refusal}`);
|
|
5016
|
+
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message: refusal });
|
|
5017
|
+
continue;
|
|
5018
|
+
}
|
|
4438
5019
|
const tomlData = parseTomlFile(filePath);
|
|
4439
5020
|
const testPayload = parseTestCaseToml(tomlData);
|
|
4440
5021
|
// Resolve key-based references to IDs. An authored name that does not
|
|
@@ -4508,21 +5089,182 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4508
5089
|
}
|
|
4509
5090
|
const stateKey = `${blockType}/${blockKey}/${slug}`;
|
|
4510
5091
|
const existingEntry = syncState?.entities?.testCases?.[stateKey];
|
|
5092
|
+
// #2896 — the row a rename left behind under the file's OLD name. It is
|
|
5093
|
+
// THIS case's row, and what it records about attachments is what the server
|
|
5094
|
+
// holds for this test case id — bookkeeping that follows the case, not the
|
|
5095
|
+
// file name. Reading it here is what lets `--prune` still see a fixture the
|
|
5096
|
+
// operator deleted in the same breath as the rename.
|
|
5097
|
+
const staleStateKey = paired.renamedFrom.get(slug);
|
|
5098
|
+
const renamedFromEntry = staleStateKey && staleStateKey !== stateKey
|
|
5099
|
+
? syncState?.entities?.testCases?.[staleStateKey]
|
|
5100
|
+
: undefined;
|
|
4511
5101
|
const attachDir = join(testsDir, slug);
|
|
4512
5102
|
const localAttachments = readLocalAttachments(attachDir, (filename) => warn(` Skipping attachment ${blockKey}/${slug}/${filename}: unsafe filename`));
|
|
4513
5103
|
const attachmentPlan = planAttachmentPush({
|
|
4514
5104
|
local: localAttachments,
|
|
4515
|
-
recorded: existingEntry,
|
|
5105
|
+
recorded: existingEntry ?? renamedFromEntry,
|
|
4516
5106
|
});
|
|
4517
|
-
//
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
5107
|
+
// #2880 — the change decision is the comparison `config diff` makes,
|
|
5108
|
+
// against the case the server holds NOW. The old gate asked the manifest
|
|
5109
|
+
// whether the file's BYTES had moved, so a comment planned an update diff
|
|
5110
|
+
// called synced and server drift was skipped however far it had gone.
|
|
5111
|
+
//
|
|
5112
|
+
// An attachment still pending upload is a change whatever the text says:
|
|
5113
|
+
// the sidecar and its fixtures are one resource to push.
|
|
5114
|
+
const liveCase = paired.bySlug.get(slug);
|
|
5115
|
+
// #2896 — the identity this file's basename asserts, when the server can
|
|
5116
|
+
// carry one and this case does not already have exactly it. A null key is
|
|
5117
|
+
// the legacy case being stamped; a different key is a file that was renamed.
|
|
5118
|
+
const pendingKey = serverSupportsKeys && liveOk && liveCase && liveCase.key !== slug
|
|
5119
|
+
? slug
|
|
5120
|
+
: undefined;
|
|
5121
|
+
/** Record this file's manifest row, retiring the one a rename left behind. */
|
|
5122
|
+
const recordTestCaseEntry = (record) => {
|
|
5123
|
+
if (!syncState)
|
|
5124
|
+
return;
|
|
5125
|
+
if (!syncState.entities.testCases)
|
|
5126
|
+
syncState.entities.testCases = {};
|
|
5127
|
+
const entry = (syncState.entities.testCases[stateKey] ??= {
|
|
5128
|
+
blockType,
|
|
5129
|
+
blockId,
|
|
5130
|
+
blockKey,
|
|
5131
|
+
slug,
|
|
5132
|
+
});
|
|
5133
|
+
entry.id = record.id;
|
|
5134
|
+
entry.contentHash = computeFileHash(filePath);
|
|
5135
|
+
if (record.semanticHash !== undefined) {
|
|
5136
|
+
entry.semanticHash = record.semanticHash;
|
|
5137
|
+
}
|
|
5138
|
+
if (record.modifiedAt)
|
|
5139
|
+
entry.modifiedAt = record.modifiedAt;
|
|
5140
|
+
// A rename carries its attachment bookkeeping across: it records which
|
|
5141
|
+
// files the SERVER holds for this case, so dropping it would leave a
|
|
5142
|
+
// fixture the operator deleted invisible to every later `--prune`.
|
|
5143
|
+
if (renamedFromEntry && entry.attachments === undefined) {
|
|
5144
|
+
if (renamedFromEntry.attachments !== undefined) {
|
|
5145
|
+
entry.attachments = renamedFromEntry.attachments;
|
|
5146
|
+
}
|
|
5147
|
+
if (renamedFromEntry.attachmentFilenames !== undefined) {
|
|
5148
|
+
entry.attachmentFilenames = renamedFromEntry.attachmentFilenames;
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
5151
|
+
// The row a rename left under the old file name names THIS case, so a
|
|
5152
|
+
// later `--prune` reading it would delete the case this push just
|
|
5153
|
+
// updated. Clearing it is bookkeeping — nothing is deleted server-side.
|
|
5154
|
+
if (staleStateKey && staleStateKey !== stateKey) {
|
|
5155
|
+
delete syncState.entities.testCases[staleStateKey];
|
|
5156
|
+
}
|
|
5157
|
+
};
|
|
5158
|
+
let gate;
|
|
5159
|
+
// The reference resolution above is what loads this block's configs, so the
|
|
5160
|
+
// lookups the comparison reads through are re-derived from it first.
|
|
5161
|
+
params.refreshLookupMaps?.();
|
|
5162
|
+
if (liveOk && liveCase) {
|
|
5163
|
+
gate = decidePushForConfig({
|
|
5164
|
+
spec: testCaseSpec,
|
|
5165
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
5166
|
+
localParsed: tomlData,
|
|
5167
|
+
live: { record: liveCase, modifiedAt: liveCase.modifiedAt },
|
|
5168
|
+
entry: existingEntry,
|
|
5169
|
+
localFileHash: computeFileHash(filePath),
|
|
5170
|
+
localExtra: testCaseExtra,
|
|
5171
|
+
force: params.options?.force,
|
|
5172
|
+
});
|
|
5173
|
+
if (gate.action === "skip" && attachmentPlan.upload.length === 0) {
|
|
5174
|
+
// #2896 — a content skip is not a reason to leave identity unrecorded.
|
|
5175
|
+
// A legacy case (key null) or one whose file was renamed still needs
|
|
5176
|
+
// the key stamped, so the skip becomes a KEY-ONLY update: without it,
|
|
5177
|
+
// `push` on an already-synced legacy tree converged on nothing and the
|
|
5178
|
+
// next fresh clone duplicated it all over again.
|
|
5179
|
+
if (pendingKey !== undefined) {
|
|
5180
|
+
if (dryRun) {
|
|
5181
|
+
changes.push({
|
|
5182
|
+
type: "test-case",
|
|
5183
|
+
action: "update",
|
|
5184
|
+
key: `${blockKey}/${slug}`,
|
|
5185
|
+
});
|
|
5186
|
+
continue;
|
|
5187
|
+
}
|
|
5188
|
+
try {
|
|
5189
|
+
const stamped = await client.updateTestCase(appId, blockType, blockId, liveCase.testCaseId, { key: pendingKey });
|
|
5190
|
+
changes.push({
|
|
5191
|
+
type: "test-case",
|
|
5192
|
+
action: "update",
|
|
5193
|
+
key: `${blockKey}/${slug}`,
|
|
5194
|
+
});
|
|
5195
|
+
info(` Recorded test-case identity: ${blockKey}/${slug}`);
|
|
5196
|
+
recordTestCaseEntry({
|
|
5197
|
+
id: liveCase.testCaseId,
|
|
5198
|
+
modifiedAt: stamped?.modifiedAt || liveCase.modifiedAt,
|
|
5199
|
+
semanticHash: gate.remoteHash,
|
|
5200
|
+
});
|
|
5201
|
+
}
|
|
5202
|
+
catch (err) {
|
|
5203
|
+
const message = err?.message || String(err);
|
|
5204
|
+
warn(` Failed to update test case ${blockKey}/${slug}: ${message}`);
|
|
5205
|
+
failures.push({
|
|
5206
|
+
type: "test-case",
|
|
5207
|
+
key: `${blockKey}/${slug}`,
|
|
5208
|
+
message,
|
|
5209
|
+
});
|
|
5210
|
+
}
|
|
5211
|
+
continue;
|
|
5212
|
+
}
|
|
5213
|
+
skipped++;
|
|
5214
|
+
// A skip decided against live state refreshes the row, so the next run
|
|
5215
|
+
// classifies against a baseline rather than inferring from timestamps.
|
|
5216
|
+
//
|
|
5217
|
+
// CREATING the row when it is missing is the point: the case this skip
|
|
5218
|
+
// agreed with may have been adopted by slug, and identity is the
|
|
5219
|
+
// manifest id first. Recording only rows that already existed left an
|
|
5220
|
+
// adopted case unmanaged, so the next rename lost its id and paired as
|
|
5221
|
+
// local-only beside remote-only — the duplicate create this pairing
|
|
5222
|
+
// exists to prevent.
|
|
5223
|
+
//
|
|
5224
|
+
// Through the same recorder as every other outcome (#2896): a rename
|
|
5225
|
+
// another machine already pushed arrives here with the content ALREADY
|
|
5226
|
+
// synced, and its stale row has to be retired even so — two rows for
|
|
5227
|
+
// one case would later attach a new file reusing the old name to this
|
|
5228
|
+
// very case instead of creating the one the operator asked for.
|
|
5229
|
+
recordTestCaseEntry({
|
|
5230
|
+
id: liveCase.testCaseId,
|
|
5231
|
+
modifiedAt: liveCase.modifiedAt,
|
|
5232
|
+
semanticHash: gate.remoteHash,
|
|
5233
|
+
});
|
|
5234
|
+
continue;
|
|
5235
|
+
}
|
|
5236
|
+
if (gate.action === "drift" ||
|
|
5237
|
+
gate.action === "conflict" ||
|
|
5238
|
+
gate.action === "immutable") {
|
|
5239
|
+
params.declined?.("test-case", `${blockKey}/${slug}`, gate, existingEntry?.modifiedAt);
|
|
5240
|
+
continue;
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
5243
|
+
else if (!liveOk) {
|
|
5244
|
+
// Degraded: the byte gate this section has always fallen back to, and a
|
|
5245
|
+
// decline when there is no hash to gate on (#2880 criterion 7).
|
|
5246
|
+
const degraded = decideDegradedPush({
|
|
5247
|
+
force: params.options?.force,
|
|
5248
|
+
storedContentHash: existingEntry?.contentHash,
|
|
5249
|
+
currentFileHash: computeFileHash(filePath),
|
|
5250
|
+
});
|
|
5251
|
+
if (degraded.action === "skip" && attachmentPlan.upload.length === 0) {
|
|
5252
|
+
skipped++;
|
|
5253
|
+
continue;
|
|
5254
|
+
}
|
|
5255
|
+
if (degraded.action === "decline") {
|
|
5256
|
+
params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
5257
|
+
continue;
|
|
5258
|
+
}
|
|
4524
5259
|
}
|
|
4525
|
-
|
|
5260
|
+
// Live state decides create-vs-update: a case the manifest never recorded
|
|
5261
|
+
// but the server has is adopted rather than duplicated (#2896's half of
|
|
5262
|
+
// this), and one the manifest names that the server no longer has is
|
|
5263
|
+
// re-created.
|
|
5264
|
+
const existingId = liveOk
|
|
5265
|
+
? liveCase?.testCaseId
|
|
5266
|
+
: existingEntry?.id;
|
|
5267
|
+
if (existingId) {
|
|
4526
5268
|
// Update existing test case. The body is FULL STATE: the handler only
|
|
4527
5269
|
// touches keys it receives, so an omitted key retains its old value and
|
|
4528
5270
|
// blanking a field in the file would never reach the server (#2769).
|
|
@@ -4537,17 +5279,30 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4537
5279
|
evaluatorPromptId: testPayload.evaluatorPromptId ?? null,
|
|
4538
5280
|
evaluatorConfigId: testPayload.evaluatorConfigId ?? null,
|
|
4539
5281
|
};
|
|
5282
|
+
// #2896 — the identity travels with the content: this is the lazy
|
|
5283
|
+
// migration of a legacy case and the second half of a rename.
|
|
5284
|
+
if (pendingKey !== undefined)
|
|
5285
|
+
updatePayload.key = pendingKey;
|
|
4540
5286
|
if (dryRun) {
|
|
4541
5287
|
changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
|
|
4542
5288
|
}
|
|
4543
5289
|
else {
|
|
4544
5290
|
try {
|
|
4545
|
-
await client.updateTestCase(appId, blockType, blockId,
|
|
5291
|
+
const updated = await client.updateTestCase(appId, blockType, blockId, existingId, updatePayload);
|
|
4546
5292
|
changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
|
|
4547
5293
|
info(` Updated test case: ${blockKey}/${slug}`);
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
5294
|
+
recordTestCaseEntry({
|
|
5295
|
+
id: existingId,
|
|
5296
|
+
modifiedAt: updated?.modifiedAt,
|
|
5297
|
+
// #2880 — the baseline, from the record the server stored (it trims
|
|
5298
|
+
// the name on write), so the next run can attribute a difference.
|
|
5299
|
+
semanticHash: remoteSemanticBaseline({
|
|
5300
|
+
spec: testCaseSpec,
|
|
5301
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
5302
|
+
returned: updated ?? { ...liveCase, ...updatePayload },
|
|
5303
|
+
extra: testCaseExtra,
|
|
5304
|
+
}),
|
|
5305
|
+
});
|
|
4551
5306
|
}
|
|
4552
5307
|
catch (err) {
|
|
4553
5308
|
const message = err?.message || String(err);
|
|
@@ -4565,12 +5320,32 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4565
5320
|
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
|
|
4566
5321
|
continue;
|
|
4567
5322
|
}
|
|
5323
|
+
// #2896 AC8 — the refusal that replaces this issue's duplicates: while
|
|
5324
|
+
// the block still holds unkeyed cases no local file could be matched to,
|
|
5325
|
+
// this file may BE one of them under another name.
|
|
5326
|
+
if (unmatchedLegacy) {
|
|
5327
|
+
const message = "unkeyed cases exist on the server that no local file could be " +
|
|
5328
|
+
"matched to — push once from a machine with sync state, or run " +
|
|
5329
|
+
"`config pull` first";
|
|
5330
|
+
warn(` Refused to create test case ${blockKey}/${slug}: ${message}`);
|
|
5331
|
+
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
|
|
5332
|
+
continue;
|
|
5333
|
+
}
|
|
4568
5334
|
if (dryRun) {
|
|
4569
5335
|
changes.push({ type: "test-case", action: "create", key: `${blockKey}/${slug}` });
|
|
4570
5336
|
}
|
|
4571
5337
|
else {
|
|
4572
5338
|
try {
|
|
4573
|
-
|
|
5339
|
+
// The basename IS the identity, so it travels with the create — that
|
|
5340
|
+
// is what makes the next clone of this tree reconcile.
|
|
5341
|
+
const created = await createTestCaseWithIdentity({
|
|
5342
|
+
client,
|
|
5343
|
+
appId,
|
|
5344
|
+
blockType,
|
|
5345
|
+
blockId,
|
|
5346
|
+
payload: testPayload,
|
|
5347
|
+
key: keySupport === "no" ? undefined : slug,
|
|
5348
|
+
});
|
|
4574
5349
|
changes.push({ type: "test-case", action: "create", key: `${blockKey}/${slug}` });
|
|
4575
5350
|
info(` Created test case: ${blockKey}/${slug}`);
|
|
4576
5351
|
// Record in sync state
|
|
@@ -4586,11 +5361,25 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4586
5361
|
slug,
|
|
4587
5362
|
modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
|
|
4588
5363
|
contentHash: computeFileHash(filePath),
|
|
5364
|
+
// #2880 — a create records its baseline too, from the stored
|
|
5365
|
+
// record: the handler trims the name, so a baseline taken from
|
|
5366
|
+
// the file would read that trim as a difference on the next run.
|
|
5367
|
+
semanticHash: remoteSemanticBaseline({
|
|
5368
|
+
spec: testCaseSpec,
|
|
5369
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
5370
|
+
returned: created,
|
|
5371
|
+
extra: testCaseExtra,
|
|
5372
|
+
}),
|
|
4589
5373
|
};
|
|
4590
5374
|
}
|
|
4591
5375
|
}
|
|
4592
5376
|
catch (err) {
|
|
4593
|
-
|
|
5377
|
+
// A key the server rejected on a block that could not be probed is
|
|
5378
|
+
// the preflight above, answered late: say the same thing it would.
|
|
5379
|
+
const serverMessage = err?.message || String(err);
|
|
5380
|
+
const message = keyError && /test case key/i.test(serverMessage)
|
|
5381
|
+
? `${keyError} — rename ${file} to a path-safe file name`
|
|
5382
|
+
: serverMessage;
|
|
4594
5383
|
warn(` Failed to create test case ${blockKey}/${slug}: ${message}`);
|
|
4595
5384
|
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
|
|
4596
5385
|
continue;
|
|
@@ -4602,7 +5391,7 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4602
5391
|
// plain push (sponsor ruling, 2026-08-18).
|
|
4603
5392
|
if (dryRun)
|
|
4604
5393
|
continue;
|
|
4605
|
-
const testCaseId =
|
|
5394
|
+
const testCaseId = existingId || syncState?.entities?.testCases?.[stateKey]?.id;
|
|
4606
5395
|
if (!testCaseId)
|
|
4607
5396
|
continue;
|
|
4608
5397
|
for (const filename of attachmentPlan.upload) {
|
|
@@ -4647,32 +5436,287 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4647
5436
|
return { skipped };
|
|
4648
5437
|
}
|
|
4649
5438
|
/**
|
|
4650
|
-
*
|
|
5439
|
+
* The file name each live test case belongs in (#2896).
|
|
5440
|
+
*
|
|
5441
|
+
* A KEYED case is named by its key: that is what the key is — the basename the
|
|
5442
|
+
* committed tree carries. An unkeyed one (created by web-admin, an older CLI or
|
|
5443
|
+
* the raw API) keeps the slug-of-name naming, with the collision suffixes
|
|
5444
|
+
* resolved only AFTER the keys have claimed theirs, so a slug can never take a
|
|
5445
|
+
* name a key owns.
|
|
5446
|
+
*
|
|
5447
|
+
* One function for pull, for pairing's adoption pass and for the diff's
|
|
5448
|
+
* remote-only rows, because three commands that name a case's file differently
|
|
5449
|
+
* is the disagreement this issue reports.
|
|
5450
|
+
*
|
|
5451
|
+
* Two names that differ only by CASE are one file on a case-insensitive
|
|
5452
|
+
* filesystem, so occupancy is tracked the way the server's key constraint
|
|
5453
|
+
* normalizes: a keyed `Foo` and an unkeyed case named "foo" get `Foo.toml` and
|
|
5454
|
+
* `foo-2.toml`, rather than one pull silently overwriting the other's sidecar.
|
|
5455
|
+
*/
|
|
5456
|
+
export function testCaseFileBasenames(liveCases, onUnusableKey, isSafeName) {
|
|
5457
|
+
const used = new Set();
|
|
5458
|
+
const occupy = (name) => normalizeTestCaseKey(name);
|
|
5459
|
+
const names = new Map();
|
|
5460
|
+
const unkeyed = [];
|
|
5461
|
+
for (const live of liveCases) {
|
|
5462
|
+
const key = typeof live?.key === "string" ? live.key : "";
|
|
5463
|
+
if (!key) {
|
|
5464
|
+
unkeyed.push(live);
|
|
5465
|
+
continue;
|
|
5466
|
+
}
|
|
5467
|
+
// The key is SERVER data on its way to becoming a path. A server that
|
|
5468
|
+
// stored one this CLI would not have sent must not be able to name a file
|
|
5469
|
+
// outside the sidecar directory.
|
|
5470
|
+
const reason = testCaseKeyError(key) ??
|
|
5471
|
+
(isSafeName && !isSafeName(key)
|
|
5472
|
+
? `test case key "${key}" is not a safe file name here`
|
|
5473
|
+
: null);
|
|
5474
|
+
if (reason) {
|
|
5475
|
+
onUnusableKey?.(live, reason);
|
|
5476
|
+
unkeyed.push(live);
|
|
5477
|
+
continue;
|
|
5478
|
+
}
|
|
5479
|
+
names.set(live, resolveSlugCollisions(key, used, occupy));
|
|
5480
|
+
}
|
|
5481
|
+
for (const live of unkeyed) {
|
|
5482
|
+
names.set(live, resolveSlugCollisions(slugifyTestCaseName(live?.name || "unnamed"), used, occupy));
|
|
5483
|
+
}
|
|
5484
|
+
return names;
|
|
5485
|
+
}
|
|
5486
|
+
/**
|
|
5487
|
+
* Whether a sidecar and a live case say the same thing (#2896).
|
|
5488
|
+
*
|
|
5489
|
+
* The corroboration the adoption passes below need: identity nothing states can
|
|
5490
|
+
* only be inferred from content, and only when the inference is unique. It is
|
|
5491
|
+
* the SAME projection `config diff` compares with, memoized per file and per
|
|
5492
|
+
* record, so pairing and the change verdict can never disagree. A file that
|
|
5493
|
+
* cannot be read matches nothing — an unparseable sidecar fails its own push
|
|
5494
|
+
* with a message that names it.
|
|
5495
|
+
*/
|
|
5496
|
+
export function testCaseContentMatcher(params) {
|
|
5497
|
+
const maps = {
|
|
5498
|
+
ruleSetIdToName: new Map(),
|
|
5499
|
+
ruleSetNameToId: new Map(),
|
|
5500
|
+
};
|
|
5501
|
+
const localHashes = new Map();
|
|
5502
|
+
const remoteHashes = new Map();
|
|
5503
|
+
return (localSlug, live) => {
|
|
5504
|
+
if (!localHashes.has(localSlug)) {
|
|
5505
|
+
let hash = null;
|
|
5506
|
+
try {
|
|
5507
|
+
hash = hashLocalConfigForDiff(params.spec, parseTomlFile(join(params.testsDir, `${localSlug}.toml`)), maps, params.extra);
|
|
5508
|
+
}
|
|
5509
|
+
catch {
|
|
5510
|
+
hash = null;
|
|
5511
|
+
}
|
|
5512
|
+
localHashes.set(localSlug, hash);
|
|
5513
|
+
}
|
|
5514
|
+
const local = localHashes.get(localSlug);
|
|
5515
|
+
if (!local)
|
|
5516
|
+
return false;
|
|
5517
|
+
if (!remoteHashes.has(live)) {
|
|
5518
|
+
remoteHashes.set(live, hashRemoteConfigForDiff(params.spec, live, maps, params.extra));
|
|
5519
|
+
}
|
|
5520
|
+
return remoteHashes.get(live) === local;
|
|
5521
|
+
};
|
|
5522
|
+
}
|
|
5523
|
+
/**
|
|
5524
|
+
* Which live test case each sidecar manages (#2880 behavior 16, #2896).
|
|
5525
|
+
*
|
|
5526
|
+
* The manifest id comes FIRST, then the identity the COMMITTED tree carries —
|
|
5527
|
+
* the file's basename, matched against the case's stored `key`. Everything
|
|
5528
|
+
* after that is adoption of a case whose identity nothing states: a rename the
|
|
5529
|
+
* manifest still remembers under the old name, a legacy case matched by
|
|
5530
|
+
* content, and finally the slug-of-name rule #2880 shipped.
|
|
5531
|
+
*
|
|
5532
|
+
* The order matters because every pass consumes its claims: identity that IS
|
|
5533
|
+
* recorded always wins over a guess, and a guess is only made when it is
|
|
5534
|
+
* unambiguous in BOTH directions. Where it is not, the file stays unpaired and
|
|
5535
|
+
* is barred from the create path — push refusing to guess is the whole point,
|
|
5536
|
+
* since the failure it replaces is a silently duplicated test case.
|
|
5537
|
+
*
|
|
5538
|
+
* Against a server with no keys the passes that depend on them are skipped
|
|
5539
|
+
* entirely, so pairing is bit-for-bit what #2880 shipped.
|
|
4651
5540
|
*
|
|
4652
|
-
*
|
|
4653
|
-
*
|
|
4654
|
-
* the second colliding name and must not read as an unpushed local file.
|
|
5541
|
+
* One function for both commands, because a diff row and a push decision that
|
|
5542
|
+
* pair differently are two answers to the same question.
|
|
4655
5543
|
*/
|
|
4656
|
-
export function
|
|
4657
|
-
const
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
const
|
|
4661
|
-
|
|
4662
|
-
|
|
5544
|
+
export function pairTestCases(input) {
|
|
5545
|
+
const bySlug = new Map();
|
|
5546
|
+
// Claimed by RECORD rather than by id: a row the server returned without one
|
|
5547
|
+
// still has to be paired or reported, never silently dropped from both sides.
|
|
5548
|
+
const claimed = new Set();
|
|
5549
|
+
const renamedFrom = new Map();
|
|
5550
|
+
const refused = new Map();
|
|
5551
|
+
const byId = new Map();
|
|
5552
|
+
for (const live of input.liveCases) {
|
|
5553
|
+
if (live?.testCaseId)
|
|
5554
|
+
byId.set(live.testCaseId, live);
|
|
5555
|
+
}
|
|
5556
|
+
const unpaired = [];
|
|
5557
|
+
for (const slug of input.localSlugs) {
|
|
5558
|
+
const managedId = input.managed?.[`${input.blockType}/${input.blockKey}/${slug}`]?.id;
|
|
5559
|
+
const live = managedId ? byId.get(managedId) : undefined;
|
|
5560
|
+
if (live) {
|
|
5561
|
+
bySlug.set(slug, live);
|
|
5562
|
+
claimed.add(live);
|
|
5563
|
+
}
|
|
5564
|
+
else {
|
|
5565
|
+
unpaired.push(slug);
|
|
5566
|
+
}
|
|
4663
5567
|
}
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
5568
|
+
const pair = (slug, live) => {
|
|
5569
|
+
bySlug.set(slug, live);
|
|
5570
|
+
claimed.add(live);
|
|
5571
|
+
// A later pass that CAN name this file's case unambiguously answers the
|
|
5572
|
+
// question an earlier one refused to guess at.
|
|
5573
|
+
refused.delete(slug);
|
|
5574
|
+
};
|
|
5575
|
+
const stillUnpaired = () => unpaired.filter((s) => !bySlug.has(s));
|
|
5576
|
+
const unkeyed = (live) => !live?.key;
|
|
5577
|
+
// The manifest rows whose file is GONE, each pointing at a live case nothing
|
|
5578
|
+
// has claimed: the rename candidates, and the rows to clear once the file
|
|
5579
|
+
// that succeeded them is recorded. Collected before the passes below consume
|
|
5580
|
+
// them, so a rename that pass 2 resolves by key still clears its old row.
|
|
5581
|
+
const staleLive = new Map();
|
|
5582
|
+
const staleName = (stateKey) => input.managed?.[stateKey]?.slug ?? stateKey.split("/").slice(2).join("/");
|
|
5583
|
+
{
|
|
5584
|
+
const localNames = new Set(input.localSlugs);
|
|
5585
|
+
for (const [stateKey, entry] of Object.entries(input.managed ?? {})) {
|
|
5586
|
+
if (!stateKey.startsWith(`${input.blockType}/${input.blockKey}/`))
|
|
5587
|
+
continue;
|
|
5588
|
+
if (localNames.has(staleName(stateKey)))
|
|
5589
|
+
continue;
|
|
5590
|
+
const live = entry?.id ? byId.get(entry.id) : undefined;
|
|
5591
|
+
if (live && !claimed.has(live))
|
|
5592
|
+
staleLive.set(live, stateKey);
|
|
5593
|
+
}
|
|
5594
|
+
}
|
|
5595
|
+
// Pass 2 — the identity the committed file carries, normalized the way the
|
|
5596
|
+
// server's uniqueness constraint normalizes it. On a server with no keys this
|
|
5597
|
+
// matches nothing at all.
|
|
5598
|
+
if (input.serverSupportsKeys) {
|
|
5599
|
+
for (const slug of stillUnpaired()) {
|
|
5600
|
+
const live = input.liveCases.find((candidate) => !claimed.has(candidate) &&
|
|
5601
|
+
typeof candidate?.key === "string" &&
|
|
5602
|
+
candidate.key !== "" &&
|
|
5603
|
+
normalizeTestCaseKey(candidate.key) === normalizeTestCaseKey(slug));
|
|
5604
|
+
if (live)
|
|
5605
|
+
pair(slug, live);
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
/**
|
|
5609
|
+
* One adoption pass: pair a file with a candidate live case only when the
|
|
5610
|
+
* match is unique in both directions. `onAmbiguity` decides what an ambiguous
|
|
5611
|
+
* file is — refused (a rename we will not guess at) or simply unpaired.
|
|
5612
|
+
*/
|
|
5613
|
+
const adoptUnambiguously = (candidatesFor, onAmbiguity, onPair) => {
|
|
5614
|
+
const files = stillUnpaired();
|
|
5615
|
+
const candidates = new Map();
|
|
5616
|
+
for (const slug of files)
|
|
5617
|
+
candidates.set(slug, candidatesFor(slug));
|
|
5618
|
+
for (const slug of files) {
|
|
5619
|
+
const mine = (candidates.get(slug) ?? []).filter((live) => !claimed.has(live));
|
|
5620
|
+
if (mine.length !== 1) {
|
|
5621
|
+
if (mine.length > 1)
|
|
5622
|
+
onAmbiguity?.(slug, mine);
|
|
5623
|
+
continue;
|
|
5624
|
+
}
|
|
5625
|
+
const [live] = mine;
|
|
5626
|
+
// The other direction: a live case two files both claim is not this
|
|
5627
|
+
// file's, whatever this file thinks.
|
|
5628
|
+
const contenders = files.filter((other) => other !== slug &&
|
|
5629
|
+
!bySlug.has(other) &&
|
|
5630
|
+
(candidates.get(other) ?? []).includes(live));
|
|
5631
|
+
if (contenders.length > 0) {
|
|
5632
|
+
onAmbiguity?.(slug, [live]);
|
|
5633
|
+
continue;
|
|
5634
|
+
}
|
|
5635
|
+
pair(slug, live);
|
|
5636
|
+
onPair?.(slug, live);
|
|
5637
|
+
}
|
|
5638
|
+
};
|
|
5639
|
+
// Pass 3 — a rename the manifest still remembers under the old name. The
|
|
5640
|
+
// stale entry's id is the live case, so following it converges on the SAME
|
|
5641
|
+
// test case rather than creating a second one (DSO-2896-003).
|
|
5642
|
+
if (input.serverSupportsKeys && input.contentMatches) {
|
|
5643
|
+
adoptUnambiguously((slug) => [...staleLive.keys()].filter((live) => input.contentMatches(slug, live)), (slug, candidates) => {
|
|
5644
|
+
const names = candidates
|
|
5645
|
+
.map((live) => staleName(staleLive.get(live)))
|
|
5646
|
+
.join(", ");
|
|
5647
|
+
refused.set(slug, `possible rename of ${names || "a case this manifest still tracks"}, ` +
|
|
5648
|
+
"refusing to guess — push from the machine that renamed it, or " +
|
|
5649
|
+
"restore the previous file name");
|
|
5650
|
+
}, (slug, live) => renamedFrom.set(slug, staleLive.get(live)));
|
|
5651
|
+
}
|
|
5652
|
+
// A file paired by KEY whose manifest row still stands under the old name is
|
|
5653
|
+
// the same rename, seen one push later: the stale row is bookkeeping to clear.
|
|
5654
|
+
for (const [slug, live] of bySlug) {
|
|
5655
|
+
const stateKey = staleLive.get(live);
|
|
5656
|
+
if (stateKey && !renamedFrom.has(slug))
|
|
5657
|
+
renamedFrom.set(slug, stateKey);
|
|
5658
|
+
}
|
|
5659
|
+
// Pass 4 — a legacy case with no key at all, matched by content. This is what
|
|
5660
|
+
// lets a fresh clone of a never-stamped tree converge instead of duplicating
|
|
5661
|
+
// (DSO-2896-001); ambiguity leaves the file unpaired for the creation guard.
|
|
5662
|
+
if (input.serverSupportsKeys && input.contentMatches) {
|
|
5663
|
+
adoptUnambiguously((slug) => input.liveCases.filter((live) => unkeyed(live) && input.contentMatches(slug, live)));
|
|
5664
|
+
}
|
|
5665
|
+
// Pass 5 — the slug-of-name rule #2880 shipped, restricted to cases whose
|
|
5666
|
+
// identity nothing else states. Adopting a KEYED case by its name would
|
|
5667
|
+
// silently re-pair a case another file owns.
|
|
5668
|
+
const derivedNames = testCaseFileBasenames(input.liveCases);
|
|
5669
|
+
const localOnly = [];
|
|
5670
|
+
for (const slug of stillUnpaired()) {
|
|
5671
|
+
const adopted = input.liveCases.find((live) => !claimed.has(live) &&
|
|
5672
|
+
(!input.serverSupportsKeys || unkeyed(live)) &&
|
|
5673
|
+
derivedNames.get(live) === slug);
|
|
5674
|
+
if (adopted)
|
|
5675
|
+
pair(slug, adopted);
|
|
5676
|
+
else if (!refused.has(slug))
|
|
5677
|
+
localOnly.push(slug);
|
|
4667
5678
|
}
|
|
4668
|
-
return
|
|
5679
|
+
return {
|
|
5680
|
+
bySlug,
|
|
5681
|
+
localOnly,
|
|
5682
|
+
remoteOnly: input.liveCases.filter((live) => !claimed.has(live)),
|
|
5683
|
+
renamedFrom,
|
|
5684
|
+
refused,
|
|
5685
|
+
};
|
|
4669
5686
|
}
|
|
4670
5687
|
/**
|
|
4671
|
-
*
|
|
4672
|
-
*
|
|
4673
|
-
*
|
|
4674
|
-
*
|
|
5688
|
+
* This block's own `configId`→`configName` pairs, for the comparison (#2880).
|
|
5689
|
+
*
|
|
5690
|
+
* A sidecar pins the config it exercises by NAME so it survives a move between
|
|
5691
|
+
* apps; the server answers with the id. Only the block's configs can equate the
|
|
5692
|
+
* two, and `config diff` reads them from the detail its per-type loops already
|
|
5693
|
+
* fetch — which covers prompts and nothing else. Without this, every script,
|
|
5694
|
+
* integration and workflow sidecar pinned to a config reported Modified
|
|
5695
|
+
* forever, and push read the same pair as server drift.
|
|
5696
|
+
*
|
|
5697
|
+
* One listing per block that has sidecars, and a failed one is silent: an
|
|
5698
|
+
* unresolved reference compares as the id both sides carry, which is the same
|
|
5699
|
+
* degradation `serializeTestCase` already applies.
|
|
4675
5700
|
*/
|
|
5701
|
+
async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
|
|
5702
|
+
try {
|
|
5703
|
+
const configs = blockType === "prompt"
|
|
5704
|
+
? ((await client.getPrompt(appId, blockId))?.configs ?? [])
|
|
5705
|
+
: blockType === "workflow"
|
|
5706
|
+
? ((await client.getWorkflow(appId, blockId))?.configs ?? [])
|
|
5707
|
+
: blockType === "script"
|
|
5708
|
+
? (await client.listScriptConfigs(appId, blockId)).items
|
|
5709
|
+
: (await client.listIntegrationConfigs(appId, blockId)).items;
|
|
5710
|
+
for (const config of configs) {
|
|
5711
|
+
if (config?.configId && config?.configName) {
|
|
5712
|
+
configIdToName.set(config.configId, config.configName);
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
catch {
|
|
5717
|
+
// Non-fatal — see above.
|
|
5718
|
+
}
|
|
5719
|
+
}
|
|
4676
5720
|
export async function compareTestCasesForBlock(params) {
|
|
4677
5721
|
const listed = await listAllTestCases(params.client, params.appId, params.blockType, params.blockId);
|
|
4678
5722
|
if (!listed.ok)
|
|
@@ -4683,17 +5727,108 @@ export async function compareTestCasesForBlock(params) {
|
|
|
4683
5727
|
.filter((f) => f.endsWith(".toml"))
|
|
4684
5728
|
.map((f) => basename(f, ".toml"))
|
|
4685
5729
|
: [];
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
5730
|
+
// #2880 — identity is the manifest id, so a rename is one Modified row rather
|
|
5731
|
+
// than a local-only file beside a remote-only case. #2896 — and the committed
|
|
5732
|
+
// key when the manifest has nothing to say, so a fresh clone reports the
|
|
5733
|
+
// truth too.
|
|
5734
|
+
const managed = params.syncState?.entities?.testCases;
|
|
5735
|
+
const spec = configDiffSpec("test-case");
|
|
5736
|
+
const extra = { lookupMaps: params.lookupMaps };
|
|
5737
|
+
const rows = [];
|
|
5738
|
+
const base = { blockType: params.blockType, blockKey: params.blockKey };
|
|
5739
|
+
// The configs this block's sidecars pin by name (see `fillBlockConfigNames`).
|
|
5740
|
+
// Read BEFORE pairing: the content-corroborated adoption passes compare the
|
|
5741
|
+
// same projection this command reports with, and a reference they cannot read
|
|
5742
|
+
// is a difference that is not there.
|
|
5743
|
+
if (localSlugs.length > 0 && params.lookupMaps) {
|
|
5744
|
+
await fillBlockConfigNames(params.client, params.appId, params.blockType, params.blockId, params.lookupMaps.configIdToName);
|
|
5745
|
+
}
|
|
5746
|
+
const paired = pairTestCases({
|
|
5747
|
+
blockType: params.blockType,
|
|
5748
|
+
blockKey: params.blockKey,
|
|
5749
|
+
localSlugs,
|
|
5750
|
+
liveCases: listed.items,
|
|
5751
|
+
managed,
|
|
5752
|
+
serverSupportsKeys: listed.items.some((live) => live && typeof live === "object" && "key" in live),
|
|
5753
|
+
contentMatches: testCaseContentMatcher({ testsDir, spec, extra }),
|
|
5754
|
+
});
|
|
5755
|
+
for (const slug of localSlugs) {
|
|
5756
|
+
const live = paired.bySlug.get(slug);
|
|
5757
|
+
if (!live) {
|
|
5758
|
+
rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
|
|
5759
|
+
continue;
|
|
5760
|
+
}
|
|
5761
|
+
const filePath = join(testsDir, `${slug}.toml`);
|
|
5762
|
+
let row;
|
|
5763
|
+
try {
|
|
5764
|
+
const tomlData = parseTomlFile(filePath);
|
|
5765
|
+
// #2880 criterion 1 — a sidecar push's preflight would reject is not one
|
|
5766
|
+
// this command calls synced, and the message is the same one. The WHOLE
|
|
5767
|
+
// preflight, not just the sidecar's own rules: an unknown key or a
|
|
5768
|
+
// mistyped value aborts push too.
|
|
5769
|
+
const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
|
|
5770
|
+
if (errors.length > 0) {
|
|
5771
|
+
row = {
|
|
5772
|
+
...base,
|
|
5773
|
+
slug,
|
|
5774
|
+
status: VALIDATION_ERROR,
|
|
5775
|
+
hint: errors.join("; "),
|
|
5776
|
+
};
|
|
5777
|
+
}
|
|
5778
|
+
else {
|
|
5779
|
+
const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
|
|
5780
|
+
// Both sides read a reference through the same lookups — see
|
|
5781
|
+
// `compareLocalToRemote`.
|
|
5782
|
+
extra);
|
|
5783
|
+
row = { ...base, slug, ...verdict };
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
catch (err) {
|
|
5787
|
+
row = {
|
|
5788
|
+
...base,
|
|
5789
|
+
slug,
|
|
5790
|
+
status: VALIDATION_ERROR,
|
|
5791
|
+
hint: `${filePath}: ${err?.message || String(err)}`,
|
|
5792
|
+
};
|
|
5793
|
+
}
|
|
5794
|
+
// #2880 behavior 19 — attachments are part of what push would do, read
|
|
5795
|
+
// from the SAME plan push executes. A sidecar whose text compares clean but
|
|
5796
|
+
// whose fixtures have not been uploaded is not Synced.
|
|
5797
|
+
const stateKey = `${params.blockType}/${params.blockKey}/${slug}`;
|
|
5798
|
+
const plan = planAttachmentPush({
|
|
5799
|
+
local: readLocalAttachments(join(testsDir, slug)),
|
|
5800
|
+
recorded: managed?.[stateKey],
|
|
5801
|
+
});
|
|
5802
|
+
if (plan.upload.length > 0) {
|
|
5803
|
+
row.attachmentUploads = plan.upload;
|
|
5804
|
+
if (row.status === "exists") {
|
|
5805
|
+
row.status = "modified";
|
|
5806
|
+
row.hint = `${plan.upload.length} attachment(s) to upload`;
|
|
5807
|
+
}
|
|
5808
|
+
}
|
|
5809
|
+
if (plan.removedRemotely.length > 0) {
|
|
5810
|
+
row.attachmentDeletions = plan.removedRemotely;
|
|
5811
|
+
row.hint = [
|
|
5812
|
+
row.hint,
|
|
5813
|
+
`${plan.removedRemotely.length} attachment(s) deleted locally — \`push --prune\` removes them server-side`,
|
|
5814
|
+
]
|
|
5815
|
+
.filter(Boolean)
|
|
5816
|
+
.join("; ");
|
|
5817
|
+
}
|
|
5818
|
+
rows.push(row);
|
|
5819
|
+
}
|
|
5820
|
+
// #2896 — the row names the file a pull would put this case in, which for a
|
|
5821
|
+
// keyed case is its key. Reporting the slug of its NAME described a file that
|
|
5822
|
+
// would never exist.
|
|
5823
|
+
const remoteNames = testCaseFileBasenames(listed.items);
|
|
5824
|
+
for (const live of paired.remoteOnly) {
|
|
5825
|
+
rows.push({
|
|
5826
|
+
...base,
|
|
5827
|
+
slug: remoteNames.get(live) ?? slugifyTestCaseName(live?.name || "unnamed"),
|
|
5828
|
+
status: "remote only",
|
|
5829
|
+
});
|
|
5830
|
+
}
|
|
5831
|
+
return { ok: true, rows };
|
|
4697
5832
|
}
|
|
4698
5833
|
/**
|
|
4699
5834
|
* Shape errors in an authored test-case sidecar, for the push preflight
|
|
@@ -4730,6 +5865,29 @@ export function testCaseSidecarErrors(filePath, tomlData) {
|
|
|
4730
5865
|
}
|
|
4731
5866
|
return errors;
|
|
4732
5867
|
}
|
|
5868
|
+
/**
|
|
5869
|
+
* EVERY reason `config push` refuses one test-case sidecar (#2880 criterion 1).
|
|
5870
|
+
*
|
|
5871
|
+
* The four checks the push preflight ran inline: the document's shape, the keys
|
|
5872
|
+
* the definition recognizes, the declared type of each value, and the sidecar's
|
|
5873
|
+
* own authoring rules (#2769). Collected in one place because `config diff`
|
|
5874
|
+
* runs the identical list — it used to run only the last of the four, so a
|
|
5875
|
+
* sidecar with an unknown key or a mistyped ordinary field was reported Synced
|
|
5876
|
+
* by the command whose whole promise is that a Synced file pushes.
|
|
5877
|
+
*/
|
|
5878
|
+
export function testCaseSidecarPreflightErrors(filePath, tomlData) {
|
|
5879
|
+
const errors = [
|
|
5880
|
+
// The document's own shape first: an unrecognized top-level table makes
|
|
5881
|
+
// every per-table check below read an absent table, so its silence would
|
|
5882
|
+
// be misleading.
|
|
5883
|
+
...configDocumentShapeErrors(filePath, tomlData, TEST_CASE_SURFACE),
|
|
5884
|
+
];
|
|
5885
|
+
const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
|
|
5886
|
+
if (unknownKeyError)
|
|
5887
|
+
errors.push(unknownKeyError);
|
|
5888
|
+
errors.push(...configDeclaredTypeErrors(filePath, tomlData, TEST_CASE_TABLE), ...testCaseSidecarErrors(filePath, tomlData));
|
|
5889
|
+
return errors;
|
|
5890
|
+
}
|
|
4733
5891
|
/** Whether a block's own authored file is still in the tree. */
|
|
4734
5892
|
function blockFileExists(configDir, blockType, blockKey) {
|
|
4735
5893
|
const resourceType = SYNC_RESOURCE_TYPES.find((t) => t.testBlockType === blockType);
|
|
@@ -4747,15 +5905,27 @@ function blockFileExists(configDir, blockType, blockKey) {
|
|
|
4747
5905
|
export function collectTestCaseDeletions(params) {
|
|
4748
5906
|
const exists = params.blockExists ??
|
|
4749
5907
|
((blockType, blockKey) => blockFileExists(params.configDir, blockType, blockKey));
|
|
5908
|
+
const entries = Object.entries(params.testCaseEntities ?? {});
|
|
5909
|
+
const fileExists = (entry) => existsSync(join(getTestsDir(params.configDir, entry.blockType, entry.blockKey), `${entry.slug}.toml`));
|
|
4750
5910
|
const candidates = [];
|
|
4751
|
-
for (const [stateKey, entry] of
|
|
5911
|
+
for (const [stateKey, entry] of entries) {
|
|
4752
5912
|
if (!entry?.blockType || !entry?.blockKey || !entry?.slug)
|
|
4753
5913
|
continue;
|
|
4754
5914
|
if (!exists(entry.blockType, entry.blockKey))
|
|
4755
5915
|
continue;
|
|
4756
|
-
|
|
4757
|
-
if (existsSync(filePath))
|
|
5916
|
+
if (fileExists(entry))
|
|
4758
5917
|
continue;
|
|
5918
|
+
// #2896 — a row whose id ANOTHER row still holds, with its file on disk, is
|
|
5919
|
+
// the trace of a rename. Deleting the case it names would delete the case
|
|
5920
|
+
// that file manages, which is how a rename used to lose its history.
|
|
5921
|
+
const supersededBy = entries.find(([otherKey, other]) => otherKey !== stateKey &&
|
|
5922
|
+
other?.id &&
|
|
5923
|
+
entry.id &&
|
|
5924
|
+
other.id === entry.id &&
|
|
5925
|
+
other?.blockType &&
|
|
5926
|
+
other?.blockKey &&
|
|
5927
|
+
other?.slug &&
|
|
5928
|
+
fileExists(other))?.[0];
|
|
4759
5929
|
candidates.push({
|
|
4760
5930
|
stateKey,
|
|
4761
5931
|
blockType: entry.blockType,
|
|
@@ -4763,6 +5933,7 @@ export function collectTestCaseDeletions(params) {
|
|
|
4763
5933
|
blockKey: entry.blockKey,
|
|
4764
5934
|
slug: entry.slug,
|
|
4765
5935
|
id: entry.id,
|
|
5936
|
+
supersededBy,
|
|
4766
5937
|
});
|
|
4767
5938
|
}
|
|
4768
5939
|
return candidates;
|
|
@@ -4815,15 +5986,19 @@ export function collectAttachmentDeletions(params) {
|
|
|
4815
5986
|
* has. Returns the lines to print; empty when nothing is pending.
|
|
4816
5987
|
*/
|
|
4817
5988
|
export function formatPendingTestDeletions(params) {
|
|
4818
|
-
|
|
5989
|
+
// #2896 — a row superseded by a RENAME is not a removal: the case is alive
|
|
5990
|
+
// under the new file name, and reporting it here would invite an operator to
|
|
5991
|
+
// prune away a test case they only renamed.
|
|
5992
|
+
const testCases = params.testCases.filter((candidate) => !candidate.supersededBy);
|
|
5993
|
+
if (testCases.length === 0 && params.attachments.length === 0)
|
|
4819
5994
|
return [];
|
|
4820
5995
|
const counts = [
|
|
4821
|
-
|
|
5996
|
+
testCases.length > 0 ? `${testCases.length} test case(s)` : null,
|
|
4822
5997
|
params.attachments.length > 0 ? `${params.attachments.length} attachment(s)` : null,
|
|
4823
5998
|
].filter(Boolean);
|
|
4824
5999
|
return [
|
|
4825
6000
|
`${counts.join(" and ")} removed locally — run \`config push --prune\` to delete them server-side:`,
|
|
4826
|
-
...
|
|
6001
|
+
...testCases.map((candidate) => ` pending delete test-case: ${candidate.blockKey}/${candidate.slug}`),
|
|
4827
6002
|
...params.attachments.map((candidate) => ` pending delete attachment: ${candidate.blockKey}/${candidate.slug}/${candidate.filename}`),
|
|
4828
6003
|
];
|
|
4829
6004
|
}
|
|
@@ -4865,6 +6040,15 @@ export async function applyTestCasePrune(params) {
|
|
|
4865
6040
|
}
|
|
4866
6041
|
for (const candidate of params.candidates) {
|
|
4867
6042
|
const key = `${candidate.blockKey}/${candidate.slug}`;
|
|
6043
|
+
// #2896 — the same case, still managed under the file it was renamed to.
|
|
6044
|
+
// Nothing is deleted server-side, so a dry run must not plan one either.
|
|
6045
|
+
if (candidate.supersededBy) {
|
|
6046
|
+
if (!params.dryRun) {
|
|
6047
|
+
delete params.testCaseEntities[candidate.stateKey];
|
|
6048
|
+
log(` Test case ${key} was renamed to ${params.testCaseEntities[candidate.supersededBy]?.slug ?? "another file"} — cleared from sync state`);
|
|
6049
|
+
}
|
|
6050
|
+
continue;
|
|
6051
|
+
}
|
|
4868
6052
|
if (params.dryRun) {
|
|
4869
6053
|
params.changes.push({ type: "test-case", action: "delete", key });
|
|
4870
6054
|
continue;
|
|
@@ -5045,6 +6229,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5045
6229
|
// The unfiltered listings, kept so the archived rows this pull declines
|
|
5046
6230
|
// to export can still be recorded as tombstones in sync state (#2803).
|
|
5047
6231
|
const integrationTombstoneCandidates = [];
|
|
6232
|
+
// #2887 — prompts and workflows carry a tombstone now, so they need
|
|
6233
|
+
// the same pass: an archived row is retired, not configuration, and
|
|
6234
|
+
// its managed key still has to reach sync state or `--prune` can never
|
|
6235
|
+
// reclaim it.
|
|
6236
|
+
const promptTombstoneCandidates = [];
|
|
6237
|
+
const workflowTombstoneCandidates = [];
|
|
5048
6238
|
// Fetch all data. Issue #1659: prune decides whether a local file's
|
|
5049
6239
|
// entity still exists server-side, so every fetch that establishes
|
|
5050
6240
|
// presence must report success or failure explicitly — an error that
|
|
@@ -5063,8 +6253,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5063
6253
|
integrationTombstoneCandidates.push(...items);
|
|
5064
6254
|
return skipArchivedForPull(items);
|
|
5065
6255
|
}),
|
|
5066
|
-
fetchAll((p) => client.listPrompts(resolvedAppId, p))
|
|
5067
|
-
|
|
6256
|
+
fetchAll((p) => client.listPrompts(resolvedAppId, p)).then((items) => {
|
|
6257
|
+
promptTombstoneCandidates.push(...items);
|
|
6258
|
+
return skipArchivedForPull(items);
|
|
6259
|
+
}),
|
|
6260
|
+
fetchAll((p) => client.listWorkflows(resolvedAppId, p)).then((items) => {
|
|
6261
|
+
workflowTombstoneCandidates.push(...items);
|
|
6262
|
+
return skipArchivedForPull(items);
|
|
6263
|
+
}),
|
|
5068
6264
|
attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
|
|
5069
6265
|
]);
|
|
5070
6266
|
const emailTemplatesResult = emailTemplatesOutcome.ok
|
|
@@ -5249,14 +6445,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5249
6445
|
warn(` ${message}`);
|
|
5250
6446
|
}
|
|
5251
6447
|
if (settings && pullsApp) {
|
|
5252
|
-
//
|
|
5253
|
-
//
|
|
5254
|
-
//
|
|
5255
|
-
|
|
5256
|
-
// push of this file fail.
|
|
5257
|
-
writePulledAppSettings(configDir, settings, {
|
|
5258
|
-
subject: " app.toml: googleClientSecret",
|
|
5259
|
-
});
|
|
6448
|
+
// Every writable setting round-trips as of #2891 — the server
|
|
6449
|
+
// withholds nothing — so the pulled file is complete and pushable as
|
|
6450
|
+
// written, with no accompanying note about what it could not carry.
|
|
6451
|
+
writePulledAppSettings(configDir, settings);
|
|
5260
6452
|
info(" Wrote app.toml");
|
|
5261
6453
|
}
|
|
5262
6454
|
// Write config vars (issue #1423). A single flat vars.toml at the
|
|
@@ -5318,6 +6510,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5318
6510
|
}
|
|
5319
6511
|
// Write integrations
|
|
5320
6512
|
const integrationEntities = {};
|
|
6513
|
+
// As for webhooks: no rule-set reference, and the pull-wide maps are
|
|
6514
|
+
// not filled until the rule-set leg, well after this one.
|
|
6515
|
+
const integrationPullMaps = {
|
|
6516
|
+
ruleSetIdToName: new Map(),
|
|
6517
|
+
ruleSetNameToId: new Map(),
|
|
6518
|
+
};
|
|
5321
6519
|
for (const integration of integrations) {
|
|
5322
6520
|
const filename = `${integration.integrationKey}.toml`;
|
|
5323
6521
|
const filePath = join(configDir, "integrations", filename);
|
|
@@ -5326,6 +6524,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5326
6524
|
id: integration.integrationId,
|
|
5327
6525
|
modifiedAt: integration.modifiedAt || new Date().toISOString(),
|
|
5328
6526
|
contentHash: computeFileHash(filePath),
|
|
6527
|
+
// #2880 — the two sides agree right now, so this is the baseline
|
|
6528
|
+
// the next push classifies direction against.
|
|
6529
|
+
semanticHash: configSemanticHash("integration", filePath, integrationPullMaps),
|
|
5329
6530
|
};
|
|
5330
6531
|
info(` Wrote integrations/${filename}`);
|
|
5331
6532
|
}
|
|
@@ -5341,6 +6542,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5341
6542
|
const webhooksDir = join(configDir, "webhooks");
|
|
5342
6543
|
mkdirSync(webhooksDir, { recursive: true });
|
|
5343
6544
|
const webhookEntities = {};
|
|
6545
|
+
// Webhooks carry no rule-set reference, so the shared projection's
|
|
6546
|
+
// resolution maps are inert for this type — the same empty pair the
|
|
6547
|
+
// diff path passes (the pull-wide maps are not filled until the
|
|
6548
|
+
// rule-set leg runs, well after this one).
|
|
6549
|
+
const webhookPullMaps = {
|
|
6550
|
+
ruleSetIdToName: new Map(),
|
|
6551
|
+
ruleSetNameToId: new Map(),
|
|
6552
|
+
};
|
|
5344
6553
|
for (const webhook of webhooks) {
|
|
5345
6554
|
const filename = `${webhook.webhookKey}.toml`;
|
|
5346
6555
|
const filePath = join(webhooksDir, filename);
|
|
@@ -5349,6 +6558,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5349
6558
|
id: webhook.webhookId,
|
|
5350
6559
|
modifiedAt: webhook.modifiedAt || new Date().toISOString(),
|
|
5351
6560
|
contentHash: computeFileHash(filePath),
|
|
6561
|
+
// #2880 — the file and the server agree right now, so this is the
|
|
6562
|
+
// baseline the next push classifies direction against.
|
|
6563
|
+
semanticHash: configSemanticHash("webhook", filePath, webhookPullMaps),
|
|
5352
6564
|
};
|
|
5353
6565
|
// A stored value that is not a reference is never written to TOML, so
|
|
5354
6566
|
// pull time is where an operator finds out: a legacy literal from
|
|
@@ -5389,6 +6601,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5389
6601
|
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
5390
6602
|
mkdirSync(cronTriggersDir, { recursive: true });
|
|
5391
6603
|
const cronTriggerEntities = {};
|
|
6604
|
+
// No rule-set reference on either type, and the pull-wide maps are not
|
|
6605
|
+
// filled until the rule-set leg — see the webhook block above.
|
|
6606
|
+
const keylessPullMaps = {
|
|
6607
|
+
ruleSetIdToName: new Map(),
|
|
6608
|
+
ruleSetNameToId: new Map(),
|
|
6609
|
+
};
|
|
5392
6610
|
for (const trigger of cronTriggerItems) {
|
|
5393
6611
|
const filename = `${trigger.triggerKey}.toml`;
|
|
5394
6612
|
const filePath = join(cronTriggersDir, filename);
|
|
@@ -5397,6 +6615,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5397
6615
|
id: trigger.triggerId,
|
|
5398
6616
|
modifiedAt: trigger.modifiedAt || new Date().toISOString(),
|
|
5399
6617
|
contentHash: computeFileHash(filePath),
|
|
6618
|
+
// #2880 — the baseline the next push classifies direction against.
|
|
6619
|
+
semanticHash: configSemanticHash("cron-trigger", filePath, keylessPullMaps),
|
|
5400
6620
|
};
|
|
5401
6621
|
}
|
|
5402
6622
|
info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
|
|
@@ -5414,6 +6634,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5414
6634
|
id: bucket.bucketId,
|
|
5415
6635
|
modifiedAt: bucket.modifiedAt || new Date().toISOString(),
|
|
5416
6636
|
contentHash: computeFileHash(filePath),
|
|
6637
|
+
// #2880 — as for cron triggers.
|
|
6638
|
+
semanticHash: configSemanticHash("blob-bucket", filePath, keylessPullMaps),
|
|
5417
6639
|
};
|
|
5418
6640
|
}
|
|
5419
6641
|
info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
|
|
@@ -5997,18 +7219,32 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5997
7219
|
idOf: (w) => w.webhookId,
|
|
5998
7220
|
}, Object.keys(priorEntities.webhooks ?? {})),
|
|
5999
7221
|
],
|
|
7222
|
+
// #2887 — the two types that gained a tombstone.
|
|
7223
|
+
[
|
|
7224
|
+
"prompts",
|
|
7225
|
+
archivedTombstoneEntries(promptTombstoneCandidates, {
|
|
7226
|
+
keyOf: (p) => p.promptKey,
|
|
7227
|
+
idOf: (p) => p.promptId,
|
|
7228
|
+
}, Object.keys(priorEntities.prompts ?? {})),
|
|
7229
|
+
],
|
|
7230
|
+
[
|
|
7231
|
+
"workflows",
|
|
7232
|
+
archivedTombstoneEntries(workflowTombstoneCandidates, {
|
|
7233
|
+
keyOf: (w) => w.workflowKey,
|
|
7234
|
+
idOf: (w) => w.workflowId,
|
|
7235
|
+
}, Object.keys(priorEntities.workflows ?? {})),
|
|
7236
|
+
],
|
|
6000
7237
|
]) {
|
|
6001
7238
|
const built = entitiesBySlot[slot];
|
|
6002
7239
|
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
6003
7240
|
if (!built || !resourceType)
|
|
6004
7241
|
continue;
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
built[key] = entry;
|
|
7242
|
+
const recorded = recordArchivedTombstones(built, tombstones, {
|
|
7243
|
+
label: resourceType.label,
|
|
7244
|
+
selection: pullSelection,
|
|
7245
|
+
matchedSelectors,
|
|
7246
|
+
});
|
|
7247
|
+
for (const key of recorded) {
|
|
6012
7248
|
info(` No ${resourceType.dir}/${resourceType.fileFromKey(key)} — ` +
|
|
6013
7249
|
"the entity is archived; `config push --prune` reclaims it");
|
|
6014
7250
|
}
|
|
@@ -6038,6 +7274,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6038
7274
|
id: prior.id,
|
|
6039
7275
|
modifiedAt: row.modifiedAt || "",
|
|
6040
7276
|
};
|
|
7277
|
+
// #2887 (F-003) — the same matched-selector rule the shared
|
|
7278
|
+
// recorder applies: a selector that resolved to a tombstone found
|
|
7279
|
+
// something, so the pull must not then report it as missing.
|
|
7280
|
+
if (pullSelection)
|
|
7281
|
+
matchedSelectors.add(`cron-trigger/${key}`);
|
|
6041
7282
|
info(` No cron-triggers/${key}.toml — the trigger is archived; ` +
|
|
6042
7283
|
"`config push --prune` reclaims it");
|
|
6043
7284
|
}
|
|
@@ -6072,6 +7313,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6072
7313
|
? {
|
|
6073
7314
|
modifiedAt: new Date().toISOString(),
|
|
6074
7315
|
contentHash: computeFileHash(join(configDir, "app.toml")),
|
|
7316
|
+
// #2880 — the file was just written FROM these settings, so
|
|
7317
|
+
// this is the state both sides agree on: the baseline the
|
|
7318
|
+
// next push classifies against. A pull also ends any
|
|
7319
|
+
// cross-app name exclusion, because the file it just wrote
|
|
7320
|
+
// states the target's own name.
|
|
7321
|
+
semanticHash: hashAppSettingsForDiff(settings),
|
|
6075
7322
|
}
|
|
6076
7323
|
: pullSelection
|
|
6077
7324
|
? priorEntities.app
|
|
@@ -6231,6 +7478,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6231
7478
|
};
|
|
6232
7479
|
}
|
|
6233
7480
|
info(`Pushing configuration for app ${resolvedAppId}...`);
|
|
7481
|
+
// The state this push STARTS from, so the failure path below can tell a
|
|
7482
|
+
// run that recorded something from one that recorded nothing (#2936).
|
|
7483
|
+
// Every applied write stamps what it did into `syncState` — an id for a
|
|
7484
|
+
// create, a baseline for an update, a removal for a delete — so a state
|
|
7485
|
+
// that still reads byte-for-byte like this one is a run with no progress
|
|
7486
|
+
// to preserve. `changes` cannot answer the question: it is the PLANNED
|
|
7487
|
+
// list, appended before the call that applies it, so a first write that
|
|
7488
|
+
// rejects leaves it non-empty having applied nothing.
|
|
7489
|
+
const syncStateAtStart = syncState ? JSON.stringify(syncState) : null;
|
|
6234
7490
|
try {
|
|
6235
7491
|
const changes = [];
|
|
6236
7492
|
let skippedCount = 0;
|
|
@@ -6428,11 +7684,37 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6428
7684
|
drifted.push({ type, key });
|
|
6429
7685
|
return;
|
|
6430
7686
|
}
|
|
7687
|
+
// #2880 criterion 7 — neither side's state is known, so nothing is
|
|
7688
|
+
// written. Named as a failure rather than a skip: the file was NOT
|
|
7689
|
+
// pushed, and a run that reported it as skipped-unchanged would be
|
|
7690
|
+
// claiming a comparison it never made.
|
|
7691
|
+
if (outcome.action === "live-unavailable") {
|
|
7692
|
+
applyFailures.push({
|
|
7693
|
+
type,
|
|
7694
|
+
key,
|
|
7695
|
+
message: "live state could not be read and the manifest holds no " +
|
|
7696
|
+
"last-sync content hash for this file, so push cannot tell " +
|
|
7697
|
+
"which side moved. Nothing was written. Run `config pull` to " +
|
|
7698
|
+
"take the server's state, or `config push --force` to " +
|
|
7699
|
+
"overwrite it.",
|
|
7700
|
+
});
|
|
7701
|
+
return;
|
|
7702
|
+
}
|
|
6431
7703
|
if (outcome.action === "immutable") {
|
|
7704
|
+
// #2934 — say WHICH two values disagree, not just which field.
|
|
7705
|
+
// "ttlTier cannot be changed" leaves the operator to go and read
|
|
7706
|
+
// both sides before they can align either; the pre-#2880 409-adopt
|
|
7707
|
+
// path named the pair in its refusal and this one has the same
|
|
7708
|
+
// diff in hand.
|
|
7709
|
+
const mismatches = (outcome.fields || [])
|
|
7710
|
+
.filter((f) => outcome.immutableFields.includes(f.field))
|
|
7711
|
+
.map((f) => `${f.field} mismatch: local ${summarizeFieldValue(f.local)} | ` +
|
|
7712
|
+
`server ${summarizeFieldValue(f.server)}`);
|
|
6432
7713
|
applyFailures.push({
|
|
6433
7714
|
type,
|
|
6434
7715
|
key,
|
|
6435
|
-
message: `${
|
|
7716
|
+
message: (mismatches.length > 0 ? `${mismatches.join("; ")}. ` : "") +
|
|
7717
|
+
`${outcome.immutableFields.join(", ")} cannot be changed by an update. ` +
|
|
6436
7718
|
`Recreate the ${type} (or rename it and push a new one) to change it; ` +
|
|
6437
7719
|
`an update would report success without changing anything.`,
|
|
6438
7720
|
});
|
|
@@ -6551,6 +7833,37 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6551
7833
|
const promptKeyToId = new Map();
|
|
6552
7834
|
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
6553
7835
|
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
7836
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
7837
|
+
const integrationConfigNameToId = new Map(); // "integrationKey#configName" → configId
|
|
7838
|
+
/**
|
|
7839
|
+
* The id→name direction the test-case comparison reads a reference
|
|
7840
|
+
* through (#2880). Filled from the name→id maps above before the
|
|
7841
|
+
* test-case legs run, and REFILLED as each block's configs are resolved:
|
|
7842
|
+
* the four maps are populated lazily, per block, as a case names one, so
|
|
7843
|
+
* a single fill before the legs start would leave every script and
|
|
7844
|
+
* integration reference — and every prompt this push had no reason to
|
|
7845
|
+
* visit — unresolved, which the comparison reads as server drift on a
|
|
7846
|
+
* file nobody touched.
|
|
7847
|
+
*/
|
|
7848
|
+
const pushTestCaseLookupMaps = {
|
|
7849
|
+
configIdToName: new Map(),
|
|
7850
|
+
promptIdToKey: new Map(),
|
|
7851
|
+
};
|
|
7852
|
+
const fillTestCaseLookupMaps = () => {
|
|
7853
|
+
for (const [key, id] of promptKeyToId) {
|
|
7854
|
+
pushTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
7855
|
+
}
|
|
7856
|
+
for (const [scoped, id] of [
|
|
7857
|
+
...promptConfigNameToId,
|
|
7858
|
+
...workflowConfigNameToId,
|
|
7859
|
+
...scriptConfigNameToId,
|
|
7860
|
+
...integrationConfigNameToId,
|
|
7861
|
+
]) {
|
|
7862
|
+
// The map is keyed `<blockKey>#<configName>`; the comparison names
|
|
7863
|
+
// the config, not the block.
|
|
7864
|
+
pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
|
|
7865
|
+
}
|
|
7866
|
+
};
|
|
6554
7867
|
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
6555
7868
|
// before the first mutating call. Push is apply-as-you-go and processes
|
|
6556
7869
|
// entities in a fixed sequence (cron triggers are created *before*
|
|
@@ -6698,7 +8011,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6698
8011
|
try {
|
|
6699
8012
|
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
6700
8013
|
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
6701
|
-
|
|
8014
|
+
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
8015
|
+
// the preflight validates the body that will actually be sent.
|
|
8016
|
+
excludeName: appSettingsNameExcluded({
|
|
8017
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
8018
|
+
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
8019
|
+
fileName: parsedApp?.app?.name,
|
|
8020
|
+
}),
|
|
6702
8021
|
});
|
|
6703
8022
|
for (const e of appErrors) {
|
|
6704
8023
|
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
@@ -6741,38 +8060,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6741
8060
|
// preflight pass, so nothing is applied and no request is issued —
|
|
6742
8061
|
// rather than being dropped on the way to the server and reading as a
|
|
6743
8062
|
// successful push that changed nothing.
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
const filePath = join(typeDir, file);
|
|
6750
|
-
let tomlData;
|
|
6751
|
-
try {
|
|
6752
|
-
tomlData = parseTomlFile(filePath);
|
|
6753
|
-
}
|
|
6754
|
-
catch {
|
|
6755
|
-
// A file that does not parse is reported by the per-type loop
|
|
6756
|
-
// with its own message; nothing to add here.
|
|
6757
|
-
continue;
|
|
6758
|
-
}
|
|
6759
|
-
// The document's own shape first: an unrecognized top-level table
|
|
6760
|
-
// makes every per-table check below read an absent table, so its
|
|
6761
|
-
// silence would be misleading.
|
|
6762
|
-
for (const shapeError of configDocumentShapeErrors(filePath, tomlData, surface)) {
|
|
6763
|
-
preflightValidationErrors.push(` ${shapeError}`);
|
|
6764
|
-
}
|
|
6765
|
-
for (const table of tables) {
|
|
6766
|
-
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
6767
|
-
if (unknownKeyError) {
|
|
6768
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
6769
|
-
}
|
|
6770
|
-
}
|
|
8063
|
+
// #2880 — the same collector `config diff` runs, so the two commands
|
|
8064
|
+
// cannot disagree about which files are pushable.
|
|
8065
|
+
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
8066
|
+
for (const message of rejected.messages) {
|
|
8067
|
+
preflightValidationErrors.push(` ${message}`);
|
|
6771
8068
|
}
|
|
6772
8069
|
}
|
|
6773
8070
|
// The `<key>.tests/*.toml` sidecars, which sit beside the block they
|
|
6774
8071
|
// test rather than in a type directory of their own. Same posture,
|
|
6775
|
-
// reached through the registry's `test-case` surface
|
|
8072
|
+
// reached through the registry's `test-case` surface — and through the
|
|
8073
|
+
// same collector `config diff` runs per sidecar (#2880), so neither
|
|
8074
|
+
// command can refuse a file the other calls Synced.
|
|
6776
8075
|
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
6777
8076
|
let tomlData;
|
|
6778
8077
|
try {
|
|
@@ -6782,18 +8081,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6782
8081
|
// Reported by the per-block test-case loop with its own message.
|
|
6783
8082
|
continue;
|
|
6784
8083
|
}
|
|
6785
|
-
for (const
|
|
6786
|
-
preflightValidationErrors.push(` ${
|
|
6787
|
-
}
|
|
6788
|
-
const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
|
|
6789
|
-
if (unknownKeyError) {
|
|
6790
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
6791
|
-
}
|
|
6792
|
-
// #2769 — with TOML the only authoring path, a sidecar the server
|
|
6793
|
-
// would reject (no `name`) or whose JSON text does not parse fails
|
|
6794
|
-
// here, before any test case is created or updated.
|
|
6795
|
-
for (const shapeError of testCaseSidecarErrors(filePath, tomlData)) {
|
|
6796
|
-
preflightValidationErrors.push(` ${shapeError}`);
|
|
8084
|
+
for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
|
|
8085
|
+
preflightValidationErrors.push(` ${message}`);
|
|
6797
8086
|
}
|
|
6798
8087
|
}
|
|
6799
8088
|
if (preflightValidationErrors.length > 0) {
|
|
@@ -6938,36 +8227,191 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6938
8227
|
// else in the slot (issue #2645): a full push applies it, and
|
|
6939
8228
|
// `--only app` applies it alone. The separate `settings push` command
|
|
6940
8229
|
// that used to own this is gone.
|
|
8230
|
+
//
|
|
8231
|
+
// #2880 behaviors 10, 11 and 14 — this block used to answer "changed?"
|
|
8232
|
+
// from the file's BYTES and then PATCH only the keys the file spelled.
|
|
8233
|
+
// Both are gone. The comparison is the one `config diff` reports, over
|
|
8234
|
+
// the full writable descriptor set, and the payload is the whole file:
|
|
8235
|
+
// a key the file no longer carries is the clear (or the declared
|
|
8236
|
+
// default) push applies, exactly as it is for every other type (#1567).
|
|
6941
8237
|
const appTomlPath = join(configDir, "app.toml");
|
|
6942
8238
|
if (existsSync(appTomlPath) && (!pushSelection || pushSelection.app)) {
|
|
6943
|
-
const
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
8239
|
+
const tomlData = parseTomlFile(appTomlPath);
|
|
8240
|
+
const appEntry = syncState?.entities?.app;
|
|
8241
|
+
const appFileHash = computeFileHash(appTomlPath);
|
|
8242
|
+
const appFileName = tomlData?.app?.name;
|
|
8243
|
+
const recordedExcludedName = appEntry?.excludedName;
|
|
8244
|
+
// DSO-001, the cross-app rule in three parts.
|
|
8245
|
+
//
|
|
8246
|
+
// `payloadExcludesName` — is `[app].name` out of the body? A
|
|
8247
|
+
// cross-app push never renames its target, and neither does a later
|
|
8248
|
+
// push of a file still stating the name that push left behind.
|
|
8249
|
+
//
|
|
8250
|
+
// `compareExcludesName` — are the two hashes computed without it?
|
|
8251
|
+
// While an exclusion RECORD exists they must be, because that is how
|
|
8252
|
+
// the baseline was stamped, and `classifyPushChange` can only answer
|
|
8253
|
+
// `local-edited` when the remote hash equals the baseline.
|
|
8254
|
+
//
|
|
8255
|
+
// `appNameEdited` — the file has moved away from the recorded name.
|
|
8256
|
+
// That is a local rename BY CONSTRUCTION, not by comparison: no
|
|
8257
|
+
// baseline hashed without `name` can ever attribute it.
|
|
8258
|
+
const payloadExcludesName = appSettingsNameExcluded({
|
|
8259
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
8260
|
+
recordedExcludedName,
|
|
8261
|
+
fileName: appFileName,
|
|
8262
|
+
});
|
|
8263
|
+
const compareExcludesName = !!isCrossAppPush || recordedExcludedName !== undefined;
|
|
8264
|
+
const appNameEdited = !isCrossAppPush &&
|
|
8265
|
+
recordedExcludedName !== undefined &&
|
|
8266
|
+
!payloadExcludesName;
|
|
8267
|
+
// The whole-truth body. Errors (an unrecognized or retired key, a
|
|
8268
|
+
// type mismatch, a missing required key) already aborted the push in
|
|
8269
|
+
// the preflight pass above, so only warnings remain here.
|
|
8270
|
+
const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
|
|
8271
|
+
excludeName: payloadExcludesName,
|
|
8272
|
+
});
|
|
8273
|
+
for (const w of appWarnings) {
|
|
8274
|
+
warn(` ${w}`);
|
|
8275
|
+
}
|
|
8276
|
+
let appLiveOk = true;
|
|
8277
|
+
let liveSettings;
|
|
8278
|
+
try {
|
|
8279
|
+
liveSettings = await client.getAppSettings(resolvedAppId);
|
|
8280
|
+
}
|
|
8281
|
+
catch (err) {
|
|
8282
|
+
appLiveOk = false;
|
|
8283
|
+
warnLiveUnavailable("app-settings", err);
|
|
8284
|
+
}
|
|
8285
|
+
/** Record the state both sides are now known to be in. */
|
|
8286
|
+
const stampAppEntry = (baselineSettings, options) => {
|
|
8287
|
+
if (!syncState)
|
|
8288
|
+
return;
|
|
8289
|
+
syncState.entities.app = {
|
|
8290
|
+
modifiedAt: baselineSettings?.modifiedAt ||
|
|
8291
|
+
appEntry?.modifiedAt ||
|
|
8292
|
+
new Date().toISOString(),
|
|
8293
|
+
contentHash: appFileHash,
|
|
8294
|
+
semanticHash: hashAppSettingsForDiff(baselineSettings, {
|
|
8295
|
+
excludeName: options.recordExclusion !== undefined ||
|
|
8296
|
+
(options.keepExclusion && compareExcludesName),
|
|
8297
|
+
}),
|
|
8298
|
+
...(options.recordExclusion !== undefined
|
|
8299
|
+
? { excludedName: options.recordExclusion }
|
|
8300
|
+
: options.keepExclusion && recordedExcludedName !== undefined
|
|
8301
|
+
? { excludedName: recordedExcludedName }
|
|
8302
|
+
: {}),
|
|
8303
|
+
};
|
|
8304
|
+
};
|
|
8305
|
+
let applyAppSettings = true;
|
|
8306
|
+
if (appLiveOk) {
|
|
8307
|
+
const compareOptions = { excludeName: compareExcludesName };
|
|
8308
|
+
const localHash = hashAppSettingsForDiff(appPayload, compareOptions);
|
|
8309
|
+
const remoteHash = hashAppSettingsForDiff(liveSettings ?? {}, compareOptions);
|
|
8310
|
+
if (options.force) {
|
|
8311
|
+
// `--force` is the operator saying "local wins": no direction
|
|
8312
|
+
// check, exactly as it is for every other type.
|
|
8313
|
+
}
|
|
8314
|
+
else if (isCrossAppPush) {
|
|
8315
|
+
// The target's settings are not this slot's history: there is no
|
|
8316
|
+
// baseline and none is inferable, and applying the source's
|
|
8317
|
+
// configuration is the whole point of the push. `name` is already
|
|
8318
|
+
// out of the body, so the one thing that could not be recovered
|
|
8319
|
+
// by a later `config pull` is not at risk.
|
|
8320
|
+
}
|
|
8321
|
+
else {
|
|
8322
|
+
const direction = classifyPushChange({
|
|
8323
|
+
localHash,
|
|
8324
|
+
remoteHash,
|
|
8325
|
+
baselineHash: appEntry?.semanticHash,
|
|
8326
|
+
legacy: {
|
|
8327
|
+
localBytesMatchStoredContentHash: appEntry?.contentHash !== undefined &&
|
|
8328
|
+
appEntry.contentHash === appFileHash,
|
|
8329
|
+
// Deliberately never true: `entities.app.modifiedAt` is
|
|
8330
|
+
// stamped from the LOCAL clock, so it proves nothing about
|
|
8331
|
+
// the server. A pre-upgrade manifest whose bytes have also
|
|
8332
|
+
// moved is therefore `unknown-direction` — a conflict, not a
|
|
8333
|
+
// blind overwrite of state nobody compared.
|
|
8334
|
+
liveModifiedAtMatchesStored: false,
|
|
8335
|
+
},
|
|
8336
|
+
});
|
|
8337
|
+
const fields = appSettingsFieldDiffs(appPayload, liveSettings ?? {}, compareOptions).map((d) => ({
|
|
8338
|
+
field: `[${d.section}].${d.key}`,
|
|
8339
|
+
local: d.local,
|
|
8340
|
+
server: d.remote,
|
|
8341
|
+
}));
|
|
8342
|
+
if (direction === "unchanged") {
|
|
8343
|
+
// A rename still has to go out: the comparison above cannot
|
|
8344
|
+
// see `name` while the exclusion record stands.
|
|
8345
|
+
if (!appNameEdited) {
|
|
8346
|
+
skippedCount++;
|
|
8347
|
+
info(" Skipped app settings (unchanged)");
|
|
8348
|
+
stampAppEntry(liveSettings, { keepExclusion: true });
|
|
8349
|
+
applyAppSettings = false;
|
|
8350
|
+
}
|
|
8351
|
+
}
|
|
8352
|
+
else if (direction === "server-drifted") {
|
|
8353
|
+
recordDeclined("app-settings", "app.toml", { action: "drift", localHash, remoteHash, fields }, appEntry?.modifiedAt);
|
|
8354
|
+
applyAppSettings = false;
|
|
8355
|
+
}
|
|
8356
|
+
else if (direction === "both-changed" ||
|
|
8357
|
+
direction === "unknown-direction") {
|
|
8358
|
+
recordDeclined("app-settings", "app.toml", {
|
|
8359
|
+
action: "conflict",
|
|
8360
|
+
direction,
|
|
8361
|
+
localHash,
|
|
8362
|
+
remoteHash,
|
|
8363
|
+
fields,
|
|
8364
|
+
serverModifiedAt: liveSettings?.modifiedAt,
|
|
8365
|
+
}, appEntry?.modifiedAt);
|
|
8366
|
+
applyAppSettings = false;
|
|
8367
|
+
}
|
|
8368
|
+
}
|
|
6948
8369
|
}
|
|
6949
8370
|
else {
|
|
6950
|
-
const
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
8371
|
+
const degraded = decideDegradedPush({
|
|
8372
|
+
force: options.force,
|
|
8373
|
+
storedContentHash: appEntry?.contentHash,
|
|
8374
|
+
currentFileHash: appFileHash,
|
|
8375
|
+
});
|
|
8376
|
+
if (degraded.action === "skip") {
|
|
8377
|
+
skippedCount++;
|
|
8378
|
+
info(" Skipped app settings (unchanged)");
|
|
8379
|
+
applyAppSettings = false;
|
|
8380
|
+
}
|
|
8381
|
+
else if (degraded.action === "decline") {
|
|
8382
|
+
recordDeclined("app-settings", "app.toml", { action: "live-unavailable" }, appEntry?.modifiedAt);
|
|
8383
|
+
applyAppSettings = false;
|
|
6960
8384
|
}
|
|
8385
|
+
}
|
|
8386
|
+
if (applyAppSettings) {
|
|
6961
8387
|
changes.push({ type: "app", action: "update", key: "settings" });
|
|
6962
8388
|
if (!options.dryRun) {
|
|
6963
|
-
await client.updateAppSettings(resolvedAppId,
|
|
8389
|
+
const updated = await client.updateAppSettings(resolvedAppId, appPayload);
|
|
6964
8390
|
info(" Updated app settings");
|
|
6965
|
-
//
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
8391
|
+
// Stamped from what the server RETURNED, not from the body sent:
|
|
8392
|
+
// `PUT /settings` normalizes (a trailing slash off `baseUrl`, a
|
|
8393
|
+
// lower-cased test-account email), so the payload is not what the
|
|
8394
|
+
// next comparison will read. The PUT echoes the full settings; if
|
|
8395
|
+
// a server ever answers with something else, re-read rather than
|
|
8396
|
+
// record a state nobody observed.
|
|
8397
|
+
const baselineSettings = updated && typeof updated === "object" && "mode" in updated
|
|
8398
|
+
? updated
|
|
8399
|
+
: await client
|
|
8400
|
+
.getAppSettings(resolvedAppId)
|
|
8401
|
+
.catch(() => undefined);
|
|
8402
|
+
if (baselineSettings) {
|
|
8403
|
+
stampAppEntry(baselineSettings, {
|
|
8404
|
+
keepExclusion: false,
|
|
8405
|
+
// A cross-app apply is what CREATES the exclusion: the
|
|
8406
|
+
// target keeps its own name, and the file's name is recorded
|
|
8407
|
+
// so a later edit to that line reads as the rename it is.
|
|
8408
|
+
// Recorded AS STATED (#2936) — the stripped line was never
|
|
8409
|
+
// type-checked, so text is not the shape it has to have, and
|
|
8410
|
+
// a re-run of the same file has to read as the same name.
|
|
8411
|
+
...(isCrossAppPush && appFileName !== undefined
|
|
8412
|
+
? { recordExclusion: appFileName }
|
|
8413
|
+
: {}),
|
|
8414
|
+
});
|
|
6971
8415
|
}
|
|
6972
8416
|
}
|
|
6973
8417
|
}
|
|
@@ -7190,13 +8634,26 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7190
8634
|
continue;
|
|
7191
8635
|
}
|
|
7192
8636
|
}
|
|
7193
|
-
else
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
8637
|
+
else {
|
|
8638
|
+
// Fallback gate: the manifest byte hash, and — since #2880 — a
|
|
8639
|
+
// decline rather than a blind write when there is no hash.
|
|
8640
|
+
const degraded = decideDegradedPush({
|
|
8641
|
+
force: options.force,
|
|
8642
|
+
storedContentHash: entry?.contentHash,
|
|
8643
|
+
currentFileHash: computeFileHash(filePath),
|
|
8644
|
+
});
|
|
8645
|
+
if (degraded.action === "skip") {
|
|
8646
|
+
skippedCount++;
|
|
8647
|
+
if (existingId)
|
|
8648
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
8649
|
+
continue;
|
|
8650
|
+
}
|
|
8651
|
+
if (degraded.action === "decline") {
|
|
8652
|
+
recordDeclined("rule-set", fileKey, { action: "live-unavailable" }, entry?.modifiedAt);
|
|
8653
|
+
if (existingId)
|
|
8654
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
8655
|
+
continue;
|
|
8656
|
+
}
|
|
7200
8657
|
}
|
|
7201
8658
|
if (existingId) {
|
|
7202
8659
|
// Update existing rule set
|
|
@@ -7337,17 +8794,118 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7337
8794
|
const integrationsDir = join(configDir, "integrations");
|
|
7338
8795
|
if (existsSync(integrationsDir)) {
|
|
7339
8796
|
const files = selectFiles("integration", integrationsDir, readdirSync(integrationsDir).filter((f) => f.endsWith(".toml")));
|
|
8797
|
+
// #2880 — integrations join the live-state reconcile (#2731 B3), for
|
|
8798
|
+
// the reason webhooks did: diff compared presence while push gated on
|
|
8799
|
+
// the file's bytes, so a field edit was invisible to one command and
|
|
8800
|
+
// applied by the other, and a comment was a change to the other and
|
|
8801
|
+
// not the one.
|
|
8802
|
+
const integrationSpec = configDiffSpec("integration");
|
|
8803
|
+
const liveIntegrations = new Map();
|
|
8804
|
+
let integrationsLiveOk = true;
|
|
8805
|
+
if (files.length > 0) {
|
|
8806
|
+
try {
|
|
8807
|
+
const listed = await fetchAll((p) => client.listIntegrations(resolvedAppId, p));
|
|
8808
|
+
for (const i of listed) {
|
|
8809
|
+
if (i?.integrationKey)
|
|
8810
|
+
liveIntegrations.set(i.integrationKey, i);
|
|
8811
|
+
}
|
|
8812
|
+
}
|
|
8813
|
+
catch (err) {
|
|
8814
|
+
integrationsLiveOk = false;
|
|
8815
|
+
warnLiveUnavailable("integrations", err);
|
|
8816
|
+
}
|
|
8817
|
+
}
|
|
8818
|
+
/** The detail — `accessRule` and `requestConfig` are not on the list. */
|
|
8819
|
+
const refetchLiveIntegration = async (integrationId) => {
|
|
8820
|
+
const full = await client.getIntegration(resolvedAppId, integrationId);
|
|
8821
|
+
return full ? { record: full } : null;
|
|
8822
|
+
};
|
|
7340
8823
|
for (const file of files) {
|
|
7341
8824
|
const filePath = join(integrationsDir, file);
|
|
7342
8825
|
const tomlData = parseTomlFile(filePath);
|
|
7343
|
-
const
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
8826
|
+
const key = tomlData.integration?.key || basename(file, ".toml");
|
|
8827
|
+
// The gate re-parses the DOCUMENT, so the key the file left to its
|
|
8828
|
+
// name has to be in it (see `withDerivedIdentity`).
|
|
8829
|
+
withDerivedIdentity(tomlData, "integration", { key });
|
|
8830
|
+
const existingEntry = syncState?.entities?.integrations?.[key];
|
|
8831
|
+
const liveListed = liveIntegrations.get(key);
|
|
8832
|
+
let integrationLiveOk = integrationsLiveOk;
|
|
8833
|
+
let liveIntegration = undefined;
|
|
8834
|
+
if (integrationLiveOk && liveListed?.integrationId) {
|
|
8835
|
+
try {
|
|
8836
|
+
liveIntegration = await client.getIntegration(resolvedAppId, liveListed.integrationId);
|
|
8837
|
+
}
|
|
8838
|
+
catch (err) {
|
|
8839
|
+
integrationLiveOk = false;
|
|
8840
|
+
warnLiveUnavailable(`integration ${key}`, err);
|
|
8841
|
+
}
|
|
7350
8842
|
}
|
|
8843
|
+
let gate;
|
|
8844
|
+
if (integrationLiveOk) {
|
|
8845
|
+
gate = decidePushForConfig({
|
|
8846
|
+
spec: integrationSpec,
|
|
8847
|
+
maps: pushDiffMaps,
|
|
8848
|
+
localParsed: tomlData,
|
|
8849
|
+
live: liveIntegration
|
|
8850
|
+
? {
|
|
8851
|
+
record: liveIntegration,
|
|
8852
|
+
modifiedAt: liveIntegration.modifiedAt,
|
|
8853
|
+
}
|
|
8854
|
+
: null,
|
|
8855
|
+
entry: existingEntry,
|
|
8856
|
+
localFileHash: computeFileHash(filePath),
|
|
8857
|
+
force: options.force,
|
|
8858
|
+
// #2934 — the create leg below adopts an existing integration
|
|
8859
|
+
// by `integrationKey` on the server's conflict (#1006), so an
|
|
8860
|
+
// integration the manifest has never recorded is that same
|
|
8861
|
+
// adopt, reached one step earlier because the reconcile above
|
|
8862
|
+
// already read the server.
|
|
8863
|
+
adoptsUntrackedByKey: true,
|
|
8864
|
+
});
|
|
8865
|
+
if (gate.action === "skip") {
|
|
8866
|
+
skippedCount++;
|
|
8867
|
+
if (syncState && liveIntegration?.integrationId) {
|
|
8868
|
+
if (!syncState.entities.integrations) {
|
|
8869
|
+
syncState.entities.integrations = {};
|
|
8870
|
+
}
|
|
8871
|
+
syncState.entities.integrations[key] = {
|
|
8872
|
+
id: liveIntegration.integrationId,
|
|
8873
|
+
modifiedAt: liveIntegration.modifiedAt ||
|
|
8874
|
+
existingEntry?.modifiedAt ||
|
|
8875
|
+
new Date().toISOString(),
|
|
8876
|
+
contentHash: computeFileHash(filePath),
|
|
8877
|
+
semanticHash: gate.remoteHash,
|
|
8878
|
+
};
|
|
8879
|
+
}
|
|
8880
|
+
continue;
|
|
8881
|
+
}
|
|
8882
|
+
if (gate.action === "drift" ||
|
|
8883
|
+
gate.action === "conflict" ||
|
|
8884
|
+
gate.action === "immutable") {
|
|
8885
|
+
recordDeclined("integration", key, gate, existingEntry?.modifiedAt);
|
|
8886
|
+
continue;
|
|
8887
|
+
}
|
|
8888
|
+
}
|
|
8889
|
+
else {
|
|
8890
|
+
const degraded = decideDegradedPush({
|
|
8891
|
+
force: options.force,
|
|
8892
|
+
storedContentHash: existingEntry?.contentHash,
|
|
8893
|
+
currentFileHash: computeFileHash(filePath),
|
|
8894
|
+
});
|
|
8895
|
+
if (degraded.action === "skip") {
|
|
8896
|
+
skippedCount++;
|
|
8897
|
+
continue;
|
|
8898
|
+
}
|
|
8899
|
+
if (degraded.action === "decline") {
|
|
8900
|
+
recordDeclined("integration", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
8901
|
+
continue;
|
|
8902
|
+
}
|
|
8903
|
+
}
|
|
8904
|
+
const existingId = resolveExistingId({
|
|
8905
|
+
liveOk: integrationLiveOk,
|
|
8906
|
+
liveId: liveListed?.integrationId,
|
|
8907
|
+
manifestId: existingEntry?.id,
|
|
8908
|
+
});
|
|
7351
8909
|
// Same body on the create, update, and adopt-then-update paths.
|
|
7352
8910
|
const payload = buildIntegrationPushPayload(key, tomlData);
|
|
7353
8911
|
// #2631 — `accessRule` is TOML-owned, so a file with no rule pushes
|
|
@@ -7361,26 +8919,41 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7361
8919
|
if (existingId) {
|
|
7362
8920
|
changes.push({ type: "integration", action: "update", key });
|
|
7363
8921
|
if (!options.dryRun) {
|
|
8922
|
+
// #2731 B4 — the guard is the LIVE timestamp the reconcile just
|
|
8923
|
+
// read (the gate carries it), not the manifest's.
|
|
7364
8924
|
const expectedModifiedAt = options.force
|
|
7365
8925
|
? undefined
|
|
7366
|
-
:
|
|
8926
|
+
: gate?.action === "update"
|
|
8927
|
+
? gate.expectedModifiedAt
|
|
8928
|
+
: existingEntry?.modifiedAt;
|
|
7367
8929
|
try {
|
|
8930
|
+
// Named as the ADOPT it is (#2934) — see the cron-trigger
|
|
8931
|
+
// leg.
|
|
8932
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
8933
|
+
info(` Integration already exists on server, adopting by key: ${key}`);
|
|
8934
|
+
}
|
|
7368
8935
|
const updated = await client.updateIntegration(resolvedAppId, existingId, payload, expectedModifiedAt);
|
|
7369
8936
|
info(` Updated integration: ${key}`);
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
8937
|
+
if (syncState) {
|
|
8938
|
+
if (!syncState.entities.integrations) {
|
|
8939
|
+
syncState.entities.integrations = {};
|
|
8940
|
+
}
|
|
8941
|
+
syncState.entities.integrations[key] = {
|
|
8942
|
+
id: existingId,
|
|
8943
|
+
modifiedAt: updated?.modifiedAt ||
|
|
8944
|
+
existingEntry?.modifiedAt ||
|
|
8945
|
+
new Date().toISOString(),
|
|
8946
|
+
contentHash: computeFileHash(filePath),
|
|
8947
|
+
// From the entity the SERVER returned: the handler
|
|
8948
|
+
// canonicalizes `requestConfig`, so the payload sent is
|
|
8949
|
+
// not what the next comparison will see.
|
|
8950
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existingId)),
|
|
8951
|
+
};
|
|
7374
8952
|
}
|
|
7375
8953
|
}
|
|
7376
8954
|
catch (err) {
|
|
7377
8955
|
if (err instanceof ConflictError) {
|
|
7378
|
-
conflicts.push(
|
|
7379
|
-
type: "integration",
|
|
7380
|
-
key,
|
|
7381
|
-
serverModifiedAt: err.serverModifiedAt,
|
|
7382
|
-
localModifiedAt: expectedModifiedAt || "unknown",
|
|
7383
|
-
});
|
|
8956
|
+
conflicts.push(await lateConflict("integration", key, err, expectedModifiedAt, () => refetchLiveIntegration(existingId), integrationSpec, tomlData));
|
|
7384
8957
|
}
|
|
7385
8958
|
else {
|
|
7386
8959
|
throw wrapEntityError(err, "update", "integration", key);
|
|
@@ -7403,6 +8976,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7403
8976
|
id: created.integrationId,
|
|
7404
8977
|
modifiedAt: created.modifiedAt,
|
|
7405
8978
|
contentHash: computeFileHash(filePath),
|
|
8979
|
+
// #2880 — a create records its baseline too, read from
|
|
8980
|
+
// the stored entity: the handler materializes the
|
|
8981
|
+
// defaults and canonicalizes `requestConfig`, so a
|
|
8982
|
+
// baseline taken from the file would read those as a
|
|
8983
|
+
// difference on the very next run.
|
|
8984
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, created, () => refetchLiveIntegration(created.integrationId)),
|
|
7406
8985
|
};
|
|
7407
8986
|
}
|
|
7408
8987
|
}
|
|
@@ -7434,6 +9013,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7434
9013
|
existing.modifiedAt ||
|
|
7435
9014
|
new Date().toISOString(),
|
|
7436
9015
|
contentHash: computeFileHash(filePath),
|
|
9016
|
+
// #2880 — an adoption converges here, so it stamps the
|
|
9017
|
+
// baseline the next run classifies against.
|
|
9018
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existing.integrationId)),
|
|
7437
9019
|
};
|
|
7438
9020
|
}
|
|
7439
9021
|
}
|
|
@@ -7445,42 +9027,187 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7445
9027
|
const webhooksDir = join(configDir, "webhooks");
|
|
7446
9028
|
if (existsSync(webhooksDir)) {
|
|
7447
9029
|
const files = selectFiles("webhook", webhooksDir, readdirSync(webhooksDir).filter((f) => f.endsWith(".toml")));
|
|
9030
|
+
// #2880 — webhooks join the live-state reconcile (#2731 B3). #2757
|
|
9031
|
+
// gave the type diff's comparator and left push on the manifest byte
|
|
9032
|
+
// hash, so the two commands answered "changed?" from different
|
|
9033
|
+
// sources and disagreed in both directions: a comment moved the bytes
|
|
9034
|
+
// and push planned an update diff read as Synced, and a file whose
|
|
9035
|
+
// bytes had not moved was skipped however far its CONTENT had drifted
|
|
9036
|
+
// from the server, so a reported `Modified` could never be pushed
|
|
9037
|
+
// away. Both are the same fix — push decides from the projection diff
|
|
9038
|
+
// compares, against the entity diff compares it to.
|
|
9039
|
+
//
|
|
9040
|
+
// It also retires `modifiedAt` as a CONTENT signal wherever a
|
|
9041
|
+
// baseline exists, which is what stops a delivery (whose
|
|
9042
|
+
// `lastTriggeredAt` bumps `modifiedAt`) from reading as a permanent
|
|
9043
|
+
// conflict on an unedited file (#2894).
|
|
9044
|
+
const webhookSpec = configDiffSpec("webhook");
|
|
9045
|
+
// The list says WHICH webhooks the server has and under which id; the
|
|
9046
|
+
// detail is what `config pull` serializes from, so the comparison
|
|
9047
|
+
// fetches it per selected file. A failed list is NOT an empty list —
|
|
9048
|
+
// treating it as one would send every webhook down the create path —
|
|
9049
|
+
// so it degrades the section to the byte-hash gate, type named.
|
|
9050
|
+
const liveWebhooks = new Map();
|
|
9051
|
+
let webhooksLiveOk = true;
|
|
9052
|
+
if (files.length > 0) {
|
|
9053
|
+
try {
|
|
9054
|
+
const listed = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
|
|
9055
|
+
for (const w of listed) {
|
|
9056
|
+
if (w?.webhookKey)
|
|
9057
|
+
liveWebhooks.set(w.webhookKey, w);
|
|
9058
|
+
}
|
|
9059
|
+
}
|
|
9060
|
+
catch (err) {
|
|
9061
|
+
webhooksLiveOk = false;
|
|
9062
|
+
warnLiveUnavailable("webhooks", err);
|
|
9063
|
+
}
|
|
9064
|
+
}
|
|
9065
|
+
/** One webhook as the server holds it NOW — the post-apply baseline. */
|
|
9066
|
+
const refetchLiveWebhook = async (webhookId) => {
|
|
9067
|
+
const full = await client.getWebhook(resolvedAppId, webhookId);
|
|
9068
|
+
return full ? { record: full } : null;
|
|
9069
|
+
};
|
|
7448
9070
|
for (const file of files) {
|
|
7449
9071
|
const filePath = join(webhooksDir, file);
|
|
7450
9072
|
const tomlData = parseTomlFile(filePath);
|
|
7451
|
-
const
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
9073
|
+
const key = tomlData.webhook?.key || basename(file, ".toml");
|
|
9074
|
+
// The gate re-parses the DOCUMENT, so the key the file left to its
|
|
9075
|
+
// name has to be in it (see `withDerivedIdentity`): `displayName`
|
|
9076
|
+
// falls back to the key, so a file stating neither would otherwise
|
|
9077
|
+
// project an empty name against the derived one the server holds.
|
|
9078
|
+
withDerivedIdentity(tomlData, "webhook", { key });
|
|
9079
|
+
const existingEntry = syncState?.entities?.webhooks?.[key];
|
|
9080
|
+
const liveListed = liveWebhooks.get(key);
|
|
9081
|
+
// A detail read that fails degrades THIS webhook to the byte-hash
|
|
9082
|
+
// gate, never the section — its neighbours still reconcile.
|
|
9083
|
+
let webhookLiveOk = webhooksLiveOk;
|
|
9084
|
+
let liveWebhook = undefined;
|
|
9085
|
+
if (webhookLiveOk && liveListed?.webhookId) {
|
|
9086
|
+
try {
|
|
9087
|
+
liveWebhook = await client.getWebhook(resolvedAppId, liveListed.webhookId);
|
|
9088
|
+
}
|
|
9089
|
+
catch (err) {
|
|
9090
|
+
webhookLiveOk = false;
|
|
9091
|
+
warnLiveUnavailable(`webhook ${key}`, err);
|
|
9092
|
+
}
|
|
7458
9093
|
}
|
|
9094
|
+
let gate;
|
|
9095
|
+
if (webhookLiveOk) {
|
|
9096
|
+
gate = decidePushForConfig({
|
|
9097
|
+
spec: webhookSpec,
|
|
9098
|
+
// Webhooks carry no rule-set reference, so these maps are inert
|
|
9099
|
+
// for this type — shared with `stampRemoteSemanticHash` so the
|
|
9100
|
+
// gate and the baseline it compares against cannot diverge.
|
|
9101
|
+
maps: pushDiffMaps,
|
|
9102
|
+
localParsed: tomlData,
|
|
9103
|
+
live: liveWebhook
|
|
9104
|
+
? { record: liveWebhook, modifiedAt: liveWebhook.modifiedAt }
|
|
9105
|
+
: null,
|
|
9106
|
+
entry: existingEntry,
|
|
9107
|
+
localFileHash: computeFileHash(filePath),
|
|
9108
|
+
force: options.force,
|
|
9109
|
+
// #2934 — the create leg below adopts an existing webhook by
|
|
9110
|
+
// `webhookKey` on the server's conflict (#1006), so a webhook
|
|
9111
|
+
// the manifest has never recorded is that same adopt, reached
|
|
9112
|
+
// one step earlier because the reconcile above already read the
|
|
9113
|
+
// server.
|
|
9114
|
+
adoptsUntrackedByKey: true,
|
|
9115
|
+
});
|
|
9116
|
+
if (gate.action === "skip") {
|
|
9117
|
+
skippedCount++;
|
|
9118
|
+
if (syncState && liveWebhook?.webhookId) {
|
|
9119
|
+
if (!syncState.entities.webhooks)
|
|
9120
|
+
syncState.entities.webhooks = {};
|
|
9121
|
+
syncState.entities.webhooks[key] = {
|
|
9122
|
+
id: liveWebhook.webhookId,
|
|
9123
|
+
modifiedAt: liveWebhook.modifiedAt ||
|
|
9124
|
+
existingEntry?.modifiedAt ||
|
|
9125
|
+
new Date().toISOString(),
|
|
9126
|
+
contentHash: computeFileHash(filePath),
|
|
9127
|
+
// The two sides agree HERE, so this is the baseline the
|
|
9128
|
+
// next run classifies against — which is what stops a
|
|
9129
|
+
// comment-only edit from re-reporting forever.
|
|
9130
|
+
semanticHash: gate.remoteHash,
|
|
9131
|
+
};
|
|
9132
|
+
}
|
|
9133
|
+
continue;
|
|
9134
|
+
}
|
|
9135
|
+
if (gate.action === "drift" ||
|
|
9136
|
+
gate.action === "conflict" ||
|
|
9137
|
+
gate.action === "immutable") {
|
|
9138
|
+
recordDeclined("webhook", key, gate, existingEntry?.modifiedAt);
|
|
9139
|
+
continue;
|
|
9140
|
+
}
|
|
9141
|
+
}
|
|
9142
|
+
else {
|
|
9143
|
+
const degraded = decideDegradedPush({
|
|
9144
|
+
force: options.force,
|
|
9145
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9146
|
+
currentFileHash: computeFileHash(filePath),
|
|
9147
|
+
});
|
|
9148
|
+
if (degraded.action === "skip") {
|
|
9149
|
+
skippedCount++;
|
|
9150
|
+
continue;
|
|
9151
|
+
}
|
|
9152
|
+
if (degraded.action === "decline") {
|
|
9153
|
+
recordDeclined("webhook", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9154
|
+
continue;
|
|
9155
|
+
}
|
|
9156
|
+
}
|
|
9157
|
+
// Live state decides create-vs-update: a webhook the manifest never
|
|
9158
|
+
// recorded is adopted, and one the manifest still names but the
|
|
9159
|
+
// server no longer has goes to the create path (#1659).
|
|
9160
|
+
const existingId = resolveExistingId({
|
|
9161
|
+
liveOk: webhookLiveOk,
|
|
9162
|
+
liveId: liveListed?.webhookId,
|
|
9163
|
+
manifestId: existingEntry?.id,
|
|
9164
|
+
});
|
|
7459
9165
|
// Same body on the create, update, and adopt-then-update paths,
|
|
7460
9166
|
// built from the vendored definition (#2644).
|
|
7461
9167
|
const payload = buildWebhookPushPayload(key, tomlData);
|
|
7462
9168
|
if (existingId) {
|
|
7463
9169
|
changes.push({ type: "webhook", action: "update", key });
|
|
7464
9170
|
if (!options.dryRun) {
|
|
9171
|
+
// #2731 B4 — the guard is the LIVE timestamp the reconcile
|
|
9172
|
+
// fetch just read (the gate carries it), not the manifest's:
|
|
9173
|
+
// that closes the window between the read and this write
|
|
9174
|
+
// rather than re-reporting a difference we just looked at.
|
|
7465
9175
|
const expectedModifiedAt = options.force
|
|
7466
9176
|
? undefined
|
|
7467
|
-
:
|
|
9177
|
+
: gate?.action === "update"
|
|
9178
|
+
? gate.expectedModifiedAt
|
|
9179
|
+
: existingEntry?.modifiedAt;
|
|
7468
9180
|
try {
|
|
9181
|
+
// Named as the ADOPT it is (#2934) — see the cron-trigger
|
|
9182
|
+
// leg: a bare "Updated webhook" would not say the push took
|
|
9183
|
+
// over an object the operator never recorded.
|
|
9184
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
9185
|
+
info(` Webhook already exists on server, adopting by key: ${key}`);
|
|
9186
|
+
}
|
|
7469
9187
|
const updated = await client.updateWebhook(resolvedAppId, existingId, payload, expectedModifiedAt);
|
|
7470
9188
|
info(` Updated webhook: ${key}`);
|
|
7471
|
-
if (syncState
|
|
7472
|
-
syncState.entities.webhooks
|
|
7473
|
-
|
|
9189
|
+
if (syncState) {
|
|
9190
|
+
if (!syncState.entities.webhooks)
|
|
9191
|
+
syncState.entities.webhooks = {};
|
|
9192
|
+
syncState.entities.webhooks[key] = {
|
|
9193
|
+
id: existingId,
|
|
9194
|
+
modifiedAt: updated?.modifiedAt ||
|
|
9195
|
+
existingEntry?.modifiedAt ||
|
|
9196
|
+
new Date().toISOString(),
|
|
9197
|
+
contentHash: computeFileHash(filePath),
|
|
9198
|
+
// Stamped from the RETURNED entity, not the payload sent:
|
|
9199
|
+
// the handler normalizes, so the payload is not what the
|
|
9200
|
+
// next comparison will see.
|
|
9201
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existingId)),
|
|
9202
|
+
};
|
|
7474
9203
|
}
|
|
7475
9204
|
}
|
|
7476
9205
|
catch (err) {
|
|
7477
9206
|
if (err instanceof ConflictError) {
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
localModifiedAt: expectedModifiedAt || "unknown",
|
|
7483
|
-
});
|
|
9207
|
+
// A late 409: the server moved between the reconcile fetch
|
|
9208
|
+
// and this write, so the gate's field diff describes a state
|
|
9209
|
+
// that is gone. Recompute against the current record.
|
|
9210
|
+
conflicts.push(await lateConflict("webhook", key, err, expectedModifiedAt, () => refetchLiveWebhook(existingId), webhookSpec, tomlData));
|
|
7484
9211
|
}
|
|
7485
9212
|
else {
|
|
7486
9213
|
throw wrapEntityError(err, "update", "webhook", key);
|
|
@@ -7502,6 +9229,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7502
9229
|
id: created.webhookId,
|
|
7503
9230
|
modifiedAt: created.modifiedAt,
|
|
7504
9231
|
contentHash: computeFileHash(filePath),
|
|
9232
|
+
// #2880 — the baseline the next run classifies against,
|
|
9233
|
+
// read from what the server stored rather than what was
|
|
9234
|
+
// sent (the handler normalizes).
|
|
9235
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, created, () => refetchLiveWebhook(created.webhookId)),
|
|
7505
9236
|
};
|
|
7506
9237
|
}
|
|
7507
9238
|
}
|
|
@@ -7533,28 +9264,119 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7533
9264
|
existing.modifiedAt ||
|
|
7534
9265
|
new Date().toISOString(),
|
|
7535
9266
|
contentHash: computeFileHash(filePath),
|
|
9267
|
+
// #2880 — an adoption converges here, so it stamps the
|
|
9268
|
+
// baseline too; without it the next run has no direction
|
|
9269
|
+
// signal for a row this one just reconciled.
|
|
9270
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existing.webhookId)),
|
|
7536
9271
|
};
|
|
7537
9272
|
}
|
|
7538
9273
|
}
|
|
7539
9274
|
}
|
|
7540
9275
|
}
|
|
7541
|
-
}
|
|
7542
|
-
}
|
|
7543
|
-
// Process cron triggers
|
|
7544
|
-
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
7545
|
-
if (existsSync(cronTriggersDir)) {
|
|
7546
|
-
const files = selectFiles("cron-trigger", cronTriggersDir, readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml")));
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
9276
|
+
}
|
|
9277
|
+
}
|
|
9278
|
+
// Process cron triggers
|
|
9279
|
+
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
9280
|
+
if (existsSync(cronTriggersDir)) {
|
|
9281
|
+
const files = selectFiles("cron-trigger", cronTriggersDir, readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml")));
|
|
9282
|
+
// #2880 — cron triggers join the live-state reconcile. The LIST row is
|
|
9283
|
+
// the whole record for this type (it is what `config pull`
|
|
9284
|
+
// serializes from), so the section needs no per-entity detail read.
|
|
9285
|
+
// The update endpoint takes no `expectedModifiedAt`, which is exactly
|
|
9286
|
+
// why the read-time direction classification matters here: it is the
|
|
9287
|
+
// only protection this type has ever had, and it was comparing bytes.
|
|
9288
|
+
const cronSpec = configDiffSpec("cron-trigger");
|
|
9289
|
+
const liveCronTriggers = new Map();
|
|
9290
|
+
let cronLiveOk = true;
|
|
9291
|
+
if (files.length > 0) {
|
|
9292
|
+
try {
|
|
9293
|
+
const listed = await client.listCronTriggers(resolvedAppId);
|
|
9294
|
+
for (const t of (listed?.items ?? [])) {
|
|
9295
|
+
if (t?.triggerKey)
|
|
9296
|
+
liveCronTriggers.set(t.triggerKey, t);
|
|
9297
|
+
}
|
|
9298
|
+
}
|
|
9299
|
+
catch (err) {
|
|
9300
|
+
cronLiveOk = false;
|
|
9301
|
+
warnLiveUnavailable("cron-triggers", err);
|
|
9302
|
+
}
|
|
9303
|
+
}
|
|
9304
|
+
const refetchLiveCronTrigger = async (triggerId) => {
|
|
9305
|
+
const full = await client.getCronTrigger(resolvedAppId, triggerId);
|
|
9306
|
+
return full ? { record: full } : null;
|
|
9307
|
+
};
|
|
9308
|
+
for (const file of files) {
|
|
9309
|
+
const filePath = join(cronTriggersDir, file);
|
|
9310
|
+
const tomlData = parseTomlFile(filePath);
|
|
9311
|
+
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
9312
|
+
withDerivedIdentity(tomlData, "cronTrigger", { key });
|
|
9313
|
+
const existingEntry = syncState?.entities?.cronTriggers?.[key];
|
|
9314
|
+
const liveCron = liveCronTriggers.get(key);
|
|
9315
|
+
let gate;
|
|
9316
|
+
if (cronLiveOk) {
|
|
9317
|
+
gate = decidePushForConfig({
|
|
9318
|
+
spec: cronSpec,
|
|
9319
|
+
maps: pushDiffMaps,
|
|
9320
|
+
localParsed: tomlData,
|
|
9321
|
+
live: liveCron
|
|
9322
|
+
? { record: liveCron, modifiedAt: liveCron.modifiedAt }
|
|
9323
|
+
: null,
|
|
9324
|
+
entry: existingEntry,
|
|
9325
|
+
localFileHash: computeFileHash(filePath),
|
|
9326
|
+
force: options.force,
|
|
9327
|
+
// #2934 — the create path below adopts an existing trigger by
|
|
9328
|
+
// `triggerKey` on the server's 409 (#976), so a trigger the
|
|
9329
|
+
// manifest has never recorded is that same adopt, reached one
|
|
9330
|
+
// step earlier because the reconcile above already read the
|
|
9331
|
+
// server. Without this the gate refuses it as a difference it
|
|
9332
|
+
// cannot attribute and the create the guard recovers from is
|
|
9333
|
+
// never sent — a conflict no `config push` can clear.
|
|
9334
|
+
adoptsUntrackedByKey: true,
|
|
9335
|
+
});
|
|
9336
|
+
if (gate.action === "skip") {
|
|
9337
|
+
skippedCount++;
|
|
9338
|
+
if (syncState && liveCron?.triggerId) {
|
|
9339
|
+
if (!syncState.entities.cronTriggers) {
|
|
9340
|
+
syncState.entities.cronTriggers = {};
|
|
9341
|
+
}
|
|
9342
|
+
syncState.entities.cronTriggers[key] = {
|
|
9343
|
+
id: liveCron.triggerId,
|
|
9344
|
+
modifiedAt: liveCron.modifiedAt ||
|
|
9345
|
+
existingEntry?.modifiedAt ||
|
|
9346
|
+
new Date().toISOString(),
|
|
9347
|
+
contentHash: computeFileHash(filePath),
|
|
9348
|
+
semanticHash: gate.remoteHash,
|
|
9349
|
+
};
|
|
9350
|
+
}
|
|
9351
|
+
continue;
|
|
9352
|
+
}
|
|
9353
|
+
if (gate.action === "drift" ||
|
|
9354
|
+
gate.action === "conflict" ||
|
|
9355
|
+
gate.action === "immutable") {
|
|
9356
|
+
recordDeclined("cron-trigger", key, gate, existingEntry?.modifiedAt);
|
|
9357
|
+
continue;
|
|
9358
|
+
}
|
|
9359
|
+
}
|
|
9360
|
+
else {
|
|
9361
|
+
const degraded = decideDegradedPush({
|
|
9362
|
+
force: options.force,
|
|
9363
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9364
|
+
currentFileHash: computeFileHash(filePath),
|
|
9365
|
+
});
|
|
9366
|
+
if (degraded.action === "skip") {
|
|
9367
|
+
skippedCount++;
|
|
9368
|
+
continue;
|
|
9369
|
+
}
|
|
9370
|
+
if (degraded.action === "decline") {
|
|
9371
|
+
recordDeclined("cron-trigger", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9372
|
+
continue;
|
|
9373
|
+
}
|
|
9374
|
+
}
|
|
9375
|
+
const existingId = resolveExistingId({
|
|
9376
|
+
liveOk: cronLiveOk,
|
|
9377
|
+
liveId: liveCron?.triggerId,
|
|
9378
|
+
manifestId: existingEntry?.id,
|
|
9379
|
+
});
|
|
7558
9380
|
// Built from the vendored definition (#2644). The create body
|
|
7559
9381
|
// omits `state` — the handler assigns it — which is what the
|
|
7560
9382
|
// definition's `writableOn` records.
|
|
@@ -7568,11 +9390,30 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7568
9390
|
changes.push({ type: "cron-trigger", action: "update", key });
|
|
7569
9391
|
if (!options.dryRun) {
|
|
7570
9392
|
try {
|
|
9393
|
+
// Say so when this update is an ADOPT (#2934): the operator
|
|
9394
|
+
// never recorded this trigger, so a bare "Updated cron
|
|
9395
|
+
// trigger" would not tell them the push took over an object
|
|
9396
|
+
// it found on the server. Same sentence the 409 recovery
|
|
9397
|
+
// below prints.
|
|
9398
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
9399
|
+
info(` Cron trigger already exists on server, adopting by key: ${key}`);
|
|
9400
|
+
}
|
|
7571
9401
|
const updated = await client.updateCronTrigger(resolvedAppId, existingId, updatePayload);
|
|
7572
9402
|
info(` Updated cron trigger: ${key}`);
|
|
7573
|
-
if (syncState
|
|
7574
|
-
syncState.entities.cronTriggers
|
|
7575
|
-
|
|
9403
|
+
if (syncState) {
|
|
9404
|
+
if (!syncState.entities.cronTriggers) {
|
|
9405
|
+
syncState.entities.cronTriggers = {};
|
|
9406
|
+
}
|
|
9407
|
+
syncState.entities.cronTriggers[key] = {
|
|
9408
|
+
id: existingId,
|
|
9409
|
+
modifiedAt: updated?.modifiedAt ||
|
|
9410
|
+
existingEntry?.modifiedAt ||
|
|
9411
|
+
new Date().toISOString(),
|
|
9412
|
+
contentHash: computeFileHash(filePath),
|
|
9413
|
+
// #2880 — from the entity the server returned: the
|
|
9414
|
+
// handler materializes `timezone`/`overlapPolicy`.
|
|
9415
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(existingId)),
|
|
9416
|
+
};
|
|
7576
9417
|
}
|
|
7577
9418
|
}
|
|
7578
9419
|
catch (err) {
|
|
@@ -7594,6 +9435,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7594
9435
|
id: created.triggerId,
|
|
7595
9436
|
modifiedAt: created.modifiedAt,
|
|
7596
9437
|
contentHash: computeFileHash(filePath),
|
|
9438
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, created, () => refetchLiveCronTrigger(created.triggerId)),
|
|
7597
9439
|
};
|
|
7598
9440
|
}
|
|
7599
9441
|
}
|
|
@@ -7629,6 +9471,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7629
9471
|
id: adoptedId,
|
|
7630
9472
|
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
7631
9473
|
contentHash: computeFileHash(filePath),
|
|
9474
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(adoptedId)),
|
|
7632
9475
|
};
|
|
7633
9476
|
}
|
|
7634
9477
|
}
|
|
@@ -7640,17 +9483,106 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7640
9483
|
const blobBucketsPushDir = join(configDir, "blob-buckets");
|
|
7641
9484
|
if (existsSync(blobBucketsPushDir)) {
|
|
7642
9485
|
const files = selectFiles("blob-bucket", blobBucketsPushDir, readdirSync(blobBucketsPushDir).filter((f) => f.endsWith(".toml")));
|
|
9486
|
+
// #2880 — blob buckets join the live-state reconcile. As for cron
|
|
9487
|
+
// triggers the list row IS the record, and the update takes no
|
|
9488
|
+
// concurrency token, so read-time classification is the protection.
|
|
9489
|
+
// `ttlTier` is create-only, so a local change to it surfaces as the
|
|
9490
|
+
// `immutable` outcome — named with its remedy instead of PATCHed into
|
|
9491
|
+
// a success that changes nothing — including under `--force`.
|
|
9492
|
+
const bucketSpec = configDiffSpec("blob-bucket");
|
|
9493
|
+
const liveBuckets = new Map();
|
|
9494
|
+
let bucketsLiveOk = true;
|
|
9495
|
+
if (files.length > 0) {
|
|
9496
|
+
try {
|
|
9497
|
+
const listed = await client.listBlobBuckets(resolvedAppId);
|
|
9498
|
+
for (const b of (listed?.items ?? [])) {
|
|
9499
|
+
if (b?.bucketKey)
|
|
9500
|
+
liveBuckets.set(b.bucketKey, b);
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
catch (err) {
|
|
9504
|
+
bucketsLiveOk = false;
|
|
9505
|
+
warnLiveUnavailable("blob-buckets", err);
|
|
9506
|
+
}
|
|
9507
|
+
}
|
|
9508
|
+
const refetchLiveBucket = async (bucketId) => {
|
|
9509
|
+
const full = await client.getBlobBucket(resolvedAppId, bucketId);
|
|
9510
|
+
return full ? { record: full } : null;
|
|
9511
|
+
};
|
|
7643
9512
|
for (const file of files) {
|
|
7644
9513
|
const filePath = join(blobBucketsPushDir, file);
|
|
7645
9514
|
const tomlData = parseTomlFile(filePath);
|
|
9515
|
+
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
9516
|
+
withDerivedIdentity(tomlData, "bucket", { key });
|
|
7646
9517
|
const bucket = tomlData.bucket || {};
|
|
7647
|
-
const
|
|
7648
|
-
const
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
9518
|
+
const existingEntry = syncState?.entities?.blobBuckets?.[key];
|
|
9519
|
+
const liveBucket = liveBuckets.get(key);
|
|
9520
|
+
let gate;
|
|
9521
|
+
if (bucketsLiveOk) {
|
|
9522
|
+
gate = decidePushForConfig({
|
|
9523
|
+
spec: bucketSpec,
|
|
9524
|
+
maps: pushDiffMaps,
|
|
9525
|
+
localParsed: tomlData,
|
|
9526
|
+
live: liveBucket
|
|
9527
|
+
? { record: liveBucket, modifiedAt: liveBucket.modifiedAt }
|
|
9528
|
+
: null,
|
|
9529
|
+
entry: existingEntry,
|
|
9530
|
+
localFileHash: computeFileHash(filePath),
|
|
9531
|
+
force: options.force,
|
|
9532
|
+
// #2934 — the create leg below adopts an existing bucket by
|
|
9533
|
+
// `bucketKey` on the server's 409 (#1006/#1459), so a bucket
|
|
9534
|
+
// the manifest has never recorded is that same adopt, reached
|
|
9535
|
+
// one step earlier because the reconcile above already read the
|
|
9536
|
+
// server. `ttlTier` is fixed at creation, so a divergent tier
|
|
9537
|
+
// still declines as `immutable` — the gate checks that before
|
|
9538
|
+
// the adopt, exactly as the 409 recovery checks it before the
|
|
9539
|
+
// update it re-issues.
|
|
9540
|
+
adoptsUntrackedByKey: true,
|
|
9541
|
+
});
|
|
9542
|
+
if (gate.action === "skip") {
|
|
9543
|
+
skippedCount++;
|
|
9544
|
+
if (syncState && liveBucket?.bucketId) {
|
|
9545
|
+
if (!syncState.entities.blobBuckets) {
|
|
9546
|
+
syncState.entities.blobBuckets = {};
|
|
9547
|
+
}
|
|
9548
|
+
syncState.entities.blobBuckets[key] = {
|
|
9549
|
+
id: liveBucket.bucketId,
|
|
9550
|
+
modifiedAt: liveBucket.modifiedAt ||
|
|
9551
|
+
existingEntry?.modifiedAt ||
|
|
9552
|
+
new Date().toISOString(),
|
|
9553
|
+
contentHash: computeFileHash(filePath),
|
|
9554
|
+
semanticHash: gate.remoteHash,
|
|
9555
|
+
};
|
|
9556
|
+
}
|
|
9557
|
+
continue;
|
|
9558
|
+
}
|
|
9559
|
+
if (gate.action === "drift" ||
|
|
9560
|
+
gate.action === "conflict" ||
|
|
9561
|
+
gate.action === "immutable") {
|
|
9562
|
+
recordDeclined("blob-bucket", key, gate, existingEntry?.modifiedAt);
|
|
9563
|
+
continue;
|
|
9564
|
+
}
|
|
9565
|
+
}
|
|
9566
|
+
else {
|
|
9567
|
+
const degraded = decideDegradedPush({
|
|
9568
|
+
force: options.force,
|
|
9569
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9570
|
+
currentFileHash: computeFileHash(filePath),
|
|
9571
|
+
});
|
|
9572
|
+
if (degraded.action === "skip") {
|
|
9573
|
+
skippedCount++;
|
|
9574
|
+
continue;
|
|
9575
|
+
}
|
|
9576
|
+
if (degraded.action === "decline") {
|
|
9577
|
+
recordDeclined("blob-bucket", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9578
|
+
continue;
|
|
9579
|
+
}
|
|
7653
9580
|
}
|
|
9581
|
+
const existingId = resolveExistingId({
|
|
9582
|
+
liveOk: bucketsLiveOk,
|
|
9583
|
+
liveId: liveBucket?.bucketId,
|
|
9584
|
+
manifestId: existingEntry?.id,
|
|
9585
|
+
});
|
|
7654
9586
|
if (existingId) {
|
|
7655
9587
|
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
7656
9588
|
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
@@ -7658,15 +9590,29 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7658
9590
|
changes.push({ type: "blob-bucket", action: "update", key });
|
|
7659
9591
|
if (!options.dryRun) {
|
|
7660
9592
|
try {
|
|
9593
|
+
// Named as the ADOPT it is (#2934) — see the cron-trigger
|
|
9594
|
+
// leg.
|
|
9595
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
9596
|
+
info(` Blob bucket already exists on server, adopting by key: ${key}`);
|
|
9597
|
+
}
|
|
7661
9598
|
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
7662
9599
|
info(` Updated blob bucket: ${key}`);
|
|
7663
9600
|
// #1459: stamp the content hash ONLY after a successful
|
|
7664
9601
|
// update, and only when this is not a dry run. A failed
|
|
7665
9602
|
// update (or a dry run) must leave the file pending rather
|
|
7666
9603
|
// than mark it in-sync and suppress it on the next push.
|
|
7667
|
-
if (syncState
|
|
7668
|
-
syncState.entities.blobBuckets
|
|
7669
|
-
|
|
9604
|
+
if (syncState) {
|
|
9605
|
+
if (!syncState.entities.blobBuckets) {
|
|
9606
|
+
syncState.entities.blobBuckets = {};
|
|
9607
|
+
}
|
|
9608
|
+
syncState.entities.blobBuckets[key] = {
|
|
9609
|
+
id: existingId,
|
|
9610
|
+
modifiedAt: existingEntry?.modifiedAt || new Date().toISOString(),
|
|
9611
|
+
contentHash: computeFileHash(filePath),
|
|
9612
|
+
// #2880 — the baseline, refetched because the PATCH
|
|
9613
|
+
// response is not the stored record for this type.
|
|
9614
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, undefined, () => refetchLiveBucket(existingId)),
|
|
9615
|
+
};
|
|
7670
9616
|
}
|
|
7671
9617
|
}
|
|
7672
9618
|
catch (err) {
|
|
@@ -7694,6 +9640,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7694
9640
|
id: created.bucketId,
|
|
7695
9641
|
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
7696
9642
|
contentHash: computeFileHash(filePath),
|
|
9643
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, created, () => refetchLiveBucket(created.bucketId)),
|
|
7697
9644
|
};
|
|
7698
9645
|
}
|
|
7699
9646
|
}
|
|
@@ -7764,6 +9711,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7764
9711
|
existing.modifiedAt ||
|
|
7765
9712
|
new Date().toISOString(),
|
|
7766
9713
|
contentHash: computeFileHash(filePath),
|
|
9714
|
+
// #2880 — an adoption converges, so it stamps the
|
|
9715
|
+
// baseline the next run classifies against.
|
|
9716
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, updated, () => refetchLiveBucket(existing.bucketId)),
|
|
7767
9717
|
};
|
|
7768
9718
|
}
|
|
7769
9719
|
}
|
|
@@ -7881,6 +9831,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7881
9831
|
entry: existingEntry,
|
|
7882
9832
|
localFileHash: computeFileHash(filePath),
|
|
7883
9833
|
force: options.force,
|
|
9834
|
+
// #2909 — the prompt create path adopts an existing prompt by
|
|
9835
|
+
// `promptKey` on a 409 (#1006), so a prompt the manifest has
|
|
9836
|
+
// never recorded is that same adopt, reached one step earlier
|
|
9837
|
+
// because the reconcile above already read the server. Refusing
|
|
9838
|
+
// it here made the documented adopt unreachable: the CREATE the
|
|
9839
|
+
// guard recovers from is never sent when live state names the
|
|
9840
|
+
// prompt, so the push reported a conflict no `config push`
|
|
9841
|
+
// could clear.
|
|
9842
|
+
adoptsUntrackedByKey: true,
|
|
7884
9843
|
});
|
|
7885
9844
|
if (gate.action === "skip") {
|
|
7886
9845
|
skippedCount++;
|
|
@@ -7917,14 +9876,26 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7917
9876
|
continue;
|
|
7918
9877
|
}
|
|
7919
9878
|
}
|
|
7920
|
-
else
|
|
7921
|
-
existingEntry?.id &&
|
|
7922
|
-
!shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
9879
|
+
else {
|
|
7923
9880
|
// Degraded: the manifest byte-hash gate this section has always
|
|
7924
|
-
// fallen back to when the server cannot be read
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
9881
|
+
// fallen back to when the server cannot be read — and, since
|
|
9882
|
+
// #2880, a decline when there is no baseline to gate on rather
|
|
9883
|
+
// than an unconditional write over unread server state.
|
|
9884
|
+
const degraded = decideDegradedPush({
|
|
9885
|
+
force: options.force,
|
|
9886
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9887
|
+
currentFileHash: computeFileHash(filePath),
|
|
9888
|
+
});
|
|
9889
|
+
if (degraded.action === "skip") {
|
|
9890
|
+
skippedCount++;
|
|
9891
|
+
if (existingEntry?.id)
|
|
9892
|
+
promptKeyToId.set(key, existingEntry.id);
|
|
9893
|
+
continue;
|
|
9894
|
+
}
|
|
9895
|
+
if (degraded.action === "decline") {
|
|
9896
|
+
recordDeclined("prompt", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9897
|
+
continue;
|
|
9898
|
+
}
|
|
7928
9899
|
}
|
|
7929
9900
|
// Live state decides create-vs-update: a prompt the manifest never
|
|
7930
9901
|
// recorded is adopted, and one the manifest still names but the
|
|
@@ -8074,6 +10045,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8074
10045
|
changes.push({ type: "prompt", action: "update", key });
|
|
8075
10046
|
promptKeyToId.set(key, existingId);
|
|
8076
10047
|
if (!options.dryRun) {
|
|
10048
|
+
// Say so when this update is an ADOPT (#2909): the operator
|
|
10049
|
+
// never recorded this prompt, so a bare "Updated prompt" would
|
|
10050
|
+
// not tell them the push took over an object it found on the
|
|
10051
|
+
// server. Same sentence the 409 recovery prints.
|
|
10052
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
10053
|
+
info(` Prompt already exists on server, adopting by key: ${key}`);
|
|
10054
|
+
}
|
|
8077
10055
|
// A prompt adopted from live state has no manifest row yet, and
|
|
8078
10056
|
// `applyPromptUpdate` stamps INTO that row — without it the
|
|
8079
10057
|
// push would apply and record nothing, and the next run would
|
|
@@ -8466,16 +10444,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8466
10444
|
}
|
|
8467
10445
|
: undefined;
|
|
8468
10446
|
}
|
|
8469
|
-
else
|
|
10447
|
+
else {
|
|
8470
10448
|
// Fallback gate: the manifest byte hash. A script with no
|
|
8471
10449
|
// recorded hash has no baseline at all and its apply is a blind
|
|
8472
10450
|
// overwrite, so it FAILS CLOSED rather than pushing on a guess.
|
|
8473
|
-
|
|
8474
|
-
|
|
10451
|
+
// #2880 — through the shared decision, so every type declines
|
|
10452
|
+
// for the same reason in the same words.
|
|
10453
|
+
const degraded = decideDegradedPush({
|
|
10454
|
+
force: options.force,
|
|
10455
|
+
storedContentHash: scriptEntry?.contentHash,
|
|
10456
|
+
currentFileHash: computeFileHash(filePath),
|
|
10457
|
+
});
|
|
10458
|
+
if (degraded.action === "skip") {
|
|
10459
|
+
skippedCount++;
|
|
8475
10460
|
continue;
|
|
8476
10461
|
}
|
|
8477
|
-
if (
|
|
8478
|
-
|
|
10462
|
+
if (degraded.action === "decline") {
|
|
10463
|
+
recordDeclined("transform", name, {
|
|
10464
|
+
action: "live-unavailable",
|
|
10465
|
+
});
|
|
8479
10466
|
continue;
|
|
8480
10467
|
}
|
|
8481
10468
|
}
|
|
@@ -8558,6 +10545,37 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8558
10545
|
const workflowsDir = join(configDir, "workflows");
|
|
8559
10546
|
if (existsSync(workflowsDir)) {
|
|
8560
10547
|
const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
|
|
10548
|
+
// #2896 — pairing for a clone with no sync state. A workflow the
|
|
10549
|
+
// manifest has no row for is not thereby a create: the create path
|
|
10550
|
+
// below already adopts an existing workflow by key on the server's
|
|
10551
|
+
// 409 (#1006/#1174), so letting live state answer the same question
|
|
10552
|
+
// one step earlier is that same adopt — reached before a doomed
|
|
10553
|
+
// create is sent, which is what the prompt reconcile does (#2909).
|
|
10554
|
+
// Without it a stateless clone plans "create workflow" for every
|
|
10555
|
+
// workflow the server already holds, and its `.tests/` sidecars —
|
|
10556
|
+
// addressed through the sync-state id the plan never records —
|
|
10557
|
+
// cascade into duplicate test-case creates. Fetched once, and only
|
|
10558
|
+
// when a file is actually untracked; a failed listing degrades to
|
|
10559
|
+
// the create-then-409 path, which still converges.
|
|
10560
|
+
let liveWorkflowIdsByKey;
|
|
10561
|
+
const lookupLiveWorkflowId = async (workflowKey) => {
|
|
10562
|
+
if (liveWorkflowIdsByKey === undefined) {
|
|
10563
|
+
try {
|
|
10564
|
+
const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
10565
|
+
liveWorkflowIdsByKey = new Map(live
|
|
10566
|
+
.filter((w) => w?.workflowKey && w?.workflowId)
|
|
10567
|
+
.map((w) => [
|
|
10568
|
+
w.workflowKey,
|
|
10569
|
+
w.workflowId,
|
|
10570
|
+
]));
|
|
10571
|
+
}
|
|
10572
|
+
catch (err) {
|
|
10573
|
+
liveWorkflowIdsByKey = null;
|
|
10574
|
+
warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
|
|
10575
|
+
}
|
|
10576
|
+
}
|
|
10577
|
+
return liveWorkflowIdsByKey?.get(workflowKey);
|
|
10578
|
+
};
|
|
8561
10579
|
for (const file of files) {
|
|
8562
10580
|
const filePath = join(workflowsDir, file);
|
|
8563
10581
|
const tomlData = parseTomlFile(filePath);
|
|
@@ -8638,7 +10656,32 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8638
10656
|
throw new Error(`${filePath}: workflow expressions are invalid:\n - ${exprErrors.join("\n - ")}`);
|
|
8639
10657
|
}
|
|
8640
10658
|
workflow.expressions = workflowExpressions;
|
|
8641
|
-
|
|
10659
|
+
let existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
10660
|
+
// #2896 — an untracked key is asked of live state before it is
|
|
10661
|
+
// declared a create. A match is adopted NOW: the id is seeded into
|
|
10662
|
+
// the manifest row exactly as the 409 recovery seeds it, so the
|
|
10663
|
+
// dry-run plan says the update a real push would perform, and the
|
|
10664
|
+
// test-case legs below can address this block instead of planning
|
|
10665
|
+
// its sidecars as duplicate creates.
|
|
10666
|
+
let adoptedFromLive = false;
|
|
10667
|
+
if (!existingId) {
|
|
10668
|
+
const liveId = await lookupLiveWorkflowId(key);
|
|
10669
|
+
if (liveId) {
|
|
10670
|
+
existingId = liveId;
|
|
10671
|
+
adoptedFromLive = true;
|
|
10672
|
+
if (syncState) {
|
|
10673
|
+
if (!syncState.entities.workflows) {
|
|
10674
|
+
syncState.entities.workflows = {};
|
|
10675
|
+
}
|
|
10676
|
+
if (!syncState.entities.workflows[key]) {
|
|
10677
|
+
syncState.entities.workflows[key] = { id: liveId };
|
|
10678
|
+
}
|
|
10679
|
+
else {
|
|
10680
|
+
syncState.entities.workflows[key].id = liveId;
|
|
10681
|
+
}
|
|
10682
|
+
}
|
|
10683
|
+
}
|
|
10684
|
+
}
|
|
8642
10685
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
8643
10686
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
8644
10687
|
// content hash (post fragment splice) so that edits to included
|
|
@@ -8806,13 +10849,30 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8806
10849
|
// Update existing workflow
|
|
8807
10850
|
changes.push({ type: "workflow", action: "update", key });
|
|
8808
10851
|
if (!options.dryRun) {
|
|
10852
|
+
// An adopted workflow has no stored modifiedAt, so the
|
|
10853
|
+
// undefined here omits the concurrency guard — the same
|
|
10854
|
+
// "first adopt forces the PATCH" the 409 branch documents.
|
|
8809
10855
|
const expectedModifiedAt = options.force
|
|
8810
10856
|
? undefined
|
|
8811
10857
|
: syncState?.entities?.workflows?.[key]?.modifiedAt;
|
|
8812
10858
|
try {
|
|
10859
|
+
let activeConfigId = existingActiveConfigId;
|
|
10860
|
+
if (adoptedFromLive && activeConfigId === undefined) {
|
|
10861
|
+
// Seed the real activeConfigId from the server, as the
|
|
10862
|
+
// 409-adopt branch does: sync state has none for an
|
|
10863
|
+
// adopted workflow, and without it the shared update body
|
|
10864
|
+
// wrongly falls back to `updateWorkflowDraft`.
|
|
10865
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
10866
|
+
activeConfigId = fullWorkflow?.workflow?.activeConfigId;
|
|
10867
|
+
if (syncState?.entities?.workflows?.[key] &&
|
|
10868
|
+
activeConfigId) {
|
|
10869
|
+
syncState.entities.workflows[key].activeConfigId =
|
|
10870
|
+
activeConfigId;
|
|
10871
|
+
}
|
|
10872
|
+
}
|
|
8813
10873
|
await applyWorkflowUpdate(existingId, {
|
|
8814
10874
|
expectedModifiedAt,
|
|
8815
|
-
activeConfigId
|
|
10875
|
+
activeConfigId,
|
|
8816
10876
|
});
|
|
8817
10877
|
}
|
|
8818
10878
|
catch (err) {
|
|
@@ -9225,13 +11285,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9225
11285
|
}
|
|
9226
11286
|
}
|
|
9227
11287
|
}
|
|
9228
|
-
// Fallback gate: the manifest byte hash,
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
11288
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
11289
|
+
// decline when there is no hash to gate on.
|
|
11290
|
+
if (!dbTypeGate) {
|
|
11291
|
+
const degraded = decideDegradedPush({
|
|
11292
|
+
force: options.force,
|
|
11293
|
+
storedContentHash: existingEntry?.contentHash,
|
|
11294
|
+
currentFileHash: computeFileHash(filePath),
|
|
11295
|
+
});
|
|
11296
|
+
if (degraded.action === "skip") {
|
|
11297
|
+
skippedCount++;
|
|
11298
|
+
continue;
|
|
11299
|
+
}
|
|
11300
|
+
if (degraded.action === "decline") {
|
|
11301
|
+
recordDeclined("database-type-config", dbType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
11302
|
+
continue;
|
|
11303
|
+
}
|
|
9235
11304
|
}
|
|
9236
11305
|
// ── Issues #813 / #1336 / #2732: state what this push's operation
|
|
9237
11306
|
// set will BE, then run a validate-first pass before any mutating
|
|
@@ -10356,6 +12425,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10356
12425
|
entry: existingEntry,
|
|
10357
12426
|
localFileHash: computeFileHash(filePath),
|
|
10358
12427
|
force: options.force,
|
|
12428
|
+
// #2934 — the create leg below adopts an existing config by
|
|
12429
|
+
// `groupType` on the server's 409 (#1851), so a config the
|
|
12430
|
+
// manifest has never recorded is that same adopt, reached one
|
|
12431
|
+
// step earlier because the reconcile above already read the
|
|
12432
|
+
// server.
|
|
12433
|
+
adoptsUntrackedByKey: true,
|
|
10359
12434
|
});
|
|
10360
12435
|
if (gate.action === "skip") {
|
|
10361
12436
|
skippedCount++;
|
|
@@ -10379,9 +12454,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10379
12454
|
continue;
|
|
10380
12455
|
}
|
|
10381
12456
|
}
|
|
10382
|
-
else
|
|
10383
|
-
|
|
10384
|
-
|
|
12457
|
+
else {
|
|
12458
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
12459
|
+
// decline rather than a blind write when there is no hash.
|
|
12460
|
+
const degraded = decideDegradedPush({
|
|
12461
|
+
force: options.force,
|
|
12462
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12463
|
+
currentFileHash: computeFileHash(filePath),
|
|
12464
|
+
});
|
|
12465
|
+
if (degraded.action === "skip") {
|
|
12466
|
+
skippedCount++;
|
|
12467
|
+
continue;
|
|
12468
|
+
}
|
|
12469
|
+
if (degraded.action === "decline") {
|
|
12470
|
+
recordDeclined("group-type-config", groupType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12471
|
+
continue;
|
|
12472
|
+
}
|
|
10385
12473
|
}
|
|
10386
12474
|
// Live state decides create-vs-update: a config the manifest never
|
|
10387
12475
|
// recorded is adopted, and one the manifest still names but the
|
|
@@ -10400,6 +12488,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10400
12488
|
? gate.expectedModifiedAt
|
|
10401
12489
|
: existingEntry?.modifiedAt;
|
|
10402
12490
|
try {
|
|
12491
|
+
// Named as the ADOPT it is (#2934) — see the cron-trigger
|
|
12492
|
+
// leg.
|
|
12493
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
12494
|
+
info(` Group type config already exists on server, adopting by key: ${groupType}`);
|
|
12495
|
+
}
|
|
10403
12496
|
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, buildGroupTypeConfigPayload(configData, "update"), expectedModifiedAt);
|
|
10404
12497
|
info(` Updated group type config: ${groupType}`);
|
|
10405
12498
|
if (syncState) {
|
|
@@ -10553,6 +12646,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10553
12646
|
entry: existingEntry,
|
|
10554
12647
|
localFileHash: computeFileHash(filePath),
|
|
10555
12648
|
force: options.force,
|
|
12649
|
+
// #2934 — as for group type configs: the create leg adopts by
|
|
12650
|
+
// `collectionType` on the server's 409 (#1851), so a config the
|
|
12651
|
+
// manifest has never recorded is that adopt.
|
|
12652
|
+
adoptsUntrackedByKey: true,
|
|
10556
12653
|
});
|
|
10557
12654
|
if (gate.action === "skip") {
|
|
10558
12655
|
skippedCount++;
|
|
@@ -10576,9 +12673,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10576
12673
|
continue;
|
|
10577
12674
|
}
|
|
10578
12675
|
}
|
|
10579
|
-
else
|
|
10580
|
-
|
|
10581
|
-
|
|
12676
|
+
else {
|
|
12677
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
12678
|
+
// decline rather than a blind write when there is no hash.
|
|
12679
|
+
const degraded = decideDegradedPush({
|
|
12680
|
+
force: options.force,
|
|
12681
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12682
|
+
currentFileHash: computeFileHash(filePath),
|
|
12683
|
+
});
|
|
12684
|
+
if (degraded.action === "skip") {
|
|
12685
|
+
skippedCount++;
|
|
12686
|
+
continue;
|
|
12687
|
+
}
|
|
12688
|
+
if (degraded.action === "decline") {
|
|
12689
|
+
recordDeclined("collection-type-config", collectionType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12690
|
+
continue;
|
|
12691
|
+
}
|
|
10582
12692
|
}
|
|
10583
12693
|
// Live state decides create-vs-update; see the group-type section.
|
|
10584
12694
|
if (resolveExistingId({
|
|
@@ -10595,6 +12705,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10595
12705
|
? gate.expectedModifiedAt
|
|
10596
12706
|
: existingEntry?.modifiedAt;
|
|
10597
12707
|
try {
|
|
12708
|
+
// Named as the ADOPT it is (#2934) — see the cron-trigger
|
|
12709
|
+
// leg.
|
|
12710
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
12711
|
+
info(` Collection type config already exists on server, adopting by key: ${collectionType}`);
|
|
12712
|
+
}
|
|
10598
12713
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, buildCollectionTypeConfigPayload(configData, "update"), expectedModifiedAt);
|
|
10599
12714
|
info(` Updated collection type config: ${collectionType}`);
|
|
10600
12715
|
if (syncState) {
|
|
@@ -10763,9 +12878,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10763
12878
|
continue;
|
|
10764
12879
|
}
|
|
10765
12880
|
}
|
|
10766
|
-
else
|
|
10767
|
-
|
|
10768
|
-
|
|
12881
|
+
else {
|
|
12882
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
12883
|
+
// decline rather than a blind write when there is no hash.
|
|
12884
|
+
const degraded = decideDegradedPush({
|
|
12885
|
+
force: options.force,
|
|
12886
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12887
|
+
currentFileHash: computeFileHash(filePath),
|
|
12888
|
+
});
|
|
12889
|
+
if (degraded.action === "skip") {
|
|
12890
|
+
skippedCount++;
|
|
12891
|
+
continue;
|
|
12892
|
+
}
|
|
12893
|
+
if (degraded.action === "decline") {
|
|
12894
|
+
recordDeclined("metadata-category-config", entityKey, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12895
|
+
continue;
|
|
12896
|
+
}
|
|
10769
12897
|
}
|
|
10770
12898
|
const isCreate = !resolveExistingId({
|
|
10771
12899
|
liveOk: metadataCategoryLiveOk,
|
|
@@ -10903,17 +13031,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10903
13031
|
continue;
|
|
10904
13032
|
}
|
|
10905
13033
|
}
|
|
10906
|
-
else
|
|
13034
|
+
else {
|
|
10907
13035
|
// Fallback: the byte-hash gate. A legacy entry has no
|
|
10908
13036
|
// `contentHash` to compare against, and this type's apply is a
|
|
10909
13037
|
// blind overwrite of whatever the server holds — so it FAILS
|
|
10910
|
-
// CLOSED
|
|
10911
|
-
|
|
10912
|
-
|
|
13038
|
+
// CLOSED rather than upserting on a guess (#2880: through the
|
|
13039
|
+
// shared decision, in the same words as every other type).
|
|
13040
|
+
const degraded = decideDegradedPush({
|
|
13041
|
+
force: options.force,
|
|
13042
|
+
storedContentHash: existingEntry?.contentHash,
|
|
13043
|
+
currentFileHash: computeFileHash(filePath),
|
|
13044
|
+
});
|
|
13045
|
+
if (degraded.action === "skip") {
|
|
13046
|
+
skippedCount++;
|
|
10913
13047
|
continue;
|
|
10914
13048
|
}
|
|
10915
|
-
if (
|
|
10916
|
-
|
|
13049
|
+
if (degraded.action === "decline") {
|
|
13050
|
+
recordDeclined("email-template", emailType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10917
13051
|
continue;
|
|
10918
13052
|
}
|
|
10919
13053
|
}
|
|
@@ -10995,8 +13129,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10995
13129
|
}
|
|
10996
13130
|
// Push test cases for every test-bearing block (#2769: integrations
|
|
10997
13131
|
// joined the three that already had a sidecar).
|
|
10998
|
-
|
|
10999
|
-
|
|
13132
|
+
//
|
|
13133
|
+
// #2880 — every block that could name a config has been pushed by now,
|
|
13134
|
+
// so the id→name direction the comparison reads references through has
|
|
13135
|
+
// everything this push learned. It is refilled per case as the lazy
|
|
13136
|
+
// per-block config lookups below resolve more of it.
|
|
13137
|
+
fillTestCaseLookupMaps();
|
|
11000
13138
|
/** The id the last sync recorded for a block, whatever this push selected. */
|
|
11001
13139
|
const recordedBlockId = (blockType, blockKey) => {
|
|
11002
13140
|
const slot = blockType === "prompt"
|
|
@@ -11092,6 +13230,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11092
13230
|
client, appId: resolvedAppId, blockType: "prompt", blockId,
|
|
11093
13231
|
blockKey: promptKey, configDir, syncState,
|
|
11094
13232
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
13233
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
13234
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
11095
13235
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
11096
13236
|
})).skipped;
|
|
11097
13237
|
}
|
|
@@ -11108,6 +13248,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11108
13248
|
client, appId: resolvedAppId, blockType: "workflow", blockId,
|
|
11109
13249
|
blockKey: workflowKey, configDir, syncState,
|
|
11110
13250
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
13251
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
13252
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
11111
13253
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
11112
13254
|
})).skipped;
|
|
11113
13255
|
}
|
|
@@ -11134,6 +13276,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11134
13276
|
client, appId: resolvedAppId, blockType: "script", blockId,
|
|
11135
13277
|
blockKey: scriptName, configDir, syncState,
|
|
11136
13278
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
13279
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
13280
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
11137
13281
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
11138
13282
|
})).skipped;
|
|
11139
13283
|
}
|
|
@@ -11156,6 +13300,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11156
13300
|
client, appId: resolvedAppId, blockType: "integration", blockId,
|
|
11157
13301
|
blockKey: integrationKey, configDir, syncState,
|
|
11158
13302
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
13303
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
13304
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
11159
13305
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
11160
13306
|
})).skipped;
|
|
11161
13307
|
}
|
|
@@ -11507,8 +13653,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11507
13653
|
}
|
|
11508
13654
|
}
|
|
11509
13655
|
catch (err) {
|
|
11510
|
-
// Save sync state even on failure so partial progress is preserved
|
|
11511
|
-
|
|
13656
|
+
// Save sync state even on failure so partial progress is preserved —
|
|
13657
|
+
// but ONLY when there is progress (#2936), which is the state having
|
|
13658
|
+
// moved, not a write having been planned. A preflight abort applies
|
|
13659
|
+
// nothing; so does a first write that the server rejects. For a
|
|
13660
|
+
// cross-app push the state in hand is the reset one: bound to the
|
|
13661
|
+
// target with every entity id dropped. Writing that after "no changes
|
|
13662
|
+
// were applied" rebinds the slot to an app it never wrote to, leaving
|
|
13663
|
+
// the next push with no baseline and nothing tracked — the conflict
|
|
13664
|
+
// `config push` cannot clear. A dry run applies nothing either, and
|
|
13665
|
+
// the success path already declines to save for it.
|
|
13666
|
+
const recordedProgress = !!syncState && JSON.stringify(syncState) !== syncStateAtStart;
|
|
13667
|
+
if (syncState && recordedProgress && !options.dryRun) {
|
|
11512
13668
|
try {
|
|
11513
13669
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
11514
13670
|
saveSyncState(configDir, syncState);
|
|
@@ -11681,6 +13837,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11681
13837
|
const localEmailTemplatesParsed = new Map();
|
|
11682
13838
|
// #2757 — the same, for webhooks.
|
|
11683
13839
|
const localWebhooksParsed = new Map();
|
|
13840
|
+
// #2880 — the same, for integrations, cron triggers and blob buckets.
|
|
13841
|
+
const localIntegrationsParsed = new Map();
|
|
13842
|
+
const localCronTriggersParsed = new Map();
|
|
13843
|
+
const localBlobBucketsParsed = new Map();
|
|
11684
13844
|
// #2748 — the same, for prompts.
|
|
11685
13845
|
const localPromptsParsed = new Map();
|
|
11686
13846
|
// Parsed local TOML per key for the five content-aware config types.
|
|
@@ -11695,6 +13855,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11695
13855
|
const tomlData = parseTomlFile(join(integrationsDir, file));
|
|
11696
13856
|
const key = tomlData.integration?.key || basename(file, ".toml");
|
|
11697
13857
|
localIntegrations.add(key);
|
|
13858
|
+
// #2880 — the parsed file is kept so the row can be COMPARED. The
|
|
13859
|
+
// resolved key is injected back the way webhooks and prompts inject
|
|
13860
|
+
// theirs: `[integration].key` is optional (push falls back to the
|
|
13861
|
+
// file name) and the comparison reads it as `displayName`'s
|
|
13862
|
+
// fallback, so a file that omits it would otherwise report Modified
|
|
13863
|
+
// forever — even right after a successful push.
|
|
13864
|
+
if (!tomlData.integration)
|
|
13865
|
+
tomlData.integration = {};
|
|
13866
|
+
tomlData.integration.key = key;
|
|
13867
|
+
localIntegrationsParsed.set(key, tomlData);
|
|
11698
13868
|
}
|
|
11699
13869
|
}
|
|
11700
13870
|
const webhooksDirPath = join(configDir, "webhooks");
|
|
@@ -11721,6 +13891,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11721
13891
|
const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
|
|
11722
13892
|
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
11723
13893
|
localCronTriggers.add(key);
|
|
13894
|
+
// #2880 — kept parsed so the row can be COMPARED, with the resolved
|
|
13895
|
+
// key injected back the way every other type injects its identity.
|
|
13896
|
+
if (!tomlData.cronTrigger)
|
|
13897
|
+
tomlData.cronTrigger = {};
|
|
13898
|
+
tomlData.cronTrigger.key = key;
|
|
13899
|
+
localCronTriggersParsed.set(key, tomlData);
|
|
11724
13900
|
}
|
|
11725
13901
|
}
|
|
11726
13902
|
const blobBucketsDiffDir = join(configDir, "blob-buckets");
|
|
@@ -11729,6 +13905,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11729
13905
|
const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
|
|
11730
13906
|
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
11731
13907
|
localBlobBuckets.add(key);
|
|
13908
|
+
// #2880 — as above.
|
|
13909
|
+
if (!tomlData.bucket)
|
|
13910
|
+
tomlData.bucket = {};
|
|
13911
|
+
tomlData.bucket.key = key;
|
|
13912
|
+
localBlobBucketsParsed.set(key, tomlData);
|
|
11732
13913
|
}
|
|
11733
13914
|
}
|
|
11734
13915
|
const promptsDir = join(configDir, "prompts");
|
|
@@ -11923,22 +14104,57 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11923
14104
|
// left alone. Prior sync state is the discriminator, mirroring the
|
|
11924
14105
|
// local-only split above.
|
|
11925
14106
|
const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
|
|
11926
|
-
// Integrations
|
|
14107
|
+
// Integrations — #2880: content-aware, mirroring the webhook block.
|
|
14108
|
+
//
|
|
14109
|
+
// Existence-only comparison meant an edited `accessRule` or upstream
|
|
14110
|
+
// `[requestConfig]` read as Synced while `config push` applied it from
|
|
14111
|
+
// the file's byte hash. The detail is fetched per integration because
|
|
14112
|
+
// `accessRule` and `requestConfig` are on the detail only, which is
|
|
14113
|
+
// also what `config pull` writes from; a per-integration fetch failure
|
|
14114
|
+
// degrades that ONE row to "content not compared".
|
|
14115
|
+
const integrationSpec = configDiffSpec("integration");
|
|
14116
|
+
// Integrations carry no rule-set reference, so the resolution maps the
|
|
14117
|
+
// shared projection takes are empty for this type.
|
|
14118
|
+
const integrationDiffMaps = {
|
|
14119
|
+
ruleSetIdToName: new Map(),
|
|
14120
|
+
ruleSetNameToId: new Map(),
|
|
14121
|
+
};
|
|
11927
14122
|
const remoteIntegrationRecords = new Map(integrationItems.map((i) => [i.integrationKey, i]));
|
|
11928
14123
|
for (const key of localIntegrations) {
|
|
11929
14124
|
if (!remoteIntegrations.has(key)) {
|
|
11930
14125
|
differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
|
|
14126
|
+
continue;
|
|
14127
|
+
}
|
|
14128
|
+
let status = "exists";
|
|
14129
|
+
let hint;
|
|
14130
|
+
const localParsed = localIntegrationsParsed.get(key);
|
|
14131
|
+
const integrationId = remoteIntegrationRecords.get(key)?.integrationId;
|
|
14132
|
+
const detailOutcome = await attemptFetch(async () => integrationId
|
|
14133
|
+
? await client.getIntegration(resolvedAppId, integrationId)
|
|
14134
|
+
: null);
|
|
14135
|
+
if (!detailOutcome.ok || !detailOutcome.value || !localParsed) {
|
|
14136
|
+
hint = "content not compared (fetch failed)";
|
|
11931
14137
|
}
|
|
11932
14138
|
else {
|
|
11933
|
-
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
}
|
|
14139
|
+
try {
|
|
14140
|
+
const remoteHash = hashRemoteConfigForDiff(integrationSpec, detailOutcome.value, integrationDiffMaps);
|
|
14141
|
+
const localHash = hashLocalConfigForDiff(integrationSpec, localParsed, integrationDiffMaps);
|
|
14142
|
+
if (remoteHash !== localHash) {
|
|
14143
|
+
status = "modified";
|
|
14144
|
+
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
14145
|
+
}
|
|
14146
|
+
}
|
|
14147
|
+
catch {
|
|
14148
|
+
hint = "content not compared (comparison failed)";
|
|
14149
|
+
}
|
|
11941
14150
|
}
|
|
14151
|
+
// #2803 — availability is operator state, not drift, and it is the
|
|
14152
|
+
// same annotation every carrying type gets, appended to whatever the
|
|
14153
|
+
// content comparison already had to say.
|
|
14154
|
+
hint = outOfServiceHint(detailOutcome.ok && detailOutcome.value
|
|
14155
|
+
? detailOutcome.value
|
|
14156
|
+
: remoteIntegrationRecords.get(key), hint);
|
|
14157
|
+
differences.push({ type: "integration", key, status, hint });
|
|
11942
14158
|
}
|
|
11943
14159
|
for (const key of remoteIntegrations) {
|
|
11944
14160
|
if (!localIntegrations.has(key)) {
|
|
@@ -11980,17 +14196,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11980
14196
|
hint = "content not compared (fetch failed)";
|
|
11981
14197
|
}
|
|
11982
14198
|
else {
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
|
|
11986
|
-
|
|
11987
|
-
|
|
11988
|
-
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
11989
|
-
}
|
|
11990
|
-
}
|
|
11991
|
-
catch {
|
|
11992
|
-
hint = "content not compared (comparison failed)";
|
|
11993
|
-
}
|
|
14199
|
+
// Through the shared verdict, so the file's unmanaged fields are
|
|
14200
|
+
// read here exactly as the push gate reads them (#2880).
|
|
14201
|
+
const verdict = compareLocalToRemote(webhookSpec, localParsed, detailOutcome.value, webhookDiffMaps);
|
|
14202
|
+
status = verdict.status;
|
|
14203
|
+
hint = verdict.hint;
|
|
11994
14204
|
}
|
|
11995
14205
|
// #2803 — a paused/archived webhook is operator state, appended to
|
|
11996
14206
|
// whatever the content comparison already had to say.
|
|
@@ -12002,35 +14212,43 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12002
14212
|
differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
|
|
12003
14213
|
}
|
|
12004
14214
|
}
|
|
12005
|
-
// Cron Triggers
|
|
14215
|
+
// Cron Triggers — #2880: content-aware. The LIST row is the whole
|
|
14216
|
+
// record for this type (it is what `config pull` serializes from), so
|
|
14217
|
+
// no second fetch is needed; a comparison that throws degrades that ONE
|
|
14218
|
+
// row rather than the diff.
|
|
14219
|
+
const cronSpec = configDiffSpec("cron-trigger");
|
|
12006
14220
|
const remoteCronTriggerRecords = new Map(cronTriggerItemsDiff.map((t) => [t.triggerKey, t]));
|
|
12007
14221
|
for (const key of localCronTriggers) {
|
|
12008
14222
|
if (!remoteCronTriggers.has(key)) {
|
|
12009
14223
|
differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
|
|
14224
|
+
continue;
|
|
12010
14225
|
}
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12014
|
-
|
|
12015
|
-
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
}
|
|
14226
|
+
const { status, hint } = compareLocalToRemote(cronSpec, localCronTriggersParsed.get(key), remoteCronTriggerRecords.get(key));
|
|
14227
|
+
differences.push({
|
|
14228
|
+
type: "cron-trigger",
|
|
14229
|
+
key,
|
|
14230
|
+
status,
|
|
14231
|
+
// #2645 criterion 9 — a paused trigger is operator state, not
|
|
14232
|
+
// drift, appended to whatever the content comparison had to say.
|
|
14233
|
+
hint: outOfServiceHint(remoteCronTriggerRecords.get(key), hint),
|
|
14234
|
+
});
|
|
12020
14235
|
}
|
|
12021
14236
|
for (const key of remoteCronTriggers) {
|
|
12022
14237
|
if (!localCronTriggers.has(key)) {
|
|
12023
14238
|
differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
|
|
12024
14239
|
}
|
|
12025
14240
|
}
|
|
12026
|
-
// Blob Buckets
|
|
14241
|
+
// Blob Buckets — #2880: the same, and the list row is the record here
|
|
14242
|
+
// too.
|
|
14243
|
+
const blobBucketSpec = configDiffSpec("blob-bucket");
|
|
14244
|
+
const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
|
|
12027
14245
|
for (const key of localBlobBuckets) {
|
|
12028
14246
|
if (!remoteBlobBuckets.has(key)) {
|
|
12029
14247
|
differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
|
|
14248
|
+
continue;
|
|
12030
14249
|
}
|
|
12031
|
-
|
|
12032
|
-
|
|
12033
|
-
}
|
|
14250
|
+
const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
|
|
14251
|
+
differences.push({ type: "blob-bucket", key, status, hint });
|
|
12034
14252
|
}
|
|
12035
14253
|
for (const key of remoteBlobBuckets) {
|
|
12036
14254
|
if (!localBlobBuckets.has(key)) {
|
|
@@ -12048,6 +14266,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12048
14266
|
// fetches the detail `config pull` writes from. A failed fetch degrades
|
|
12049
14267
|
// that ONE row to "content not compared", never the whole diff.
|
|
12050
14268
|
const promptSpec = configDiffSpec("prompt");
|
|
14269
|
+
/**
|
|
14270
|
+
* The id→name lookups a test case's references are read through
|
|
14271
|
+
* (#2880). Filled from the prompt details this loop already fetches —
|
|
14272
|
+
* a second listing per block would double the diff's request count to
|
|
14273
|
+
* learn what is already on the wire.
|
|
14274
|
+
*/
|
|
14275
|
+
const diffTestCaseLookupMaps = {
|
|
14276
|
+
configIdToName: new Map(),
|
|
14277
|
+
promptIdToKey: new Map(),
|
|
14278
|
+
};
|
|
14279
|
+
for (const p of promptItems) {
|
|
14280
|
+
const key = p?.promptKey;
|
|
14281
|
+
const id = p?.promptId;
|
|
14282
|
+
if (key && id)
|
|
14283
|
+
diffTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
14284
|
+
}
|
|
12051
14285
|
// Prompts carry no rule-set reference, so the resolution maps the shared
|
|
12052
14286
|
// projection takes are empty for this type.
|
|
12053
14287
|
const promptDiffMaps = {
|
|
@@ -12069,6 +14303,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12069
14303
|
hint = "content not compared (fetch failed)";
|
|
12070
14304
|
}
|
|
12071
14305
|
else {
|
|
14306
|
+
// #2880 — the prompt's configs, for the test-case references that
|
|
14307
|
+
// name them.
|
|
14308
|
+
for (const config of detailOutcome.value.configs ?? []) {
|
|
14309
|
+
if (config?.configId && config?.configName) {
|
|
14310
|
+
diffTestCaseLookupMaps.configIdToName.set(config.configId, config.configName);
|
|
14311
|
+
}
|
|
14312
|
+
}
|
|
12072
14313
|
try {
|
|
12073
14314
|
const remoteHash = hashRemoteConfigForDiff(promptSpec, detailOutcome.value, promptDiffMaps);
|
|
12074
14315
|
const localHash = hashLocalConfigForDiff(promptSpec, localParsed, promptDiffMaps);
|
|
@@ -12440,6 +14681,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12440
14681
|
blockId,
|
|
12441
14682
|
blockKey,
|
|
12442
14683
|
configDir,
|
|
14684
|
+
// #2880 — identity is the manifest id, and the attachment plan
|
|
14685
|
+
// reads the hashes the manifest recorded.
|
|
14686
|
+
syncState,
|
|
14687
|
+
lookupMaps: diffTestCaseLookupMaps,
|
|
12443
14688
|
});
|
|
12444
14689
|
if (!outcome.ok) {
|
|
12445
14690
|
// A block whose listing failed is REPORTED, not skipped in silence.
|
|
@@ -12482,6 +14727,96 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12482
14727
|
await compareTestCases("integration", i.integrationId, key);
|
|
12483
14728
|
}
|
|
12484
14729
|
}
|
|
14730
|
+
// App settings — #2880 behavior 9, subsuming #2875.
|
|
14731
|
+
//
|
|
14732
|
+
// The whole-tree diff used not to compare `app.toml` AT ALL, so an
|
|
14733
|
+
// edited file reported every count as zero: "nothing to push" printed
|
|
14734
|
+
// over a push that would change the running app. `--only app` had the
|
|
14735
|
+
// per-field comparison the whole time; this is that same helper, on the
|
|
14736
|
+
// command an operator actually runs to ask whether anything differs.
|
|
14737
|
+
//
|
|
14738
|
+
// Compared over the FULL writable descriptor set, because push applies
|
|
14739
|
+
// the file as the whole truth (criterion 4): a key the file omits is
|
|
14740
|
+
// the clear push will send, not an absence of opinion. The settings are
|
|
14741
|
+
// fetched only when there is a local file to compare and the selection
|
|
14742
|
+
// asks for it, so an unselected `app.toml` costs no request.
|
|
14743
|
+
const appTomlPathForDiff = join(configDir, "app.toml");
|
|
14744
|
+
if (existsSync(appTomlPathForDiff) &&
|
|
14745
|
+
selectionIncludesApp(diffSelection)) {
|
|
14746
|
+
const appRow = (status, hint) => differences.push({
|
|
14747
|
+
type: "app-settings",
|
|
14748
|
+
key: "app.toml",
|
|
14749
|
+
status,
|
|
14750
|
+
hint,
|
|
14751
|
+
});
|
|
14752
|
+
let appTomlData;
|
|
14753
|
+
try {
|
|
14754
|
+
appTomlData = parseTomlFile(appTomlPathForDiff);
|
|
14755
|
+
}
|
|
14756
|
+
catch (err) {
|
|
14757
|
+
appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
|
|
14758
|
+
}
|
|
14759
|
+
if (appTomlData) {
|
|
14760
|
+
const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData);
|
|
14761
|
+
// The SAME validator push's preflight runs (criterion 1): a
|
|
14762
|
+
// declared-type mismatch, an unrecognized or retired key, and a
|
|
14763
|
+
// missing required key are reported here with push's own message
|
|
14764
|
+
// rather than compared past and called Synced.
|
|
14765
|
+
const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, { excludeName });
|
|
14766
|
+
if (appErrors.length > 0) {
|
|
14767
|
+
appRow(VALIDATION_ERROR, appErrors.join("; "));
|
|
14768
|
+
}
|
|
14769
|
+
else {
|
|
14770
|
+
const settingsOutcome = await attemptFetch(() => client.getAppSettings(resolvedAppId));
|
|
14771
|
+
if (!settingsOutcome.ok) {
|
|
14772
|
+
// #1485's rule, for this type: a failed read is not "matches".
|
|
14773
|
+
notCompared.push({
|
|
14774
|
+
type: "app-settings",
|
|
14775
|
+
reason: gateReason(settingsOutcome.error),
|
|
14776
|
+
});
|
|
14777
|
+
}
|
|
14778
|
+
else {
|
|
14779
|
+
const fieldDiffs = appSettingsFieldDiffs(localAppSettings, settingsOutcome.value ?? {}, { excludeName });
|
|
14780
|
+
if (fieldDiffs.length === 0) {
|
|
14781
|
+
appRow("exists");
|
|
14782
|
+
}
|
|
14783
|
+
else {
|
|
14784
|
+
appRow("modified", `${fieldDiffs.length} field(s) differ: ` +
|
|
14785
|
+
fieldDiffs
|
|
14786
|
+
.map((d) => `[${d.section}].${d.key}`)
|
|
14787
|
+
.join(", ") +
|
|
14788
|
+
" — `config diff --only app` shows the values");
|
|
14789
|
+
}
|
|
14790
|
+
}
|
|
14791
|
+
}
|
|
14792
|
+
}
|
|
14793
|
+
}
|
|
14794
|
+
// #2880 criterion 1 (symmetry) — a file `config push` would refuse is
|
|
14795
|
+
// not a file this command calls Synced.
|
|
14796
|
+
//
|
|
14797
|
+
// The comparison above can only report a difference it can SEE, and a
|
|
14798
|
+
// local key with no server counterpart is invisible to it: the
|
|
14799
|
+
// reporter's prompts still carrying the `[prompt].status` #2803 retired
|
|
14800
|
+
// compared clean field for field while push refused the whole tree. So
|
|
14801
|
+
// the same preflight push runs decides these rows, and its message —
|
|
14802
|
+
// verbatim, so the two commands read identically — replaces whatever
|
|
14803
|
+
// the comparison had to say about the file.
|
|
14804
|
+
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
14805
|
+
const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
|
|
14806
|
+
const hint = rejected.messages.join("; ");
|
|
14807
|
+
if (row) {
|
|
14808
|
+
row.status = VALIDATION_ERROR;
|
|
14809
|
+
row.hint = hint;
|
|
14810
|
+
}
|
|
14811
|
+
else {
|
|
14812
|
+
differences.push({
|
|
14813
|
+
type: rejected.type,
|
|
14814
|
+
key: rejected.key,
|
|
14815
|
+
status: VALIDATION_ERROR,
|
|
14816
|
+
hint,
|
|
14817
|
+
});
|
|
14818
|
+
}
|
|
14819
|
+
}
|
|
12485
14820
|
// `--only` (issue #2645) reports just the objects the operator named.
|
|
12486
14821
|
// The rows are filtered rather than the fetches: every leg keeps its own
|
|
12487
14822
|
// failure handling, so a type whose listing failed is still reported as
|
|
@@ -12489,7 +14824,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12489
14824
|
if (diffSelection) {
|
|
12490
14825
|
differences = differences.filter((row) => row.type === "var"
|
|
12491
14826
|
? diffSelection.varKeys.has(row.key)
|
|
12492
|
-
:
|
|
14827
|
+
: // #2880 — `app.toml` is a root singleton, outside
|
|
14828
|
+
// `SYNC_RESOURCE_TYPES`, so it has its own selection predicate
|
|
14829
|
+
// (the row is only built when this is true, but the filter is
|
|
14830
|
+
// what makes that visible where selection is decided).
|
|
14831
|
+
row.type === "app-settings"
|
|
14832
|
+
? selectionIncludesApp(diffSelection)
|
|
14833
|
+
: selectionIncludesKey(diffSelection, row.type, row.key));
|
|
12493
14834
|
}
|
|
12494
14835
|
divider();
|
|
12495
14836
|
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
@@ -12501,6 +14842,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12501
14842
|
const remoteOnlyUnmanaged = differences.filter((d) => d.status === REMOTE_ONLY_UNMANAGED);
|
|
12502
14843
|
const modified = differences.filter((d) => d.status === "modified");
|
|
12503
14844
|
const existing = differences.filter((d) => d.status === "exists");
|
|
14845
|
+
// #2880 — files push would refuse. Reported before anything else: no
|
|
14846
|
+
// other row's status is trustworthy while a file in the tree cannot be
|
|
14847
|
+
// pushed at all.
|
|
14848
|
+
const invalid = differences.filter((d) => d.status === VALIDATION_ERROR);
|
|
14849
|
+
// #2880 — the same messages `config push` aborts with, so a clean diff
|
|
14850
|
+
// can be read as "nothing to push" and a dirty one names the fix.
|
|
14851
|
+
if (invalid.length > 0) {
|
|
14852
|
+
warn("Invalid — `config push` will refuse these (not compared):");
|
|
14853
|
+
for (const d of invalid) {
|
|
14854
|
+
console.log(` ${chalk.red("✗")} ${d.type}: ${d.key}`);
|
|
14855
|
+
for (const message of (d.hint ?? "").split("; ")) {
|
|
14856
|
+
if (message)
|
|
14857
|
+
console.log(` ${message}`);
|
|
14858
|
+
}
|
|
14859
|
+
}
|
|
14860
|
+
console.log();
|
|
14861
|
+
}
|
|
12504
14862
|
if (localOnly.length > 0) {
|
|
12505
14863
|
info("Local only — new (will be created on push):");
|
|
12506
14864
|
for (const d of localOnly) {
|
|
@@ -12571,17 +14929,35 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12571
14929
|
const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
12572
14930
|
const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
|
|
12573
14931
|
const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
|
|
12574
|
-
|
|
14932
|
+
// #2880 — sidecars whose CONTENT differs, and sidecars push would
|
|
14933
|
+
// refuse. Both used to read as "= test-case: …", because the comparison
|
|
14934
|
+
// stopped at whether the slug existed on both sides.
|
|
14935
|
+
const tcModified = testCaseDiffs.filter((d) => d.status === "modified");
|
|
14936
|
+
const tcInvalid = testCaseDiffs.filter((d) => d.status === VALIDATION_ERROR);
|
|
14937
|
+
if (tcLocalOnly.length > 0 ||
|
|
14938
|
+
tcRemoteOnly.length > 0 ||
|
|
14939
|
+
tcModified.length > 0 ||
|
|
14940
|
+
tcInvalid.length > 0) {
|
|
12575
14941
|
console.log();
|
|
12576
14942
|
info("Test Cases:");
|
|
14943
|
+
for (const d of tcInvalid) {
|
|
14944
|
+
console.log(` ${chalk.red("✗")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
14945
|
+
if (d.hint)
|
|
14946
|
+
console.log(` ${d.hint}`);
|
|
14947
|
+
}
|
|
12577
14948
|
for (const d of tcLocalOnly) {
|
|
12578
14949
|
console.log(` ${chalk.green("+")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
12579
14950
|
}
|
|
12580
14951
|
for (const d of tcRemoteOnly) {
|
|
12581
14952
|
console.log(` ${chalk.red("-")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
12582
14953
|
}
|
|
14954
|
+
for (const d of tcModified) {
|
|
14955
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
14956
|
+
console.log(` ${chalk.yellow("~")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
14957
|
+
}
|
|
12583
14958
|
for (const d of tcSynced) {
|
|
12584
|
-
|
|
14959
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
14960
|
+
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
12585
14961
|
}
|
|
12586
14962
|
}
|
|
12587
14963
|
divider();
|
|
@@ -12591,13 +14967,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12591
14967
|
keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
|
|
12592
14968
|
keyValue("Modified", modified.length);
|
|
12593
14969
|
keyValue("Synced", existing.length);
|
|
14970
|
+
if (invalid.length > 0) {
|
|
14971
|
+
keyValue("Invalid (push would refuse)", invalid.length);
|
|
14972
|
+
}
|
|
12594
14973
|
if (notCompared.length > 0) {
|
|
12595
14974
|
keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
|
|
12596
14975
|
}
|
|
12597
14976
|
if (testCaseDiffs.length > 0) {
|
|
12598
14977
|
keyValue("Test Cases (local only)", tcLocalOnly.length);
|
|
12599
14978
|
keyValue("Test Cases (remote only)", tcRemoteOnly.length);
|
|
14979
|
+
keyValue("Test Cases (modified)", tcModified.length);
|
|
12600
14980
|
keyValue("Test Cases (synced)", tcSynced.length);
|
|
14981
|
+
if (tcInvalid.length > 0) {
|
|
14982
|
+
keyValue("Test Cases (push would refuse)", tcInvalid.length);
|
|
14983
|
+
}
|
|
12601
14984
|
}
|
|
12602
14985
|
}
|
|
12603
14986
|
catch (err) {
|