primitive-admin 1.1.0 → 1.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +14 -13
  2. package/assets/skill/skills/primitive-platform/SKILL.md +6 -0
  3. package/dist/src/commands/collections.js +20 -17
  4. package/dist/src/commands/collections.js.map +1 -1
  5. package/dist/src/commands/connections.js +1 -5
  6. package/dist/src/commands/connections.js.map +1 -1
  7. package/dist/src/commands/database-types.js +13 -313
  8. package/dist/src/commands/database-types.js.map +1 -1
  9. package/dist/src/commands/databases.js +7 -128
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/groups.js +16 -0
  12. package/dist/src/commands/groups.js.map +1 -1
  13. package/dist/src/commands/llm.js +9 -6
  14. package/dist/src/commands/llm.js.map +1 -1
  15. package/dist/src/commands/sessions.js +3 -8
  16. package/dist/src/commands/sessions.js.map +1 -1
  17. package/dist/src/commands/settings.d.ts +18 -0
  18. package/dist/src/commands/settings.js +54 -0
  19. package/dist/src/commands/settings.js.map +1 -0
  20. package/dist/src/commands/sync.d.ts +77 -7
  21. package/dist/src/commands/sync.js +368 -198
  22. package/dist/src/commands/sync.js.map +1 -1
  23. package/dist/src/commands/tokens.js +1 -6
  24. package/dist/src/commands/tokens.js.map +1 -1
  25. package/dist/src/commands/workflows.js +3 -3
  26. package/dist/src/commands/workflows.js.map +1 -1
  27. package/dist/src/lib/api-client.d.ts +3 -70
  28. package/dist/src/lib/api-client.js +20 -106
  29. package/dist/src/lib/api-client.js.map +1 -1
  30. package/dist/src/lib/app-match-guard.d.ts +44 -0
  31. package/dist/src/lib/app-match-guard.js +72 -0
  32. package/dist/src/lib/app-match-guard.js.map +1 -0
  33. package/dist/src/lib/collection-export.d.ts +37 -9
  34. package/dist/src/lib/collection-export.js +48 -12
  35. package/dist/src/lib/collection-export.js.map +1 -1
  36. package/dist/src/lib/config-object-descriptor.js +2 -3
  37. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  38. package/dist/src/lib/function-triggers.d.ts +66 -0
  39. package/dist/src/lib/function-triggers.js +285 -0
  40. package/dist/src/lib/function-triggers.js.map +1 -0
  41. package/dist/src/lib/generated-allowlist.js +0 -1
  42. package/dist/src/lib/generated-allowlist.js.map +1 -1
  43. package/dist/src/lib/generated-config-surfaces.d.ts +4 -2
  44. package/dist/src/lib/generated-config-surfaces.js +138 -68
  45. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  46. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  47. package/dist/src/lib/generated-sdk-types.js +1 -1
  48. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  49. package/dist/src/lib/generated-workflow-model-fields.d.ts +20 -0
  50. package/dist/src/lib/generated-workflow-model-fields.js +53 -0
  51. package/dist/src/lib/generated-workflow-model-fields.js.map +1 -0
  52. package/dist/src/lib/google-client-secret-status.d.ts +35 -0
  53. package/dist/src/lib/google-client-secret-status.js +56 -0
  54. package/dist/src/lib/google-client-secret-status.js.map +1 -0
  55. package/dist/src/lib/log-inspection.d.ts +1 -6
  56. package/dist/src/lib/log-inspection.js +5 -7
  57. package/dist/src/lib/log-inspection.js.map +1 -1
  58. package/dist/src/lib/migration-nag.d.ts +2 -2
  59. package/dist/src/lib/migration-nag.js +4 -3
  60. package/dist/src/lib/migration-nag.js.map +1 -1
  61. package/dist/src/lib/paginate.d.ts +8 -24
  62. package/dist/src/lib/paginate.js +8 -21
  63. package/dist/src/lib/paginate.js.map +1 -1
  64. package/dist/src/lib/sync-dir-selector.d.ts +1 -1
  65. package/dist/src/lib/sync-dir-selector.js +1 -1
  66. package/dist/src/lib/toml-database-config.js +2 -2
  67. package/dist/src/lib/toml-database-config.js.map +1 -1
  68. package/dist/src/lib/workflow-field-descriptor.d.ts +91 -0
  69. package/dist/src/lib/workflow-field-descriptor.js +153 -0
  70. package/dist/src/lib/workflow-field-descriptor.js.map +1 -0
  71. package/package.json +2 -2
@@ -1629,8 +1629,8 @@ export function serializeBlobBucket(bucket, logger) {
1629
1629
  *
1630
1630
  * EXACT extraction of the inline builder shared by the existing-update and
1631
1631
  * 409-adopt branches — do NOT "clean up" the truthiness checks on the access
1632
- * model. The server treats `preset` and `accessPolicy` as mutually exclusive,
1633
- * only clears `ruleSetId` when a preset/accessPolicy is also present, and
1632
+ * model. The server treats `preset` and `ruleSetId` as mutually exclusive,
1633
+ * only clears `ruleSetId` when a preset is also present, and
1634
1634
  * refuses to leave a bucket with no access model or a blank name — so those
1635
1635
  * fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
1636
1636
  * sent. Both call sites must go through this helper so their field sets never
@@ -1655,12 +1655,6 @@ export function buildBlobBucketUpdatePayload(bucket) {
1655
1655
  description: tomlOwned,
1656
1656
  },
1657
1657
  });
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
1658
  for (const key of Object.keys(updatePayload)) {
1665
1659
  if (updatePayload[key] === undefined)
1666
1660
  delete updatePayload[key];
@@ -1698,12 +1692,9 @@ const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "st
1698
1692
  * first-wins rule: making the file's meaning depend on entry order is the kind
1699
1693
  * of implicit behavior this issue exists to remove, and silently picking one
1700
1694
  * would let the repo claim something the server does not do.
1701
- *
1702
- * `isActive` is accepted as a legacy spelling — the create leg read it before
1703
- * pull ever wrote a marker, so a hand-authored file carrying it keeps working.
1704
1695
  */
1705
1696
  export function promptActiveConfigName(configs) {
1706
- const marked = (configs || []).filter((c) => c?.active === true || c?.isActive === true);
1697
+ const marked = (configs || []).filter((c) => c?.active === true);
1707
1698
  if (marked.length > 1) {
1708
1699
  throw new Error(`Only one [[configs]] entry may set active = true; found ${marked.length} ` +
1709
1700
  `(${marked.map((c) => c.name).join(", ")}).`);
@@ -1764,9 +1755,6 @@ export function buildBlobBucketCreatePayload(key, bucket) {
1764
1755
  },
1765
1756
  }),
1766
1757
  };
1767
- if (!payload.preset && bucket?.accessPolicy) {
1768
- payload.accessPolicy = bucket.accessPolicy;
1769
- }
1770
1758
  for (const field of Object.keys(payload)) {
1771
1759
  if (payload[field] === undefined)
1772
1760
  delete payload[field];
@@ -4012,7 +4000,7 @@ export function parseDatabaseTypeToml(tomlData) {
4012
4000
  },
4013
4001
  }));
4014
4002
  canonicalizeDatabaseTypeConfig(typeConfig);
4015
- // Both rule-set spellings; the name form resolves to an id later.
4003
+ // The `ruleSetName` reference; it resolves to an id later.
4016
4004
  applyRuleSetReference(typeSection, typeConfig);
4017
4005
  if (tomlData.triggers) {
4018
4006
  typeConfig.triggers = tomlData.triggers;
@@ -4081,13 +4069,16 @@ export function parseRuleSetToml(tomlData) {
4081
4069
  /**
4082
4070
  * Read a config's rule-set reference off its TOML table (#2644).
4083
4071
  *
4084
- * Both spellings are accepted — the portable `ruleSetName` and the legacy
4085
- * `ruleSetId` — and the name form is left as `_ruleSetName` for
4072
+ * One spelling, the portable `ruleSetName`, left as `_ruleSetName` for
4086
4073
  * `resolveRuleSetReference` to turn into an id once the app's rule sets are
4087
- * known. Neither present means no reference, which push sends as an explicit
4088
- * `null` so removing the line clears it (#1567).
4074
+ * known. Absent means no reference, which push sends as an explicit `null` so
4075
+ * removing the line clears it (#1567). The id spelling `ruleSetId` is retired
4076
+ * (#3997): the preflight refuses it through `retired-keys.ts`, and it is never
4077
+ * read here, so no id authored in TOML reaches the wire.
4089
4078
  */
4090
4079
  function applyRuleSetReference(section, entity) {
4080
+ // The payload builder put the `ruleSetName` value on the `ruleSetId` field;
4081
+ // the resolver sets the real id.
4091
4082
  delete entity.ruleSetId;
4092
4083
  if (section.ruleSetName && section.ruleSetName !== "") {
4093
4084
  // Trimmed to match the name the server stores for the rule set itself
@@ -4097,9 +4088,6 @@ function applyRuleSetReference(section, entity) {
4097
4088
  ? section.ruleSetName.trim()
4098
4089
  : section.ruleSetName;
4099
4090
  }
4100
- else if (section.ruleSetId && section.ruleSetId !== "") {
4101
- entity.ruleSetId = section.ruleSetId;
4102
- }
4103
4091
  }
4104
4092
  /** Drop keys the TOML did not carry, so `"x" in entity` stays meaningful. */
4105
4093
  function dropUndefined(entity) {
@@ -4162,22 +4150,22 @@ export function parseCollectionTypeConfigToml(tomlData) {
4162
4150
  // the SAME push parser before hashing.
4163
4151
  //
4164
4152
  // Parsing through the push parser — rather than hashing the raw parsed TOML —
4165
- // is what makes the comparison encoding-independent, and is why the four known
4153
+ // is what makes the comparison encoding-independent, and is why the known
4166
4154
  // false-diff traps do not fire: a legacy JSON-string operation and the native
4167
4155
  // `[operations.definition]` table the server emits normalize to one shape
4168
- // (`normalizeOperationFromToml`); a `ruleSetName` reference and its legacy
4169
- // `ruleSetId` resolve to the same id (`normalizeRuleSetRefForDiff`); an omitted
4170
- // `autoAddCreator` picks up the same default the server serializes
4171
- // (`parseGroupTypeConfigToml`); and a metadata-category's identity is read from
4172
- // the parsed `[metadataCategoryConfig]` content, not the filename. Hashing raw
4173
- // parsed TOML would show a false `Modified` for each of those equivalent forms.
4156
+ // (`normalizeOperationFromToml`); a `ruleSetName` reference resolves to the id
4157
+ // the server holds (`normalizeRuleSetRefForDiff`); an omitted `autoAddCreator`
4158
+ // picks up the same default the server serializes (`parseGroupTypeConfigToml`);
4159
+ // and a metadata-category's identity is read from the parsed
4160
+ // `[metadataCategoryConfig]` content, not the filename. Hashing raw parsed TOML
4161
+ // would show a false `Modified` for each of those equivalent forms.
4174
4162
  /**
4175
4163
  * Resolve a parsed entity's rule-set reference to a stable id for hashing.
4176
4164
  * `parseGroupTypeConfigToml` / `parseCollectionTypeConfigToml` /
4177
- * `parseDatabaseTypeToml` leave either `_ruleSetName` (key-based) or a legacy
4178
- * `ruleSetId` on the entity; collapsing both to `ruleSetId` here means the two
4179
- * encodings of the same reference hash equal. `throwOnMissing: false` so an
4180
- * unresolvable name degrades gracefully rather than aborting the diff.
4165
+ * `parseDatabaseTypeToml` leave the `ruleSetName` reference as `_ruleSetName`;
4166
+ * resolving it to `ruleSetId` here means the local file and the server entity
4167
+ * hash the same reference. `throwOnMissing: false` so an unresolvable name
4168
+ * degrades gracefully rather than aborting the diff.
4181
4169
  *
4182
4170
  * When the name does NOT resolve — a typo, or a rule set added locally in the
4183
4171
  * same tree but not yet on the server — `resolveRuleSetReference` deletes
@@ -4538,16 +4526,16 @@ function jsonFieldsForDiff(table, entity) {
4538
4526
  /**
4539
4527
  * One test-case reference as the comparison reads it (#2880 behavior 17).
4540
4528
  *
4541
- * A reference reaches the projection in one of three states, and all three
4542
- * describe the same thing on both sides once this is applied:
4529
+ * A reference reaches the projection as the value in its NAME slot, in one of
4530
+ * two states, and both describe the same thing on both sides once this is
4531
+ * applied:
4543
4532
  *
4544
- * - a portable NAME (`configName = "default"`), which a test file carries so
4545
- * it survives a move between apps;
4546
- * - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
4547
- * pinned by id would differ forever from the file pull writes for it;
4548
- * - a legacy ID that does NOT resolve, which stays the id, because pull writes
4549
- * an unresolvable id straight back and comparing it away would hide a pin
4550
- * the next push still sends.
4533
+ * - a portable NAME (`configName = "default"`) — what a test file carries,
4534
+ * and what the server's id renders as when the lookups resolve it;
4535
+ * - a live ID the lookups could NOT resolve, which the comparison's rendering
4536
+ * (`serializeTestCaseForComparison`) carries in the name slot and which
4537
+ * stays the id here, so a case pinned to a deleted or unlistable config is
4538
+ * a difference against an unpinned file rather than hidden (#3998).
4551
4539
  */
4552
4540
  function testCaseReference(value, idToName) {
4553
4541
  if (typeof value !== "string" || value === "")
@@ -4824,14 +4812,6 @@ const CONFIG_DIFF_SPECS = {
4824
4812
  table: BLOB_BUCKET_TABLE,
4825
4813
  parse: (doc) => {
4826
4814
  const bucket = { ...(doc?.bucket ?? {}) };
4827
- // #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
4828
- // the wire and still authored in unmigrated files. Both spellings mean
4829
- // the same access model, so they project the same way; without this an
4830
- // unmigrated file reports a difference no push can clear (push sends the
4831
- // legacy key and the server answers with `preset`).
4832
- if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
4833
- bucket.preset = bucket.accessPolicy;
4834
- }
4835
4815
  return {
4836
4816
  bucketKey: doc?.bucket?.key ?? "",
4837
4817
  ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
@@ -4865,7 +4845,7 @@ const CONFIG_DIFF_SPECS = {
4865
4845
  const keys = [];
4866
4846
  if (!bucket.name)
4867
4847
  keys.push("name");
4868
- if (!bucket.preset && !bucket.accessPolicy)
4848
+ if (!bucket.preset)
4869
4849
  keys.push("preset");
4870
4850
  if (!bucket.ruleSetId)
4871
4851
  keys.push("ruleSetId");
@@ -4877,17 +4857,15 @@ const CONFIG_DIFF_SPECS = {
4877
4857
  // edited expectation read as Synced while push applied it from the file's
4878
4858
  // byte hash.
4879
4859
  //
4880
- // The type's own difficulty is that a reference has two legal spellings — the
4881
- // portable `configName` a test file carries so it survives a move between
4882
- // apps, and the legacy `configId` pull still writes back when the name cannot
4883
- // be resolved. They name the same thing, so they project the same way; the
4884
- // `extra` context is what makes that possible (the id→name lookups are not in
4885
- // the document).
4860
+ // The type's own difficulty is that a reference is a NAME in the file and an
4861
+ // id on the server. The comparison renders the server's id through the
4862
+ // id→name lookups (which are not in the document — the `extra` context
4863
+ // carries them), so both sides project the thing referred to.
4886
4864
  "test-case": {
4887
4865
  label: "test-case",
4888
4866
  table: TEST_CASE_TABLE,
4889
4867
  parse: (doc) => parseTestCaseToml(doc),
4890
- serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
4868
+ serialize: (record, _maps, extra) => serializeTestCaseForComparison(record, extra?.lookupMaps),
4891
4869
  // The server trims a test-case name on write (`src/admin-api.ts`), so a
4892
4870
  // padded one in the file is not a difference — it is what the server will
4893
4871
  // store (#2880 DSO-003).
@@ -4897,12 +4875,11 @@ const CONFIG_DIFF_SPECS = {
4897
4875
  },
4898
4876
  extras: (entity, _doc, extra) => ({
4899
4877
  // These three OVERRIDE the definition's fields of the same name: the
4900
- // value compared is the thing referred to, not the spelling that referred
4901
- // to it. Precedence matches the push path exactly — a file carrying both
4902
- // an id and a name is pushed on the id, so it is compared on the id.
4903
- configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
4904
- evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
4905
- evaluatorConfigId: testCaseReference(entity.evaluatorConfigId ?? entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
4878
+ // value compared is the thing referred to, read from the name the file
4879
+ // (or the comparison's rendering of the server record) carries.
4880
+ configId: testCaseReference(entity._configName, extra?.lookupMaps?.configIdToName),
4881
+ evaluatorPromptId: testCaseReference(entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
4882
+ evaluatorConfigId: testCaseReference(entity._evaluatorConfigName, extra?.lookupMaps?.configIdToName),
4906
4883
  }),
4907
4884
  describe: (entity) => `test case "${entity?.name}"`,
4908
4885
  },
@@ -5066,7 +5043,7 @@ export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
5066
5043
  * Hash a SERVER entity the same way, by serializing it into the file `sync
5067
5044
  * pull` would write and hashing that — so the two sides are normalized
5068
5045
  * identically by construction, and a legacy encoding on disk (a JSON-string
5069
- * operation, an id-based rule-set reference) is not a false `Modified`.
5046
+ * operation) is not a false `Modified`.
5070
5047
  *
5071
5048
  * `extra` carries the sibling rows a type's file also holds: a database type's
5072
5049
  * operations and subscriptions, which the list response does not include.
@@ -5721,49 +5698,81 @@ export function getTestsDir(configDir, blockType, blockKey) {
5721
5698
  function blockSelectionLabel(blockType) {
5722
5699
  return blockType === "script" ? "transform" : blockType;
5723
5700
  }
5724
- export function serializeTestCase(testCase, lookupMaps, options = {}) {
5725
- // #2644 — name any server key this CLI version does not know rather than
5726
- // dropping it silently on the way into the file. Test cases are a registered
5727
- // surface; their sidecar shape is the only thing that sets them apart.
5728
- warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
5729
- // Resolve ID references to key-based references
5730
- const configName = testCase.configId && lookupMaps?.configIdToName
5731
- ? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
5732
- const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
5733
- ? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
5734
- const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
5735
- ? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
5701
+ /**
5702
+ * The `[test]` table for one server record, and the references it could not
5703
+ * name (#3998).
5704
+ *
5705
+ * A sidecar authors its references by NAME only — the id spellings are retired
5706
+ * — so a reference whose id has no lookup entry has no honest spelling in a
5707
+ * file. `unresolvedSpelling` says what goes in the name slot for one: `"empty"`
5708
+ * for the file pull writes (and the caller decides whether to write it at all),
5709
+ * `"raw-id"` for the comparison's rendering, which is parsed and never written.
5710
+ */
5711
+ function testCaseSidecarTable(testCase, lookupMaps, unresolvedSpelling) {
5712
+ const unresolved = [];
5713
+ const reference = (field, tomlKey, idToName) => {
5714
+ const id = testCase?.[field];
5715
+ if (typeof id !== "string" || id === "")
5716
+ return "";
5717
+ const name = idToName?.get(id);
5718
+ if (name)
5719
+ return name;
5720
+ unresolved.push({ tomlKey, field, id });
5721
+ return unresolvedSpelling === "raw-id" ? id : "";
5722
+ };
5723
+ const configName = reference("configId", "configName", lookupMaps?.configIdToName);
5724
+ const evaluatorPromptKey = reference("evaluatorPromptId", "evaluatorPromptKey", lookupMaps?.promptIdToKey);
5725
+ // The judge's config belongs to the evaluator prompt, not to this sidecar's
5726
+ // block; config ids are app-unique, so the app-wide map names it.
5727
+ const evaluatorConfigName = reference("evaluatorConfigId", "evaluatorConfigName", lookupMaps?.configIdToName);
5736
5728
  // #2644 criterion 1 — the `[test]` key set comes from the vendored
5737
5729
  // definition, so a field added there is emitted by pull with no edit here.
5738
5730
  // The three reference fields carry the resolved NAME (a test file is portable
5739
5731
  // across apps); the rest keep the empty-string / "{}" / "[]" spellings a test
5740
5732
  // file has always used for "not set".
5741
- const data = {
5742
- test: projectRecordToToml(TEST_CASE_TABLE, testCase, {
5743
- name: (record) => record.name || "",
5744
- description: (record) => record.description || "",
5745
- inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
5746
- configId: () => configName,
5747
- evaluatorPromptId: () => evaluatorPromptKey,
5748
- evaluatorConfigId: () => evaluatorConfigName,
5749
- expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5750
- expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5751
- expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5752
- }),
5753
- };
5754
- // #2769 — a reference the id→name lookup could not resolve (an unknown id, or
5755
- // a config listing that failed on this pull) is written back in the legacy id
5756
- // spelling the parser still accepts, NOT as an empty name. Rewriting a pinned
5757
- // test as unpinned would push the pin away on the next `config push`.
5758
- if (testCase.configId && !configName)
5759
- data.test.configId = testCase.configId;
5760
- if (testCase.evaluatorPromptId && !evaluatorPromptKey) {
5761
- data.test.evaluatorPromptId = testCase.evaluatorPromptId;
5762
- }
5763
- if (testCase.evaluatorConfigId && !evaluatorConfigName) {
5764
- data.test.evaluatorConfigId = testCase.evaluatorConfigId;
5765
- }
5766
- return stringifyConfigToml(data);
5733
+ const test = projectRecordToToml(TEST_CASE_TABLE, testCase, {
5734
+ name: (record) => record.name || "",
5735
+ description: (record) => record.description || "",
5736
+ inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
5737
+ configId: () => configName,
5738
+ evaluatorPromptId: () => evaluatorPromptKey,
5739
+ evaluatorConfigId: () => evaluatorConfigName,
5740
+ expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5741
+ expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5742
+ expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5743
+ });
5744
+ return { test, unresolved };
5745
+ }
5746
+ /**
5747
+ * The sidecar `config pull` writes for one server record. It never emits an id
5748
+ * key (#3998): a reference the lookups cannot name is written as an empty name
5749
+ * and reported through `onUnresolved`, and `pullTestCasesForBlock` declines to
5750
+ * write such a case rather than unpin it on disk.
5751
+ */
5752
+ export function serializeTestCase(testCase, lookupMaps, options = {}) {
5753
+ // #2644 — name any server key this CLI version does not know rather than
5754
+ // dropping it silently on the way into the file. Test cases are a registered
5755
+ // surface; their sidecar shape is the only thing that sets them apart.
5756
+ warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
5757
+ const { test, unresolved } = testCaseSidecarTable(testCase, lookupMaps, "empty");
5758
+ if (unresolved.length > 0)
5759
+ options.onUnresolved?.(unresolved);
5760
+ return stringifyConfigToml({ test });
5761
+ }
5762
+ /**
5763
+ * The rendering of a server record that the comparison parses (#3998) — never
5764
+ * written to disk.
5765
+ *
5766
+ * It differs from the file pull writes in one place: a reference the lookups
5767
+ * cannot name carries its raw id in the NAME slot. A live case pinned to a
5768
+ * deleted or unlistable configuration must stay a difference against an
5769
+ * unpinned file; projecting it as "no reference" would read the pair as Synced,
5770
+ * and the next content edit would push `configId: null` and clear the pin in
5771
+ * silence.
5772
+ */
5773
+ export function serializeTestCaseForComparison(testCase, lookupMaps) {
5774
+ const { test } = testCaseSidecarTable(testCase, lookupMaps, "raw-id");
5775
+ return stringifyConfigToml({ test });
5767
5776
  }
5768
5777
  /**
5769
5778
  * The TOML spelling of a JSON-valued test-case field (#2769).
@@ -5813,20 +5822,22 @@ export function parseTestCaseToml(tomlData) {
5813
5822
  description: (value) => (value ? value : undefined),
5814
5823
  // Always sent — the server requires it, and `{}` is the empty spelling.
5815
5824
  inputVariables: (value) => parseJsonText(value, ["{}"]) ?? {},
5816
- // The legacy ID-based spellings stay accepted (declared in the
5817
- // definition's `tomlOnlyKeys`); the name-based ones become the `_`
5818
- // markers below, which the push path resolves against the live app.
5819
- configId: (_value, _mode, toml) => toml.configId || undefined,
5820
- evaluatorPromptId: (_value, _mode, toml) => toml.evaluatorPromptId || undefined,
5821
- evaluatorConfigId: (_value, _mode, toml) => toml.evaluatorConfigId || undefined,
5825
+ // A reference is authored by name only: the names become the `_`
5826
+ // markers below, which the push path resolves against the live app. The
5827
+ // id spellings are retired (#3998) and refused by the preflight, so
5828
+ // nothing is read into the wire field here — and these producers must
5829
+ // stay, because without one `buildPayloadFromToml` would forward the
5830
+ // NAME (`configName`) under the id field.
5831
+ configId: () => undefined,
5832
+ evaluatorPromptId: () => undefined,
5833
+ evaluatorConfigId: () => undefined,
5822
5834
  expectedOutputPattern: (value) => (value ? value : undefined),
5823
5835
  expectedOutputContains: (value) => parseJsonText(value, ["[]"]),
5824
5836
  expectedJsonSubset: (value) => parseJsonText(value, ["{}"]),
5825
5837
  },
5826
5838
  });
5827
5839
  // An omitted field must be absent from the body, not present-and-undefined:
5828
- // the push path tests `!testPayload.configId` and the server's unknown-key
5829
- // check reads the body's keys.
5840
+ // the server's unknown-key check reads the body's keys.
5830
5841
  for (const key of Object.keys(payload)) {
5831
5842
  if (payload[key] === undefined)
5832
5843
  delete payload[key];
@@ -6179,6 +6190,35 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6179
6190
  items,
6180
6191
  };
6181
6192
  }
6193
+ /**
6194
+ * The id→name lookups `config pull` writes test-case references through
6195
+ * (#3998).
6196
+ *
6197
+ * The prompt half is built from EVERY prompt's detail — the pull fetches them
6198
+ * all before `--only` filters the list — so a judge prompt outside the
6199
+ * selection still names. Configurations come from the pulled blocks (a case
6200
+ * pins one of its own block's) and from every prompt (a judge's config is the
6201
+ * judge prompt's). An entry without both an id and a name is skipped.
6202
+ */
6203
+ export function testCaseLookupMapsForPull(input) {
6204
+ const configIdToName = new Map();
6205
+ const promptIdToKey = new Map();
6206
+ const addConfig = (config) => {
6207
+ if (config?.configId && config?.configName) {
6208
+ configIdToName.set(config.configId, config.configName);
6209
+ }
6210
+ };
6211
+ for (const prompt of input.prompts) {
6212
+ if (prompt?.promptId && prompt?.promptKey) {
6213
+ promptIdToKey.set(prompt.promptId, prompt.promptKey);
6214
+ }
6215
+ for (const config of prompt?.configs ?? [])
6216
+ addConfig(config);
6217
+ }
6218
+ for (const config of input.configs)
6219
+ addConfig(config);
6220
+ return { configIdToName, promptIdToKey };
6221
+ }
6182
6222
  /**
6183
6223
  * Every test case a block has, draining the cursor (#2769).
6184
6224
  *
@@ -6231,6 +6271,27 @@ export function carryForwardTestCaseEntities(params) {
6231
6271
  }
6232
6272
  return carried;
6233
6273
  }
6274
+ /**
6275
+ * Whether two sidecar names in `testsDir` are one file on disk (#3998).
6276
+ *
6277
+ * Names that differ only by case are one file on the case-insensitive
6278
+ * filesystems most of these trees live on and two on the rest (#2896), so the
6279
+ * answer comes from the filesystem itself: the same device and inode.
6280
+ */
6281
+ function sameTestCaseFile(testsDir, a, b) {
6282
+ if (a === b)
6283
+ return true;
6284
+ if (normalizeTestCaseKey(a) !== normalizeTestCaseKey(b))
6285
+ return false;
6286
+ try {
6287
+ const left = statSync(join(testsDir, `${a}.toml`));
6288
+ const right = statSync(join(testsDir, `${b}.toml`));
6289
+ return left.dev === right.dev && left.ino === right.ino;
6290
+ }
6291
+ catch {
6292
+ return false;
6293
+ }
6294
+ }
6234
6295
  /**
6235
6296
  * Write one block's test cases into its `<key>.tests/` sidecar and record them
6236
6297
  * in sync state. Exported so the unit tests can drive it against a stubbed
@@ -6246,10 +6307,9 @@ export async function pullTestCasesForBlock(params) {
6246
6307
  const testsDir = getTestsDir(configDir, blockType, blockKey);
6247
6308
  // A block with no cases and no sidecar is not a block whose sidecar is empty:
6248
6309
  // creating the directory here would commit an empty folder Git cannot carry.
6249
- if (testCases.length === 0 && !existsSync(testsDir))
6250
- return { ok: true, count: 0 };
6251
- if (testCases.length > 0)
6252
- ensureDir(testsDir);
6310
+ if (testCases.length === 0 && !existsSync(testsDir)) {
6311
+ return { ok: true, count: 0, unwritten: [] };
6312
+ }
6253
6313
  const pulledSlugs = new Set();
6254
6314
  // #2896 — a keyed case is written to the file its key names, so pull stops
6255
6315
  // renaming what push authored: the repro's `full-refresh-takes-all-history`
@@ -6259,15 +6319,35 @@ export async function pullTestCasesForBlock(params) {
6259
6319
  // The key is server data becoming a path: the containment check an
6260
6320
  // attachment's filename gets, so nothing lands outside the sidecar dir.
6261
6321
  (name) => attachmentWritePath(testsDir, `${name}.toml`) !== null);
6322
+ // #3998 — render every case before anything is written. A reference the
6323
+ // lookups cannot name has no honest spelling in a sidecar (the id keys are
6324
+ // retired, and an empty name would unpin the case on the next push), so such
6325
+ // a case is NOT written: its file, fixtures and record stay as they were.
6326
+ const writable = [];
6327
+ const skipped = [];
6262
6328
  for (const tc of testCases) {
6263
6329
  const slug = fileNames.get(tc);
6330
+ let references = [];
6331
+ const toml = serializeTestCase(tc, lookupMaps, {
6332
+ file: join(testsDir, `${slug}.toml`),
6333
+ logger: (message) => log(message),
6334
+ onUnresolved: (refs) => {
6335
+ references = refs;
6336
+ },
6337
+ });
6338
+ if (references.length > 0)
6339
+ skipped.push({ tc, slug, references });
6340
+ else
6341
+ writable.push({ tc, slug, toml });
6342
+ }
6343
+ if (writable.length > 0)
6344
+ ensureDir(testsDir);
6345
+ const writtenSlugs = new Map(writable.map((w) => [w.slug, w.tc]));
6346
+ for (const { tc, slug, toml } of writable) {
6264
6347
  pulledSlugs.add(slug);
6265
6348
  // Write test case TOML
6266
6349
  const tomlPath = join(testsDir, `${slug}.toml`);
6267
- writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps, {
6268
- file: tomlPath,
6269
- logger: (message) => log(message),
6270
- }));
6350
+ writeFileSync(tomlPath, toml);
6271
6351
  // Attachments: the sidecar directory is the authored set, so pull makes it
6272
6352
  // match the server — downloading what is there, recording each file's
6273
6353
  // content hash (so push can see a byte change under an unchanged name), and
@@ -6359,12 +6439,79 @@ export async function pullTestCasesForBlock(params) {
6359
6439
  }),
6360
6440
  };
6361
6441
  }
6442
+ // #3998 — a skipped case is found by its ID in the prior state, not by the
6443
+ // file name it would get now: the server can rename a case's key between
6444
+ // pulls, and the old file is the one holding the pin. Every such file is
6445
+ // kept out of the cleanup below and every such record carried forward under
6446
+ // its existing key — unless a case written above now owns that file, whose
6447
+ // write wins it (the key-to-file rule of #2896). "That file" is decided by
6448
+ // the filesystem: `Alpha` and `alpha` are one file where names fold case.
6449
+ const unwritten = [];
6450
+ const writtenOwner = (candidate) => writtenSlugs.get(candidate) ??
6451
+ writable.find((w) => sameTestCaseFile(testsDir, w.slug, candidate))?.tc;
6452
+ for (const { tc, slug, references } of skipped) {
6453
+ const priorEntries = Object.entries(params.priorTestCaseEntities ?? {}).filter(([, entry]) => entry?.blockType === blockType &&
6454
+ entry?.blockKey === blockKey &&
6455
+ entry?.id === tc.testCaseId);
6456
+ const slugs = new Set([slug]);
6457
+ for (const [stateKey, entry] of priorEntries) {
6458
+ slugs.add(entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1));
6459
+ }
6460
+ const preservedSlugs = [];
6461
+ const superseded = new Set();
6462
+ for (const candidate of slugs) {
6463
+ const owner = writtenOwner(candidate);
6464
+ if (owner) {
6465
+ superseded.add(candidate);
6466
+ warn(` ${blockType}: ${blockKey}/${candidate}.toml now holds test case ` +
6467
+ `"${owner?.name ?? ""}" — the prior sidecar of unwritten test case ` +
6468
+ `"${tc?.name ?? ""}" at that path was superseded`);
6469
+ continue;
6470
+ }
6471
+ pulledSlugs.add(candidate);
6472
+ if (existsSync(join(testsDir, `${candidate}.toml`))) {
6473
+ preservedSlugs.push(candidate);
6474
+ }
6475
+ }
6476
+ for (const [stateKey, entry] of priorEntries) {
6477
+ const entrySlug = entry.slug ?? stateKey.slice(stateKey.lastIndexOf("/") + 1);
6478
+ if (superseded.has(entrySlug))
6479
+ continue;
6480
+ if (testCaseEntities[stateKey])
6481
+ continue;
6482
+ testCaseEntities[stateKey] = entry;
6483
+ }
6484
+ const what = references
6485
+ .map((ref) => `${ref.tomlKey} (id ${ref.id})`)
6486
+ .join(", ");
6487
+ const kept = preservedSlugs.length > 0
6488
+ ? `kept ${preservedSlugs.map((s) => `${s}.toml`).join(", ")} as it was`
6489
+ : "no sidecar exists for it";
6490
+ warn(` Test case not written for ${blockType}: ${blockKey}/${slug} ` +
6491
+ `("${tc?.name ?? ""}"): ${what} could not be resolved to a name — ` +
6492
+ `${kept}, so the pin is not lost. The configuration or prompt may have ` +
6493
+ "been deleted (re-pin the case on the server), or its listing failed " +
6494
+ "on this pull (run `config pull` again).");
6495
+ unwritten.push({
6496
+ blockType,
6497
+ blockKey,
6498
+ slug,
6499
+ name: tc?.name ?? "",
6500
+ testCaseId: tc?.testCaseId,
6501
+ references,
6502
+ preservedSlugs,
6503
+ });
6504
+ }
6362
6505
  // Clean up local files for remotely-deleted test cases
6363
6506
  if (existsSync(testsDir)) {
6364
6507
  const localFiles = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
6365
6508
  for (const file of localFiles) {
6366
6509
  const localSlug = basename(file, ".toml");
6367
- if (!pulledSlugs.has(localSlug)) {
6510
+ // A listing keeps the casing a file was created with, so the name a case
6511
+ // was just written to can come back as `Alpha.toml` — the same file.
6512
+ const pulled = pulledSlugs.has(localSlug) ||
6513
+ [...pulledSlugs].some((slug) => sameTestCaseFile(testsDir, slug, localSlug));
6514
+ if (!pulled) {
6368
6515
  // Remove orphaned TOML
6369
6516
  unlinkSync(join(testsDir, file));
6370
6517
  // Remove orphaned attachment dir if exists
@@ -6384,7 +6531,7 @@ export async function pullTestCasesForBlock(params) {
6384
6531
  }
6385
6532
  }
6386
6533
  }
6387
- return { ok: true, count: testCases.length };
6534
+ return { ok: true, count: writable.length, unwritten };
6388
6535
  }
6389
6536
  /**
6390
6537
  * One pull leg: every block of a type the pull selected (#2769).
@@ -6398,6 +6545,7 @@ export async function pullTestCasesForBlocks(params) {
6398
6545
  const log = params.logger ?? ((message) => info(message));
6399
6546
  let count = 0;
6400
6547
  const skippedBlocks = [];
6548
+ const unwritten = [];
6401
6549
  for (const block of params.blocks) {
6402
6550
  const outcome = await pullTestCasesForBlock({
6403
6551
  client: params.client,
@@ -6425,9 +6573,14 @@ export async function pullTestCasesForBlocks(params) {
6425
6573
  if (outcome.count > 0) {
6426
6574
  log(` Wrote ${outcome.count} test case(s) for ${params.blockType}: ${block.key}`);
6427
6575
  }
6576
+ if (outcome.unwritten.length > 0) {
6577
+ log(` ${outcome.unwritten.length} test case(s) not written for ` +
6578
+ `${params.blockType}: ${block.key} (unresolved references; see above)`);
6579
+ }
6428
6580
  count += outcome.count;
6581
+ unwritten.push(...outcome.unwritten);
6429
6582
  }
6430
- return { count, skippedBlocks };
6583
+ return { count, skippedBlocks, unwritten };
6431
6584
  }
6432
6585
  /** The 10 MB per-attachment cap the server enforces. */
6433
6586
  const TEN_MB = 10 * 1024 * 1024;
@@ -6619,9 +6772,20 @@ export async function pushTestCasesForBlock(params) {
6619
6772
  continue;
6620
6773
  }
6621
6774
  const tomlData = parseTomlFile(filePath);
6775
+ // #3998 — the command's preflight refuses a sidecar it cannot push, but
6776
+ // this leg is exported and the parser no longer reads the retired id keys:
6777
+ // a file reaching it unchecked would be pushed UNPINNED in silence. The
6778
+ // same per-loop defense the workflow leg keeps (#976).
6779
+ const sidecarErrors = testCaseSidecarPreflightErrors(filePath, tomlData);
6780
+ if (sidecarErrors.length > 0) {
6781
+ const message = sidecarErrors.join("; ");
6782
+ warn(` Failed to push test case ${blockKey}/${slug}: ${message}`);
6783
+ failures.push({ type: "test-case", key: `${blockKey}/${slug}`, message });
6784
+ continue;
6785
+ }
6622
6786
  const testPayload = parseTestCaseToml(tomlData);
6623
- // Resolve key-based references to IDs. An authored name that does not
6624
- // resolve is a FAILURE: silently dropping it would push the test case with
6787
+ // Resolve the authored names to IDs — a name is the only spelling a
6788
+ // sidecar has (#3998). An authored name that does not resolve is a FAILURE: silently dropping it would push the test case with
6625
6789
  // no pin, quietly changing what the case tests (#2769). The exception is a
6626
6790
  // preview of a block this push has not created yet — its configs cannot
6627
6791
  // exist to be named, so a dry run reports the reference as pending rather
@@ -6642,7 +6806,7 @@ export async function pushTestCasesForBlock(params) {
6642
6806
  delete testPayload._configName;
6643
6807
  delete testPayload._evaluatorPromptKey;
6644
6808
  delete testPayload._evaluatorConfigName;
6645
- if (configName && !testPayload.configId) {
6809
+ if (configName) {
6646
6810
  const configMap = blockType === "prompt"
6647
6811
  ? resolutionMaps?.promptConfigNameToId
6648
6812
  : blockType === "script"
@@ -6660,14 +6824,14 @@ export async function pushTestCasesForBlock(params) {
6660
6824
  else
6661
6825
  noteUnresolved(`configName "${configName}"`, blockType, blockKey);
6662
6826
  }
6663
- if (evaluatorPromptKey && !testPayload.evaluatorPromptId) {
6827
+ if (evaluatorPromptKey) {
6664
6828
  const resolvedId = resolutionMaps?.promptKeyToId.get(evaluatorPromptKey);
6665
6829
  if (resolvedId)
6666
6830
  testPayload.evaluatorPromptId = resolvedId;
6667
6831
  else
6668
6832
  noteUnresolved(`evaluatorPromptKey "${evaluatorPromptKey}"`, "prompt", evaluatorPromptKey);
6669
6833
  }
6670
- if (evaluatorConfigName && !testPayload.evaluatorConfigId) {
6834
+ if (evaluatorConfigName) {
6671
6835
  let resolvedId = evaluatorPromptKey
6672
6836
  ? resolutionMaps?.promptConfigNameToId.get(`${evaluatorPromptKey}#${evaluatorConfigName}`)
6673
6837
  : undefined;
@@ -7248,9 +7412,10 @@ export function pairTestCases(input) {
7248
7412
  * integration and workflow sidecar pinned to a config reported Modified
7249
7413
  * forever, and push read the same pair as server drift.
7250
7414
  *
7251
- * One listing per block that has sidecars, and a failed one is silent: an
7252
- * unresolved reference compares as the id both sides carry, which is the same
7253
- * degradation `serializeTestCase` already applies.
7415
+ * One listing per block that has sidecars, and a failed one is silent: the
7416
+ * live side's unresolved reference then compares as its raw id
7417
+ * (`serializeTestCaseForComparison`), so the row reads Modified rather than
7418
+ * hiding a pin.
7254
7419
  */
7255
7420
  async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
7256
7421
  try {
@@ -7308,10 +7473,6 @@ export async function compareTestCasesForBlock(params) {
7308
7473
  });
7309
7474
  for (const slug of localSlugs) {
7310
7475
  const live = paired.bySlug.get(slug);
7311
- if (!live) {
7312
- rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7313
- continue;
7314
- }
7315
7476
  const filePath = join(testsDir, `${slug}.toml`);
7316
7477
  let row;
7317
7478
  try {
@@ -7319,7 +7480,9 @@ export async function compareTestCasesForBlock(params) {
7319
7480
  // #2880 criterion 1 — a sidecar push's preflight would reject is not one
7320
7481
  // this command calls synced, and the message is the same one. The WHOLE
7321
7482
  // preflight, not just the sidecar's own rules: an unknown key or a
7322
- // mistyped value aborts push too.
7483
+ // mistyped value aborts push too. #3998 — and an UNPAIRED sidecar is
7484
+ // checked before it is called local-only, so a new file push would refuse
7485
+ // (a retired id key) is reported with the guidance, not as a create.
7323
7486
  const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
7324
7487
  if (errors.length > 0) {
7325
7488
  row = {
@@ -7329,6 +7492,10 @@ export async function compareTestCasesForBlock(params) {
7329
7492
  hint: errors.join("; "),
7330
7493
  };
7331
7494
  }
7495
+ else if (!live) {
7496
+ rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7497
+ continue;
7498
+ }
7332
7499
  else {
7333
7500
  const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
7334
7501
  // Both sides read a reference through the same lookups — see
@@ -7345,6 +7512,12 @@ export async function compareTestCasesForBlock(params) {
7345
7512
  hint: `${filePath}: ${err?.message || String(err)}`,
7346
7513
  };
7347
7514
  }
7515
+ // An unpaired file push would refuse has no attachment plan, as a
7516
+ // local-only one has none.
7517
+ if (!live) {
7518
+ rows.push(row);
7519
+ continue;
7520
+ }
7348
7521
  // #2880 behavior 19 — attachments are part of what push would do, read
7349
7522
  // from the SAME plan push executes. A sidecar whose text compares clean but
7350
7523
  // whose fixtures have not been uploaded is not Synced.
@@ -7820,7 +7993,11 @@ export function registerConfigSyncCommands(sync) {
7820
7993
  const appWorkflowItems = selectPull("workflow", workflowItems.filter((w) => !isPlatformOwnedWorkflow(w)), (w) => w.workflowKey);
7821
7994
  // Fetch details for each entity
7822
7995
  const integrations = selectPull("integration", await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId))), (i) => i.integrationKey);
7823
- const prompts = selectPull("prompt", await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId))), (p) => p.promptKey);
7996
+ // #3998 — every prompt's detail is kept, not just the selection's: a
7997
+ // test case of a selected block may be judged by a prompt `--only`
7998
+ // left out, and its sidecar names that prompt by key.
7999
+ const promptDetails = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
8000
+ const prompts = selectPull("prompt", promptDetails, (p) => p.promptKey);
7824
8001
  const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
7825
8002
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
7826
8003
  // #2645 — fetch EVERY config's body, not just the active one. The
@@ -8321,57 +8498,38 @@ export function registerConfigSyncCommands(sync) {
8321
8498
  if (pulledFunctionsCount > 0) {
8322
8499
  info(` Pulled ${pulledFunctionsCount} function(s)`);
8323
8500
  }
8324
- // Build lookup maps for test case serialization
8325
- const configIdToName = new Map();
8326
- const promptIdToKey = new Map();
8327
- for (const prompt of prompts) {
8328
- promptIdToKey.set(prompt.promptId, prompt.promptKey);
8329
- if (prompt.configs) {
8330
- for (const config of prompt.configs) {
8331
- configIdToName.set(config.configId, config.configName);
8332
- }
8333
- }
8334
- }
8335
- // Also include workflow config names
8336
- for (const { configs } of workflows) {
8337
- if (configs) {
8338
- for (const config of configs) {
8339
- configIdToName.set(config.configId, config.configName);
8340
- }
8341
- }
8342
- }
8343
- // Include script config names so a pinned script test case
8344
- // round-trips its `configName` reference (the same way prompt/workflow
8345
- // configs above do). One extra `configs` fetch per pulled script.
8346
- for (const [name, entity] of Object.entries(scriptEntities)) {
8501
+ // Lookup maps for test case serialization (#3998): every prompt's
8502
+ // detail, so a judge prompt outside an `--only` selection still names,
8503
+ // and the configurations of the pulled blocks — a case pins one of its
8504
+ // own block's. A reference these cannot resolve is reported by
8505
+ // `pullTestCasesForBlock`, and its sidecar is not written.
8506
+ const blockConfigs = [];
8507
+ for (const { configs } of workflows)
8508
+ blockConfigs.push(...(configs ?? []));
8509
+ // One `configs` fetch per pulled script and integration. A failed one
8510
+ // is non-fatal: the references it would have named are reported.
8511
+ for (const entity of Object.values(scriptEntities)) {
8347
8512
  try {
8348
- const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
8349
- for (const config of scriptConfigs) {
8350
- configIdToName.set(config.configId, config.configName);
8351
- }
8513
+ const { items } = await client.listScriptConfigs(resolvedAppId, entity.id);
8514
+ blockConfigs.push(...items);
8352
8515
  }
8353
8516
  catch {
8354
- // Non-fatal: a script test case pinned to a config serializes with
8355
- // its legacy id spelling instead of a name (#2769), so the pin
8356
- // survives. The header/body still pulled fine — `name` retained.
8357
- void name;
8517
+ // Non-fatal — see above.
8358
8518
  }
8359
8519
  }
8360
- // Integration config names, for the same reason (#2769). A failed fetch
8361
- // is non-fatal here too: `serializeTestCase` keeps the pin as `configId`
8362
- // rather than writing an empty reference.
8363
8520
  for (const integration of integrations) {
8364
8521
  try {
8365
- const { items: integrationConfigs } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8366
- for (const config of integrationConfigs) {
8367
- configIdToName.set(config.configId, config.configName);
8368
- }
8522
+ const { items } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8523
+ blockConfigs.push(...items);
8369
8524
  }
8370
8525
  catch {
8371
8526
  // Non-fatal — see above.
8372
8527
  }
8373
8528
  }
8374
- const testCaseLookupMaps = { configIdToName, promptIdToKey };
8529
+ const testCaseLookupMaps = testCaseLookupMapsForPull({
8530
+ prompts: promptDetails,
8531
+ configs: blockConfigs,
8532
+ });
8375
8533
  // #2731 B4 — pull is one of the two moments local and server provably
8376
8534
  // agree, so it stamps the semantic baseline beside the byte hash (push
8377
8535
  // stamps the other one, from the entity the server returned). The
@@ -8411,6 +8569,7 @@ export function registerConfigSyncCommands(sync) {
8411
8569
  const testCaseEntities = {};
8412
8570
  const priorTestCaseEntities = priorEntities.testCases;
8413
8571
  let totalTestCases = 0;
8572
+ let unwrittenTestCases = 0;
8414
8573
  for (const [blockType, blocks] of [
8415
8574
  [
8416
8575
  "prompt",
@@ -8440,7 +8599,7 @@ export function registerConfigSyncCommands(sync) {
8440
8599
  })),
8441
8600
  ],
8442
8601
  ]) {
8443
- const { count } = await pullTestCasesForBlocks({
8602
+ const { count, unwritten } = await pullTestCasesForBlocks({
8444
8603
  client,
8445
8604
  appId: resolvedAppId,
8446
8605
  configDir,
@@ -8452,6 +8611,14 @@ export function registerConfigSyncCommands(sync) {
8452
8611
  logger: (message) => info(message),
8453
8612
  });
8454
8613
  totalTestCases += count;
8614
+ unwrittenTestCases += unwritten.length;
8615
+ }
8616
+ // #3998 — a case pull could not name a reference for is not written, so
8617
+ // its pin is never lost; the count is repeated here so it is not missed
8618
+ // among the per-block lines. Exit status is unchanged, as for every
8619
+ // other partial fetch this command reports.
8620
+ if (unwrittenTestCases > 0) {
8621
+ warn(`${unwrittenTestCases} test case(s) not written — references could not be resolved; see above`);
8455
8622
  }
8456
8623
  // Build ruleSetId → name map for database types and group type configs
8457
8624
  const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
@@ -8969,6 +9136,9 @@ export function registerConfigSyncCommands(sync) {
8969
9136
  keyValue("Functions", pulledFunctionsCount);
8970
9137
  keyValue("Email Templates", emailTemplates.length);
8971
9138
  keyValue("Test Cases", totalTestCases);
9139
+ if (unwrittenTestCases > 0) {
9140
+ keyValue("Test Cases not written", unwrittenTestCases);
9141
+ }
8972
9142
  keyValue("Database Types", databaseTypesWithOps.length);
8973
9143
  keyValue("Rule Sets", ruleSets.length);
8974
9144
  keyValue("Group Type Configs", groupTypeConfigs.length);
@@ -17263,28 +17433,28 @@ What a push guarantees (two stages):
17263
17433
  let unchangedCount = 0;
17264
17434
  // ---- database-type-configs/ pass ----
17265
17435
  if (dbFiles.length > 0) {
17266
- // Hydrate ruleSetIdToName from local sync state so files that
17267
- // reference a rule set via the legacy `ruleSetId = "01..."` form
17268
- // round-trip with the correct `ruleSetName`. Without this, any user
17269
- // who runs migrate-toml against a file with a rule-set assignment
17270
- // would silently lose that reference (review feedback r3246633010).
17271
- //
17272
- // Files that use the modern key-based `ruleSetName = "..."` form
17273
- // don't need the map at all — `parseDatabaseTypeToml` stores the
17274
- // value in `typeConfig._ruleSetName` and the serializer now prefers
17275
- // that. The map is only load-bearing for the legacy ID-based form.
17276
- const ruleSetIdToName = new Map();
17277
- const migrateSyncState = loadSyncState(configDir);
17278
- if (migrateSyncState?.entities?.ruleSets) {
17279
- for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
17280
- if (entry && typeof entry === "object" && "id" in entry && entry.id) {
17281
- // fileKey is the sanitized rule-set name (see config pull at
17282
- // sync.ts:1385). It's the same shape the server returned and
17283
- // matches what a TOML file's ruleSetName field would carry.
17284
- ruleSetIdToName.set(entry.id, fileKey);
17285
- }
17286
- }
17436
+ // #3997 — refuse a tree carrying a retired key (the id-based
17437
+ // `ruleSetId`) with the message `config push` gives, before anything is
17438
+ // rewritten. This is a separate pass, not a check inside the loop below:
17439
+ // that loop writes each file and saves sync state before it parses the
17440
+ // next, so an in-loop abort would leave earlier files rewritten. Every
17441
+ // offending file is reported, so the author fixes the tree once.
17442
+ const retiredKeyErrors = [];
17443
+ for (const file of dbFiles) {
17444
+ const message = configUnknownKeyError(`database-type-configs/${file}`, parseTomlFile(join(dbTypesDir, file)), DATABASE_TYPE_TABLE);
17445
+ if (message)
17446
+ retiredKeyErrors.push(message);
17287
17447
  }
17448
+ if (retiredKeyErrors.length > 0) {
17449
+ for (const message of retiredKeyErrors)
17450
+ error(` ${message}`);
17451
+ error(`Aborting migrate-toml: ${retiredKeyErrors.length} database-type ` +
17452
+ "file(s) carry a retired or unrecognized key; nothing was rewritten.");
17453
+ process.exit(1);
17454
+ }
17455
+ // The parser yields the rule-set reference only as `_ruleSetName`,
17456
+ // which the serializer prefers, so there is no id to map to a name.
17457
+ const ruleSetIdToName = new Map();
17288
17458
  for (const file of dbFiles) {
17289
17459
  const filePath = join(dbTypesDir, file);
17290
17460
  const rawBefore = readFileSync(filePath, "utf-8");