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

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 (98) hide show
  1. package/README.md +34 -0
  2. package/assets/skill/skills/primitive-platform/SKILL.md +37 -1
  3. package/dist/src/commands/analytics.js +17 -5
  4. package/dist/src/commands/analytics.js.map +1 -1
  5. package/dist/src/commands/auth-sessions.d.ts +7 -0
  6. package/dist/src/commands/auth-sessions.js +144 -0
  7. package/dist/src/commands/auth-sessions.js.map +1 -0
  8. package/dist/src/commands/auth.js +47 -8
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/collections.js +648 -1
  11. package/dist/src/commands/collections.js.map +1 -1
  12. package/dist/src/commands/database-type-configs.js +1 -1
  13. package/dist/src/commands/database-type-configs.js.map +1 -1
  14. package/dist/src/commands/databases.js +11 -2
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/documents.d.ts +20 -0
  17. package/dist/src/commands/documents.js +213 -17
  18. package/dist/src/commands/documents.js.map +1 -1
  19. package/dist/src/commands/functions.js +56 -5
  20. package/dist/src/commands/functions.js.map +1 -1
  21. package/dist/src/commands/integrations.js +4 -0
  22. package/dist/src/commands/integrations.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +83 -41
  24. package/dist/src/commands/sync.js +553 -81
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/commands/users.js +3 -1
  27. package/dist/src/commands/users.js.map +1 -1
  28. package/dist/src/lib/api-client.d.ts +111 -7
  29. package/dist/src/lib/api-client.js +92 -20
  30. package/dist/src/lib/api-client.js.map +1 -1
  31. package/dist/src/lib/app-settings-descriptor.d.ts +1 -1
  32. package/dist/src/lib/app-settings-descriptor.js +0 -1
  33. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  34. package/dist/src/lib/collection-export.d.ts +184 -0
  35. package/dist/src/lib/collection-export.js +252 -0
  36. package/dist/src/lib/collection-export.js.map +1 -0
  37. package/dist/src/lib/config-object-descriptor.js +17 -3
  38. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  39. package/dist/src/lib/config-payload.d.ts +8 -1
  40. package/dist/src/lib/config-payload.js +29 -9
  41. package/dist/src/lib/config-payload.js.map +1 -1
  42. package/dist/src/lib/config-surface.js +11 -7
  43. package/dist/src/lib/config-surface.js.map +1 -1
  44. package/dist/src/lib/deprecation.d.ts +22 -0
  45. package/dist/src/lib/deprecation.js +43 -0
  46. package/dist/src/lib/deprecation.js.map +1 -0
  47. package/dist/src/lib/env-resolver-core.js +10 -3
  48. package/dist/src/lib/env-resolver-core.js.map +1 -1
  49. package/dist/src/lib/function-db-types.d.ts +14 -3
  50. package/dist/src/lib/function-db-types.js +55 -11
  51. package/dist/src/lib/function-db-types.js.map +1 -1
  52. package/dist/src/lib/function-document-types.d.ts +13 -2
  53. package/dist/src/lib/function-document-types.js +79 -30
  54. package/dist/src/lib/function-document-types.js.map +1 -1
  55. package/dist/src/lib/function-trigger-listing.d.ts +27 -0
  56. package/dist/src/lib/function-trigger-listing.js +70 -0
  57. package/dist/src/lib/function-trigger-listing.js.map +1 -0
  58. package/dist/src/lib/generated-config-surfaces.d.ts +540 -10
  59. package/dist/src/lib/generated-config-surfaces.js +1915 -173
  60. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  61. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  62. package/dist/src/lib/generated-sdk-types.js +1 -1
  63. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  64. package/dist/src/lib/log-inspection.d.ts +18 -1
  65. package/dist/src/lib/log-inspection.js +5 -0
  66. package/dist/src/lib/log-inspection.js.map +1 -1
  67. package/dist/src/lib/logout-admin-session.d.ts +33 -0
  68. package/dist/src/lib/logout-admin-session.js +70 -0
  69. package/dist/src/lib/logout-admin-session.js.map +1 -0
  70. package/dist/src/lib/prompt-cost-format.d.ts +11 -0
  71. package/dist/src/lib/prompt-cost-format.js +41 -0
  72. package/dist/src/lib/prompt-cost-format.js.map +1 -0
  73. package/dist/src/lib/prompt-schema-codegen.d.ts +53 -0
  74. package/dist/src/lib/prompt-schema-codegen.js +253 -3
  75. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  76. package/dist/src/lib/refresh-admin-credentials.d.ts +9 -1
  77. package/dist/src/lib/refresh-admin-credentials.js +22 -2
  78. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  79. package/dist/src/lib/storage-pending-retry.d.ts +26 -0
  80. package/dist/src/lib/storage-pending-retry.js +42 -0
  81. package/dist/src/lib/storage-pending-retry.js.map +1 -0
  82. package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
  83. package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
  84. package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
  85. package/dist/src/lib/swift-codegen/banners.d.ts +6 -0
  86. package/dist/src/lib/swift-codegen/banners.js +6 -0
  87. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  88. package/dist/src/lib/swift-codegen/dbGenerator.js +11 -3
  89. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  90. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +7 -0
  91. package/dist/src/lib/swift-codegen/functionGenerator.js +41 -3
  92. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  93. package/dist/src/lib/swift-codegen/generator.js +8 -2
  94. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  95. package/dist/src/lib/workflow-usage.d.ts +11 -13
  96. package/dist/src/lib/workflow-usage.js +12 -14
  97. package/dist/src/lib/workflow-usage.js.map +1 -1
  98. package/package.json +2 -2
@@ -3,6 +3,7 @@ import { join, basename } from "path";
3
3
  import { createHash } from "crypto";
4
4
  import { lookup as mimeLookup } from "mime-types";
5
5
  import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
6
+ import { retryOnStoragePending } from "../lib/storage-pending-retry.js";
6
7
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
7
8
  import { canEmitNative } from "../lib/toml-native-form.js";
8
9
  // #1446's `type: "json"` emission rule, shared with `serializeFunction` since
@@ -34,10 +35,18 @@ import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
34
35
  // #2644 — the one definition per configuration object, vendored from the server.
35
36
  import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
36
37
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
37
- import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
38
+ import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
38
39
  // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
39
40
  // preflight refuses exactly what the admin routes refuse.
40
41
  import { hoistDeprecatedChatKeys, promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
42
+ // #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
43
+ import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
44
+ // #3799 — the meaning-preserving schema translation, vendored the same way, so
45
+ // the preflight refuses exactly the schemas the admin routes refuse.
46
+ import { agentSchemaTranslationRefusals } from "../lib/generated-config-surfaces.js";
47
+ // #3801 — the strict-output rule, vendored the same way, so the preflight
48
+ // refuses exactly the strict configs the admin routes refuse.
49
+ import { resolvePromptStrictOutput } from "../lib/generated-config-surfaces.js";
41
50
  // #3179 — the function's two halves: the header payloads and the envelope
42
51
  // `config push` sends, and the bytes `config pull` writes back.
43
52
  import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
@@ -1668,22 +1677,20 @@ export function buildBlobBucketUpdatePayload(bucket) {
1668
1677
  */
1669
1678
  const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "status");
1670
1679
  /**
1671
- * The status a freshly CREATED prompt config still owes the file, or null
1672
- * (#3201).
1680
+ * #3201's post-create status patch is RETIRED — #3799 (DSO-3799-005).
1673
1681
  *
1674
- * `status` is update-only — `POST /prompts` and `POST /prompts/:id/configs`
1675
- * both assign `active` whatever the body says — so an authored
1676
- * `status = "archived"` was silently dropped on every create path: a pulled
1677
- * tree pushed from a clean clone into a fresh environment brought every
1678
- * retired config back into service. Read through the push payload builder, so
1679
- * a file that omits the key asks for the default and needs no request.
1682
+ * `status` was update-only, so `POST /prompts` and `POST /prompts/:id/configs`
1683
+ * both assigned `active` whatever the body said, and an authored
1684
+ * `status = "archived"` needed a follow-up PATCH ordered after the activation of
1685
+ * the entry the file marks live. Both create paths honor the key now
1686
+ * (`writableOn: BOTH`, validated by one rule on all three paths), so the entry
1687
+ * lands retired on its own create: the same end state, two fewer calls per
1688
+ * retired config, and no window in which the server holds a status the file
1689
+ * never asked for. That window is what made a pulled tree unpushable into a
1690
+ * fresh environment once an agent's schemas are checked against each
1691
+ * non-archived config's provider — the check fired on the transient active
1692
+ * state.
1680
1693
  */
1681
- export function createdConfigStatusPatch(tomlConfig) {
1682
- const { status } = buildPromptConfigPayload(tomlConfig, { mode: "update" });
1683
- return typeof status === "string" && status !== PROMPT_CONFIG_DEFAULT_STATUS
1684
- ? { status }
1685
- : null;
1686
- }
1687
1694
  /**
1688
1695
  * Which `[[configs]]` entry a prompt file marks as live (issue #2645).
1689
1696
  *
@@ -1791,6 +1798,11 @@ export function serializePrompt(prompt, logger) {
1791
1798
  ...projectRecordToToml(PROMPT_TABLE, prompt, {
1792
1799
  inputSchema: jsonField(promptSubject),
1793
1800
  outputSchema: jsonField(promptSubject),
1801
+ // #3798 — the agent declaration goes out as `[prompt.agent]` tables
1802
+ // (JSON-string fallback for a `null` TOML cannot spell), exactly as
1803
+ // stored: the server keeps it as authored, so what pull writes is
1804
+ // what the author pushed.
1805
+ agent: jsonField(promptSubject),
1794
1806
  }),
1795
1807
  },
1796
1808
  configs: (prompt.configs || []).map((config) => ({
@@ -1819,7 +1831,10 @@ export function serializePrompt(prompt, logger) {
1819
1831
  // `config pull` and `config diff` down with it. The JSON-string
1820
1832
  // fallback keeps the declaration whole either way.
1821
1833
  questions: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1822
- }),
1834
+ },
1835
+ // #3798 — the model settings chat and agent share are written under the
1836
+ // block the prompt's kind names: `[configs.agent]` for an agent.
1837
+ { tomlGroup: promptKindOf(prompt) }),
1823
1838
  // #2645 — the live config has to be identifiable in the repo. Emitted
1824
1839
  // only on the active entry (`undefined` elsewhere so the key is omitted),
1825
1840
  // which keeps the marker unambiguous by construction.
@@ -1891,6 +1906,16 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
1891
1906
  // dropped and leaving a stale rule live. On create it is omitted so
1892
1907
  // the push-adopt path (#1006) can still reach the server's 409.
1893
1908
  accessRule: (value) => (create ? value : (value ?? null)),
1909
+ // #3798 — the agent declaration, as authored: the server stores it
1910
+ // unchanged, so the value sent is the value pull writes back. On
1911
+ // update a deleted block CLEARS it — for an agent only: a chat or
1912
+ // decisions file carries no block to delete, and omitting the key
1913
+ // keeps its body what it was before this field existed.
1914
+ agent: (value) => create || value !== undefined
1915
+ ? value
1916
+ : promptKindOf(prompt) === "agent"
1917
+ ? null
1918
+ : undefined,
1894
1919
  // #3626, CR3626-004 — the kind NORMALIZED, exactly as the resolver
1895
1920
  // that validates it reads it. `kind = " chat "` and `kind = ""` both
1896
1921
  // mean `chat` to `resolvePromptKind`, so the server stores `chat` and
@@ -1914,6 +1939,10 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
1914
1939
  };
1915
1940
  if (create)
1916
1941
  payload.promptKey = key;
1942
+ // #3798 — a prompt that declares no agent block sends no `agent` key at all,
1943
+ // so a chat or decisions body is byte for byte what it was.
1944
+ if (payload.agent === undefined)
1945
+ delete payload.agent;
1917
1946
  return payload;
1918
1947
  }
1919
1948
  /**
@@ -1947,9 +1976,15 @@ export function buildPromptCreatePayload(key, tomlData) {
1947
1976
  // #3626, SO3626-001 — the placeholder is a CHAT fallback. A decisions prompt
1948
1977
  // has no template at all (its input is a `state` value plus its questions),
1949
1978
  // 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 };
1979
+ // every later update. #3798 — nor has an agent: the model sees each message
1980
+ // as sent.
1981
+ if (promptKindOf(prompt) !== "chat") {
1982
+ const body = { ...prompt, ...seed };
1983
+ for (const field of Object.keys(body)) {
1984
+ if (body[field] === undefined)
1985
+ delete body[field];
1986
+ }
1987
+ return body;
1953
1988
  }
1954
1989
  return {
1955
1990
  ...prompt,
@@ -1987,12 +2022,11 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
1987
2022
  * response rather than assumed, so a handler that one day honors the name
1988
2023
  * produces no needless PATCH.
1989
2024
  *
1990
- * #3201 — the entry's `status` is deferred the OTHER way and is deliberately
1991
- * not here. The create points the prompt's `activeConfigId` at this seeded
1992
- * config, so retiring it in this early patch would archive the config the
1993
- * prompt is serving from and leave it there until the marked entry is created
1994
- * and activated — a window a failed create or activation freezes. It is
1995
- * applied after activation instead (`createdConfigStatusPatch`).
2025
+ * #3201 — the entry's `status` was deferred the OTHER way, to a patch after the
2026
+ * activation, because the create pointed the prompt's `activeConfigId` at a
2027
+ * config it had forced live. #3799 (DSO-3799-005) — the create carries the
2028
+ * entry's `status` itself now, so there is no deferral either way and this patch
2029
+ * is the two fields `POST /prompts` cannot express, nothing more.
1996
2030
  */
1997
2031
  export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
1998
2032
  const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
@@ -2049,6 +2083,12 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
2049
2083
  mode,
2050
2084
  values: {
2051
2085
  provider: (value) => (create ? value || "openrouter" : value),
2086
+ // #3799 (DSO-3799-005) — carried on CREATE when the entry names one, and
2087
+ // omitted otherwise, so the create body for a file that says nothing
2088
+ // about status is what the server has always received (principle 5). On
2089
+ // update it still sends the declared default for an absent key, which is
2090
+ // what lets a deleted line converge (#2880 DSO-002).
2091
+ status: (value) => create ? value : (value ?? PROMPT_CONFIG_DEFAULT_STATUS),
2052
2092
  model: (value) => create ? value || "google/gemini-2.0-flash-001" : value,
2053
2093
  description: tomlOwned,
2054
2094
  systemPrompt: tomlOwned,
@@ -2065,6 +2105,9 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
2065
2105
  // the merged config refused as "both set".
2066
2106
  reasoningEffort: tomlOwned,
2067
2107
  reasoningBudget: tomlOwned,
2108
+ // #3801 — deleting `strictOutput = true` has to turn strict output off
2109
+ // server-side, or the file stops describing the request that is sent.
2110
+ strictOutput: tomlOwned,
2068
2111
  // #3626, SO3626-002 — the decisions declaration is TOML-owned on the
2069
2112
  // same terms: deleting `[configs.decisions]` has to clear `questions`
2070
2113
  // server-side, or the file stops describing what the model is asked.
@@ -2182,13 +2225,14 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2182
2225
  const misplaced = trulyUnknown
2183
2226
  .map((key) => ({
2184
2227
  key,
2185
- group: table.fields.find((f) => f.tomlKey === key && f.tomlGroup)
2186
- ?.tomlGroup,
2228
+ groups: tomlGroupsOf(table.fields.find((f) => f.tomlKey === key && f.tomlGroup) ??
2229
+ {}),
2187
2230
  }))
2188
- .filter((entry) => !!entry.group);
2189
- for (const { key, group } of misplaced) {
2231
+ .filter((entry) => entry.groups.length > 0);
2232
+ for (const { key, groups } of misplaced) {
2233
+ // #3798 — a key two blocks share names both; the prompt's kind picks one.
2190
2234
  messages.push(`${filePath}: ${prefix}[0].${key} is not a [[${prefix}]] key; ` +
2191
- `write it under [${prefix}.${group}]`);
2235
+ `write it under ${groups.map((g) => `[${prefix}.${g}]`).join(" or ")}`);
2192
2236
  }
2193
2237
  const misplacedKeys = new Set(misplaced.map((m) => m.key));
2194
2238
  const stillUnknown = trulyUnknown.filter((k) => !misplacedKeys.has(k));
@@ -2568,6 +2612,15 @@ export async function runConfigPushPreflight(input) {
2568
2612
  knownTargets =
2569
2613
  treeTargets.integrations.size > 0 ? treeTargets : null;
2570
2614
  }
2615
+ // #3719 — the project's document schema rides every function version,
2616
+ // so a unique constraint on a stringset field in it refuses the push
2617
+ // here, once, rather than deploying with seeding silently off. Bounded
2618
+ // by the selection like the rest of this block: only a push that
2619
+ // carries a function carries the schema.
2620
+ const documentSchemaError = documentSchemaPreflightError(resolveDocumentSchema(configDir));
2621
+ if (documentSchemaError) {
2622
+ preflightValidationErrors.push(` ${documentSchemaError}`);
2623
+ }
2571
2624
  for (const file of functionFiles) {
2572
2625
  const filePath = join(preflightFunctionsDir, file);
2573
2626
  let tomlData;
@@ -2636,6 +2689,9 @@ export async function runConfigPushPreflight(input) {
2636
2689
  // single pull-then-push clears the notice. Printed even under `--dry-run`,
2637
2690
  // which never reaches the apply — the notice is about the FILE.
2638
2691
  const preflightPromptsDir = join(configDir, "prompts");
2692
+ // #3798 — the functions an agent's references are checked against, read
2693
+ // once and only when a selected prompt is an agent that names one.
2694
+ let agentFunctions = null;
2639
2695
  if (existsSync(preflightPromptsDir)) {
2640
2696
  for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2641
2697
  const filePath = join(preflightPromptsDir, file);
@@ -2650,8 +2706,36 @@ export async function runConfigPushPreflight(input) {
2650
2706
  const notice = promptFlatChatKeyWarning(filePath, tomlData);
2651
2707
  if (notice)
2652
2708
  warn(` ${notice}`);
2709
+ // #3798 — an agent's tool, turn-context and history functions must
2710
+ // exist, and a tool's must declare both schemas, before anything is
2711
+ // applied. Bounded by `--only` like the rest of this loop.
2712
+ if (promptKindOf(tomlData?.prompt) === "agent" &&
2713
+ agentDeclarationFunctionKeys(tomlData?.prompt?.agent).length > 0) {
2714
+ if (!agentFunctions) {
2715
+ let live = null;
2716
+ try {
2717
+ live = (await client.listFunctions(resolvedAppId)).items;
2718
+ }
2719
+ catch {
2720
+ live = null;
2721
+ }
2722
+ agentFunctions = agentFunctionFactsForPush({
2723
+ configDir,
2724
+ pushSelection,
2725
+ live,
2726
+ });
2727
+ }
2728
+ for (const message of promptAgentFunctionErrors(filePath, tomlData, agentFunctions)) {
2729
+ preflightValidationErrors.push(` ${message}`);
2730
+ }
2731
+ }
2653
2732
  }
2654
2733
  }
2734
+ // #3798 — a test case on an agent is refused by the admin routes; say so
2735
+ // here, before any other file is applied.
2736
+ for (const message of promptAgentSidecarErrors(configDir)) {
2737
+ preflightValidationErrors.push(` ${message}`);
2738
+ }
2655
2739
  // The `<key>.tests/*.toml` sidecars, which sit beside the block they
2656
2740
  // test rather than in a type directory of their own. Same posture,
2657
2741
  // reached through the registry's `test-case` surface — and through the
@@ -2691,6 +2775,41 @@ export async function runConfigPushPreflight(input) {
2691
2775
  * whose spelling is not the declared type. A file that does not parse is left
2692
2776
  * to the per-type loop that reads it, which names the parse error itself.
2693
2777
  */
2778
+ /**
2779
+ * The blocks one `[[configs]]` entry carries that its prompt's kind does not
2780
+ * name — #3798. An agent's entry may carry only `[configs.agent]`; a chat or
2781
+ * decisions entry may not carry it. `movedFlat` are the deprecated flat chat
2782
+ * keys the hoist moved under `chat`, named as the author wrote them.
2783
+ *
2784
+ * Only the blocks the other arms of `resolvePromptKind` cannot see are checked
2785
+ * here: a decisions block on a chat entry, and chat keys on a decisions entry,
2786
+ * stay the resolver's refusals with their #3626 wording.
2787
+ */
2788
+ function promptEntryBlockErrors(kind, entry, movedFlat) {
2789
+ const has = (block) => entry != null && typeof entry === "object" && entry[block] !== undefined;
2790
+ const errors = [];
2791
+ if (kind === "agent") {
2792
+ if (movedFlat.length > 0) {
2793
+ errors.push(`sets ${movedFlat.join(", ")} flat — the deprecated [configs.chat] ` +
2794
+ 'spelling — but prompt kind is "agent", so it may carry only a ' +
2795
+ "[configs.agent] block; write them under [configs.agent]");
2796
+ }
2797
+ for (const block of ["chat", "decisions"]) {
2798
+ if (!has(block))
2799
+ continue;
2800
+ errors.push(`carries a [configs.${block}] block, but prompt kind is "agent", so ` +
2801
+ "it may carry only a [configs.agent] block (systemPrompt, " +
2802
+ "temperature, topP, maxTokens, reasoningEffort, reasoningBudget)");
2803
+ }
2804
+ return errors;
2805
+ }
2806
+ if (has("agent")) {
2807
+ errors.push(`carries a [configs.agent] block, but prompt kind is "${kind}", so it ` +
2808
+ `may carry only a [configs.${kind}] block. An agent is its own ` +
2809
+ 'prompt: create it under a new key with kind = "agent"');
2810
+ }
2811
+ return errors;
2812
+ }
2694
2813
  /**
2695
2814
  * A prompt file's KIND errors — #3626, decidable from the file alone.
2696
2815
  *
@@ -2702,15 +2821,94 @@ export async function runConfigPushPreflight(input) {
2702
2821
  * the server's cannot disagree — and this one lands in the preflight, before
2703
2822
  * the first mutating call.
2704
2823
  */
2824
+ /**
2825
+ * The `[[configs]]` entries of a prompt file as the agent rules read them —
2826
+ * #3799: which provider each named configuration runs on, and whether the file
2827
+ * retires it.
2828
+ *
2829
+ * An archived entry is skipped by the translation rule (a config the server does
2830
+ * not run cannot make the declaration untranslatable), which is what lets a
2831
+ * pulled tree keep a retired config on a provider the current schemas could not
2832
+ * reach. `status` is read through the same default the surface declares, so an
2833
+ * entry that omits the key is live.
2834
+ */
2835
+ function promptFileConfigFacts(entries) {
2836
+ return (entries || []).map((entry, index) => ({
2837
+ configName: String(entry?.name ?? `configs[${index}]`),
2838
+ provider: String(entry?.provider ?? ""),
2839
+ status: String(entry?.status ?? PROMPT_CONFIG_DEFAULT_STATUS),
2840
+ }));
2841
+ }
2705
2842
  export function promptKindFileErrors(filePath, tomlData) {
2706
2843
  const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
2707
2844
  const errors = [];
2845
+ const fileKind = promptKindOf(tomlData?.prompt);
2846
+ // #3798 — the `[prompt.agent]` declaration's file-decidable half: its shape,
2847
+ // and `[prompt].outputSchema` on an agent. The function half needs app state
2848
+ // and runs in the push preflight (`promptAgentFunctionErrors`).
2849
+ const declaration = resolveAgentDeclaration({
2850
+ kind: tomlData?.prompt?.kind,
2851
+ agent: tomlData?.prompt?.agent,
2852
+ outputSchema: tomlData?.prompt?.outputSchema,
2853
+ });
2854
+ if (declaration.ok === false) {
2855
+ for (const refusal of declaration.refusals) {
2856
+ errors.push(`${filePath}: ${refusal.message}`);
2857
+ }
2858
+ }
2859
+ else if (fileKind === "agent") {
2860
+ // #3799 — the schemas a CLIENT tool and an event declare are in the file,
2861
+ // so whether each provider the file configures can carry them without
2862
+ // losing meaning is file-decidable too: `config diff` reports the same row
2863
+ // as `config push`. A server tool's schemas are its FUNCTION's and need app
2864
+ // state, so they are checked in the push preflight
2865
+ // (`promptAgentFunctionErrors`). Only after the shape refusals above, so a
2866
+ // malformed declaration is not also reported as untranslatable.
2867
+ for (const refusal of agentSchemaTranslationRefusals({
2868
+ declaration: tomlData?.prompt?.agent,
2869
+ configs: promptFileConfigFacts(entries),
2870
+ })) {
2871
+ errors.push(`${filePath}: ${refusal.message}`);
2872
+ }
2873
+ }
2708
2874
  for (const [index, entry] of entries.entries()) {
2709
2875
  const hoisted = hoistDeprecatedChatKeys(entry);
2710
2876
  for (const key of hoisted.conflicts) {
2711
2877
  errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
2712
2878
  `[configs.chat]; keep the [configs.chat] spelling`);
2713
2879
  }
2880
+ // #3798 — the block rule for three kinds, stated per entry: an entry may
2881
+ // carry only the block its prompt's kind names. An agent's `[configs.agent]`
2882
+ // holds keys chat also holds, so a stray block would otherwise be read (or
2883
+ // ignored) in silence by the payload builder.
2884
+ const blockErrors = promptEntryBlockErrors(fileKind, entry, hoisted.moved).map((message) => `${filePath}: configs[${index}] ${message}`);
2885
+ if (blockErrors.length > 0) {
2886
+ errors.push(...blockErrors);
2887
+ continue;
2888
+ }
2889
+ if (fileKind === "agent") {
2890
+ const agent = hoisted.entry.agent ?? {};
2891
+ const resolution = resolvePromptKind({
2892
+ kind: "agent",
2893
+ provider: hoisted.entry.provider,
2894
+ model: hoisted.entry.model,
2895
+ config: {
2896
+ systemPrompt: agent.systemPrompt,
2897
+ temperature: agent.temperature,
2898
+ topP: agent.topP,
2899
+ maxTokens: agent.maxTokens,
2900
+ reasoningEffort: agent.reasoningEffort,
2901
+ reasoningBudget: agent.reasoningBudget,
2902
+ // #3801 — a chat key: named, so the refusal says which key an agent
2903
+ // does not take (§D1: an agent has no output schema).
2904
+ strictOutput: agent.strictOutput,
2905
+ },
2906
+ });
2907
+ if (resolution.ok === false) {
2908
+ errors.push(`${filePath}: configs[${index}] — ${resolution.error}`);
2909
+ }
2910
+ continue;
2911
+ }
2714
2912
  const chat = hoisted.entry.chat ?? {};
2715
2913
  const decisions = hoisted.entry.decisions ?? {};
2716
2914
  // #3626 — the FIRST entry of a chat prompt carries the create seed's
@@ -2726,7 +2924,7 @@ export function promptKindFileErrors(filePath, tomlData) {
2726
2924
  // `userPromptTemplate`, so reading absence more narrowly than the builder
2727
2925
  // does refused the file this CLI had just written, for a value the create
2728
2926
  // would have replaced with the placeholder anyway.
2729
- const seedsTemplate = index === 0 && promptKindOf(tomlData?.prompt) !== "decisions";
2927
+ const seedsTemplate = index === 0 && fileKind === "chat";
2730
2928
  const userPromptTemplate = seedsTemplate
2731
2929
  ? chat.userPromptTemplate || "{{ input }}"
2732
2930
  : chat.userPromptTemplate;
@@ -2744,12 +2942,200 @@ export function promptKindFileErrors(filePath, tomlData) {
2744
2942
  outputSchema: chat.outputSchema,
2745
2943
  reasoningEffort: chat.reasoningEffort,
2746
2944
  reasoningBudget: chat.reasoningBudget,
2945
+ strictOutput: chat.strictOutput,
2747
2946
  questions: decisions.questions,
2748
2947
  },
2749
2948
  });
2750
2949
  if (resolution.ok === false) {
2751
2950
  errors.push(`${filePath}: configs[${index}] — ${resolution.error}`);
2951
+ continue;
2952
+ }
2953
+ // #3801 — strict output, file-decidable: the entry's own keys, the file's
2954
+ // `[prompt].outputSchema` and the entry's status (an archived entry is not
2955
+ // held to the prompt schema, DSO-3801-002). The route runs the same rule
2956
+ // against what the server holds.
2957
+ if (resolution.kind === "chat") {
2958
+ const strict = resolvePromptStrictOutput({
2959
+ provider: hoisted.entry.provider,
2960
+ strictOutput: chat.strictOutput,
2961
+ outputFormat: chat.outputFormat,
2962
+ configOutputSchema: chat.outputSchema,
2963
+ promptOutputSchema: tomlData?.prompt?.outputSchema,
2964
+ status: hoisted.entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
2965
+ });
2966
+ if (strict.ok === false) {
2967
+ errors.push(`${filePath}: configs[${index}] — ${strict.error} (${strict.code})`);
2968
+ }
2969
+ }
2970
+ }
2971
+ return errors;
2972
+ }
2973
+ /** A schema slot counts as declared when it holds anything but nothing. */
2974
+ function declaresSchema(value) {
2975
+ if (value === undefined || value === null)
2976
+ return false;
2977
+ return !(typeof value === "string" && value.trim() === "");
2978
+ }
2979
+ /**
2980
+ * The functions an agent's references are checked against at push — #3798,
2981
+ * DSO-3798-002.
2982
+ *
2983
+ * `config push` applies prompts, then (for agents) the server functions leg,
2984
+ * so a reference may be satisfied by a function that is live already or by one
2985
+ * this push creates. What the push BELIEVES is therefore the live listing
2986
+ * overlaid by the function files this push SELECTS (`--only`, every file when
2987
+ * unscoped): a selected file's schemas are what the server will hold after the
2988
+ * push, so it wins over the live row. An UNSELECTED file is not read at all —
2989
+ * its edits will not be applied, and a function only it declares will not
2990
+ * exist afterwards.
2991
+ *
2992
+ * `live` is `null` when the listing could not be fetched: the selected files
2993
+ * still count, and `liveListed` lets the refusal say why every other reference
2994
+ * could not be confirmed. A live row that is archived is a tombstone, not a
2995
+ * function; an inactive one is accepted (the turn engine re-checks
2996
+ * availability on every call).
2997
+ */
2998
+ export function agentFunctionFactsForPush(input) {
2999
+ const facts = {};
3000
+ for (const row of input.live ?? []) {
3001
+ const key = String(row?.functionKey ?? "");
3002
+ if (!key || row?.status === "archived")
3003
+ continue;
3004
+ facts[key] = {
3005
+ hasInputSchema: declaresSchema(row.inputSchema),
3006
+ hasOutputSchema: declaresSchema(row.outputSchema),
3007
+ // #3799 — the schemas themselves, as stored (a JSON string from the API,
3008
+ // an object from a tree file), so a server tool's two schemas can be
3009
+ // translated for each of the agent's providers.
3010
+ inputSchema: row.inputSchema,
3011
+ outputSchema: row.outputSchema,
3012
+ };
3013
+ }
3014
+ const functionsDir = join(input.configDir, "functions");
3015
+ if (existsSync(functionsDir)) {
3016
+ for (const file of filterFilesBySelection(input.pushSelection, "function", functionsDir, readdirSync(functionsDir).filter(isFunctionDeclarationFile))) {
3017
+ let table;
3018
+ try {
3019
+ table = parseTomlFile(join(functionsDir, file))?.function ?? {};
3020
+ }
3021
+ catch {
3022
+ // Reported by the function preflight with its own message.
3023
+ continue;
3024
+ }
3025
+ const key = table.key || basename(file, ".toml");
3026
+ facts[key] = {
3027
+ hasInputSchema: declaresSchema(table.inputSchema),
3028
+ hasOutputSchema: declaresSchema(table.outputSchema),
3029
+ // #3799 — a SELECTED file's schemas are what the server will hold after
3030
+ // this push, so they are what the translation is checked against.
3031
+ inputSchema: table.inputSchema,
3032
+ outputSchema: table.outputSchema,
3033
+ };
3034
+ }
3035
+ }
3036
+ return { facts, liveListed: input.live !== null };
3037
+ }
3038
+ /**
3039
+ * The function half of an agent's declaration — #3798: every tool, turn
3040
+ * context and history function that the functions this push believes
3041
+ * (`agentFunctionFactsForPush`) do not have, and every tool function lacking an
3042
+ * input or output schema. The same `resolveAgentDeclaration` the admin routes
3043
+ * run; only its function refusals are reported here, because the collector
3044
+ * (`promptKindFileErrors`) already reported the file-decidable ones.
3045
+ */
3046
+ export function promptAgentFunctionErrors(filePath, tomlData, known) {
3047
+ if (promptKindOf(tomlData?.prompt) !== "agent")
3048
+ return [];
3049
+ const result = resolveAgentDeclaration({
3050
+ kind: "agent",
3051
+ agent: tomlData?.prompt?.agent,
3052
+ functions: known.facts,
3053
+ });
3054
+ const errors = result.ok === false
3055
+ ? result.refusals
3056
+ .filter((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" ||
3057
+ refusal.code === "AGENT_TOOL_FUNCTION_SCHEMA_MISSING")
3058
+ .map((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" && !known.liveListed
3059
+ ? `${filePath}: ${refusal.message} (the app's live functions could ` +
3060
+ "not be listed, so only the function files this push applies were " +
3061
+ "checked)"
3062
+ : `${filePath}: ${refusal.message}`)
3063
+ : [];
3064
+ // #3799 — a SERVER tool's schemas are its function's, so translating them
3065
+ // needs the facts this push believes (the live listing overlaid by the
3066
+ // function files it selects, DSO-3798-002). The client-tool and event half is
3067
+ // file-decidable and reported by `promptKindFileErrors`, which `config diff`
3068
+ // runs too; passing the facts here means only the server-tool refusals are
3069
+ // new, because that collector already saw the rest.
3070
+ const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
3071
+ const configs = promptFileConfigFacts(entries);
3072
+ const fileDecidable = new Set(agentSchemaTranslationRefusals({
3073
+ declaration: tomlData?.prompt?.agent,
3074
+ configs,
3075
+ }).map((refusal) => refusal.message));
3076
+ for (const refusal of agentSchemaTranslationRefusals({
3077
+ declaration: tomlData?.prompt?.agent,
3078
+ functions: known.facts,
3079
+ configs,
3080
+ })) {
3081
+ if (fileDecidable.has(refusal.message))
3082
+ continue;
3083
+ errors.push(`${filePath}: ${refusal.message}`);
3084
+ }
3085
+ return errors;
3086
+ }
3087
+ /**
3088
+ * The order `config push` applies prompt files in — #3798. An agent names
3089
+ * server functions that must exist when it is created, and the push applies
3090
+ * prompts before functions, so agent files go AFTER the server-functions leg;
3091
+ * every other file keeps its place. A file that does not parse stays first, so
3092
+ * the leg reports it where it always has.
3093
+ */
3094
+ export function partitionPromptFilesForPush(promptsDir, files) {
3095
+ const beforeFunctions = [];
3096
+ const afterFunctions = [];
3097
+ for (const file of files) {
3098
+ let kind = "chat";
3099
+ try {
3100
+ kind = promptKindOf(parseTomlFile(join(promptsDir, file))?.prompt);
3101
+ }
3102
+ catch {
3103
+ beforeFunctions.push(file);
3104
+ continue;
3105
+ }
3106
+ (kind === "agent" ? afterFunctions : beforeFunctions).push(file);
3107
+ }
3108
+ return { beforeFunctions, afterFunctions };
3109
+ }
3110
+ /**
3111
+ * Every `prompts/<key>.tests/*.toml` sidecar whose prompt is an agent — #3798
3112
+ * (§D1: prompt test cases are refused for the kind in v1). The admin test-case
3113
+ * routes refuse the same case with `AGENT_TEST_CASE_NOT_SUPPORTED`; this says
3114
+ * so before anything is applied.
3115
+ */
3116
+ export function promptAgentSidecarErrors(configDir) {
3117
+ const promptsDir = join(configDir, "prompts");
3118
+ const errors = [];
3119
+ for (const filePath of testCaseTomlFiles(configDir)) {
3120
+ const parent = basename(join(filePath, ".."));
3121
+ if (!filePath.startsWith(promptsDir) || !parent.endsWith(".tests"))
3122
+ continue;
3123
+ const key = parent.slice(0, -".tests".length);
3124
+ const owner = join(promptsDir, `${key}.toml`);
3125
+ if (!existsSync(owner))
3126
+ continue;
3127
+ let tomlData;
3128
+ try {
3129
+ tomlData = parseTomlFile(owner);
3130
+ }
3131
+ catch {
3132
+ continue;
2752
3133
  }
3134
+ if (promptKindOf(tomlData?.prompt) !== "agent")
3135
+ continue;
3136
+ const promptKey = tomlData?.prompt?.key || key;
3137
+ errors.push(`${filePath.slice(configDir.length + 1)}: ` +
3138
+ agentTestCaseRefusal(promptKey, "subject").message);
2753
3139
  }
2754
3140
  return errors;
2755
3141
  }
@@ -3059,6 +3445,17 @@ export function workflowPushSendsUpdate(configDir, key, parsedWorkflowToml, sync
3059
3445
  *
3060
3446
  * Returns `null` when the name is present.
3061
3447
  */
3448
+ /**
3449
+ * The preflight error a project's document schema contributes, or `null` —
3450
+ * #3719.
3451
+ *
3452
+ * Only a `refusal` counts: a unique constraint on a stringset field, which no
3453
+ * writer can key consistently, so the push stops before any mutation. Every
3454
+ * other unusable-schema case stays the apply loop's warning (D3455-004).
3455
+ */
3456
+ export function documentSchemaPreflightError(resolution) {
3457
+ return resolution.refusal;
3458
+ }
3062
3459
  export function workflowUpdateNameError(filePath, workflow) {
3063
3460
  const name = workflow?.name;
3064
3461
  if (typeof name === "string" && name.trim() !== "")
@@ -10772,8 +11169,14 @@ What a push guarantees (two stages):
10772
11169
  }
10773
11170
  // Process prompts
10774
11171
  const promptsDir = join(configDir, "prompts");
10775
- if (existsSync(promptsDir)) {
10776
- const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
11172
+ // #3798 — the prompt leg as one function, called twice. `config push`
11173
+ // applies prompts BEFORE server functions, and an agent's tools name
11174
+ // functions the server must hold when the agent is created, so a tree
11175
+ // that pushes an agent together with its tool functions would be
11176
+ // refused on its first push. Chat and decisions files keep their place;
11177
+ // agent files are applied after the server-functions leg below
11178
+ // (`deferredAgentPromptFiles`). Nothing inside the leg changed.
11179
+ const pushPromptFiles = async (files) => {
10777
11180
  // #2869 — prompts join the live-state reconcile (#2731 B3). #2748
10778
11181
  // gave the type diff's comparator and left push on the manifest byte
10779
11182
  // hash, so the two commands answered "changed?" from different
@@ -10976,6 +11379,79 @@ What a push guarantees (two stages):
10976
11379
  // (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
10977
11380
  // sync-state entity record before calling this so the stamp lands.
10978
11381
  const applyPromptUpdate = async (promptId, expectedModifiedAt) => {
11382
+ // #3799 (CSO-3799-005) — an agent's declaration is checked against
11383
+ // every config the server holds non-archived, so a file that
11384
+ // retires a config AND declares a schema that config's provider
11385
+ // cannot carry must retire it first: sent the other way round,
11386
+ // the prompt PATCH is refused for a config the file has already
11387
+ // archived, and no re-run changes the order. Only the entries
11388
+ // the file archives and the server does not yet — every other
11389
+ // config update still follows the prompt, and decisions prompts
11390
+ // keep their sequence. A config PATCH does not move the
11391
+ // prompt's `modifiedAt`, so the concurrency guard below still
11392
+ // holds.
11393
+ //
11394
+ // #3801 (DSO-3801-001) — the same staging, with a second admission
11395
+ // rule. The admin API refuses clearing a CHAT prompt's
11396
+ // `[prompt].outputSchema` while a config it holds non-archived is
11397
+ // strict, so a file that clears the schema AND turns strict output
11398
+ // off (or retires the config) would be refused on every push if the
11399
+ // prompt went first. Those config updates go ahead of the prompt
11400
+ // PATCH, with the payload they would have been sent anyway. The
11401
+ // read this needs is the one the name→id mapping below makes,
11402
+ // moved ahead of the PATCH, so the request count does not grow.
11403
+ const stagedFirst = new Set();
11404
+ const fileKind = promptKindOf(tomlData?.prompt);
11405
+ const retiring = configs.filter((c) => c?.name &&
11406
+ String(c?.status ?? "").trim().toLowerCase() === "archived");
11407
+ const stagesRetiringAgent = fileKind === "agent" && retiring.length > 0;
11408
+ const clearsPromptSchema = fileKind === "chat" &&
11409
+ !declaresSchema(tomlData?.prompt?.outputSchema);
11410
+ const current = stagesRetiringAgent || clearsPromptSchema
11411
+ ? await client.getPrompt(resolvedAppId, promptId)
11412
+ : null;
11413
+ const liveServerConfig = (name) => {
11414
+ const found = (current?.configs ?? []).find((c) => c.configName === name);
11415
+ return found && String(found.status ?? "active") !== "archived"
11416
+ ? found
11417
+ : null;
11418
+ };
11419
+ const staged = [];
11420
+ if (stagesRetiringAgent) {
11421
+ for (const tomlConfig of retiring) {
11422
+ const serverConfig = liveServerConfig(tomlConfig.name);
11423
+ if (serverConfig)
11424
+ staged.push({ tomlConfig, serverConfig });
11425
+ }
11426
+ }
11427
+ if (clearsPromptSchema && declaresSchema(current?.outputSchema)) {
11428
+ for (const tomlConfig of configs) {
11429
+ if (!tomlConfig?.name)
11430
+ continue;
11431
+ const serverConfig = liveServerConfig(tomlConfig.name);
11432
+ if (!serverConfig || serverConfig.strictOutput !== true)
11433
+ continue;
11434
+ const chat = hoistDeprecatedChatKeys(tomlConfig).entry.chat ?? {};
11435
+ const retired = String(tomlConfig?.status ?? "").trim().toLowerCase() ===
11436
+ "archived";
11437
+ // An entry that stays strict AND live is refused in preflight
11438
+ // (no schema to send); it is never staged.
11439
+ if (chat.strictOutput === true && !retired)
11440
+ continue;
11441
+ staged.push({ tomlConfig, serverConfig });
11442
+ }
11443
+ }
11444
+ for (const { tomlConfig, serverConfig } of staged) {
11445
+ if (stagedFirst.has(tomlConfig.name))
11446
+ continue;
11447
+ try {
11448
+ await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
11449
+ }
11450
+ catch (err) {
11451
+ throw wrapPromptConfigError(err, "update", key, tomlConfig.name);
11452
+ }
11453
+ stagedFirst.add(tomlConfig.name);
11454
+ }
10979
11455
  const updated = await client.updatePrompt(resolvedAppId, promptId, buildPromptPushPayload(key, tomlData, { mode: "update" }), expectedModifiedAt);
10980
11456
  info(` Updated prompt: ${key}`);
10981
11457
  // Record the `modifiedAt` this PATCH just caused, so a later
@@ -10998,7 +11474,10 @@ What a push guarantees (two stages):
10998
11474
  try {
10999
11475
  // Fetch full prompt to get config name→ID mappings
11000
11476
  // (updatePrompt response doesn't include configs)
11001
- const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
11477
+ // #3801 — the read made ahead of the prompt PATCH when the
11478
+ // staging needed it (neither the PATCH nor a staged config
11479
+ // update moves a config's id or the activation).
11480
+ const fullPrompt = current ?? (await client.getPrompt(resolvedAppId, promptId));
11002
11481
  if (fullPrompt?.configs) {
11003
11482
  for (const config of fullPrompt.configs) {
11004
11483
  promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
@@ -11012,6 +11491,10 @@ What a push guarantees (two stages):
11012
11491
  continue;
11013
11492
  const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
11014
11493
  if (serverConfig) {
11494
+ // Staged ahead of the prompt PATCH, with this same
11495
+ // payload (#3799 CSO-3799-005, #3801 DSO-3801-001).
11496
+ if (stagedFirst.has(configName))
11497
+ continue;
11015
11498
  // Update existing config
11016
11499
  try {
11017
11500
  await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
@@ -11032,18 +11515,9 @@ What a push guarantees (two stages):
11032
11515
  }
11033
11516
  if (newConfig?.configId) {
11034
11517
  promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
11035
- // #3201 — the create assigned `active`; a file that
11036
- // retired this entry says so in the only mode the
11037
- // server accepts the key in.
11038
- const statusPatch = createdConfigStatusPatch(tomlConfig);
11039
- if (statusPatch) {
11040
- try {
11041
- await client.updatePromptConfig(resolvedAppId, promptId, newConfig.configId, statusPatch);
11042
- }
11043
- catch (err) {
11044
- throw wrapPromptConfigError(err, "update", key, configName);
11045
- }
11046
- }
11518
+ // #3799 — the create body carried the entry's own
11519
+ // `status`, so there is no follow-up patch (#3201's is
11520
+ // retired).
11047
11521
  }
11048
11522
  }
11049
11523
  }
@@ -11377,21 +11851,11 @@ What a push guarantees (two stages):
11377
11851
  throw wrapPromptConfigError(err, "create", key, extraName);
11378
11852
  }
11379
11853
  if (extraCreated?.configId) {
11380
- const configName = extraName;
11381
- promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
11382
- // #3201 — as on the update path: the create ignored
11383
- // the entry's `status`, so a retired one is applied
11384
- // here or the fresh environment runs a config the
11385
- // file retired.
11386
- const statusPatch = createdConfigStatusPatch(extraConfig);
11387
- if (statusPatch) {
11388
- try {
11389
- await client.updatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId, statusPatch);
11390
- }
11391
- catch (err) {
11392
- throw wrapPromptConfigError(err, "update", key, configName);
11393
- }
11394
- }
11854
+ promptConfigNameToId.set(`${key}#${extraName}`, extraCreated.configId);
11855
+ // #3799 — as on the update path: the create carried the
11856
+ // entry's `status`, so a retired entry is retired when
11857
+ // it is created and the fresh environment never runs a
11858
+ // config the file retired.
11395
11859
  }
11396
11860
  }
11397
11861
  info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
@@ -11423,25 +11887,17 @@ What a push guarantees (two stages):
11423
11887
  activated = true;
11424
11888
  }
11425
11889
  }
11426
- // #3201 — the seeded config's own `status`, applied LAST for
11427
- // the same reason activation runs after the extra creates:
11428
- // the create points `activeConfigId` at this config, so a
11429
- // file that retires it is asking for a state that only makes
11430
- // sense once the entry it marks active exists and is live.
11431
- // Retiring it earlier would leave the prompt serving from an
11432
- // archived config for the length of the remaining calls, and
11433
- // permanently if one of them failed.
11434
- const seededStatusPatch = seededConfigId
11435
- ? createdConfigStatusPatch(firstConfig || {})
11436
- : null;
11437
- if (created?.promptId && seededConfigId && seededStatusPatch) {
11438
- try {
11439
- await client.updatePromptConfig(resolvedAppId, created.promptId, seededConfigId, seededStatusPatch);
11440
- }
11441
- catch (err) {
11442
- throw wrapPromptConfigError(err, "update", key, firstConfigPatch?.configName || seededConfig?.configName);
11443
- }
11444
- }
11890
+ // #3799 (DSO-3799-005) — the seeded config's `status` is in
11891
+ // the create body (`POST /prompts` honors it now), so #3201's
11892
+ // trailing patch is gone. Activation still runs after every
11893
+ // create (#2645), so the marked entry is what the prompt ends
11894
+ // up serving; when the FIRST entry is the retired one, the
11895
+ // seeded config the prompt points at is created archived and
11896
+ // the activation moves the pointer off it. A push that dies
11897
+ // between the two leaves a prompt pointing at an archived
11898
+ // config — recovered by re-running the push, and the price of
11899
+ // never creating a config in a state the file did not ask for
11900
+ // (which is what an agent's schema check fires on).
11445
11901
  // Stamp sync state ONLY after all create work (base prompt +
11446
11902
  // every extra config) succeeded. If an extra-config create
11447
11903
  // above threw, we never reach here, so the file hash is not
@@ -11477,6 +11933,13 @@ What a push guarantees (two stages):
11477
11933
  }
11478
11934
  }
11479
11935
  }
11936
+ };
11937
+ let deferredAgentPromptFiles = [];
11938
+ if (existsSync(promptsDir)) {
11939
+ const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
11940
+ const ordered = partitionPromptFilesForPush(promptsDir, files);
11941
+ deferredAgentPromptFiles = ordered.afterFunctions;
11942
+ await pushPromptFiles(ordered.beforeFunctions);
11480
11943
  }
11481
11944
  // Process transforms (Rhai scripts) — issue #892, slice 7 of #783.
11482
11945
  //
@@ -13850,7 +14313,11 @@ What a push guarantees (two stages):
13850
14313
  let pushedVersion;
13851
14314
  try {
13852
14315
  if (!functionId) {
13853
- const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
14316
+ // #3835 — a create the server could not confirm in storage
14317
+ // (`STORAGE_CONSISTENCY_PENDING`) is asked again, not recorded
14318
+ // as not applied. Its row keeps the key, so a retry that meets
14319
+ // it as a 409 is adopted below like any other held key.
14320
+ const created = await retryOnStoragePending(() => client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {}))).catch(async (err) => {
13854
14321
  // #1006 — a function the server already holds under this key
13855
14322
  // is adopted rather than re-created. The conflict may name a
13856
14323
  // WORKFLOW, a script or a webhook instead: those are the
@@ -13949,6 +14416,11 @@ What a push guarantees (two stages):
13949
14416
  }
13950
14417
  }
13951
14418
  }
14419
+ // #3798 — the agent prompts, now that every function they name that
14420
+ // this push applies is on the server (see `pushPromptFiles`).
14421
+ if (deferredAgentPromptFiles.length > 0) {
14422
+ await pushPromptFiles(deferredAgentPromptFiles);
14423
+ }
13952
14424
  // Process group type configs
13953
14425
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
13954
14426
  if (existsSync(groupTypeConfigsDir)) {