primitive-admin 1.2.0-alpha.0 → 1.2.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/bin/primitive.js +22 -24
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/agent-sessions.d.ts +20 -0
- package/dist/src/commands/agent-sessions.js +275 -0
- package/dist/src/commands/agent-sessions.js.map +1 -0
- package/dist/src/commands/apps-children.d.ts +54 -0
- package/dist/src/commands/apps-children.js +578 -0
- package/dist/src/commands/apps-children.js.map +1 -0
- package/dist/src/commands/apps.js +88 -9
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth-sessions.d.ts +3 -3
- package/dist/src/commands/auth-sessions.js +117 -23
- package/dist/src/commands/auth-sessions.js.map +1 -1
- package/dist/src/commands/auth.js +248 -15
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/connections.js +1 -5
- package/dist/src/commands/connections.js.map +1 -1
- package/dist/src/commands/databases.js +7 -109
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +29 -0
- package/dist/src/commands/documents.js +676 -191
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.d.ts +9 -1
- package/dist/src/commands/env.js +158 -45
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.js +7 -1
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/init.js +2 -1
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +14 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +4 -0
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/scripts.js +1 -1
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sessions.js +3 -8
- package/dist/src/commands/sessions.js.map +1 -1
- package/dist/src/commands/sync-app-settings.d.ts +25 -7
- package/dist/src/commands/sync-app-settings.js +81 -20
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +183 -12
- package/dist/src/commands/sync.js +974 -452
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +1 -6
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +77 -2
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +161 -35
- package/dist/src/lib/api-client.js +210 -86
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +9 -1
- package/dist/src/lib/app-settings-descriptor.js +22 -10
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/auth-flow.d.ts +41 -1
- package/dist/src/lib/auth-flow.js +45 -16
- package/dist/src/lib/auth-flow.js.map +1 -1
- package/dist/src/lib/child-apps-local.d.ts +184 -0
- package/dist/src/lib/child-apps-local.js +282 -0
- package/dist/src/lib/child-apps-local.js.map +1 -0
- package/dist/src/lib/ci-session-request.d.ts +22 -0
- package/dist/src/lib/ci-session-request.js +51 -0
- package/dist/src/lib/ci-session-request.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +4 -1
- package/dist/src/lib/codegen-shared/generatedFiles.js +19 -13
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +5 -5
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-payload.js +2 -3
- package/dist/src/lib/config-payload.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +2 -1
- package/dist/src/lib/config-surface.js +7 -28
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +17 -2
- package/dist/src/lib/credentials-store.js +72 -6
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/document-export-lookups.d.ts +31 -0
- package/dist/src/lib/document-export-lookups.js +53 -0
- package/dist/src/lib/document-export-lookups.js.map +1 -0
- package/dist/src/lib/document-export-permissions.d.ts +7 -5
- package/dist/src/lib/document-export-permissions.js +7 -5
- package/dist/src/lib/document-export-permissions.js.map +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +69 -1
- package/dist/src/lib/env-resolver-core.js +137 -20
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +14 -2
- package/dist/src/lib/env-resolver.js +32 -10
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/function-run.d.ts +4 -1
- package/dist/src/lib/function-run.js +24 -4
- package/dist/src/lib/function-run.js.map +1 -1
- package/dist/src/lib/function-sync.d.ts +5 -2
- package/dist/src/lib/function-sync.js +7 -4
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +49 -50
- package/dist/src/lib/generated-config-surfaces.js +283 -166
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +1 -1
- package/dist/src/lib/generated-sdk-types.js +1 -1
- package/dist/src/lib/generated-sdk-types.js.map +1 -1
- package/dist/src/lib/init-adopt.js +12 -3
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/local-state.d.ts +17 -1
- package/dist/src/lib/local-state.js +94 -15
- package/dist/src/lib/local-state.js.map +1 -1
- package/dist/src/lib/local-test-cases.d.ts +5 -2
- package/dist/src/lib/local-test-cases.js +7 -4
- package/dist/src/lib/local-test-cases.js.map +1 -1
- package/dist/src/lib/log-inspection.d.ts +25 -0
- package/dist/src/lib/log-inspection.js +24 -0
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/logout-admin-session.d.ts +2 -1
- package/dist/src/lib/logout-admin-session.js +7 -1
- package/dist/src/lib/logout-admin-session.js.map +1 -1
- package/dist/src/lib/pull-write.d.ts +31 -0
- package/dist/src/lib/pull-write.js +49 -0
- package/dist/src/lib/pull-write.js.map +1 -0
- package/dist/src/lib/root-import-decision.d.ts +59 -0
- package/dist/src/lib/root-import-decision.js +95 -0
- package/dist/src/lib/root-import-decision.js.map +1 -0
- package/dist/src/lib/scope-request.d.ts +69 -0
- package/dist/src/lib/scope-request.js +188 -0
- package/dist/src/lib/scope-request.js.map +1 -0
- package/dist/src/lib/session-id.d.ts +9 -0
- package/dist/src/lib/session-id.js +28 -0
- package/dist/src/lib/session-id.js.map +1 -0
- package/dist/src/lib/snapshots.d.ts +17 -1
- package/dist/src/lib/snapshots.js +50 -2
- package/dist/src/lib/snapshots.js.map +1 -1
- package/dist/src/lib/sync-paths.d.ts +35 -0
- package/dist/src/lib/sync-paths.js +57 -5
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/transient-retry.d.ts +34 -0
- package/dist/src/lib/transient-retry.js +57 -0
- package/dist/src/lib/transient-retry.js.map +1 -0
- package/dist/src/types/index.d.ts +21 -4
- package/package.json +4 -3
|
@@ -13,7 +13,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
|
|
|
13
13
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
14
14
|
import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
|
|
15
15
|
import { getCurrentAppId, getServerUrl } from "../lib/config.js";
|
|
16
|
-
import {
|
|
16
|
+
import { requireCurrentEnvironment } from "../lib/env-resolver.js";
|
|
17
|
+
import { resolveSyncDir, resolveSnapshotsRoot, resolveSyncStateReadPath, resolveSyncStateWritePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
|
|
17
18
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
18
19
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
19
20
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
@@ -22,6 +23,7 @@ import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request
|
|
|
22
23
|
import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
|
|
23
24
|
import { canonicalJsonStringify } from "../lib/canonical-json.js";
|
|
24
25
|
import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
|
|
26
|
+
import { newPullWriteTally, writePulledFile, } from "../lib/pull-write.js";
|
|
25
27
|
import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
|
|
26
28
|
import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
|
|
27
29
|
import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
|
|
@@ -33,12 +35,12 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
|
|
|
33
35
|
// payload builder so the diff normalizer and the wire cannot disagree.
|
|
34
36
|
import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
|
|
35
37
|
// #2644 — the one definition per configuration object, vendored from the server.
|
|
36
|
-
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, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage,
|
|
38
|
+
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, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
37
39
|
import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
|
|
38
40
|
import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
|
|
39
41
|
// #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
|
|
40
42
|
// preflight refuses exactly what the admin routes refuse.
|
|
41
|
-
import {
|
|
43
|
+
import { promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
|
|
42
44
|
// #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
|
|
43
45
|
import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
|
|
44
46
|
// #3799 — the meaning-preserving schema translation, vendored the same way, so
|
|
@@ -98,8 +100,13 @@ function printStaleInstanceWarning(updated) {
|
|
|
98
100
|
warn(` ${w.message}`);
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The current environment's baseline. Every reader goes through
|
|
105
|
+
* `resolveSyncStateReadPath`, so a machine-local environment reads its own
|
|
106
|
+
* machine-local baseline and never the committed tree's (#4117).
|
|
107
|
+
*/
|
|
108
|
+
function loadSyncState() {
|
|
109
|
+
const syncFile = resolveSyncStateReadPath();
|
|
103
110
|
if (!existsSync(syncFile)) {
|
|
104
111
|
return null;
|
|
105
112
|
}
|
|
@@ -110,8 +117,8 @@ function loadSyncState(configDir) {
|
|
|
110
117
|
return null;
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
|
-
function saveSyncState(
|
|
114
|
-
const syncFile =
|
|
120
|
+
function saveSyncState(state) {
|
|
121
|
+
const syncFile = resolveSyncStateWritePath();
|
|
115
122
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
116
123
|
}
|
|
117
124
|
/**
|
|
@@ -449,6 +456,30 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs, logger, opti
|
|
|
449
456
|
const parsed = parseConfigToml(serialized);
|
|
450
457
|
return hashWorkflowTomlForDiff(parsed, undefined, options);
|
|
451
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Whether a workflow file already describes the workflow `remoteToml`
|
|
461
|
+
* serializes (#4160), by `config diff`'s comparison: the normalized hash, with
|
|
462
|
+
* activation compared only when the file names a configuration.
|
|
463
|
+
*
|
|
464
|
+
* Three shapes never match, because pull has work to do on them: a file with
|
|
465
|
+
* `[[include]]` fragments (`reconcileWorkflowIncludes` owns those), one still
|
|
466
|
+
* carrying the retired inline `[[configs]]` list, and any other file push
|
|
467
|
+
* refuses (`pullMustRewrite`).
|
|
468
|
+
*/
|
|
469
|
+
export function sameWorkflowAs(workflowKey, remoteToml) {
|
|
470
|
+
return (localContent) => {
|
|
471
|
+
const local = parseConfigToml(localContent);
|
|
472
|
+
if ("include" in local || "configs" in local)
|
|
473
|
+
return false;
|
|
474
|
+
if (pullMustRewrite("workflow", local))
|
|
475
|
+
return false;
|
|
476
|
+
const compareActivation = Boolean(authoredActiveConfigName(local));
|
|
477
|
+
return (hashWorkflowTomlForDiff(local, workflowKey, { compareActivation }) ===
|
|
478
|
+
hashWorkflowTomlForDiff(parseConfigToml(remoteToml), undefined, {
|
|
479
|
+
compareActivation,
|
|
480
|
+
}));
|
|
481
|
+
};
|
|
482
|
+
}
|
|
452
483
|
/**
|
|
453
484
|
* Build the [auth] block of app.toml from server settings (pull direction).
|
|
454
485
|
*
|
|
@@ -1600,20 +1631,32 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
|
|
|
1600
1631
|
}
|
|
1601
1632
|
return payload;
|
|
1602
1633
|
}
|
|
1603
|
-
export function serializeBlobBucket(bucket, logger) {
|
|
1634
|
+
export function serializeBlobBucket(bucket, ruleSetIdToName = new Map(), logger) {
|
|
1635
|
+
const file = `blob-buckets/${bucket?.bucketKey}.toml`;
|
|
1604
1636
|
// #2644 — the `[bucket]` key set comes from the vendored definition.
|
|
1605
|
-
warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE,
|
|
1637
|
+
warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE, file, logger);
|
|
1638
|
+
// #4116 — the rule set is written by NAME, which every app can resolve. An
|
|
1639
|
+
// id the listing cannot name is reported and left out, never written: an id
|
|
1640
|
+
// in the file is the spelling a copied tree cannot resolve (the #3998 rule).
|
|
1641
|
+
const ruleSetName = bucket?.ruleSetId
|
|
1642
|
+
? ruleSetIdToName.get(bucket.ruleSetId)
|
|
1643
|
+
: undefined;
|
|
1644
|
+
if (bucket?.ruleSetId && !ruleSetName) {
|
|
1645
|
+
logger?.(`${file}: rule set ${bucket.ruleSetId} is not in this app's rule-set listing; ` +
|
|
1646
|
+
"no ruleSetName written (an orphaned or unlisted rule set denies access closed)");
|
|
1647
|
+
}
|
|
1606
1648
|
const data = {
|
|
1607
1649
|
bucket: {
|
|
1608
1650
|
key: bucket.bucketKey,
|
|
1609
1651
|
...projectRecordToToml(BLOB_BUCKET_TABLE, bucket, {
|
|
1610
|
-
// #1020: `preset` is the honest key; a custom bucket carries
|
|
1611
|
-
// instead. Omit preset for custom buckets (preset === "custom").
|
|
1652
|
+
// #1020: `preset` is the honest key; a custom bucket carries its rule
|
|
1653
|
+
// set instead. Omit preset for custom buckets (preset === "custom").
|
|
1612
1654
|
preset: (record) => record.preset && record.preset !== "custom"
|
|
1613
1655
|
? record.preset
|
|
1614
1656
|
: record.ruleSetId
|
|
1615
1657
|
? undefined
|
|
1616
1658
|
: record.preset,
|
|
1659
|
+
ruleSetId: () => ruleSetName,
|
|
1617
1660
|
}),
|
|
1618
1661
|
},
|
|
1619
1662
|
};
|
|
@@ -1624,13 +1667,80 @@ export function serializeBlobBucket(bucket, logger) {
|
|
|
1624
1667
|
});
|
|
1625
1668
|
return stringifyConfigToml(data);
|
|
1626
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* The entity a `blob-buckets/<key>.toml` describes (#4116) — the parse
|
|
1672
|
+
* `config push` builds its bodies from and `config diff` compares.
|
|
1673
|
+
*
|
|
1674
|
+
* Read in CREATE mode, so the immutable `ttlTier` stays nameable. The rule set
|
|
1675
|
+
* is left as `_ruleSetName` for `resolveRuleSetReference`; the deprecated
|
|
1676
|
+
* `ruleSetId` spelling is forwarded as the id it already is. A file carrying
|
|
1677
|
+
* both is refused by the preflight (`blobBucketFileErrors`) before this runs.
|
|
1678
|
+
*
|
|
1679
|
+
* An omitted `name` stays omitted: the update leaves the server's name alone,
|
|
1680
|
+
* and only the create body falls back to the key.
|
|
1681
|
+
*/
|
|
1682
|
+
export function parseBlobBucketToml(doc) {
|
|
1683
|
+
const section = doc?.bucket ?? {};
|
|
1684
|
+
const entity = dropUndefined({
|
|
1685
|
+
bucketKey: section.key ?? "",
|
|
1686
|
+
...buildPayloadFromToml(BLOB_BUCKET_TABLE, section, {
|
|
1687
|
+
mode: "create",
|
|
1688
|
+
values: {
|
|
1689
|
+
name: (value) => value || undefined,
|
|
1690
|
+
description: tomlOwned,
|
|
1691
|
+
},
|
|
1692
|
+
}),
|
|
1693
|
+
});
|
|
1694
|
+
// The builder read the `ruleSetName` value onto the `ruleSetId` field.
|
|
1695
|
+
delete entity.ruleSetId;
|
|
1696
|
+
if (typeof section.ruleSetName === "string" && section.ruleSetName.trim() !== "") {
|
|
1697
|
+
// Trimmed, as `applyRuleSetReference` does for the type configs.
|
|
1698
|
+
entity._ruleSetName = section.ruleSetName.trim();
|
|
1699
|
+
}
|
|
1700
|
+
else if (typeof section.ruleSetId === "string" && section.ruleSetId !== "") {
|
|
1701
|
+
entity.ruleSetId = section.ruleSetId;
|
|
1702
|
+
}
|
|
1703
|
+
return entity;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The advisory `config push` prints for a bucket file still naming its rule set
|
|
1707
|
+
* by id (#4116). The key is deprecated, not retired: the file pushes.
|
|
1708
|
+
*/
|
|
1709
|
+
export function blobBucketDeprecatedKeyWarning(filePath, tomlData) {
|
|
1710
|
+
const bucket = tomlData?.bucket;
|
|
1711
|
+
if (!bucket || bucket.ruleSetId === undefined)
|
|
1712
|
+
return null;
|
|
1713
|
+
// Beside `ruleSetName` it is a refusal instead (`blobBucketFileErrors`).
|
|
1714
|
+
if (bucket.ruleSetName !== undefined)
|
|
1715
|
+
return null;
|
|
1716
|
+
return (`${filePath}: bucket.ruleSetId is deprecated — a rule-set id belongs to one app, ` +
|
|
1717
|
+
`so a copied tree cannot resolve it; write ruleSetName = "<name>" ` +
|
|
1718
|
+
"(config pull rewrites the file). Retiring the key is a later change.");
|
|
1719
|
+
}
|
|
1720
|
+
/** A bucket file's own refusals, decided from the file alone (#4116). */
|
|
1721
|
+
export function blobBucketFileErrors(filePath, tomlData) {
|
|
1722
|
+
const bucket = tomlData?.bucket;
|
|
1723
|
+
if (bucket && bucket.ruleSetId !== undefined && bucket.ruleSetName !== undefined) {
|
|
1724
|
+
return [`${filePath}: bucket.ruleSetId is set beside bucket.ruleSetName; keep ruleSetName`];
|
|
1725
|
+
}
|
|
1726
|
+
// The deprecated spelling is a TOML-only key, so the declared-type check
|
|
1727
|
+
// reads only `ruleSetName`; the old key keeps the field's type under its
|
|
1728
|
+
// own name, or a mistyped id would be dropped from the body unreported.
|
|
1729
|
+
const field = BLOB_BUCKET_TABLE.fields.find((f) => f.field === "ruleSetId");
|
|
1730
|
+
if (!field)
|
|
1731
|
+
return [];
|
|
1732
|
+
return configDeclaredTypeErrors(filePath, tomlData, {
|
|
1733
|
+
...BLOB_BUCKET_TABLE,
|
|
1734
|
+
fields: [{ ...field, tomlKey: "ruleSetId" }],
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1627
1737
|
/**
|
|
1628
1738
|
* Build the PATCH payload for a blob-bucket update from local TOML.
|
|
1629
1739
|
*
|
|
1630
1740
|
* EXACT extraction of the inline builder shared by the existing-update and
|
|
1631
1741
|
* 409-adopt branches — do NOT "clean up" the truthiness checks on the access
|
|
1632
|
-
* model. The server treats `preset` and `
|
|
1633
|
-
* only clears `ruleSetId` when a preset
|
|
1742
|
+
* model. The server treats `preset` and `ruleSetId` as mutually exclusive,
|
|
1743
|
+
* only clears `ruleSetId` when a preset is also present, and
|
|
1634
1744
|
* refuses to leave a bucket with no access model or a blank name — so those
|
|
1635
1745
|
* fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
|
|
1636
1746
|
* sent. Both call sites must go through this helper so their field sets never
|
|
@@ -1639,7 +1749,9 @@ export function serializeBlobBucket(bucket, logger) {
|
|
|
1639
1749
|
export function buildBlobBucketUpdatePayload(bucket) {
|
|
1640
1750
|
// The access model's truthiness checks are the SERVER's rule and stay
|
|
1641
1751
|
// verbatim; the definition contributes the field list, not the semantics.
|
|
1642
|
-
|
|
1752
|
+
// #4116 — reads the parsed ENTITY (`parseBlobBucketToml`, its rule set
|
|
1753
|
+
// resolved), whose field names are the wire keys.
|
|
1754
|
+
const updatePayload = projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
|
|
1643
1755
|
mode: "update",
|
|
1644
1756
|
values: {
|
|
1645
1757
|
// A preset and a rule set are mutually exclusive per request, and a
|
|
@@ -1655,12 +1767,6 @@ export function buildBlobBucketUpdatePayload(bucket) {
|
|
|
1655
1767
|
description: tomlOwned,
|
|
1656
1768
|
},
|
|
1657
1769
|
});
|
|
1658
|
-
// `accessPolicy` is the pre-#1020 spelling of `preset`, declared in the
|
|
1659
|
-
// definition's `tomlOnlyKeys`: an unmigrated file still pushes its access
|
|
1660
|
-
// model, and the server takes at most one of the two.
|
|
1661
|
-
if (!updatePayload.preset && bucket?.accessPolicy) {
|
|
1662
|
-
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
1663
|
-
}
|
|
1664
1770
|
for (const key of Object.keys(updatePayload)) {
|
|
1665
1771
|
if (updatePayload[key] === undefined)
|
|
1666
1772
|
delete updatePayload[key];
|
|
@@ -1743,7 +1849,8 @@ export function promptConfigPayload(tomlConfig, options = {}) {
|
|
|
1743
1849
|
: { description: tomlConfig.description ?? null, ...shared };
|
|
1744
1850
|
}
|
|
1745
1851
|
/**
|
|
1746
|
-
* Build the POST body for a blob-bucket create from
|
|
1852
|
+
* Build the POST body for a blob-bucket create from the parsed entity
|
|
1853
|
+
* (`parseBlobBucketToml`, its rule set resolved).
|
|
1747
1854
|
*
|
|
1748
1855
|
* `ttlTier` is here and absent from the update body because the definition says
|
|
1749
1856
|
* so (`writableOn: ["create"]`) — the retention tier is fixed at creation.
|
|
@@ -1751,7 +1858,7 @@ export function promptConfigPayload(tomlConfig, options = {}) {
|
|
|
1751
1858
|
export function buildBlobBucketCreatePayload(key, bucket) {
|
|
1752
1859
|
const payload = {
|
|
1753
1860
|
bucketKey: key,
|
|
1754
|
-
...
|
|
1861
|
+
...projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
|
|
1755
1862
|
mode: "create",
|
|
1756
1863
|
values: {
|
|
1757
1864
|
name: (value) => value || key,
|
|
@@ -1761,9 +1868,6 @@ export function buildBlobBucketCreatePayload(key, bucket) {
|
|
|
1761
1868
|
},
|
|
1762
1869
|
}),
|
|
1763
1870
|
};
|
|
1764
|
-
if (!payload.preset && bucket?.accessPolicy) {
|
|
1765
|
-
payload.accessPolicy = bucket.accessPolicy;
|
|
1766
|
-
}
|
|
1767
1871
|
for (const field of Object.keys(payload)) {
|
|
1768
1872
|
if (payload[field] === undefined)
|
|
1769
1873
|
delete payload[field];
|
|
@@ -2068,15 +2172,10 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
|
|
|
2068
2172
|
* differ from the one the server has always received.
|
|
2069
2173
|
*/
|
|
2070
2174
|
const tomlOwned = (value) => (create ? value : (value ?? null));
|
|
2071
|
-
// #3626
|
|
2072
|
-
// spelling is
|
|
2073
|
-
//
|
|
2074
|
-
|
|
2075
|
-
// A key set BOTH ways is refused in preflight
|
|
2076
|
-
// (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
|
|
2077
|
-
// standing so nothing here has to guess.
|
|
2078
|
-
const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
|
|
2079
|
-
return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
|
|
2175
|
+
// #3626 — the chat keys are read from `[configs.chat]` only. Their flat
|
|
2176
|
+
// spelling is retired (#3642) and refused in preflight, so a root chat key
|
|
2177
|
+
// never reaches this builder as a value.
|
|
2178
|
+
return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
|
|
2080
2179
|
mode,
|
|
2081
2180
|
values: {
|
|
2082
2181
|
provider: (value) => (create ? value || "openrouter" : value),
|
|
@@ -2202,16 +2301,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
2202
2301
|
// once beside the definitions (`src/config-surface/retired-keys.ts`), not the
|
|
2203
2302
|
// generic "check the spelling, or upgrade the CLI" hint, which is exactly
|
|
2204
2303
|
// backwards for it. The server's create/update handlers read the same table.
|
|
2304
|
+
// #3642 — a spelling retired from TOML only (a flat chat key under
|
|
2305
|
+
// `[[configs]]`) is answered from the same module, before the misplaced-key
|
|
2306
|
+
// line below could send the author to the block without saying why.
|
|
2205
2307
|
const prefix = table.tomlPath.join(".");
|
|
2206
2308
|
const retiredHere = [...unknown]
|
|
2207
|
-
.filter((k) =>
|
|
2309
|
+
.filter((k) => retiredTomlKey(prefix, k))
|
|
2208
2310
|
.sort();
|
|
2209
2311
|
const trulyUnknown = [...unknown]
|
|
2210
|
-
.filter((k) => !
|
|
2312
|
+
.filter((k) => !retiredTomlKey(prefix, k))
|
|
2211
2313
|
.sort();
|
|
2212
2314
|
const messages = [];
|
|
2213
2315
|
for (const key of retiredHere) {
|
|
2214
|
-
messages.push(`${filePath}: ${prefix}.${key} — ${
|
|
2316
|
+
messages.push(`${filePath}: ${prefix}.${key} — ${retiredTomlKey(prefix, key).toml}`);
|
|
2215
2317
|
}
|
|
2216
2318
|
// #3626, SO3626-013 — a key the definition DOES declare, written at the
|
|
2217
2319
|
// table root when it is authored under a group. Before grouping,
|
|
@@ -2309,49 +2411,72 @@ export function unknownKeyPreflightTargets() {
|
|
|
2309
2411
|
*/
|
|
2310
2412
|
function localConfigRowKey(label, tomlData, file) {
|
|
2311
2413
|
const fallback = basename(file, ".toml");
|
|
2312
|
-
const
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
return stated("webhook", "key");
|
|
2318
|
-
case "cron-trigger":
|
|
2319
|
-
return stated("cronTrigger", "key");
|
|
2320
|
-
case "blob-bucket":
|
|
2321
|
-
return stated("bucket", "key");
|
|
2322
|
-
case "prompt":
|
|
2323
|
-
return stated("prompt", "key");
|
|
2324
|
-
case "workflow":
|
|
2325
|
-
return stated("workflow", "key");
|
|
2326
|
-
// #3320 — the declared key, like the function preflight collector, the
|
|
2327
|
-
// apply loop and `--only`. A renamed file is one object, so both
|
|
2328
|
-
// collectors' messages land on the one comparison row.
|
|
2329
|
-
case "function":
|
|
2330
|
-
return stated("function", "key");
|
|
2331
|
-
case "email-template":
|
|
2332
|
-
return stated("template", "emailType");
|
|
2333
|
-
case "database-type-config":
|
|
2334
|
-
return stated("type", "databaseType");
|
|
2335
|
-
case "group-type-config":
|
|
2336
|
-
return stated("groupTypeConfig", "groupType");
|
|
2337
|
-
case "collection-type-config":
|
|
2338
|
-
return stated("collectionTypeConfig", "collectionType");
|
|
2339
|
-
case "metadata-category-config": {
|
|
2340
|
-
const section = tomlData?.metadataCategoryConfig || {};
|
|
2341
|
-
if (section.resourceType && section.category) {
|
|
2342
|
-
return `${section.resourceType}#${section.category}`;
|
|
2343
|
-
}
|
|
2344
|
-
// `resourceType.category.toml`, the filename fallback push and diff share.
|
|
2345
|
-
const dot = fallback.indexOf(".");
|
|
2346
|
-
return dot > 0
|
|
2347
|
-
? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
|
|
2348
|
-
: fallback;
|
|
2414
|
+
const identity = CONFIG_ROW_IDENTITY[label];
|
|
2415
|
+
if (label === "metadata-category-config") {
|
|
2416
|
+
const section = tomlData?.metadataCategoryConfig || {};
|
|
2417
|
+
if (section.resourceType && section.category) {
|
|
2418
|
+
return `${section.resourceType}#${section.category}`;
|
|
2349
2419
|
}
|
|
2350
|
-
//
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2420
|
+
// `resourceType.category.toml`, the filename fallback push and diff share.
|
|
2421
|
+
const dot = fallback.indexOf(".");
|
|
2422
|
+
return dot > 0
|
|
2423
|
+
? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
|
|
2424
|
+
: fallback;
|
|
2425
|
+
}
|
|
2426
|
+
// Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
|
|
2427
|
+
// still updates the same entity, so the row is the file's.
|
|
2428
|
+
if (!identity)
|
|
2429
|
+
return fallback;
|
|
2430
|
+
const [table, key] = identity[0];
|
|
2431
|
+
return tomlData?.[table]?.[key] || fallback;
|
|
2432
|
+
}
|
|
2433
|
+
/**
|
|
2434
|
+
* The `[table].key` fields that name the object a config file describes, per
|
|
2435
|
+
* type — what `localConfigRowKey` reads before falling back to the file name.
|
|
2436
|
+
* A type absent here (rule sets, test cases) is identified by its file name
|
|
2437
|
+
* alone.
|
|
2438
|
+
*/
|
|
2439
|
+
const CONFIG_ROW_IDENTITY = {
|
|
2440
|
+
integration: [["integration", "key"]],
|
|
2441
|
+
webhook: [["webhook", "key"]],
|
|
2442
|
+
"cron-trigger": [["cronTrigger", "key"]],
|
|
2443
|
+
"blob-bucket": [["bucket", "key"]],
|
|
2444
|
+
prompt: [["prompt", "key"]],
|
|
2445
|
+
workflow: [["workflow", "key"]],
|
|
2446
|
+
// #3320 — the declared key, like the function preflight collector, the
|
|
2447
|
+
// apply loop and `--only`. A renamed file is one object, so both
|
|
2448
|
+
// collectors' messages land on the one comparison row.
|
|
2449
|
+
function: [["function", "key"]],
|
|
2450
|
+
"email-template": [["template", "emailType"]],
|
|
2451
|
+
"database-type-config": [["type", "databaseType"]],
|
|
2452
|
+
"group-type-config": [["groupTypeConfig", "groupType"]],
|
|
2453
|
+
"collection-type-config": [["collectionTypeConfig", "collectionType"]],
|
|
2454
|
+
"metadata-category-config": [
|
|
2455
|
+
["metadataCategoryConfig", "resourceType"],
|
|
2456
|
+
["metadataCategoryConfig", "category"],
|
|
2457
|
+
],
|
|
2458
|
+
};
|
|
2459
|
+
/**
|
|
2460
|
+
* The local file as `config diff` compares it against the server object pull
|
|
2461
|
+
* writes to `file` (#4160), or `null` when the file names a different object.
|
|
2462
|
+
*
|
|
2463
|
+
* Diff pairs a file with a server object by the identity the file states, or
|
|
2464
|
+
* its file name when it states none, and injects that identity before
|
|
2465
|
+
* comparing. So a file whose stated key is another object's is not this
|
|
2466
|
+
* object's file whatever its other values say, and a file that leaves its
|
|
2467
|
+
* identity to the file name is compared with the identity filled in.
|
|
2468
|
+
*/
|
|
2469
|
+
function localConfigWithServerIdentity(label, local, server, file) {
|
|
2470
|
+
if (localConfigRowKey(label, local, file) !== localConfigRowKey(label, server, file)) {
|
|
2471
|
+
return null;
|
|
2354
2472
|
}
|
|
2473
|
+
const resolved = { ...local };
|
|
2474
|
+
for (const [table, key] of CONFIG_ROW_IDENTITY[label] ?? []) {
|
|
2475
|
+
if (server?.[table]?.[key] === undefined)
|
|
2476
|
+
continue;
|
|
2477
|
+
resolved[table] = { ...(resolved[table] ?? {}), [key]: server[table][key] };
|
|
2478
|
+
}
|
|
2479
|
+
return resolved;
|
|
2355
2480
|
}
|
|
2356
2481
|
/**
|
|
2357
2482
|
* `config push`'s preflight — every check the authored files decide on their
|
|
@@ -2382,7 +2507,7 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2382
2507
|
* that being a visible change to this signature.
|
|
2383
2508
|
*/
|
|
2384
2509
|
export async function runConfigPushPreflight(input) {
|
|
2385
|
-
const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
|
|
2510
|
+
const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, } = input;
|
|
2386
2511
|
/**
|
|
2387
2512
|
* Narrow a type's file list to the `--only` selection — the same one-line
|
|
2388
2513
|
* closure the action holds, rebuilt from the selection it passed in rather
|
|
@@ -2523,6 +2648,24 @@ export async function runConfigPushPreflight(input) {
|
|
|
2523
2648
|
}
|
|
2524
2649
|
}
|
|
2525
2650
|
}
|
|
2651
|
+
// #4116 — the deprecated `ruleSetId` spelling in a bucket file. Advisory
|
|
2652
|
+
// only: the file pushes. Bounded by `--only`, like the notice above.
|
|
2653
|
+
const preflightBucketsDir = join(configDir, "blob-buckets");
|
|
2654
|
+
if (existsSync(preflightBucketsDir)) {
|
|
2655
|
+
for (const file of selectFiles("blob-bucket", preflightBucketsDir, readdirSync(preflightBucketsDir).filter((f) => f.endsWith(".toml")))) {
|
|
2656
|
+
let tomlData;
|
|
2657
|
+
try {
|
|
2658
|
+
tomlData = parseTomlFile(join(preflightBucketsDir, file));
|
|
2659
|
+
}
|
|
2660
|
+
catch {
|
|
2661
|
+
// A file that does not parse is reported by the collector below.
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
const advice = blobBucketDeprecatedKeyWarning(`blob-buckets/${file}`, tomlData);
|
|
2665
|
+
if (advice)
|
|
2666
|
+
warn(` ${advice}`);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2526
2669
|
// Validate app.toml settings up-front (issue #1033). A mistyped value
|
|
2527
2670
|
// is a hard error that must abort BEFORE any mutation — same
|
|
2528
2671
|
// fail-before-mutate contract as the vars preflight below. The apply
|
|
@@ -2539,10 +2682,12 @@ export async function runConfigPushPreflight(input) {
|
|
|
2539
2682
|
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
2540
2683
|
// the preflight validates the body that will actually be sent.
|
|
2541
2684
|
excludeName: appSettingsNameExcluded({
|
|
2685
|
+
localEnvironment,
|
|
2542
2686
|
isCrossAppPush: !!isCrossAppPush,
|
|
2543
2687
|
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
2544
2688
|
fileName: parsedApp?.app?.name,
|
|
2545
2689
|
}),
|
|
2690
|
+
nameOptional: localEnvironment,
|
|
2546
2691
|
});
|
|
2547
2692
|
for (const e of appErrors) {
|
|
2548
2693
|
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
@@ -2680,11 +2825,9 @@ export async function runConfigPushPreflight(input) {
|
|
|
2680
2825
|
preflightValidationErrors.push(` ${message}`);
|
|
2681
2826
|
}
|
|
2682
2827
|
}
|
|
2683
|
-
// #
|
|
2684
|
-
//
|
|
2685
|
-
//
|
|
2686
|
-
// single pull-then-push clears the notice. Printed even under `--dry-run`,
|
|
2687
|
-
// which never reaches the apply — the notice is about the FILE.
|
|
2828
|
+
// #3798 — the prompt files this push selects, read once more for the check
|
|
2829
|
+
// that needs app state: an agent's functions must exist. Bounded by `--only`,
|
|
2830
|
+
// and run before anything is applied.
|
|
2688
2831
|
const preflightPromptsDir = join(configDir, "prompts");
|
|
2689
2832
|
// #3798 — the functions an agent's references are checked against, read
|
|
2690
2833
|
// once and only when a selected prompt is an agent that names one.
|
|
@@ -2700,9 +2843,6 @@ export async function runConfigPushPreflight(input) {
|
|
|
2700
2843
|
// A file that does not parse is reported by the collector above.
|
|
2701
2844
|
continue;
|
|
2702
2845
|
}
|
|
2703
|
-
const notice = promptFlatChatKeyWarning(filePath, tomlData);
|
|
2704
|
-
if (notice)
|
|
2705
|
-
warn(` ${notice}`);
|
|
2706
2846
|
// #3798 — an agent's tool, turn-context and history functions must
|
|
2707
2847
|
// exist, and a tool's must declare both schemas, before anything is
|
|
2708
2848
|
// applied. Bounded by `--only` like the rest of this loop.
|
|
@@ -2775,22 +2915,16 @@ export async function runConfigPushPreflight(input) {
|
|
|
2775
2915
|
/**
|
|
2776
2916
|
* The blocks one `[[configs]]` entry carries that its prompt's kind does not
|
|
2777
2917
|
* name — #3798. An agent's entry may carry only `[configs.agent]`; a chat or
|
|
2778
|
-
* decisions entry may not carry it.
|
|
2779
|
-
* keys the hoist moved under `chat`, named as the author wrote them.
|
|
2918
|
+
* decisions entry may not carry it.
|
|
2780
2919
|
*
|
|
2781
2920
|
* Only the blocks the other arms of `resolvePromptKind` cannot see are checked
|
|
2782
2921
|
* here: a decisions block on a chat entry, and chat keys on a decisions entry,
|
|
2783
2922
|
* stay the resolver's refusals with their #3626 wording.
|
|
2784
2923
|
*/
|
|
2785
|
-
function promptEntryBlockErrors(kind, entry
|
|
2924
|
+
function promptEntryBlockErrors(kind, entry) {
|
|
2786
2925
|
const has = (block) => entry != null && typeof entry === "object" && entry[block] !== undefined;
|
|
2787
2926
|
const errors = [];
|
|
2788
2927
|
if (kind === "agent") {
|
|
2789
|
-
if (movedFlat.length > 0) {
|
|
2790
|
-
errors.push(`sets ${movedFlat.join(", ")} flat — the deprecated [configs.chat] ` +
|
|
2791
|
-
'spelling — but prompt kind is "agent", so it may carry only a ' +
|
|
2792
|
-
"[configs.agent] block; write them under [configs.agent]");
|
|
2793
|
-
}
|
|
2794
2928
|
for (const block of ["chat", "decisions"]) {
|
|
2795
2929
|
if (!has(block))
|
|
2796
2930
|
continue;
|
|
@@ -2810,10 +2944,9 @@ function promptEntryBlockErrors(kind, entry, movedFlat) {
|
|
|
2810
2944
|
/**
|
|
2811
2945
|
* A prompt file's KIND errors — #3626, decidable from the file alone.
|
|
2812
2946
|
*
|
|
2813
|
-
*
|
|
2814
|
-
*
|
|
2815
|
-
*
|
|
2816
|
-
* decisions declaration. The last two come from `resolvePromptKind`, the same
|
|
2947
|
+
* Two refusals, the same rule seen from different angles: a `[[configs]]`
|
|
2948
|
+
* entry carrying the block of the OTHER kind, and a malformed decisions
|
|
2949
|
+
* declaration. Both come from `resolvePromptKind`, the same
|
|
2817
2950
|
* vendored function the admin routes validate with, so the CLI's refusal and
|
|
2818
2951
|
* the server's cannot disagree — and this one lands in the preflight, before
|
|
2819
2952
|
* the first mutating call.
|
|
@@ -2868,27 +3001,28 @@ export function promptKindFileErrors(filePath, tomlData) {
|
|
|
2868
3001
|
errors.push(`${filePath}: ${refusal.message}`);
|
|
2869
3002
|
}
|
|
2870
3003
|
}
|
|
2871
|
-
for (const [index,
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
3004
|
+
for (const [index, rawEntry] of entries.entries()) {
|
|
3005
|
+
// #3642 — the blocks are read as the file wrote them. A chat key at the
|
|
3006
|
+
// entry's root is the retired flat spelling, which the unknown-key check
|
|
3007
|
+
// refuses on its own; it is not a value any rule here reads.
|
|
3008
|
+
const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry)
|
|
3009
|
+
? rawEntry
|
|
3010
|
+
: {};
|
|
2877
3011
|
// #3798 — the block rule for three kinds, stated per entry: an entry may
|
|
2878
3012
|
// carry only the block its prompt's kind names. An agent's `[configs.agent]`
|
|
2879
3013
|
// holds keys chat also holds, so a stray block would otherwise be read (or
|
|
2880
3014
|
// ignored) in silence by the payload builder.
|
|
2881
|
-
const blockErrors = promptEntryBlockErrors(fileKind, entry
|
|
3015
|
+
const blockErrors = promptEntryBlockErrors(fileKind, entry).map((message) => `${filePath}: configs[${index}] ${message}`);
|
|
2882
3016
|
if (blockErrors.length > 0) {
|
|
2883
3017
|
errors.push(...blockErrors);
|
|
2884
3018
|
continue;
|
|
2885
3019
|
}
|
|
2886
3020
|
if (fileKind === "agent") {
|
|
2887
|
-
const agent =
|
|
3021
|
+
const agent = entry.agent ?? {};
|
|
2888
3022
|
const resolution = resolvePromptKind({
|
|
2889
3023
|
kind: "agent",
|
|
2890
|
-
provider:
|
|
2891
|
-
model:
|
|
3024
|
+
provider: entry.provider,
|
|
3025
|
+
model: entry.model,
|
|
2892
3026
|
config: {
|
|
2893
3027
|
systemPrompt: agent.systemPrompt,
|
|
2894
3028
|
temperature: agent.temperature,
|
|
@@ -2906,8 +3040,8 @@ export function promptKindFileErrors(filePath, tomlData) {
|
|
|
2906
3040
|
}
|
|
2907
3041
|
continue;
|
|
2908
3042
|
}
|
|
2909
|
-
const chat =
|
|
2910
|
-
const decisions =
|
|
3043
|
+
const chat = entry.chat ?? {};
|
|
3044
|
+
const decisions = entry.decisions ?? {};
|
|
2911
3045
|
// #3626 — the FIRST entry of a chat prompt carries the create seed's
|
|
2912
3046
|
// placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
|
|
2913
3047
|
// file names no template, which is how a prompt authored without one has
|
|
@@ -2927,8 +3061,8 @@ export function promptKindFileErrors(filePath, tomlData) {
|
|
|
2927
3061
|
: chat.userPromptTemplate;
|
|
2928
3062
|
const resolution = resolvePromptKind({
|
|
2929
3063
|
kind: tomlData?.prompt?.kind,
|
|
2930
|
-
provider:
|
|
2931
|
-
model:
|
|
3064
|
+
provider: entry.provider,
|
|
3065
|
+
model: entry.model,
|
|
2932
3066
|
config: {
|
|
2933
3067
|
systemPrompt: chat.systemPrompt,
|
|
2934
3068
|
userPromptTemplate,
|
|
@@ -2953,12 +3087,12 @@ export function promptKindFileErrors(filePath, tomlData) {
|
|
|
2953
3087
|
// against what the server holds.
|
|
2954
3088
|
if (resolution.kind === "chat") {
|
|
2955
3089
|
const strict = resolvePromptStrictOutput({
|
|
2956
|
-
provider:
|
|
3090
|
+
provider: entry.provider,
|
|
2957
3091
|
strictOutput: chat.strictOutput,
|
|
2958
3092
|
outputFormat: chat.outputFormat,
|
|
2959
3093
|
configOutputSchema: chat.outputSchema,
|
|
2960
3094
|
promptOutputSchema: tomlData?.prompt?.outputSchema,
|
|
2961
|
-
status:
|
|
3095
|
+
status: entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
|
|
2962
3096
|
});
|
|
2963
3097
|
if (strict.ok === false) {
|
|
2964
3098
|
errors.push(`${filePath}: configs[${index}] — ${strict.error} (${strict.code})`);
|
|
@@ -3174,26 +3308,64 @@ export function promptKindImmutableOutcome(storedKind, localDoc) {
|
|
|
3174
3308
|
};
|
|
3175
3309
|
}
|
|
3176
3310
|
/**
|
|
3177
|
-
* The
|
|
3178
|
-
*
|
|
3311
|
+
* The file-decidable refusals `config push` gives one config file — the
|
|
3312
|
+
* per-file body of `collectConfigFileValidationErrors`, shared with `config
|
|
3313
|
+
* pull` (#4160), which never keeps a local file push would refuse.
|
|
3179
3314
|
*
|
|
3180
|
-
*
|
|
3181
|
-
*
|
|
3315
|
+
* `tables` are the tables whose unrecognized keys are checked here (none for a
|
|
3316
|
+
* workflow, whose own validator owns that message); declared types run over
|
|
3317
|
+
* every table of the surface.
|
|
3182
3318
|
*/
|
|
3183
|
-
export function
|
|
3184
|
-
const
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3319
|
+
export function configFileValidationMessages(filePath, tomlData, surface, tables) {
|
|
3320
|
+
const messages = [];
|
|
3321
|
+
// The document's own shape first: an unrecognized top-level table makes
|
|
3322
|
+
// every per-table check below read an absent table, so its silence would
|
|
3323
|
+
// be misleading.
|
|
3324
|
+
messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
|
|
3325
|
+
for (const table of tables) {
|
|
3326
|
+
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
3327
|
+
if (unknownKeyError)
|
|
3328
|
+
messages.push(unknownKeyError);
|
|
3189
3329
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3330
|
+
// Declared types run over EVERY table of the surface, workflows included
|
|
3331
|
+
// — `tables` above is pruned for workflows because `validateWorkflowToml`
|
|
3332
|
+
// owns their unrecognized-key message, which is a different check.
|
|
3333
|
+
for (const table of surface.tables) {
|
|
3334
|
+
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
3335
|
+
}
|
|
3336
|
+
// #3626 — the prompt kind's own file-decidable refusals, run by the same
|
|
3337
|
+
// collector so `config push` and `config diff` cannot disagree about
|
|
3338
|
+
// which prompt files are pushable.
|
|
3339
|
+
if (surface.label === "prompt") {
|
|
3340
|
+
messages.push(...promptKindFileErrors(filePath, tomlData));
|
|
3341
|
+
}
|
|
3342
|
+
// #4116 — a bucket naming its rule set both ways.
|
|
3343
|
+
if (surface.label === "blob-bucket") {
|
|
3344
|
+
messages.push(...blobBucketFileErrors(filePath, tomlData));
|
|
3345
|
+
}
|
|
3346
|
+
return messages;
|
|
3347
|
+
}
|
|
3348
|
+
/**
|
|
3349
|
+
* Whether `config pull` must rewrite a local file whatever it says (#4160):
|
|
3350
|
+
* `config push` would refuse it, or it spells something in a deprecated form
|
|
3351
|
+
* pull exists to migrate (a bucket naming its rule set by id). Keeping such a
|
|
3352
|
+
* file because its VALUES match would leave the tree unpushable, or the
|
|
3353
|
+
* deprecation unresolved, after a pull that reported success.
|
|
3354
|
+
*/
|
|
3355
|
+
export function pullMustRewrite(label, tomlData) {
|
|
3356
|
+
// A test-case sidecar is not one of the preflight's per-directory targets;
|
|
3357
|
+
// push refuses it through its own collector.
|
|
3358
|
+
if (label === "test-case") {
|
|
3359
|
+
return testCaseSidecarPreflightErrors("", tomlData).length > 0;
|
|
3360
|
+
}
|
|
3361
|
+
const target = unknownKeyPreflightTargets().find((t) => t.surface.label === label);
|
|
3362
|
+
if (target) {
|
|
3363
|
+
const tables = label === "workflow" ? [WORKFLOW_TABLE] : target.tables;
|
|
3364
|
+
if (configFileValidationMessages("", tomlData, target.surface, tables).length > 0) {
|
|
3365
|
+
return true;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return (label === "blob-bucket" && blobBucketDeprecatedKeyWarning("", tomlData) !== null);
|
|
3197
3369
|
}
|
|
3198
3370
|
export function collectConfigFileValidationErrors(configDir) {
|
|
3199
3371
|
const found = [];
|
|
@@ -3214,28 +3386,7 @@ export function collectConfigFileValidationErrors(configDir) {
|
|
|
3214
3386
|
catch {
|
|
3215
3387
|
continue;
|
|
3216
3388
|
}
|
|
3217
|
-
const messages =
|
|
3218
|
-
// The document's own shape first: an unrecognized top-level table makes
|
|
3219
|
-
// every per-table check below read an absent table, so its silence would
|
|
3220
|
-
// be misleading.
|
|
3221
|
-
messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
|
|
3222
|
-
for (const table of tables) {
|
|
3223
|
-
const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
|
|
3224
|
-
if (unknownKeyError)
|
|
3225
|
-
messages.push(unknownKeyError);
|
|
3226
|
-
}
|
|
3227
|
-
// Declared types run over EVERY table of the surface, workflows included
|
|
3228
|
-
// — `tables` above is pruned for workflows because `validateWorkflowToml`
|
|
3229
|
-
// owns their unrecognized-key message, which is a different check.
|
|
3230
|
-
for (const table of surface.tables) {
|
|
3231
|
-
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
3232
|
-
}
|
|
3233
|
-
// #3626 — the prompt kind's own file-decidable refusals, run by the same
|
|
3234
|
-
// collector so `config push` and `config diff` cannot disagree about
|
|
3235
|
-
// which prompt files are pushable.
|
|
3236
|
-
if (surface.label === "prompt") {
|
|
3237
|
-
messages.push(...promptKindFileErrors(filePath, tomlData));
|
|
3238
|
-
}
|
|
3389
|
+
const messages = configFileValidationMessages(filePath, tomlData, surface, tables);
|
|
3239
3390
|
if (messages.length === 0)
|
|
3240
3391
|
continue;
|
|
3241
3392
|
found.push({
|
|
@@ -4535,16 +4686,16 @@ function jsonFieldsForDiff(table, entity) {
|
|
|
4535
4686
|
/**
|
|
4536
4687
|
* One test-case reference as the comparison reads it (#2880 behavior 17).
|
|
4537
4688
|
*
|
|
4538
|
-
* A reference reaches the projection in
|
|
4539
|
-
* describe the same thing on both sides once this is
|
|
4689
|
+
* A reference reaches the projection as the value in its NAME slot, in one of
|
|
4690
|
+
* two states, and both describe the same thing on both sides once this is
|
|
4691
|
+
* applied:
|
|
4540
4692
|
*
|
|
4541
|
-
* - a portable NAME (`configName = "default"`)
|
|
4542
|
-
*
|
|
4543
|
-
* - a
|
|
4544
|
-
*
|
|
4545
|
-
*
|
|
4546
|
-
*
|
|
4547
|
-
* the next push still sends.
|
|
4693
|
+
* - a portable NAME (`configName = "default"`) — what a test file carries,
|
|
4694
|
+
* and what the server's id renders as when the lookups resolve it;
|
|
4695
|
+
* - a live ID the lookups could NOT resolve, which the comparison's rendering
|
|
4696
|
+
* (`serializeTestCaseForComparison`) carries in the name slot and which
|
|
4697
|
+
* stays the id here, so a case pinned to a deleted or unlistable config is
|
|
4698
|
+
* a difference against an unpinned file rather than hidden (#3998).
|
|
4548
4699
|
*/
|
|
4549
4700
|
function testCaseReference(value, idToName) {
|
|
4550
4701
|
if (typeof value !== "string" || value === "")
|
|
@@ -4819,28 +4970,14 @@ const CONFIG_DIFF_SPECS = {
|
|
|
4819
4970
|
"blob-bucket": {
|
|
4820
4971
|
label: "blob-bucket",
|
|
4821
4972
|
table: BLOB_BUCKET_TABLE,
|
|
4822
|
-
parse: (doc) =>
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
bucket.preset = bucket.accessPolicy;
|
|
4831
|
-
}
|
|
4832
|
-
return {
|
|
4833
|
-
bucketKey: doc?.bucket?.key ?? "",
|
|
4834
|
-
...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
|
|
4835
|
-
mode: "create",
|
|
4836
|
-
values: {
|
|
4837
|
-
name: (value) => value || doc?.bucket?.key || "",
|
|
4838
|
-
description: tomlOwned,
|
|
4839
|
-
},
|
|
4840
|
-
}),
|
|
4841
|
-
};
|
|
4842
|
-
},
|
|
4843
|
-
serialize: (record) => serializeBlobBucket(record),
|
|
4973
|
+
parse: (doc) => parseBlobBucketToml(doc),
|
|
4974
|
+
// #4116 — the rule set is compared by the thing referred to: the record's
|
|
4975
|
+
// id is rendered as its name, and both sides resolve the name back.
|
|
4976
|
+
serialize: (record, maps) => serializeBlobBucket(record, maps.ruleSetIdToName),
|
|
4977
|
+
extras: (entity) => ({
|
|
4978
|
+
_unresolvedRuleSetName: entity._unresolvedRuleSetName,
|
|
4979
|
+
}),
|
|
4980
|
+
ruleSetRef: true,
|
|
4844
4981
|
// `name` is trimmed on the way in and a blank description is stored as
|
|
4845
4982
|
// none (DSO-003).
|
|
4846
4983
|
canonicalize: (entity) => canonicalizeServerTextFields(entity, {
|
|
@@ -4862,9 +4999,14 @@ const CONFIG_DIFF_SPECS = {
|
|
|
4862
4999
|
const keys = [];
|
|
4863
5000
|
if (!bucket.name)
|
|
4864
5001
|
keys.push("name");
|
|
4865
|
-
if (!bucket.preset
|
|
5002
|
+
if (!bucket.preset)
|
|
4866
5003
|
keys.push("preset");
|
|
4867
|
-
|
|
5004
|
+
// Either spelling manages the rule set (#4116) — read as the parse reads
|
|
5005
|
+
// it, so a blank name is no reference rather than a difference the
|
|
5006
|
+
// update cannot carry.
|
|
5007
|
+
const named = typeof bucket.ruleSetName === "string" && bucket.ruleSetName.trim() !== "";
|
|
5008
|
+
const legacy = typeof bucket.ruleSetId === "string" && bucket.ruleSetId !== "";
|
|
5009
|
+
if (!named && !legacy)
|
|
4868
5010
|
keys.push("ruleSetId");
|
|
4869
5011
|
return keys;
|
|
4870
5012
|
},
|
|
@@ -4874,17 +5016,15 @@ const CONFIG_DIFF_SPECS = {
|
|
|
4874
5016
|
// edited expectation read as Synced while push applied it from the file's
|
|
4875
5017
|
// byte hash.
|
|
4876
5018
|
//
|
|
4877
|
-
// The type's own difficulty is that a reference
|
|
4878
|
-
//
|
|
4879
|
-
//
|
|
4880
|
-
//
|
|
4881
|
-
// `extra` context is what makes that possible (the id→name lookups are not in
|
|
4882
|
-
// the document).
|
|
5019
|
+
// The type's own difficulty is that a reference is a NAME in the file and an
|
|
5020
|
+
// id on the server. The comparison renders the server's id through the
|
|
5021
|
+
// id→name lookups (which are not in the document — the `extra` context
|
|
5022
|
+
// carries them), so both sides project the thing referred to.
|
|
4883
5023
|
"test-case": {
|
|
4884
5024
|
label: "test-case",
|
|
4885
5025
|
table: TEST_CASE_TABLE,
|
|
4886
5026
|
parse: (doc) => parseTestCaseToml(doc),
|
|
4887
|
-
serialize: (record, _maps, extra) =>
|
|
5027
|
+
serialize: (record, _maps, extra) => serializeTestCaseForComparison(record, extra?.lookupMaps),
|
|
4888
5028
|
// The server trims a test-case name on write (`src/admin-api.ts`), so a
|
|
4889
5029
|
// padded one in the file is not a difference — it is what the server will
|
|
4890
5030
|
// store (#2880 DSO-003).
|
|
@@ -4894,12 +5034,11 @@ const CONFIG_DIFF_SPECS = {
|
|
|
4894
5034
|
},
|
|
4895
5035
|
extras: (entity, _doc, extra) => ({
|
|
4896
5036
|
// These three OVERRIDE the definition's fields of the same name: the
|
|
4897
|
-
// value compared is the thing referred to,
|
|
4898
|
-
//
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
|
|
5037
|
+
// value compared is the thing referred to, read from the name the file
|
|
5038
|
+
// (or the comparison's rendering of the server record) carries.
|
|
5039
|
+
configId: testCaseReference(entity._configName, extra?.lookupMaps?.configIdToName),
|
|
5040
|
+
evaluatorPromptId: testCaseReference(entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
|
|
5041
|
+
evaluatorConfigId: testCaseReference(entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
|
|
4903
5042
|
}),
|
|
4904
5043
|
describe: (entity) => `test case "${entity?.name}"`,
|
|
4905
5044
|
},
|
|
@@ -4984,6 +5123,15 @@ export function projectRemoteConfig(spec, record, maps, extra) {
|
|
|
4984
5123
|
export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
|
|
4985
5124
|
const local = projectLocalConfig(spec, localParsed, maps, localExtra);
|
|
4986
5125
|
const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
|
|
5126
|
+
reconcileUnmanagedFields(spec, localParsed, local, remote);
|
|
5127
|
+
return { local, remote };
|
|
5128
|
+
}
|
|
5129
|
+
/**
|
|
5130
|
+
* Take the server's value for every field the local file leaves unmanaged
|
|
5131
|
+
* (#2880), on `local` in place — the reconciliation `projectConfigPair` makes,
|
|
5132
|
+
* shared with `config pull`'s keep-or-write comparison (#4160).
|
|
5133
|
+
*/
|
|
5134
|
+
function reconcileUnmanagedFields(spec, localParsed, local, remote) {
|
|
4987
5135
|
for (const key of spec.unmanaged?.(localParsed) ?? []) {
|
|
4988
5136
|
// Key-for-key, so an unmanaged field the server does not hold either is
|
|
4989
5137
|
// absent on both sides rather than present-and-undefined on one.
|
|
@@ -4992,7 +5140,20 @@ export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra,
|
|
|
4992
5140
|
else
|
|
4993
5141
|
delete local[key];
|
|
4994
5142
|
}
|
|
4995
|
-
|
|
5143
|
+
}
|
|
5144
|
+
/**
|
|
5145
|
+
* One `config diff` row's verdict once both sides exist (#4116, DSO-4116-003).
|
|
5146
|
+
*
|
|
5147
|
+
* A type whose files name a rule set cannot be compared without the app's
|
|
5148
|
+
* rule-set listing: an unresolvable reference would read as a difference that
|
|
5149
|
+
* is not there. So when the listing failed the row says so rather than
|
|
5150
|
+
* comparing; otherwise `compare` decides.
|
|
5151
|
+
*/
|
|
5152
|
+
export function configDiffRowVerdict(parsed, remote, compare, contentComparable) {
|
|
5153
|
+
if (!contentComparable) {
|
|
5154
|
+
return { status: "exists", hint: "content not compared (rule sets unavailable)" };
|
|
5155
|
+
}
|
|
5156
|
+
return compare(parsed, remote);
|
|
4996
5157
|
}
|
|
4997
5158
|
/**
|
|
4998
5159
|
* One `config diff` row's content verdict for a registered type (#2880).
|
|
@@ -5506,6 +5667,59 @@ export function configSemanticHash(label, filePath, maps) {
|
|
|
5506
5667
|
return undefined;
|
|
5507
5668
|
}
|
|
5508
5669
|
}
|
|
5670
|
+
/**
|
|
5671
|
+
* Whether a local config file says what the file `config pull` would write
|
|
5672
|
+
* says (#4160) — the definition-projected field set `config diff` compares, so
|
|
5673
|
+
* a file diff calls Synced keeps its comments, key order and explicit defaults
|
|
5674
|
+
* through a pull.
|
|
5675
|
+
*
|
|
5676
|
+
* Both sides go through the same projection with the same context, so
|
|
5677
|
+
* `extra` is the context a reference is read through (a test case's lookups),
|
|
5678
|
+
* never server-only rows. As in diff, the file is paired by the identity it
|
|
5679
|
+
* states (or `filePath`'s name), and the fields it leaves unmanaged take the
|
|
5680
|
+
* server's values — so a kept file's baseline is the server's projection
|
|
5681
|
+
* (`pulledSemanticHash`), never the file's own.
|
|
5682
|
+
*/
|
|
5683
|
+
export function sameConfigAs(label, serverContent, filePath, maps, extra) {
|
|
5684
|
+
const spec = configDiffSpec(label);
|
|
5685
|
+
return (localContent) => {
|
|
5686
|
+
const parsed = parseConfigToml(localContent);
|
|
5687
|
+
if (pullMustRewrite(label, parsed))
|
|
5688
|
+
return false;
|
|
5689
|
+
const server = parseConfigToml(serverContent);
|
|
5690
|
+
const local = localConfigWithServerIdentity(label, parsed, server, filePath);
|
|
5691
|
+
if (local === null)
|
|
5692
|
+
return false;
|
|
5693
|
+
const localProjected = projectLocalConfig(spec, local, maps, extra);
|
|
5694
|
+
const serverProjected = projectLocalConfig(spec, server, maps, extra);
|
|
5695
|
+
reconcileUnmanagedFields(spec, local, localProjected, serverProjected);
|
|
5696
|
+
return (computeExpandedContentHash(localProjected) ===
|
|
5697
|
+
computeExpandedContentHash(serverProjected));
|
|
5698
|
+
};
|
|
5699
|
+
}
|
|
5700
|
+
/**
|
|
5701
|
+
* The semantic baseline `config pull` stamps for a config file (#4160): the
|
|
5702
|
+
* projection of what the server serializes, whether the file was written or
|
|
5703
|
+
* kept. A kept file can leave unmanaged fields or its identity to the server,
|
|
5704
|
+
* so hashing it would record a baseline the server never had.
|
|
5705
|
+
*/
|
|
5706
|
+
export function pulledSemanticHash(label, serverContent, maps) {
|
|
5707
|
+
try {
|
|
5708
|
+
return hashLocalConfigForDiff(configDiffSpec(label), parseConfigToml(serverContent), maps);
|
|
5709
|
+
}
|
|
5710
|
+
catch {
|
|
5711
|
+
return undefined;
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
/**
|
|
5715
|
+
* Whether a local TOML file parses to the same document as `serverContent`
|
|
5716
|
+
* (#4160): the comparison for `vars.toml`, which has no field definition
|
|
5717
|
+
* behind it, so only comments, key order and spelling are free.
|
|
5718
|
+
*/
|
|
5719
|
+
export function sameTomlAs(serverContent) {
|
|
5720
|
+
return (localContent) => canonicalJsonStringify(parseConfigToml(localContent)) ===
|
|
5721
|
+
canonicalJsonStringify(parseConfigToml(serverContent));
|
|
5722
|
+
}
|
|
5509
5723
|
/**
|
|
5510
5724
|
* The sync state `config push` writes into (#2731 B6, closing #2374).
|
|
5511
5725
|
*
|
|
@@ -5718,49 +5932,81 @@ export function getTestsDir(configDir, blockType, blockKey) {
|
|
|
5718
5932
|
function blockSelectionLabel(blockType) {
|
|
5719
5933
|
return blockType === "script" ? "transform" : blockType;
|
|
5720
5934
|
}
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5935
|
+
/**
|
|
5936
|
+
* The `[test]` table for one server record, and the references it could not
|
|
5937
|
+
* name (#3998).
|
|
5938
|
+
*
|
|
5939
|
+
* A sidecar authors its references by NAME only — the id spellings are retired
|
|
5940
|
+
* — so a reference whose id has no lookup entry has no honest spelling in a
|
|
5941
|
+
* file. `unresolvedSpelling` says what goes in the name slot for one: `"empty"`
|
|
5942
|
+
* for the file pull writes (and the caller decides whether to write it at all),
|
|
5943
|
+
* `"raw-id"` for the comparison's rendering, which is parsed and never written.
|
|
5944
|
+
*/
|
|
5945
|
+
function testCaseSidecarTable(testCase, lookupMaps, unresolvedSpelling) {
|
|
5946
|
+
const unresolved = [];
|
|
5947
|
+
const reference = (field, tomlKey, idToName) => {
|
|
5948
|
+
const id = testCase?.[field];
|
|
5949
|
+
if (typeof id !== "string" || id === "")
|
|
5950
|
+
return "";
|
|
5951
|
+
const name = idToName?.get(id);
|
|
5952
|
+
if (name)
|
|
5953
|
+
return name;
|
|
5954
|
+
unresolved.push({ tomlKey, field, id });
|
|
5955
|
+
return unresolvedSpelling === "raw-id" ? id : "";
|
|
5956
|
+
};
|
|
5957
|
+
const configName = reference("configId", "configName", lookupMaps?.configIdToName);
|
|
5958
|
+
const evaluatorPromptKey = reference("evaluatorPromptId", "evaluatorPromptKey", lookupMaps?.promptIdToKey);
|
|
5959
|
+
// The judge's config belongs to the evaluator prompt, not to this sidecar's
|
|
5960
|
+
// block; config ids are app-unique, so the app-wide map names it.
|
|
5961
|
+
const evaluatorConfigName = reference("evaluatorConfigId", "evaluatorConfigName", lookupMaps?.configIdToName);
|
|
5733
5962
|
// #2644 criterion 1 — the `[test]` key set comes from the vendored
|
|
5734
5963
|
// definition, so a field added there is emitted by pull with no edit here.
|
|
5735
5964
|
// The three reference fields carry the resolved NAME (a test file is portable
|
|
5736
5965
|
// across apps); the rest keep the empty-string / "{}" / "[]" spellings a test
|
|
5737
5966
|
// file has always used for "not set".
|
|
5738
|
-
const
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
}
|
|
5763
|
-
|
|
5967
|
+
const test = projectRecordToToml(TEST_CASE_TABLE, testCase, {
|
|
5968
|
+
name: (record) => record.name || "",
|
|
5969
|
+
description: (record) => record.description || "",
|
|
5970
|
+
inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
|
|
5971
|
+
configId: () => configName,
|
|
5972
|
+
evaluatorPromptId: () => evaluatorPromptKey,
|
|
5973
|
+
evaluatorConfigId: () => evaluatorConfigName,
|
|
5974
|
+
expectedOutputPattern: (record) => record.expectedOutputPattern || "",
|
|
5975
|
+
expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
|
|
5976
|
+
expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
|
|
5977
|
+
});
|
|
5978
|
+
return { test, unresolved };
|
|
5979
|
+
}
|
|
5980
|
+
/**
|
|
5981
|
+
* The sidecar `config pull` writes for one server record. It never emits an id
|
|
5982
|
+
* key (#3998): a reference the lookups cannot name is written as an empty name
|
|
5983
|
+
* and reported through `onUnresolved`, and `pullTestCasesForBlock` declines to
|
|
5984
|
+
* write such a case rather than unpin it on disk.
|
|
5985
|
+
*/
|
|
5986
|
+
export function serializeTestCase(testCase, lookupMaps, options = {}) {
|
|
5987
|
+
// #2644 — name any server key this CLI version does not know rather than
|
|
5988
|
+
// dropping it silently on the way into the file. Test cases are a registered
|
|
5989
|
+
// surface; their sidecar shape is the only thing that sets them apart.
|
|
5990
|
+
warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
|
|
5991
|
+
const { test, unresolved } = testCaseSidecarTable(testCase, lookupMaps, "empty");
|
|
5992
|
+
if (unresolved.length > 0)
|
|
5993
|
+
options.onUnresolved?.(unresolved);
|
|
5994
|
+
return stringifyConfigToml({ test });
|
|
5995
|
+
}
|
|
5996
|
+
/**
|
|
5997
|
+
* The rendering of a server record that the comparison parses (#3998) — never
|
|
5998
|
+
* written to disk.
|
|
5999
|
+
*
|
|
6000
|
+
* It differs from the file pull writes in one place: a reference the lookups
|
|
6001
|
+
* cannot name carries its raw id in the NAME slot. A live case pinned to a
|
|
6002
|
+
* deleted or unlistable configuration must stay a difference against an
|
|
6003
|
+
* unpinned file; projecting it as "no reference" would read the pair as Synced,
|
|
6004
|
+
* and the next content edit would push `configId: null` and clear the pin in
|
|
6005
|
+
* silence.
|
|
6006
|
+
*/
|
|
6007
|
+
export function serializeTestCaseForComparison(testCase, lookupMaps) {
|
|
6008
|
+
const { test } = testCaseSidecarTable(testCase, lookupMaps, "raw-id");
|
|
6009
|
+
return stringifyConfigToml({ test });
|
|
5764
6010
|
}
|
|
5765
6011
|
/**
|
|
5766
6012
|
* The TOML spelling of a JSON-valued test-case field (#2769).
|
|
@@ -5810,20 +6056,22 @@ export function parseTestCaseToml(tomlData) {
|
|
|
5810
6056
|
description: (value) => (value ? value : undefined),
|
|
5811
6057
|
// Always sent — the server requires it, and `{}` is the empty spelling.
|
|
5812
6058
|
inputVariables: (value) => parseJsonText(value, ["{}"]) ?? {},
|
|
5813
|
-
//
|
|
5814
|
-
//
|
|
5815
|
-
//
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
6059
|
+
// A reference is authored by name only: the names become the `_`
|
|
6060
|
+
// markers below, which the push path resolves against the live app. The
|
|
6061
|
+
// id spellings are retired (#3998) and refused by the preflight, so
|
|
6062
|
+
// nothing is read into the wire field here — and these producers must
|
|
6063
|
+
// stay, because without one `buildPayloadFromToml` would forward the
|
|
6064
|
+
// NAME (`configName`) under the id field.
|
|
6065
|
+
configId: () => undefined,
|
|
6066
|
+
evaluatorPromptId: () => undefined,
|
|
6067
|
+
evaluatorConfigId: () => undefined,
|
|
5819
6068
|
expectedOutputPattern: (value) => (value ? value : undefined),
|
|
5820
6069
|
expectedOutputContains: (value) => parseJsonText(value, ["[]"]),
|
|
5821
6070
|
expectedJsonSubset: (value) => parseJsonText(value, ["{}"]),
|
|
5822
6071
|
},
|
|
5823
6072
|
});
|
|
5824
6073
|
// An omitted field must be absent from the body, not present-and-undefined:
|
|
5825
|
-
// the
|
|
5826
|
-
// check reads the body's keys.
|
|
6074
|
+
// the server's unknown-key check reads the body's keys.
|
|
5827
6075
|
for (const key of Object.keys(payload)) {
|
|
5828
6076
|
if (payload[key] === undefined)
|
|
5829
6077
|
delete payload[key];
|
|
@@ -5877,7 +6125,9 @@ export async function pullScripts(client, appId, configDir, logger = () => { },
|
|
|
5877
6125
|
* `config pull --only` (issue #2645): which transform names this pull may
|
|
5878
6126
|
* write. Defaults to every one the server lists.
|
|
5879
6127
|
*/
|
|
5880
|
-
selects = () => true
|
|
6128
|
+
selects = () => true,
|
|
6129
|
+
/** Counts each transform as written or left unchanged (#4160). */
|
|
6130
|
+
writes) {
|
|
5881
6131
|
const transformsDir = join(configDir, "transforms");
|
|
5882
6132
|
ensureDir(transformsDir);
|
|
5883
6133
|
const scriptEntities = {};
|
|
@@ -5930,7 +6180,9 @@ selects = () => true) {
|
|
|
5930
6180
|
}
|
|
5931
6181
|
const filename = `${name}.rhai`;
|
|
5932
6182
|
const filePath = join(transformsDir, filename);
|
|
5933
|
-
|
|
6183
|
+
// A transform's comparator is its bytes, so only identical bytes keep the
|
|
6184
|
+
// file (#4160).
|
|
6185
|
+
const fileWritten = writePulledFile(filePath, activeConfig.body, writes);
|
|
5934
6186
|
scriptEntities[name] = {
|
|
5935
6187
|
id: script.scriptId,
|
|
5936
6188
|
modifiedAt: script.modifiedAt || new Date().toISOString(),
|
|
@@ -5943,7 +6195,8 @@ selects = () => true) {
|
|
|
5943
6195
|
semanticHash: computeScriptBodyHash(activeConfig.body),
|
|
5944
6196
|
};
|
|
5945
6197
|
written += 1;
|
|
5946
|
-
|
|
6198
|
+
if (fileWritten)
|
|
6199
|
+
logger(` Wrote transforms/${filename}`);
|
|
5947
6200
|
}
|
|
5948
6201
|
const serverKeys = items.map((s) => s?.name).filter(Boolean);
|
|
5949
6202
|
const presence = detailsOk
|
|
@@ -6019,7 +6272,9 @@ export function pushedFunctionBaseline(input) {
|
|
|
6019
6272
|
* normalizes outside the tree, and the refusals are reported rather than
|
|
6020
6273
|
* swallowed.
|
|
6021
6274
|
*/
|
|
6022
|
-
export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true
|
|
6275
|
+
export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true,
|
|
6276
|
+
/** Counts each function file as written or left unchanged (#4160). */
|
|
6277
|
+
writes) {
|
|
6023
6278
|
const functionsDir = join(configDir, "functions");
|
|
6024
6279
|
ensureDir(functionsDir);
|
|
6025
6280
|
const functionEntities = {};
|
|
@@ -6062,12 +6317,17 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
|
|
|
6062
6317
|
// The logger carries #1446's one warning: a schema holding a `null`
|
|
6063
6318
|
// anywhere cannot be a native table, so it is written as a JSON string
|
|
6064
6319
|
// and the operator is told which key that happened to.
|
|
6065
|
-
|
|
6320
|
+
const content = serializeFunction(record, undefined, logger);
|
|
6321
|
+
const fileWritten = writePulledFile(filePath, content, writes, sameConfigAs("function", content, filePath, {
|
|
6322
|
+
ruleSetIdToName: new Map(),
|
|
6323
|
+
ruleSetNameToId: new Map(),
|
|
6324
|
+
}));
|
|
6066
6325
|
entity.contentHash = computeFileHash(filePath);
|
|
6067
6326
|
entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
|
|
6068
6327
|
functionEntities[key] = entity;
|
|
6069
6328
|
written += 1;
|
|
6070
|
-
|
|
6329
|
+
if (fileWritten)
|
|
6330
|
+
logger(` Wrote functions/${key}.toml (no version pushed yet)`);
|
|
6071
6331
|
continue;
|
|
6072
6332
|
}
|
|
6073
6333
|
let active;
|
|
@@ -6123,7 +6383,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
|
|
|
6123
6383
|
// this is what says so rather than editing a developer's file in silence.
|
|
6124
6384
|
for (const note of plan.notes)
|
|
6125
6385
|
logger(` ${note}`);
|
|
6126
|
-
const applied = applyFunctionPull(plan);
|
|
6386
|
+
const applied = applyFunctionPull(plan, writes);
|
|
6127
6387
|
for (const refusal of applied.refusals)
|
|
6128
6388
|
logger(` ${refusal}`);
|
|
6129
6389
|
entity.contentHash = computeFileHash(filePath);
|
|
@@ -6161,7 +6421,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
|
|
|
6161
6421
|
});
|
|
6162
6422
|
for (const warning of schemaPlan.warnings)
|
|
6163
6423
|
logger(` ${warning}`);
|
|
6164
|
-
const schemaApplied = applyFunctionPull(schemaPlan);
|
|
6424
|
+
const schemaApplied = applyFunctionPull(schemaPlan, writes);
|
|
6165
6425
|
for (const refusal of schemaApplied.refusals)
|
|
6166
6426
|
logger(` ${refusal}`);
|
|
6167
6427
|
const presence = detailsOk
|
|
@@ -6176,6 +6436,35 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
|
|
|
6176
6436
|
items,
|
|
6177
6437
|
};
|
|
6178
6438
|
}
|
|
6439
|
+
/**
|
|
6440
|
+
* The id→name lookups `config pull` writes test-case references through
|
|
6441
|
+
* (#3998).
|
|
6442
|
+
*
|
|
6443
|
+
* The prompt half is built from EVERY prompt's detail — the pull fetches them
|
|
6444
|
+
* all before `--only` filters the list — so a judge prompt outside the
|
|
6445
|
+
* selection still names. Configurations come from the pulled blocks (a case
|
|
6446
|
+
* pins one of its own block's) and from every prompt (a judge's config is the
|
|
6447
|
+
* judge prompt's). An entry without both an id and a name is skipped.
|
|
6448
|
+
*/
|
|
6449
|
+
export function testCaseLookupMapsForPull(input) {
|
|
6450
|
+
const configIdToName = new Map();
|
|
6451
|
+
const promptIdToKey = new Map();
|
|
6452
|
+
const addConfig = (config) => {
|
|
6453
|
+
if (config?.configId && config?.configName) {
|
|
6454
|
+
configIdToName.set(config.configId, config.configName);
|
|
6455
|
+
}
|
|
6456
|
+
};
|
|
6457
|
+
for (const prompt of input.prompts) {
|
|
6458
|
+
if (prompt?.promptId && prompt?.promptKey) {
|
|
6459
|
+
promptIdToKey.set(prompt.promptId, prompt.promptKey);
|
|
6460
|
+
}
|
|
6461
|
+
for (const config of prompt?.configs ?? [])
|
|
6462
|
+
addConfig(config);
|
|
6463
|
+
}
|
|
6464
|
+
for (const config of input.configs)
|
|
6465
|
+
addConfig(config);
|
|
6466
|
+
return { configIdToName, promptIdToKey };
|
|
6467
|
+
}
|
|
6179
6468
|
/**
|
|
6180
6469
|
* Every test case a block has, draining the cursor (#2769).
|
|
6181
6470
|
*
|
|
@@ -6228,6 +6517,27 @@ export function carryForwardTestCaseEntities(params) {
|
|
|
6228
6517
|
}
|
|
6229
6518
|
return carried;
|
|
6230
6519
|
}
|
|
6520
|
+
/**
|
|
6521
|
+
* Whether two sidecar names in `testsDir` are one file on disk (#3998).
|
|
6522
|
+
*
|
|
6523
|
+
* Names that differ only by case are one file on the case-insensitive
|
|
6524
|
+
* filesystems most of these trees live on and two on the rest (#2896), so the
|
|
6525
|
+
* answer comes from the filesystem itself: the same device and inode.
|
|
6526
|
+
*/
|
|
6527
|
+
function sameTestCaseFile(testsDir, a, b) {
|
|
6528
|
+
if (a === b)
|
|
6529
|
+
return true;
|
|
6530
|
+
if (normalizeTestCaseKey(a) !== normalizeTestCaseKey(b))
|
|
6531
|
+
return false;
|
|
6532
|
+
try {
|
|
6533
|
+
const left = statSync(join(testsDir, `${a}.toml`));
|
|
6534
|
+
const right = statSync(join(testsDir, `${b}.toml`));
|
|
6535
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
6536
|
+
}
|
|
6537
|
+
catch {
|
|
6538
|
+
return false;
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6231
6541
|
/**
|
|
6232
6542
|
* Write one block's test cases into its `<key>.tests/` sidecar and record them
|
|
6233
6543
|
* in sync state. Exported so the unit tests can drive it against a stubbed
|
|
@@ -6243,10 +6553,9 @@ export async function pullTestCasesForBlock(params) {
|
|
|
6243
6553
|
const testsDir = getTestsDir(configDir, blockType, blockKey);
|
|
6244
6554
|
// A block with no cases and no sidecar is not a block whose sidecar is empty:
|
|
6245
6555
|
// creating the directory here would commit an empty folder Git cannot carry.
|
|
6246
|
-
if (testCases.length === 0 && !existsSync(testsDir))
|
|
6247
|
-
return { ok: true, count: 0 };
|
|
6248
|
-
|
|
6249
|
-
ensureDir(testsDir);
|
|
6556
|
+
if (testCases.length === 0 && !existsSync(testsDir)) {
|
|
6557
|
+
return { ok: true, count: 0, unwritten: [] };
|
|
6558
|
+
}
|
|
6250
6559
|
const pulledSlugs = new Set();
|
|
6251
6560
|
// #2896 — a keyed case is written to the file its key names, so pull stops
|
|
6252
6561
|
// renaming what push authored: the repro's `full-refresh-takes-all-history`
|
|
@@ -6256,15 +6565,35 @@ export async function pullTestCasesForBlock(params) {
|
|
|
6256
6565
|
// The key is server data becoming a path: the containment check an
|
|
6257
6566
|
// attachment's filename gets, so nothing lands outside the sidecar dir.
|
|
6258
6567
|
(name) => attachmentWritePath(testsDir, `${name}.toml`) !== null);
|
|
6568
|
+
// #3998 — render every case before anything is written. A reference the
|
|
6569
|
+
// lookups cannot name has no honest spelling in a sidecar (the id keys are
|
|
6570
|
+
// retired, and an empty name would unpin the case on the next push), so such
|
|
6571
|
+
// a case is NOT written: its file, fixtures and record stay as they were.
|
|
6572
|
+
const writable = [];
|
|
6573
|
+
const skipped = [];
|
|
6259
6574
|
for (const tc of testCases) {
|
|
6260
6575
|
const slug = fileNames.get(tc);
|
|
6576
|
+
let references = [];
|
|
6577
|
+
const toml = serializeTestCase(tc, lookupMaps, {
|
|
6578
|
+
file: join(testsDir, `${slug}.toml`),
|
|
6579
|
+
logger: (message) => log(message),
|
|
6580
|
+
onUnresolved: (refs) => {
|
|
6581
|
+
references = refs;
|
|
6582
|
+
},
|
|
6583
|
+
});
|
|
6584
|
+
if (references.length > 0)
|
|
6585
|
+
skipped.push({ tc, slug, references });
|
|
6586
|
+
else
|
|
6587
|
+
writable.push({ tc, slug, toml });
|
|
6588
|
+
}
|
|
6589
|
+
if (writable.length > 0)
|
|
6590
|
+
ensureDir(testsDir);
|
|
6591
|
+
const writtenSlugs = new Map(writable.map((w) => [w.slug, w.tc]));
|
|
6592
|
+
for (const { tc, slug, toml } of writable) {
|
|
6261
6593
|
pulledSlugs.add(slug);
|
|
6262
6594
|
// Write test case TOML
|
|
6263
6595
|
const tomlPath = join(testsDir, `${slug}.toml`);
|
|
6264
|
-
|
|
6265
|
-
file: tomlPath,
|
|
6266
|
-
logger: (message) => log(message),
|
|
6267
|
-
}));
|
|
6596
|
+
writePulledFile(tomlPath, toml, params.writes, sameConfigAs("test-case", toml, tomlPath, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, { lookupMaps }));
|
|
6268
6597
|
// Attachments: the sidecar directory is the authored set, so pull makes it
|
|
6269
6598
|
// match the server — downloading what is there, recording each file's
|
|
6270
6599
|
// content hash (so push can see a byte change under an unchanged name), and
|
|
@@ -6288,7 +6617,8 @@ export async function pullTestCasesForBlock(params) {
|
|
|
6288
6617
|
}
|
|
6289
6618
|
try {
|
|
6290
6619
|
const downloaded = await client.downloadTestCaseAttachment(appId, blockType, blockId, tc.testCaseId, att.filename);
|
|
6291
|
-
|
|
6620
|
+
// An attachment's comparator is its bytes (#4160).
|
|
6621
|
+
writePulledFile(destination, downloaded.data, params.writes);
|
|
6292
6622
|
(attachments ??= {})[att.filename] = computeFileHash(destination);
|
|
6293
6623
|
}
|
|
6294
6624
|
catch (err) {
|
|
@@ -6356,12 +6686,79 @@ export async function pullTestCasesForBlock(params) {
|
|
|
6356
6686
|
}),
|
|
6357
6687
|
};
|
|
6358
6688
|
}
|
|
6689
|
+
// #3998 — a skipped case is found by its ID in the prior state, not by the
|
|
6690
|
+
// file name it would get now: the server can rename a case's key between
|
|
6691
|
+
// pulls, and the old file is the one holding the pin. Every such file is
|
|
6692
|
+
// kept out of the cleanup below and every such record carried forward under
|
|
6693
|
+
// its existing key — unless a case written above now owns that file, whose
|
|
6694
|
+
// write wins it (the key-to-file rule of #2896). "That file" is decided by
|
|
6695
|
+
// the filesystem: `Alpha` and `alpha` are one file where names fold case.
|
|
6696
|
+
const unwritten = [];
|
|
6697
|
+
const writtenOwner = (candidate) => writtenSlugs.get(candidate) ??
|
|
6698
|
+
writable.find((w) => sameTestCaseFile(testsDir, w.slug, candidate))?.tc;
|
|
6699
|
+
for (const { tc, slug, references } of skipped) {
|
|
6700
|
+
const priorEntries = Object.entries(params.priorTestCaseEntities ?? {}).filter(([, entry]) => entry?.blockType === blockType &&
|
|
6701
|
+
entry?.blockKey === blockKey &&
|
|
6702
|
+
entry?.id === tc.testCaseId);
|
|
6703
|
+
const slugs = new Set([slug]);
|
|
6704
|
+
for (const [stateKey, entry] of priorEntries) {
|
|
6705
|
+
slugs.add(entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1));
|
|
6706
|
+
}
|
|
6707
|
+
const preservedSlugs = [];
|
|
6708
|
+
const superseded = new Set();
|
|
6709
|
+
for (const candidate of slugs) {
|
|
6710
|
+
const owner = writtenOwner(candidate);
|
|
6711
|
+
if (owner) {
|
|
6712
|
+
superseded.add(candidate);
|
|
6713
|
+
warn(` ${blockType}: ${blockKey}/${candidate}.toml now holds test case ` +
|
|
6714
|
+
`"${owner?.name ?? ""}" — the prior sidecar of unwritten test case ` +
|
|
6715
|
+
`"${tc?.name ?? ""}" at that path was superseded`);
|
|
6716
|
+
continue;
|
|
6717
|
+
}
|
|
6718
|
+
pulledSlugs.add(candidate);
|
|
6719
|
+
if (existsSync(join(testsDir, `${candidate}.toml`))) {
|
|
6720
|
+
preservedSlugs.push(candidate);
|
|
6721
|
+
}
|
|
6722
|
+
}
|
|
6723
|
+
for (const [stateKey, entry] of priorEntries) {
|
|
6724
|
+
const entrySlug = entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1);
|
|
6725
|
+
if (superseded.has(entrySlug))
|
|
6726
|
+
continue;
|
|
6727
|
+
if (testCaseEntities[stateKey])
|
|
6728
|
+
continue;
|
|
6729
|
+
testCaseEntities[stateKey] = entry;
|
|
6730
|
+
}
|
|
6731
|
+
const what = references
|
|
6732
|
+
.map((ref) => `${ref.tomlKey} (id ${ref.id})`)
|
|
6733
|
+
.join(", ");
|
|
6734
|
+
const kept = preservedSlugs.length > 0
|
|
6735
|
+
? `kept ${preservedSlugs.map((s) => `${s}.toml`).join(", ")} as it was`
|
|
6736
|
+
: "no sidecar exists for it";
|
|
6737
|
+
warn(` Test case not written for ${blockType}: ${blockKey}/${slug} ` +
|
|
6738
|
+
`("${tc?.name ?? ""}"): ${what} could not be resolved to a name — ` +
|
|
6739
|
+
`${kept}, so the pin is not lost. The configuration or prompt may have ` +
|
|
6740
|
+
"been deleted (re-pin the case on the server), or its listing failed " +
|
|
6741
|
+
"on this pull (run `config pull` again).");
|
|
6742
|
+
unwritten.push({
|
|
6743
|
+
blockType,
|
|
6744
|
+
blockKey,
|
|
6745
|
+
slug,
|
|
6746
|
+
name: tc?.name ?? "",
|
|
6747
|
+
testCaseId: tc?.testCaseId,
|
|
6748
|
+
references,
|
|
6749
|
+
preservedSlugs,
|
|
6750
|
+
});
|
|
6751
|
+
}
|
|
6359
6752
|
// Clean up local files for remotely-deleted test cases
|
|
6360
6753
|
if (existsSync(testsDir)) {
|
|
6361
6754
|
const localFiles = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
|
|
6362
6755
|
for (const file of localFiles) {
|
|
6363
6756
|
const localSlug = basename(file, ".toml");
|
|
6364
|
-
|
|
6757
|
+
// A listing keeps the casing a file was created with, so the name a case
|
|
6758
|
+
// was just written to can come back as `Alpha.toml` — the same file.
|
|
6759
|
+
const pulled = pulledSlugs.has(localSlug) ||
|
|
6760
|
+
[...pulledSlugs].some((slug) => sameTestCaseFile(testsDir, slug, localSlug));
|
|
6761
|
+
if (!pulled) {
|
|
6365
6762
|
// Remove orphaned TOML
|
|
6366
6763
|
unlinkSync(join(testsDir, file));
|
|
6367
6764
|
// Remove orphaned attachment dir if exists
|
|
@@ -6381,7 +6778,7 @@ export async function pullTestCasesForBlock(params) {
|
|
|
6381
6778
|
}
|
|
6382
6779
|
}
|
|
6383
6780
|
}
|
|
6384
|
-
return { ok: true, count:
|
|
6781
|
+
return { ok: true, count: writable.length, unwritten };
|
|
6385
6782
|
}
|
|
6386
6783
|
/**
|
|
6387
6784
|
* One pull leg: every block of a type the pull selected (#2769).
|
|
@@ -6395,6 +6792,7 @@ export async function pullTestCasesForBlocks(params) {
|
|
|
6395
6792
|
const log = params.logger ?? ((message) => info(message));
|
|
6396
6793
|
let count = 0;
|
|
6397
6794
|
const skippedBlocks = [];
|
|
6795
|
+
const unwritten = [];
|
|
6398
6796
|
for (const block of params.blocks) {
|
|
6399
6797
|
const outcome = await pullTestCasesForBlock({
|
|
6400
6798
|
client: params.client,
|
|
@@ -6406,6 +6804,7 @@ export async function pullTestCasesForBlocks(params) {
|
|
|
6406
6804
|
testCaseEntities: params.testCaseEntities,
|
|
6407
6805
|
priorTestCaseEntities: params.priorTestCaseEntities,
|
|
6408
6806
|
lookupMaps: params.lookupMaps,
|
|
6807
|
+
writes: params.writes,
|
|
6409
6808
|
});
|
|
6410
6809
|
if (!outcome.ok) {
|
|
6411
6810
|
skippedBlocks.push(block.key);
|
|
@@ -6422,9 +6821,14 @@ export async function pullTestCasesForBlocks(params) {
|
|
|
6422
6821
|
if (outcome.count > 0) {
|
|
6423
6822
|
log(` Wrote ${outcome.count} test case(s) for ${params.blockType}: ${block.key}`);
|
|
6424
6823
|
}
|
|
6824
|
+
if (outcome.unwritten.length > 0) {
|
|
6825
|
+
log(` ${outcome.unwritten.length} test case(s) not written for ` +
|
|
6826
|
+
`${params.blockType}: ${block.key} (unresolved references; see above)`);
|
|
6827
|
+
}
|
|
6425
6828
|
count += outcome.count;
|
|
6829
|
+
unwritten.push(...outcome.unwritten);
|
|
6426
6830
|
}
|
|
6427
|
-
return { count, skippedBlocks };
|
|
6831
|
+
return { count, skippedBlocks, unwritten };
|
|
6428
6832
|
}
|
|
6429
6833
|
/** The 10 MB per-attachment cap the server enforces. */
|
|
6430
6834
|
const TEN_MB = 10 * 1024 * 1024;
|
|
@@ -6616,9 +7020,20 @@ export async function pushTestCasesForBlock(params) {
|
|
|
6616
7020
|
continue;
|
|
6617
7021
|
}
|
|
6618
7022
|
const tomlData = parseTomlFile(filePath);
|
|
7023
|
+
// #3998 — the command's preflight refuses a sidecar it cannot push, but
|
|
7024
|
+
// this leg is exported and the parser no longer reads the retired id keys:
|
|
7025
|
+
// a file reaching it unchecked would be pushed UNPINNED in silence. The
|
|
7026
|
+
// same per-loop defense the workflow leg keeps (#976).
|
|
7027
|
+
const sidecarErrors = testCaseSidecarPreflightErrors(filePath, tomlData);
|
|
7028
|
+
if (sidecarErrors.length > 0) {
|
|
7029
|
+
const message = sidecarErrors.join("; ");
|
|
7030
|
+
warn(` Failed to push test case ${blockKey}/${slug}: ${message}`);
|
|
7031
|
+
failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
|
|
7032
|
+
continue;
|
|
7033
|
+
}
|
|
6619
7034
|
const testPayload = parseTestCaseToml(tomlData);
|
|
6620
|
-
// Resolve
|
|
6621
|
-
// resolve is a FAILURE: silently dropping it would push the test case with
|
|
7035
|
+
// Resolve the authored names to IDs — a name is the only spelling a
|
|
7036
|
+
// sidecar has (#3998). An authored name that does not resolve is a FAILURE: silently dropping it would push the test case with
|
|
6622
7037
|
// no pin, quietly changing what the case tests (#2769). The exception is a
|
|
6623
7038
|
// preview of a block this push has not created yet — its configs cannot
|
|
6624
7039
|
// exist to be named, so a dry run reports the reference as pending rather
|
|
@@ -6639,7 +7054,7 @@ export async function pushTestCasesForBlock(params) {
|
|
|
6639
7054
|
delete testPayload._configName;
|
|
6640
7055
|
delete testPayload._evaluatorPromptKey;
|
|
6641
7056
|
delete testPayload._evaluatorConfigName;
|
|
6642
|
-
if (configName
|
|
7057
|
+
if (configName) {
|
|
6643
7058
|
const configMap = blockType === "prompt"
|
|
6644
7059
|
? resolutionMaps?.promptConfigNameToId
|
|
6645
7060
|
: blockType === "script"
|
|
@@ -6657,14 +7072,14 @@ export async function pushTestCasesForBlock(params) {
|
|
|
6657
7072
|
else
|
|
6658
7073
|
noteUnresolved(`configName "${configName}"`, blockType, blockKey);
|
|
6659
7074
|
}
|
|
6660
|
-
if (evaluatorPromptKey
|
|
7075
|
+
if (evaluatorPromptKey) {
|
|
6661
7076
|
const resolvedId = resolutionMaps?.promptKeyToId.get(evaluatorPromptKey);
|
|
6662
7077
|
if (resolvedId)
|
|
6663
7078
|
testPayload.evaluatorPromptId = resolvedId;
|
|
6664
7079
|
else
|
|
6665
7080
|
noteUnresolved(`evaluatorPromptKey "${evaluatorPromptKey}"`, "prompt", evaluatorPromptKey);
|
|
6666
7081
|
}
|
|
6667
|
-
if (evaluatorConfigName
|
|
7082
|
+
if (evaluatorConfigName) {
|
|
6668
7083
|
let resolvedId = evaluatorPromptKey
|
|
6669
7084
|
? resolutionMaps?.promptConfigNameToId.get(`${evaluatorPromptKey}#${evaluatorConfigName}`)
|
|
6670
7085
|
: undefined;
|
|
@@ -7245,9 +7660,10 @@ export function pairTestCases(input) {
|
|
|
7245
7660
|
* integration and workflow sidecar pinned to a config reported Modified
|
|
7246
7661
|
* forever, and push read the same pair as server drift.
|
|
7247
7662
|
*
|
|
7248
|
-
* One listing per block that has sidecars, and a failed one is silent:
|
|
7249
|
-
* unresolved reference compares as
|
|
7250
|
-
*
|
|
7663
|
+
* One listing per block that has sidecars, and a failed one is silent: the
|
|
7664
|
+
* live side's unresolved reference then compares as its raw id
|
|
7665
|
+
* (`serializeTestCaseForComparison`), so the row reads Modified rather than
|
|
7666
|
+
* hiding a pin.
|
|
7251
7667
|
*/
|
|
7252
7668
|
async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
|
|
7253
7669
|
try {
|
|
@@ -7305,10 +7721,6 @@ export async function compareTestCasesForBlock(params) {
|
|
|
7305
7721
|
});
|
|
7306
7722
|
for (const slug of localSlugs) {
|
|
7307
7723
|
const live = paired.bySlug.get(slug);
|
|
7308
|
-
if (!live) {
|
|
7309
|
-
rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
|
|
7310
|
-
continue;
|
|
7311
|
-
}
|
|
7312
7724
|
const filePath = join(testsDir, `${slug}.toml`);
|
|
7313
7725
|
let row;
|
|
7314
7726
|
try {
|
|
@@ -7316,7 +7728,9 @@ export async function compareTestCasesForBlock(params) {
|
|
|
7316
7728
|
// #2880 criterion 1 — a sidecar push's preflight would reject is not one
|
|
7317
7729
|
// this command calls synced, and the message is the same one. The WHOLE
|
|
7318
7730
|
// preflight, not just the sidecar's own rules: an unknown key or a
|
|
7319
|
-
// mistyped value aborts push too.
|
|
7731
|
+
// mistyped value aborts push too. #3998 — and an UNPAIRED sidecar is
|
|
7732
|
+
// checked before it is called local-only, so a new file push would refuse
|
|
7733
|
+
// (a retired id key) is reported with the guidance, not as a create.
|
|
7320
7734
|
const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
|
|
7321
7735
|
if (errors.length > 0) {
|
|
7322
7736
|
row = {
|
|
@@ -7326,6 +7740,10 @@ export async function compareTestCasesForBlock(params) {
|
|
|
7326
7740
|
hint: errors.join("; "),
|
|
7327
7741
|
};
|
|
7328
7742
|
}
|
|
7743
|
+
else if (!live) {
|
|
7744
|
+
rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
|
|
7745
|
+
continue;
|
|
7746
|
+
}
|
|
7329
7747
|
else {
|
|
7330
7748
|
const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
|
|
7331
7749
|
// Both sides read a reference through the same lookups — see
|
|
@@ -7342,6 +7760,12 @@ export async function compareTestCasesForBlock(params) {
|
|
|
7342
7760
|
hint: `${filePath}: ${err?.message || String(err)}`,
|
|
7343
7761
|
};
|
|
7344
7762
|
}
|
|
7763
|
+
// An unpaired file push would refuse has no attachment plan, as a
|
|
7764
|
+
// local-only one has none.
|
|
7765
|
+
if (!live) {
|
|
7766
|
+
rows.push(row);
|
|
7767
|
+
continue;
|
|
7768
|
+
}
|
|
7345
7769
|
// #2880 behavior 19 — attachments are part of what push would do, read
|
|
7346
7770
|
// from the SAME plan push executes. A sidecar whose text compares clean but
|
|
7347
7771
|
// whose fixtures have not been uploaded is not Synced.
|
|
@@ -7705,7 +8129,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7705
8129
|
lastSyncedAt: new Date().toISOString(),
|
|
7706
8130
|
entities: {},
|
|
7707
8131
|
};
|
|
7708
|
-
saveSyncState(
|
|
8132
|
+
saveSyncState(state);
|
|
7709
8133
|
success(`Initialized config directory: ${configDir}`);
|
|
7710
8134
|
info("Run 'primitive config pull' to download current configuration.");
|
|
7711
8135
|
});
|
|
@@ -7724,6 +8148,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7724
8148
|
.action(async (options) => {
|
|
7725
8149
|
const resolvedAppId = getCurrentAppId();
|
|
7726
8150
|
const configDir = resolveSyncDir();
|
|
8151
|
+
const pullsLocalEnvironment = requireCurrentEnvironment().local;
|
|
7727
8152
|
if (!options.json) {
|
|
7728
8153
|
info(`Using per-environment config directory: ${configDir}`);
|
|
7729
8154
|
}
|
|
@@ -7817,7 +8242,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7817
8242
|
const appWorkflowItems = selectPull("workflow", workflowItems.filter((w) => !isPlatformOwnedWorkflow(w)), (w) => w.workflowKey);
|
|
7818
8243
|
// Fetch details for each entity
|
|
7819
8244
|
const integrations = selectPull("integration", await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId))), (i) => i.integrationKey);
|
|
7820
|
-
|
|
8245
|
+
// #3998 — every prompt's detail is kept, not just the selection's: a
|
|
8246
|
+
// test case of a selected block may be judged by a prompt `--only`
|
|
8247
|
+
// left out, and its sidecar names that prompt by key.
|
|
8248
|
+
const promptDetails = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
|
|
8249
|
+
const prompts = selectPull("prompt", promptDetails, (p) => p.promptKey);
|
|
7821
8250
|
const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
|
|
7822
8251
|
const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
|
|
7823
8252
|
// #2645 — fetch EVERY config's body, not just the active one. The
|
|
@@ -7926,7 +8355,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7926
8355
|
// sync dir (nothing to back up). Prune to 28 days after success.
|
|
7927
8356
|
try {
|
|
7928
8357
|
const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
|
|
7929
|
-
|
|
8358
|
+
// A machine-local environment's baseline is its own file, not the
|
|
8359
|
+
// shared tree's (#4117): that is the one the snapshot carries.
|
|
8360
|
+
const snapshot = createSnapshot(configDir, snapshotsRoot, {
|
|
8361
|
+
baselinePath: pullsLocalEnvironment ? resolveSyncStateReadPath() : undefined,
|
|
8362
|
+
});
|
|
7930
8363
|
if (snapshot) {
|
|
7931
8364
|
info(` Snapshot: ${snapshot.path}`);
|
|
7932
8365
|
pruneSnapshots(snapshotsRoot, 28);
|
|
@@ -7955,11 +8388,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7955
8388
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
7956
8389
|
ensureDir(join(configDir, "metadata-category-configs"));
|
|
7957
8390
|
ensureDir(join(configDir, "email-templates"));
|
|
8391
|
+
// #4160 — every pulled file goes through `writePulledFile`, which
|
|
8392
|
+
// leaves a file alone when it already says what the server says, and
|
|
8393
|
+
// counts both outcomes for the summary below.
|
|
8394
|
+
const pullWrites = newPullWriteTally();
|
|
7958
8395
|
// The prior sync state is the record of which local files a previous
|
|
7959
8396
|
// pull wrote — the discriminator the prune pass keys off (#1659). Read
|
|
7960
8397
|
// it now: `saveSyncState` at the end of the pull overwrites it, and the
|
|
7961
8398
|
// files themselves are about to be rewritten.
|
|
7962
|
-
const priorState = loadSyncState(
|
|
8399
|
+
const priorState = loadSyncState();
|
|
7963
8400
|
const priorEntities = priorState?.entities ?? {};
|
|
7964
8401
|
// Write app settings. Under `--only`, `app` has to be named — a pull
|
|
7965
8402
|
// scoped to one workflow must not overwrite app.toml, which is what
|
|
@@ -7986,8 +8423,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7986
8423
|
// Every writable setting round-trips as of #2891 — the server
|
|
7987
8424
|
// withholds nothing — so the pulled file is complete and pushable as
|
|
7988
8425
|
// written, with no accompanying note about what it could not carry.
|
|
7989
|
-
|
|
7990
|
-
|
|
8426
|
+
// On a machine-local environment the tree's name is the shared
|
|
8427
|
+
// tree's, and a pull never writes the child's over it (#4117).
|
|
8428
|
+
const written = writePulledAppSettings(configDir, settings, {
|
|
8429
|
+
keepStatedName: pullsLocalEnvironment,
|
|
8430
|
+
tally: pullWrites,
|
|
8431
|
+
});
|
|
8432
|
+
if (written.written)
|
|
8433
|
+
info(" Wrote app.toml");
|
|
8434
|
+
if (written.note)
|
|
8435
|
+
warn(` ${written.note}`);
|
|
7991
8436
|
}
|
|
7992
8437
|
// Write config vars (issue #1423). A single flat vars.toml at the
|
|
7993
8438
|
// sync-dir root (like app.toml), holding the per-environment non-secret
|
|
@@ -8027,17 +8472,21 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8027
8472
|
? Object.fromEntries(Object.entries(parseConfigToml(readFileSync(varsTomlPathPull, "utf-8"))).map(([k, v]) => [k, String(v ?? "")]))
|
|
8028
8473
|
: {};
|
|
8029
8474
|
const scoped = planScopedVarsPull(varsOutcome.vars, pullSelection.varKeys, localVars, priorEntities.vars);
|
|
8030
|
-
|
|
8475
|
+
const varsWritten = writePulledFile(varsTomlPathPull, scoped.content, pullWrites, sameTomlAs(scoped.content));
|
|
8031
8476
|
Object.assign(varEntities, scoped.varEntities);
|
|
8032
|
-
|
|
8477
|
+
if (varsWritten) {
|
|
8478
|
+
info(` Wrote vars.toml (${pullSelection.varKeys.size} selected var(s))`);
|
|
8479
|
+
}
|
|
8033
8480
|
}
|
|
8034
8481
|
else {
|
|
8035
8482
|
const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
|
|
8036
8483
|
Object.assign(varEntities, varsPlan.varEntities);
|
|
8037
8484
|
if (varsPlan.write && varsPlan.content !== null) {
|
|
8038
|
-
|
|
8485
|
+
const varsWritten = writePulledFile(varsTomlPathPull, varsPlan.content, pullWrites, sameTomlAs(varsPlan.content));
|
|
8039
8486
|
const count = varsOutcome.vars.length;
|
|
8040
|
-
|
|
8487
|
+
if (varsWritten) {
|
|
8488
|
+
info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
|
|
8489
|
+
}
|
|
8041
8490
|
}
|
|
8042
8491
|
else {
|
|
8043
8492
|
const err = varsOutcome.error;
|
|
@@ -8057,16 +8506,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8057
8506
|
for (const integration of integrations) {
|
|
8058
8507
|
const filename = `${integration.integrationKey}.toml`;
|
|
8059
8508
|
const filePath = join(configDir, "integrations", filename);
|
|
8060
|
-
|
|
8509
|
+
const content = serializeIntegration(integration, (msg) => info(` ${msg}`));
|
|
8510
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("integration", content, filePath, integrationPullMaps));
|
|
8061
8511
|
integrationEntities[integration.integrationKey] = {
|
|
8062
8512
|
id: integration.integrationId,
|
|
8063
8513
|
modifiedAt: integration.modifiedAt || new Date().toISOString(),
|
|
8064
8514
|
contentHash: computeFileHash(filePath),
|
|
8065
8515
|
// #2880 — the two sides agree right now, so this is the baseline
|
|
8066
8516
|
// the next push classifies direction against.
|
|
8067
|
-
semanticHash:
|
|
8517
|
+
semanticHash: pulledSemanticHash("integration", content, integrationPullMaps),
|
|
8068
8518
|
};
|
|
8069
|
-
|
|
8519
|
+
if (written)
|
|
8520
|
+
info(` Wrote integrations/${filename}`);
|
|
8070
8521
|
}
|
|
8071
8522
|
// Pull webhooks. #2803 — an ARCHIVED webhook is retired, not
|
|
8072
8523
|
// configuration, so it is not exported: writing a tombstone into the
|
|
@@ -8091,14 +8542,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8091
8542
|
for (const webhook of webhooks) {
|
|
8092
8543
|
const filename = `${webhook.webhookKey}.toml`;
|
|
8093
8544
|
const filePath = join(webhooksDir, filename);
|
|
8094
|
-
|
|
8545
|
+
const content = serializeWebhook(webhook, (msg) => info(` ${msg}`));
|
|
8546
|
+
writePulledFile(filePath, content, pullWrites, sameConfigAs("webhook", content, filePath, webhookPullMaps));
|
|
8095
8547
|
webhookEntities[webhook.webhookKey] = {
|
|
8096
8548
|
id: webhook.webhookId,
|
|
8097
8549
|
modifiedAt: webhook.modifiedAt || new Date().toISOString(),
|
|
8098
8550
|
contentHash: computeFileHash(filePath),
|
|
8099
8551
|
// #2880 — the file and the server agree right now, so this is the
|
|
8100
8552
|
// baseline the next push classifies direction against.
|
|
8101
|
-
semanticHash:
|
|
8553
|
+
semanticHash: pulledSemanticHash("webhook", content, webhookPullMaps),
|
|
8102
8554
|
};
|
|
8103
8555
|
// A stored value that is not a reference is never written to TOML, so
|
|
8104
8556
|
// pull time is where an operator finds out: a legacy literal from
|
|
@@ -8131,6 +8583,24 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8131
8583
|
}
|
|
8132
8584
|
}
|
|
8133
8585
|
info(` Pulled ${webhooks.length} webhook(s)`);
|
|
8586
|
+
// #2731 B4 — pull is one of the two moments local and server provably
|
|
8587
|
+
// agree, so it stamps the semantic baseline beside the byte hash (push
|
|
8588
|
+
// stamps the other one, from the entity the server returned). The
|
|
8589
|
+
// rule-set maps the shared projection resolves references through are
|
|
8590
|
+
// filled here, before the first type that names a rule set (blob
|
|
8591
|
+
// buckets, #4116), from the FULL listing rather than the `--only`
|
|
8592
|
+
// selection: a scoped pull of a bucket must still name its rule set.
|
|
8593
|
+
const ruleSetIdToName = new Map();
|
|
8594
|
+
const ruleSetNameToIdPull = new Map();
|
|
8595
|
+
const pullDiffMaps = {
|
|
8596
|
+
ruleSetIdToName,
|
|
8597
|
+
ruleSetNameToId: ruleSetNameToIdPull,
|
|
8598
|
+
};
|
|
8599
|
+
for (const ruleSet of Array.isArray(ruleSetsResult) ? ruleSetsResult : []) {
|
|
8600
|
+
ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
|
|
8601
|
+
if (ruleSet.name)
|
|
8602
|
+
ruleSetNameToIdPull.set(ruleSet.name, ruleSet.ruleSetId);
|
|
8603
|
+
}
|
|
8134
8604
|
// Pull cron triggers. An older server without the route (or any other
|
|
8135
8605
|
// failure) leaves the type unlisted — tolerated as before, but recorded
|
|
8136
8606
|
// so prune skips it rather than reading it as "no cron triggers".
|
|
@@ -8139,8 +8609,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8139
8609
|
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
8140
8610
|
mkdirSync(cronTriggersDir, { recursive: true });
|
|
8141
8611
|
const cronTriggerEntities = {};
|
|
8142
|
-
// No rule-set reference on
|
|
8143
|
-
// filled until the rule-set leg — see the webhook block above.
|
|
8612
|
+
// No rule-set reference on a cron trigger — see the webhook block above.
|
|
8144
8613
|
const keylessPullMaps = {
|
|
8145
8614
|
ruleSetIdToName: new Map(),
|
|
8146
8615
|
ruleSetNameToId: new Map(),
|
|
@@ -8148,13 +8617,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8148
8617
|
for (const trigger of cronTriggerItems) {
|
|
8149
8618
|
const filename = `${trigger.triggerKey}.toml`;
|
|
8150
8619
|
const filePath = join(cronTriggersDir, filename);
|
|
8151
|
-
|
|
8620
|
+
const content = serializeCronTrigger(trigger, (msg) => info(` ${msg}`));
|
|
8621
|
+
writePulledFile(filePath, content, pullWrites, sameConfigAs("cron-trigger", content, filePath, keylessPullMaps));
|
|
8152
8622
|
cronTriggerEntities[trigger.triggerKey] = {
|
|
8153
8623
|
id: trigger.triggerId,
|
|
8154
8624
|
modifiedAt: trigger.modifiedAt || new Date().toISOString(),
|
|
8155
8625
|
contentHash: computeFileHash(filePath),
|
|
8156
8626
|
// #2880 — the baseline the next push classifies direction against.
|
|
8157
|
-
semanticHash:
|
|
8627
|
+
semanticHash: pulledSemanticHash("cron-trigger", content, keylessPullMaps),
|
|
8158
8628
|
};
|
|
8159
8629
|
}
|
|
8160
8630
|
info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
|
|
@@ -8164,31 +8634,50 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8164
8634
|
const blobBucketsDir = join(configDir, "blob-buckets");
|
|
8165
8635
|
mkdirSync(blobBucketsDir, { recursive: true });
|
|
8166
8636
|
const blobBucketEntities = {};
|
|
8637
|
+
// #4116 — a bound bucket's file names its rule set, which only the
|
|
8638
|
+
// rule-set listing can supply. When that listing failed, the bucket's
|
|
8639
|
+
// file and sync-state entry are kept as they are rather than rewritten
|
|
8640
|
+
// without the name.
|
|
8641
|
+
const unwrittenBlobBucketKeys = [];
|
|
8167
8642
|
for (const bucket of blobBucketItems) {
|
|
8643
|
+
if (!ruleSetsOutcome.ok && bucket.ruleSetId) {
|
|
8644
|
+
unwrittenBlobBucketKeys.push(bucket.bucketKey);
|
|
8645
|
+
continue;
|
|
8646
|
+
}
|
|
8168
8647
|
const filename = `${bucket.bucketKey}.toml`;
|
|
8169
8648
|
const filePath = join(blobBucketsDir, filename);
|
|
8170
|
-
|
|
8649
|
+
const content = serializeBlobBucket(bucket, ruleSetIdToName, (msg) => info(` ${msg}`));
|
|
8650
|
+
writePulledFile(filePath, content, pullWrites, sameConfigAs("blob-bucket", content, filePath, pullDiffMaps));
|
|
8171
8651
|
blobBucketEntities[bucket.bucketKey] = {
|
|
8172
8652
|
id: bucket.bucketId,
|
|
8173
8653
|
modifiedAt: bucket.modifiedAt || new Date().toISOString(),
|
|
8174
8654
|
contentHash: computeFileHash(filePath),
|
|
8175
|
-
// #2880 —
|
|
8176
|
-
semanticHash:
|
|
8655
|
+
// #2880 — the baseline the next push classifies direction against.
|
|
8656
|
+
semanticHash: pulledSemanticHash("blob-bucket", content, pullDiffMaps),
|
|
8177
8657
|
};
|
|
8178
8658
|
}
|
|
8179
|
-
info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
|
|
8659
|
+
info(` Pulled ${blobBucketItems.length - unwrittenBlobBucketKeys.length} blob bucket(s)`);
|
|
8660
|
+
if (unwrittenBlobBucketKeys.length > 0) {
|
|
8661
|
+
warn(`${unwrittenBlobBucketKeys.length} blob bucket(s) not written — the rule-set ` +
|
|
8662
|
+
`listing failed, so their ruleSetName is unknown; existing files kept ` +
|
|
8663
|
+
`(${unwrittenBlobBucketKeys.join(", ")})`);
|
|
8664
|
+
}
|
|
8180
8665
|
// Write prompts
|
|
8181
8666
|
const promptEntities = {};
|
|
8182
8667
|
for (const prompt of prompts) {
|
|
8183
8668
|
const filename = `${prompt.promptKey}.toml`;
|
|
8184
8669
|
const filePath = join(configDir, "prompts", filename);
|
|
8185
|
-
|
|
8670
|
+
const content = serializePrompt(prompt, (msg) => info(` ${msg}`));
|
|
8671
|
+
const written = writePulledFile(filePath, content, pullWrites,
|
|
8672
|
+
// Prompts carry no rule-set reference.
|
|
8673
|
+
sameConfigAs("prompt", content, filePath, keylessPullMaps));
|
|
8186
8674
|
promptEntities[prompt.promptKey] = {
|
|
8187
8675
|
id: prompt.promptId,
|
|
8188
8676
|
modifiedAt: prompt.modifiedAt || new Date().toISOString(),
|
|
8189
8677
|
contentHash: computeFileHash(filePath),
|
|
8190
8678
|
};
|
|
8191
|
-
|
|
8679
|
+
if (written)
|
|
8680
|
+
info(` Wrote prompts/${filename}`);
|
|
8192
8681
|
}
|
|
8193
8682
|
// Write workflows
|
|
8194
8683
|
const workflowEntities = {};
|
|
@@ -8214,13 +8703,21 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8214
8703
|
if (reconciled.message) {
|
|
8215
8704
|
info(` ${reconciled.message}`);
|
|
8216
8705
|
}
|
|
8706
|
+
let workflowWritten = false;
|
|
8217
8707
|
if (reconciled.action === "write-merged") {
|
|
8218
8708
|
writeFileSync(filePath, reconciled.content);
|
|
8709
|
+
pullWrites.written += 1;
|
|
8710
|
+
workflowWritten = true;
|
|
8219
8711
|
}
|
|
8220
8712
|
else if (reconciled.action === "write-expanded") {
|
|
8221
|
-
|
|
8713
|
+
// #4160 — a file with no fragments that already describes the
|
|
8714
|
+
// running workflow keeps its bytes.
|
|
8715
|
+
workflowWritten = writePulledFile(filePath, remoteToml, pullWrites, sameWorkflowAs(workflow.workflowKey, remoteToml));
|
|
8716
|
+
}
|
|
8717
|
+
else {
|
|
8718
|
+
pullWrites.unchanged += 1;
|
|
8222
8719
|
}
|
|
8223
|
-
if (
|
|
8720
|
+
if (workflowWritten) {
|
|
8224
8721
|
info(` Wrote workflows/${filename}`);
|
|
8225
8722
|
}
|
|
8226
8723
|
// #2645 — the workflow's OTHER named configs, one file each. The
|
|
@@ -8232,8 +8729,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8232
8729
|
const sidecarDir = workflowConfigsDir(configDir, workflow.workflowKey);
|
|
8233
8730
|
mkdirSync(sidecarDir, { recursive: true });
|
|
8234
8731
|
for (const sidecar of sidecars) {
|
|
8235
|
-
|
|
8236
|
-
|
|
8732
|
+
const sidecarWritten = writePulledFile(join(sidecarDir, `${sidecar.name}.toml`), sidecar.content, pullWrites, (local) => hashWorkflowConfigSidecar(parseConfigToml(local)) ===
|
|
8733
|
+
hashWorkflowConfigSidecar(parseConfigToml(sidecar.content)));
|
|
8734
|
+
if (sidecarWritten) {
|
|
8735
|
+
info(` Wrote workflows/${workflow.workflowKey}.configs/${sidecar.name}.toml`);
|
|
8736
|
+
}
|
|
8237
8737
|
}
|
|
8238
8738
|
}
|
|
8239
8739
|
// A sidecar for a config the server no longer has is a file that
|
|
@@ -8300,7 +8800,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8300
8800
|
return false;
|
|
8301
8801
|
matchedSelectors.add(`transform/${name}`);
|
|
8302
8802
|
return true;
|
|
8303
|
-
});
|
|
8803
|
+
}, pullWrites);
|
|
8304
8804
|
if (pulledScriptsCount > 0) {
|
|
8305
8805
|
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
8306
8806
|
}
|
|
@@ -8314,79 +8814,49 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8314
8814
|
return false;
|
|
8315
8815
|
matchedSelectors.add(`function/${key}`);
|
|
8316
8816
|
return true;
|
|
8317
|
-
});
|
|
8817
|
+
}, pullWrites);
|
|
8318
8818
|
if (pulledFunctionsCount > 0) {
|
|
8319
8819
|
info(` Pulled ${pulledFunctionsCount} function(s)`);
|
|
8320
8820
|
}
|
|
8321
|
-
//
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
// Also include workflow config names
|
|
8333
|
-
for (const { configs } of workflows) {
|
|
8334
|
-
if (configs) {
|
|
8335
|
-
for (const config of configs) {
|
|
8336
|
-
configIdToName.set(config.configId, config.configName);
|
|
8337
|
-
}
|
|
8338
|
-
}
|
|
8339
|
-
}
|
|
8340
|
-
// Include script config names so a pinned script test case
|
|
8341
|
-
// round-trips its `configName` reference (the same way prompt/workflow
|
|
8342
|
-
// configs above do). One extra `configs` fetch per pulled script.
|
|
8343
|
-
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
8821
|
+
// Lookup maps for test case serialization (#3998): every prompt's
|
|
8822
|
+
// detail, so a judge prompt outside an `--only` selection still names,
|
|
8823
|
+
// and the configurations of the pulled blocks — a case pins one of its
|
|
8824
|
+
// own block's. A reference these cannot resolve is reported by
|
|
8825
|
+
// `pullTestCasesForBlock`, and its sidecar is not written.
|
|
8826
|
+
const blockConfigs = [];
|
|
8827
|
+
for (const { configs } of workflows)
|
|
8828
|
+
blockConfigs.push(...(configs ?? []));
|
|
8829
|
+
// One `configs` fetch per pulled script and integration. A failed one
|
|
8830
|
+
// is non-fatal: the references it would have named are reported.
|
|
8831
|
+
for (const entity of Object.values(scriptEntities)) {
|
|
8344
8832
|
try {
|
|
8345
|
-
const { items
|
|
8346
|
-
|
|
8347
|
-
configIdToName.set(config.configId, config.configName);
|
|
8348
|
-
}
|
|
8833
|
+
const { items } = await client.listScriptConfigs(resolvedAppId, entity.id);
|
|
8834
|
+
blockConfigs.push(...items);
|
|
8349
8835
|
}
|
|
8350
8836
|
catch {
|
|
8351
|
-
// Non-fatal
|
|
8352
|
-
// its legacy id spelling instead of a name (#2769), so the pin
|
|
8353
|
-
// survives. The header/body still pulled fine — `name` retained.
|
|
8354
|
-
void name;
|
|
8837
|
+
// Non-fatal — see above.
|
|
8355
8838
|
}
|
|
8356
8839
|
}
|
|
8357
|
-
// Integration config names, for the same reason (#2769). A failed fetch
|
|
8358
|
-
// is non-fatal here too: `serializeTestCase` keeps the pin as `configId`
|
|
8359
|
-
// rather than writing an empty reference.
|
|
8360
8840
|
for (const integration of integrations) {
|
|
8361
8841
|
try {
|
|
8362
|
-
const { items
|
|
8363
|
-
|
|
8364
|
-
configIdToName.set(config.configId, config.configName);
|
|
8365
|
-
}
|
|
8842
|
+
const { items } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
|
|
8843
|
+
blockConfigs.push(...items);
|
|
8366
8844
|
}
|
|
8367
8845
|
catch {
|
|
8368
8846
|
// Non-fatal — see above.
|
|
8369
8847
|
}
|
|
8370
8848
|
}
|
|
8371
|
-
const testCaseLookupMaps = {
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
// rule-set maps the shared projection resolves references through are
|
|
8376
|
-
// declared here and filled below, before the first type that needs
|
|
8377
|
-
// them is written.
|
|
8378
|
-
const ruleSetIdToName = new Map();
|
|
8379
|
-
const ruleSetNameToIdPull = new Map();
|
|
8380
|
-
const pullDiffMaps = {
|
|
8381
|
-
ruleSetIdToName,
|
|
8382
|
-
ruleSetNameToId: ruleSetNameToIdPull,
|
|
8383
|
-
};
|
|
8849
|
+
const testCaseLookupMaps = testCaseLookupMapsForPull({
|
|
8850
|
+
prompts: promptDetails,
|
|
8851
|
+
configs: blockConfigs,
|
|
8852
|
+
});
|
|
8384
8853
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
8385
8854
|
const emailTemplateEntities = {};
|
|
8386
8855
|
for (const template of emailTemplates) {
|
|
8387
8856
|
const filename = `${template.emailType}.toml`;
|
|
8388
8857
|
const templatePath = join(configDir, "email-templates", filename);
|
|
8389
|
-
|
|
8858
|
+
const content = serializeEmailTemplate(template, (msg) => info(` ${msg}`));
|
|
8859
|
+
const written = writePulledFile(templatePath, content, pullWrites, sameConfigAs("email-template", content, templatePath, pullDiffMaps));
|
|
8390
8860
|
emailTemplateEntities[template.emailType] = {
|
|
8391
8861
|
id: template.emailType,
|
|
8392
8862
|
modifiedAt: template.override?.modifiedAt || new Date().toISOString(),
|
|
@@ -8394,9 +8864,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8394
8864
|
// live-fetch fallback gates on, and an entry without one fails
|
|
8395
8865
|
// closed rather than being blind-upserted.
|
|
8396
8866
|
contentHash: computeFileHash(templatePath),
|
|
8397
|
-
semanticHash:
|
|
8867
|
+
semanticHash: pulledSemanticHash("email-template", content, pullDiffMaps),
|
|
8398
8868
|
};
|
|
8399
|
-
|
|
8869
|
+
if (written)
|
|
8870
|
+
info(` Wrote email-templates/${filename}`);
|
|
8400
8871
|
}
|
|
8401
8872
|
// Email-template files for removed overrides used to be cleaned up here
|
|
8402
8873
|
// by a hand-coded loop — the only type that pruned at all (#1659). It is
|
|
@@ -8408,6 +8879,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8408
8879
|
const testCaseEntities = {};
|
|
8409
8880
|
const priorTestCaseEntities = priorEntities.testCases;
|
|
8410
8881
|
let totalTestCases = 0;
|
|
8882
|
+
let unwrittenTestCases = 0;
|
|
8411
8883
|
for (const [blockType, blocks] of [
|
|
8412
8884
|
[
|
|
8413
8885
|
"prompt",
|
|
@@ -8437,7 +8909,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8437
8909
|
})),
|
|
8438
8910
|
],
|
|
8439
8911
|
]) {
|
|
8440
|
-
const { count } = await pullTestCasesForBlocks({
|
|
8912
|
+
const { count, unwritten } = await pullTestCasesForBlocks({
|
|
8441
8913
|
client,
|
|
8442
8914
|
appId: resolvedAppId,
|
|
8443
8915
|
configDir,
|
|
@@ -8447,16 +8919,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8447
8919
|
priorTestCaseEntities,
|
|
8448
8920
|
lookupMaps: testCaseLookupMaps,
|
|
8449
8921
|
logger: (message) => info(message),
|
|
8922
|
+
writes: pullWrites,
|
|
8450
8923
|
});
|
|
8451
8924
|
totalTestCases += count;
|
|
8925
|
+
unwrittenTestCases += unwritten.length;
|
|
8452
8926
|
}
|
|
8453
|
-
//
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8927
|
+
// #3998 — a case pull could not name a reference for is not written, so
|
|
8928
|
+
// its pin is never lost; the count is repeated here so it is not missed
|
|
8929
|
+
// among the per-block lines. Exit status is unchanged, as for every
|
|
8930
|
+
// other partial fetch this command reports.
|
|
8931
|
+
if (unwrittenTestCases > 0) {
|
|
8932
|
+
warn(`${unwrittenTestCases} test case(s) not written — references could not be resolved; see above`);
|
|
8459
8933
|
}
|
|
8934
|
+
// The rule-set files this pull writes. The reference maps above are
|
|
8935
|
+
// filled from the full listing instead, so a scoped pull of a type that
|
|
8936
|
+
// names a rule set still names it (#4116, DSO-4116-001).
|
|
8937
|
+
const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
|
|
8460
8938
|
// Write database types
|
|
8461
8939
|
const databaseTypeEntities = {};
|
|
8462
8940
|
for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
|
|
@@ -8468,14 +8946,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8468
8946
|
const hints = existsSync(filePath)
|
|
8469
8947
|
? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
|
|
8470
8948
|
: undefined;
|
|
8471
|
-
|
|
8949
|
+
const content = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
8472
8950
|
hints,
|
|
8473
8951
|
defaultForm: "native",
|
|
8474
8952
|
logger: (msg) => info(` ${msg}`),
|
|
8475
8953
|
// Issue #803: emit `[[subscriptions]]` blocks so a pull → push
|
|
8476
8954
|
// cycle round-trips subscriptions without a spurious diff.
|
|
8477
8955
|
subscriptions,
|
|
8478
|
-
})
|
|
8956
|
+
});
|
|
8957
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("database-type-config", content, filePath, pullDiffMaps));
|
|
8479
8958
|
const opsEntities = {};
|
|
8480
8959
|
for (const op of operations) {
|
|
8481
8960
|
opsEntities[op.name] = {
|
|
@@ -8499,11 +8978,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8499
8978
|
operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
|
|
8500
8979
|
subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
|
|
8501
8980
|
contentHash: computeFileHash(filePath),
|
|
8502
|
-
semanticHash:
|
|
8981
|
+
semanticHash: pulledSemanticHash("database-type-config", content, pullDiffMaps),
|
|
8503
8982
|
hasSchema: typeof typeConfig.schema === "string" &&
|
|
8504
8983
|
typeConfig.schema.trim().length > 0,
|
|
8505
8984
|
};
|
|
8506
|
-
|
|
8985
|
+
if (written)
|
|
8986
|
+
info(` Wrote database-type-configs/${filename}`);
|
|
8507
8987
|
}
|
|
8508
8988
|
// #3182 — the typed-handle artifacts are REGENERATED, never pulled:
|
|
8509
8989
|
// they are not in the envelope, and a fresh clone must end up with the
|
|
@@ -8526,14 +9006,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8526
9006
|
const fileKey = ruleSetFileKey(ruleSet);
|
|
8527
9007
|
const filename = `${fileKey}.toml`;
|
|
8528
9008
|
const filePath = join(configDir, "rule-sets", filename);
|
|
8529
|
-
|
|
9009
|
+
const content = serializeRuleSet(ruleSet, (msg) => info(` ${msg}`));
|
|
9010
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("rule-set", content, filePath, pullDiffMaps));
|
|
8530
9011
|
ruleSetEntities[fileKey] = {
|
|
8531
9012
|
id: ruleSet.ruleSetId,
|
|
8532
9013
|
modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
|
|
8533
9014
|
contentHash: computeFileHash(filePath),
|
|
8534
|
-
semanticHash:
|
|
9015
|
+
semanticHash: pulledSemanticHash("rule-set", content, pullDiffMaps),
|
|
8535
9016
|
};
|
|
8536
|
-
|
|
9017
|
+
if (written)
|
|
9018
|
+
info(` Wrote rule-sets/${filename}`);
|
|
8537
9019
|
}
|
|
8538
9020
|
// Write group type configs
|
|
8539
9021
|
const groupTypeConfigEntities = {};
|
|
@@ -8541,13 +9023,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8541
9023
|
for (const config of groupTypeConfigs) {
|
|
8542
9024
|
const filename = `${config.groupType}.toml`;
|
|
8543
9025
|
const filePath = join(configDir, "group-type-configs", filename);
|
|
8544
|
-
|
|
9026
|
+
const content = serializeGroupTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
|
|
9027
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("group-type-config", content, filePath, pullDiffMaps));
|
|
8545
9028
|
groupTypeConfigEntities[config.groupType] = {
|
|
8546
9029
|
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
8547
9030
|
contentHash: computeFileHash(filePath),
|
|
8548
|
-
semanticHash:
|
|
9031
|
+
semanticHash: pulledSemanticHash("group-type-config", content, pullDiffMaps),
|
|
8549
9032
|
};
|
|
8550
|
-
|
|
9033
|
+
if (written)
|
|
9034
|
+
info(` Wrote group-type-configs/${filename}`);
|
|
8551
9035
|
}
|
|
8552
9036
|
// Write collection type configs
|
|
8553
9037
|
const collectionTypeConfigEntities = {};
|
|
@@ -8555,13 +9039,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8555
9039
|
for (const config of collectionTypeConfigs) {
|
|
8556
9040
|
const filename = `${config.collectionType}.toml`;
|
|
8557
9041
|
const filePath = join(configDir, "collection-type-configs", filename);
|
|
8558
|
-
|
|
9042
|
+
const content = serializeCollectionTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
|
|
9043
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("collection-type-config", content, filePath, pullDiffMaps));
|
|
8559
9044
|
collectionTypeConfigEntities[config.collectionType] = {
|
|
8560
9045
|
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
8561
9046
|
contentHash: computeFileHash(filePath),
|
|
8562
|
-
semanticHash:
|
|
9047
|
+
semanticHash: pulledSemanticHash("collection-type-config", content, pullDiffMaps),
|
|
8563
9048
|
};
|
|
8564
|
-
|
|
9049
|
+
if (written)
|
|
9050
|
+
info(` Wrote collection-type-configs/${filename}`);
|
|
8565
9051
|
}
|
|
8566
9052
|
// Write metadata category configs (issue #1304, P-B). One file per
|
|
8567
9053
|
// `{resourceType, category}`; the entity key is the `resourceType#category`
|
|
@@ -8571,13 +9057,15 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8571
9057
|
for (const config of metadataCategoryConfigs) {
|
|
8572
9058
|
const filename = `${config.resourceType}.${config.category}.toml`;
|
|
8573
9059
|
const filePath = join(configDir, "metadata-category-configs", filename);
|
|
8574
|
-
|
|
9060
|
+
const content = serializeMetadataCategoryConfig(config, (msg) => info(` ${msg}`));
|
|
9061
|
+
const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("metadata-category-config", content, filePath, pullDiffMaps));
|
|
8575
9062
|
metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
|
|
8576
9063
|
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
8577
9064
|
contentHash: computeFileHash(filePath),
|
|
8578
|
-
semanticHash:
|
|
9065
|
+
semanticHash: pulledSemanticHash("metadata-category-config", content, pullDiffMaps),
|
|
8579
9066
|
};
|
|
8580
|
-
|
|
9067
|
+
if (written)
|
|
9068
|
+
info(` Wrote metadata-category-configs/${filename}`);
|
|
8581
9069
|
}
|
|
8582
9070
|
// ── Prune reconciliation (issue #1659) ───────────────────────────────
|
|
8583
9071
|
//
|
|
@@ -8727,6 +9215,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8727
9215
|
preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
|
|
8728
9216
|
? emailTemplateOverrides.map((t) => t.emailType)
|
|
8729
9217
|
: []);
|
|
9218
|
+
preserveUnwrittenEntries(blobBucketEntities, priorEntities.blobBuckets, unwrittenBlobBucketKeys);
|
|
8730
9219
|
const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
|
|
8731
9220
|
const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
|
|
8732
9221
|
const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
|
|
@@ -8924,8 +9413,12 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8924
9413
|
// this is the state both sides agree on: the baseline the
|
|
8925
9414
|
// next push classifies against. A pull also ends any
|
|
8926
9415
|
// cross-app name exclusion, because the file it just wrote
|
|
8927
|
-
// states the target's own name
|
|
8928
|
-
|
|
9416
|
+
// states the target's own name — except on a
|
|
9417
|
+
// machine-local environment, whose file states the shared
|
|
9418
|
+
// tree's name and whose baseline never includes one.
|
|
9419
|
+
semanticHash: hashAppSettingsForDiff(settings, {
|
|
9420
|
+
excludeName: pullsLocalEnvironment,
|
|
9421
|
+
}),
|
|
8929
9422
|
}
|
|
8930
9423
|
: pullSelection
|
|
8931
9424
|
? priorEntities.app
|
|
@@ -8952,7 +9445,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8952
9445
|
: undefined,
|
|
8953
9446
|
},
|
|
8954
9447
|
};
|
|
8955
|
-
saveSyncState(
|
|
9448
|
+
saveSyncState(state);
|
|
8956
9449
|
divider();
|
|
8957
9450
|
success(`Pulled configuration to ${configDir}`);
|
|
8958
9451
|
keyValue("Config Vars", Object.keys(varEntities).length);
|
|
@@ -8966,12 +9459,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8966
9459
|
keyValue("Functions", pulledFunctionsCount);
|
|
8967
9460
|
keyValue("Email Templates", emailTemplates.length);
|
|
8968
9461
|
keyValue("Test Cases", totalTestCases);
|
|
9462
|
+
if (unwrittenTestCases > 0) {
|
|
9463
|
+
keyValue("Test Cases not written", unwrittenTestCases);
|
|
9464
|
+
}
|
|
8969
9465
|
keyValue("Database Types", databaseTypesWithOps.length);
|
|
8970
9466
|
keyValue("Rule Sets", ruleSets.length);
|
|
8971
9467
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
8972
9468
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
8973
9469
|
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
8974
9470
|
keyValue("Pruned", prunedCount);
|
|
9471
|
+
keyValue("Files written", pullWrites.written);
|
|
9472
|
+
keyValue("Files unchanged", pullWrites.unchanged);
|
|
8975
9473
|
// A selector the server had nothing for wrote nothing. Say so and fail:
|
|
8976
9474
|
// otherwise a typo pulls an empty set and reports success, and the
|
|
8977
9475
|
// operator reads "Pulled configuration" as "my object is now in the
|
|
@@ -9079,7 +9577,11 @@ What a push guarantees (two stages):
|
|
|
9079
9577
|
// state write holds on the first push and the end-of-run save persists
|
|
9080
9578
|
// the ids. Without it a successful create-push recorded nothing and the
|
|
9081
9579
|
// next push re-entered every create path.
|
|
9082
|
-
let syncState = ensurePushSyncState(loadSyncState(
|
|
9580
|
+
let syncState = ensurePushSyncState(loadSyncState(), resolvedAppId, getServerUrl());
|
|
9581
|
+
// A machine-local environment (#4117) shares a committed environment's
|
|
9582
|
+
// tree, so `[app].name` in it is that environment's name: never sent,
|
|
9583
|
+
// never compared, never hashed into this environment's baseline.
|
|
9584
|
+
const localEnvironment = requireCurrentEnvironment().local;
|
|
9083
9585
|
// Detect cross-app push: if pushing to a different app, clear entity IDs
|
|
9084
9586
|
// so everything goes through the create path
|
|
9085
9587
|
const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
|
|
@@ -9515,6 +10017,7 @@ What a push guarantees (two stages):
|
|
|
9515
10017
|
syncState,
|
|
9516
10018
|
force: !!options.force,
|
|
9517
10019
|
isCrossAppPush: !!isCrossAppPush,
|
|
10020
|
+
localEnvironment,
|
|
9518
10021
|
client,
|
|
9519
10022
|
});
|
|
9520
10023
|
// Re-derived, not returned: both are one-line functions of inputs the
|
|
@@ -9691,13 +10194,21 @@ What a push guarantees (two stages):
|
|
|
9691
10194
|
// `appNameEdited` — the file has moved away from the recorded name.
|
|
9692
10195
|
// That is a local rename BY CONSTRUCTION, not by comparison: no
|
|
9693
10196
|
// baseline hashed without `name` can ever attribute it.
|
|
10197
|
+
//
|
|
10198
|
+
// On a machine-local environment (#4117) all three are settled
|
|
10199
|
+
// before they start: the name is the shared tree's, so it is out of
|
|
10200
|
+
// the body and the comparison, and no edit to it is this app's.
|
|
9694
10201
|
const payloadExcludesName = appSettingsNameExcluded({
|
|
10202
|
+
localEnvironment,
|
|
9695
10203
|
isCrossAppPush: !!isCrossAppPush,
|
|
9696
10204
|
recordedExcludedName,
|
|
9697
10205
|
fileName: appFileName,
|
|
9698
10206
|
});
|
|
9699
|
-
const compareExcludesName =
|
|
9700
|
-
|
|
10207
|
+
const compareExcludesName = localEnvironment ||
|
|
10208
|
+
!!isCrossAppPush ||
|
|
10209
|
+
recordedExcludedName !== undefined;
|
|
10210
|
+
const appNameEdited = !localEnvironment &&
|
|
10211
|
+
!isCrossAppPush &&
|
|
9701
10212
|
recordedExcludedName !== undefined &&
|
|
9702
10213
|
!payloadExcludesName;
|
|
9703
10214
|
// The whole-truth body. Errors (an unrecognized or retired key, a
|
|
@@ -9705,6 +10216,7 @@ What a push guarantees (two stages):
|
|
|
9705
10216
|
// the preflight pass above, so only warnings remain here.
|
|
9706
10217
|
const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
|
|
9707
10218
|
excludeName: payloadExcludesName,
|
|
10219
|
+
nameOptional: localEnvironment,
|
|
9708
10220
|
});
|
|
9709
10221
|
for (const w of appWarnings) {
|
|
9710
10222
|
warn(` ${w}`);
|
|
@@ -9727,8 +10239,12 @@ What a push guarantees (two stages):
|
|
|
9727
10239
|
appEntry?.modifiedAt ||
|
|
9728
10240
|
new Date().toISOString(),
|
|
9729
10241
|
contentHash: appFileHash,
|
|
10242
|
+
// Every stamp on a machine-local environment is hashed without
|
|
10243
|
+
// the name, or the next comparison — which never sees it —
|
|
10244
|
+
// could not match the baseline (#4117).
|
|
9730
10245
|
semanticHash: hashAppSettingsForDiff(baselineSettings, {
|
|
9731
|
-
excludeName:
|
|
10246
|
+
excludeName: localEnvironment ||
|
|
10247
|
+
options.recordExclusion !== undefined ||
|
|
9732
10248
|
(options.keepExclusion && compareExcludesName),
|
|
9733
10249
|
}),
|
|
9734
10250
|
...(options.recordExclusion !== undefined
|
|
@@ -10936,16 +11452,25 @@ What a push guarantees (two stages):
|
|
|
10936
11452
|
const liveBuckets = new Map();
|
|
10937
11453
|
let bucketsLiveOk = true;
|
|
10938
11454
|
if (files.length > 0) {
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
11455
|
+
// #4116 — a bound bucket's rule set is compared by name, so the
|
|
11456
|
+
// comparison needs the rule-set listing; without it the leg
|
|
11457
|
+
// degrades as the type configs do.
|
|
11458
|
+
bucketsLiveOk = (await loadLiveRuleSets()).ok;
|
|
11459
|
+
if (!bucketsLiveOk) {
|
|
11460
|
+
warnLiveUnavailable("blob-buckets", "rule sets unavailable");
|
|
10945
11461
|
}
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
11462
|
+
else {
|
|
11463
|
+
try {
|
|
11464
|
+
const listed = await client.listBlobBuckets(resolvedAppId);
|
|
11465
|
+
for (const b of (listed?.items ?? [])) {
|
|
11466
|
+
if (b?.bucketKey)
|
|
11467
|
+
liveBuckets.set(b.bucketKey, b);
|
|
11468
|
+
}
|
|
11469
|
+
}
|
|
11470
|
+
catch (err) {
|
|
11471
|
+
bucketsLiveOk = false;
|
|
11472
|
+
warnLiveUnavailable("blob-buckets", err);
|
|
11473
|
+
}
|
|
10949
11474
|
}
|
|
10950
11475
|
}
|
|
10951
11476
|
const refetchLiveBucket = async (bucketId) => {
|
|
@@ -10957,7 +11482,13 @@ What a push guarantees (two stages):
|
|
|
10957
11482
|
const tomlData = parseTomlFile(filePath);
|
|
10958
11483
|
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
10959
11484
|
withDerivedIdentity(tomlData, "bucket", { key });
|
|
10960
|
-
|
|
11485
|
+
// #4116 — `ruleSetName` resolves to THIS app's rule set before any
|
|
11486
|
+
// request; a name that resolves nowhere fails here, so a bucket is
|
|
11487
|
+
// never created or updated without its rule set.
|
|
11488
|
+
const bucket = parseBlobBucketToml(tomlData);
|
|
11489
|
+
resolveRuleSetReference(bucket, ruleSetNameToId, `blob bucket "${key}"`, {
|
|
11490
|
+
hint: ruleSetSelectionHint,
|
|
11491
|
+
});
|
|
10961
11492
|
const existingEntry = syncState?.entities?.blobBuckets?.[key];
|
|
10962
11493
|
const liveBucket = liveBuckets.get(key);
|
|
10963
11494
|
let gate;
|
|
@@ -11428,7 +11959,7 @@ What a push guarantees (two stages):
|
|
|
11428
11959
|
const serverConfig = liveServerConfig(tomlConfig.name);
|
|
11429
11960
|
if (!serverConfig || serverConfig.strictOutput !== true)
|
|
11430
11961
|
continue;
|
|
11431
|
-
const chat =
|
|
11962
|
+
const chat = tomlConfig.chat ?? {};
|
|
11432
11963
|
const retired = String(tomlConfig?.status ?? "").trim().toLowerCase() ===
|
|
11433
11964
|
"archived";
|
|
11434
11965
|
// An entry that stays strict AND live is refused in preflight
|
|
@@ -15662,7 +16193,7 @@ What a push guarantees (two stages):
|
|
|
15662
16193
|
// Update sync state for non-conflicting changes
|
|
15663
16194
|
if (syncState) {
|
|
15664
16195
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
15665
|
-
saveSyncState(
|
|
16196
|
+
saveSyncState(syncState);
|
|
15666
16197
|
}
|
|
15667
16198
|
const successCount = countAppliedChanges({
|
|
15668
16199
|
changes,
|
|
@@ -15693,7 +16224,7 @@ What a push guarantees (two stages):
|
|
|
15693
16224
|
// Update sync state after push
|
|
15694
16225
|
if (syncState) {
|
|
15695
16226
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
15696
|
-
saveSyncState(
|
|
16227
|
+
saveSyncState(syncState);
|
|
15697
16228
|
}
|
|
15698
16229
|
reportDrift();
|
|
15699
16230
|
success(formatPushSummary({
|
|
@@ -15718,7 +16249,7 @@ What a push guarantees (two stages):
|
|
|
15718
16249
|
if (syncState && recordedProgress && !options.dryRun) {
|
|
15719
16250
|
try {
|
|
15720
16251
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
15721
|
-
saveSyncState(
|
|
16252
|
+
saveSyncState(syncState);
|
|
15722
16253
|
}
|
|
15723
16254
|
catch {
|
|
15724
16255
|
// Don't mask the original error
|
|
@@ -15751,7 +16282,7 @@ What a push guarantees (two stages):
|
|
|
15751
16282
|
error(`Config directory not found: ${configDir}`);
|
|
15752
16283
|
process.exit(1);
|
|
15753
16284
|
}
|
|
15754
|
-
const syncState = loadSyncState(
|
|
16285
|
+
const syncState = loadSyncState();
|
|
15755
16286
|
// `--only` scopes the comparison the same way it scopes push and pull.
|
|
15756
16287
|
const diffSelection = readSelection(options);
|
|
15757
16288
|
// `--only app` on its own IS the retired `settings diff`: it reports the
|
|
@@ -15837,7 +16368,6 @@ What a push guarantees (two stages):
|
|
|
15837
16368
|
const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
|
|
15838
16369
|
const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
|
|
15839
16370
|
const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
|
|
15840
|
-
const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
|
|
15841
16371
|
const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
|
|
15842
16372
|
// #1660 — exclude platform-owned workflows (reserved `__internal.*`)
|
|
15843
16373
|
// from the remote set so `diff` never reports them (they are not app
|
|
@@ -15874,7 +16404,6 @@ What a push guarantees (two stages):
|
|
|
15874
16404
|
const localIntegrations = new Set();
|
|
15875
16405
|
const localWebhooks = new Set();
|
|
15876
16406
|
const localCronTriggers = new Set();
|
|
15877
|
-
const localBlobBuckets = new Set();
|
|
15878
16407
|
const localPrompts = new Set();
|
|
15879
16408
|
const localWorkflows = new Set();
|
|
15880
16409
|
const localEmailTemplates = new Set();
|
|
@@ -15950,7 +16479,6 @@ What a push guarantees (two stages):
|
|
|
15950
16479
|
for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
|
|
15951
16480
|
const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
|
|
15952
16481
|
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
15953
|
-
localBlobBuckets.add(key);
|
|
15954
16482
|
// #2880 — as above.
|
|
15955
16483
|
if (!tomlData.bucket)
|
|
15956
16484
|
tomlData.bucket = {};
|
|
@@ -16303,23 +16831,6 @@ What a push guarantees (two stages):
|
|
|
16303
16831
|
differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
|
|
16304
16832
|
}
|
|
16305
16833
|
}
|
|
16306
|
-
// Blob Buckets — #2880: the same, and the list row is the record here
|
|
16307
|
-
// too.
|
|
16308
|
-
const blobBucketSpec = configDiffSpec("blob-bucket");
|
|
16309
|
-
const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
|
|
16310
|
-
for (const key of localBlobBuckets) {
|
|
16311
|
-
if (!remoteBlobBuckets.has(key)) {
|
|
16312
|
-
differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
|
|
16313
|
-
continue;
|
|
16314
|
-
}
|
|
16315
|
-
const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
|
|
16316
|
-
differences.push({ type: "blob-bucket", key, status, hint });
|
|
16317
|
-
}
|
|
16318
|
-
for (const key of remoteBlobBuckets) {
|
|
16319
|
-
if (!localBlobBuckets.has(key)) {
|
|
16320
|
-
differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
|
|
16321
|
-
}
|
|
16322
|
-
}
|
|
16323
16834
|
// Prompts — #2748: content-aware comparison through the shared registry
|
|
16324
16835
|
// spec, for the reason webhooks got one in #2757. Existence-only
|
|
16325
16836
|
// reporting meant a prompt's `accessRule` (and every other `[prompt]`
|
|
@@ -16745,20 +17256,12 @@ What a push guarantees (two stages):
|
|
|
16745
17256
|
differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
|
|
16746
17257
|
continue;
|
|
16747
17258
|
}
|
|
16748
|
-
|
|
16749
|
-
|
|
16750
|
-
|
|
16751
|
-
|
|
16752
|
-
|
|
16753
|
-
|
|
16754
|
-
hint = "content not compared (rule sets unavailable)";
|
|
16755
|
-
}
|
|
16756
|
-
else {
|
|
16757
|
-
// The shared verdict names the differing field (#3267) and
|
|
16758
|
-
// degrades a malformed local file to "not compared" rather than
|
|
16759
|
-
// aborting the whole diff.
|
|
16760
|
-
({ status, hint } = compare(parsed, remoteEntities.get(key)));
|
|
16761
|
-
}
|
|
17259
|
+
// Rule-set list failed, so a `ruleSetName` reference can't be
|
|
17260
|
+
// resolved on either side — decline the content comparison rather
|
|
17261
|
+
// than risk a false `Modified`. Otherwise the shared verdict names
|
|
17262
|
+
// the differing field (#3267) and degrades a malformed local file
|
|
17263
|
+
// to "not compared" rather than aborting the whole diff.
|
|
17264
|
+
const { status, hint } = configDiffRowVerdict(parsed, remoteEntities.get(key), compare, contentComparable);
|
|
16762
17265
|
differences.push({ type: typeLabel, key, status, hint });
|
|
16763
17266
|
}
|
|
16764
17267
|
for (const key of remoteEntities.keys()) {
|
|
@@ -16782,6 +17285,9 @@ What a push guarantees (two stages):
|
|
|
16782
17285
|
compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
|
|
16783
17286
|
compareByDefinition("collection-type-config", "collectionTypeConfigs", localCollectionTypeConfigsParsed, remoteCollectionTypeConfigs, ruleSetsOk);
|
|
16784
17287
|
compareByDefinition("metadata-category-config", "metadataCategoryConfigs", localMetadataCategoryParsed, remoteMetadataCategoryConfigs, true);
|
|
17288
|
+
// Blob buckets — #2880: the list row is the record. #4116: a bucket
|
|
17289
|
+
// names its rule set, so its rows take the type configs' gate.
|
|
17290
|
+
compareByDefinition("blob-bucket", "blobBuckets", localBlobBucketsParsed, new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b])), blobBucketsOutcomeDiff.ok && ruleSetsOk);
|
|
16785
17291
|
// database-type-configs: the list carries only the type header, so operations
|
|
16786
17292
|
// and subscriptions are fetched per type (mirroring pull) before the
|
|
16787
17293
|
// content comparison. A per-type fetch failure degrades that one row to
|
|
@@ -16912,12 +17418,16 @@ What a push guarantees (two stages):
|
|
|
16912
17418
|
appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
|
|
16913
17419
|
}
|
|
16914
17420
|
if (appTomlData) {
|
|
16915
|
-
const
|
|
17421
|
+
const diffsLocalEnvironment = requireCurrentEnvironment().local;
|
|
17422
|
+
const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData, diffsLocalEnvironment);
|
|
16916
17423
|
// The SAME validator push's preflight runs (criterion 1): a
|
|
16917
17424
|
// declared-type mismatch, an unrecognized or retired key, and a
|
|
16918
17425
|
// missing required key are reported here with push's own message
|
|
16919
17426
|
// rather than compared past and called Synced.
|
|
16920
|
-
const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, {
|
|
17427
|
+
const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, {
|
|
17428
|
+
excludeName,
|
|
17429
|
+
nameOptional: diffsLocalEnvironment,
|
|
17430
|
+
});
|
|
16921
17431
|
if (appErrors.length > 0) {
|
|
16922
17432
|
appRow(VALIDATION_ERROR, appErrors.join("; "));
|
|
16923
17433
|
}
|
|
@@ -17322,11 +17832,11 @@ What a push guarantees (two stages):
|
|
|
17322
17832
|
writeFileSync(filePath, rewritten);
|
|
17323
17833
|
info(`Migrated database-type-configs/${file}`);
|
|
17324
17834
|
// Update content hash in sync state if we have an entry for it.
|
|
17325
|
-
const syncState = loadSyncState(
|
|
17835
|
+
const syncState = loadSyncState();
|
|
17326
17836
|
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
17327
17837
|
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
17328
17838
|
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
17329
|
-
saveSyncState(
|
|
17839
|
+
saveSyncState(syncState);
|
|
17330
17840
|
}
|
|
17331
17841
|
}
|
|
17332
17842
|
migratedCount++;
|
|
@@ -17354,11 +17864,11 @@ What a push guarantees (two stages):
|
|
|
17354
17864
|
writeFileSync(filePath, rewritten);
|
|
17355
17865
|
info(`Migrated workflows/${file}`);
|
|
17356
17866
|
// Update the stored content hash so a subsequent push sees a no-op.
|
|
17357
|
-
const syncState = loadSyncState(
|
|
17867
|
+
const syncState = loadSyncState();
|
|
17358
17868
|
if (syncState?.entities?.workflows?.[workflowKey]) {
|
|
17359
17869
|
syncState.entities.workflows[workflowKey].contentHash =
|
|
17360
17870
|
computeWorkflowContentHash(configDir, workflowKey, parseTomlFile(filePath));
|
|
17361
|
-
saveSyncState(
|
|
17871
|
+
saveSyncState(syncState);
|
|
17362
17872
|
}
|
|
17363
17873
|
}
|
|
17364
17874
|
migratedCount++;
|
|
@@ -17496,14 +18006,26 @@ What a push guarantees (two stages):
|
|
|
17496
18006
|
return;
|
|
17497
18007
|
}
|
|
17498
18008
|
}
|
|
18009
|
+
// A machine-local environment's baseline lives outside the shared tree
|
|
18010
|
+
// (#4117): the snapshot's baseline goes back there, and the tree's own
|
|
18011
|
+
// baseline — the committed environment's — is left exactly as it is.
|
|
18012
|
+
const baselinePath = requireCurrentEnvironment().local
|
|
18013
|
+
? resolveSyncStateWritePath()
|
|
18014
|
+
: undefined;
|
|
17499
18015
|
try {
|
|
17500
|
-
restoreSnapshot(target.path, configDir);
|
|
18016
|
+
restoreSnapshot(target.path, configDir, { baselinePath });
|
|
17501
18017
|
}
|
|
17502
18018
|
catch (err) {
|
|
17503
18019
|
error(`Restore failed: ${err?.message ?? err}`);
|
|
17504
18020
|
process.exit(1);
|
|
17505
18021
|
}
|
|
17506
|
-
|
|
18022
|
+
if (baselinePath) {
|
|
18023
|
+
success(`Restored snapshot ${target.id} into ${configDir} and this environment's baseline into ${baselinePath}.`);
|
|
18024
|
+
info(`The committed baseline in ${configDir} belongs to the shared tree's environment and was left as it was.`);
|
|
18025
|
+
}
|
|
18026
|
+
else {
|
|
18027
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
|
|
18028
|
+
}
|
|
17507
18029
|
info("Run 'primitive config diff' to inspect the restored state versus the server.");
|
|
17508
18030
|
});
|
|
17509
18031
|
}
|