primitive-admin 1.1.0-alpha.84 → 1.1.0-alpha.85

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 (95) hide show
  1. package/README.md +43 -1
  2. package/dist/src/commands/admins.js +29 -32
  3. package/dist/src/commands/admins.js.map +1 -1
  4. package/dist/src/commands/analytics.js +2 -1
  5. package/dist/src/commands/analytics.js.map +1 -1
  6. package/dist/src/commands/apps.js +12 -5
  7. package/dist/src/commands/apps.js.map +1 -1
  8. package/dist/src/commands/blob-buckets.js +2 -1
  9. package/dist/src/commands/blob-buckets.js.map +1 -1
  10. package/dist/src/commands/catalog.js +26 -10
  11. package/dist/src/commands/catalog.js.map +1 -1
  12. package/dist/src/commands/collection-type-configs.js +2 -1
  13. package/dist/src/commands/collection-type-configs.js.map +1 -1
  14. package/dist/src/commands/collections.js +27 -18
  15. package/dist/src/commands/collections.js.map +1 -1
  16. package/dist/src/commands/connections.js +8 -9
  17. package/dist/src/commands/connections.js.map +1 -1
  18. package/dist/src/commands/cron-triggers.js +2 -1
  19. package/dist/src/commands/cron-triggers.js.map +1 -1
  20. package/dist/src/commands/database-type-configs.js +3 -2
  21. package/dist/src/commands/database-type-configs.js.map +1 -1
  22. package/dist/src/commands/databases.js +16 -8
  23. package/dist/src/commands/databases.js.map +1 -1
  24. package/dist/src/commands/documents.js +23 -11
  25. package/dist/src/commands/documents.js.map +1 -1
  26. package/dist/src/commands/email-templates.js +2 -1
  27. package/dist/src/commands/email-templates.js.map +1 -1
  28. package/dist/src/commands/env.js +11 -1
  29. package/dist/src/commands/env.js.map +1 -1
  30. package/dist/src/commands/feature-flags.js +2 -1
  31. package/dist/src/commands/feature-flags.js.map +1 -1
  32. package/dist/src/commands/functions.js +2 -1
  33. package/dist/src/commands/functions.js.map +1 -1
  34. package/dist/src/commands/group-type-configs.js +2 -1
  35. package/dist/src/commands/group-type-configs.js.map +1 -1
  36. package/dist/src/commands/groups.js +29 -8
  37. package/dist/src/commands/groups.js.map +1 -1
  38. package/dist/src/commands/guides.js +17 -11
  39. package/dist/src/commands/guides.js.map +1 -1
  40. package/dist/src/commands/integrations.js +26 -9
  41. package/dist/src/commands/integrations.js.map +1 -1
  42. package/dist/src/commands/locks.js +2 -1
  43. package/dist/src/commands/locks.js.map +1 -1
  44. package/dist/src/commands/metadata-category-configs.js +2 -1
  45. package/dist/src/commands/metadata-category-configs.js.map +1 -1
  46. package/dist/src/commands/metadata.js +8 -1
  47. package/dist/src/commands/metadata.js.map +1 -1
  48. package/dist/src/commands/prompts.js +28 -11
  49. package/dist/src/commands/prompts.js.map +1 -1
  50. package/dist/src/commands/rule-sets.js +2 -1
  51. package/dist/src/commands/rule-sets.js.map +1 -1
  52. package/dist/src/commands/scripts.js +15 -6
  53. package/dist/src/commands/scripts.js.map +1 -1
  54. package/dist/src/commands/secrets.js +2 -1
  55. package/dist/src/commands/secrets.js.map +1 -1
  56. package/dist/src/commands/sessions.js +10 -9
  57. package/dist/src/commands/sessions.js.map +1 -1
  58. package/dist/src/commands/sync.d.ts +64 -0
  59. package/dist/src/commands/sync.js +347 -17
  60. package/dist/src/commands/sync.js.map +1 -1
  61. package/dist/src/commands/tokens.js +2 -1
  62. package/dist/src/commands/tokens.js.map +1 -1
  63. package/dist/src/commands/users.js +16 -9
  64. package/dist/src/commands/users.js.map +1 -1
  65. package/dist/src/commands/vars.js +2 -1
  66. package/dist/src/commands/vars.js.map +1 -1
  67. package/dist/src/commands/waitlist.js +2 -1
  68. package/dist/src/commands/waitlist.js.map +1 -1
  69. package/dist/src/commands/webhooks.js +17 -10
  70. package/dist/src/commands/webhooks.js.map +1 -1
  71. package/dist/src/commands/workflows.js +36 -18
  72. package/dist/src/commands/workflows.js.map +1 -1
  73. package/dist/src/lib/api-client.d.ts +83 -1
  74. package/dist/src/lib/api-client.js +139 -55
  75. package/dist/src/lib/api-client.js.map +1 -1
  76. package/dist/src/lib/config-object-descriptor.js +29 -7
  77. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  78. package/dist/src/lib/config-payload.js +27 -2
  79. package/dist/src/lib/config-payload.js.map +1 -1
  80. package/dist/src/lib/config-surface.d.ts +2 -1
  81. package/dist/src/lib/config-surface.js +50 -3
  82. package/dist/src/lib/config-surface.js.map +1 -1
  83. package/dist/src/lib/generated-config-surfaces.d.ts +182 -1
  84. package/dist/src/lib/generated-config-surfaces.js +441 -2
  85. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  86. package/dist/src/lib/list-options.d.ts +68 -0
  87. package/dist/src/lib/list-options.js +89 -0
  88. package/dist/src/lib/list-options.js.map +1 -0
  89. package/dist/src/lib/log-inspection.d.ts +16 -0
  90. package/dist/src/lib/log-inspection.js +23 -0
  91. package/dist/src/lib/log-inspection.js.map +1 -1
  92. package/dist/src/lib/paginate.d.ts +15 -0
  93. package/dist/src/lib/paginate.js +17 -0
  94. package/dist/src/lib/paginate.js.map +1 -1
  95. package/package.json +2 -2
@@ -32,9 +32,12 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
32
32
  // payload builder so the diff normalizer and the wire cannot disagree.
33
33
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
34
34
  // #2644 — the one definition per configuration object, vendored from the server.
35
- import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
35
+ import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
36
36
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
37
37
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
38
+ // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
39
+ // preflight refuses exactly what the admin routes refuse.
40
+ import { hoistDeprecatedChatKeys, promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
38
41
  // #3179 — the function's two halves: the header payloads and the envelope
39
42
  // `config push` sends, and the bytes `config pull` writes back.
40
43
  import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
@@ -713,6 +716,20 @@ export function validateVarsFile(parsedVars, options = {}) {
713
716
  * edit — reported as a conflict instead. `force` skips the conflict guard,
714
717
  * matching `config push --force` for every other entity.
715
718
  *
719
+ * A var only the SERVER moved is the other half of the same comparison, and it
720
+ * is reported rather than skipped (issue #3714). Its local value still equals
721
+ * the baseline, so there is nothing to push and no conflict — but the skip that
722
+ * follows from "local is unchanged" used to be taken before the snapshot was
723
+ * consulted at all, so the push said nothing about a var edited in the Admin
724
+ * Console and counted it among the skipped-unchanged. The `drifted[]` keys are
725
+ * what every other synced type reaches through `classifyPushChange` →
726
+ * `server-drifted`: the caller prints `DRIFT var: <key>` under "Server drift —
727
+ * not pushed", writes nothing, and leaves the exit code alone. A baseline key
728
+ * the snapshot no longer holds at all is the same one-sided move (the server
729
+ * deleted the row; the local file stands still) and is reported the same way.
730
+ * `--force` is the resolution that keeps the local value, `config pull` the one
731
+ * that takes the server's.
732
+ *
716
733
  * Pure and side-effect-free (the caller owns the API calls and sync-state
717
734
  * updates) so the guard is unit-testable without a live server. Assumes
718
735
  * `parsedVars` has already passed `validateVarsFile` (values are strings). The
@@ -726,6 +743,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
726
743
  const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
727
744
  const upserts = [];
728
745
  const conflicts = [];
746
+ const drifted = [];
729
747
  const deletions = [];
730
748
  let skippedCount = 0;
731
749
  const localKeys = new Set();
@@ -733,9 +751,32 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
733
751
  localKeys.add(key);
734
752
  const valueHash = hashVarValue(value);
735
753
  const existing = baseline?.[key];
736
- // Unchanged since last sync — skip (unless forced), mirroring the
737
- // content-hash skip the other file-backed entities use.
754
+ // Unchanged since last sync — nothing to push (unless forced), mirroring
755
+ // the content-hash skip the other file-backed entities use. But "the local
756
+ // file did not move" is only half the comparison: ask the snapshot whether
757
+ // the SERVER moved before calling the pair unchanged (issue #3714). One
758
+ // side having moved is drift, which is reported and not written; only a
759
+ // pair that still agrees is a skipped-unchanged file.
738
760
  if (!options.force && existing && existing.contentHash === valueHash) {
761
+ const remote = remoteByKey.get(key);
762
+ if (!remote) {
763
+ // The row is gone from an authoritative snapshot (the caller fails the
764
+ // push closed when the fetch fails, and the `--force` fallback that
765
+ // leaves it empty never reaches here): the server deleted what we last
766
+ // synced while the file stood still. `--force` recreates it from the
767
+ // local value; `config pull` drops the line.
768
+ drifted.push({ key, serverModifiedAt: "deleted" });
769
+ continue;
770
+ }
771
+ if (typeof remote.value === "string" &&
772
+ hashVarValue(remote.value) !== existing.contentHash) {
773
+ drifted.push({ key, serverModifiedAt: remote.updatedAt || "unknown" });
774
+ continue;
775
+ }
776
+ // Either both sides still hold the last-synced value, or the snapshot
777
+ // carries no readable value for the key and nothing establishes a move.
778
+ // Nothing is written on this path either way, so the only question is
779
+ // what to report, and an unprovable drift is not one to claim.
739
780
  skippedCount++;
740
781
  continue;
741
782
  }
@@ -823,7 +864,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
823
864
  }
824
865
  deletions.push(key);
825
866
  }
826
- return { upserts, deletions, conflicts, skippedCount };
867
+ return { upserts, deletions, conflicts, drifted, skippedCount };
827
868
  }
828
869
  /**
829
870
  * Compute the number of config vars the server would hold after a push plan
@@ -1771,6 +1812,13 @@ export function serializePrompt(prompt, logger) {
1771
1812
  topP: (record) => (record.topP != null ? Number(record.topP) : undefined),
1772
1813
  outputSchema: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1773
1814
  providerConfig: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1815
+ // #3626, CR3626-002 — the decisions declaration is a `json` field and
1816
+ // goes out the same way its siblings do. Written straight through, a
1817
+ // stored question carrying a `null` anywhere lost that member on pull
1818
+ // (smol-toml has no null), and a `null` inside a LIST threw, taking
1819
+ // `config pull` and `config diff` down with it. The JSON-string
1820
+ // fallback keeps the declaration whole either way.
1821
+ questions: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1774
1822
  }),
1775
1823
  // #2645 — the live config has to be identifiable in the repo. Emitted
1776
1824
  // only on the active entry (`undefined` elsewhere so the key is omitted),
@@ -1843,6 +1891,24 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
1843
1891
  // dropped and leaving a stale rule live. On create it is omitted so
1844
1892
  // the push-adopt path (#1006) can still reach the server's 409.
1845
1893
  accessRule: (value) => (create ? value : (value ?? null)),
1894
+ // #3626, CR3626-004 — the kind NORMALIZED, exactly as the resolver
1895
+ // that validates it reads it. `kind = " chat "` and `kind = ""` both
1896
+ // mean `chat` to `resolvePromptKind`, so the server stores `chat` and
1897
+ // the comparison — which projects `[prompt]` in create mode precisely
1898
+ // so a changed kind is visible (SO3626-008) — would otherwise read the
1899
+ // authored spelling against the stored one and call a freshly created
1900
+ // prompt an attempted kind change, refused as immutable on every later
1901
+ // push including `--force`. An UNKNOWN kind is passed through as
1902
+ // written: it is a refusal (preflight, then the create handler), and it
1903
+ // has to name what the file says.
1904
+ kind: (value) => {
1905
+ if (value === undefined || value === null)
1906
+ return "chat";
1907
+ if (typeof value !== "string")
1908
+ return value;
1909
+ const text = value.trim();
1910
+ return text === "" ? "chat" : text;
1911
+ },
1846
1912
  },
1847
1913
  }),
1848
1914
  };
@@ -1877,11 +1943,19 @@ export function buildPromptCreatePayload(key, tomlData) {
1877
1943
  for (const owned of ["configName", ...SEEDED_CONFIG_DEFERRED]) {
1878
1944
  delete seed[owned];
1879
1945
  }
1946
+ const prompt = buildPromptPushPayload(key, tomlData, { mode: "create" });
1947
+ // #3626, SO3626-001 — the placeholder is a CHAT fallback. A decisions prompt
1948
+ // has no template at all (its input is a `state` value plus its questions),
1949
+ // and injecting one would store a chat key the block rule then refuses on
1950
+ // every later update.
1951
+ if (promptKindOf(prompt) === "decisions") {
1952
+ return { ...prompt, ...seed };
1953
+ }
1880
1954
  return {
1881
- ...buildPromptPushPayload(key, tomlData, { mode: "create" }),
1955
+ ...prompt,
1882
1956
  ...seed,
1883
- // The server requires a template; keep the historical placeholder so a
1884
- // prompt authored without one still creates.
1957
+ // The server requires a template for a chat config; keep the historical
1958
+ // placeholder so a prompt authored without one still creates.
1885
1959
  userPromptTemplate: seed.userPromptTemplate || "{{ input }}",
1886
1960
  };
1887
1961
  }
@@ -1925,9 +1999,14 @@ export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "de
1925
1999
  const patch = {};
1926
2000
  for (const field of SEEDED_CONFIG_DEFERRED) {
1927
2001
  const value = full[field];
1928
- // A value the entry does not carry is left alone: the seeded config has
1929
- // none either, so there is nothing to clear.
1930
- if (value === undefined || value === null)
2002
+ // #3626 — an omitted value CLEARS. The create handler does not merely skip
2003
+ // `description`, it writes the hard-coded "Default configuration", so
2004
+ // leaving it alone left every newly-pushed prompt reporting `Modified`
2005
+ // forever against a description its file never asked for: push's own
2006
+ // "the file is the whole truth" rule (#1567), broken by the one field the
2007
+ // create body cannot carry. `outputSchema` is unaffected — create never
2008
+ // sets the seeded config's, so the null is a no-op there.
2009
+ if (value === undefined)
1931
2010
  continue;
1932
2011
  patch[field] = value;
1933
2012
  }
@@ -1958,7 +2037,15 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
1958
2037
  * differ from the one the server has always received.
1959
2038
  */
1960
2039
  const tomlOwned = (value) => (create ? value : (value ?? null));
1961
- return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
2040
+ // #3626 §4 — the nine chat keys moved under `[configs.chat]`, and the flat
2041
+ // spelling is accepted as DEPRECATED. Hoisting here rather than at each call
2042
+ // site means push, `config diff` and the create-seed builder all read a file
2043
+ // the same way, so an unmigrated file and its pulled rewrite compare Synced.
2044
+ // A key set BOTH ways is refused in preflight
2045
+ // (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
2046
+ // standing so nothing here has to guess.
2047
+ const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
2048
+ return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
1962
2049
  mode,
1963
2050
  values: {
1964
2051
  provider: (value) => (create ? value || "openrouter" : value),
@@ -1978,6 +2065,10 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
1978
2065
  // the merged config refused as "both set".
1979
2066
  reasoningEffort: tomlOwned,
1980
2067
  reasoningBudget: tomlOwned,
2068
+ // #3626, SO3626-002 — the decisions declaration is TOML-owned on the
2069
+ // same terms: deleting `[configs.decisions]` has to clear `questions`
2070
+ // server-side, or the file stops describing what the model is asked.
2071
+ questions: tomlOwned,
1981
2072
  },
1982
2073
  });
1983
2074
  }
@@ -2053,11 +2144,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2053
2144
  : []
2054
2145
  : [at];
2055
2146
  const unknown = new Set();
2147
+ // #3626 — a key inside a declared `[configs.<group>]` block that no field
2148
+ // declares under it, and a group value that is not a table at all. Reported
2149
+ // with the group in the path (`configs.chat.nope`) so the author is told
2150
+ // where the CLI looked.
2151
+ const groupedUnknown = new Set();
2056
2152
  for (const entry of entries) {
2057
2153
  for (const key of findUnknownTomlKeys(entry, table))
2058
2154
  unknown.add(key);
2155
+ for (const key of findUnknownGroupedTomlKeys(entry, table)) {
2156
+ groupedUnknown.add(key);
2157
+ }
2059
2158
  }
2060
- if (unknown.size === 0)
2159
+ if (unknown.size === 0 && groupedUnknown.size === 0)
2061
2160
  return null;
2062
2161
  // #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
2063
2162
  // once beside the definitions (`src/config-surface/retired-keys.ts`), not the
@@ -2074,8 +2173,32 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2074
2173
  for (const key of retiredHere) {
2075
2174
  messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
2076
2175
  }
2077
- if (trulyUnknown.length > 0) {
2078
- messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, trulyUnknown));
2176
+ // #3626, SO3626-013 — a key the definition DOES declare, written at the
2177
+ // table root when it is authored under a group. Before grouping,
2178
+ // `acceptedTomlKeys` took every field key at the root, so a flat `questions`
2179
+ // passed this check and was then dropped in silence by the payload builder.
2180
+ // It is an unknown key now, and the message says where it belongs rather
2181
+ // than sending the author to the generic "check the spelling" hint.
2182
+ const misplaced = trulyUnknown
2183
+ .map((key) => ({
2184
+ key,
2185
+ group: table.fields.find((f) => f.tomlKey === key && f.tomlGroup)
2186
+ ?.tomlGroup,
2187
+ }))
2188
+ .filter((entry) => !!entry.group);
2189
+ for (const { key, group } of misplaced) {
2190
+ messages.push(`${filePath}: ${prefix}[0].${key} is not a [[${prefix}]] key; ` +
2191
+ `write it under [${prefix}.${group}]`);
2192
+ }
2193
+ const misplacedKeys = new Set(misplaced.map((m) => m.key));
2194
+ const stillUnknown = trulyUnknown.filter((k) => !misplacedKeys.has(k));
2195
+ if (stillUnknown.length > 0) {
2196
+ messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, stillUnknown));
2197
+ }
2198
+ if (groupedUnknown.size > 0) {
2199
+ messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, [
2200
+ ...groupedUnknown,
2201
+ ].sort()));
2079
2202
  }
2080
2203
  return messages.join("\n");
2081
2204
  }
@@ -2507,6 +2630,28 @@ export async function runConfigPushPreflight(input) {
2507
2630
  preflightValidationErrors.push(` ${message}`);
2508
2631
  }
2509
2632
  }
2633
+ // #3626 §4 — one advisory line per prompt file still written in the flat
2634
+ // chat spelling, beside the `celContextAccess` notice above. The push
2635
+ // continues and the keys are applied; `config pull` rewrites the file, so a
2636
+ // single pull-then-push clears the notice. Printed even under `--dry-run`,
2637
+ // which never reaches the apply — the notice is about the FILE.
2638
+ const preflightPromptsDir = join(configDir, "prompts");
2639
+ if (existsSync(preflightPromptsDir)) {
2640
+ for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2641
+ const filePath = join(preflightPromptsDir, file);
2642
+ let tomlData;
2643
+ try {
2644
+ tomlData = parseTomlFile(filePath);
2645
+ }
2646
+ catch {
2647
+ // A file that does not parse is reported by the collector above.
2648
+ continue;
2649
+ }
2650
+ const notice = promptFlatChatKeyWarning(filePath, tomlData);
2651
+ if (notice)
2652
+ warn(` ${notice}`);
2653
+ }
2654
+ }
2510
2655
  // The `<key>.tests/*.toml` sidecars, which sit beside the block they
2511
2656
  // test rather than in a type directory of their own. Same posture,
2512
2657
  // reached through the registry's `test-case` surface — and through the
@@ -2546,6 +2691,127 @@ export async function runConfigPushPreflight(input) {
2546
2691
  * whose spelling is not the declared type. A file that does not parse is left
2547
2692
  * to the per-type loop that reads it, which names the parse error itself.
2548
2693
  */
2694
+ /**
2695
+ * A prompt file's KIND errors — #3626, decidable from the file alone.
2696
+ *
2697
+ * Three refusals, all of them the same rule seen from different angles: a key
2698
+ * set both flat and under `[configs.chat]` (the hoist will not guess), a
2699
+ * `[[configs]]` entry carrying the block of the OTHER kind, and a malformed
2700
+ * decisions declaration. The last two come from `resolvePromptKind`, the same
2701
+ * vendored function the admin routes validate with, so the CLI's refusal and
2702
+ * the server's cannot disagree — and this one lands in the preflight, before
2703
+ * the first mutating call.
2704
+ */
2705
+ export function promptKindFileErrors(filePath, tomlData) {
2706
+ const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
2707
+ const errors = [];
2708
+ for (const [index, entry] of entries.entries()) {
2709
+ const hoisted = hoistDeprecatedChatKeys(entry);
2710
+ for (const key of hoisted.conflicts) {
2711
+ errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
2712
+ `[configs.chat]; keep the [configs.chat] spelling`);
2713
+ }
2714
+ const chat = hoisted.entry.chat ?? {};
2715
+ const decisions = hoisted.entry.decisions ?? {};
2716
+ // #3626 — the FIRST entry of a chat prompt carries the create seed's
2717
+ // placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
2718
+ // file names no template, which is how a prompt authored without one has
2719
+ // always created; refusing it here would break a file that pushed before
2720
+ // the kind existed. An entry that really names none on the UPDATE path is
2721
+ // still refused, by the server, with this same message.
2722
+ //
2723
+ // CR3626-005 — the fallback is `||`, not `??`, because that is what
2724
+ // `buildPromptCreatePayload` applies (`seed.userPromptTemplate ||
2725
+ // "{{ input }}"`). `config create prompt` scaffolds an EMPTY
2726
+ // `userPromptTemplate`, so reading absence more narrowly than the builder
2727
+ // does refused the file this CLI had just written, for a value the create
2728
+ // would have replaced with the placeholder anyway.
2729
+ const seedsTemplate = index === 0 && promptKindOf(tomlData?.prompt) !== "decisions";
2730
+ const userPromptTemplate = seedsTemplate
2731
+ ? chat.userPromptTemplate || "{{ input }}"
2732
+ : chat.userPromptTemplate;
2733
+ const resolution = resolvePromptKind({
2734
+ kind: tomlData?.prompt?.kind,
2735
+ provider: hoisted.entry.provider,
2736
+ model: hoisted.entry.model,
2737
+ config: {
2738
+ systemPrompt: chat.systemPrompt,
2739
+ userPromptTemplate,
2740
+ temperature: chat.temperature,
2741
+ topP: chat.topP,
2742
+ maxTokens: chat.maxTokens,
2743
+ outputFormat: chat.outputFormat,
2744
+ outputSchema: chat.outputSchema,
2745
+ reasoningEffort: chat.reasoningEffort,
2746
+ reasoningBudget: chat.reasoningBudget,
2747
+ questions: decisions.questions,
2748
+ },
2749
+ });
2750
+ if (resolution.ok === false) {
2751
+ errors.push(`${filePath}: configs[${index}] — ${resolution.error}`);
2752
+ }
2753
+ }
2754
+ return errors;
2755
+ }
2756
+ /**
2757
+ * The immutable-`kind` refusal for a prompt update the comparison gate did not
2758
+ * decide — #3626, CR3626-001.
2759
+ *
2760
+ * `kind` is create-only (D3626-008), and the gate refuses a change to it on
2761
+ * every path that compares the two sides. Two update paths do not compare: a
2762
+ * prompt whose DETAIL read failed falls back to the manifest byte-hash gate,
2763
+ * which `--force` writes straight through, and a prompt ADOPTED after a
2764
+ * create's 409 is re-issued as an update without a comparison at all. Both
2765
+ * would otherwise PATCH the file's schemas, access rule and metadata onto a
2766
+ * row of the other kind, and only then have its configs refused against the
2767
+ * kind the update cannot change.
2768
+ *
2769
+ * Returns the gate's own outcome shape, so `recordDeclined` prints the same
2770
+ * two values and the same remedy — create the prompt under a new key.
2771
+ *
2772
+ * `null` when the kinds agree and when the server's kind is simply not KNOWN:
2773
+ * a push that could not read live state at all has nothing to compare, and
2774
+ * that path's contract (`decideDegradedPush`, #2880) is already "decline
2775
+ * unless the operator forces it", stated for every field at once.
2776
+ */
2777
+ export function promptKindImmutableOutcome(storedKind, localDoc) {
2778
+ if (!storedKind)
2779
+ return null;
2780
+ const localKind = promptKindOf(localDoc?.prompt);
2781
+ if (localKind === storedKind)
2782
+ return null;
2783
+ return {
2784
+ action: "immutable",
2785
+ // The hashes are the gate's evidence for WHICH side moved, and this
2786
+ // refusal does not depend on that: the field cannot be written either way.
2787
+ localHash: "",
2788
+ remoteHash: "",
2789
+ fields: [{ field: "kind", local: localKind, server: storedKind }],
2790
+ immutableFields: ["kind"],
2791
+ };
2792
+ }
2793
+ /**
2794
+ * The one deprecation notice a prompt file using the FLAT chat spelling earns
2795
+ * — #3626 §4, beside the `celContextAccess` notice's precedent.
2796
+ *
2797
+ * Advisory: the push continues and the keys are applied. `null` when the file
2798
+ * is already written in the grouped spelling, so a migrated tree is silent.
2799
+ */
2800
+ export function promptFlatChatKeyWarning(filePath, tomlData) {
2801
+ const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
2802
+ const moved = new Set();
2803
+ for (const entry of entries) {
2804
+ for (const key of hoistDeprecatedChatKeys(entry).moved)
2805
+ moved.add(key);
2806
+ }
2807
+ if (moved.size === 0)
2808
+ return null;
2809
+ const named = [...moved].sort().join(", ");
2810
+ return (`${filePath}: ${named} ${moved.size === 1 ? "is" : "are"} written flat ` +
2811
+ "under [[configs]]; this spelling is deprecated — move them under " +
2812
+ "[configs.chat] (`config pull` rewrites the file). Rejecting the flat " +
2813
+ "spelling is tracked in #3642.");
2814
+ }
2549
2815
  export function collectConfigFileValidationErrors(configDir) {
2550
2816
  const found = [];
2551
2817
  for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
@@ -2581,6 +2847,12 @@ export function collectConfigFileValidationErrors(configDir) {
2581
2847
  for (const table of surface.tables) {
2582
2848
  messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
2583
2849
  }
2850
+ // #3626 — the prompt kind's own file-decidable refusals, run by the same
2851
+ // collector so `config push` and `config diff` cannot disagree about
2852
+ // which prompt files are pushable.
2853
+ if (surface.label === "prompt") {
2854
+ messages.push(...promptKindFileErrors(filePath, tomlData));
2855
+ }
2584
2856
  if (messages.length === 0)
2585
2857
  continue;
2586
2858
  found.push({
@@ -4055,7 +4327,17 @@ const CONFIG_DIFF_SPECS = {
4055
4327
  // (`[prompt].key`) is still read here, because it is what `displayName`
4056
4328
  // falls back to. Diff injects the resolved key into a file that omits it,
4057
4329
  // exactly as push derives it from the file name.
4058
- parse: (doc) => buildPromptPushPayload(doc?.prompt?.key ?? "", doc),
4330
+ //
4331
+ // #3626, SO3626-008 — projected in CREATE mode. `kind` is `writableOn:
4332
+ // ["create"]`, and `buildPayloadFromToml` drops a field not writable in the
4333
+ // mode, so an update-mode projection would leave a CHANGED kind out of the
4334
+ // compared surface entirely: `findImmutableFieldDiffs` would never see it,
4335
+ // diff would call the file Synced, and push would start applying the
4336
+ // prompt's other changes before the server refused a config against the
4337
+ // unchanged kind. The blob-bucket comparator projects in create mode for
4338
+ // exactly this reason (`ttlTier`, below). PATCH payloads still omit `kind`
4339
+ // — this is the comparison's projection, not push's.
4340
+ parse: (doc) => buildPromptPushPayload(doc?.prompt?.key ?? "", doc, { mode: "create" }),
4059
4341
  serialize: (record) => serializePrompt(record),
4060
4342
  // Read from the DOCUMENT, not the parsed entity: `[[configs]]` is a sibling
4061
4343
  // table of `[prompt]`, and the prompt's own push payload does not carry it
@@ -8908,6 +9190,9 @@ What a push guarantees (two stages):
8908
9190
  upserts: varPlan.upserts.filter((u) => wanted.has(u.key)),
8909
9191
  deletions: varPlan.deletions.filter((k) => wanted.has(k)),
8910
9192
  conflicts: varPlan.conflicts.filter((c) => wanted.has(c.key)),
9193
+ // A drifted var nobody selected is not this push's business to
9194
+ // report, the same way its writes are not this push's to make.
9195
+ drifted: varPlan.drifted.filter((d) => wanted.has(d.key)),
8911
9196
  skippedCount: 0,
8912
9197
  };
8913
9198
  }
@@ -9200,6 +9485,13 @@ What a push guarantees (two stages):
9200
9485
  localModifiedAt: c.localModifiedAt,
9201
9486
  });
9202
9487
  }
9488
+ // …and var drift into the shared drift accumulator (#3714), so a var
9489
+ // only the server moved prints the `DRIFT var: KEY` row every other
9490
+ // type prints for that shape. It is not a failure: nothing was
9491
+ // written and neither the exit code nor the pushed count moves.
9492
+ for (const d of varPlan.drifted) {
9493
+ drifted.push({ type: "var", key: d.key });
9494
+ }
9203
9495
  // Delete removed keys BEFORE upserting new ones (issue #1423 review
9204
9496
  // pass 3). At the server's 100-var cap, a replacement (remove one key,
9205
9497
  // add another) is a valid final state, but upserting the new key first
@@ -10502,13 +10794,26 @@ What a push guarantees (two stages):
10502
10794
  // manifest byte-hash gate with the type named, like every other
10503
10795
  // converted type.
10504
10796
  const livePromptIds = new Map();
10797
+ // #3626, CR3626-001 — the KIND the server holds for each prompt,
10798
+ // taken from the same list. `kind` is immutable after create
10799
+ // (D3626-008), and the comparison gate refuses a change to it — but
10800
+ // only on the paths that reach that gate. This map is what the other
10801
+ // two read: a prompt whose DETAIL read failed (degraded to the
10802
+ // byte-hash gate, which `--force` writes through) and a prompt
10803
+ // adopted by key after a 409 (which re-issues the file as an UPDATE
10804
+ // without ever comparing). Both would otherwise PATCH the prompt's
10805
+ // schemas, access rule and metadata onto a row of the other kind
10806
+ // before a config was refused against the kind that did not change.
10807
+ const livePromptKinds = new Map();
10505
10808
  let promptsLiveOk = true;
10506
10809
  if (files.length > 0) {
10507
10810
  try {
10508
10811
  const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
10509
10812
  for (const p of listed) {
10510
- if (p?.promptKey)
10511
- livePromptIds.set(p.promptKey, p.promptId);
10813
+ if (!p?.promptKey)
10814
+ continue;
10815
+ livePromptIds.set(p.promptKey, p.promptId);
10816
+ livePromptKinds.set(p.promptKey, promptKindOf(p));
10512
10817
  }
10513
10818
  }
10514
10819
  catch (err) {
@@ -10831,6 +11136,18 @@ What a push guarantees (two stages):
10831
11136
  }
10832
11137
  };
10833
11138
  if (existingId) {
11139
+ // #3626, CR3626-001 — the kind, before anything is written. The
11140
+ // comparison gate above already refuses a changed `kind`, but it
11141
+ // only ran when the DETAIL read succeeded; a prompt degraded to
11142
+ // the byte-hash gate reaches this line with `--force` and no
11143
+ // comparison at all. The list's kind is enough to answer it, and
11144
+ // answering it here keeps the promise the gate makes: a kind
11145
+ // change is declined before the first mutating call.
11146
+ const degradedKindDecline = promptKindImmutableOutcome(livePrompt ? promptKindOf(livePrompt) : livePromptKinds.get(key), tomlData);
11147
+ if (degradedKindDecline) {
11148
+ recordDeclined("prompt", key, degradedKindDecline, existingEntry?.modifiedAt);
11149
+ continue;
11150
+ }
10834
11151
  // Update existing prompt
10835
11152
  changes.push({ type: "prompt", action: "update", key });
10836
11153
  promptKeyToId.set(key, existingId);
@@ -10923,6 +11240,19 @@ What a push guarantees (two stages):
10923
11240
  });
10924
11241
  adopted = true;
10925
11242
  const adoptedId = existing.promptId;
11243
+ // #3626, CR3626-001 — the adopted prompt's KIND, before the
11244
+ // re-issue writes anything. This path never reaches the
11245
+ // comparison gate (the create is what failed), so without
11246
+ // this an orphaned chat prompt would take a decisions file's
11247
+ // schemas, access rule and metadata by PATCH and only then
11248
+ // refuse its configs against the kind the update cannot
11249
+ // change. The lookup already read the record, so the answer
11250
+ // costs nothing.
11251
+ const adoptKindDecline = promptKindImmutableOutcome(promptKindOf(existing), tomlData);
11252
+ if (adoptKindDecline) {
11253
+ recordDeclined("prompt", key, adoptKindDecline);
11254
+ continue;
11255
+ }
10926
11256
  // Seed the sync-state entity record so the shared update body
10927
11257
  // can stamp modifiedAt/contentHash into it (sync state has no
10928
11258
  // entry for an orphaned prompt), and track the key→ID map.