primitive-admin 1.2.0-alpha.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.
@@ -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];
@@ -1761,9 +1755,6 @@ export function buildBlobBucketCreatePayload(key, bucket) {
1761
1755
  },
1762
1756
  }),
1763
1757
  };
1764
- if (!payload.preset && bucket?.accessPolicy) {
1765
- payload.accessPolicy = bucket.accessPolicy;
1766
- }
1767
1758
  for (const field of Object.keys(payload)) {
1768
1759
  if (payload[field] === undefined)
1769
1760
  delete payload[field];
@@ -4535,16 +4526,16 @@ function jsonFieldsForDiff(table, entity) {
4535
4526
  /**
4536
4527
  * One test-case reference as the comparison reads it (#2880 behavior 17).
4537
4528
  *
4538
- * A reference reaches the projection in one of three states, and all three
4539
- * 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:
4540
4532
  *
4541
- * - a portable NAME (`configName = "default"`), which a test file carries so
4542
- * it survives a move between apps;
4543
- * - a legacy ID that RESOLVES, which becomes that same name — otherwise a file
4544
- * pinned by id would differ forever from the file pull writes for it;
4545
- * - a legacy ID that does NOT resolve, which stays the id, because pull writes
4546
- * an unresolvable id straight back and comparing it away would hide a pin
4547
- * 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).
4548
4539
  */
4549
4540
  function testCaseReference(value, idToName) {
4550
4541
  if (typeof value !== "string" || value === "")
@@ -4821,14 +4812,6 @@ const CONFIG_DIFF_SPECS = {
4821
4812
  table: BLOB_BUCKET_TABLE,
4822
4813
  parse: (doc) => {
4823
4814
  const bucket = { ...(doc?.bucket ?? {}) };
4824
- // #1020 — `accessPolicy` is the pre-`preset` spelling, still accepted on
4825
- // the wire and still authored in unmigrated files. Both spellings mean
4826
- // the same access model, so they project the same way; without this an
4827
- // unmigrated file reports a difference no push can clear (push sends the
4828
- // legacy key and the server answers with `preset`).
4829
- if (bucket.preset === undefined && bucket.accessPolicy !== undefined) {
4830
- bucket.preset = bucket.accessPolicy;
4831
- }
4832
4815
  return {
4833
4816
  bucketKey: doc?.bucket?.key ?? "",
4834
4817
  ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
@@ -4862,7 +4845,7 @@ const CONFIG_DIFF_SPECS = {
4862
4845
  const keys = [];
4863
4846
  if (!bucket.name)
4864
4847
  keys.push("name");
4865
- if (!bucket.preset && !bucket.accessPolicy)
4848
+ if (!bucket.preset)
4866
4849
  keys.push("preset");
4867
4850
  if (!bucket.ruleSetId)
4868
4851
  keys.push("ruleSetId");
@@ -4874,17 +4857,15 @@ const CONFIG_DIFF_SPECS = {
4874
4857
  // edited expectation read as Synced while push applied it from the file's
4875
4858
  // byte hash.
4876
4859
  //
4877
- // The type's own difficulty is that a reference has two legal spellings — the
4878
- // portable `configName` a test file carries so it survives a move between
4879
- // apps, and the legacy `configId` pull still writes back when the name cannot
4880
- // be resolved. They name the same thing, so they project the same way; the
4881
- // `extra` context is what makes that possible (the id→name lookups are not in
4882
- // 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.
4883
4864
  "test-case": {
4884
4865
  label: "test-case",
4885
4866
  table: TEST_CASE_TABLE,
4886
4867
  parse: (doc) => parseTestCaseToml(doc),
4887
- serialize: (record, _maps, extra) => serializeTestCase(record, extra?.lookupMaps),
4868
+ serialize: (record, _maps, extra) => serializeTestCaseForComparison(record, extra?.lookupMaps),
4888
4869
  // The server trims a test-case name on write (`src/admin-api.ts`), so a
4889
4870
  // padded one in the file is not a difference — it is what the server will
4890
4871
  // store (#2880 DSO-003).
@@ -4894,12 +4875,11 @@ const CONFIG_DIFF_SPECS = {
4894
4875
  },
4895
4876
  extras: (entity, _doc, extra) => ({
4896
4877
  // These three OVERRIDE the definition's fields of the same name: the
4897
- // value compared is the thing referred to, not the spelling that referred
4898
- // to it. Precedence matches the push path exactly — a file carrying both
4899
- // an id and a name is pushed on the id, so it is compared on the id.
4900
- configId: testCaseReference(entity.configId ?? entity._configName, extra?.lookupMaps?.configIdToName),
4901
- evaluatorPromptId: testCaseReference(entity.evaluatorPromptId ?? entity._evaluatorPromptKey, extra?.lookupMaps?.promptIdToKey),
4902
- 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),
4903
4883
  }),
4904
4884
  describe: (entity) => `test case "${entity?.name}"`,
4905
4885
  },
@@ -5718,49 +5698,81 @@ export function getTestsDir(configDir, blockType, blockKey) {
5718
5698
  function blockSelectionLabel(blockType) {
5719
5699
  return blockType === "script" ? "transform" : blockType;
5720
5700
  }
5721
- export function serializeTestCase(testCase, lookupMaps, options = {}) {
5722
- // #2644 — name any server key this CLI version does not know rather than
5723
- // dropping it silently on the way into the file. Test cases are a registered
5724
- // surface; their sidecar shape is the only thing that sets them apart.
5725
- warnUnrecognizedServerKeys(testCase, TEST_CASE_TABLE, options.file ?? `<block>.tests/${testCase?.name ?? "unnamed"}.toml`, options.logger);
5726
- // Resolve ID references to key-based references
5727
- const configName = testCase.configId && lookupMaps?.configIdToName
5728
- ? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
5729
- const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
5730
- ? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
5731
- const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
5732
- ? (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);
5733
5728
  // #2644 criterion 1 — the `[test]` key set comes from the vendored
5734
5729
  // definition, so a field added there is emitted by pull with no edit here.
5735
5730
  // The three reference fields carry the resolved NAME (a test file is portable
5736
5731
  // across apps); the rest keep the empty-string / "{}" / "[]" spellings a test
5737
5732
  // file has always used for "not set".
5738
- const data = {
5739
- test: projectRecordToToml(TEST_CASE_TABLE, testCase, {
5740
- name: (record) => record.name || "",
5741
- description: (record) => record.description || "",
5742
- inputVariables: (record) => jsonFieldText(record.inputVariables, "{}"),
5743
- configId: () => configName,
5744
- evaluatorPromptId: () => evaluatorPromptKey,
5745
- evaluatorConfigId: () => evaluatorConfigName,
5746
- expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5747
- expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5748
- expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5749
- }),
5750
- };
5751
- // #2769 — a reference the id→name lookup could not resolve (an unknown id, or
5752
- // a config listing that failed on this pull) is written back in the legacy id
5753
- // spelling the parser still accepts, NOT as an empty name. Rewriting a pinned
5754
- // test as unpinned would push the pin away on the next `config push`.
5755
- if (testCase.configId && !configName)
5756
- data.test.configId = testCase.configId;
5757
- if (testCase.evaluatorPromptId && !evaluatorPromptKey) {
5758
- data.test.evaluatorPromptId = testCase.evaluatorPromptId;
5759
- }
5760
- if (testCase.evaluatorConfigId && !evaluatorConfigName) {
5761
- data.test.evaluatorConfigId = testCase.evaluatorConfigId;
5762
- }
5763
- 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 });
5764
5776
  }
5765
5777
  /**
5766
5778
  * The TOML spelling of a JSON-valued test-case field (#2769).
@@ -5810,20 +5822,22 @@ export function parseTestCaseToml(tomlData) {
5810
5822
  description: (value) => (value ? value : undefined),
5811
5823
  // Always sent — the server requires it, and `{}` is the empty spelling.
5812
5824
  inputVariables: (value) => parseJsonText(value, ["{}"]) ?? {},
5813
- // The legacy ID-based spellings stay accepted (declared in the
5814
- // definition's `tomlOnlyKeys`); the name-based ones become the `_`
5815
- // markers below, which the push path resolves against the live app.
5816
- configId: (_value, _mode, toml) => toml.configId || undefined,
5817
- evaluatorPromptId: (_value, _mode, toml) => toml.evaluatorPromptId || undefined,
5818
- 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,
5819
5834
  expectedOutputPattern: (value) => (value ? value : undefined),
5820
5835
  expectedOutputContains: (value) => parseJsonText(value, ["[]"]),
5821
5836
  expectedJsonSubset: (value) => parseJsonText(value, ["{}"]),
5822
5837
  },
5823
5838
  });
5824
5839
  // An omitted field must be absent from the body, not present-and-undefined:
5825
- // the push path tests `!testPayload.configId` and the server's unknown-key
5826
- // check reads the body's keys.
5840
+ // the server's unknown-key check reads the body's keys.
5827
5841
  for (const key of Object.keys(payload)) {
5828
5842
  if (payload[key] === undefined)
5829
5843
  delete payload[key];
@@ -6176,6 +6190,35 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6176
6190
  items,
6177
6191
  };
6178
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
+ }
6179
6222
  /**
6180
6223
  * Every test case a block has, draining the cursor (#2769).
6181
6224
  *
@@ -6228,6 +6271,27 @@ export function carryForwardTestCaseEntities(params) {
6228
6271
  }
6229
6272
  return carried;
6230
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
+ }
6231
6295
  /**
6232
6296
  * Write one block's test cases into its `<key>.tests/` sidecar and record them
6233
6297
  * in sync state. Exported so the unit tests can drive it against a stubbed
@@ -6243,10 +6307,9 @@ export async function pullTestCasesForBlock(params) {
6243
6307
  const testsDir = getTestsDir(configDir, blockType, blockKey);
6244
6308
  // A block with no cases and no sidecar is not a block whose sidecar is empty:
6245
6309
  // 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
- if (testCases.length > 0)
6249
- ensureDir(testsDir);
6310
+ if (testCases.length === 0 && !existsSync(testsDir)) {
6311
+ return { ok: true, count: 0, unwritten: [] };
6312
+ }
6250
6313
  const pulledSlugs = new Set();
6251
6314
  // #2896 — a keyed case is written to the file its key names, so pull stops
6252
6315
  // renaming what push authored: the repro's `full-refresh-takes-all-history`
@@ -6256,15 +6319,35 @@ export async function pullTestCasesForBlock(params) {
6256
6319
  // The key is server data becoming a path: the containment check an
6257
6320
  // attachment's filename gets, so nothing lands outside the sidecar dir.
6258
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 = [];
6259
6328
  for (const tc of testCases) {
6260
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) {
6261
6347
  pulledSlugs.add(slug);
6262
6348
  // Write test case TOML
6263
6349
  const tomlPath = join(testsDir, `${slug}.toml`);
6264
- writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps, {
6265
- file: tomlPath,
6266
- logger: (message) => log(message),
6267
- }));
6350
+ writeFileSync(tomlPath, toml);
6268
6351
  // Attachments: the sidecar directory is the authored set, so pull makes it
6269
6352
  // match the server — downloading what is there, recording each file's
6270
6353
  // content hash (so push can see a byte change under an unchanged name), and
@@ -6356,12 +6439,79 @@ export async function pullTestCasesForBlock(params) {
6356
6439
  }),
6357
6440
  };
6358
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
+ }
6359
6505
  // Clean up local files for remotely-deleted test cases
6360
6506
  if (existsSync(testsDir)) {
6361
6507
  const localFiles = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
6362
6508
  for (const file of localFiles) {
6363
6509
  const localSlug = basename(file, ".toml");
6364
- 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) {
6365
6515
  // Remove orphaned TOML
6366
6516
  unlinkSync(join(testsDir, file));
6367
6517
  // Remove orphaned attachment dir if exists
@@ -6381,7 +6531,7 @@ export async function pullTestCasesForBlock(params) {
6381
6531
  }
6382
6532
  }
6383
6533
  }
6384
- return { ok: true, count: testCases.length };
6534
+ return { ok: true, count: writable.length, unwritten };
6385
6535
  }
6386
6536
  /**
6387
6537
  * One pull leg: every block of a type the pull selected (#2769).
@@ -6395,6 +6545,7 @@ export async function pullTestCasesForBlocks(params) {
6395
6545
  const log = params.logger ?? ((message) => info(message));
6396
6546
  let count = 0;
6397
6547
  const skippedBlocks = [];
6548
+ const unwritten = [];
6398
6549
  for (const block of params.blocks) {
6399
6550
  const outcome = await pullTestCasesForBlock({
6400
6551
  client: params.client,
@@ -6422,9 +6573,14 @@ export async function pullTestCasesForBlocks(params) {
6422
6573
  if (outcome.count > 0) {
6423
6574
  log(` Wrote ${outcome.count} test case(s) for ${params.blockType}: ${block.key}`);
6424
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
+ }
6425
6580
  count += outcome.count;
6581
+ unwritten.push(...outcome.unwritten);
6426
6582
  }
6427
- return { count, skippedBlocks };
6583
+ return { count, skippedBlocks, unwritten };
6428
6584
  }
6429
6585
  /** The 10 MB per-attachment cap the server enforces. */
6430
6586
  const TEN_MB = 10 * 1024 * 1024;
@@ -6616,9 +6772,20 @@ export async function pushTestCasesForBlock(params) {
6616
6772
  continue;
6617
6773
  }
6618
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
+ }
6619
6786
  const testPayload = parseTestCaseToml(tomlData);
6620
- // Resolve key-based references to IDs. An authored name that does not
6621
- // 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
6622
6789
  // no pin, quietly changing what the case tests (#2769). The exception is a
6623
6790
  // preview of a block this push has not created yet — its configs cannot
6624
6791
  // exist to be named, so a dry run reports the reference as pending rather
@@ -6639,7 +6806,7 @@ export async function pushTestCasesForBlock(params) {
6639
6806
  delete testPayload._configName;
6640
6807
  delete testPayload._evaluatorPromptKey;
6641
6808
  delete testPayload._evaluatorConfigName;
6642
- if (configName && !testPayload.configId) {
6809
+ if (configName) {
6643
6810
  const configMap = blockType === "prompt"
6644
6811
  ? resolutionMaps?.promptConfigNameToId
6645
6812
  : blockType === "script"
@@ -6657,14 +6824,14 @@ export async function pushTestCasesForBlock(params) {
6657
6824
  else
6658
6825
  noteUnresolved(`configName "${configName}"`, blockType, blockKey);
6659
6826
  }
6660
- if (evaluatorPromptKey && !testPayload.evaluatorPromptId) {
6827
+ if (evaluatorPromptKey) {
6661
6828
  const resolvedId = resolutionMaps?.promptKeyToId.get(evaluatorPromptKey);
6662
6829
  if (resolvedId)
6663
6830
  testPayload.evaluatorPromptId = resolvedId;
6664
6831
  else
6665
6832
  noteUnresolved(`evaluatorPromptKey "${evaluatorPromptKey}"`, "prompt", evaluatorPromptKey);
6666
6833
  }
6667
- if (evaluatorConfigName && !testPayload.evaluatorConfigId) {
6834
+ if (evaluatorConfigName) {
6668
6835
  let resolvedId = evaluatorPromptKey
6669
6836
  ? resolutionMaps?.promptConfigNameToId.get(`${evaluatorPromptKey}#${evaluatorConfigName}`)
6670
6837
  : undefined;
@@ -7245,9 +7412,10 @@ export function pairTestCases(input) {
7245
7412
  * integration and workflow sidecar pinned to a config reported Modified
7246
7413
  * forever, and push read the same pair as server drift.
7247
7414
  *
7248
- * One listing per block that has sidecars, and a failed one is silent: an
7249
- * unresolved reference compares as the id both sides carry, which is the same
7250
- * 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.
7251
7419
  */
7252
7420
  async function fillBlockConfigNames(client, appId, blockType, blockId, configIdToName) {
7253
7421
  try {
@@ -7305,10 +7473,6 @@ export async function compareTestCasesForBlock(params) {
7305
7473
  });
7306
7474
  for (const slug of localSlugs) {
7307
7475
  const live = paired.bySlug.get(slug);
7308
- if (!live) {
7309
- rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7310
- continue;
7311
- }
7312
7476
  const filePath = join(testsDir, `${slug}.toml`);
7313
7477
  let row;
7314
7478
  try {
@@ -7316,7 +7480,9 @@ export async function compareTestCasesForBlock(params) {
7316
7480
  // #2880 criterion 1 — a sidecar push's preflight would reject is not one
7317
7481
  // this command calls synced, and the message is the same one. The WHOLE
7318
7482
  // preflight, not just the sidecar's own rules: an unknown key or a
7319
- // 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.
7320
7486
  const errors = testCaseSidecarPreflightErrors(filePath, tomlData);
7321
7487
  if (errors.length > 0) {
7322
7488
  row = {
@@ -7326,6 +7492,10 @@ export async function compareTestCasesForBlock(params) {
7326
7492
  hint: errors.join("; "),
7327
7493
  };
7328
7494
  }
7495
+ else if (!live) {
7496
+ rows.push({ ...base, slug, status: LOCAL_ONLY_NEW });
7497
+ continue;
7498
+ }
7329
7499
  else {
7330
7500
  const verdict = compareLocalToRemote(spec, tomlData, live, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, extra,
7331
7501
  // Both sides read a reference through the same lookups — see
@@ -7342,6 +7512,12 @@ export async function compareTestCasesForBlock(params) {
7342
7512
  hint: `${filePath}: ${err?.message || String(err)}`,
7343
7513
  };
7344
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
+ }
7345
7521
  // #2880 behavior 19 — attachments are part of what push would do, read
7346
7522
  // from the SAME plan push executes. A sidecar whose text compares clean but
7347
7523
  // whose fixtures have not been uploaded is not Synced.
@@ -7817,7 +7993,11 @@ export function registerConfigSyncCommands(sync) {
7817
7993
  const appWorkflowItems = selectPull("workflow", workflowItems.filter((w) => !isPlatformOwnedWorkflow(w)), (w) => w.workflowKey);
7818
7994
  // Fetch details for each entity
7819
7995
  const integrations = selectPull("integration", await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId))), (i) => i.integrationKey);
7820
- 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);
7821
8001
  const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
7822
8002
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
7823
8003
  // #2645 — fetch EVERY config's body, not just the active one. The
@@ -8318,57 +8498,38 @@ export function registerConfigSyncCommands(sync) {
8318
8498
  if (pulledFunctionsCount > 0) {
8319
8499
  info(` Pulled ${pulledFunctionsCount} function(s)`);
8320
8500
  }
8321
- // Build lookup maps for test case serialization
8322
- const configIdToName = new Map();
8323
- const promptIdToKey = new Map();
8324
- for (const prompt of prompts) {
8325
- promptIdToKey.set(prompt.promptId, prompt.promptKey);
8326
- if (prompt.configs) {
8327
- for (const config of prompt.configs) {
8328
- configIdToName.set(config.configId, config.configName);
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)) {
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)) {
8344
8512
  try {
8345
- const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
8346
- for (const config of scriptConfigs) {
8347
- configIdToName.set(config.configId, config.configName);
8348
- }
8513
+ const { items } = await client.listScriptConfigs(resolvedAppId, entity.id);
8514
+ blockConfigs.push(...items);
8349
8515
  }
8350
8516
  catch {
8351
- // Non-fatal: a script test case pinned to a config serializes with
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;
8517
+ // Non-fatal — see above.
8355
8518
  }
8356
8519
  }
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
8520
  for (const integration of integrations) {
8361
8521
  try {
8362
- const { items: integrationConfigs } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8363
- for (const config of integrationConfigs) {
8364
- configIdToName.set(config.configId, config.configName);
8365
- }
8522
+ const { items } = await client.listIntegrationConfigs(resolvedAppId, integration.integrationId);
8523
+ blockConfigs.push(...items);
8366
8524
  }
8367
8525
  catch {
8368
8526
  // Non-fatal — see above.
8369
8527
  }
8370
8528
  }
8371
- const testCaseLookupMaps = { configIdToName, promptIdToKey };
8529
+ const testCaseLookupMaps = testCaseLookupMapsForPull({
8530
+ prompts: promptDetails,
8531
+ configs: blockConfigs,
8532
+ });
8372
8533
  // #2731 B4 — pull is one of the two moments local and server provably
8373
8534
  // agree, so it stamps the semantic baseline beside the byte hash (push
8374
8535
  // stamps the other one, from the entity the server returned). The
@@ -8408,6 +8569,7 @@ export function registerConfigSyncCommands(sync) {
8408
8569
  const testCaseEntities = {};
8409
8570
  const priorTestCaseEntities = priorEntities.testCases;
8410
8571
  let totalTestCases = 0;
8572
+ let unwrittenTestCases = 0;
8411
8573
  for (const [blockType, blocks] of [
8412
8574
  [
8413
8575
  "prompt",
@@ -8437,7 +8599,7 @@ export function registerConfigSyncCommands(sync) {
8437
8599
  })),
8438
8600
  ],
8439
8601
  ]) {
8440
- const { count } = await pullTestCasesForBlocks({
8602
+ const { count, unwritten } = await pullTestCasesForBlocks({
8441
8603
  client,
8442
8604
  appId: resolvedAppId,
8443
8605
  configDir,
@@ -8449,6 +8611,14 @@ export function registerConfigSyncCommands(sync) {
8449
8611
  logger: (message) => info(message),
8450
8612
  });
8451
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`);
8452
8622
  }
8453
8623
  // Build ruleSetId → name map for database types and group type configs
8454
8624
  const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
@@ -8966,6 +9136,9 @@ export function registerConfigSyncCommands(sync) {
8966
9136
  keyValue("Functions", pulledFunctionsCount);
8967
9137
  keyValue("Email Templates", emailTemplates.length);
8968
9138
  keyValue("Test Cases", totalTestCases);
9139
+ if (unwrittenTestCases > 0) {
9140
+ keyValue("Test Cases not written", unwrittenTestCases);
9141
+ }
8969
9142
  keyValue("Database Types", databaseTypesWithOps.length);
8970
9143
  keyValue("Rule Sets", ruleSets.length);
8971
9144
  keyValue("Group Type Configs", groupTypeConfigs.length);