primitive-admin 1.1.0-alpha.64 → 1.1.0-alpha.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/assets/skill/skills/primitive-platform/SKILL.md +83 -13
- package/dist/bin/primitive.js +6 -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 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +25 -28
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/env.d.ts +12 -1
- package/dist/src/commands/env.js +96 -21
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/feature-flags.d.ts +14 -0
- package/dist/src/commands/feature-flags.js +116 -0
- package/dist/src/commands/feature-flags.js.map +1 -0
- package/dist/src/commands/init.d.ts +1 -1
- package/dist/src/commands/init.js +78 -25
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +55 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +58 -3
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -1
- package/dist/src/commands/rule-sets.js +1 -0
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/sync-app-settings.d.ts +83 -55
- package/dist/src/commands/sync-app-settings.js +131 -200
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +309 -40
- package/dist/src/commands/sync.js +2493 -397
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/users.js +92 -1
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/webhooks.js +59 -2
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +32 -22
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +51 -4
- package/dist/src/lib/api-client.js +71 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +143 -15
- package/dist/src/lib/app-settings-descriptor.js +315 -28
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +6 -14
- 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 +38 -1
- package/dist/src/lib/config-surface.js +123 -1
- 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 +66 -0
- package/dist/src/lib/generated-config-surfaces.js +391 -104
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- 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/log-inspection.d.ts +2 -2
- package/dist/src/lib/log-inspection.js +3 -3
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/object-status-filter.d.ts +22 -0
- package/dist/src/lib/object-status-filter.js +45 -0
- package/dist/src/lib/object-status-filter.js.map +1 -0
- 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/swift-codegen/generator.js +2 -4
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +8 -0
- package/dist/src/lib/sync-resource-types.js +21 -2
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/template.d.ts +27 -0
- package/dist/src/lib/template.js +46 -20
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/workflow-defaults.d.ts +1 -2
- package/dist/src/lib/workflow-defaults.js +3 -3
- package/dist/src/lib/workflow-defaults.js.map +1 -1
- package/dist/src/lib/workflow-payload.js +0 -6
- package/dist/src/lib/workflow-payload.js.map +1 -1
- package/dist/src/types/index.d.ts +32 -7
- package/package.json +2 -2
|
@@ -14,10 +14,12 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
|
|
|
14
14
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
15
15
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
16
16
|
import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
|
|
17
|
+
import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request-config.js";
|
|
18
|
+
import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
|
|
17
19
|
import { canonicalJsonStringify } from "../lib/canonical-json.js";
|
|
18
20
|
import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
|
|
19
|
-
import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
|
|
20
|
-
import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, } from "./sync-app-settings.js";
|
|
21
|
+
import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
|
|
22
|
+
import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
|
|
21
23
|
import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
|
|
22
24
|
// #2645 — named config bodies live in `workflows/<key>.configs/<name>.toml`.
|
|
23
25
|
import { workflowConfigsDir, serializeWorkflowConfigSidecar, parseWorkflowConfigSidecar, readWorkflowConfigSidecars, } from "../lib/workflow-config-sidecar.js";
|
|
@@ -27,11 +29,11 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
|
|
|
27
29
|
// payload builder so the diff normalizer and the wire cannot disagree.
|
|
28
30
|
import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
|
|
29
31
|
// #2644 — the one definition per configuration object, vendored from the server.
|
|
30
|
-
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
32
|
+
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
31
33
|
import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
|
|
32
34
|
import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
|
|
33
35
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
34
|
-
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
36
|
+
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
35
37
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
36
38
|
import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
|
|
37
39
|
import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
|
|
@@ -151,7 +153,7 @@ export function shouldPushExpandedFile(parsed, storedHash) {
|
|
|
151
153
|
* value that pushes successfully hashes equal to its read-back — see
|
|
152
154
|
* `normalizeWorkflowTomlDefaults`.
|
|
153
155
|
*/
|
|
154
|
-
const WORKFLOW_LOWERCASED_FIELDS = ["
|
|
156
|
+
const WORKFLOW_LOWERCASED_FIELDS = ["dequeueOrder"];
|
|
155
157
|
const WORKFLOW_FLOORED_FIELDS = [
|
|
156
158
|
"perUserMaxRunning",
|
|
157
159
|
"perUserMaxQueued",
|
|
@@ -221,15 +223,15 @@ export function resolveActiveWorkflowConfig(workflow, configs) {
|
|
|
221
223
|
* server's accepted-value canonicalization
|
|
222
224
|
* (`src/workflows/config/workflow-field-handlers.ts`). A value the server
|
|
223
225
|
* would REJECT is left alone: push fails loudly for those, so there is
|
|
224
|
-
* nothing to preview. #2743 — without this, `
|
|
226
|
+
* nothing to preview. #2743 — without this, `dequeueOrder = "LIFO"` or
|
|
225
227
|
* `perUserMaxRunning = 4.9` pushes fine and then reports Modified forever.
|
|
226
228
|
* 4. Fill the model defaults (`WORKFLOW_MODEL_DEFAULTS`) for what the file
|
|
227
229
|
* omits. `perAppMax*` / `queueTtlSeconds` joined that list in #1177: once
|
|
228
230
|
* the pull serializer emitted them (a GET always returns them, they carry
|
|
229
231
|
* a non-null model default), an unchanged workflow hashed unequal to a
|
|
230
232
|
* local TOML that omits them — a false `modified` that made `config pull`
|
|
231
|
-
* rewrite the file just to inject defaults. `status`
|
|
232
|
-
*
|
|
233
|
+
* rewrite the file just to inject defaults. (`status` was on this list
|
|
234
|
+
* until #2803 took availability off the config wire entirely.)
|
|
233
235
|
* 5. Fill `activeConfigName` with the name the server itself creates
|
|
234
236
|
* (`default`), so a hand-authored file that never named a config compares
|
|
235
237
|
* equal to the workflow that pushing it produces (#2743). This is the
|
|
@@ -1016,7 +1018,7 @@ export function workflowActiveConfigNameUnresolvable(configDir, key, localParsed
|
|
|
1016
1018
|
* repeats forever because no push changes it. But the repo does not describe
|
|
1017
1019
|
* which configuration is live, and only `config pull` records it, so the fact is
|
|
1018
1020
|
* carried as a hint on the Synced row — the same way an operational disable is
|
|
1019
|
-
* (`
|
|
1021
|
+
* (`outOfServiceHint`). The server-created `default` is the shape a
|
|
1020
1022
|
* hand-authored file already means, so it is silent.
|
|
1021
1023
|
*
|
|
1022
1024
|
* Pure and non-throwing, like every other diff predicate. Returns the existing
|
|
@@ -1032,16 +1034,70 @@ export function unmanagedActiveConfigHint(localParsed, workflow, configs, existi
|
|
|
1032
1034
|
`it alone — run \`config pull\` to record it in [workflow].activeConfigName`;
|
|
1033
1035
|
return existingHint ? `${existingHint}; ${note}` : note;
|
|
1034
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Drop the archived rows from a pull listing (#2803).
|
|
1039
|
+
*
|
|
1040
|
+
* A tombstone is retired, not configuration. Exporting one wrote a file the
|
|
1041
|
+
* repo then claimed to describe — and, once `status` left the TOML surface, a
|
|
1042
|
+
* file whose very next push would fail on the `status` line it carried.
|
|
1043
|
+
* `config push --prune` is the path that reclaims an archived row; pull's job
|
|
1044
|
+
* is to describe what is configured.
|
|
1045
|
+
*/
|
|
1046
|
+
export function skipArchivedForPull(items) {
|
|
1047
|
+
return (items || []).filter((item) => String(item?.status ?? "").toLowerCase() !== "archived");
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* The sync-state entries a pull keeps for the archived rows it did NOT export
|
|
1051
|
+
* (#2803).
|
|
1052
|
+
*
|
|
1053
|
+
* Pull removes a tombstone's file, and prune candidates come only from prior
|
|
1054
|
+
* sync state — so dropping the state entry with the file would strand the row:
|
|
1055
|
+
* no file to delete, no managed key to prune, and (for webhooks and cron
|
|
1056
|
+
* triggers) a cap slot and a reserved key nothing could ever reclaim, which is
|
|
1057
|
+
* the exact failure #2232 fixed. So the pull records the tombstone instead: no
|
|
1058
|
+
* file, but a managed entry carrying the archived row's CURRENT `modifiedAt`,
|
|
1059
|
+
* so the next opt-in, confirmed `config push --prune` point-reads it, finds it
|
|
1060
|
+
* unchanged, and hard-deletes it — the reclamation path the model promises.
|
|
1061
|
+
*
|
|
1062
|
+
* Only keys the config tree already managed are recorded. A row archived
|
|
1063
|
+
* server-side that this repo never described is not this repo's to delete.
|
|
1064
|
+
*
|
|
1065
|
+
* Pure, so it is unit-testable without a live server.
|
|
1066
|
+
*/
|
|
1067
|
+
export function archivedTombstoneEntries(items, select, managedKeys) {
|
|
1068
|
+
const managed = new Set(managedKeys);
|
|
1069
|
+
const entries = {};
|
|
1070
|
+
for (const item of items || []) {
|
|
1071
|
+
if (String(item?.status ?? "").toLowerCase() !== "archived")
|
|
1072
|
+
continue;
|
|
1073
|
+
const key = select.keyOf(item);
|
|
1074
|
+
const id = select.idOf(item);
|
|
1075
|
+
if (!key || !id || !managed.has(key))
|
|
1076
|
+
continue;
|
|
1077
|
+
entries[key] = { id, modifiedAt: item.modifiedAt || "" };
|
|
1078
|
+
}
|
|
1079
|
+
return entries;
|
|
1080
|
+
}
|
|
1035
1081
|
/**
|
|
1036
1082
|
* The `config diff` annotation for an object an operator took out of service
|
|
1037
|
-
* (issue #2645, criterion 9).
|
|
1083
|
+
* (issue #2645, criterion 9; #2803).
|
|
1084
|
+
*
|
|
1085
|
+
* Availability is deliberately not a TOML key, so an object that is out of
|
|
1086
|
+
* service is NOT drift — the committed configuration still describes it
|
|
1087
|
+
* exactly. Saying so out loud is the point: the reason the control exists is
|
|
1088
|
+
* that an out-of-band write used to make `config diff` report a difference
|
|
1089
|
+
* nobody intentionally created (#1976). Reporting it as a hint on an
|
|
1090
|
+
* otherwise-`Synced` row is what keeps that promise visible instead of merely
|
|
1091
|
+
* true.
|
|
1092
|
+
*
|
|
1093
|
+
* Reads either spelling: the canonical `status: "inactive"` for a type that has
|
|
1094
|
+
* cut over to #2803's single control, and the superseded
|
|
1095
|
+
* `operationallyDisabled` boolean for one that has not yet.
|
|
1038
1096
|
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
* created (#1976). Reporting it as a hint on an otherwise-`Synced` row is what
|
|
1044
|
-
* keeps that promise visible instead of merely true.
|
|
1097
|
+
* An ARCHIVED row gets its own annotation. `config pull` no longer exports
|
|
1098
|
+
* tombstones, so without one an archived webhook or integration whose file is
|
|
1099
|
+
* still in the tree read as plain `Synced` — the one status where "the server
|
|
1100
|
+
* has exactly what the file says" is most misleading.
|
|
1045
1101
|
*
|
|
1046
1102
|
* When the row already carries a hint — a real content difference — the toggle
|
|
1047
1103
|
* is APPENDED rather than substituted: the drift is the part that needs acting
|
|
@@ -1049,12 +1105,19 @@ export function unmanagedActiveConfigHint(localParsed, workflow, configs, existi
|
|
|
1049
1105
|
*
|
|
1050
1106
|
* Pure, so it is unit-testable without a live server.
|
|
1051
1107
|
*/
|
|
1052
|
-
export function
|
|
1053
|
-
|
|
1108
|
+
export function outOfServiceHint(remote, existingHint) {
|
|
1109
|
+
const stored = String(remote?.status ?? "").toLowerCase();
|
|
1110
|
+
if (stored === "archived") {
|
|
1111
|
+
return existingHint
|
|
1112
|
+
? `${existingHint}; archived`
|
|
1113
|
+
: "archived; `config push --prune` reclaims it";
|
|
1114
|
+
}
|
|
1115
|
+
const outOfService = remote?.operationallyDisabled === true || stored === "inactive";
|
|
1116
|
+
if (!outOfService)
|
|
1054
1117
|
return existingHint;
|
|
1055
1118
|
return existingHint
|
|
1056
|
-
? `${existingHint};
|
|
1057
|
-
: "
|
|
1119
|
+
? `${existingHint}; inactive`
|
|
1120
|
+
: "inactive; configuration unchanged";
|
|
1058
1121
|
}
|
|
1059
1122
|
/**
|
|
1060
1123
|
* Serialize an integration DETAIL response to its `integrations/<key>.toml`.
|
|
@@ -1264,15 +1327,26 @@ export function buildWebhookPushPayload(key, tomlData, { mode = "update" } = {})
|
|
|
1264
1327
|
},
|
|
1265
1328
|
}),
|
|
1266
1329
|
};
|
|
1267
|
-
//
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1330
|
+
// The three JSON-carrying tables. TOML-owned like `description` above
|
|
1331
|
+
// (#1567), so on an UPDATE each is sent as value-or-`null`: sending nothing
|
|
1332
|
+
// for an absent table left the stored value in place, which the shared
|
|
1333
|
+
// comparison then reported as a difference forever — push planned the same
|
|
1334
|
+
// update on every run, the server changed nothing, and deleting a
|
|
1335
|
+
// `[inputMapping]` section could not be made to take effect. The server
|
|
1336
|
+
// stores an explicit `null` (`updates.<key> = payload.<key>`).
|
|
1337
|
+
//
|
|
1338
|
+
// A CREATE sends only what the file has: there is nothing to clear, and the
|
|
1339
|
+
// create handlers read an absent key as "no value" already.
|
|
1340
|
+
const jsonTables = [
|
|
1341
|
+
["allowedIpCidrs", tomlData?.allowedIps?.cidrs],
|
|
1342
|
+
["inputMapping", tomlData?.inputMapping],
|
|
1343
|
+
["metadata", tomlData?.metadata],
|
|
1344
|
+
];
|
|
1345
|
+
for (const [key, value] of jsonTables) {
|
|
1346
|
+
if (value)
|
|
1347
|
+
payload[key] = JSON.stringify(value);
|
|
1348
|
+
else if (mode === "update")
|
|
1349
|
+
payload[key] = null;
|
|
1276
1350
|
}
|
|
1277
1351
|
// Forward the declared `[verification.<scheme>]` table so scheme
|
|
1278
1352
|
// configuration reaches the server. Without this the table is silently
|
|
@@ -1364,12 +1438,24 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
|
|
|
1364
1438
|
},
|
|
1365
1439
|
}),
|
|
1366
1440
|
};
|
|
1367
|
-
//
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1441
|
+
// The two JSON-carrying tables, TOML-owned like `description` (#1567): an
|
|
1442
|
+
// UPDATE sends value-or-`null`, so deleting a `[rootInput]` section clears it
|
|
1443
|
+
// instead of leaving a value the shared comparison reports as a difference
|
|
1444
|
+
// on every run that push cannot clear. The controller takes an explicit
|
|
1445
|
+
// `null` as the clear and validates anything else as JSON.
|
|
1446
|
+
//
|
|
1447
|
+
// A CREATE sends only what the file has: the create leg rejects a `null` as
|
|
1448
|
+
// invalid JSON, and there is nothing to clear on a trigger that does not
|
|
1449
|
+
// exist yet.
|
|
1450
|
+
const jsonTables = [
|
|
1451
|
+
["rootInput", tomlData?.rootInput],
|
|
1452
|
+
["inputMapping", tomlData?.inputMapping],
|
|
1453
|
+
];
|
|
1454
|
+
for (const [key, value] of jsonTables) {
|
|
1455
|
+
if (value)
|
|
1456
|
+
payload[key] = JSON.stringify(value);
|
|
1457
|
+
else if (mode === "update")
|
|
1458
|
+
payload[key] = null;
|
|
1373
1459
|
}
|
|
1374
1460
|
return payload;
|
|
1375
1461
|
}
|
|
@@ -1829,23 +1915,6 @@ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log =
|
|
|
1829
1915
|
* are checked one by one and the unknown keys reported together, so a typo in
|
|
1830
1916
|
* the third `[[configs]]` block is as loud as one in the first.
|
|
1831
1917
|
*/
|
|
1832
|
-
/**
|
|
1833
|
-
* Keys deliberately RETIRED from a table's TOML surface (#2645), each with the
|
|
1834
|
-
* guidance the generic unrecognized-key hint would get wrong: "upgrade the
|
|
1835
|
-
* CLI" is exactly backwards for a key this CLI removed on purpose. Every
|
|
1836
|
-
* pre-#2645 pull wrote a `state` line into cron-trigger files, so existing
|
|
1837
|
-
* repos hit this on their first push after upgrading — the failure is
|
|
1838
|
-
* deliberate (sponsor decision at the approval gate, 2026-08-15): the line has
|
|
1839
|
-
* to be removed, not silently ignored, so the file never claims a pause state
|
|
1840
|
-
* push does not apply.
|
|
1841
|
-
*/
|
|
1842
|
-
const RETIRED_TOML_KEYS = {
|
|
1843
|
-
cronTrigger: {
|
|
1844
|
-
state: "`state` is no longer authored in TOML (#2645). Pausing is " +
|
|
1845
|
-
"`primitive cron-triggers pause|resume`, and the remaining lifecycle " +
|
|
1846
|
-
"values are server-owned. Delete the line and push again.",
|
|
1847
|
-
},
|
|
1848
|
-
};
|
|
1849
1918
|
export function configUnknownKeyError(filePath, tomlData, table) {
|
|
1850
1919
|
const at = table.tomlPath.reduce((node, segment) => (node == null ? node : node[segment]), tomlData);
|
|
1851
1920
|
const entries = table.repeated
|
|
@@ -1860,13 +1929,20 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
1860
1929
|
}
|
|
1861
1930
|
if (unknown.size === 0)
|
|
1862
1931
|
return null;
|
|
1932
|
+
// #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
|
|
1933
|
+
// once beside the definitions (`src/config-surface/retired-keys.ts`), not the
|
|
1934
|
+
// generic "check the spelling, or upgrade the CLI" hint, which is exactly
|
|
1935
|
+
// backwards for it. The server's create/update handlers read the same table.
|
|
1863
1936
|
const prefix = table.tomlPath.join(".");
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1937
|
+
const retiredHere = [...unknown]
|
|
1938
|
+
.filter((k) => retiredConfigKey(prefix, k))
|
|
1939
|
+
.sort();
|
|
1940
|
+
const trulyUnknown = [...unknown]
|
|
1941
|
+
.filter((k) => !retiredConfigKey(prefix, k))
|
|
1942
|
+
.sort();
|
|
1867
1943
|
const messages = [];
|
|
1868
1944
|
for (const key of retiredHere) {
|
|
1869
|
-
messages.push(`${filePath}: ${prefix}.${key} — ${
|
|
1945
|
+
messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
|
|
1870
1946
|
}
|
|
1871
1947
|
if (trulyUnknown.length > 0) {
|
|
1872
1948
|
messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, trulyUnknown));
|
|
@@ -1928,6 +2004,114 @@ export function unknownKeyPreflightTargets() {
|
|
|
1928
2004
|
}
|
|
1929
2005
|
return targets;
|
|
1930
2006
|
}
|
|
2007
|
+
/**
|
|
2008
|
+
* The diff row a per-entity config file belongs to (#2880 criterion 1).
|
|
2009
|
+
*
|
|
2010
|
+
* `config diff` pairs a local file with a server entity by a key each type
|
|
2011
|
+
* derives its own way — the `[table].key` the file states, its identity field,
|
|
2012
|
+
* or the file name standing in for either. The validation reporter has to land
|
|
2013
|
+
* its message on THAT row, so the derivation lives here once rather than being
|
|
2014
|
+
* inferred from the file name and quietly missing a renamed file.
|
|
2015
|
+
*/
|
|
2016
|
+
function localConfigRowKey(label, tomlData, file) {
|
|
2017
|
+
const fallback = basename(file, ".toml");
|
|
2018
|
+
const stated = (table, key) => tomlData?.[table]?.[key] || fallback;
|
|
2019
|
+
switch (label) {
|
|
2020
|
+
case "integration":
|
|
2021
|
+
return stated("integration", "key");
|
|
2022
|
+
case "webhook":
|
|
2023
|
+
return stated("webhook", "key");
|
|
2024
|
+
case "cron-trigger":
|
|
2025
|
+
return stated("cronTrigger", "key");
|
|
2026
|
+
case "blob-bucket":
|
|
2027
|
+
return stated("bucket", "key");
|
|
2028
|
+
case "prompt":
|
|
2029
|
+
return stated("prompt", "key");
|
|
2030
|
+
case "workflow":
|
|
2031
|
+
return stated("workflow", "key");
|
|
2032
|
+
case "email-template":
|
|
2033
|
+
return stated("template", "emailType");
|
|
2034
|
+
case "database-type-config":
|
|
2035
|
+
return stated("type", "databaseType");
|
|
2036
|
+
case "group-type-config":
|
|
2037
|
+
return stated("groupTypeConfig", "groupType");
|
|
2038
|
+
case "collection-type-config":
|
|
2039
|
+
return stated("collectionTypeConfig", "collectionType");
|
|
2040
|
+
case "metadata-category-config": {
|
|
2041
|
+
const section = tomlData?.metadataCategoryConfig || {};
|
|
2042
|
+
if (section.resourceType && section.category) {
|
|
2043
|
+
return `${section.resourceType}#${section.category}`;
|
|
2044
|
+
}
|
|
2045
|
+
// `resourceType.category.toml`, the filename fallback push and diff share.
|
|
2046
|
+
const dot = fallback.indexOf(".");
|
|
2047
|
+
return dot > 0
|
|
2048
|
+
? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
|
|
2049
|
+
: fallback;
|
|
2050
|
+
}
|
|
2051
|
+
// Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
|
|
2052
|
+
// still updates the same entity, so the row is the file's.
|
|
2053
|
+
default:
|
|
2054
|
+
return fallback;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Every per-entity config file whose TOML `config push` would refuse (#2880
|
|
2059
|
+
* criterion 1, symmetry).
|
|
2060
|
+
*
|
|
2061
|
+
* One collector, two commands. Push aborts on the messages before issuing a
|
|
2062
|
+
* request; `config diff` renders them as their own rows, because the failure
|
|
2063
|
+
* this closes is a file diff calls Synced and push then rejects — the state the
|
|
2064
|
+
* issue's reporter hit after #2803 retired `[prompt].status`, where a local key
|
|
2065
|
+
* with no server counterpart is invisible to a comparison and fatal to a push.
|
|
2066
|
+
*
|
|
2067
|
+
* Three checks, all definition-driven: the document's table shape, keys no
|
|
2068
|
+
* definition declares (retired keys among them), and — since #2880 — values
|
|
2069
|
+
* whose spelling is not the declared type. A file that does not parse is left
|
|
2070
|
+
* to the per-type loop that reads it, which names the parse error itself.
|
|
2071
|
+
*/
|
|
2072
|
+
export function collectConfigFileValidationErrors(configDir) {
|
|
2073
|
+
const found = [];
|
|
2074
|
+
for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
|
|
2075
|
+
const typeDir = join(configDir, dir);
|
|
2076
|
+
if (!existsSync(typeDir))
|
|
2077
|
+
continue;
|
|
2078
|
+
for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
|
|
2079
|
+
const filePath = join(typeDir, file);
|
|
2080
|
+
let tomlData;
|
|
2081
|
+
try {
|
|
2082
|
+
tomlData = parseTomlFile(filePath);
|
|
2083
|
+
}
|
|
2084
|
+
catch {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
const messages = [];
|
|
2088
|
+
// The document's own shape first: an unrecognized top-level table makes
|
|
2089
|
+
// every per-table check below read an absent table, so its silence would
|
|
2090
|
+
// be misleading.
|
|
2091
|
+
messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
|
|
2092
|
+
for (const table of tables) {
|
|
2093
|
+
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
2094
|
+
if (unknownKeyError)
|
|
2095
|
+
messages.push(unknownKeyError);
|
|
2096
|
+
}
|
|
2097
|
+
// Declared types run over EVERY table of the surface, workflows included
|
|
2098
|
+
// — `tables` above is pruned for workflows because `validateWorkflowToml`
|
|
2099
|
+
// owns their unrecognized-key message, which is a different check.
|
|
2100
|
+
for (const table of surface.tables) {
|
|
2101
|
+
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
2102
|
+
}
|
|
2103
|
+
if (messages.length === 0)
|
|
2104
|
+
continue;
|
|
2105
|
+
found.push({
|
|
2106
|
+
type: surface.label,
|
|
2107
|
+
key: localConfigRowKey(surface.label, tomlData, file),
|
|
2108
|
+
filePath,
|
|
2109
|
+
messages,
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return found;
|
|
2114
|
+
}
|
|
1931
2115
|
/**
|
|
1932
2116
|
* Every `<key>.tests/*.toml` sidecar under a config directory.
|
|
1933
2117
|
*
|
|
@@ -2096,7 +2280,7 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
|
2096
2280
|
// `key` and `activeConfigName` are authored keys that are NOT part of the
|
|
2097
2281
|
// write field surface — declared in the definition's `tomlOnlyKeys`, with the
|
|
2098
2282
|
// reason there. They keep their historical positions (first, and right after
|
|
2099
|
-
// `
|
|
2283
|
+
// `description`) so migrating to the definition does not reshuffle files.
|
|
2100
2284
|
const workflowTable = { key: workflow.workflowKey };
|
|
2101
2285
|
for (const field of WORKFLOW_TABLE.fields) {
|
|
2102
2286
|
const produce = PULL_VALUE[field.field];
|
|
@@ -2116,7 +2300,10 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
|
2116
2300
|
? undefined
|
|
2117
2301
|
: value;
|
|
2118
2302
|
}
|
|
2119
|
-
|
|
2303
|
+
// #2803 — this used to hang off `status`, which has left the field surface
|
|
2304
|
+
// (availability is server-owned). `description` is now the key it follows,
|
|
2305
|
+
// which keeps `activeConfigName` in the same place in existing files.
|
|
2306
|
+
if (field.field === "description") {
|
|
2120
2307
|
workflowTable.activeConfigName = activeConfigName;
|
|
2121
2308
|
}
|
|
2122
2309
|
}
|
|
@@ -2764,6 +2951,47 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
|
|
|
2764
2951
|
entity._unresolvedRuleSetName = referencedName;
|
|
2765
2952
|
}
|
|
2766
2953
|
}
|
|
2954
|
+
/**
|
|
2955
|
+
* A field's value as the comparison reads it — the VALUE, not its spelling
|
|
2956
|
+
* (#2869).
|
|
2957
|
+
*
|
|
2958
|
+
* Only a declared `number` the DEFINITION marks `dualEncoded` needs this
|
|
2959
|
+
* (#2880), and only because the server genuinely accepts both spellings:
|
|
2960
|
+
* `temperature` and `topP` are `StringField`s on the
|
|
2961
|
+
* model, stored as strings and returned parsed, so a file authored
|
|
2962
|
+
* `temperature = "0.2"` and a server holding `0.2` describe one value. Compared
|
|
2963
|
+
* as spellings it is a `Modified` no push can clear — push sends the string,
|
|
2964
|
+
* the server stores it, pull writes the number back, and the difference
|
|
2965
|
+
* returns — so the operator's only remedy is to overwrite the local file.
|
|
2966
|
+
*
|
|
2967
|
+
* Every OTHER declared number is strictly numeric: `maxTokens = "1024"` is not
|
|
2968
|
+
* the number the server holds, and normalizing it here would have both
|
|
2969
|
+
* commands agree on a value the author never wrote. It is a validation error
|
|
2970
|
+
* both commands report instead (`configDeclaredTypeErrors`), which is why the
|
|
2971
|
+
* carve-out is read from the definition rather than from `type === "number"`.
|
|
2972
|
+
*
|
|
2973
|
+
* A string that is not a number never reaches here: `temperature = "warm"` is
|
|
2974
|
+
* a field-named validation error from both commands (`spellsDecimalNumber` is
|
|
2975
|
+
* the grammar the declared-type validator admits a dual encoding by, so what
|
|
2976
|
+
* preflight accepts is exactly what this can equate).
|
|
2977
|
+
*
|
|
2978
|
+
* "Is a number" is decided by the grammar the SERVER reads the string with —
|
|
2979
|
+
* `parseFloat`, on the way back out — not by JavaScript's `Number`, which is
|
|
2980
|
+
* wider: `Number("0x1")` is 1 where `parseFloat("0x1")` is 0, so treating that
|
|
2981
|
+
* file as equal to a server holding 1 would skip a push whose payload means
|
|
2982
|
+
* something else, re-creating in a corner the very silence this issue is about.
|
|
2983
|
+
* So only what `parseFloat` consumes WHOLE — sign, digits, one dot, an
|
|
2984
|
+
* exponent — normalizes; anything else stays the string it is.
|
|
2985
|
+
*/
|
|
2986
|
+
function comparableFieldValue(field, value) {
|
|
2987
|
+
if (field.type !== "number" || typeof value !== "string")
|
|
2988
|
+
return value;
|
|
2989
|
+
if (field.dualEncoded === undefined)
|
|
2990
|
+
return value;
|
|
2991
|
+
if (!spellsDecimalNumber(value))
|
|
2992
|
+
return value;
|
|
2993
|
+
return Number(value.trim());
|
|
2994
|
+
}
|
|
2767
2995
|
/**
|
|
2768
2996
|
* `config diff`'s hash for one configuration object, over the field set its
|
|
2769
2997
|
* DEFINITION declares (#2644 criterion 8).
|
|
@@ -2785,7 +3013,16 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
|
|
|
2785
3013
|
export function projectConfigForComparison(table, entity, extras = {}) {
|
|
2786
3014
|
const projected = {};
|
|
2787
3015
|
for (const field of table.fields) {
|
|
2788
|
-
const
|
|
3016
|
+
const raw = entity?.[field.field];
|
|
3017
|
+
// #2880 DSO-002 — a key the file does not carry describes the value the
|
|
3018
|
+
// SERVER materializes for it, so that is what the comparison reads. Both
|
|
3019
|
+
// sides run through here, and the server side always carries the value
|
|
3020
|
+
// already, so this only ever fills the absent side in. Without it a
|
|
3021
|
+
// hand-authored file that omits an optional defaulted field reports
|
|
3022
|
+
// `Modified` on every run against a server that agrees with it.
|
|
3023
|
+
const value = comparableFieldValue(field, raw === undefined && field.serverDefault !== undefined
|
|
3024
|
+
? field.serverDefault
|
|
3025
|
+
: raw);
|
|
2789
3026
|
if (value === undefined)
|
|
2790
3027
|
continue;
|
|
2791
3028
|
// #2731 — `emit: "whenSet"` says pull OMITS the key when the value is
|
|
@@ -2938,6 +3175,25 @@ function jsonFieldsForDiff(table, entity) {
|
|
|
2938
3175
|
}
|
|
2939
3176
|
return projected;
|
|
2940
3177
|
}
|
|
3178
|
+
/**
|
|
3179
|
+
* One test-case reference as the comparison reads it (#2880 behavior 17).
|
|
3180
|
+
*
|
|
3181
|
+
* A reference reaches the projection in one of three states, and all three
|
|
3182
|
+
* describe the same thing on both sides once this is applied:
|
|
3183
|
+
*
|
|
3184
|
+
* - a portable NAME (`configName = "default"`), which a test file carries so
|
|
3185
|
+
* it survives a move between apps;
|
|
3186
|
+
* - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
|
|
3187
|
+
* pinned by id would differ forever from the file pull writes for it;
|
|
3188
|
+
* - a legacy ID that does NOT resolve, which stays the id, because pull writes
|
|
3189
|
+
* an unresolvable id straight back and comparing it away would hide a pin
|
|
3190
|
+
* the next push still sends.
|
|
3191
|
+
*/
|
|
3192
|
+
function testCaseReference(value, idToName) {
|
|
3193
|
+
if (typeof value !== "string" || value === "")
|
|
3194
|
+
return undefined;
|
|
3195
|
+
return idToName?.get(value) ?? value;
|
|
3196
|
+
}
|
|
2941
3197
|
const CONFIG_DIFF_SPECS = {
|
|
2942
3198
|
"rule-set": {
|
|
2943
3199
|
label: "rule-set",
|
|
@@ -3012,6 +3268,16 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3012
3268
|
// exactly as push derives it from the file name.
|
|
3013
3269
|
parse: (doc) => buildWebhookPushPayload(doc?.webhook?.key ?? "", doc),
|
|
3014
3270
|
serialize: (record) => serializeWebhook(record),
|
|
3271
|
+
// The text the create/update handlers rewrite on the way in (DSO-003):
|
|
3272
|
+
// `displayName` and `workflowKey` are trimmed, a blank description is
|
|
3273
|
+
// stored as none, and a signing secret is stored as the canonical
|
|
3274
|
+
// `{{secrets.KEY}}` form — so an authored `"{{ secrets.KEY }}"` is the same
|
|
3275
|
+
// reference the server returns rather than a row push re-applies forever.
|
|
3276
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3277
|
+
trimmed: ["displayName", "workflowKey"],
|
|
3278
|
+
optional: ["description"],
|
|
3279
|
+
secretReference: ["signingSecret"],
|
|
3280
|
+
}),
|
|
3015
3281
|
extras: (entity) => ({
|
|
3016
3282
|
// The four structural tables a webhook file also carries, compared as the
|
|
3017
3283
|
// values they encode rather than as their transport spellings.
|
|
@@ -3024,6 +3290,21 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3024
3290
|
// webhook that holds none.
|
|
3025
3291
|
config: entity.config ?? null,
|
|
3026
3292
|
}),
|
|
3293
|
+
// The two present-only fields, matching what `buildWebhookPushPayload`
|
|
3294
|
+
// sends: a file with no `[verification]` section does not manage the stored
|
|
3295
|
+
// scheme configuration (a bare `[verification]` table is how such a file
|
|
3296
|
+
// clears it), and a file with no `signingSecret` line does not manage the
|
|
3297
|
+
// secret reference — pushing `null` for either would revoke verification
|
|
3298
|
+
// the file never mentioned. Compared as differences they were rows push
|
|
3299
|
+
// could never clear.
|
|
3300
|
+
unmanaged: (doc) => {
|
|
3301
|
+
const keys = [];
|
|
3302
|
+
if (doc?.verification === undefined)
|
|
3303
|
+
keys.push("config");
|
|
3304
|
+
if (doc?.webhook?.signingSecret === undefined)
|
|
3305
|
+
keys.push("signingSecret");
|
|
3306
|
+
return keys;
|
|
3307
|
+
},
|
|
3027
3308
|
describe: (entity) => `webhook "${entity?.webhookKey}"`,
|
|
3028
3309
|
},
|
|
3029
3310
|
// #2748 — prompts join the registry comparator, the third type to arrive by
|
|
@@ -3076,6 +3357,148 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3076
3357
|
}),
|
|
3077
3358
|
describe: (entity) => `prompt "${entity?.displayName}"`,
|
|
3078
3359
|
},
|
|
3360
|
+
// #2880 — integrations join the registry comparator, the last of the flat
|
|
3361
|
+
// per-entity types to arrive by the route email templates (#2731 B2),
|
|
3362
|
+
// webhooks (#2757) and prompts (#2748) took: `config diff` reported key
|
|
3363
|
+
// existence only, so an edited `accessRule` or upstream `[requestConfig]`
|
|
3364
|
+
// read as Synced while push applied it from the file's byte hash.
|
|
3365
|
+
"integration": {
|
|
3366
|
+
label: "integration",
|
|
3367
|
+
table: INTEGRATION_TABLE,
|
|
3368
|
+
// The wire shape push sends. `integrationKey` is not part of the compared
|
|
3369
|
+
// surface — the two sides are paired on it before the comparison runs —
|
|
3370
|
+
// but the TOML spelling (`[integration].key`) is read here because it is
|
|
3371
|
+
// what `displayName` falls back to.
|
|
3372
|
+
parse: (doc) => buildIntegrationPushPayload(doc?.integration?.key ?? "", doc),
|
|
3373
|
+
serialize: (record) => serializeIntegration(record),
|
|
3374
|
+
// The upstream request definition is a top-level table the definition
|
|
3375
|
+
// classifies structural, so it is hashed beside the fields — as the value
|
|
3376
|
+
// the SERVER would store, not as the text the file holds (DSO-003).
|
|
3377
|
+
canonicalize: (entity) => {
|
|
3378
|
+
entity.requestConfig = canonicalizeIntegrationRequestConfig(entity.requestConfig ?? {});
|
|
3379
|
+
// The scalars the handler rewrites beside it: `displayName` is trimmed
|
|
3380
|
+
// and a blank `description` is stored as none.
|
|
3381
|
+
canonicalizeServerTextFields(entity, {
|
|
3382
|
+
trimmed: ["displayName"],
|
|
3383
|
+
optional: ["description"],
|
|
3384
|
+
});
|
|
3385
|
+
},
|
|
3386
|
+
extras: (entity) => ({ requestConfig: entity.requestConfig ?? {} }),
|
|
3387
|
+
describe: (entity) => `integration "${entity?.integrationKey}"`,
|
|
3388
|
+
},
|
|
3389
|
+
// #2880 — cron triggers, on the same route. `status` is server-owned
|
|
3390
|
+
// (#2645/#2803) and therefore absent from the table, so an operator's pause
|
|
3391
|
+
// is not drift the configuration can push away; `[rootInput]` and
|
|
3392
|
+
// `[inputMapping]` are structural tables stored as JSON strings, compared as
|
|
3393
|
+
// the values they encode.
|
|
3394
|
+
"cron-trigger": {
|
|
3395
|
+
label: "cron-trigger",
|
|
3396
|
+
table: CRON_TRIGGER_TABLE,
|
|
3397
|
+
parse: (doc) => buildCronTriggerPushPayload(doc?.cronTrigger?.key ?? "", doc),
|
|
3398
|
+
serialize: (record) => serializeCronTrigger(record),
|
|
3399
|
+
// `displayName` is trimmed on the way in and a blank description is stored
|
|
3400
|
+
// as none (DSO-003), so neither is a difference push could never settle.
|
|
3401
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3402
|
+
trimmed: ["displayName"],
|
|
3403
|
+
optional: ["description"],
|
|
3404
|
+
}),
|
|
3405
|
+
extras: (entity) => ({
|
|
3406
|
+
rootInput: jsonBlobForDiff(entity.rootInput),
|
|
3407
|
+
inputMapping: jsonBlobForDiff(entity.inputMapping),
|
|
3408
|
+
}),
|
|
3409
|
+
describe: (entity) => `cron trigger "${entity?.triggerKey}"`,
|
|
3410
|
+
},
|
|
3411
|
+
// #2880 — blob buckets. The parse is CREATE-mode on purpose: `ttlTier` is
|
|
3412
|
+
// `writableOn: ["create"]`, and a field left out of the projection cannot be
|
|
3413
|
+
// NAMED as the immutable difference it is — the update would PATCH
|
|
3414
|
+
// "successfully", change nothing, and re-report forever.
|
|
3415
|
+
"blob-bucket": {
|
|
3416
|
+
label: "blob-bucket",
|
|
3417
|
+
table: BLOB_BUCKET_TABLE,
|
|
3418
|
+
parse: (doc) => {
|
|
3419
|
+
const bucket = { ...(doc?.bucket ?? {}) };
|
|
3420
|
+
// #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
|
|
3421
|
+
// the wire and still authored in unmigrated files. Both spellings mean
|
|
3422
|
+
// the same access model, so they project the same way; without this an
|
|
3423
|
+
// unmigrated file reports a difference no push can clear (push sends the
|
|
3424
|
+
// legacy key and the server answers with `preset`).
|
|
3425
|
+
if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
|
|
3426
|
+
bucket.preset = bucket.accessPolicy;
|
|
3427
|
+
}
|
|
3428
|
+
return {
|
|
3429
|
+
bucketKey: doc?.bucket?.key ?? "",
|
|
3430
|
+
...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
|
|
3431
|
+
mode: "create",
|
|
3432
|
+
values: {
|
|
3433
|
+
name: (value) => value || doc?.bucket?.key || "",
|
|
3434
|
+
description: tomlOwned,
|
|
3435
|
+
},
|
|
3436
|
+
}),
|
|
3437
|
+
};
|
|
3438
|
+
},
|
|
3439
|
+
serialize: (record) => serializeBlobBucket(record),
|
|
3440
|
+
// `name` is trimmed on the way in and a blank description is stored as
|
|
3441
|
+
// none (DSO-003).
|
|
3442
|
+
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
3443
|
+
trimmed: ["name"],
|
|
3444
|
+
optional: ["description"],
|
|
3445
|
+
}),
|
|
3446
|
+
// The three fields a bucket file leaves alone by omitting them, matching
|
|
3447
|
+
// what `buildBlobBucketUpdatePayload` sends (#2880 criterion 3). The server
|
|
3448
|
+
// refuses to leave a bucket nameless or without an access model, so none of
|
|
3449
|
+
// them is clearable and the update omits an absent key rather than nulling
|
|
3450
|
+
// it. Compared anyway, a bucket whose live name differs from its key — or
|
|
3451
|
+
// one whose access model the file does not state — is a `Modified` row and
|
|
3452
|
+
// an update that cannot carry the difference it reports, planned again on
|
|
3453
|
+
// every run. `ttlTier` is deliberately NOT here: it is immutable rather
|
|
3454
|
+
// than unmanaged, and naming it is the whole point of the create-mode parse
|
|
3455
|
+
// above.
|
|
3456
|
+
unmanaged: (doc) => {
|
|
3457
|
+
const bucket = doc?.bucket ?? {};
|
|
3458
|
+
const keys = [];
|
|
3459
|
+
if (!bucket.name)
|
|
3460
|
+
keys.push("name");
|
|
3461
|
+
if (!bucket.preset && !bucket.accessPolicy)
|
|
3462
|
+
keys.push("preset");
|
|
3463
|
+
if (!bucket.ruleSetId)
|
|
3464
|
+
keys.push("ruleSetId");
|
|
3465
|
+
return keys;
|
|
3466
|
+
},
|
|
3467
|
+
describe: (entity) => `blob bucket "${entity?.bucketKey}"`,
|
|
3468
|
+
},
|
|
3469
|
+
// #2880 — test cases. Their sidecars were compared by SLUG PRESENCE, so an
|
|
3470
|
+
// edited expectation read as Synced while push applied it from the file's
|
|
3471
|
+
// byte hash.
|
|
3472
|
+
//
|
|
3473
|
+
// The type's own difficulty is that a reference has two legal spellings — the
|
|
3474
|
+
// portable `configName` a test file carries so it survives a move between
|
|
3475
|
+
// apps, and the legacy `configId` pull still writes back when the name cannot
|
|
3476
|
+
// be resolved. They name the same thing, so they project the same way; the
|
|
3477
|
+
// `extra` context is what makes that possible (the id→name lookups are not in
|
|
3478
|
+
// the document).
|
|
3479
|
+
"test-case": {
|
|
3480
|
+
label: "test-case",
|
|
3481
|
+
table: TEST_CASE_TABLE,
|
|
3482
|
+
parse: (doc) => parseTestCaseToml(doc),
|
|
3483
|
+
serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
|
|
3484
|
+
// The server trims a test-case name on write (`src/admin-api.ts`), so a
|
|
3485
|
+
// padded one in the file is not a difference — it is what the server will
|
|
3486
|
+
// store (#2880 DSO-003).
|
|
3487
|
+
canonicalize: (entity) => {
|
|
3488
|
+
if (typeof entity.name === "string")
|
|
3489
|
+
entity.name = entity.name.trim();
|
|
3490
|
+
},
|
|
3491
|
+
extras: (entity, _doc, extra) => ({
|
|
3492
|
+
// These three OVERRIDE the definition's fields of the same name: the
|
|
3493
|
+
// value compared is the thing referred to, not the spelling that referred
|
|
3494
|
+
// to it. Precedence matches the push path exactly — a file carrying both
|
|
3495
|
+
// an id and a name is pushed on the id, so it is compared on the id.
|
|
3496
|
+
configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
|
|
3497
|
+
evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
|
|
3498
|
+
evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
|
|
3499
|
+
}),
|
|
3500
|
+
describe: (entity) => `test case "${entity?.name}"`,
|
|
3501
|
+
},
|
|
3079
3502
|
"database-type-config": {
|
|
3080
3503
|
label: "database-type-config",
|
|
3081
3504
|
table: DATABASE_TYPE_TABLE,
|
|
@@ -3107,9 +3530,10 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3107
3530
|
};
|
|
3108
3531
|
/**
|
|
3109
3532
|
* Every type the shared comparator serves — the types `config diff` compares
|
|
3110
|
-
* field-for-field, and that `config push` gates on the same projection
|
|
3111
|
-
*
|
|
3112
|
-
* in #
|
|
3533
|
+
* field-for-field, and that `config push` gates on the same projection
|
|
3534
|
+
* (#2731 B1/B2; webhooks joined the comparator in #2757 and their push joined
|
|
3535
|
+
* the same gate in #2880, so no registered type answers "changed?" from the
|
|
3536
|
+
* file's bytes any more).
|
|
3113
3537
|
*
|
|
3114
3538
|
* Published so the round-trip acceptance bar (#2731 B9) can require a fixture
|
|
3115
3539
|
* per type instead of listing them a second time by hand: converting a type is
|
|
@@ -3132,20 +3556,82 @@ export function configDiffSpec(label) {
|
|
|
3132
3556
|
* a push that needs to SAY what differs and a diff that only needs to know THAT
|
|
3133
3557
|
* something differs read the same projection.
|
|
3134
3558
|
*/
|
|
3135
|
-
export function projectLocalConfig(spec, parsedToml, maps) {
|
|
3136
|
-
const entity = spec.parse(parsedToml);
|
|
3559
|
+
export function projectLocalConfig(spec, parsedToml, maps, extra) {
|
|
3560
|
+
const entity = spec.parse(parsedToml, extra);
|
|
3137
3561
|
if (spec.ruleSetRef) {
|
|
3138
3562
|
normalizeRuleSetRefForDiff(entity, maps.ruleSetNameToId, spec.describe(entity));
|
|
3139
3563
|
}
|
|
3140
|
-
|
|
3564
|
+
// #2880 DSO-003 — before anything is hashed, so the extras below read the
|
|
3565
|
+
// canonical values too.
|
|
3566
|
+
spec.canonicalize?.(entity);
|
|
3567
|
+
return projectConfigForComparison(spec.table, entity, spec.extras?.(entity, parsedToml, extra));
|
|
3141
3568
|
}
|
|
3142
3569
|
/** The same projection for a SERVER entity — see `hashRemoteConfigForDiff`. */
|
|
3143
3570
|
export function projectRemoteConfig(spec, record, maps, extra) {
|
|
3144
|
-
return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
|
|
3571
|
+
return projectLocalConfig(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* BOTH sides of one comparison, with the file's unmanaged keys reconciled
|
|
3575
|
+
* (#2880) — the one place `spec.unmanaged` is honored, so `config diff` and
|
|
3576
|
+
* `config push` cannot read the same file differently.
|
|
3577
|
+
*/
|
|
3578
|
+
export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
|
|
3579
|
+
const local = projectLocalConfig(spec, localParsed, maps, localExtra);
|
|
3580
|
+
const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
|
|
3581
|
+
for (const key of spec.unmanaged?.(localParsed) ?? []) {
|
|
3582
|
+
// Key-for-key, so an unmanaged field the server does not hold either is
|
|
3583
|
+
// absent on both sides rather than present-and-undefined on one.
|
|
3584
|
+
if (key in remote)
|
|
3585
|
+
local[key] = remote[key];
|
|
3586
|
+
else
|
|
3587
|
+
delete local[key];
|
|
3588
|
+
}
|
|
3589
|
+
return { local, remote };
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* One `config diff` row's content verdict for a registered type (#2880).
|
|
3593
|
+
*
|
|
3594
|
+
* The three outcomes the per-type blocks were each spelling out by hand:
|
|
3595
|
+
* equal (`exists`), different (`modified`, framed as a preview of
|
|
3596
|
+
* `config pull`), and "could not tell" — a missing record or a comparison that
|
|
3597
|
+
* threw, which degrades THIS row and never the whole diff. Written once so a
|
|
3598
|
+
* type joining the comparator cannot accidentally report a fourth thing.
|
|
3599
|
+
*
|
|
3600
|
+
* The two `extra` arguments are the same split `decidePushForConfig` makes:
|
|
3601
|
+
* `extra` carries what only the SERVER side has (a database type's operation
|
|
3602
|
+
* rows), while `localExtra` is context BOTH sides read a value through — a test
|
|
3603
|
+
* case's id→name lookups. Giving the local side nothing was a silent
|
|
3604
|
+
* mistranslation: a sidecar pinned by a resolvable `configId` projected the id
|
|
3605
|
+
* while the server's projection resolved it to the name, so an untouched file
|
|
3606
|
+
* reported Modified in diff and drifted in push.
|
|
3607
|
+
*/
|
|
3608
|
+
export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
3609
|
+
ruleSetIdToName: new Map(),
|
|
3610
|
+
ruleSetNameToId: new Map(),
|
|
3611
|
+
}, extra, localExtra) {
|
|
3612
|
+
if (!localParsed || !remoteRecord) {
|
|
3613
|
+
return { status: "exists", hint: "content not compared (fetch failed)" };
|
|
3614
|
+
}
|
|
3615
|
+
try {
|
|
3616
|
+
const pair = projectConfigPair(spec, localParsed, remoteRecord, maps, extra ?? localExtra, localExtra);
|
|
3617
|
+
const remoteHash = computeExpandedContentHash(pair.remote);
|
|
3618
|
+
const localHash = computeExpandedContentHash(pair.local);
|
|
3619
|
+
if (remoteHash === localHash)
|
|
3620
|
+
return { status: "exists" };
|
|
3621
|
+
return {
|
|
3622
|
+
status: "modified",
|
|
3623
|
+
hint: "run `config pull` to rewrite the local TOML to match running state",
|
|
3624
|
+
};
|
|
3625
|
+
}
|
|
3626
|
+
catch {
|
|
3627
|
+
// A file the comparison cannot read is reported as not compared rather
|
|
3628
|
+
// than aborting the diff; `config push` names the fault precisely.
|
|
3629
|
+
return { status: "exists", hint: "content not compared (comparison failed)" };
|
|
3630
|
+
}
|
|
3145
3631
|
}
|
|
3146
3632
|
/** Hash a LOCAL config file's definition-projected field set (#2644). */
|
|
3147
|
-
export function hashLocalConfigForDiff(spec, parsedToml, maps) {
|
|
3148
|
-
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps));
|
|
3633
|
+
export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
|
|
3634
|
+
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
|
|
3149
3635
|
}
|
|
3150
3636
|
/**
|
|
3151
3637
|
* Hash a SERVER entity the same way, by serializing it into the file `sync
|
|
@@ -3157,32 +3643,24 @@ export function hashLocalConfigForDiff(spec, parsedToml, maps) {
|
|
|
3157
3643
|
* operations and subscriptions, which the list response does not include.
|
|
3158
3644
|
*/
|
|
3159
3645
|
export function hashRemoteConfigForDiff(spec, record, maps, extra) {
|
|
3160
|
-
return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps);
|
|
3646
|
+
return hashLocalConfigForDiff(spec, parseConfigToml(spec.serialize(record, maps, extra)), maps, extra);
|
|
3647
|
+
}
|
|
3648
|
+
export function decideDegradedPush(input) {
|
|
3649
|
+
if (input.force)
|
|
3650
|
+
return { action: "send" };
|
|
3651
|
+
if (input.storedContentHash === undefined) {
|
|
3652
|
+
return { action: "decline", reason: "no-baseline" };
|
|
3653
|
+
}
|
|
3654
|
+
return input.storedContentHash === input.currentFileHash
|
|
3655
|
+
? { action: "skip" }
|
|
3656
|
+
: { action: "send" };
|
|
3161
3657
|
}
|
|
3162
|
-
/**
|
|
3163
|
-
* `config push`'s change decision for one configuration file (#2731 B1/B3/B4/B8).
|
|
3164
|
-
*
|
|
3165
|
-
* This replaces `shouldPushFile` for the converted types. The differences that
|
|
3166
|
-
* matter, in order:
|
|
3167
|
-
*
|
|
3168
|
-
* - the comparison is `config diff`'s — the definition-projected field set on
|
|
3169
|
-
* both sides — so "skip as unchanged" and "Synced" cannot disagree, and a
|
|
3170
|
-
* comment-only or formatting-only edit is not a change;
|
|
3171
|
-
* - the right-hand side is the LIVE entity, not the manifest, so out-of-band
|
|
3172
|
-
* server drift is visible instead of being overwritten;
|
|
3173
|
-
* - the manifest becomes a baseline that answers WHICH side moved, so a
|
|
3174
|
-
* difference is attributed rather than assumed to be a local edit.
|
|
3175
|
-
*
|
|
3176
|
-
* Declining is a first-class outcome here. `drift`, `conflict` and `immutable`
|
|
3177
|
-
* each carry the field diff their report prints, and nothing is applied.
|
|
3178
|
-
*/
|
|
3179
3658
|
export function decidePushForConfig(input) {
|
|
3180
|
-
const { spec, maps, localParsed, live, entry, localFileHash, force } = input;
|
|
3659
|
+
const { spec, maps, localParsed, live, entry, localFileHash, localExtra, force } = input;
|
|
3181
3660
|
if (!live?.record)
|
|
3182
3661
|
return { action: "create" };
|
|
3183
|
-
const localProjected =
|
|
3662
|
+
const { local: localProjected, remote: remoteProjected } = projectConfigPair(spec, localParsed, live.record, maps, live.extra ?? localExtra, localExtra);
|
|
3184
3663
|
const localHash = computeExpandedContentHash(localProjected);
|
|
3185
|
-
const remoteProjected = projectRemoteConfig(spec, live.record, maps, live.extra);
|
|
3186
3664
|
const remoteHash = computeExpandedContentHash(remoteProjected);
|
|
3187
3665
|
const fields = diffProjectedRecords(localProjected, remoteProjected);
|
|
3188
3666
|
// A difference in a field UPDATE does not accept would PATCH "successfully"
|
|
@@ -3225,6 +3703,37 @@ export function decidePushForConfig(input) {
|
|
|
3225
3703
|
entry.modifiedAt === live.modifiedAt,
|
|
3226
3704
|
},
|
|
3227
3705
|
});
|
|
3706
|
+
// #2909 — the adopt, for a type whose push path adopts by key. `entry` is
|
|
3707
|
+
// read for ANY of its three signals, not just the baseline: a row carrying
|
|
3708
|
+
// only a legacy signal has been synced, and its unattributable difference is
|
|
3709
|
+
// the conflict below. This is the never-reconciled file alone.
|
|
3710
|
+
//
|
|
3711
|
+
// Deliberately the SIGNALS and not the row's existence: an `id`-only row is
|
|
3712
|
+
// what the 409 recovery seeds before its update lands (and a failed push
|
|
3713
|
+
// still saves), so it names the object an adopt found without recording that
|
|
3714
|
+
// any content was ever reconciled. There is nothing there to attribute a
|
|
3715
|
+
// move with, and refusing it would wedge that half-adopted slot in a
|
|
3716
|
+
// conflict no `config push` can clear — this same regression, one step in.
|
|
3717
|
+
const untracked = entry?.semanticHash === undefined &&
|
|
3718
|
+
entry?.contentHash === undefined &&
|
|
3719
|
+
entry?.modifiedAt === undefined;
|
|
3720
|
+
if (direction === "unknown-direction" &&
|
|
3721
|
+
input.adoptsUntrackedByKey &&
|
|
3722
|
+
untracked) {
|
|
3723
|
+
if (immutableFields.length > 0)
|
|
3724
|
+
return immutable;
|
|
3725
|
+
return {
|
|
3726
|
+
action: "update",
|
|
3727
|
+
direction: "adopt-untracked",
|
|
3728
|
+
localHash,
|
|
3729
|
+
remoteHash,
|
|
3730
|
+
// As for `local-edited`: the LIVE timestamp closes the window between the
|
|
3731
|
+
// reconcile's read and this write. The 409 path omits it because it never
|
|
3732
|
+
// read the record it adopts; this one just did.
|
|
3733
|
+
expectedModifiedAt: live.modifiedAt,
|
|
3734
|
+
fields,
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3228
3737
|
switch (direction) {
|
|
3229
3738
|
case "unchanged":
|
|
3230
3739
|
return { action: "skip", localHash, remoteHash };
|
|
@@ -4186,6 +4695,14 @@ export async function pullTestCasesForBlock(params) {
|
|
|
4186
4695
|
modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
|
|
4187
4696
|
attachments,
|
|
4188
4697
|
contentHash: computeFileHash(tomlPath),
|
|
4698
|
+
// #2880 — the file and the server agree right now, so this is the
|
|
4699
|
+
// baseline the next push attributes a difference against.
|
|
4700
|
+
semanticHash: remoteSemanticBaseline({
|
|
4701
|
+
spec: configDiffSpec("test-case"),
|
|
4702
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
4703
|
+
returned: tc,
|
|
4704
|
+
extra: { lookupMaps },
|
|
4705
|
+
}),
|
|
4189
4706
|
};
|
|
4190
4707
|
}
|
|
4191
4708
|
// Clean up local files for remotely-deleted test cases
|
|
@@ -4265,7 +4782,7 @@ const TEN_MB = 10 * 1024 * 1024;
|
|
|
4265
4782
|
* content hashes (#2769). A name the guard rejects is dropped here, before it
|
|
4266
4783
|
* can be turned into an upload.
|
|
4267
4784
|
*/
|
|
4268
|
-
function readLocalAttachments(attachDir, onUnsafe) {
|
|
4785
|
+
function readLocalAttachments(attachDir, onUnsafe = () => { }) {
|
|
4269
4786
|
if (!existsSync(attachDir))
|
|
4270
4787
|
return [];
|
|
4271
4788
|
const files = [];
|
|
@@ -4345,6 +4862,25 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4345
4862
|
const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
|
|
4346
4863
|
if (files.length === 0)
|
|
4347
4864
|
return { skipped };
|
|
4865
|
+
// #2880 — the block's live cases, read once. They are what `config diff`
|
|
4866
|
+
// compares against and what `config pull` writes from, so push deciding from
|
|
4867
|
+
// anything else is how the two commands came to disagree. A failed listing
|
|
4868
|
+
// degrades this block to the byte-hash gate, named, rather than sending every
|
|
4869
|
+
// sidecar down the create path.
|
|
4870
|
+
const testCaseSpec = configDiffSpec("test-case");
|
|
4871
|
+
const testCaseExtra = { lookupMaps: params.lookupMaps };
|
|
4872
|
+
const listed = await listAllTestCases(client, appId, blockType, blockId);
|
|
4873
|
+
const liveOk = listed.ok;
|
|
4874
|
+
if (!liveOk) {
|
|
4875
|
+
warn(` ${blockKey} test cases: live state unavailable — falling back to last-sync content hashes`);
|
|
4876
|
+
}
|
|
4877
|
+
const paired = pairTestCases({
|
|
4878
|
+
blockType,
|
|
4879
|
+
blockKey,
|
|
4880
|
+
localSlugs: files.map((f) => basename(f, ".toml")),
|
|
4881
|
+
liveCases: liveOk ? listed.items : [],
|
|
4882
|
+
managed: syncState?.entities?.testCases,
|
|
4883
|
+
});
|
|
4348
4884
|
for (const file of files) {
|
|
4349
4885
|
const slug = basename(file, ".toml");
|
|
4350
4886
|
const filePath = join(testsDir, file);
|
|
@@ -4427,15 +4963,89 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4427
4963
|
local: localAttachments,
|
|
4428
4964
|
recorded: existingEntry,
|
|
4429
4965
|
});
|
|
4430
|
-
//
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4966
|
+
// #2880 — the change decision is the comparison `config diff` makes,
|
|
4967
|
+
// against the case the server holds NOW. The old gate asked the manifest
|
|
4968
|
+
// whether the file's BYTES had moved, so a comment planned an update diff
|
|
4969
|
+
// called synced and server drift was skipped however far it had gone.
|
|
4970
|
+
//
|
|
4971
|
+
// An attachment still pending upload is a change whatever the text says:
|
|
4972
|
+
// the sidecar and its fixtures are one resource to push.
|
|
4973
|
+
const liveCase = paired.bySlug.get(slug);
|
|
4974
|
+
let gate;
|
|
4975
|
+
// The reference resolution above is what loads this block's configs, so the
|
|
4976
|
+
// lookups the comparison reads through are re-derived from it first.
|
|
4977
|
+
params.refreshLookupMaps?.();
|
|
4978
|
+
if (liveOk && liveCase) {
|
|
4979
|
+
gate = decidePushForConfig({
|
|
4980
|
+
spec: testCaseSpec,
|
|
4981
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
4982
|
+
localParsed: tomlData,
|
|
4983
|
+
live: { record: liveCase, modifiedAt: liveCase.modifiedAt },
|
|
4984
|
+
entry: existingEntry,
|
|
4985
|
+
localFileHash: computeFileHash(filePath),
|
|
4986
|
+
localExtra: testCaseExtra,
|
|
4987
|
+
force: params.options?.force,
|
|
4988
|
+
});
|
|
4989
|
+
if (gate.action === "skip" && attachmentPlan.upload.length === 0) {
|
|
4990
|
+
skipped++;
|
|
4991
|
+
// A skip decided against live state refreshes the row, so the next run
|
|
4992
|
+
// classifies against a baseline rather than inferring from timestamps.
|
|
4993
|
+
//
|
|
4994
|
+
// CREATING the row when it is missing is the point: the case this skip
|
|
4995
|
+
// agreed with may have been adopted by slug, and identity is the
|
|
4996
|
+
// manifest id first. Recording only rows that already existed left an
|
|
4997
|
+
// adopted case unmanaged, so the next rename lost its id and paired as
|
|
4998
|
+
// local-only beside remote-only — the duplicate create this pairing
|
|
4999
|
+
// exists to prevent.
|
|
5000
|
+
if (syncState) {
|
|
5001
|
+
if (!syncState.entities.testCases)
|
|
5002
|
+
syncState.entities.testCases = {};
|
|
5003
|
+
const entry = (syncState.entities.testCases[stateKey] ??= {
|
|
5004
|
+
blockType,
|
|
5005
|
+
blockId,
|
|
5006
|
+
blockKey,
|
|
5007
|
+
slug,
|
|
5008
|
+
});
|
|
5009
|
+
entry.id = liveCase.testCaseId;
|
|
5010
|
+
entry.contentHash = computeFileHash(filePath);
|
|
5011
|
+
entry.semanticHash = gate.remoteHash;
|
|
5012
|
+
if (liveCase.modifiedAt)
|
|
5013
|
+
entry.modifiedAt = liveCase.modifiedAt;
|
|
5014
|
+
}
|
|
5015
|
+
continue;
|
|
5016
|
+
}
|
|
5017
|
+
if (gate.action === "drift" ||
|
|
5018
|
+
gate.action === "conflict" ||
|
|
5019
|
+
gate.action === "immutable") {
|
|
5020
|
+
params.declined?.("test-case", `${blockKey}/${slug}`, gate, existingEntry?.modifiedAt);
|
|
5021
|
+
continue;
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
else if (!liveOk) {
|
|
5025
|
+
// Degraded: the byte gate this section has always fallen back to, and a
|
|
5026
|
+
// decline when there is no hash to gate on (#2880 criterion 7).
|
|
5027
|
+
const degraded = decideDegradedPush({
|
|
5028
|
+
force: params.options?.force,
|
|
5029
|
+
storedContentHash: existingEntry?.contentHash,
|
|
5030
|
+
currentFileHash: computeFileHash(filePath),
|
|
5031
|
+
});
|
|
5032
|
+
if (degraded.action === "skip" && attachmentPlan.upload.length === 0) {
|
|
5033
|
+
skipped++;
|
|
5034
|
+
continue;
|
|
5035
|
+
}
|
|
5036
|
+
if (degraded.action === "decline") {
|
|
5037
|
+
params.declined?.("test-case", `${blockKey}/${slug}`, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
5038
|
+
continue;
|
|
5039
|
+
}
|
|
4437
5040
|
}
|
|
4438
|
-
|
|
5041
|
+
// Live state decides create-vs-update: a case the manifest never recorded
|
|
5042
|
+
// but the server has is adopted rather than duplicated (#2896's half of
|
|
5043
|
+
// this), and one the manifest names that the server no longer has is
|
|
5044
|
+
// re-created.
|
|
5045
|
+
const existingId = liveOk
|
|
5046
|
+
? liveCase?.testCaseId
|
|
5047
|
+
: existingEntry?.id;
|
|
5048
|
+
if (existingId) {
|
|
4439
5049
|
// Update existing test case. The body is FULL STATE: the handler only
|
|
4440
5050
|
// touches keys it receives, so an omitted key retains its old value and
|
|
4441
5051
|
// blanking a field in the file would never reach the server (#2769).
|
|
@@ -4455,11 +5065,30 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4455
5065
|
}
|
|
4456
5066
|
else {
|
|
4457
5067
|
try {
|
|
4458
|
-
await client.updateTestCase(appId, blockType, blockId,
|
|
5068
|
+
const updated = await client.updateTestCase(appId, blockType, blockId, existingId, updatePayload);
|
|
4459
5069
|
changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
|
|
4460
5070
|
info(` Updated test case: ${blockKey}/${slug}`);
|
|
4461
|
-
if (syncState
|
|
4462
|
-
syncState.entities.testCases
|
|
5071
|
+
if (syncState) {
|
|
5072
|
+
if (!syncState.entities.testCases)
|
|
5073
|
+
syncState.entities.testCases = {};
|
|
5074
|
+
const entry = (syncState.entities.testCases[stateKey] ??= {
|
|
5075
|
+
blockType,
|
|
5076
|
+
blockId,
|
|
5077
|
+
blockKey,
|
|
5078
|
+
slug,
|
|
5079
|
+
});
|
|
5080
|
+
entry.id = existingId;
|
|
5081
|
+
entry.contentHash = computeFileHash(filePath);
|
|
5082
|
+
if (updated?.modifiedAt)
|
|
5083
|
+
entry.modifiedAt = updated.modifiedAt;
|
|
5084
|
+
// #2880 — the baseline, from the record the server stored (it trims
|
|
5085
|
+
// the name on write), so the next run can attribute a difference.
|
|
5086
|
+
entry.semanticHash = remoteSemanticBaseline({
|
|
5087
|
+
spec: testCaseSpec,
|
|
5088
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
5089
|
+
returned: updated ?? { ...liveCase, ...updatePayload },
|
|
5090
|
+
extra: testCaseExtra,
|
|
5091
|
+
});
|
|
4463
5092
|
}
|
|
4464
5093
|
}
|
|
4465
5094
|
catch (err) {
|
|
@@ -4499,6 +5128,15 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4499
5128
|
slug,
|
|
4500
5129
|
modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
|
|
4501
5130
|
contentHash: computeFileHash(filePath),
|
|
5131
|
+
// #2880 — a create records its baseline too, from the stored
|
|
5132
|
+
// record: the handler trims the name, so a baseline taken from
|
|
5133
|
+
// the file would read that trim as a difference on the next run.
|
|
5134
|
+
semanticHash: remoteSemanticBaseline({
|
|
5135
|
+
spec: testCaseSpec,
|
|
5136
|
+
maps: { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() },
|
|
5137
|
+
returned: created,
|
|
5138
|
+
extra: testCaseExtra,
|
|
5139
|
+
}),
|
|
4502
5140
|
};
|
|
4503
5141
|
}
|
|
4504
5142
|
}
|
|
@@ -4515,7 +5153,7 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4515
5153
|
// plain push (sponsor ruling, 2026-08-18).
|
|
4516
5154
|
if (dryRun)
|
|
4517
5155
|
continue;
|
|
4518
|
-
const testCaseId =
|
|
5156
|
+
const testCaseId = existingId || syncState?.entities?.testCases?.[stateKey]?.id;
|
|
4519
5157
|
if (!testCaseId)
|
|
4520
5158
|
continue;
|
|
4521
5159
|
for (const filename of attachmentPlan.upload) {
|
|
@@ -4560,32 +5198,101 @@ export async function pushTestCasesForBlock(params) {
|
|
|
4560
5198
|
return { skipped };
|
|
4561
5199
|
}
|
|
4562
5200
|
/**
|
|
4563
|
-
*
|
|
5201
|
+
* Which live test case each sidecar manages (#2880 behavior 16).
|
|
5202
|
+
*
|
|
5203
|
+
* The manifest id comes FIRST. A test case's slug is derived from its name, so
|
|
5204
|
+
* matching on the slug alone made a rename — in either direction — read as a
|
|
5205
|
+
* new local file plus a removable remote case: diff showed two rows for one
|
|
5206
|
+
* case, and push created a duplicate. `entities.testCases[stateKey].id` is
|
|
5207
|
+
* stable across every rename, so it is the identity, and the slug is only how
|
|
5208
|
+
* a sidecar the manifest has never seen is ADOPTED.
|
|
4564
5209
|
*
|
|
4565
|
-
*
|
|
4566
|
-
* same collision suffixes, same order —
|
|
4567
|
-
* the second colliding name and
|
|
5210
|
+
* Adoption derives its slugs exactly the way pull writes them — same slugify,
|
|
5211
|
+
* same collision suffixes, same listing order — so a `<slug>-2.toml` on disk IS
|
|
5212
|
+
* the second colliding name and is paired with it.
|
|
5213
|
+
*
|
|
5214
|
+
* One function for both commands, because a diff row and a push decision that
|
|
5215
|
+
* pair differently are two answers to the same question.
|
|
4568
5216
|
*/
|
|
4569
|
-
export function
|
|
5217
|
+
export function pairTestCases(input) {
|
|
5218
|
+
const bySlug = new Map();
|
|
5219
|
+
// Claimed by RECORD rather than by id: a row the server returned without one
|
|
5220
|
+
// still has to be paired or reported, never silently dropped from both sides.
|
|
5221
|
+
const claimed = new Set();
|
|
5222
|
+
const byId = new Map();
|
|
5223
|
+
for (const live of input.liveCases) {
|
|
5224
|
+
if (live?.testCaseId)
|
|
5225
|
+
byId.set(live.testCaseId, live);
|
|
5226
|
+
}
|
|
5227
|
+
const unpaired = [];
|
|
5228
|
+
for (const slug of input.localSlugs) {
|
|
5229
|
+
const managedId = input.managed?.[`${input.blockType}/${input.blockKey}/${slug}`]?.id;
|
|
5230
|
+
const live = managedId ? byId.get(managedId) : undefined;
|
|
5231
|
+
if (live) {
|
|
5232
|
+
bySlug.set(slug, live);
|
|
5233
|
+
claimed.add(live);
|
|
5234
|
+
}
|
|
5235
|
+
else {
|
|
5236
|
+
unpaired.push(slug);
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
// The slug pull would write for each live case, in listing order, so the
|
|
5240
|
+
// collision suffixes line up with the files on disk.
|
|
4570
5241
|
const usedSlugs = new Set();
|
|
4571
|
-
const
|
|
4572
|
-
const
|
|
4573
|
-
|
|
4574
|
-
for (const slug of local) {
|
|
4575
|
-
rows.push({ slug, status: remoteSlugs.has(slug) ? "exists" : LOCAL_ONLY_NEW });
|
|
5242
|
+
const slugOf = new Map();
|
|
5243
|
+
for (const live of input.liveCases) {
|
|
5244
|
+
slugOf.set(live, resolveSlugCollisions(slugifyTestCaseName(live?.name || "unnamed"), usedSlugs));
|
|
4576
5245
|
}
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
5246
|
+
const localOnly = [];
|
|
5247
|
+
for (const slug of unpaired) {
|
|
5248
|
+
const adopted = input.liveCases.find((live) => !claimed.has(live) && slugOf.get(live) === slug);
|
|
5249
|
+
if (adopted) {
|
|
5250
|
+
bySlug.set(slug, adopted);
|
|
5251
|
+
claimed.add(adopted);
|
|
5252
|
+
}
|
|
5253
|
+
else {
|
|
5254
|
+
localOnly.push(slug);
|
|
5255
|
+
}
|
|
4580
5256
|
}
|
|
4581
|
-
return
|
|
5257
|
+
return {
|
|
5258
|
+
bySlug,
|
|
5259
|
+
localOnly,
|
|
5260
|
+
remoteOnly: input.liveCases.filter((live) => !claimed.has(live)),
|
|
5261
|
+
};
|
|
4582
5262
|
}
|
|
4583
5263
|
/**
|
|
4584
|
-
*
|
|
4585
|
-
*
|
|
4586
|
-
*
|
|
4587
|
-
*
|
|
5264
|
+
* This block's own `configId`→`configName` pairs, for the comparison (#2880).
|
|
5265
|
+
*
|
|
5266
|
+
* A sidecar pins the config it exercises by NAME so it survives a move between
|
|
5267
|
+
* apps; the server answers with the id. Only the block's configs can equate the
|
|
5268
|
+
* two, and `config diff` reads them from the detail its per-type loops already
|
|
5269
|
+
* fetch — which covers prompts and nothing else. Without this, every script,
|
|
5270
|
+
* integration and workflow sidecar pinned to a config reported Modified
|
|
5271
|
+
* forever, and push read the same pair as server drift.
|
|
5272
|
+
*
|
|
5273
|
+
* One listing per block that has sidecars, and a failed one is silent: an
|
|
5274
|
+
* unresolved reference compares as the id both sides carry, which is the same
|
|
5275
|
+
* degradation `serializeTestCase` already applies.
|
|
4588
5276
|
*/
|
|
5277
|
+
async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
|
|
5278
|
+
try {
|
|
5279
|
+
const configs = blockType === "prompt"
|
|
5280
|
+
? ((await client.getPrompt(appId, blockId))?.configs ?? [])
|
|
5281
|
+
: blockType === "workflow"
|
|
5282
|
+
? ((await client.getWorkflow(appId, blockId))?.configs ?? [])
|
|
5283
|
+
: blockType === "script"
|
|
5284
|
+
? (await client.listScriptConfigs(appId, blockId)).items
|
|
5285
|
+
: (await client.listIntegrationConfigs(appId, blockId)).items;
|
|
5286
|
+
for (const config of configs) {
|
|
5287
|
+
if (config?.configId && config?.configName) {
|
|
5288
|
+
configIdToName.set(config.configId, config.configName);
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
catch {
|
|
5293
|
+
// Non-fatal — see above.
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
4589
5296
|
export async function compareTestCasesForBlock(params) {
|
|
4590
5297
|
const listed = await listAllTestCases(params.client, params.appId, params.blockType, params.blockId);
|
|
4591
5298
|
if (!listed.ok)
|
|
@@ -4596,17 +5303,97 @@ export async function compareTestCasesForBlock(params) {
|
|
|
4596
5303
|
.filter((f) => f.endsWith(".toml"))
|
|
4597
5304
|
.map((f) => basename(f, ".toml"))
|
|
4598
5305
|
: [];
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
5306
|
+
// #2880 — identity is the manifest id, so a rename is one Modified row rather
|
|
5307
|
+
// than a local-only file beside a remote-only case.
|
|
5308
|
+
const managed = params.syncState?.entities?.testCases;
|
|
5309
|
+
const paired = pairTestCases({
|
|
5310
|
+
blockType: params.blockType,
|
|
5311
|
+
blockKey: params.blockKey,
|
|
5312
|
+
localSlugs,
|
|
5313
|
+
liveCases: listed.items,
|
|
5314
|
+
managed,
|
|
5315
|
+
});
|
|
5316
|
+
const spec = configDiffSpec("test-case");
|
|
5317
|
+
const extra = { lookupMaps: params.lookupMaps };
|
|
5318
|
+
const rows = [];
|
|
5319
|
+
const base = { blockType: params.blockType, blockKey: params.blockKey };
|
|
5320
|
+
// The configs this block's sidecars pin by name (see `fillBlockConfigNames`).
|
|
5321
|
+
if (localSlugs.length > 0 && params.lookupMaps) {
|
|
5322
|
+
await fillBlockConfigNames(params.client, params.appId, params.blockType, params.blockId, params.lookupMaps.configIdToName);
|
|
5323
|
+
}
|
|
5324
|
+
for (const slug of localSlugs) {
|
|
5325
|
+
const live = paired.bySlug.get(slug);
|
|
5326
|
+
if (!live) {
|
|
5327
|
+
rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
|
|
5328
|
+
continue;
|
|
5329
|
+
}
|
|
5330
|
+
const filePath = join(testsDir, `${slug}.toml`);
|
|
5331
|
+
let row;
|
|
5332
|
+
try {
|
|
5333
|
+
const tomlData = parseTomlFile(filePath);
|
|
5334
|
+
// #2880 criterion 1 — a sidecar push's preflight would reject is not one
|
|
5335
|
+
// this command calls synced, and the message is the same one. The WHOLE
|
|
5336
|
+
// preflight, not just the sidecar's own rules: an unknown key or a
|
|
5337
|
+
// mistyped value aborts push too.
|
|
5338
|
+
const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
|
|
5339
|
+
if (errors.length > 0) {
|
|
5340
|
+
row = {
|
|
5341
|
+
...base,
|
|
5342
|
+
slug,
|
|
5343
|
+
status: VALIDATION_ERROR,
|
|
5344
|
+
hint: errors.join("; "),
|
|
5345
|
+
};
|
|
5346
|
+
}
|
|
5347
|
+
else {
|
|
5348
|
+
const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
|
|
5349
|
+
// Both sides read a reference through the same lookups — see
|
|
5350
|
+
// `compareLocalToRemote`.
|
|
5351
|
+
extra);
|
|
5352
|
+
row = { ...base, slug, ...verdict };
|
|
5353
|
+
}
|
|
5354
|
+
}
|
|
5355
|
+
catch (err) {
|
|
5356
|
+
row = {
|
|
5357
|
+
...base,
|
|
5358
|
+
slug,
|
|
5359
|
+
status: VALIDATION_ERROR,
|
|
5360
|
+
hint: `${filePath}: ${err?.message || String(err)}`,
|
|
5361
|
+
};
|
|
5362
|
+
}
|
|
5363
|
+
// #2880 behavior 19 — attachments are part of what push would do, read
|
|
5364
|
+
// from the SAME plan push executes. A sidecar whose text compares clean but
|
|
5365
|
+
// whose fixtures have not been uploaded is not Synced.
|
|
5366
|
+
const stateKey = `${params.blockType}/${params.blockKey}/${slug}`;
|
|
5367
|
+
const plan = planAttachmentPush({
|
|
5368
|
+
local: readLocalAttachments(join(testsDir, slug)),
|
|
5369
|
+
recorded: managed?.[stateKey],
|
|
5370
|
+
});
|
|
5371
|
+
if (plan.upload.length > 0) {
|
|
5372
|
+
row.attachmentUploads = plan.upload;
|
|
5373
|
+
if (row.status === "exists") {
|
|
5374
|
+
row.status = "modified";
|
|
5375
|
+
row.hint = `${plan.upload.length} attachment(s) to upload`;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
if (plan.removedRemotely.length > 0) {
|
|
5379
|
+
row.attachmentDeletions = plan.removedRemotely;
|
|
5380
|
+
row.hint = [
|
|
5381
|
+
row.hint,
|
|
5382
|
+
`${plan.removedRemotely.length} attachment(s) deleted locally — \`push --prune\` removes them server-side`,
|
|
5383
|
+
]
|
|
5384
|
+
.filter(Boolean)
|
|
5385
|
+
.join("; ");
|
|
5386
|
+
}
|
|
5387
|
+
rows.push(row);
|
|
5388
|
+
}
|
|
5389
|
+
for (const live of paired.remoteOnly) {
|
|
5390
|
+
rows.push({
|
|
5391
|
+
...base,
|
|
5392
|
+
slug: slugifyTestCaseName(live?.name || "unnamed"),
|
|
5393
|
+
status: "remote only",
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
return { ok: true, rows };
|
|
4610
5397
|
}
|
|
4611
5398
|
/**
|
|
4612
5399
|
* Shape errors in an authored test-case sidecar, for the push preflight
|
|
@@ -4643,6 +5430,29 @@ export function testCaseSidecarErrors(filePath, tomlData) {
|
|
|
4643
5430
|
}
|
|
4644
5431
|
return errors;
|
|
4645
5432
|
}
|
|
5433
|
+
/**
|
|
5434
|
+
* EVERY reason `config push` refuses one test-case sidecar (#2880 criterion 1).
|
|
5435
|
+
*
|
|
5436
|
+
* The four checks the push preflight ran inline: the document's shape, the keys
|
|
5437
|
+
* the definition recognizes, the declared type of each value, and the sidecar's
|
|
5438
|
+
* own authoring rules (#2769). Collected in one place because `config diff`
|
|
5439
|
+
* runs the identical list — it used to run only the last of the four, so a
|
|
5440
|
+
* sidecar with an unknown key or a mistyped ordinary field was reported Synced
|
|
5441
|
+
* by the command whose whole promise is that a Synced file pushes.
|
|
5442
|
+
*/
|
|
5443
|
+
export function testCaseSidecarPreflightErrors(filePath, tomlData) {
|
|
5444
|
+
const errors = [
|
|
5445
|
+
// The document's own shape first: an unrecognized top-level table makes
|
|
5446
|
+
// every per-table check below read an absent table, so its silence would
|
|
5447
|
+
// be misleading.
|
|
5448
|
+
...configDocumentShapeErrors(filePath, tomlData, TEST_CASE_SURFACE),
|
|
5449
|
+
];
|
|
5450
|
+
const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
|
|
5451
|
+
if (unknownKeyError)
|
|
5452
|
+
errors.push(unknownKeyError);
|
|
5453
|
+
errors.push(...configDeclaredTypeErrors(filePath, tomlData, TEST_CASE_TABLE), ...testCaseSidecarErrors(filePath, tomlData));
|
|
5454
|
+
return errors;
|
|
5455
|
+
}
|
|
4646
5456
|
/** Whether a block's own authored file is still in the tree. */
|
|
4647
5457
|
function blockFileExists(configDir, blockType, blockKey) {
|
|
4648
5458
|
const resourceType = SYNC_RESOURCE_TYPES.find((t) => t.testBlockType === blockType);
|
|
@@ -4955,6 +5765,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
4955
5765
|
}
|
|
4956
5766
|
info(`Pulling configuration for app ${resolvedAppId}...`);
|
|
4957
5767
|
try {
|
|
5768
|
+
// The unfiltered listings, kept so the archived rows this pull declines
|
|
5769
|
+
// to export can still be recorded as tombstones in sync state (#2803).
|
|
5770
|
+
const integrationTombstoneCandidates = [];
|
|
4958
5771
|
// Fetch all data. Issue #1659: prune decides whether a local file's
|
|
4959
5772
|
// entity still exists server-side, so every fetch that establishes
|
|
4960
5773
|
// presence must report success or failure explicitly — an error that
|
|
@@ -4965,7 +5778,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
4965
5778
|
// an explicit outcome instead.
|
|
4966
5779
|
const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
|
|
4967
5780
|
client.getAppSettings(resolvedAppId).catch(() => null),
|
|
4968
|
-
|
|
5781
|
+
// #2803 — an archived integration is retired, not configuration, so
|
|
5782
|
+
// pull does not export it (same rule as webhooks). Its tombstone is
|
|
5783
|
+
// recorded in sync state (see `archivedTombstoneEntries`) so
|
|
5784
|
+
// `--prune` can still reclaim it.
|
|
5785
|
+
fetchAll((p) => client.listIntegrations(resolvedAppId, p)).then((items) => {
|
|
5786
|
+
integrationTombstoneCandidates.push(...items);
|
|
5787
|
+
return skipArchivedForPull(items);
|
|
5788
|
+
}),
|
|
4969
5789
|
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
4970
5790
|
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
4971
5791
|
attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
|
|
@@ -5152,14 +5972,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5152
5972
|
warn(` ${message}`);
|
|
5153
5973
|
}
|
|
5154
5974
|
if (settings && pullsApp) {
|
|
5155
|
-
//
|
|
5156
|
-
//
|
|
5157
|
-
//
|
|
5158
|
-
|
|
5159
|
-
// push of this file fail.
|
|
5160
|
-
writePulledAppSettings(configDir, settings, {
|
|
5161
|
-
subject: " app.toml: googleClientSecret",
|
|
5162
|
-
});
|
|
5975
|
+
// Every writable setting round-trips as of #2891 — the server
|
|
5976
|
+
// withholds nothing — so the pulled file is complete and pushable as
|
|
5977
|
+
// written, with no accompanying note about what it could not carry.
|
|
5978
|
+
writePulledAppSettings(configDir, settings);
|
|
5163
5979
|
info(" Wrote app.toml");
|
|
5164
5980
|
}
|
|
5165
5981
|
// Write config vars (issue #1423). A single flat vars.toml at the
|
|
@@ -5221,6 +6037,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5221
6037
|
}
|
|
5222
6038
|
// Write integrations
|
|
5223
6039
|
const integrationEntities = {};
|
|
6040
|
+
// As for webhooks: no rule-set reference, and the pull-wide maps are
|
|
6041
|
+
// not filled until the rule-set leg, well after this one.
|
|
6042
|
+
const integrationPullMaps = {
|
|
6043
|
+
ruleSetIdToName: new Map(),
|
|
6044
|
+
ruleSetNameToId: new Map(),
|
|
6045
|
+
};
|
|
5224
6046
|
for (const integration of integrations) {
|
|
5225
6047
|
const filename = `${integration.integrationKey}.toml`;
|
|
5226
6048
|
const filePath = join(configDir, "integrations", filename);
|
|
@@ -5229,15 +6051,32 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5229
6051
|
id: integration.integrationId,
|
|
5230
6052
|
modifiedAt: integration.modifiedAt || new Date().toISOString(),
|
|
5231
6053
|
contentHash: computeFileHash(filePath),
|
|
6054
|
+
// #2880 — the two sides agree right now, so this is the baseline
|
|
6055
|
+
// the next push classifies direction against.
|
|
6056
|
+
semanticHash: configSemanticHash("integration", filePath, integrationPullMaps),
|
|
5232
6057
|
};
|
|
5233
6058
|
info(` Wrote integrations/${filename}`);
|
|
5234
6059
|
}
|
|
5235
|
-
// Pull webhooks
|
|
5236
|
-
|
|
6060
|
+
// Pull webhooks. #2803 — an ARCHIVED webhook is retired, not
|
|
6061
|
+
// configuration, so it is not exported: writing a tombstone into the
|
|
6062
|
+
// config tree produced a file whose own next push would fail (status
|
|
6063
|
+
// is no longer authorable), and left the archived row looking like
|
|
6064
|
+
// something the repo still describes. `--prune` is what reclaims it —
|
|
6065
|
+
// which is why the tombstone still gets a sync-state entry below.
|
|
6066
|
+
const webhookTombstoneCandidates = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
|
|
6067
|
+
const webhookItems = skipArchivedForPull(webhookTombstoneCandidates);
|
|
5237
6068
|
const webhooks = selectPull("webhook", await Promise.all(webhookItems.map((w) => client.getWebhook(resolvedAppId, w.webhookId))), (w) => w.webhookKey);
|
|
5238
6069
|
const webhooksDir = join(configDir, "webhooks");
|
|
5239
6070
|
mkdirSync(webhooksDir, { recursive: true });
|
|
5240
6071
|
const webhookEntities = {};
|
|
6072
|
+
// Webhooks carry no rule-set reference, so the shared projection's
|
|
6073
|
+
// resolution maps are inert for this type — the same empty pair the
|
|
6074
|
+
// diff path passes (the pull-wide maps are not filled until the
|
|
6075
|
+
// rule-set leg runs, well after this one).
|
|
6076
|
+
const webhookPullMaps = {
|
|
6077
|
+
ruleSetIdToName: new Map(),
|
|
6078
|
+
ruleSetNameToId: new Map(),
|
|
6079
|
+
};
|
|
5241
6080
|
for (const webhook of webhooks) {
|
|
5242
6081
|
const filename = `${webhook.webhookKey}.toml`;
|
|
5243
6082
|
const filePath = join(webhooksDir, filename);
|
|
@@ -5246,6 +6085,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5246
6085
|
id: webhook.webhookId,
|
|
5247
6086
|
modifiedAt: webhook.modifiedAt || new Date().toISOString(),
|
|
5248
6087
|
contentHash: computeFileHash(filePath),
|
|
6088
|
+
// #2880 — the file and the server agree right now, so this is the
|
|
6089
|
+
// baseline the next push classifies direction against.
|
|
6090
|
+
semanticHash: configSemanticHash("webhook", filePath, webhookPullMaps),
|
|
5249
6091
|
};
|
|
5250
6092
|
// A stored value that is not a reference is never written to TOML, so
|
|
5251
6093
|
// pull time is where an operator finds out: a legacy literal from
|
|
@@ -5286,6 +6128,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5286
6128
|
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
5287
6129
|
mkdirSync(cronTriggersDir, { recursive: true });
|
|
5288
6130
|
const cronTriggerEntities = {};
|
|
6131
|
+
// No rule-set reference on either type, and the pull-wide maps are not
|
|
6132
|
+
// filled until the rule-set leg — see the webhook block above.
|
|
6133
|
+
const keylessPullMaps = {
|
|
6134
|
+
ruleSetIdToName: new Map(),
|
|
6135
|
+
ruleSetNameToId: new Map(),
|
|
6136
|
+
};
|
|
5289
6137
|
for (const trigger of cronTriggerItems) {
|
|
5290
6138
|
const filename = `${trigger.triggerKey}.toml`;
|
|
5291
6139
|
const filePath = join(cronTriggersDir, filename);
|
|
@@ -5294,6 +6142,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5294
6142
|
id: trigger.triggerId,
|
|
5295
6143
|
modifiedAt: trigger.modifiedAt || new Date().toISOString(),
|
|
5296
6144
|
contentHash: computeFileHash(filePath),
|
|
6145
|
+
// #2880 — the baseline the next push classifies direction against.
|
|
6146
|
+
semanticHash: configSemanticHash("cron-trigger", filePath, keylessPullMaps),
|
|
5297
6147
|
};
|
|
5298
6148
|
}
|
|
5299
6149
|
info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
|
|
@@ -5311,6 +6161,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5311
6161
|
id: bucket.bucketId,
|
|
5312
6162
|
modifiedAt: bucket.modifiedAt || new Date().toISOString(),
|
|
5313
6163
|
contentHash: computeFileHash(filePath),
|
|
6164
|
+
// #2880 — as for cron triggers.
|
|
6165
|
+
semanticHash: configSemanticHash("blob-bucket", filePath, keylessPullMaps),
|
|
5314
6166
|
};
|
|
5315
6167
|
}
|
|
5316
6168
|
info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
|
|
@@ -5872,6 +6724,73 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5872
6724
|
}
|
|
5873
6725
|
}
|
|
5874
6726
|
}
|
|
6727
|
+
// #2803 — the tombstones. This pull exported no file for an archived
|
|
6728
|
+
// webhook or integration and (above) removed the one a prior pull had
|
|
6729
|
+
// written, so without an entry here the row would drop out of managed
|
|
6730
|
+
// state entirely: `config push --prune` collects its candidates from
|
|
6731
|
+
// this map, and a row it cannot see is a row nothing can reclaim. The
|
|
6732
|
+
// entry carries the archived row's CURRENT `modifiedAt` so the prune
|
|
6733
|
+
// point-read reads "unchanged" rather than "edited out-of-band".
|
|
6734
|
+
for (const [slot, tombstones] of [
|
|
6735
|
+
[
|
|
6736
|
+
"integrations",
|
|
6737
|
+
archivedTombstoneEntries(integrationTombstoneCandidates, {
|
|
6738
|
+
keyOf: (i) => i.integrationKey,
|
|
6739
|
+
idOf: (i) => i.integrationId,
|
|
6740
|
+
}, Object.keys(priorEntities.integrations ?? {})),
|
|
6741
|
+
],
|
|
6742
|
+
[
|
|
6743
|
+
"webhooks",
|
|
6744
|
+
archivedTombstoneEntries(webhookTombstoneCandidates, {
|
|
6745
|
+
keyOf: (w) => w.webhookKey,
|
|
6746
|
+
idOf: (w) => w.webhookId,
|
|
6747
|
+
}, Object.keys(priorEntities.webhooks ?? {})),
|
|
6748
|
+
],
|
|
6749
|
+
]) {
|
|
6750
|
+
const built = entitiesBySlot[slot];
|
|
6751
|
+
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
6752
|
+
if (!built || !resourceType)
|
|
6753
|
+
continue;
|
|
6754
|
+
for (const [key, entry] of Object.entries(tombstones)) {
|
|
6755
|
+
if (key in built)
|
|
6756
|
+
continue;
|
|
6757
|
+
if (!selectionIncludesKey(pullSelection, resourceType.label, key)) {
|
|
6758
|
+
continue;
|
|
6759
|
+
}
|
|
6760
|
+
built[key] = entry;
|
|
6761
|
+
info(` No ${resourceType.dir}/${resourceType.fileFromKey(key)} — ` +
|
|
6762
|
+
"the entity is archived; `config push --prune` reclaims it");
|
|
6763
|
+
}
|
|
6764
|
+
}
|
|
6765
|
+
// Cron triggers reach the same place by a different road: the app-API
|
|
6766
|
+
// listing filters archived rows server-side, so pull cannot see a
|
|
6767
|
+
// tombstone in the list at all. The managed keys the listing dropped are
|
|
6768
|
+
// point-read by id instead — a row that still answers is archived (a
|
|
6769
|
+
// real delete 404s), and gets the same tombstone entry.
|
|
6770
|
+
if (cronTriggersOutcome.ok) {
|
|
6771
|
+
const listedCronKeys = new Set(cronTriggerItems.map((t) => t.triggerKey));
|
|
6772
|
+
for (const [key, prior] of Object.entries((priorEntities.cronTriggers ?? {}))) {
|
|
6773
|
+
if (listedCronKeys.has(key))
|
|
6774
|
+
continue;
|
|
6775
|
+
if (key in finalCronTriggerEntities)
|
|
6776
|
+
continue;
|
|
6777
|
+
if (!prior?.id)
|
|
6778
|
+
continue;
|
|
6779
|
+
if (!selectionIncludesKey(pullSelection, "cron-trigger", key)) {
|
|
6780
|
+
continue;
|
|
6781
|
+
}
|
|
6782
|
+
const detail = await attemptFetch(() => client.getCronTrigger(resolvedAppId, prior.id));
|
|
6783
|
+
const row = detail.ok ? detail.value : null;
|
|
6784
|
+
if (String(row?.status ?? "").toLowerCase() !== "archived")
|
|
6785
|
+
continue;
|
|
6786
|
+
finalCronTriggerEntities[key] = {
|
|
6787
|
+
id: prior.id,
|
|
6788
|
+
modifiedAt: row.modifiedAt || "",
|
|
6789
|
+
};
|
|
6790
|
+
info(` No cron-triggers/${key}.toml — the trigger is archived; ` +
|
|
6791
|
+
"`config push --prune` reclaims it");
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
5875
6794
|
// Under `--only`, every object this pull did not look at keeps its
|
|
5876
6795
|
// prior state entry. Its file was left exactly as it was, so dropping
|
|
5877
6796
|
// the record would make the next pull treat a managed file as
|
|
@@ -5902,6 +6821,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
5902
6821
|
? {
|
|
5903
6822
|
modifiedAt: new Date().toISOString(),
|
|
5904
6823
|
contentHash: computeFileHash(join(configDir, "app.toml")),
|
|
6824
|
+
// #2880 — the file was just written FROM these settings, so
|
|
6825
|
+
// this is the state both sides agree on: the baseline the
|
|
6826
|
+
// next push classifies against. A pull also ends any
|
|
6827
|
+
// cross-app name exclusion, because the file it just wrote
|
|
6828
|
+
// states the target's own name.
|
|
6829
|
+
semanticHash: hashAppSettingsForDiff(settings),
|
|
5905
6830
|
}
|
|
5906
6831
|
: pullSelection
|
|
5907
6832
|
? priorEntities.app
|
|
@@ -6258,6 +7183,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6258
7183
|
drifted.push({ type, key });
|
|
6259
7184
|
return;
|
|
6260
7185
|
}
|
|
7186
|
+
// #2880 criterion 7 — neither side's state is known, so nothing is
|
|
7187
|
+
// written. Named as a failure rather than a skip: the file was NOT
|
|
7188
|
+
// pushed, and a run that reported it as skipped-unchanged would be
|
|
7189
|
+
// claiming a comparison it never made.
|
|
7190
|
+
if (outcome.action === "live-unavailable") {
|
|
7191
|
+
applyFailures.push({
|
|
7192
|
+
type,
|
|
7193
|
+
key,
|
|
7194
|
+
message: "live state could not be read and the manifest holds no " +
|
|
7195
|
+
"last-sync content hash for this file, so push cannot tell " +
|
|
7196
|
+
"which side moved. Nothing was written. Run `config pull` to " +
|
|
7197
|
+
"take the server's state, or `config push --force` to " +
|
|
7198
|
+
"overwrite it.",
|
|
7199
|
+
});
|
|
7200
|
+
return;
|
|
7201
|
+
}
|
|
6261
7202
|
if (outcome.action === "immutable") {
|
|
6262
7203
|
applyFailures.push({
|
|
6263
7204
|
type,
|
|
@@ -6381,6 +7322,37 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6381
7322
|
const promptKeyToId = new Map();
|
|
6382
7323
|
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
6383
7324
|
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
7325
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
7326
|
+
const integrationConfigNameToId = new Map(); // "integrationKey#configName" → configId
|
|
7327
|
+
/**
|
|
7328
|
+
* The id→name direction the test-case comparison reads a reference
|
|
7329
|
+
* through (#2880). Filled from the name→id maps above before the
|
|
7330
|
+
* test-case legs run, and REFILLED as each block's configs are resolved:
|
|
7331
|
+
* the four maps are populated lazily, per block, as a case names one, so
|
|
7332
|
+
* a single fill before the legs start would leave every script and
|
|
7333
|
+
* integration reference — and every prompt this push had no reason to
|
|
7334
|
+
* visit — unresolved, which the comparison reads as server drift on a
|
|
7335
|
+
* file nobody touched.
|
|
7336
|
+
*/
|
|
7337
|
+
const pushTestCaseLookupMaps = {
|
|
7338
|
+
configIdToName: new Map(),
|
|
7339
|
+
promptIdToKey: new Map(),
|
|
7340
|
+
};
|
|
7341
|
+
const fillTestCaseLookupMaps = () => {
|
|
7342
|
+
for (const [key, id] of promptKeyToId) {
|
|
7343
|
+
pushTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
7344
|
+
}
|
|
7345
|
+
for (const [scoped, id] of [
|
|
7346
|
+
...promptConfigNameToId,
|
|
7347
|
+
...workflowConfigNameToId,
|
|
7348
|
+
...scriptConfigNameToId,
|
|
7349
|
+
...integrationConfigNameToId,
|
|
7350
|
+
]) {
|
|
7351
|
+
// The map is keyed `<blockKey>#<configName>`; the comparison names
|
|
7352
|
+
// the config, not the block.
|
|
7353
|
+
pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
|
|
7354
|
+
}
|
|
7355
|
+
};
|
|
6384
7356
|
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
6385
7357
|
// before the first mutating call. Push is apply-as-you-go and processes
|
|
6386
7358
|
// entities in a fixed sequence (cron triggers are created *before*
|
|
@@ -6528,7 +7500,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6528
7500
|
try {
|
|
6529
7501
|
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
6530
7502
|
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
6531
|
-
|
|
7503
|
+
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
7504
|
+
// the preflight validates the body that will actually be sent.
|
|
7505
|
+
excludeName: appSettingsNameExcluded({
|
|
7506
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
7507
|
+
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
7508
|
+
fileName: parsedApp?.app?.name,
|
|
7509
|
+
}),
|
|
6532
7510
|
});
|
|
6533
7511
|
for (const e of appErrors) {
|
|
6534
7512
|
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
@@ -6571,38 +7549,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6571
7549
|
// preflight pass, so nothing is applied and no request is issued —
|
|
6572
7550
|
// rather than being dropped on the way to the server and reading as a
|
|
6573
7551
|
// successful push that changed nothing.
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
const filePath = join(typeDir, file);
|
|
6580
|
-
let tomlData;
|
|
6581
|
-
try {
|
|
6582
|
-
tomlData = parseTomlFile(filePath);
|
|
6583
|
-
}
|
|
6584
|
-
catch {
|
|
6585
|
-
// A file that does not parse is reported by the per-type loop
|
|
6586
|
-
// with its own message; nothing to add here.
|
|
6587
|
-
continue;
|
|
6588
|
-
}
|
|
6589
|
-
// The document's own shape first: an unrecognized top-level table
|
|
6590
|
-
// makes every per-table check below read an absent table, so its
|
|
6591
|
-
// silence would be misleading.
|
|
6592
|
-
for (const shapeError of configDocumentShapeErrors(filePath, tomlData, surface)) {
|
|
6593
|
-
preflightValidationErrors.push(` ${shapeError}`);
|
|
6594
|
-
}
|
|
6595
|
-
for (const table of tables) {
|
|
6596
|
-
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
6597
|
-
if (unknownKeyError) {
|
|
6598
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
6599
|
-
}
|
|
6600
|
-
}
|
|
7552
|
+
// #2880 — the same collector `config diff` runs, so the two commands
|
|
7553
|
+
// cannot disagree about which files are pushable.
|
|
7554
|
+
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
7555
|
+
for (const message of rejected.messages) {
|
|
7556
|
+
preflightValidationErrors.push(` ${message}`);
|
|
6601
7557
|
}
|
|
6602
7558
|
}
|
|
6603
7559
|
// The `<key>.tests/*.toml` sidecars, which sit beside the block they
|
|
6604
7560
|
// test rather than in a type directory of their own. Same posture,
|
|
6605
|
-
// reached through the registry's `test-case` surface
|
|
7561
|
+
// reached through the registry's `test-case` surface — and through the
|
|
7562
|
+
// same collector `config diff` runs per sidecar (#2880), so neither
|
|
7563
|
+
// command can refuse a file the other calls Synced.
|
|
6606
7564
|
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
6607
7565
|
let tomlData;
|
|
6608
7566
|
try {
|
|
@@ -6612,18 +7570,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6612
7570
|
// Reported by the per-block test-case loop with its own message.
|
|
6613
7571
|
continue;
|
|
6614
7572
|
}
|
|
6615
|
-
for (const
|
|
6616
|
-
preflightValidationErrors.push(` ${
|
|
6617
|
-
}
|
|
6618
|
-
const unknownKeyError = configUnknownKeyError(filePath, tomlData, TEST_CASE_TABLE);
|
|
6619
|
-
if (unknownKeyError) {
|
|
6620
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
6621
|
-
}
|
|
6622
|
-
// #2769 — with TOML the only authoring path, a sidecar the server
|
|
6623
|
-
// would reject (no `name`) or whose JSON text does not parse fails
|
|
6624
|
-
// here, before any test case is created or updated.
|
|
6625
|
-
for (const shapeError of testCaseSidecarErrors(filePath, tomlData)) {
|
|
6626
|
-
preflightValidationErrors.push(` ${shapeError}`);
|
|
7573
|
+
for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
|
|
7574
|
+
preflightValidationErrors.push(` ${message}`);
|
|
6627
7575
|
}
|
|
6628
7576
|
}
|
|
6629
7577
|
if (preflightValidationErrors.length > 0) {
|
|
@@ -6768,36 +7716,188 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6768
7716
|
// else in the slot (issue #2645): a full push applies it, and
|
|
6769
7717
|
// `--only app` applies it alone. The separate `settings push` command
|
|
6770
7718
|
// that used to own this is gone.
|
|
7719
|
+
//
|
|
7720
|
+
// #2880 behaviors 10, 11 and 14 — this block used to answer "changed?"
|
|
7721
|
+
// from the file's BYTES and then PATCH only the keys the file spelled.
|
|
7722
|
+
// Both are gone. The comparison is the one `config diff` reports, over
|
|
7723
|
+
// the full writable descriptor set, and the payload is the whole file:
|
|
7724
|
+
// a key the file no longer carries is the clear (or the declared
|
|
7725
|
+
// default) push applies, exactly as it is for every other type (#1567).
|
|
6771
7726
|
const appTomlPath = join(configDir, "app.toml");
|
|
6772
7727
|
if (existsSync(appTomlPath) && (!pushSelection || pushSelection.app)) {
|
|
6773
|
-
const
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
7728
|
+
const tomlData = parseTomlFile(appTomlPath);
|
|
7729
|
+
const appEntry = syncState?.entities?.app;
|
|
7730
|
+
const appFileHash = computeFileHash(appTomlPath);
|
|
7731
|
+
const appFileName = tomlData?.app?.name;
|
|
7732
|
+
const recordedExcludedName = appEntry?.excludedName;
|
|
7733
|
+
// DSO-001, the cross-app rule in three parts.
|
|
7734
|
+
//
|
|
7735
|
+
// `payloadExcludesName` — is `[app].name` out of the body? A
|
|
7736
|
+
// cross-app push never renames its target, and neither does a later
|
|
7737
|
+
// push of a file still stating the name that push left behind.
|
|
7738
|
+
//
|
|
7739
|
+
// `compareExcludesName` — are the two hashes computed without it?
|
|
7740
|
+
// While an exclusion RECORD exists they must be, because that is how
|
|
7741
|
+
// the baseline was stamped, and `classifyPushChange` can only answer
|
|
7742
|
+
// `local-edited` when the remote hash equals the baseline.
|
|
7743
|
+
//
|
|
7744
|
+
// `appNameEdited` — the file has moved away from the recorded name.
|
|
7745
|
+
// That is a local rename BY CONSTRUCTION, not by comparison: no
|
|
7746
|
+
// baseline hashed without `name` can ever attribute it.
|
|
7747
|
+
const payloadExcludesName = appSettingsNameExcluded({
|
|
7748
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
7749
|
+
recordedExcludedName,
|
|
7750
|
+
fileName: appFileName,
|
|
7751
|
+
});
|
|
7752
|
+
const compareExcludesName = !!isCrossAppPush || recordedExcludedName !== undefined;
|
|
7753
|
+
const appNameEdited = !isCrossAppPush &&
|
|
7754
|
+
recordedExcludedName !== undefined &&
|
|
7755
|
+
!payloadExcludesName;
|
|
7756
|
+
// The whole-truth body. Errors (an unrecognized or retired key, a
|
|
7757
|
+
// type mismatch, a missing required key) already aborted the push in
|
|
7758
|
+
// the preflight pass above, so only warnings remain here.
|
|
7759
|
+
const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
|
|
7760
|
+
excludeName: payloadExcludesName,
|
|
7761
|
+
});
|
|
7762
|
+
for (const w of appWarnings) {
|
|
7763
|
+
warn(` ${w}`);
|
|
6778
7764
|
}
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
7765
|
+
let appLiveOk = true;
|
|
7766
|
+
let liveSettings;
|
|
7767
|
+
try {
|
|
7768
|
+
liveSettings = await client.getAppSettings(resolvedAppId);
|
|
7769
|
+
}
|
|
7770
|
+
catch (err) {
|
|
7771
|
+
appLiveOk = false;
|
|
7772
|
+
warnLiveUnavailable("app-settings", err);
|
|
7773
|
+
}
|
|
7774
|
+
/** Record the state both sides are now known to be in. */
|
|
7775
|
+
const stampAppEntry = (baselineSettings, options) => {
|
|
7776
|
+
if (!syncState)
|
|
7777
|
+
return;
|
|
7778
|
+
syncState.entities.app = {
|
|
7779
|
+
modifiedAt: baselineSettings?.modifiedAt ||
|
|
7780
|
+
appEntry?.modifiedAt ||
|
|
7781
|
+
new Date().toISOString(),
|
|
7782
|
+
contentHash: appFileHash,
|
|
7783
|
+
semanticHash: hashAppSettingsForDiff(baselineSettings, {
|
|
7784
|
+
excludeName: options.recordExclusion !== undefined ||
|
|
7785
|
+
(options.keepExclusion && compareExcludesName),
|
|
7786
|
+
}),
|
|
7787
|
+
...(options.recordExclusion !== undefined
|
|
7788
|
+
? { excludedName: options.recordExclusion }
|
|
7789
|
+
: options.keepExclusion && recordedExcludedName !== undefined
|
|
7790
|
+
? { excludedName: recordedExcludedName }
|
|
7791
|
+
: {}),
|
|
7792
|
+
};
|
|
7793
|
+
};
|
|
7794
|
+
let applyAppSettings = true;
|
|
7795
|
+
if (appLiveOk) {
|
|
7796
|
+
const compareOptions = { excludeName: compareExcludesName };
|
|
7797
|
+
const localHash = hashAppSettingsForDiff(appPayload, compareOptions);
|
|
7798
|
+
const remoteHash = hashAppSettingsForDiff(liveSettings ?? {}, compareOptions);
|
|
7799
|
+
if (options.force) {
|
|
7800
|
+
// `--force` is the operator saying "local wins": no direction
|
|
7801
|
+
// check, exactly as it is for every other type.
|
|
7802
|
+
}
|
|
7803
|
+
else if (isCrossAppPush) {
|
|
7804
|
+
// The target's settings are not this slot's history: there is no
|
|
7805
|
+
// baseline and none is inferable, and applying the source's
|
|
7806
|
+
// configuration is the whole point of the push. `name` is already
|
|
7807
|
+
// out of the body, so the one thing that could not be recovered
|
|
7808
|
+
// by a later `config pull` is not at risk.
|
|
6790
7809
|
}
|
|
7810
|
+
else {
|
|
7811
|
+
const direction = classifyPushChange({
|
|
7812
|
+
localHash,
|
|
7813
|
+
remoteHash,
|
|
7814
|
+
baselineHash: appEntry?.semanticHash,
|
|
7815
|
+
legacy: {
|
|
7816
|
+
localBytesMatchStoredContentHash: appEntry?.contentHash !== undefined &&
|
|
7817
|
+
appEntry.contentHash === appFileHash,
|
|
7818
|
+
// Deliberately never true: `entities.app.modifiedAt` is
|
|
7819
|
+
// stamped from the LOCAL clock, so it proves nothing about
|
|
7820
|
+
// the server. A pre-upgrade manifest whose bytes have also
|
|
7821
|
+
// moved is therefore `unknown-direction` — a conflict, not a
|
|
7822
|
+
// blind overwrite of state nobody compared.
|
|
7823
|
+
liveModifiedAtMatchesStored: false,
|
|
7824
|
+
},
|
|
7825
|
+
});
|
|
7826
|
+
const fields = appSettingsFieldDiffs(appPayload, liveSettings ?? {}, compareOptions).map((d) => ({
|
|
7827
|
+
field: `[${d.section}].${d.key}`,
|
|
7828
|
+
local: d.local,
|
|
7829
|
+
server: d.remote,
|
|
7830
|
+
}));
|
|
7831
|
+
if (direction === "unchanged") {
|
|
7832
|
+
// A rename still has to go out: the comparison above cannot
|
|
7833
|
+
// see `name` while the exclusion record stands.
|
|
7834
|
+
if (!appNameEdited) {
|
|
7835
|
+
skippedCount++;
|
|
7836
|
+
info(" Skipped app settings (unchanged)");
|
|
7837
|
+
stampAppEntry(liveSettings, { keepExclusion: true });
|
|
7838
|
+
applyAppSettings = false;
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
else if (direction === "server-drifted") {
|
|
7842
|
+
recordDeclined("app-settings", "app.toml", { action: "drift", localHash, remoteHash, fields }, appEntry?.modifiedAt);
|
|
7843
|
+
applyAppSettings = false;
|
|
7844
|
+
}
|
|
7845
|
+
else if (direction === "both-changed" ||
|
|
7846
|
+
direction === "unknown-direction") {
|
|
7847
|
+
recordDeclined("app-settings", "app.toml", {
|
|
7848
|
+
action: "conflict",
|
|
7849
|
+
direction,
|
|
7850
|
+
localHash,
|
|
7851
|
+
remoteHash,
|
|
7852
|
+
fields,
|
|
7853
|
+
serverModifiedAt: liveSettings?.modifiedAt,
|
|
7854
|
+
}, appEntry?.modifiedAt);
|
|
7855
|
+
applyAppSettings = false;
|
|
7856
|
+
}
|
|
7857
|
+
}
|
|
7858
|
+
}
|
|
7859
|
+
else {
|
|
7860
|
+
const degraded = decideDegradedPush({
|
|
7861
|
+
force: options.force,
|
|
7862
|
+
storedContentHash: appEntry?.contentHash,
|
|
7863
|
+
currentFileHash: appFileHash,
|
|
7864
|
+
});
|
|
7865
|
+
if (degraded.action === "skip") {
|
|
7866
|
+
skippedCount++;
|
|
7867
|
+
info(" Skipped app settings (unchanged)");
|
|
7868
|
+
applyAppSettings = false;
|
|
7869
|
+
}
|
|
7870
|
+
else if (degraded.action === "decline") {
|
|
7871
|
+
recordDeclined("app-settings", "app.toml", { action: "live-unavailable" }, appEntry?.modifiedAt);
|
|
7872
|
+
applyAppSettings = false;
|
|
7873
|
+
}
|
|
7874
|
+
}
|
|
7875
|
+
if (applyAppSettings) {
|
|
6791
7876
|
changes.push({ type: "app", action: "update", key: "settings" });
|
|
6792
7877
|
if (!options.dryRun) {
|
|
6793
|
-
await client.updateAppSettings(resolvedAppId,
|
|
7878
|
+
const updated = await client.updateAppSettings(resolvedAppId, appPayload);
|
|
6794
7879
|
info(" Updated app settings");
|
|
6795
|
-
//
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
7880
|
+
// Stamped from what the server RETURNED, not from the body sent:
|
|
7881
|
+
// `PUT /settings` normalizes (a trailing slash off `baseUrl`, a
|
|
7882
|
+
// lower-cased test-account email), so the payload is not what the
|
|
7883
|
+
// next comparison will read. The PUT echoes the full settings; if
|
|
7884
|
+
// a server ever answers with something else, re-read rather than
|
|
7885
|
+
// record a state nobody observed.
|
|
7886
|
+
const baselineSettings = updated && typeof updated === "object" && "mode" in updated
|
|
7887
|
+
? updated
|
|
7888
|
+
: await client
|
|
7889
|
+
.getAppSettings(resolvedAppId)
|
|
7890
|
+
.catch(() => undefined);
|
|
7891
|
+
if (baselineSettings) {
|
|
7892
|
+
stampAppEntry(baselineSettings, {
|
|
7893
|
+
keepExclusion: false,
|
|
7894
|
+
// A cross-app apply is what CREATES the exclusion: the
|
|
7895
|
+
// target keeps its own name, and the file's name is recorded
|
|
7896
|
+
// so a later edit to that line reads as the rename it is.
|
|
7897
|
+
...(isCrossAppPush && appFileName !== undefined
|
|
7898
|
+
? { recordExclusion: String(appFileName) }
|
|
7899
|
+
: {}),
|
|
7900
|
+
});
|
|
6801
7901
|
}
|
|
6802
7902
|
}
|
|
6803
7903
|
}
|
|
@@ -7020,13 +8120,26 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7020
8120
|
continue;
|
|
7021
8121
|
}
|
|
7022
8122
|
}
|
|
7023
|
-
else
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
8123
|
+
else {
|
|
8124
|
+
// Fallback gate: the manifest byte hash, and — since #2880 — a
|
|
8125
|
+
// decline rather than a blind write when there is no hash.
|
|
8126
|
+
const degraded = decideDegradedPush({
|
|
8127
|
+
force: options.force,
|
|
8128
|
+
storedContentHash: entry?.contentHash,
|
|
8129
|
+
currentFileHash: computeFileHash(filePath),
|
|
8130
|
+
});
|
|
8131
|
+
if (degraded.action === "skip") {
|
|
8132
|
+
skippedCount++;
|
|
8133
|
+
if (existingId)
|
|
8134
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
8135
|
+
continue;
|
|
8136
|
+
}
|
|
8137
|
+
if (degraded.action === "decline") {
|
|
8138
|
+
recordDeclined("rule-set", fileKey, { action: "live-unavailable" }, entry?.modifiedAt);
|
|
8139
|
+
if (existingId)
|
|
8140
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
8141
|
+
continue;
|
|
8142
|
+
}
|
|
7030
8143
|
}
|
|
7031
8144
|
if (existingId) {
|
|
7032
8145
|
// Update existing rule set
|
|
@@ -7167,17 +8280,112 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7167
8280
|
const integrationsDir = join(configDir, "integrations");
|
|
7168
8281
|
if (existsSync(integrationsDir)) {
|
|
7169
8282
|
const files = selectFiles("integration", integrationsDir, readdirSync(integrationsDir).filter((f) => f.endsWith(".toml")));
|
|
8283
|
+
// #2880 — integrations join the live-state reconcile (#2731 B3), for
|
|
8284
|
+
// the reason webhooks did: diff compared presence while push gated on
|
|
8285
|
+
// the file's bytes, so a field edit was invisible to one command and
|
|
8286
|
+
// applied by the other, and a comment was a change to the other and
|
|
8287
|
+
// not the one.
|
|
8288
|
+
const integrationSpec = configDiffSpec("integration");
|
|
8289
|
+
const liveIntegrations = new Map();
|
|
8290
|
+
let integrationsLiveOk = true;
|
|
8291
|
+
if (files.length > 0) {
|
|
8292
|
+
try {
|
|
8293
|
+
const listed = await fetchAll((p) => client.listIntegrations(resolvedAppId, p));
|
|
8294
|
+
for (const i of listed) {
|
|
8295
|
+
if (i?.integrationKey)
|
|
8296
|
+
liveIntegrations.set(i.integrationKey, i);
|
|
8297
|
+
}
|
|
8298
|
+
}
|
|
8299
|
+
catch (err) {
|
|
8300
|
+
integrationsLiveOk = false;
|
|
8301
|
+
warnLiveUnavailable("integrations", err);
|
|
8302
|
+
}
|
|
8303
|
+
}
|
|
8304
|
+
/** The detail — `accessRule` and `requestConfig` are not on the list. */
|
|
8305
|
+
const refetchLiveIntegration = async (integrationId) => {
|
|
8306
|
+
const full = await client.getIntegration(resolvedAppId, integrationId);
|
|
8307
|
+
return full ? { record: full } : null;
|
|
8308
|
+
};
|
|
7170
8309
|
for (const file of files) {
|
|
7171
8310
|
const filePath = join(integrationsDir, file);
|
|
7172
8311
|
const tomlData = parseTomlFile(filePath);
|
|
7173
|
-
const
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
8312
|
+
const key = tomlData.integration?.key || basename(file, ".toml");
|
|
8313
|
+
// The gate re-parses the DOCUMENT, so the key the file left to its
|
|
8314
|
+
// name has to be in it (see `withDerivedIdentity`).
|
|
8315
|
+
withDerivedIdentity(tomlData, "integration", { key });
|
|
8316
|
+
const existingEntry = syncState?.entities?.integrations?.[key];
|
|
8317
|
+
const liveListed = liveIntegrations.get(key);
|
|
8318
|
+
let integrationLiveOk = integrationsLiveOk;
|
|
8319
|
+
let liveIntegration = undefined;
|
|
8320
|
+
if (integrationLiveOk && liveListed?.integrationId) {
|
|
8321
|
+
try {
|
|
8322
|
+
liveIntegration = await client.getIntegration(resolvedAppId, liveListed.integrationId);
|
|
8323
|
+
}
|
|
8324
|
+
catch (err) {
|
|
8325
|
+
integrationLiveOk = false;
|
|
8326
|
+
warnLiveUnavailable(`integration ${key}`, err);
|
|
8327
|
+
}
|
|
8328
|
+
}
|
|
8329
|
+
let gate;
|
|
8330
|
+
if (integrationLiveOk) {
|
|
8331
|
+
gate = decidePushForConfig({
|
|
8332
|
+
spec: integrationSpec,
|
|
8333
|
+
maps: pushDiffMaps,
|
|
8334
|
+
localParsed: tomlData,
|
|
8335
|
+
live: liveIntegration
|
|
8336
|
+
? {
|
|
8337
|
+
record: liveIntegration,
|
|
8338
|
+
modifiedAt: liveIntegration.modifiedAt,
|
|
8339
|
+
}
|
|
8340
|
+
: null,
|
|
8341
|
+
entry: existingEntry,
|
|
8342
|
+
localFileHash: computeFileHash(filePath),
|
|
8343
|
+
force: options.force,
|
|
8344
|
+
});
|
|
8345
|
+
if (gate.action === "skip") {
|
|
8346
|
+
skippedCount++;
|
|
8347
|
+
if (syncState && liveIntegration?.integrationId) {
|
|
8348
|
+
if (!syncState.entities.integrations) {
|
|
8349
|
+
syncState.entities.integrations = {};
|
|
8350
|
+
}
|
|
8351
|
+
syncState.entities.integrations[key] = {
|
|
8352
|
+
id: liveIntegration.integrationId,
|
|
8353
|
+
modifiedAt: liveIntegration.modifiedAt ||
|
|
8354
|
+
existingEntry?.modifiedAt ||
|
|
8355
|
+
new Date().toISOString(),
|
|
8356
|
+
contentHash: computeFileHash(filePath),
|
|
8357
|
+
semanticHash: gate.remoteHash,
|
|
8358
|
+
};
|
|
8359
|
+
}
|
|
8360
|
+
continue;
|
|
8361
|
+
}
|
|
8362
|
+
if (gate.action === "drift" ||
|
|
8363
|
+
gate.action === "conflict" ||
|
|
8364
|
+
gate.action === "immutable") {
|
|
8365
|
+
recordDeclined("integration", key, gate, existingEntry?.modifiedAt);
|
|
8366
|
+
continue;
|
|
8367
|
+
}
|
|
8368
|
+
}
|
|
8369
|
+
else {
|
|
8370
|
+
const degraded = decideDegradedPush({
|
|
8371
|
+
force: options.force,
|
|
8372
|
+
storedContentHash: existingEntry?.contentHash,
|
|
8373
|
+
currentFileHash: computeFileHash(filePath),
|
|
8374
|
+
});
|
|
8375
|
+
if (degraded.action === "skip") {
|
|
8376
|
+
skippedCount++;
|
|
8377
|
+
continue;
|
|
8378
|
+
}
|
|
8379
|
+
if (degraded.action === "decline") {
|
|
8380
|
+
recordDeclined("integration", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
8381
|
+
continue;
|
|
8382
|
+
}
|
|
7180
8383
|
}
|
|
8384
|
+
const existingId = resolveExistingId({
|
|
8385
|
+
liveOk: integrationLiveOk,
|
|
8386
|
+
liveId: liveListed?.integrationId,
|
|
8387
|
+
manifestId: existingEntry?.id,
|
|
8388
|
+
});
|
|
7181
8389
|
// Same body on the create, update, and adopt-then-update paths.
|
|
7182
8390
|
const payload = buildIntegrationPushPayload(key, tomlData);
|
|
7183
8391
|
// #2631 — `accessRule` is TOML-owned, so a file with no rule pushes
|
|
@@ -7191,26 +8399,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7191
8399
|
if (existingId) {
|
|
7192
8400
|
changes.push({ type: "integration", action: "update", key });
|
|
7193
8401
|
if (!options.dryRun) {
|
|
8402
|
+
// #2731 B4 — the guard is the LIVE timestamp the reconcile just
|
|
8403
|
+
// read (the gate carries it), not the manifest's.
|
|
7194
8404
|
const expectedModifiedAt = options.force
|
|
7195
8405
|
? undefined
|
|
7196
|
-
:
|
|
8406
|
+
: gate?.action === "update"
|
|
8407
|
+
? gate.expectedModifiedAt
|
|
8408
|
+
: existingEntry?.modifiedAt;
|
|
7197
8409
|
try {
|
|
7198
8410
|
const updated = await client.updateIntegration(resolvedAppId, existingId, payload, expectedModifiedAt);
|
|
7199
8411
|
info(` Updated integration: ${key}`);
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
8412
|
+
if (syncState) {
|
|
8413
|
+
if (!syncState.entities.integrations) {
|
|
8414
|
+
syncState.entities.integrations = {};
|
|
8415
|
+
}
|
|
8416
|
+
syncState.entities.integrations[key] = {
|
|
8417
|
+
id: existingId,
|
|
8418
|
+
modifiedAt: updated?.modifiedAt ||
|
|
8419
|
+
existingEntry?.modifiedAt ||
|
|
8420
|
+
new Date().toISOString(),
|
|
8421
|
+
contentHash: computeFileHash(filePath),
|
|
8422
|
+
// From the entity the SERVER returned: the handler
|
|
8423
|
+
// canonicalizes `requestConfig`, so the payload sent is
|
|
8424
|
+
// not what the next comparison will see.
|
|
8425
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existingId)),
|
|
8426
|
+
};
|
|
7204
8427
|
}
|
|
7205
8428
|
}
|
|
7206
8429
|
catch (err) {
|
|
7207
8430
|
if (err instanceof ConflictError) {
|
|
7208
|
-
conflicts.push(
|
|
7209
|
-
type: "integration",
|
|
7210
|
-
key,
|
|
7211
|
-
serverModifiedAt: err.serverModifiedAt,
|
|
7212
|
-
localModifiedAt: expectedModifiedAt || "unknown",
|
|
7213
|
-
});
|
|
8431
|
+
conflicts.push(await lateConflict("integration", key, err, expectedModifiedAt, () => refetchLiveIntegration(existingId), integrationSpec, tomlData));
|
|
7214
8432
|
}
|
|
7215
8433
|
else {
|
|
7216
8434
|
throw wrapEntityError(err, "update", "integration", key);
|
|
@@ -7233,6 +8451,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7233
8451
|
id: created.integrationId,
|
|
7234
8452
|
modifiedAt: created.modifiedAt,
|
|
7235
8453
|
contentHash: computeFileHash(filePath),
|
|
8454
|
+
// #2880 — a create records its baseline too, read from
|
|
8455
|
+
// the stored entity: the handler materializes the
|
|
8456
|
+
// defaults and canonicalizes `requestConfig`, so a
|
|
8457
|
+
// baseline taken from the file would read those as a
|
|
8458
|
+
// difference on the very next run.
|
|
8459
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, created, () => refetchLiveIntegration(created.integrationId)),
|
|
7236
8460
|
};
|
|
7237
8461
|
}
|
|
7238
8462
|
}
|
|
@@ -7264,6 +8488,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7264
8488
|
existing.modifiedAt ||
|
|
7265
8489
|
new Date().toISOString(),
|
|
7266
8490
|
contentHash: computeFileHash(filePath),
|
|
8491
|
+
// #2880 — an adoption converges here, so it stamps the
|
|
8492
|
+
// baseline the next run classifies against.
|
|
8493
|
+
semanticHash: await stampRemoteSemanticHash(integrationSpec, updated, () => refetchLiveIntegration(existing.integrationId)),
|
|
7267
8494
|
};
|
|
7268
8495
|
}
|
|
7269
8496
|
}
|
|
@@ -7275,42 +8502,175 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7275
8502
|
const webhooksDir = join(configDir, "webhooks");
|
|
7276
8503
|
if (existsSync(webhooksDir)) {
|
|
7277
8504
|
const files = selectFiles("webhook", webhooksDir, readdirSync(webhooksDir).filter((f) => f.endsWith(".toml")));
|
|
8505
|
+
// #2880 — webhooks join the live-state reconcile (#2731 B3). #2757
|
|
8506
|
+
// gave the type diff's comparator and left push on the manifest byte
|
|
8507
|
+
// hash, so the two commands answered "changed?" from different
|
|
8508
|
+
// sources and disagreed in both directions: a comment moved the bytes
|
|
8509
|
+
// and push planned an update diff read as Synced, and a file whose
|
|
8510
|
+
// bytes had not moved was skipped however far its CONTENT had drifted
|
|
8511
|
+
// from the server, so a reported `Modified` could never be pushed
|
|
8512
|
+
// away. Both are the same fix — push decides from the projection diff
|
|
8513
|
+
// compares, against the entity diff compares it to.
|
|
8514
|
+
//
|
|
8515
|
+
// It also retires `modifiedAt` as a CONTENT signal wherever a
|
|
8516
|
+
// baseline exists, which is what stops a delivery (whose
|
|
8517
|
+
// `lastTriggeredAt` bumps `modifiedAt`) from reading as a permanent
|
|
8518
|
+
// conflict on an unedited file (#2894).
|
|
8519
|
+
const webhookSpec = configDiffSpec("webhook");
|
|
8520
|
+
// The list says WHICH webhooks the server has and under which id; the
|
|
8521
|
+
// detail is what `config pull` serializes from, so the comparison
|
|
8522
|
+
// fetches it per selected file. A failed list is NOT an empty list —
|
|
8523
|
+
// treating it as one would send every webhook down the create path —
|
|
8524
|
+
// so it degrades the section to the byte-hash gate, type named.
|
|
8525
|
+
const liveWebhooks = new Map();
|
|
8526
|
+
let webhooksLiveOk = true;
|
|
8527
|
+
if (files.length > 0) {
|
|
8528
|
+
try {
|
|
8529
|
+
const listed = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
|
|
8530
|
+
for (const w of listed) {
|
|
8531
|
+
if (w?.webhookKey)
|
|
8532
|
+
liveWebhooks.set(w.webhookKey, w);
|
|
8533
|
+
}
|
|
8534
|
+
}
|
|
8535
|
+
catch (err) {
|
|
8536
|
+
webhooksLiveOk = false;
|
|
8537
|
+
warnLiveUnavailable("webhooks", err);
|
|
8538
|
+
}
|
|
8539
|
+
}
|
|
8540
|
+
/** One webhook as the server holds it NOW — the post-apply baseline. */
|
|
8541
|
+
const refetchLiveWebhook = async (webhookId) => {
|
|
8542
|
+
const full = await client.getWebhook(resolvedAppId, webhookId);
|
|
8543
|
+
return full ? { record: full } : null;
|
|
8544
|
+
};
|
|
7278
8545
|
for (const file of files) {
|
|
7279
8546
|
const filePath = join(webhooksDir, file);
|
|
7280
8547
|
const tomlData = parseTomlFile(filePath);
|
|
7281
|
-
const
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
8548
|
+
const key = tomlData.webhook?.key || basename(file, ".toml");
|
|
8549
|
+
// The gate re-parses the DOCUMENT, so the key the file left to its
|
|
8550
|
+
// name has to be in it (see `withDerivedIdentity`): `displayName`
|
|
8551
|
+
// falls back to the key, so a file stating neither would otherwise
|
|
8552
|
+
// project an empty name against the derived one the server holds.
|
|
8553
|
+
withDerivedIdentity(tomlData, "webhook", { key });
|
|
8554
|
+
const existingEntry = syncState?.entities?.webhooks?.[key];
|
|
8555
|
+
const liveListed = liveWebhooks.get(key);
|
|
8556
|
+
// A detail read that fails degrades THIS webhook to the byte-hash
|
|
8557
|
+
// gate, never the section — its neighbours still reconcile.
|
|
8558
|
+
let webhookLiveOk = webhooksLiveOk;
|
|
8559
|
+
let liveWebhook = undefined;
|
|
8560
|
+
if (webhookLiveOk && liveListed?.webhookId) {
|
|
8561
|
+
try {
|
|
8562
|
+
liveWebhook = await client.getWebhook(resolvedAppId, liveListed.webhookId);
|
|
8563
|
+
}
|
|
8564
|
+
catch (err) {
|
|
8565
|
+
webhookLiveOk = false;
|
|
8566
|
+
warnLiveUnavailable(`webhook ${key}`, err);
|
|
8567
|
+
}
|
|
8568
|
+
}
|
|
8569
|
+
let gate;
|
|
8570
|
+
if (webhookLiveOk) {
|
|
8571
|
+
gate = decidePushForConfig({
|
|
8572
|
+
spec: webhookSpec,
|
|
8573
|
+
// Webhooks carry no rule-set reference, so these maps are inert
|
|
8574
|
+
// for this type — shared with `stampRemoteSemanticHash` so the
|
|
8575
|
+
// gate and the baseline it compares against cannot diverge.
|
|
8576
|
+
maps: pushDiffMaps,
|
|
8577
|
+
localParsed: tomlData,
|
|
8578
|
+
live: liveWebhook
|
|
8579
|
+
? { record: liveWebhook, modifiedAt: liveWebhook.modifiedAt }
|
|
8580
|
+
: null,
|
|
8581
|
+
entry: existingEntry,
|
|
8582
|
+
localFileHash: computeFileHash(filePath),
|
|
8583
|
+
force: options.force,
|
|
8584
|
+
});
|
|
8585
|
+
if (gate.action === "skip") {
|
|
8586
|
+
skippedCount++;
|
|
8587
|
+
if (syncState && liveWebhook?.webhookId) {
|
|
8588
|
+
if (!syncState.entities.webhooks)
|
|
8589
|
+
syncState.entities.webhooks = {};
|
|
8590
|
+
syncState.entities.webhooks[key] = {
|
|
8591
|
+
id: liveWebhook.webhookId,
|
|
8592
|
+
modifiedAt: liveWebhook.modifiedAt ||
|
|
8593
|
+
existingEntry?.modifiedAt ||
|
|
8594
|
+
new Date().toISOString(),
|
|
8595
|
+
contentHash: computeFileHash(filePath),
|
|
8596
|
+
// The two sides agree HERE, so this is the baseline the
|
|
8597
|
+
// next run classifies against — which is what stops a
|
|
8598
|
+
// comment-only edit from re-reporting forever.
|
|
8599
|
+
semanticHash: gate.remoteHash,
|
|
8600
|
+
};
|
|
8601
|
+
}
|
|
8602
|
+
continue;
|
|
8603
|
+
}
|
|
8604
|
+
if (gate.action === "drift" ||
|
|
8605
|
+
gate.action === "conflict" ||
|
|
8606
|
+
gate.action === "immutable") {
|
|
8607
|
+
recordDeclined("webhook", key, gate, existingEntry?.modifiedAt);
|
|
8608
|
+
continue;
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
else {
|
|
8612
|
+
const degraded = decideDegradedPush({
|
|
8613
|
+
force: options.force,
|
|
8614
|
+
storedContentHash: existingEntry?.contentHash,
|
|
8615
|
+
currentFileHash: computeFileHash(filePath),
|
|
8616
|
+
});
|
|
8617
|
+
if (degraded.action === "skip") {
|
|
8618
|
+
skippedCount++;
|
|
8619
|
+
continue;
|
|
8620
|
+
}
|
|
8621
|
+
if (degraded.action === "decline") {
|
|
8622
|
+
recordDeclined("webhook", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
8623
|
+
continue;
|
|
8624
|
+
}
|
|
7288
8625
|
}
|
|
8626
|
+
// Live state decides create-vs-update: a webhook the manifest never
|
|
8627
|
+
// recorded is adopted, and one the manifest still names but the
|
|
8628
|
+
// server no longer has goes to the create path (#1659).
|
|
8629
|
+
const existingId = resolveExistingId({
|
|
8630
|
+
liveOk: webhookLiveOk,
|
|
8631
|
+
liveId: liveListed?.webhookId,
|
|
8632
|
+
manifestId: existingEntry?.id,
|
|
8633
|
+
});
|
|
7289
8634
|
// Same body on the create, update, and adopt-then-update paths,
|
|
7290
8635
|
// built from the vendored definition (#2644).
|
|
7291
8636
|
const payload = buildWebhookPushPayload(key, tomlData);
|
|
7292
8637
|
if (existingId) {
|
|
7293
8638
|
changes.push({ type: "webhook", action: "update", key });
|
|
7294
8639
|
if (!options.dryRun) {
|
|
8640
|
+
// #2731 B4 — the guard is the LIVE timestamp the reconcile
|
|
8641
|
+
// fetch just read (the gate carries it), not the manifest's:
|
|
8642
|
+
// that closes the window between the read and this write
|
|
8643
|
+
// rather than re-reporting a difference we just looked at.
|
|
7295
8644
|
const expectedModifiedAt = options.force
|
|
7296
8645
|
? undefined
|
|
7297
|
-
:
|
|
8646
|
+
: gate?.action === "update"
|
|
8647
|
+
? gate.expectedModifiedAt
|
|
8648
|
+
: existingEntry?.modifiedAt;
|
|
7298
8649
|
try {
|
|
7299
8650
|
const updated = await client.updateWebhook(resolvedAppId, existingId, payload, expectedModifiedAt);
|
|
7300
8651
|
info(` Updated webhook: ${key}`);
|
|
7301
|
-
if (syncState
|
|
7302
|
-
syncState.entities.webhooks
|
|
7303
|
-
|
|
8652
|
+
if (syncState) {
|
|
8653
|
+
if (!syncState.entities.webhooks)
|
|
8654
|
+
syncState.entities.webhooks = {};
|
|
8655
|
+
syncState.entities.webhooks[key] = {
|
|
8656
|
+
id: existingId,
|
|
8657
|
+
modifiedAt: updated?.modifiedAt ||
|
|
8658
|
+
existingEntry?.modifiedAt ||
|
|
8659
|
+
new Date().toISOString(),
|
|
8660
|
+
contentHash: computeFileHash(filePath),
|
|
8661
|
+
// Stamped from the RETURNED entity, not the payload sent:
|
|
8662
|
+
// the handler normalizes, so the payload is not what the
|
|
8663
|
+
// next comparison will see.
|
|
8664
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existingId)),
|
|
8665
|
+
};
|
|
7304
8666
|
}
|
|
7305
8667
|
}
|
|
7306
8668
|
catch (err) {
|
|
7307
8669
|
if (err instanceof ConflictError) {
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
localModifiedAt: expectedModifiedAt || "unknown",
|
|
7313
|
-
});
|
|
8670
|
+
// A late 409: the server moved between the reconcile fetch
|
|
8671
|
+
// and this write, so the gate's field diff describes a state
|
|
8672
|
+
// that is gone. Recompute against the current record.
|
|
8673
|
+
conflicts.push(await lateConflict("webhook", key, err, expectedModifiedAt, () => refetchLiveWebhook(existingId), webhookSpec, tomlData));
|
|
7314
8674
|
}
|
|
7315
8675
|
else {
|
|
7316
8676
|
throw wrapEntityError(err, "update", "webhook", key);
|
|
@@ -7332,6 +8692,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7332
8692
|
id: created.webhookId,
|
|
7333
8693
|
modifiedAt: created.modifiedAt,
|
|
7334
8694
|
contentHash: computeFileHash(filePath),
|
|
8695
|
+
// #2880 — the baseline the next run classifies against,
|
|
8696
|
+
// read from what the server stored rather than what was
|
|
8697
|
+
// sent (the handler normalizes).
|
|
8698
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, created, () => refetchLiveWebhook(created.webhookId)),
|
|
7335
8699
|
};
|
|
7336
8700
|
}
|
|
7337
8701
|
}
|
|
@@ -7363,6 +8727,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7363
8727
|
existing.modifiedAt ||
|
|
7364
8728
|
new Date().toISOString(),
|
|
7365
8729
|
contentHash: computeFileHash(filePath),
|
|
8730
|
+
// #2880 — an adoption converges here, so it stamps the
|
|
8731
|
+
// baseline too; without it the next run has no direction
|
|
8732
|
+
// signal for a row this one just reconciled.
|
|
8733
|
+
semanticHash: await stampRemoteSemanticHash(webhookSpec, updated, () => refetchLiveWebhook(existing.webhookId)),
|
|
7366
8734
|
};
|
|
7367
8735
|
}
|
|
7368
8736
|
}
|
|
@@ -7374,17 +8742,96 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7374
8742
|
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
7375
8743
|
if (existsSync(cronTriggersDir)) {
|
|
7376
8744
|
const files = selectFiles("cron-trigger", cronTriggersDir, readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml")));
|
|
8745
|
+
// #2880 — cron triggers join the live-state reconcile. The LIST row is
|
|
8746
|
+
// the whole record for this type (it is what `config pull`
|
|
8747
|
+
// serializes from), so the section needs no per-entity detail read.
|
|
8748
|
+
// The update endpoint takes no `expectedModifiedAt`, which is exactly
|
|
8749
|
+
// why the read-time direction classification matters here: it is the
|
|
8750
|
+
// only protection this type has ever had, and it was comparing bytes.
|
|
8751
|
+
const cronSpec = configDiffSpec("cron-trigger");
|
|
8752
|
+
const liveCronTriggers = new Map();
|
|
8753
|
+
let cronLiveOk = true;
|
|
8754
|
+
if (files.length > 0) {
|
|
8755
|
+
try {
|
|
8756
|
+
const listed = await client.listCronTriggers(resolvedAppId);
|
|
8757
|
+
for (const t of (listed?.items ?? [])) {
|
|
8758
|
+
if (t?.triggerKey)
|
|
8759
|
+
liveCronTriggers.set(t.triggerKey, t);
|
|
8760
|
+
}
|
|
8761
|
+
}
|
|
8762
|
+
catch (err) {
|
|
8763
|
+
cronLiveOk = false;
|
|
8764
|
+
warnLiveUnavailable("cron-triggers", err);
|
|
8765
|
+
}
|
|
8766
|
+
}
|
|
8767
|
+
const refetchLiveCronTrigger = async (triggerId) => {
|
|
8768
|
+
const full = await client.getCronTrigger(resolvedAppId, triggerId);
|
|
8769
|
+
return full ? { record: full } : null;
|
|
8770
|
+
};
|
|
7377
8771
|
for (const file of files) {
|
|
7378
8772
|
const filePath = join(cronTriggersDir, file);
|
|
7379
8773
|
const tomlData = parseTomlFile(filePath);
|
|
7380
|
-
const
|
|
7381
|
-
|
|
7382
|
-
const
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
8774
|
+
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
8775
|
+
withDerivedIdentity(tomlData, "cronTrigger", { key });
|
|
8776
|
+
const existingEntry = syncState?.entities?.cronTriggers?.[key];
|
|
8777
|
+
const liveCron = liveCronTriggers.get(key);
|
|
8778
|
+
let gate;
|
|
8779
|
+
if (cronLiveOk) {
|
|
8780
|
+
gate = decidePushForConfig({
|
|
8781
|
+
spec: cronSpec,
|
|
8782
|
+
maps: pushDiffMaps,
|
|
8783
|
+
localParsed: tomlData,
|
|
8784
|
+
live: liveCron
|
|
8785
|
+
? { record: liveCron, modifiedAt: liveCron.modifiedAt }
|
|
8786
|
+
: null,
|
|
8787
|
+
entry: existingEntry,
|
|
8788
|
+
localFileHash: computeFileHash(filePath),
|
|
8789
|
+
force: options.force,
|
|
8790
|
+
});
|
|
8791
|
+
if (gate.action === "skip") {
|
|
8792
|
+
skippedCount++;
|
|
8793
|
+
if (syncState && liveCron?.triggerId) {
|
|
8794
|
+
if (!syncState.entities.cronTriggers) {
|
|
8795
|
+
syncState.entities.cronTriggers = {};
|
|
8796
|
+
}
|
|
8797
|
+
syncState.entities.cronTriggers[key] = {
|
|
8798
|
+
id: liveCron.triggerId,
|
|
8799
|
+
modifiedAt: liveCron.modifiedAt ||
|
|
8800
|
+
existingEntry?.modifiedAt ||
|
|
8801
|
+
new Date().toISOString(),
|
|
8802
|
+
contentHash: computeFileHash(filePath),
|
|
8803
|
+
semanticHash: gate.remoteHash,
|
|
8804
|
+
};
|
|
8805
|
+
}
|
|
8806
|
+
continue;
|
|
8807
|
+
}
|
|
8808
|
+
if (gate.action === "drift" ||
|
|
8809
|
+
gate.action === "conflict" ||
|
|
8810
|
+
gate.action === "immutable") {
|
|
8811
|
+
recordDeclined("cron-trigger", key, gate, existingEntry?.modifiedAt);
|
|
8812
|
+
continue;
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
else {
|
|
8816
|
+
const degraded = decideDegradedPush({
|
|
8817
|
+
force: options.force,
|
|
8818
|
+
storedContentHash: existingEntry?.contentHash,
|
|
8819
|
+
currentFileHash: computeFileHash(filePath),
|
|
8820
|
+
});
|
|
8821
|
+
if (degraded.action === "skip") {
|
|
8822
|
+
skippedCount++;
|
|
8823
|
+
continue;
|
|
8824
|
+
}
|
|
8825
|
+
if (degraded.action === "decline") {
|
|
8826
|
+
recordDeclined("cron-trigger", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
8827
|
+
continue;
|
|
8828
|
+
}
|
|
7387
8829
|
}
|
|
8830
|
+
const existingId = resolveExistingId({
|
|
8831
|
+
liveOk: cronLiveOk,
|
|
8832
|
+
liveId: liveCron?.triggerId,
|
|
8833
|
+
manifestId: existingEntry?.id,
|
|
8834
|
+
});
|
|
7388
8835
|
// Built from the vendored definition (#2644). The create body
|
|
7389
8836
|
// omits `state` — the handler assigns it — which is what the
|
|
7390
8837
|
// definition's `writableOn` records.
|
|
@@ -7400,9 +8847,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7400
8847
|
try {
|
|
7401
8848
|
const updated = await client.updateCronTrigger(resolvedAppId, existingId, updatePayload);
|
|
7402
8849
|
info(` Updated cron trigger: ${key}`);
|
|
7403
|
-
if (syncState
|
|
7404
|
-
syncState.entities.cronTriggers
|
|
7405
|
-
|
|
8850
|
+
if (syncState) {
|
|
8851
|
+
if (!syncState.entities.cronTriggers) {
|
|
8852
|
+
syncState.entities.cronTriggers = {};
|
|
8853
|
+
}
|
|
8854
|
+
syncState.entities.cronTriggers[key] = {
|
|
8855
|
+
id: existingId,
|
|
8856
|
+
modifiedAt: updated?.modifiedAt ||
|
|
8857
|
+
existingEntry?.modifiedAt ||
|
|
8858
|
+
new Date().toISOString(),
|
|
8859
|
+
contentHash: computeFileHash(filePath),
|
|
8860
|
+
// #2880 — from the entity the server returned: the
|
|
8861
|
+
// handler materializes `timezone`/`overlapPolicy`.
|
|
8862
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(existingId)),
|
|
8863
|
+
};
|
|
7406
8864
|
}
|
|
7407
8865
|
}
|
|
7408
8866
|
catch (err) {
|
|
@@ -7424,6 +8882,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7424
8882
|
id: created.triggerId,
|
|
7425
8883
|
modifiedAt: created.modifiedAt,
|
|
7426
8884
|
contentHash: computeFileHash(filePath),
|
|
8885
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, created, () => refetchLiveCronTrigger(created.triggerId)),
|
|
7427
8886
|
};
|
|
7428
8887
|
}
|
|
7429
8888
|
}
|
|
@@ -7459,6 +8918,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7459
8918
|
id: adoptedId,
|
|
7460
8919
|
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
7461
8920
|
contentHash: computeFileHash(filePath),
|
|
8921
|
+
semanticHash: await stampRemoteSemanticHash(cronSpec, updated, () => refetchLiveCronTrigger(adoptedId)),
|
|
7462
8922
|
};
|
|
7463
8923
|
}
|
|
7464
8924
|
}
|
|
@@ -7470,17 +8930,97 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7470
8930
|
const blobBucketsPushDir = join(configDir, "blob-buckets");
|
|
7471
8931
|
if (existsSync(blobBucketsPushDir)) {
|
|
7472
8932
|
const files = selectFiles("blob-bucket", blobBucketsPushDir, readdirSync(blobBucketsPushDir).filter((f) => f.endsWith(".toml")));
|
|
8933
|
+
// #2880 — blob buckets join the live-state reconcile. As for cron
|
|
8934
|
+
// triggers the list row IS the record, and the update takes no
|
|
8935
|
+
// concurrency token, so read-time classification is the protection.
|
|
8936
|
+
// `ttlTier` is create-only, so a local change to it surfaces as the
|
|
8937
|
+
// `immutable` outcome — named with its remedy instead of PATCHed into
|
|
8938
|
+
// a success that changes nothing — including under `--force`.
|
|
8939
|
+
const bucketSpec = configDiffSpec("blob-bucket");
|
|
8940
|
+
const liveBuckets = new Map();
|
|
8941
|
+
let bucketsLiveOk = true;
|
|
8942
|
+
if (files.length > 0) {
|
|
8943
|
+
try {
|
|
8944
|
+
const listed = await client.listBlobBuckets(resolvedAppId);
|
|
8945
|
+
for (const b of (listed?.items ?? [])) {
|
|
8946
|
+
if (b?.bucketKey)
|
|
8947
|
+
liveBuckets.set(b.bucketKey, b);
|
|
8948
|
+
}
|
|
8949
|
+
}
|
|
8950
|
+
catch (err) {
|
|
8951
|
+
bucketsLiveOk = false;
|
|
8952
|
+
warnLiveUnavailable("blob-buckets", err);
|
|
8953
|
+
}
|
|
8954
|
+
}
|
|
8955
|
+
const refetchLiveBucket = async (bucketId) => {
|
|
8956
|
+
const full = await client.getBlobBucket(resolvedAppId, bucketId);
|
|
8957
|
+
return full ? { record: full } : null;
|
|
8958
|
+
};
|
|
7473
8959
|
for (const file of files) {
|
|
7474
8960
|
const filePath = join(blobBucketsPushDir, file);
|
|
7475
8961
|
const tomlData = parseTomlFile(filePath);
|
|
8962
|
+
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
8963
|
+
withDerivedIdentity(tomlData, "bucket", { key });
|
|
7476
8964
|
const bucket = tomlData.bucket || {};
|
|
7477
|
-
const
|
|
7478
|
-
const
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
8965
|
+
const existingEntry = syncState?.entities?.blobBuckets?.[key];
|
|
8966
|
+
const liveBucket = liveBuckets.get(key);
|
|
8967
|
+
let gate;
|
|
8968
|
+
if (bucketsLiveOk) {
|
|
8969
|
+
gate = decidePushForConfig({
|
|
8970
|
+
spec: bucketSpec,
|
|
8971
|
+
maps: pushDiffMaps,
|
|
8972
|
+
localParsed: tomlData,
|
|
8973
|
+
live: liveBucket
|
|
8974
|
+
? { record: liveBucket, modifiedAt: liveBucket.modifiedAt }
|
|
8975
|
+
: null,
|
|
8976
|
+
entry: existingEntry,
|
|
8977
|
+
localFileHash: computeFileHash(filePath),
|
|
8978
|
+
force: options.force,
|
|
8979
|
+
});
|
|
8980
|
+
if (gate.action === "skip") {
|
|
8981
|
+
skippedCount++;
|
|
8982
|
+
if (syncState && liveBucket?.bucketId) {
|
|
8983
|
+
if (!syncState.entities.blobBuckets) {
|
|
8984
|
+
syncState.entities.blobBuckets = {};
|
|
8985
|
+
}
|
|
8986
|
+
syncState.entities.blobBuckets[key] = {
|
|
8987
|
+
id: liveBucket.bucketId,
|
|
8988
|
+
modifiedAt: liveBucket.modifiedAt ||
|
|
8989
|
+
existingEntry?.modifiedAt ||
|
|
8990
|
+
new Date().toISOString(),
|
|
8991
|
+
contentHash: computeFileHash(filePath),
|
|
8992
|
+
semanticHash: gate.remoteHash,
|
|
8993
|
+
};
|
|
8994
|
+
}
|
|
8995
|
+
continue;
|
|
8996
|
+
}
|
|
8997
|
+
if (gate.action === "drift" ||
|
|
8998
|
+
gate.action === "conflict" ||
|
|
8999
|
+
gate.action === "immutable") {
|
|
9000
|
+
recordDeclined("blob-bucket", key, gate, existingEntry?.modifiedAt);
|
|
9001
|
+
continue;
|
|
9002
|
+
}
|
|
9003
|
+
}
|
|
9004
|
+
else {
|
|
9005
|
+
const degraded = decideDegradedPush({
|
|
9006
|
+
force: options.force,
|
|
9007
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9008
|
+
currentFileHash: computeFileHash(filePath),
|
|
9009
|
+
});
|
|
9010
|
+
if (degraded.action === "skip") {
|
|
9011
|
+
skippedCount++;
|
|
9012
|
+
continue;
|
|
9013
|
+
}
|
|
9014
|
+
if (degraded.action === "decline") {
|
|
9015
|
+
recordDeclined("blob-bucket", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9016
|
+
continue;
|
|
9017
|
+
}
|
|
7483
9018
|
}
|
|
9019
|
+
const existingId = resolveExistingId({
|
|
9020
|
+
liveOk: bucketsLiveOk,
|
|
9021
|
+
liveId: liveBucket?.bucketId,
|
|
9022
|
+
manifestId: existingEntry?.id,
|
|
9023
|
+
});
|
|
7484
9024
|
if (existingId) {
|
|
7485
9025
|
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
7486
9026
|
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
@@ -7494,9 +9034,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7494
9034
|
// update, and only when this is not a dry run. A failed
|
|
7495
9035
|
// update (or a dry run) must leave the file pending rather
|
|
7496
9036
|
// than mark it in-sync and suppress it on the next push.
|
|
7497
|
-
if (syncState
|
|
7498
|
-
syncState.entities.blobBuckets
|
|
7499
|
-
|
|
9037
|
+
if (syncState) {
|
|
9038
|
+
if (!syncState.entities.blobBuckets) {
|
|
9039
|
+
syncState.entities.blobBuckets = {};
|
|
9040
|
+
}
|
|
9041
|
+
syncState.entities.blobBuckets[key] = {
|
|
9042
|
+
id: existingId,
|
|
9043
|
+
modifiedAt: existingEntry?.modifiedAt || new Date().toISOString(),
|
|
9044
|
+
contentHash: computeFileHash(filePath),
|
|
9045
|
+
// #2880 — the baseline, refetched because the PATCH
|
|
9046
|
+
// response is not the stored record for this type.
|
|
9047
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, undefined, () => refetchLiveBucket(existingId)),
|
|
9048
|
+
};
|
|
7500
9049
|
}
|
|
7501
9050
|
}
|
|
7502
9051
|
catch (err) {
|
|
@@ -7524,6 +9073,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7524
9073
|
id: created.bucketId,
|
|
7525
9074
|
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
7526
9075
|
contentHash: computeFileHash(filePath),
|
|
9076
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, created, () => refetchLiveBucket(created.bucketId)),
|
|
7527
9077
|
};
|
|
7528
9078
|
}
|
|
7529
9079
|
}
|
|
@@ -7594,6 +9144,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7594
9144
|
existing.modifiedAt ||
|
|
7595
9145
|
new Date().toISOString(),
|
|
7596
9146
|
contentHash: computeFileHash(filePath),
|
|
9147
|
+
// #2880 — an adoption converges, so it stamps the
|
|
9148
|
+
// baseline the next run classifies against.
|
|
9149
|
+
semanticHash: await stampRemoteSemanticHash(bucketSpec, updated, () => refetchLiveBucket(existing.bucketId)),
|
|
7597
9150
|
};
|
|
7598
9151
|
}
|
|
7599
9152
|
}
|
|
@@ -7605,11 +9158,59 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7605
9158
|
const promptsDir = join(configDir, "prompts");
|
|
7606
9159
|
if (existsSync(promptsDir)) {
|
|
7607
9160
|
const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
|
|
9161
|
+
// #2869 — prompts join the live-state reconcile (#2731 B3). #2748
|
|
9162
|
+
// gave the type diff's comparator and left push on the manifest byte
|
|
9163
|
+
// hash, so the two commands answered "changed?" from different
|
|
9164
|
+
// sources and disagreed in both directions: a comment moved the bytes
|
|
9165
|
+
// and push planned an update diff read as Synced, and a file whose
|
|
9166
|
+
// bytes had not moved was skipped however far its CONTENT had drifted
|
|
9167
|
+
// from the server, so a reported `Modified` could never be pushed
|
|
9168
|
+
// away. Both are the same fix — push decides from the projection diff
|
|
9169
|
+
// compares, against the entity diff compares it to.
|
|
9170
|
+
const promptSpec = configDiffSpec("prompt");
|
|
9171
|
+
// The list says WHICH prompts the server has (and under which id);
|
|
9172
|
+
// the `[[configs]]` the comparison needs are only on the detail, so
|
|
9173
|
+
// that is fetched inside the loop for the selected file that needs
|
|
9174
|
+
// it. A `--only` push therefore reads only what it was asked to push.
|
|
9175
|
+
//
|
|
9176
|
+
// A failed list is NOT an empty list: treating it as one would send
|
|
9177
|
+
// every prompt down the create path. It degrades the section to the
|
|
9178
|
+
// manifest byte-hash gate with the type named, like every other
|
|
9179
|
+
// converted type.
|
|
9180
|
+
const livePromptIds = new Map();
|
|
9181
|
+
let promptsLiveOk = true;
|
|
9182
|
+
if (files.length > 0) {
|
|
9183
|
+
try {
|
|
9184
|
+
const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
|
|
9185
|
+
for (const p of listed) {
|
|
9186
|
+
if (p?.promptKey)
|
|
9187
|
+
livePromptIds.set(p.promptKey, p.promptId);
|
|
9188
|
+
}
|
|
9189
|
+
}
|
|
9190
|
+
catch (err) {
|
|
9191
|
+
promptsLiveOk = false;
|
|
9192
|
+
warnLiveUnavailable("prompts", err);
|
|
9193
|
+
}
|
|
9194
|
+
}
|
|
9195
|
+
/** One prompt as the server holds it NOW — the baseline after an
|
|
9196
|
+
* apply, and the record a late 409 is reported against. The DETAIL,
|
|
9197
|
+
* because the list carries no `[[configs]]`. */
|
|
9198
|
+
const refetchLivePrompt = async (promptId) => {
|
|
9199
|
+
const full = await client.getPrompt(resolvedAppId, promptId);
|
|
9200
|
+
return full ? { record: full } : null;
|
|
9201
|
+
};
|
|
7608
9202
|
for (const file of files) {
|
|
7609
9203
|
const filePath = join(promptsDir, file);
|
|
7610
9204
|
const tomlData = parseTomlFile(filePath);
|
|
7611
|
-
const
|
|
7612
|
-
|
|
9205
|
+
const key = tomlData.prompt?.key || basename(file, ".toml");
|
|
9206
|
+
// The gate re-parses the DOCUMENT, so the key the file left to its
|
|
9207
|
+
// name has to be in it (see `withDerivedIdentity`): `displayName`
|
|
9208
|
+
// falls back to the key, so a file stating neither would otherwise
|
|
9209
|
+
// project an empty name against the derived one the server holds —
|
|
9210
|
+
// an update no push can clear, since the update writes that same
|
|
9211
|
+
// derived name back. Diff injects the same value before it hashes.
|
|
9212
|
+
withDerivedIdentity(tomlData, "prompt", { key });
|
|
9213
|
+
const prompt = tomlData.prompt;
|
|
7613
9214
|
const configs = tomlData.configs || [];
|
|
7614
9215
|
// #2645 — resolve the `active = true` marker BEFORE anything is
|
|
7615
9216
|
// written. Two marked entries is a file-level mistake, and finding
|
|
@@ -7633,16 +9234,110 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7633
9234
|
});
|
|
7634
9235
|
if (promptRuleWarning)
|
|
7635
9236
|
warn(` ${promptRuleWarning}`);
|
|
7636
|
-
const
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
9237
|
+
const existingEntry = syncState?.entities?.prompts?.[key];
|
|
9238
|
+
const livePromptId = livePromptIds.get(key);
|
|
9239
|
+
// A detail read that fails degrades THIS prompt to the byte-hash
|
|
9240
|
+
// gate, never the section — its neighbours still reconcile.
|
|
9241
|
+
let promptLiveOk = promptsLiveOk;
|
|
9242
|
+
let livePrompt = undefined;
|
|
9243
|
+
if (promptLiveOk && livePromptId) {
|
|
9244
|
+
try {
|
|
9245
|
+
livePrompt = await client.getPrompt(resolvedAppId, livePromptId);
|
|
9246
|
+
}
|
|
9247
|
+
catch (err) {
|
|
9248
|
+
promptLiveOk = false;
|
|
9249
|
+
warnLiveUnavailable(`prompt ${key}`, err);
|
|
9250
|
+
}
|
|
9251
|
+
}
|
|
9252
|
+
let gate;
|
|
9253
|
+
if (promptLiveOk) {
|
|
9254
|
+
gate = decidePushForConfig({
|
|
9255
|
+
spec: promptSpec,
|
|
9256
|
+
// Prompts carry no rule-set reference, so these maps are inert
|
|
9257
|
+
// for this type — shared with `stampRemoteSemanticHash` so the
|
|
9258
|
+
// gate and the baseline it compares against cannot diverge.
|
|
9259
|
+
maps: pushDiffMaps,
|
|
9260
|
+
localParsed: tomlData,
|
|
9261
|
+
live: livePrompt
|
|
9262
|
+
? { record: livePrompt, modifiedAt: livePrompt.modifiedAt }
|
|
9263
|
+
: null,
|
|
9264
|
+
entry: existingEntry,
|
|
9265
|
+
localFileHash: computeFileHash(filePath),
|
|
9266
|
+
force: options.force,
|
|
9267
|
+
// #2909 — the prompt create path adopts an existing prompt by
|
|
9268
|
+
// `promptKey` on a 409 (#1006), so a prompt the manifest has
|
|
9269
|
+
// never recorded is that same adopt, reached one step earlier
|
|
9270
|
+
// because the reconcile above already read the server. Refusing
|
|
9271
|
+
// it here made the documented adopt unreachable: the CREATE the
|
|
9272
|
+
// guard recovers from is never sent when live state names the
|
|
9273
|
+
// prompt, so the push reported a conflict no `config push`
|
|
9274
|
+
// could clear.
|
|
9275
|
+
adoptsUntrackedByKey: true,
|
|
9276
|
+
});
|
|
9277
|
+
if (gate.action === "skip") {
|
|
9278
|
+
skippedCount++;
|
|
9279
|
+
// The config name→ID mappings a test case may need are fetched
|
|
9280
|
+
// by the test legs' `loadBlockConfigs`, on the first case that
|
|
9281
|
+
// names one — a read that no longer depends on this file being
|
|
9282
|
+
// pushed.
|
|
9283
|
+
if (livePromptId)
|
|
9284
|
+
promptKeyToId.set(key, livePromptId);
|
|
9285
|
+
// A skip decided against live state always HAS a live prompt
|
|
9286
|
+
// (there is nothing to compare against otherwise), so the row
|
|
9287
|
+
// records the server's own id.
|
|
9288
|
+
if (syncState && livePromptId) {
|
|
9289
|
+
if (!syncState.entities.prompts)
|
|
9290
|
+
syncState.entities.prompts = {};
|
|
9291
|
+
syncState.entities.prompts[key] = {
|
|
9292
|
+
id: livePromptId,
|
|
9293
|
+
modifiedAt: livePrompt?.modifiedAt ||
|
|
9294
|
+
existingEntry?.modifiedAt ||
|
|
9295
|
+
new Date().toISOString(),
|
|
9296
|
+
contentHash: computeFileHash(filePath),
|
|
9297
|
+
// The two sides agree HERE, so this is the baseline the
|
|
9298
|
+
// next run classifies against — which is what stops a
|
|
9299
|
+
// comment-only edit from re-reporting forever.
|
|
9300
|
+
semanticHash: gate.remoteHash,
|
|
9301
|
+
};
|
|
9302
|
+
}
|
|
9303
|
+
continue;
|
|
9304
|
+
}
|
|
9305
|
+
if (gate.action === "drift" ||
|
|
9306
|
+
gate.action === "conflict" ||
|
|
9307
|
+
gate.action === "immutable") {
|
|
9308
|
+
recordDeclined("prompt", key, gate, existingEntry?.modifiedAt);
|
|
9309
|
+
continue;
|
|
9310
|
+
}
|
|
9311
|
+
}
|
|
9312
|
+
else {
|
|
9313
|
+
// Degraded: the manifest byte-hash gate this section has always
|
|
9314
|
+
// fallen back to when the server cannot be read — and, since
|
|
9315
|
+
// #2880, a decline when there is no baseline to gate on rather
|
|
9316
|
+
// than an unconditional write over unread server state.
|
|
9317
|
+
const degraded = decideDegradedPush({
|
|
9318
|
+
force: options.force,
|
|
9319
|
+
storedContentHash: existingEntry?.contentHash,
|
|
9320
|
+
currentFileHash: computeFileHash(filePath),
|
|
9321
|
+
});
|
|
9322
|
+
if (degraded.action === "skip") {
|
|
9323
|
+
skippedCount++;
|
|
9324
|
+
if (existingEntry?.id)
|
|
9325
|
+
promptKeyToId.set(key, existingEntry.id);
|
|
9326
|
+
continue;
|
|
9327
|
+
}
|
|
9328
|
+
if (degraded.action === "decline") {
|
|
9329
|
+
recordDeclined("prompt", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
9330
|
+
continue;
|
|
9331
|
+
}
|
|
7645
9332
|
}
|
|
9333
|
+
// Live state decides create-vs-update: a prompt the manifest never
|
|
9334
|
+
// recorded is adopted, and one the manifest still names but the
|
|
9335
|
+
// server no longer has goes to the create path (#1659).
|
|
9336
|
+
const existingId = resolveExistingId({
|
|
9337
|
+
liveOk: promptLiveOk,
|
|
9338
|
+
liveId: livePromptId,
|
|
9339
|
+
manifestId: existingEntry?.id,
|
|
9340
|
+
});
|
|
7646
9341
|
// Shared update body for both the in-manifest update branch and the
|
|
7647
9342
|
// adopt-by-key recovery branch (#1006). Re-issues the prompt as an
|
|
7648
9343
|
// UPDATE and syncs its configs (update existing by name, create
|
|
@@ -7663,66 +9358,119 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7663
9358
|
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
7664
9359
|
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
7665
9360
|
}
|
|
7666
|
-
//
|
|
7667
|
-
//
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
7681
|
-
if (serverConfig) {
|
|
7682
|
-
// Update existing config
|
|
7683
|
-
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
|
|
9361
|
+
// #2869 — a prompt is applied by SEVERAL requests: the prompt,
|
|
9362
|
+
// then each `[[configs]]` entry, then the activation. A failure
|
|
9363
|
+
// among them leaves the server holding PART of the file, so the
|
|
9364
|
+
// baseline has to follow it that far. Left at the pre-push state
|
|
9365
|
+
// it would say "both sides moved" on the retry, and an operator
|
|
9366
|
+
// would have to `--force` past a half-applied push of this run's
|
|
9367
|
+
// own making. The content hash still waits for the whole apply,
|
|
9368
|
+
// so the retry still has an edit to send.
|
|
9369
|
+
try {
|
|
9370
|
+
// Fetch full prompt to get config name→ID mappings
|
|
9371
|
+
// (updatePrompt response doesn't include configs)
|
|
9372
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
|
|
9373
|
+
if (fullPrompt?.configs) {
|
|
9374
|
+
for (const config of fullPrompt.configs) {
|
|
9375
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
7684
9376
|
}
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
9377
|
+
}
|
|
9378
|
+
// Update existing prompt configs with TOML values
|
|
9379
|
+
if (fullPrompt?.configs && configs.length > 0) {
|
|
9380
|
+
for (const tomlConfig of configs) {
|
|
9381
|
+
const configName = tomlConfig.name;
|
|
9382
|
+
if (!configName)
|
|
9383
|
+
continue;
|
|
9384
|
+
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
9385
|
+
if (serverConfig) {
|
|
9386
|
+
// Update existing config
|
|
9387
|
+
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
|
|
9388
|
+
}
|
|
9389
|
+
else {
|
|
9390
|
+
// Create new config that doesn't exist on server yet
|
|
9391
|
+
const newConfig = await client.createPromptConfig(resolvedAppId, promptId, buildPromptConfigPayload(tomlConfig, { mode: "create" }));
|
|
9392
|
+
if (newConfig?.configId) {
|
|
9393
|
+
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
9394
|
+
}
|
|
7690
9395
|
}
|
|
7691
9396
|
}
|
|
9397
|
+
info(` Synced ${configs.length} config(s) for prompt: ${key}`);
|
|
7692
9398
|
}
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
9399
|
+
// #2645 — the `active = true` marker is authoritative on update
|
|
9400
|
+
// too, not only on create. Without this the file could name a live
|
|
9401
|
+
// config that was not live, which is the drift the marker exists
|
|
9402
|
+
// to make visible in the first place.
|
|
9403
|
+
const activeName = activeConfigNameForPrompt;
|
|
9404
|
+
let activated = false;
|
|
9405
|
+
if (activeName) {
|
|
9406
|
+
const targetId = promptConfigNameToId.get(`${key}#${activeName}`);
|
|
9407
|
+
if (!targetId) {
|
|
9408
|
+
throw new Error(`${filePath}: active = true names configuration "${activeName}", ` +
|
|
9409
|
+
`which has no [[configs]] entry on the server.`);
|
|
9410
|
+
}
|
|
9411
|
+
if (targetId !== fullPrompt?.activeConfigId) {
|
|
9412
|
+
await client.activatePromptConfig(resolvedAppId, promptId, targetId);
|
|
9413
|
+
info(` Activated config "${activeName}" for prompt: ${key}`);
|
|
9414
|
+
activated = true;
|
|
9415
|
+
}
|
|
7706
9416
|
}
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
9417
|
+
// Everything this push owed the server has landed, so the file is
|
|
9418
|
+
// now applied. Activation writes the prompt record itself, so its
|
|
9419
|
+
// `modifiedAt` has moved again since the PATCH above — re-read it
|
|
9420
|
+
// rather than leave the next push to conflict on a stale token.
|
|
9421
|
+
const promptEntity = syncState?.entities?.prompts?.[key];
|
|
9422
|
+
if (promptEntity) {
|
|
9423
|
+
if (activated) {
|
|
9424
|
+
const refreshed = await client.getPrompt(resolvedAppId, promptId);
|
|
9425
|
+
if (refreshed?.modifiedAt) {
|
|
9426
|
+
promptEntity.modifiedAt = refreshed.modifiedAt;
|
|
9427
|
+
}
|
|
9428
|
+
}
|
|
9429
|
+
promptEntity.contentHash = computeFileHash(filePath);
|
|
9430
|
+
// #2869 B4 — the baseline reads back what the server now holds,
|
|
9431
|
+
// prompt and configs together. The PATCH response carries no
|
|
9432
|
+
// `[[configs]]`, and the entries were applied by their own
|
|
9433
|
+
// requests after it, so only a re-read describes the state both
|
|
9434
|
+
// sides now agree on; a baseline taken from the local file
|
|
9435
|
+
// would stamp values the handlers normalized and make the next
|
|
9436
|
+
// legitimate edit read as a conflict this push created.
|
|
9437
|
+
let applied = null;
|
|
9438
|
+
promptEntity.semanticHash = await stampRemoteSemanticHash(promptSpec, undefined, async () => {
|
|
9439
|
+
const current = await refetchLivePrompt(promptId);
|
|
9440
|
+
applied = current?.record ?? null;
|
|
9441
|
+
return current;
|
|
9442
|
+
});
|
|
9443
|
+
// #2869 — that same read says whether the update actually
|
|
9444
|
+
// CLOSED the difference. Push updates and creates the
|
|
9445
|
+
// `[[configs]]` the file lists; it deletes none and clears no
|
|
9446
|
+
// activation, so a file omitting an entry the server holds
|
|
9447
|
+
// (or naming no active config) is a difference the update
|
|
9448
|
+
// cannot apply. Reported as a clean update it becomes the
|
|
9449
|
+
// disagreement this issue is about one level down: push says
|
|
9450
|
+
// it applied the file, and the next `config diff` reports the
|
|
9451
|
+
// same row again. So name what survived the push.
|
|
9452
|
+
if (applied) {
|
|
9453
|
+
const residual = diffProjectedRecords(projectLocalConfig(promptSpec, tomlData, pushDiffMaps), projectRemoteConfig(promptSpec, applied, pushDiffMaps)).map((d) => d.field);
|
|
9454
|
+
if (residual.length > 0) {
|
|
9455
|
+
warn(` Prompt ${key} still differs from the server after this push ` +
|
|
9456
|
+
`(${residual.join(", ")}) — push does not delete a [[configs]] entry ` +
|
|
9457
|
+
`the file omits, or clear an activation, so config diff keeps ` +
|
|
9458
|
+
`reporting it. Run primitive config pull to take the server's ` +
|
|
9459
|
+
`version, or remove the configuration server-side.`);
|
|
9460
|
+
}
|
|
9461
|
+
}
|
|
7711
9462
|
}
|
|
7712
9463
|
}
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
if (refreshed?.modifiedAt) {
|
|
7722
|
-
promptEntity.modifiedAt = refreshed.modifiedAt;
|
|
7723
|
-
}
|
|
9464
|
+
catch (err) {
|
|
9465
|
+
const partialEntity = syncState?.entities?.prompts?.[key];
|
|
9466
|
+
if (partialEntity) {
|
|
9467
|
+
const partial = await stampRemoteSemanticHash(promptSpec, undefined, () => refetchLivePrompt(promptId));
|
|
9468
|
+
// A baseline that cannot be read is left alone rather than
|
|
9469
|
+
// cleared: the legacy signals are a better answer than none.
|
|
9470
|
+
if (partial)
|
|
9471
|
+
partialEntity.semanticHash = partial;
|
|
7724
9472
|
}
|
|
7725
|
-
|
|
9473
|
+
throw err;
|
|
7726
9474
|
}
|
|
7727
9475
|
};
|
|
7728
9476
|
if (existingId) {
|
|
@@ -7730,9 +9478,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7730
9478
|
changes.push({ type: "prompt", action: "update", key });
|
|
7731
9479
|
promptKeyToId.set(key, existingId);
|
|
7732
9480
|
if (!options.dryRun) {
|
|
9481
|
+
// Say so when this update is an ADOPT (#2909): the operator
|
|
9482
|
+
// never recorded this prompt, so a bare "Updated prompt" would
|
|
9483
|
+
// not tell them the push took over an object it found on the
|
|
9484
|
+
// server. Same sentence the 409 recovery prints.
|
|
9485
|
+
if (gate?.action === "update" && gate.direction === "adopt-untracked") {
|
|
9486
|
+
info(` Prompt already exists on server, adopting by key: ${key}`);
|
|
9487
|
+
}
|
|
9488
|
+
// A prompt adopted from live state has no manifest row yet, and
|
|
9489
|
+
// `applyPromptUpdate` stamps INTO that row — without it the
|
|
9490
|
+
// push would apply and record nothing, and the next run would
|
|
9491
|
+
// re-decide from no baseline at all.
|
|
9492
|
+
if (syncState) {
|
|
9493
|
+
if (!syncState.entities.prompts)
|
|
9494
|
+
syncState.entities.prompts = {};
|
|
9495
|
+
syncState.entities.prompts[key] = {
|
|
9496
|
+
...(existingEntry ?? {
|
|
9497
|
+
modifiedAt: livePrompt?.modifiedAt || "",
|
|
9498
|
+
}),
|
|
9499
|
+
id: existingId,
|
|
9500
|
+
};
|
|
9501
|
+
}
|
|
7733
9502
|
const expectedModifiedAt = options.force
|
|
7734
9503
|
? undefined
|
|
7735
|
-
:
|
|
9504
|
+
: gate?.action === "update"
|
|
9505
|
+
? // The guard is the LIVE timestamp the gate read, not the
|
|
9506
|
+
// manifest's: the reconcile just read the server, so this
|
|
9507
|
+
// closes the window between that read and this write
|
|
9508
|
+
// rather than conflicting on a token a prior push moved.
|
|
9509
|
+
gate.expectedModifiedAt
|
|
9510
|
+
: existingEntry?.modifiedAt;
|
|
7736
9511
|
try {
|
|
7737
9512
|
await applyPromptUpdate(existingId, expectedModifiedAt);
|
|
7738
9513
|
}
|
|
@@ -7927,6 +9702,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7927
9702
|
id: created.promptId,
|
|
7928
9703
|
modifiedAt,
|
|
7929
9704
|
contentHash: computeFileHash(filePath),
|
|
9705
|
+
// #2869 B4 — the SERVER's record, read back with the
|
|
9706
|
+
// configs this create seeded and patched, so a value the
|
|
9707
|
+
// handlers normalized is not stamped as the state both
|
|
9708
|
+
// sides agreed on.
|
|
9709
|
+
semanticHash: await stampRemoteSemanticHash(promptSpec, undefined, () => refetchLivePrompt(created.promptId)),
|
|
7930
9710
|
};
|
|
7931
9711
|
}
|
|
7932
9712
|
}
|
|
@@ -8097,16 +9877,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8097
9877
|
}
|
|
8098
9878
|
: undefined;
|
|
8099
9879
|
}
|
|
8100
|
-
else
|
|
9880
|
+
else {
|
|
8101
9881
|
// Fallback gate: the manifest byte hash. A script with no
|
|
8102
9882
|
// recorded hash has no baseline at all and its apply is a blind
|
|
8103
9883
|
// overwrite, so it FAILS CLOSED rather than pushing on a guess.
|
|
8104
|
-
|
|
8105
|
-
|
|
9884
|
+
// #2880 — through the shared decision, so every type declines
|
|
9885
|
+
// for the same reason in the same words.
|
|
9886
|
+
const degraded = decideDegradedPush({
|
|
9887
|
+
force: options.force,
|
|
9888
|
+
storedContentHash: scriptEntry?.contentHash,
|
|
9889
|
+
currentFileHash: computeFileHash(filePath),
|
|
9890
|
+
});
|
|
9891
|
+
if (degraded.action === "skip") {
|
|
9892
|
+
skippedCount++;
|
|
8106
9893
|
continue;
|
|
8107
9894
|
}
|
|
8108
|
-
if (
|
|
8109
|
-
|
|
9895
|
+
if (degraded.action === "decline") {
|
|
9896
|
+
recordDeclined("transform", name, {
|
|
9897
|
+
action: "live-unavailable",
|
|
9898
|
+
});
|
|
8110
9899
|
continue;
|
|
8111
9900
|
}
|
|
8112
9901
|
}
|
|
@@ -8856,13 +10645,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8856
10645
|
}
|
|
8857
10646
|
}
|
|
8858
10647
|
}
|
|
8859
|
-
// Fallback gate: the manifest byte hash,
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
10648
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
10649
|
+
// decline when there is no hash to gate on.
|
|
10650
|
+
if (!dbTypeGate) {
|
|
10651
|
+
const degraded = decideDegradedPush({
|
|
10652
|
+
force: options.force,
|
|
10653
|
+
storedContentHash: existingEntry?.contentHash,
|
|
10654
|
+
currentFileHash: computeFileHash(filePath),
|
|
10655
|
+
});
|
|
10656
|
+
if (degraded.action === "skip") {
|
|
10657
|
+
skippedCount++;
|
|
10658
|
+
continue;
|
|
10659
|
+
}
|
|
10660
|
+
if (degraded.action === "decline") {
|
|
10661
|
+
recordDeclined("database-type-config", dbType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10662
|
+
continue;
|
|
10663
|
+
}
|
|
8866
10664
|
}
|
|
8867
10665
|
// ── Issues #813 / #1336 / #2732: state what this push's operation
|
|
8868
10666
|
// set will BE, then run a validate-first pass before any mutating
|
|
@@ -10010,9 +11808,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10010
11808
|
continue;
|
|
10011
11809
|
}
|
|
10012
11810
|
}
|
|
10013
|
-
else
|
|
10014
|
-
|
|
10015
|
-
|
|
11811
|
+
else {
|
|
11812
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
11813
|
+
// decline rather than a blind write when there is no hash.
|
|
11814
|
+
const degraded = decideDegradedPush({
|
|
11815
|
+
force: options.force,
|
|
11816
|
+
storedContentHash: existingEntry?.contentHash,
|
|
11817
|
+
currentFileHash: computeFileHash(filePath),
|
|
11818
|
+
});
|
|
11819
|
+
if (degraded.action === "skip") {
|
|
11820
|
+
skippedCount++;
|
|
11821
|
+
continue;
|
|
11822
|
+
}
|
|
11823
|
+
if (degraded.action === "decline") {
|
|
11824
|
+
recordDeclined("group-type-config", groupType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
11825
|
+
continue;
|
|
11826
|
+
}
|
|
10016
11827
|
}
|
|
10017
11828
|
// Live state decides create-vs-update: a config the manifest never
|
|
10018
11829
|
// recorded is adopted, and one the manifest still names but the
|
|
@@ -10207,9 +12018,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10207
12018
|
continue;
|
|
10208
12019
|
}
|
|
10209
12020
|
}
|
|
10210
|
-
else
|
|
10211
|
-
|
|
10212
|
-
|
|
12021
|
+
else {
|
|
12022
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
12023
|
+
// decline rather than a blind write when there is no hash.
|
|
12024
|
+
const degraded = decideDegradedPush({
|
|
12025
|
+
force: options.force,
|
|
12026
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12027
|
+
currentFileHash: computeFileHash(filePath),
|
|
12028
|
+
});
|
|
12029
|
+
if (degraded.action === "skip") {
|
|
12030
|
+
skippedCount++;
|
|
12031
|
+
continue;
|
|
12032
|
+
}
|
|
12033
|
+
if (degraded.action === "decline") {
|
|
12034
|
+
recordDeclined("collection-type-config", collectionType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12035
|
+
continue;
|
|
12036
|
+
}
|
|
10213
12037
|
}
|
|
10214
12038
|
// Live state decides create-vs-update; see the group-type section.
|
|
10215
12039
|
if (resolveExistingId({
|
|
@@ -10394,9 +12218,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10394
12218
|
continue;
|
|
10395
12219
|
}
|
|
10396
12220
|
}
|
|
10397
|
-
else
|
|
10398
|
-
|
|
10399
|
-
|
|
12221
|
+
else {
|
|
12222
|
+
// Fallback gate: the manifest byte hash — and, since #2880, a
|
|
12223
|
+
// decline rather than a blind write when there is no hash.
|
|
12224
|
+
const degraded = decideDegradedPush({
|
|
12225
|
+
force: options.force,
|
|
12226
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12227
|
+
currentFileHash: computeFileHash(filePath),
|
|
12228
|
+
});
|
|
12229
|
+
if (degraded.action === "skip") {
|
|
12230
|
+
skippedCount++;
|
|
12231
|
+
continue;
|
|
12232
|
+
}
|
|
12233
|
+
if (degraded.action === "decline") {
|
|
12234
|
+
recordDeclined("metadata-category-config", entityKey, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12235
|
+
continue;
|
|
12236
|
+
}
|
|
10400
12237
|
}
|
|
10401
12238
|
const isCreate = !resolveExistingId({
|
|
10402
12239
|
liveOk: metadataCategoryLiveOk,
|
|
@@ -10534,17 +12371,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10534
12371
|
continue;
|
|
10535
12372
|
}
|
|
10536
12373
|
}
|
|
10537
|
-
else
|
|
12374
|
+
else {
|
|
10538
12375
|
// Fallback: the byte-hash gate. A legacy entry has no
|
|
10539
12376
|
// `contentHash` to compare against, and this type's apply is a
|
|
10540
12377
|
// blind overwrite of whatever the server holds — so it FAILS
|
|
10541
|
-
// CLOSED
|
|
10542
|
-
|
|
10543
|
-
|
|
12378
|
+
// CLOSED rather than upserting on a guess (#2880: through the
|
|
12379
|
+
// shared decision, in the same words as every other type).
|
|
12380
|
+
const degraded = decideDegradedPush({
|
|
12381
|
+
force: options.force,
|
|
12382
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12383
|
+
currentFileHash: computeFileHash(filePath),
|
|
12384
|
+
});
|
|
12385
|
+
if (degraded.action === "skip") {
|
|
12386
|
+
skippedCount++;
|
|
10544
12387
|
continue;
|
|
10545
12388
|
}
|
|
10546
|
-
if (
|
|
10547
|
-
|
|
12389
|
+
if (degraded.action === "decline") {
|
|
12390
|
+
recordDeclined("email-template", emailType, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10548
12391
|
continue;
|
|
10549
12392
|
}
|
|
10550
12393
|
}
|
|
@@ -10626,8 +12469,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10626
12469
|
}
|
|
10627
12470
|
// Push test cases for every test-bearing block (#2769: integrations
|
|
10628
12471
|
// joined the three that already had a sidecar).
|
|
10629
|
-
|
|
10630
|
-
|
|
12472
|
+
//
|
|
12473
|
+
// #2880 — every block that could name a config has been pushed by now,
|
|
12474
|
+
// so the id→name direction the comparison reads references through has
|
|
12475
|
+
// everything this push learned. It is refilled per case as the lazy
|
|
12476
|
+
// per-block config lookups below resolve more of it.
|
|
12477
|
+
fillTestCaseLookupMaps();
|
|
10631
12478
|
/** The id the last sync recorded for a block, whatever this push selected. */
|
|
10632
12479
|
const recordedBlockId = (blockType, blockKey) => {
|
|
10633
12480
|
const slot = blockType === "prompt"
|
|
@@ -10723,6 +12570,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10723
12570
|
client, appId: resolvedAppId, blockType: "prompt", blockId,
|
|
10724
12571
|
blockKey: promptKey, configDir, syncState,
|
|
10725
12572
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
12573
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
12574
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
10726
12575
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
10727
12576
|
})).skipped;
|
|
10728
12577
|
}
|
|
@@ -10739,6 +12588,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10739
12588
|
client, appId: resolvedAppId, blockType: "workflow", blockId,
|
|
10740
12589
|
blockKey: workflowKey, configDir, syncState,
|
|
10741
12590
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
12591
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
12592
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
10742
12593
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
10743
12594
|
})).skipped;
|
|
10744
12595
|
}
|
|
@@ -10765,6 +12616,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10765
12616
|
client, appId: resolvedAppId, blockType: "script", blockId,
|
|
10766
12617
|
blockKey: scriptName, configDir, syncState,
|
|
10767
12618
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
12619
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
12620
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
10768
12621
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
10769
12622
|
})).skipped;
|
|
10770
12623
|
}
|
|
@@ -10787,6 +12640,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10787
12640
|
client, appId: resolvedAppId, blockType: "integration", blockId,
|
|
10788
12641
|
blockKey: integrationKey, configDir, syncState,
|
|
10789
12642
|
dryRun: options.dryRun, changes, failures: applyFailures,
|
|
12643
|
+
declined: recordDeclined, lookupMaps: pushTestCaseLookupMaps,
|
|
12644
|
+
refreshLookupMaps: fillTestCaseLookupMaps,
|
|
10790
12645
|
resolutionMaps: pushMaps, options: { force: options.force },
|
|
10791
12646
|
})).skipped;
|
|
10792
12647
|
}
|
|
@@ -11312,6 +13167,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11312
13167
|
const localEmailTemplatesParsed = new Map();
|
|
11313
13168
|
// #2757 — the same, for webhooks.
|
|
11314
13169
|
const localWebhooksParsed = new Map();
|
|
13170
|
+
// #2880 — the same, for integrations, cron triggers and blob buckets.
|
|
13171
|
+
const localIntegrationsParsed = new Map();
|
|
13172
|
+
const localCronTriggersParsed = new Map();
|
|
13173
|
+
const localBlobBucketsParsed = new Map();
|
|
11315
13174
|
// #2748 — the same, for prompts.
|
|
11316
13175
|
const localPromptsParsed = new Map();
|
|
11317
13176
|
// Parsed local TOML per key for the five content-aware config types.
|
|
@@ -11326,6 +13185,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11326
13185
|
const tomlData = parseTomlFile(join(integrationsDir, file));
|
|
11327
13186
|
const key = tomlData.integration?.key || basename(file, ".toml");
|
|
11328
13187
|
localIntegrations.add(key);
|
|
13188
|
+
// #2880 — the parsed file is kept so the row can be COMPARED. The
|
|
13189
|
+
// resolved key is injected back the way webhooks and prompts inject
|
|
13190
|
+
// theirs: `[integration].key` is optional (push falls back to the
|
|
13191
|
+
// file name) and the comparison reads it as `displayName`'s
|
|
13192
|
+
// fallback, so a file that omits it would otherwise report Modified
|
|
13193
|
+
// forever — even right after a successful push.
|
|
13194
|
+
if (!tomlData.integration)
|
|
13195
|
+
tomlData.integration = {};
|
|
13196
|
+
tomlData.integration.key = key;
|
|
13197
|
+
localIntegrationsParsed.set(key, tomlData);
|
|
11329
13198
|
}
|
|
11330
13199
|
}
|
|
11331
13200
|
const webhooksDirPath = join(configDir, "webhooks");
|
|
@@ -11352,6 +13221,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11352
13221
|
const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
|
|
11353
13222
|
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
11354
13223
|
localCronTriggers.add(key);
|
|
13224
|
+
// #2880 — kept parsed so the row can be COMPARED, with the resolved
|
|
13225
|
+
// key injected back the way every other type injects its identity.
|
|
13226
|
+
if (!tomlData.cronTrigger)
|
|
13227
|
+
tomlData.cronTrigger = {};
|
|
13228
|
+
tomlData.cronTrigger.key = key;
|
|
13229
|
+
localCronTriggersParsed.set(key, tomlData);
|
|
11355
13230
|
}
|
|
11356
13231
|
}
|
|
11357
13232
|
const blobBucketsDiffDir = join(configDir, "blob-buckets");
|
|
@@ -11360,6 +13235,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11360
13235
|
const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
|
|
11361
13236
|
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
11362
13237
|
localBlobBuckets.add(key);
|
|
13238
|
+
// #2880 — as above.
|
|
13239
|
+
if (!tomlData.bucket)
|
|
13240
|
+
tomlData.bucket = {};
|
|
13241
|
+
tomlData.bucket.key = key;
|
|
13242
|
+
localBlobBucketsParsed.set(key, tomlData);
|
|
11363
13243
|
}
|
|
11364
13244
|
}
|
|
11365
13245
|
const promptsDir = join(configDir, "prompts");
|
|
@@ -11554,14 +13434,57 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11554
13434
|
// left alone. Prior sync state is the discriminator, mirroring the
|
|
11555
13435
|
// local-only split above.
|
|
11556
13436
|
const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
|
|
11557
|
-
// Integrations
|
|
13437
|
+
// Integrations — #2880: content-aware, mirroring the webhook block.
|
|
13438
|
+
//
|
|
13439
|
+
// Existence-only comparison meant an edited `accessRule` or upstream
|
|
13440
|
+
// `[requestConfig]` read as Synced while `config push` applied it from
|
|
13441
|
+
// the file's byte hash. The detail is fetched per integration because
|
|
13442
|
+
// `accessRule` and `requestConfig` are on the detail only, which is
|
|
13443
|
+
// also what `config pull` writes from; a per-integration fetch failure
|
|
13444
|
+
// degrades that ONE row to "content not compared".
|
|
13445
|
+
const integrationSpec = configDiffSpec("integration");
|
|
13446
|
+
// Integrations carry no rule-set reference, so the resolution maps the
|
|
13447
|
+
// shared projection takes are empty for this type.
|
|
13448
|
+
const integrationDiffMaps = {
|
|
13449
|
+
ruleSetIdToName: new Map(),
|
|
13450
|
+
ruleSetNameToId: new Map(),
|
|
13451
|
+
};
|
|
13452
|
+
const remoteIntegrationRecords = new Map(integrationItems.map((i) => [i.integrationKey, i]));
|
|
11558
13453
|
for (const key of localIntegrations) {
|
|
11559
13454
|
if (!remoteIntegrations.has(key)) {
|
|
11560
13455
|
differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
|
|
13456
|
+
continue;
|
|
13457
|
+
}
|
|
13458
|
+
let status = "exists";
|
|
13459
|
+
let hint;
|
|
13460
|
+
const localParsed = localIntegrationsParsed.get(key);
|
|
13461
|
+
const integrationId = remoteIntegrationRecords.get(key)?.integrationId;
|
|
13462
|
+
const detailOutcome = await attemptFetch(async () => integrationId
|
|
13463
|
+
? await client.getIntegration(resolvedAppId, integrationId)
|
|
13464
|
+
: null);
|
|
13465
|
+
if (!detailOutcome.ok || !detailOutcome.value || !localParsed) {
|
|
13466
|
+
hint = "content not compared (fetch failed)";
|
|
11561
13467
|
}
|
|
11562
13468
|
else {
|
|
11563
|
-
|
|
13469
|
+
try {
|
|
13470
|
+
const remoteHash = hashRemoteConfigForDiff(integrationSpec, detailOutcome.value, integrationDiffMaps);
|
|
13471
|
+
const localHash = hashLocalConfigForDiff(integrationSpec, localParsed, integrationDiffMaps);
|
|
13472
|
+
if (remoteHash !== localHash) {
|
|
13473
|
+
status = "modified";
|
|
13474
|
+
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
13475
|
+
}
|
|
13476
|
+
}
|
|
13477
|
+
catch {
|
|
13478
|
+
hint = "content not compared (comparison failed)";
|
|
13479
|
+
}
|
|
11564
13480
|
}
|
|
13481
|
+
// #2803 — availability is operator state, not drift, and it is the
|
|
13482
|
+
// same annotation every carrying type gets, appended to whatever the
|
|
13483
|
+
// content comparison already had to say.
|
|
13484
|
+
hint = outOfServiceHint(detailOutcome.ok && detailOutcome.value
|
|
13485
|
+
? detailOutcome.value
|
|
13486
|
+
: remoteIntegrationRecords.get(key), hint);
|
|
13487
|
+
differences.push({ type: "integration", key, status, hint });
|
|
11565
13488
|
}
|
|
11566
13489
|
for (const key of remoteIntegrations) {
|
|
11567
13490
|
if (!localIntegrations.has(key)) {
|
|
@@ -11603,18 +13526,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11603
13526
|
hint = "content not compared (fetch failed)";
|
|
11604
13527
|
}
|
|
11605
13528
|
else {
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
|
|
11609
|
-
|
|
11610
|
-
|
|
11611
|
-
|
|
11612
|
-
|
|
11613
|
-
|
|
11614
|
-
|
|
11615
|
-
hint = "content not compared (comparison failed)";
|
|
11616
|
-
}
|
|
11617
|
-
}
|
|
13529
|
+
// Through the shared verdict, so the file's unmanaged fields are
|
|
13530
|
+
// read here exactly as the push gate reads them (#2880).
|
|
13531
|
+
const verdict = compareLocalToRemote(webhookSpec, localParsed, detailOutcome.value, webhookDiffMaps);
|
|
13532
|
+
status = verdict.status;
|
|
13533
|
+
hint = verdict.hint;
|
|
13534
|
+
}
|
|
13535
|
+
// #2803 — a paused/archived webhook is operator state, appended to
|
|
13536
|
+
// whatever the content comparison already had to say.
|
|
13537
|
+
hint = outOfServiceHint(detailOutcome.ok ? detailOutcome.value : undefined, hint);
|
|
11618
13538
|
differences.push({ type: "webhook", key, status, hint });
|
|
11619
13539
|
}
|
|
11620
13540
|
for (const key of remoteWebhooks) {
|
|
@@ -11622,35 +13542,43 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11622
13542
|
differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
|
|
11623
13543
|
}
|
|
11624
13544
|
}
|
|
11625
|
-
// Cron Triggers
|
|
13545
|
+
// Cron Triggers — #2880: content-aware. The LIST row is the whole
|
|
13546
|
+
// record for this type (it is what `config pull` serializes from), so
|
|
13547
|
+
// no second fetch is needed; a comparison that throws degrades that ONE
|
|
13548
|
+
// row rather than the diff.
|
|
13549
|
+
const cronSpec = configDiffSpec("cron-trigger");
|
|
11626
13550
|
const remoteCronTriggerRecords = new Map(cronTriggerItemsDiff.map((t) => [t.triggerKey, t]));
|
|
11627
13551
|
for (const key of localCronTriggers) {
|
|
11628
13552
|
if (!remoteCronTriggers.has(key)) {
|
|
11629
13553
|
differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
|
|
13554
|
+
continue;
|
|
11630
13555
|
}
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
|
|
11636
|
-
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
}
|
|
13556
|
+
const { status, hint } = compareLocalToRemote(cronSpec, localCronTriggersParsed.get(key), remoteCronTriggerRecords.get(key));
|
|
13557
|
+
differences.push({
|
|
13558
|
+
type: "cron-trigger",
|
|
13559
|
+
key,
|
|
13560
|
+
status,
|
|
13561
|
+
// #2645 criterion 9 — a paused trigger is operator state, not
|
|
13562
|
+
// drift, appended to whatever the content comparison had to say.
|
|
13563
|
+
hint: outOfServiceHint(remoteCronTriggerRecords.get(key), hint),
|
|
13564
|
+
});
|
|
11640
13565
|
}
|
|
11641
13566
|
for (const key of remoteCronTriggers) {
|
|
11642
13567
|
if (!localCronTriggers.has(key)) {
|
|
11643
13568
|
differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
|
|
11644
13569
|
}
|
|
11645
13570
|
}
|
|
11646
|
-
// Blob Buckets
|
|
13571
|
+
// Blob Buckets — #2880: the same, and the list row is the record here
|
|
13572
|
+
// too.
|
|
13573
|
+
const blobBucketSpec = configDiffSpec("blob-bucket");
|
|
13574
|
+
const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
|
|
11647
13575
|
for (const key of localBlobBuckets) {
|
|
11648
13576
|
if (!remoteBlobBuckets.has(key)) {
|
|
11649
13577
|
differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
|
|
13578
|
+
continue;
|
|
11650
13579
|
}
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
}
|
|
13580
|
+
const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
|
|
13581
|
+
differences.push({ type: "blob-bucket", key, status, hint });
|
|
11654
13582
|
}
|
|
11655
13583
|
for (const key of remoteBlobBuckets) {
|
|
11656
13584
|
if (!localBlobBuckets.has(key)) {
|
|
@@ -11668,6 +13596,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11668
13596
|
// fetches the detail `config pull` writes from. A failed fetch degrades
|
|
11669
13597
|
// that ONE row to "content not compared", never the whole diff.
|
|
11670
13598
|
const promptSpec = configDiffSpec("prompt");
|
|
13599
|
+
/**
|
|
13600
|
+
* The id→name lookups a test case's references are read through
|
|
13601
|
+
* (#2880). Filled from the prompt details this loop already fetches —
|
|
13602
|
+
* a second listing per block would double the diff's request count to
|
|
13603
|
+
* learn what is already on the wire.
|
|
13604
|
+
*/
|
|
13605
|
+
const diffTestCaseLookupMaps = {
|
|
13606
|
+
configIdToName: new Map(),
|
|
13607
|
+
promptIdToKey: new Map(),
|
|
13608
|
+
};
|
|
13609
|
+
for (const p of promptItems) {
|
|
13610
|
+
const key = p?.promptKey;
|
|
13611
|
+
const id = p?.promptId;
|
|
13612
|
+
if (key && id)
|
|
13613
|
+
diffTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
13614
|
+
}
|
|
11671
13615
|
// Prompts carry no rule-set reference, so the resolution maps the shared
|
|
11672
13616
|
// projection takes are empty for this type.
|
|
11673
13617
|
const promptDiffMaps = {
|
|
@@ -11689,6 +13633,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11689
13633
|
hint = "content not compared (fetch failed)";
|
|
11690
13634
|
}
|
|
11691
13635
|
else {
|
|
13636
|
+
// #2880 — the prompt's configs, for the test-case references that
|
|
13637
|
+
// name them.
|
|
13638
|
+
for (const config of detailOutcome.value.configs ?? []) {
|
|
13639
|
+
if (config?.configId && config?.configName) {
|
|
13640
|
+
diffTestCaseLookupMaps.configIdToName.set(config.configId, config.configName);
|
|
13641
|
+
}
|
|
13642
|
+
}
|
|
11692
13643
|
try {
|
|
11693
13644
|
const remoteHash = hashRemoteConfigForDiff(promptSpec, detailOutcome.value, promptDiffMaps);
|
|
11694
13645
|
const localHash = hashLocalConfigForDiff(promptSpec, localParsed, promptDiffMaps);
|
|
@@ -11704,6 +13655,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11704
13655
|
hint = "content not compared (comparison failed)";
|
|
11705
13656
|
}
|
|
11706
13657
|
}
|
|
13658
|
+
// #2803 — an inactive prompt is operator state, appended to whatever
|
|
13659
|
+
// the content comparison already had to say.
|
|
13660
|
+
hint = outOfServiceHint(detailOutcome.ok ? detailOutcome.value : undefined, hint);
|
|
11707
13661
|
differences.push({ type: "prompt", key, status, hint });
|
|
11708
13662
|
}
|
|
11709
13663
|
for (const key of remotePrompts) {
|
|
@@ -11737,8 +13691,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11737
13691
|
// canonicalizes on the way in. Only an explicitly-set non-default
|
|
11738
13692
|
// server value reads as Modified.
|
|
11739
13693
|
const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
|
|
11740
|
-
// #2645 — the list rows carry `
|
|
11741
|
-
// readable without a second fetch per workflow.
|
|
13694
|
+
// #2645/#2803 — the list rows carry the canonical `status`, so
|
|
13695
|
+
// availability is readable without a second fetch per workflow.
|
|
11742
13696
|
const remoteWorkflowRecords = new Map(workflowItems.map((w) => [w.workflowKey, w]));
|
|
11743
13697
|
for (const key of localWorkflows) {
|
|
11744
13698
|
if (!remoteWorkflows.has(key)) {
|
|
@@ -11823,10 +13777,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11823
13777
|
status = "exists";
|
|
11824
13778
|
hint = "content not compared (fetch failed)";
|
|
11825
13779
|
}
|
|
11826
|
-
// #2645 criterion 9 —
|
|
11827
|
-
// still describes it exactly. Annotate the row so the operator
|
|
11828
|
-
// is visible without being mistaken for a difference
|
|
11829
|
-
hint =
|
|
13780
|
+
// #2645 criterion 9 / #2803 — an inactive workflow is not drift; its
|
|
13781
|
+
// TOML still describes it exactly. Annotate the row so the operator
|
|
13782
|
+
// state is visible without being mistaken for a difference.
|
|
13783
|
+
hint = outOfServiceHint(remoteWorkflowRecords.get(key), hint);
|
|
11830
13784
|
differences.push({ type: "workflow", key, status, hint });
|
|
11831
13785
|
}
|
|
11832
13786
|
for (const key of remoteWorkflows) {
|
|
@@ -12057,6 +14011,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12057
14011
|
blockId,
|
|
12058
14012
|
blockKey,
|
|
12059
14013
|
configDir,
|
|
14014
|
+
// #2880 — identity is the manifest id, and the attachment plan
|
|
14015
|
+
// reads the hashes the manifest recorded.
|
|
14016
|
+
syncState,
|
|
14017
|
+
lookupMaps: diffTestCaseLookupMaps,
|
|
12060
14018
|
});
|
|
12061
14019
|
if (!outcome.ok) {
|
|
12062
14020
|
// A block whose listing failed is REPORTED, not skipped in silence.
|
|
@@ -12099,6 +14057,96 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12099
14057
|
await compareTestCases("integration", i.integrationId, key);
|
|
12100
14058
|
}
|
|
12101
14059
|
}
|
|
14060
|
+
// App settings — #2880 behavior 9, subsuming #2875.
|
|
14061
|
+
//
|
|
14062
|
+
// The whole-tree diff used not to compare `app.toml` AT ALL, so an
|
|
14063
|
+
// edited file reported every count as zero: "nothing to push" printed
|
|
14064
|
+
// over a push that would change the running app. `--only app` had the
|
|
14065
|
+
// per-field comparison the whole time; this is that same helper, on the
|
|
14066
|
+
// command an operator actually runs to ask whether anything differs.
|
|
14067
|
+
//
|
|
14068
|
+
// Compared over the FULL writable descriptor set, because push applies
|
|
14069
|
+
// the file as the whole truth (criterion 4): a key the file omits is
|
|
14070
|
+
// the clear push will send, not an absence of opinion. The settings are
|
|
14071
|
+
// fetched only when there is a local file to compare and the selection
|
|
14072
|
+
// asks for it, so an unselected `app.toml` costs no request.
|
|
14073
|
+
const appTomlPathForDiff = join(configDir, "app.toml");
|
|
14074
|
+
if (existsSync(appTomlPathForDiff) &&
|
|
14075
|
+
selectionIncludesApp(diffSelection)) {
|
|
14076
|
+
const appRow = (status, hint) => differences.push({
|
|
14077
|
+
type: "app-settings",
|
|
14078
|
+
key: "app.toml",
|
|
14079
|
+
status,
|
|
14080
|
+
hint,
|
|
14081
|
+
});
|
|
14082
|
+
let appTomlData;
|
|
14083
|
+
try {
|
|
14084
|
+
appTomlData = parseTomlFile(appTomlPathForDiff);
|
|
14085
|
+
}
|
|
14086
|
+
catch (err) {
|
|
14087
|
+
appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
|
|
14088
|
+
}
|
|
14089
|
+
if (appTomlData) {
|
|
14090
|
+
const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData);
|
|
14091
|
+
// The SAME validator push's preflight runs (criterion 1): a
|
|
14092
|
+
// declared-type mismatch, an unrecognized or retired key, and a
|
|
14093
|
+
// missing required key are reported here with push's own message
|
|
14094
|
+
// rather than compared past and called Synced.
|
|
14095
|
+
const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, { excludeName });
|
|
14096
|
+
if (appErrors.length > 0) {
|
|
14097
|
+
appRow(VALIDATION_ERROR, appErrors.join("; "));
|
|
14098
|
+
}
|
|
14099
|
+
else {
|
|
14100
|
+
const settingsOutcome = await attemptFetch(() => client.getAppSettings(resolvedAppId));
|
|
14101
|
+
if (!settingsOutcome.ok) {
|
|
14102
|
+
// #1485's rule, for this type: a failed read is not "matches".
|
|
14103
|
+
notCompared.push({
|
|
14104
|
+
type: "app-settings",
|
|
14105
|
+
reason: gateReason(settingsOutcome.error),
|
|
14106
|
+
});
|
|
14107
|
+
}
|
|
14108
|
+
else {
|
|
14109
|
+
const fieldDiffs = appSettingsFieldDiffs(localAppSettings, settingsOutcome.value ?? {}, { excludeName });
|
|
14110
|
+
if (fieldDiffs.length === 0) {
|
|
14111
|
+
appRow("exists");
|
|
14112
|
+
}
|
|
14113
|
+
else {
|
|
14114
|
+
appRow("modified", `${fieldDiffs.length} field(s) differ: ` +
|
|
14115
|
+
fieldDiffs
|
|
14116
|
+
.map((d) => `[${d.section}].${d.key}`)
|
|
14117
|
+
.join(", ") +
|
|
14118
|
+
" — `config diff --only app` shows the values");
|
|
14119
|
+
}
|
|
14120
|
+
}
|
|
14121
|
+
}
|
|
14122
|
+
}
|
|
14123
|
+
}
|
|
14124
|
+
// #2880 criterion 1 (symmetry) — a file `config push` would refuse is
|
|
14125
|
+
// not a file this command calls Synced.
|
|
14126
|
+
//
|
|
14127
|
+
// The comparison above can only report a difference it can SEE, and a
|
|
14128
|
+
// local key with no server counterpart is invisible to it: the
|
|
14129
|
+
// reporter's prompts still carrying the `[prompt].status` #2803 retired
|
|
14130
|
+
// compared clean field for field while push refused the whole tree. So
|
|
14131
|
+
// the same preflight push runs decides these rows, and its message —
|
|
14132
|
+
// verbatim, so the two commands read identically — replaces whatever
|
|
14133
|
+
// the comparison had to say about the file.
|
|
14134
|
+
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
14135
|
+
const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
|
|
14136
|
+
const hint = rejected.messages.join("; ");
|
|
14137
|
+
if (row) {
|
|
14138
|
+
row.status = VALIDATION_ERROR;
|
|
14139
|
+
row.hint = hint;
|
|
14140
|
+
}
|
|
14141
|
+
else {
|
|
14142
|
+
differences.push({
|
|
14143
|
+
type: rejected.type,
|
|
14144
|
+
key: rejected.key,
|
|
14145
|
+
status: VALIDATION_ERROR,
|
|
14146
|
+
hint,
|
|
14147
|
+
});
|
|
14148
|
+
}
|
|
14149
|
+
}
|
|
12102
14150
|
// `--only` (issue #2645) reports just the objects the operator named.
|
|
12103
14151
|
// The rows are filtered rather than the fetches: every leg keeps its own
|
|
12104
14152
|
// failure handling, so a type whose listing failed is still reported as
|
|
@@ -12106,7 +14154,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12106
14154
|
if (diffSelection) {
|
|
12107
14155
|
differences = differences.filter((row) => row.type === "var"
|
|
12108
14156
|
? diffSelection.varKeys.has(row.key)
|
|
12109
|
-
:
|
|
14157
|
+
: // #2880 — `app.toml` is a root singleton, outside
|
|
14158
|
+
// `SYNC_RESOURCE_TYPES`, so it has its own selection predicate
|
|
14159
|
+
// (the row is only built when this is true, but the filter is
|
|
14160
|
+
// what makes that visible where selection is decided).
|
|
14161
|
+
row.type === "app-settings"
|
|
14162
|
+
? selectionIncludesApp(diffSelection)
|
|
14163
|
+
: selectionIncludesKey(diffSelection, row.type, row.key));
|
|
12110
14164
|
}
|
|
12111
14165
|
divider();
|
|
12112
14166
|
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
@@ -12118,6 +14172,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12118
14172
|
const remoteOnlyUnmanaged = differences.filter((d) => d.status === REMOTE_ONLY_UNMANAGED);
|
|
12119
14173
|
const modified = differences.filter((d) => d.status === "modified");
|
|
12120
14174
|
const existing = differences.filter((d) => d.status === "exists");
|
|
14175
|
+
// #2880 — files push would refuse. Reported before anything else: no
|
|
14176
|
+
// other row's status is trustworthy while a file in the tree cannot be
|
|
14177
|
+
// pushed at all.
|
|
14178
|
+
const invalid = differences.filter((d) => d.status === VALIDATION_ERROR);
|
|
14179
|
+
// #2880 — the same messages `config push` aborts with, so a clean diff
|
|
14180
|
+
// can be read as "nothing to push" and a dirty one names the fix.
|
|
14181
|
+
if (invalid.length > 0) {
|
|
14182
|
+
warn("Invalid — `config push` will refuse these (not compared):");
|
|
14183
|
+
for (const d of invalid) {
|
|
14184
|
+
console.log(` ${chalk.red("✗")} ${d.type}: ${d.key}`);
|
|
14185
|
+
for (const message of (d.hint ?? "").split("; ")) {
|
|
14186
|
+
if (message)
|
|
14187
|
+
console.log(` ${message}`);
|
|
14188
|
+
}
|
|
14189
|
+
}
|
|
14190
|
+
console.log();
|
|
14191
|
+
}
|
|
12121
14192
|
if (localOnly.length > 0) {
|
|
12122
14193
|
info("Local only — new (will be created on push):");
|
|
12123
14194
|
for (const d of localOnly) {
|
|
@@ -12188,17 +14259,35 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12188
14259
|
const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
12189
14260
|
const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
|
|
12190
14261
|
const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
|
|
12191
|
-
|
|
14262
|
+
// #2880 — sidecars whose CONTENT differs, and sidecars push would
|
|
14263
|
+
// refuse. Both used to read as "= test-case: …", because the comparison
|
|
14264
|
+
// stopped at whether the slug existed on both sides.
|
|
14265
|
+
const tcModified = testCaseDiffs.filter((d) => d.status === "modified");
|
|
14266
|
+
const tcInvalid = testCaseDiffs.filter((d) => d.status === VALIDATION_ERROR);
|
|
14267
|
+
if (tcLocalOnly.length > 0 ||
|
|
14268
|
+
tcRemoteOnly.length > 0 ||
|
|
14269
|
+
tcModified.length > 0 ||
|
|
14270
|
+
tcInvalid.length > 0) {
|
|
12192
14271
|
console.log();
|
|
12193
14272
|
info("Test Cases:");
|
|
14273
|
+
for (const d of tcInvalid) {
|
|
14274
|
+
console.log(` ${chalk.red("✗")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
14275
|
+
if (d.hint)
|
|
14276
|
+
console.log(` ${d.hint}`);
|
|
14277
|
+
}
|
|
12194
14278
|
for (const d of tcLocalOnly) {
|
|
12195
14279
|
console.log(` ${chalk.green("+")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
12196
14280
|
}
|
|
12197
14281
|
for (const d of tcRemoteOnly) {
|
|
12198
14282
|
console.log(` ${chalk.red("-")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
|
|
12199
14283
|
}
|
|
14284
|
+
for (const d of tcModified) {
|
|
14285
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
14286
|
+
console.log(` ${chalk.yellow("~")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
14287
|
+
}
|
|
12200
14288
|
for (const d of tcSynced) {
|
|
12201
|
-
|
|
14289
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
14290
|
+
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
12202
14291
|
}
|
|
12203
14292
|
}
|
|
12204
14293
|
divider();
|
|
@@ -12208,13 +14297,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12208
14297
|
keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
|
|
12209
14298
|
keyValue("Modified", modified.length);
|
|
12210
14299
|
keyValue("Synced", existing.length);
|
|
14300
|
+
if (invalid.length > 0) {
|
|
14301
|
+
keyValue("Invalid (push would refuse)", invalid.length);
|
|
14302
|
+
}
|
|
12211
14303
|
if (notCompared.length > 0) {
|
|
12212
14304
|
keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
|
|
12213
14305
|
}
|
|
12214
14306
|
if (testCaseDiffs.length > 0) {
|
|
12215
14307
|
keyValue("Test Cases (local only)", tcLocalOnly.length);
|
|
12216
14308
|
keyValue("Test Cases (remote only)", tcRemoteOnly.length);
|
|
14309
|
+
keyValue("Test Cases (modified)", tcModified.length);
|
|
12217
14310
|
keyValue("Test Cases (synced)", tcSynced.length);
|
|
14311
|
+
if (tcInvalid.length > 0) {
|
|
14312
|
+
keyValue("Test Cases (push would refuse)", tcInvalid.length);
|
|
14313
|
+
}
|
|
12218
14314
|
}
|
|
12219
14315
|
}
|
|
12220
14316
|
catch (err) {
|