primitive-admin 1.0.63 → 1.0.64

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 (328) hide show
  1. package/README.md +132 -42
  2. package/assets/skill/skills/primitive-platform/SKILL.md +142 -661
  3. package/dist/bin/primitive.js +121 -41
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +37 -50
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +178 -9
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +12 -5
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/auth-sessions.d.ts +7 -0
  12. package/dist/src/commands/auth-sessions.js +144 -0
  13. package/dist/src/commands/auth-sessions.js.map +1 -0
  14. package/dist/src/commands/auth.js +76 -116
  15. package/dist/src/commands/auth.js.map +1 -1
  16. package/dist/src/commands/blob-buckets.js +2 -1
  17. package/dist/src/commands/blob-buckets.js.map +1 -1
  18. package/dist/src/commands/catalog.js +26 -10
  19. package/dist/src/commands/catalog.js.map +1 -1
  20. package/dist/src/commands/collection-type-configs.js +3 -10
  21. package/dist/src/commands/collection-type-configs.js.map +1 -1
  22. package/dist/src/commands/collections.js +736 -19
  23. package/dist/src/commands/collections.js.map +1 -1
  24. package/dist/src/commands/config.js +13 -27
  25. package/dist/src/commands/config.js.map +1 -1
  26. package/dist/src/commands/connections.js +8 -9
  27. package/dist/src/commands/connections.js.map +1 -1
  28. package/dist/src/commands/cron-triggers.js +2 -1
  29. package/dist/src/commands/cron-triggers.js.map +1 -1
  30. package/dist/src/commands/database-type-configs.js +5 -12
  31. package/dist/src/commands/database-type-configs.js.map +1 -1
  32. package/dist/src/commands/databases.js +59 -72
  33. package/dist/src/commands/databases.js.map +1 -1
  34. package/dist/src/commands/documents.d.ts +58 -0
  35. package/dist/src/commands/documents.js +1623 -95
  36. package/dist/src/commands/documents.js.map +1 -1
  37. package/dist/src/commands/email-templates.js +2 -1
  38. package/dist/src/commands/email-templates.js.map +1 -1
  39. package/dist/src/commands/env.d.ts +1 -1
  40. package/dist/src/commands/env.js +89 -43
  41. package/dist/src/commands/env.js.map +1 -1
  42. package/dist/src/commands/feature-flags.js +2 -1
  43. package/dist/src/commands/feature-flags.js.map +1 -1
  44. package/dist/src/commands/functions.d.ts +20 -0
  45. package/dist/src/commands/functions.js +1630 -0
  46. package/dist/src/commands/functions.js.map +1 -0
  47. package/dist/src/commands/group-type-configs.js +2 -1
  48. package/dist/src/commands/group-type-configs.js.map +1 -1
  49. package/dist/src/commands/groups.js +30 -17
  50. package/dist/src/commands/groups.js.map +1 -1
  51. package/dist/src/commands/guides.js +17 -11
  52. package/dist/src/commands/guides.js.map +1 -1
  53. package/dist/src/commands/init.d.ts +12 -15
  54. package/dist/src/commands/init.js +44 -92
  55. package/dist/src/commands/init.js.map +1 -1
  56. package/dist/src/commands/integrations.js +35 -12
  57. package/dist/src/commands/integrations.js.map +1 -1
  58. package/dist/src/commands/locks.js +17 -2
  59. package/dist/src/commands/locks.js.map +1 -1
  60. package/dist/src/commands/metadata-category-configs.js +2 -1
  61. package/dist/src/commands/metadata-category-configs.js.map +1 -1
  62. package/dist/src/commands/metadata.js +8 -1
  63. package/dist/src/commands/metadata.js.map +1 -1
  64. package/dist/src/commands/prompts.js +106 -59
  65. package/dist/src/commands/prompts.js.map +1 -1
  66. package/dist/src/commands/rule-sets.js +3 -10
  67. package/dist/src/commands/rule-sets.js.map +1 -1
  68. package/dist/src/commands/scripts.js +33 -28
  69. package/dist/src/commands/scripts.js.map +1 -1
  70. package/dist/src/commands/secrets.js +2 -1
  71. package/dist/src/commands/secrets.js.map +1 -1
  72. package/dist/src/commands/sessions.js +10 -9
  73. package/dist/src/commands/sessions.js.map +1 -1
  74. package/dist/src/commands/sync-app-settings.js +3 -5
  75. package/dist/src/commands/sync-app-settings.js.map +1 -1
  76. package/dist/src/commands/sync.d.ts +390 -13
  77. package/dist/src/commands/sync.js +2613 -451
  78. package/dist/src/commands/sync.js.map +1 -1
  79. package/dist/src/commands/tokens.js +2 -1
  80. package/dist/src/commands/tokens.js.map +1 -1
  81. package/dist/src/commands/users.js +19 -10
  82. package/dist/src/commands/users.js.map +1 -1
  83. package/dist/src/commands/vars.js +2 -1
  84. package/dist/src/commands/vars.js.map +1 -1
  85. package/dist/src/commands/waitlist.js +2 -1
  86. package/dist/src/commands/waitlist.js.map +1 -1
  87. package/dist/src/commands/webhooks.d.ts +29 -0
  88. package/dist/src/commands/webhooks.js +135 -64
  89. package/dist/src/commands/webhooks.js.map +1 -1
  90. package/dist/src/commands/workflows.d.ts +1 -29
  91. package/dist/src/commands/workflows.js +113 -179
  92. package/dist/src/commands/workflows.js.map +1 -1
  93. package/dist/src/lib/api-client.d.ts +628 -14
  94. package/dist/src/lib/api-client.js +776 -70
  95. package/dist/src/lib/api-client.js.map +1 -1
  96. package/dist/src/lib/app-settings-descriptor.d.ts +1 -1
  97. package/dist/src/lib/app-settings-descriptor.js +9 -1
  98. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  99. package/dist/src/lib/block-selector.d.ts +58 -0
  100. package/dist/src/lib/block-selector.js +92 -0
  101. package/dist/src/lib/block-selector.js.map +1 -0
  102. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -54
  103. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +31 -153
  104. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  105. package/dist/src/lib/collection-export.d.ts +184 -0
  106. package/dist/src/lib/collection-export.js +252 -0
  107. package/dist/src/lib/collection-export.js.map +1 -0
  108. package/dist/src/lib/config-json-field.d.ts +28 -0
  109. package/dist/src/lib/config-json-field.js +56 -0
  110. package/dist/src/lib/config-json-field.js.map +1 -0
  111. package/dist/src/lib/config-object-descriptor.js +88 -6
  112. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  113. package/dist/src/lib/config-payload.d.ts +8 -1
  114. package/dist/src/lib/config-payload.js +49 -4
  115. package/dist/src/lib/config-payload.js.map +1 -1
  116. package/dist/src/lib/config-surface.d.ts +12 -1
  117. package/dist/src/lib/config-surface.js +71 -3
  118. package/dist/src/lib/config-surface.js.map +1 -1
  119. package/dist/src/lib/config.d.ts +31 -13
  120. package/dist/src/lib/config.js +34 -70
  121. package/dist/src/lib/config.js.map +1 -1
  122. package/dist/src/lib/credentials-store.d.ts +37 -22
  123. package/dist/src/lib/credentials-store.js +103 -97
  124. package/dist/src/lib/credentials-store.js.map +1 -1
  125. package/dist/src/lib/db-codegen/dbTypeIR.js +8 -0
  126. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  127. package/dist/src/lib/deprecation.d.ts +22 -0
  128. package/dist/src/lib/deprecation.js +43 -0
  129. package/dist/src/lib/deprecation.js.map +1 -0
  130. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  131. package/dist/src/lib/document-export-permissions.js +54 -0
  132. package/dist/src/lib/document-export-permissions.js.map +1 -0
  133. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  134. package/dist/src/lib/document-ingest-artifact.js +505 -0
  135. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  136. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  137. package/dist/src/lib/document-ingest-input.js +132 -0
  138. package/dist/src/lib/document-ingest-input.js.map +1 -0
  139. package/dist/src/lib/document-ingest-rows.d.ts +137 -0
  140. package/dist/src/lib/document-ingest-rows.js +181 -0
  141. package/dist/src/lib/document-ingest-rows.js.map +1 -0
  142. package/dist/src/lib/document-ingest.d.ts +101 -0
  143. package/dist/src/lib/document-ingest.js +384 -0
  144. package/dist/src/lib/document-ingest.js.map +1 -0
  145. package/dist/src/lib/env-resolver-core.d.ts +89 -18
  146. package/dist/src/lib/env-resolver-core.js +145 -23
  147. package/dist/src/lib/env-resolver-core.js.map +1 -1
  148. package/dist/src/lib/env-resolver.d.ts +18 -3
  149. package/dist/src/lib/env-resolver.js +23 -3
  150. package/dist/src/lib/env-resolver.js.map +1 -1
  151. package/dist/src/lib/function-bundle.d.ts +147 -0
  152. package/dist/src/lib/function-bundle.js +341 -0
  153. package/dist/src/lib/function-bundle.js.map +1 -0
  154. package/dist/src/lib/function-collect.d.ts +123 -0
  155. package/dist/src/lib/function-collect.js +610 -0
  156. package/dist/src/lib/function-collect.js.map +1 -0
  157. package/dist/src/lib/function-db-types.d.ts +202 -0
  158. package/dist/src/lib/function-db-types.js +870 -0
  159. package/dist/src/lib/function-db-types.js.map +1 -0
  160. package/dist/src/lib/function-document-types.d.ts +144 -0
  161. package/dist/src/lib/function-document-types.js +370 -0
  162. package/dist/src/lib/function-document-types.js.map +1 -0
  163. package/dist/src/lib/function-grants-preflight.d.ts +64 -0
  164. package/dist/src/lib/function-grants-preflight.js +105 -0
  165. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  166. package/dist/src/lib/function-log-lines.d.ts +76 -0
  167. package/dist/src/lib/function-log-lines.js +160 -0
  168. package/dist/src/lib/function-log-lines.js.map +1 -0
  169. package/dist/src/lib/function-log-row.d.ts +29 -0
  170. package/dist/src/lib/function-log-row.js +73 -0
  171. package/dist/src/lib/function-log-row.js.map +1 -0
  172. package/dist/src/lib/function-log-tail.d.ts +132 -0
  173. package/dist/src/lib/function-log-tail.js +262 -0
  174. package/dist/src/lib/function-log-tail.js.map +1 -0
  175. package/dist/src/lib/function-run.d.ts +289 -0
  176. package/dist/src/lib/function-run.js +389 -0
  177. package/dist/src/lib/function-run.js.map +1 -0
  178. package/dist/src/lib/function-schema-codegen.d.ts +143 -0
  179. package/dist/src/lib/function-schema-codegen.js +420 -0
  180. package/dist/src/lib/function-schema-codegen.js.map +1 -0
  181. package/dist/src/lib/function-sync.d.ts +459 -0
  182. package/dist/src/lib/function-sync.js +1258 -0
  183. package/dist/src/lib/function-sync.js.map +1 -0
  184. package/dist/src/lib/function-trigger-listing.d.ts +27 -0
  185. package/dist/src/lib/function-trigger-listing.js +70 -0
  186. package/dist/src/lib/function-trigger-listing.js.map +1 -0
  187. package/dist/src/lib/function-typecheck.d.ts +86 -0
  188. package/dist/src/lib/function-typecheck.js +370 -0
  189. package/dist/src/lib/function-typecheck.js.map +1 -0
  190. package/dist/src/lib/function-versions.d.ts +122 -0
  191. package/dist/src/lib/function-versions.js +182 -0
  192. package/dist/src/lib/function-versions.js.map +1 -0
  193. package/dist/src/lib/generated-allowlist.js +4 -0
  194. package/dist/src/lib/generated-allowlist.js.map +1 -1
  195. package/dist/src/lib/generated-config-surfaces.d.ts +2332 -78
  196. package/dist/src/lib/generated-config-surfaces.js +6979 -468
  197. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  198. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  199. package/dist/src/lib/generated-sdk-types.js +13 -0
  200. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  201. package/dist/src/lib/init-adopt.js +2 -2
  202. package/dist/src/lib/init-adopt.js.map +1 -1
  203. package/dist/src/lib/init-assets.js +3 -3
  204. package/dist/src/lib/init-config.d.ts +1 -1
  205. package/dist/src/lib/init-ios-links.d.ts +6 -5
  206. package/dist/src/lib/init-ios-links.js +9 -8
  207. package/dist/src/lib/init-ios-links.js.map +1 -1
  208. package/dist/src/lib/init-plan.d.ts +1 -1
  209. package/dist/src/lib/init-plan.js +1 -1
  210. package/dist/src/lib/init-xcode.d.ts +6 -5
  211. package/dist/src/lib/init-xcode.js +34 -10
  212. package/dist/src/lib/init-xcode.js.map +1 -1
  213. package/dist/src/lib/ios-app-id.d.ts +34 -0
  214. package/dist/src/lib/ios-app-id.js +69 -0
  215. package/dist/src/lib/ios-app-id.js.map +1 -0
  216. package/dist/src/lib/list-options.d.ts +68 -0
  217. package/dist/src/lib/list-options.js +89 -0
  218. package/dist/src/lib/list-options.js.map +1 -0
  219. package/dist/src/lib/local-state.d.ts +1 -1
  220. package/dist/src/lib/local-state.js +1 -1
  221. package/dist/src/lib/local-test-cases.d.ts +1 -1
  222. package/dist/src/lib/local-test-cases.js +2 -1
  223. package/dist/src/lib/local-test-cases.js.map +1 -1
  224. package/dist/src/lib/log-inspection.d.ts +152 -5
  225. package/dist/src/lib/log-inspection.js +179 -2
  226. package/dist/src/lib/log-inspection.js.map +1 -1
  227. package/dist/src/lib/logout-admin-session.d.ts +33 -0
  228. package/dist/src/lib/logout-admin-session.js +70 -0
  229. package/dist/src/lib/logout-admin-session.js.map +1 -0
  230. package/dist/src/lib/output.d.ts +15 -0
  231. package/dist/src/lib/output.js +28 -0
  232. package/dist/src/lib/output.js.map +1 -1
  233. package/dist/src/lib/paginate.d.ts +15 -0
  234. package/dist/src/lib/paginate.js +17 -0
  235. package/dist/src/lib/paginate.js.map +1 -1
  236. package/dist/src/lib/project-config.d.ts +38 -21
  237. package/dist/src/lib/project-config.js +57 -47
  238. package/dist/src/lib/project-config.js.map +1 -1
  239. package/dist/src/lib/prompt-cost-format.d.ts +11 -0
  240. package/dist/src/lib/prompt-cost-format.js +41 -0
  241. package/dist/src/lib/prompt-cost-format.js.map +1 -0
  242. package/dist/src/lib/prompt-schema-codegen.d.ts +147 -0
  243. package/dist/src/lib/prompt-schema-codegen.js +462 -0
  244. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  245. package/dist/src/lib/refresh-admin-credentials.d.ts +9 -1
  246. package/dist/src/lib/refresh-admin-credentials.js +22 -2
  247. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  248. package/dist/src/lib/resolve-owner.d.ts +19 -0
  249. package/dist/src/lib/resolve-owner.js +20 -0
  250. package/dist/src/lib/resolve-owner.js.map +1 -0
  251. package/dist/src/lib/skill-installer.d.ts +49 -3
  252. package/dist/src/lib/skill-installer.js +275 -100
  253. package/dist/src/lib/skill-installer.js.map +1 -1
  254. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  255. package/dist/src/lib/snapshot-audit-source.js +58 -0
  256. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  257. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  258. package/dist/src/lib/snapshot-audit-store.js +196 -0
  259. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  260. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  261. package/dist/src/lib/snapshot-audit.js +431 -0
  262. package/dist/src/lib/snapshot-audit.js.map +1 -0
  263. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  264. package/dist/src/lib/snapshot-build-rows.js +87 -0
  265. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  266. package/dist/src/lib/snapshot-build.d.ts +50 -0
  267. package/dist/src/lib/snapshot-build.js +111 -0
  268. package/dist/src/lib/snapshot-build.js.map +1 -0
  269. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  270. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  271. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  272. package/dist/src/lib/snapshots.d.ts +8 -9
  273. package/dist/src/lib/snapshots.js +20 -83
  274. package/dist/src/lib/snapshots.js.map +1 -1
  275. package/dist/src/lib/step-run-table.d.ts +43 -0
  276. package/dist/src/lib/step-run-table.js +129 -0
  277. package/dist/src/lib/step-run-table.js.map +1 -0
  278. package/dist/src/lib/storage-pending-retry.d.ts +26 -0
  279. package/dist/src/lib/storage-pending-retry.js +42 -0
  280. package/dist/src/lib/storage-pending-retry.js.map +1 -0
  281. package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
  282. package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
  283. package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
  284. package/dist/src/lib/swift-codegen/banners.d.ts +24 -0
  285. package/dist/src/lib/swift-codegen/banners.js +25 -0
  286. package/dist/src/lib/swift-codegen/banners.js.map +1 -0
  287. package/dist/src/lib/swift-codegen/dbGenerator.js +19 -7
  288. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  289. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +139 -0
  290. package/dist/src/lib/swift-codegen/functionGenerator.js +462 -0
  291. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -0
  292. package/dist/src/lib/swift-codegen/generator.d.ts +7 -1
  293. package/dist/src/lib/swift-codegen/generator.js +28 -11
  294. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  295. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +15 -0
  296. package/dist/src/lib/swift-codegen/schemaToSwift.js +21 -4
  297. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -1
  298. package/dist/src/lib/sync-paths.d.ts +72 -55
  299. package/dist/src/lib/sync-paths.js +97 -100
  300. package/dist/src/lib/sync-paths.js.map +1 -1
  301. package/dist/src/lib/sync-resource-types.d.ts +23 -4
  302. package/dist/src/lib/sync-resource-types.js +106 -8
  303. package/dist/src/lib/sync-resource-types.js.map +1 -1
  304. package/dist/src/lib/sync-selectors.d.ts +45 -2
  305. package/dist/src/lib/sync-selectors.js +66 -5
  306. package/dist/src/lib/sync-selectors.js.map +1 -1
  307. package/dist/src/lib/template.d.ts +2 -2
  308. package/dist/src/lib/template.js +2 -2
  309. package/dist/src/lib/test-case-keys.d.ts +1 -1
  310. package/dist/src/lib/test-case-keys.js +1 -1
  311. package/dist/src/lib/test-case-variables.d.ts +14 -0
  312. package/dist/src/lib/test-case-variables.js +42 -0
  313. package/dist/src/lib/test-case-variables.js.map +1 -1
  314. package/dist/src/lib/toml-database-config.js +19 -2
  315. package/dist/src/lib/toml-database-config.js.map +1 -1
  316. package/dist/src/lib/webhook-deliver.d.ts +209 -0
  317. package/dist/src/lib/webhook-deliver.js +519 -0
  318. package/dist/src/lib/webhook-deliver.js.map +1 -0
  319. package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
  320. package/dist/src/lib/workflow-codegen/generator.js +1 -1
  321. package/dist/src/lib/workflow-toml-validator.d.ts +17 -8
  322. package/dist/src/lib/workflow-toml-validator.js +25 -12
  323. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  324. package/dist/src/lib/workflow-usage.d.ts +196 -0
  325. package/dist/src/lib/workflow-usage.js +310 -0
  326. package/dist/src/lib/workflow-usage.js.map +1 -0
  327. package/dist/src/types/index.d.ts +12 -2
  328. package/package.json +5 -4
@@ -3,13 +3,17 @@ 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";
9
+ // #1446's `type: "json"` emission rule, shared with `serializeFunction` since
10
+ // #3317 — see `config-json-field.ts`.
11
+ import { serializeJsonField } from "../lib/config-json-field.js";
8
12
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
9
13
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
14
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
11
- import { getServerUrl, resolveAppId } from "../lib/config.js";
12
- import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
15
+ import { getCurrentAppId, getServerUrl } from "../lib/config.js";
16
+ import { resolveSyncDir, resolveSnapshotsRoot, syncStatePath, syncStateReadPath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
13
17
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
14
18
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
15
19
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -29,9 +33,40 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
29
33
  // payload builder so the diff normalizer and the wire cannot disagree.
30
34
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
31
35
  // #2644 — the one definition per configuration object, vendored from the server.
32
- 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, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
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";
33
37
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
34
- import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
38
+ import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
39
+ // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
40
+ // preflight refuses exactly what the admin routes refuse.
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";
50
+ // #3179 — the function's two halves: the header payloads and the envelope
51
+ // `config push` sends, and the bytes `config pull` writes back.
52
+ import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
53
+ import { newerVersionLine, pullNewerVersionLine, rollbackDiffHint, } from "../lib/function-versions.js";
54
+ import { FunctionBuildError } from "../lib/function-bundle.js";
55
+ // #3182 — the local half of capability validation. The grammar is the vendored
56
+ // module the server also imports; this is where an author's own file is read.
57
+ import { configObjectKeysFromTree, functionGrantErrors, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
58
+ // #3182 — the typed handle's generated declarations and the compiler wiring
59
+ // that makes an editor load them.
60
+ import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
61
+ // #3281 — the functions declaration and the per-key invokers ride the same
62
+ // plan; a malformed schema is their one refusal, named by file.
63
+ import { FunctionSchemaCodegenError } from "../lib/function-schema-codegen.js";
64
+ // #3455 — the one resolution rule push, pull and diff share for the project's
65
+ // document schema.
66
+ import { isFunctionDeclarationFile, resolveDocumentSchema, } from "../lib/function-document-types.js";
67
+ // #3331 — and the compiler is actually asked: a push holds the code it ships
68
+ // to the declarations it just generated.
69
+ import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
35
70
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
36
71
  import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
37
72
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -41,7 +76,7 @@ import { slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-fi
41
76
  // diff and their tests have always imported them from this module.
42
77
  export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-file-names.js";
43
78
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
44
- import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
79
+ import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, trackedSelectorKeys, unmatchedSelectors, } from "../lib/sync-selectors.js";
45
80
  import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
46
81
  import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
47
82
  import chalk from "chalk";
@@ -64,7 +99,7 @@ function printStaleInstanceWarning(updated) {
64
99
  }
65
100
  }
66
101
  function loadSyncState(configDir) {
67
- const syncFile = join(configDir, ".primitive-sync.json");
102
+ const syncFile = syncStateReadPath(configDir);
68
103
  if (!existsSync(syncFile)) {
69
104
  return null;
70
105
  }
@@ -76,7 +111,7 @@ function loadSyncState(configDir) {
76
111
  }
77
112
  }
78
113
  function saveSyncState(configDir, state) {
79
- const syncFile = join(configDir, ".primitive-sync.json");
114
+ const syncFile = syncStatePath(configDir);
80
115
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
81
116
  }
82
117
  /**
@@ -690,6 +725,20 @@ export function validateVarsFile(parsedVars, options = {}) {
690
725
  * edit — reported as a conflict instead. `force` skips the conflict guard,
691
726
  * matching `config push --force` for every other entity.
692
727
  *
728
+ * A var only the SERVER moved is the other half of the same comparison, and it
729
+ * is reported rather than skipped (issue #3714). Its local value still equals
730
+ * the baseline, so there is nothing to push and no conflict — but the skip that
731
+ * follows from "local is unchanged" used to be taken before the snapshot was
732
+ * consulted at all, so the push said nothing about a var edited in the Admin
733
+ * Console and counted it among the skipped-unchanged. The `drifted[]` keys are
734
+ * what every other synced type reaches through `classifyPushChange` →
735
+ * `server-drifted`: the caller prints `DRIFT var: <key>` under "Server drift —
736
+ * not pushed", writes nothing, and leaves the exit code alone. A baseline key
737
+ * the snapshot no longer holds at all is the same one-sided move (the server
738
+ * deleted the row; the local file stands still) and is reported the same way.
739
+ * `--force` is the resolution that keeps the local value, `config pull` the one
740
+ * that takes the server's.
741
+ *
693
742
  * Pure and side-effect-free (the caller owns the API calls and sync-state
694
743
  * updates) so the guard is unit-testable without a live server. Assumes
695
744
  * `parsedVars` has already passed `validateVarsFile` (values are strings). The
@@ -703,6 +752,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
703
752
  const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
704
753
  const upserts = [];
705
754
  const conflicts = [];
755
+ const drifted = [];
706
756
  const deletions = [];
707
757
  let skippedCount = 0;
708
758
  const localKeys = new Set();
@@ -710,9 +760,32 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
710
760
  localKeys.add(key);
711
761
  const valueHash = hashVarValue(value);
712
762
  const existing = baseline?.[key];
713
- // Unchanged since last sync — skip (unless forced), mirroring the
714
- // content-hash skip the other file-backed entities use.
763
+ // Unchanged since last sync — nothing to push (unless forced), mirroring
764
+ // the content-hash skip the other file-backed entities use. But "the local
765
+ // file did not move" is only half the comparison: ask the snapshot whether
766
+ // the SERVER moved before calling the pair unchanged (issue #3714). One
767
+ // side having moved is drift, which is reported and not written; only a
768
+ // pair that still agrees is a skipped-unchanged file.
715
769
  if (!options.force && existing && existing.contentHash === valueHash) {
770
+ const remote = remoteByKey.get(key);
771
+ if (!remote) {
772
+ // The row is gone from an authoritative snapshot (the caller fails the
773
+ // push closed when the fetch fails, and the `--force` fallback that
774
+ // leaves it empty never reaches here): the server deleted what we last
775
+ // synced while the file stood still. `--force` recreates it from the
776
+ // local value; `config pull` drops the line.
777
+ drifted.push({ key, serverModifiedAt: "deleted" });
778
+ continue;
779
+ }
780
+ if (typeof remote.value === "string" &&
781
+ hashVarValue(remote.value) !== existing.contentHash) {
782
+ drifted.push({ key, serverModifiedAt: remote.updatedAt || "unknown" });
783
+ continue;
784
+ }
785
+ // Either both sides still hold the last-synced value, or the snapshot
786
+ // carries no readable value for the key and nothing establishes a move.
787
+ // Nothing is written on this path either way, so the only question is
788
+ // what to report, and an unprovable drift is not one to claim.
716
789
  skippedCount++;
717
790
  continue;
718
791
  }
@@ -800,7 +873,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
800
873
  }
801
874
  deletions.push(key);
802
875
  }
803
- return { upserts, deletions, conflicts, skippedCount };
876
+ return { upserts, deletions, conflicts, drifted, skippedCount };
804
877
  }
805
878
  /**
806
879
  * Compute the number of config vars the server would hold after a push plan
@@ -1594,6 +1667,30 @@ export function buildBlobBucketUpdatePayload(bucket) {
1594
1667
  }
1595
1668
  return updatePayload;
1596
1669
  }
1670
+ /**
1671
+ * The `[[configs]] status` the server assigns when a file names none (#3201).
1672
+ *
1673
+ * Taken from the definition, not spelled again here: pull omits the key at
1674
+ * this value and push sends this value for the omitted key, and those two
1675
+ * statements have to be the same one or a pulled file would not describe the
1676
+ * state it was pulled from.
1677
+ */
1678
+ const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "status");
1679
+ /**
1680
+ * #3201's post-create status patch is RETIRED — #3799 (DSO-3799-005).
1681
+ *
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.
1693
+ */
1597
1694
  /**
1598
1695
  * Which `[[configs]]` entry a prompt file marks as live (issue #2645).
1599
1696
  *
@@ -1701,17 +1798,43 @@ export function serializePrompt(prompt, logger) {
1701
1798
  ...projectRecordToToml(PROMPT_TABLE, prompt, {
1702
1799
  inputSchema: jsonField(promptSubject),
1703
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),
1704
1806
  }),
1705
1807
  },
1706
1808
  configs: (prompt.configs || []).map((config) => ({
1707
1809
  ...projectRecordToToml(PROMPT_CONFIG_TABLE, config, {
1810
+ // #3201 — the per-version key, written only when the config is
1811
+ // RETIRED. `active` is what the create handler assigns and what an
1812
+ // absent key means (the definition's `serverDefault`), so emitting it
1813
+ // put a `status = "active"` line in every pulled prompt file: the one
1814
+ // spelling the availability contract says a config file never carries
1815
+ // (#2803), on the line `config create` scaffolds the `active = true`
1816
+ // marker onto. A retired config still says so — that is what #2645 put
1817
+ // the key in the file for.
1818
+ status: (record) => record?.status === PROMPT_CONFIG_DEFAULT_STATUS
1819
+ ? undefined
1820
+ : record?.status,
1708
1821
  // Stored as a string on the model and returned parsed; coerce
1709
1822
  // defensively so an older server's string still writes as a number.
1710
1823
  temperature: (record) => record.temperature != null ? Number(record.temperature) : undefined,
1711
1824
  topP: (record) => (record.topP != null ? Number(record.topP) : undefined),
1712
1825
  outputSchema: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1713
1826
  providerConfig: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
1714
- }),
1827
+ // #3626, CR3626-002 — the decisions declaration is a `json` field and
1828
+ // goes out the same way its siblings do. Written straight through, a
1829
+ // stored question carrying a `null` anywhere lost that member on pull
1830
+ // (smol-toml has no null), and a `null` inside a LIST threw, taking
1831
+ // `config pull` and `config diff` down with it. The JSON-string
1832
+ // fallback keeps the declaration whole either way.
1833
+ questions: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
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) }),
1715
1838
  // #2645 — the live config has to be identifiable in the repo. Emitted
1716
1839
  // only on the active entry (`undefined` elsewhere so the key is omitted),
1717
1840
  // which keeps the marker unambiguous by construction.
@@ -1783,11 +1906,43 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
1783
1906
  // dropped and leaving a stale rule live. On create it is omitted so
1784
1907
  // the push-adopt path (#1006) can still reach the server's 409.
1785
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,
1919
+ // #3626, CR3626-004 — the kind NORMALIZED, exactly as the resolver
1920
+ // that validates it reads it. `kind = " chat "` and `kind = ""` both
1921
+ // mean `chat` to `resolvePromptKind`, so the server stores `chat` and
1922
+ // the comparison — which projects `[prompt]` in create mode precisely
1923
+ // so a changed kind is visible (SO3626-008) — would otherwise read the
1924
+ // authored spelling against the stored one and call a freshly created
1925
+ // prompt an attempted kind change, refused as immutable on every later
1926
+ // push including `--force`. An UNKNOWN kind is passed through as
1927
+ // written: it is a refusal (preflight, then the create handler), and it
1928
+ // has to name what the file says.
1929
+ kind: (value) => {
1930
+ if (value === undefined || value === null)
1931
+ return "chat";
1932
+ if (typeof value !== "string")
1933
+ return value;
1934
+ const text = value.trim();
1935
+ return text === "" ? "chat" : text;
1936
+ },
1786
1937
  },
1787
1938
  }),
1788
1939
  };
1789
1940
  if (create)
1790
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;
1791
1946
  return payload;
1792
1947
  }
1793
1948
  /**
@@ -1817,11 +1972,25 @@ export function buildPromptCreatePayload(key, tomlData) {
1817
1972
  for (const owned of ["configName", ...SEEDED_CONFIG_DEFERRED]) {
1818
1973
  delete seed[owned];
1819
1974
  }
1975
+ const prompt = buildPromptPushPayload(key, tomlData, { mode: "create" });
1976
+ // #3626, SO3626-001 — the placeholder is a CHAT fallback. A decisions prompt
1977
+ // has no template at all (its input is a `state` value plus its questions),
1978
+ // and injecting one would store a chat key the block rule then refuses on
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;
1988
+ }
1820
1989
  return {
1821
- ...buildPromptPushPayload(key, tomlData, { mode: "create" }),
1990
+ ...prompt,
1822
1991
  ...seed,
1823
- // The server requires a template; keep the historical placeholder so a
1824
- // prompt authored without one still creates.
1992
+ // The server requires a template for a chat config; keep the historical
1993
+ // placeholder so a prompt authored without one still creates.
1825
1994
  userPromptTemplate: seed.userPromptTemplate || "{{ input }}",
1826
1995
  };
1827
1996
  }
@@ -1852,15 +2021,26 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
1852
2021
  * `seededConfigName` is what the server called it — read from the create
1853
2022
  * response rather than assumed, so a handler that one day honors the name
1854
2023
  * produces no needless PATCH.
2024
+ *
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.
1855
2030
  */
1856
2031
  export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
1857
2032
  const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
1858
2033
  const patch = {};
1859
2034
  for (const field of SEEDED_CONFIG_DEFERRED) {
1860
2035
  const value = full[field];
1861
- // A value the entry does not carry is left alone: the seeded config has
1862
- // none either, so there is nothing to clear.
1863
- if (value === undefined || value === null)
2036
+ // #3626 — an omitted value CLEARS. The create handler does not merely skip
2037
+ // `description`, it writes the hard-coded "Default configuration", so
2038
+ // leaving it alone left every newly-pushed prompt reporting `Modified`
2039
+ // forever against a description its file never asked for: push's own
2040
+ // "the file is the whole truth" rule (#1567), broken by the one field the
2041
+ // create body cannot carry. `outputSchema` is unaffected — create never
2042
+ // sets the seeded config's, so the null is a no-op there.
2043
+ if (value === undefined)
1864
2044
  continue;
1865
2045
  patch[field] = value;
1866
2046
  }
@@ -1879,62 +2059,62 @@ export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "de
1879
2059
  */
1880
2060
  export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
1881
2061
  const create = mode === "create";
1882
- return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
2062
+ /**
2063
+ * Issue #1567, generalized at #3358 (DSO-3358-005): the field is TOML-OWNED
2064
+ * on update — send `?? null` so removing the line from a `[[configs]]` block
2065
+ * CLEARS it on the server (the PATCH nulls it on an explicit `null` and
2066
+ * preserves it on omit). Without this an absent key is `undefined`, which
2067
+ * `JSON.stringify` drops, so a deleted line left the stored value live and
2068
+ * the file stopped describing what runs.
2069
+ *
2070
+ * `create` still omits: a create body that nulled every unset key would
2071
+ * differ from the one the server has always received.
2072
+ */
2073
+ const tomlOwned = (value) => (create ? value : (value ?? null));
2074
+ // #3626 §4 — the nine chat keys moved under `[configs.chat]`, and the flat
2075
+ // spelling is accepted as DEPRECATED. Hoisting here rather than at each call
2076
+ // site means push, `config diff` and the create-seed builder all read a file
2077
+ // the same way, so an unmigrated file and its pulled rewrite compare Synced.
2078
+ // A key set BOTH ways is refused in preflight
2079
+ // (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
2080
+ // standing so nothing here has to guess.
2081
+ const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
2082
+ return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
1883
2083
  mode,
1884
2084
  values: {
1885
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),
1886
2092
  model: (value) => create ? value || "google/gemini-2.0-flash-001" : value,
1887
- // Issue #1567: `description` is TOML-owned on update — send `?? null` so
1888
- // removing it from a `[[configs]]` block clears it on the server (the
1889
- // PATCH nulls it on an explicit `null`, preserves on omit).
1890
- description: (value) => (create ? value : (value ?? null)),
2093
+ description: tomlOwned,
2094
+ systemPrompt: tomlOwned,
2095
+ userPromptTemplate: tomlOwned,
2096
+ temperature: tomlOwned,
2097
+ topP: tomlOwned,
2098
+ maxTokens: tomlOwned,
2099
+ outputFormat: tomlOwned,
2100
+ outputSchema: tomlOwned,
2101
+ providerConfig: tomlOwned,
2102
+ // #3358 — the two reasoning keys are what made the gap load-bearing:
2103
+ // they are mutually exclusive, so switching between them is a deleted
2104
+ // line plus an added one, and a deleted line that does not clear leaves
2105
+ // the merged config refused as "both set".
2106
+ reasoningEffort: tomlOwned,
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,
2111
+ // #3626, SO3626-002 — the decisions declaration is TOML-owned on the
2112
+ // same terms: deleting `[configs.decisions]` has to clear `questions`
2113
+ // server-side, or the file stops describing what the model is asked.
2114
+ questions: tomlOwned,
1891
2115
  },
1892
2116
  });
1893
2117
  }
1894
- /**
1895
- * Emit a `type: "json"` field (a JSON Schema, a provider-options blob) as TOML.
1896
- *
1897
- * Issue #1446: pull used to `JSON.stringify` schema fields into a single
1898
- * escaped-JSON line — unreadable and undiffable. We now emit the parsed value
1899
- * as a native table whenever it round-trips faithfully
1900
- * (`canEmitNative === null`), falling back to the JSON string only for the
1901
- * values TOML silently drops (`null` / `undefined` anywhere inside). The empty
1902
- * object `{}` is a real, distinct value (for a schema: "accepts anything") and
1903
- * emits as an empty table, so it survives a pull → push cycle instead of being
1904
- * cleared server-side.
1905
- *
1906
- * Returns `undefined` for an absent value (the key is then omitted). The
1907
- * sponsor-approved decision is to always move to native aggressively — no
1908
- * form preservation — so this does not consult the existing file's form.
1909
- *
1910
- * `subject` names the object in the fallback warning, e.g. `Workflow 'greet'`.
1911
- */
1912
- function serializeJsonField(value, fieldName, subject, log) {
1913
- if (value === undefined || value === null)
1914
- return undefined;
1915
- // JSON fields arrive from the server GET parsed; defensively accept a JSON
1916
- // string too (e.g. an inline-authored file re-serialized here).
1917
- let obj = value;
1918
- if (typeof value === "string") {
1919
- try {
1920
- obj = JSON.parse(value);
1921
- }
1922
- catch {
1923
- // Not valid JSON — keep the original string as the emitted value.
1924
- return value;
1925
- }
1926
- }
1927
- if (obj && typeof obj === "object") {
1928
- const reason = canEmitNative(obj);
1929
- if (reason) {
1930
- log(`${subject}: cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
1931
- return JSON.stringify(obj);
1932
- }
1933
- return obj;
1934
- }
1935
- // A non-object value (unexpected) — preserve it as a JSON string.
1936
- return JSON.stringify(obj);
1937
- }
1938
2118
  /** The `[workflow]` case of `serializeJsonField` (#1446, message unchanged). */
1939
2119
  function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
1940
2120
  return serializeJsonField(value, fieldName, `Workflow '${workflowKey}'`, log);
@@ -2007,11 +2187,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2007
2187
  : []
2008
2188
  : [at];
2009
2189
  const unknown = new Set();
2190
+ // #3626 — a key inside a declared `[configs.<group>]` block that no field
2191
+ // declares under it, and a group value that is not a table at all. Reported
2192
+ // with the group in the path (`configs.chat.nope`) so the author is told
2193
+ // where the CLI looked.
2194
+ const groupedUnknown = new Set();
2010
2195
  for (const entry of entries) {
2011
2196
  for (const key of findUnknownTomlKeys(entry, table))
2012
2197
  unknown.add(key);
2198
+ for (const key of findUnknownGroupedTomlKeys(entry, table)) {
2199
+ groupedUnknown.add(key);
2200
+ }
2013
2201
  }
2014
- if (unknown.size === 0)
2202
+ if (unknown.size === 0 && groupedUnknown.size === 0)
2015
2203
  return null;
2016
2204
  // #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
2017
2205
  // once beside the definitions (`src/config-surface/retired-keys.ts`), not the
@@ -2028,8 +2216,33 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2028
2216
  for (const key of retiredHere) {
2029
2217
  messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
2030
2218
  }
2031
- if (trulyUnknown.length > 0) {
2032
- messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, trulyUnknown));
2219
+ // #3626, SO3626-013 — a key the definition DOES declare, written at the
2220
+ // table root when it is authored under a group. Before grouping,
2221
+ // `acceptedTomlKeys` took every field key at the root, so a flat `questions`
2222
+ // passed this check and was then dropped in silence by the payload builder.
2223
+ // It is an unknown key now, and the message says where it belongs rather
2224
+ // than sending the author to the generic "check the spelling" hint.
2225
+ const misplaced = trulyUnknown
2226
+ .map((key) => ({
2227
+ key,
2228
+ groups: tomlGroupsOf(table.fields.find((f) => f.tomlKey === key && f.tomlGroup) ??
2229
+ {}),
2230
+ }))
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.
2234
+ messages.push(`${filePath}: ${prefix}[0].${key} is not a [[${prefix}]] key; ` +
2235
+ `write it under ${groups.map((g) => `[${prefix}.${g}]`).join(" or ")}`);
2236
+ }
2237
+ const misplacedKeys = new Set(misplaced.map((m) => m.key));
2238
+ const stillUnknown = trulyUnknown.filter((k) => !misplacedKeys.has(k));
2239
+ if (stillUnknown.length > 0) {
2240
+ messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, stillUnknown));
2241
+ }
2242
+ if (groupedUnknown.size > 0) {
2243
+ messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, [
2244
+ ...groupedUnknown,
2245
+ ].sort()));
2033
2246
  }
2034
2247
  return messages.join("\n");
2035
2248
  }
@@ -2113,6 +2326,11 @@ function localConfigRowKey(label, tomlData, file) {
2113
2326
  return stated("prompt", "key");
2114
2327
  case "workflow":
2115
2328
  return stated("workflow", "key");
2329
+ // #3320 — the declared key, like the function preflight collector, the
2330
+ // apply loop and `--only`. A renamed file is one object, so both
2331
+ // collectors' messages land on the one comparison row.
2332
+ case "function":
2333
+ return stated("function", "key");
2116
2334
  case "email-template":
2117
2335
  return stated("template", "emailType");
2118
2336
  case "database-type-config":
@@ -2138,6 +2356,410 @@ function localConfigRowKey(label, tomlData, file) {
2138
2356
  return fallback;
2139
2357
  }
2140
2358
  }
2359
+ /**
2360
+ * `config push`'s preflight — every check the authored files decide on their
2361
+ * own, run before the FIRST mutating call (#976 fix A, #3320, #3375).
2362
+ *
2363
+ * The block below was inline inside the push command's `.action()` callback
2364
+ * until #3375 lifted it here unchanged. It had to become an exported symbol
2365
+ * for the registry's promise to be checkable at all: a rule declared
2366
+ * `preflight` says it runs before `config push` mutates anything, and
2367
+ * `src/config-surface/preflight-reachability.ts` resolves that claim
2368
+ * statically from the declared `<module>#<export>` against exactly this entry
2369
+ * point. With the preflight anonymous, "reached from the preflight" had
2370
+ * nothing to resolve against, and the stage string was satisfied by typing it.
2371
+ *
2372
+ * Its boundary is therefore precise and load-bearing: this function is what
2373
+ * contributes to `preflightValidationErrors` and aborts. The advisory reads
2374
+ * that follow the abort in the action — the remote vars snapshot and both caps
2375
+ * — stay there and are declared `apply`, because the server is what enforces
2376
+ * them (`intent` §Non-goals, "Emptying the apply stage").
2377
+ *
2378
+ * A network call inside a preflight is not disqualifying: `listIntegrations`
2379
+ * was already here, answering "will this `integration:` grant have a target?"
2380
+ * against the live listing merged with the tree, and falling back to the tree
2381
+ * alone when the listing cannot be fetched.
2382
+ *
2383
+ * `input` names what the block used to close over. `client` is narrowed to the
2384
+ * one method it calls, so an apply call cannot drift in here later without
2385
+ * that being a visible change to this signature.
2386
+ */
2387
+ export async function runConfigPushPreflight(input) {
2388
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
2389
+ /**
2390
+ * Narrow a type's file list to the `--only` selection — the same one-line
2391
+ * closure the action holds, rebuilt from the selection it passed in rather
2392
+ * than handed across the boundary as a function.
2393
+ */
2394
+ const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
2395
+ // ── Issue #976 (fix A): front-load ALL client-side TOML validation
2396
+ // before the first mutating call. Push is apply-as-you-go and processes
2397
+ // entities in a fixed sequence (cron triggers are created *before*
2398
+ // workflows are validated). Previously each entity's validation lived
2399
+ // inside its own apply loop and aborted via `process.exit(1)` — so a
2400
+ // known-bad sibling file (e.g. an invalid workflow TOML) could let an
2401
+ // earlier create (a cron trigger) be applied, then abort. The
2402
+ // `process.exit` also bypassed the save-on-failure catch below, so the
2403
+ // orphaned create never reached the sync-state baseline and the retry
2404
+ // re-issued a CREATE that the server rejected with 409 forever.
2405
+ //
2406
+ // Running every validation up-front means a known-bad file aborts
2407
+ // BEFORE anything is created. We collect all errors (not just the first)
2408
+ // and THROW — so the single save-on-failure catch is the only exit path
2409
+ // (no more `process.exit` bypass class). The per-loop validators below
2410
+ // remain as defense-in-depth but are converted to throws too.
2411
+ const preflightValidationErrors = [];
2412
+ // Validate all workflow TOMLs (#685 misnested-header check and the
2413
+ // #1447 system + accessRule lint).
2414
+ const preflightWorkflowsDir = join(configDir, "workflows");
2415
+ if (existsSync(preflightWorkflowsDir)) {
2416
+ // Under `--only` the preflight validates just the selected files:
2417
+ // an unrelated broken workflow elsewhere in the slot must not block
2418
+ // applying the one object the operator asked for.
2419
+ for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
2420
+ const filePath = join(preflightWorkflowsDir, file);
2421
+ try {
2422
+ const tomlData = parseTomlFile(filePath);
2423
+ // #1660 — skip platform-owned workflows (the reserved
2424
+ // `__internal.*` namespace) BEFORE validating, mirroring the
2425
+ // apply loop below. `config push` validates every workflow TOML
2426
+ // here, up-front, before reaching that skip — so without this
2427
+ // second skip a stale/hollow `__internal.*.toml` with an invalid
2428
+ // `steps` shape would abort the whole push in preflight. Resolve
2429
+ // the key the same way the apply loop does (`[workflow].key`,
2430
+ // falling back to the filename) and match on the resolved key,
2431
+ // never the filename alone, so a renamed file is still caught.
2432
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
2433
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
2434
+ continue;
2435
+ }
2436
+ const tomlErrors = validateWorkflowToml(tomlData);
2437
+ if (tomlErrors.length > 0) {
2438
+ preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
2439
+ }
2440
+ // #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
2441
+ // now no-ops). Printed once per file at pre-flight so the author
2442
+ // sees them whether or not the push goes on to apply.
2443
+ for (const note of collectWorkflowTomlNotes(tomlData)) {
2444
+ console.log(` ${filePath}: ${note}`);
2445
+ }
2446
+ const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
2447
+ if (unknownKeyError) {
2448
+ preflightValidationErrors.push(` ${unknownKeyError}`);
2449
+ }
2450
+ // #2743 — the always-send update payload cannot carry a missing
2451
+ // `[workflow].name`. That is a local TOML error like every other
2452
+ // one here, so it belongs in the front-loaded pass (#976): caught
2453
+ // in the apply loop it would abort AFTER earlier resources (app
2454
+ // settings, cron triggers, …) had already been written, and
2455
+ // `--dry-run` — which never reaches the apply — would plan an
2456
+ // update the real push then refuses.
2457
+ //
2458
+ // Scoped to the workflows this push will actually UPDATE. A
2459
+ // create falls back to the manifest key, so an absent name is
2460
+ // legitimate authoring there — and once that create lands, the
2461
+ // same unchanged file must keep pushing clean (the apply loop
2462
+ // skips it on its content hash) instead of failing a push that
2463
+ // was never going to send it.
2464
+ if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, force)) {
2465
+ const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
2466
+ if (nameError) {
2467
+ preflightValidationErrors.push(` ${nameError}`);
2468
+ }
2469
+ }
2470
+ }
2471
+ catch (err) {
2472
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2473
+ }
2474
+ }
2475
+ }
2476
+ // Validate all database-type TOMLs (issue #803 subscription
2477
+ // access/accessRule conflict + issue #752 $params references).
2478
+ const preflightDbTypesDir = join(configDir, "database-type-configs");
2479
+ if (existsSync(preflightDbTypesDir)) {
2480
+ for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
2481
+ const filePath = join(preflightDbTypesDir, file);
2482
+ let rawToml;
2483
+ let tomlData;
2484
+ try {
2485
+ rawToml = readFileSync(filePath, "utf-8");
2486
+ tomlData = parseTomlFile(filePath);
2487
+ }
2488
+ catch (err) {
2489
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2490
+ continue;
2491
+ }
2492
+ let operations = [];
2493
+ try {
2494
+ ({ operations } = parseDatabaseTypeToml(tomlData));
2495
+ }
2496
+ catch (err) {
2497
+ if (err instanceof SubscriptionAccessKeyConflictError) {
2498
+ preflightValidationErrors.push(` ${file}: ${err.message}`);
2499
+ continue;
2500
+ }
2501
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2502
+ continue;
2503
+ }
2504
+ const validation = validateOperations({
2505
+ filePath,
2506
+ rawToml,
2507
+ operations,
2508
+ });
2509
+ for (const e of validation.errors) {
2510
+ preflightValidationErrors.push(` ${formatIssue(e)}`);
2511
+ }
2512
+ // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
2513
+ // / `metadataAccess` gate on a database type config is a single CEL
2514
+ // expression that gates BOTH read and update of the database CEL
2515
+ // context — granting read implicitly grants update. Steer operators
2516
+ // to resource metadata categories (separate readRule/writeRule).
2517
+ // This is advisory only: the push continues and the runtime gate is
2518
+ // unchanged. It fires only when the deprecated key is actually
2519
+ // present, so metadata-category-only configs never trip it.
2520
+ const typeSection = tomlData.type || {};
2521
+ if (typeSection.celContextAccess || typeSection.metadataAccess) {
2522
+ const usedKey = typeSection.celContextAccess
2523
+ ? "celContextAccess"
2524
+ : "metadataAccess";
2525
+ warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
2526
+ }
2527
+ }
2528
+ }
2529
+ // Validate app.toml settings up-front (issue #1033). A mistyped value
2530
+ // is a hard error that must abort BEFORE any mutation — same
2531
+ // fail-before-mutate contract as the vars preflight below. The apply
2532
+ // block re-parses for the payload.
2533
+ // #2645 — bounded by `--only`, like the workflow and database-type
2534
+ // preflights above. `config push --only workflow/x` is the replacement for
2535
+ // `workflows update --from-file`, so it must work on a tree where
2536
+ // app.toml happens to be mid-edit.
2537
+ const preflightAppTomlPath = join(configDir, "app.toml");
2538
+ if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
2539
+ try {
2540
+ const parsedApp = parseTomlFile(preflightAppTomlPath);
2541
+ const appErrors = collectAppSettingsPushErrors(parsedApp, {
2542
+ // #2880 DSO-001 — the same exclusion the apply block computes, so
2543
+ // the preflight validates the body that will actually be sent.
2544
+ excludeName: appSettingsNameExcluded({
2545
+ isCrossAppPush: !!isCrossAppPush,
2546
+ recordedExcludedName: syncState?.entities?.app?.excludedName,
2547
+ fileName: parsedApp?.app?.name,
2548
+ }),
2549
+ });
2550
+ for (const e of appErrors) {
2551
+ preflightValidationErrors.push(` app.toml: ${e}`);
2552
+ }
2553
+ }
2554
+ catch (err) {
2555
+ preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
2556
+ }
2557
+ }
2558
+ // Validate config vars up-front (issue #1423 review). Parse vars.toml
2559
+ // and check the same key/value constraints the server enforces here, in
2560
+ // the preflight pass, so an invalid entry aborts BEFORE any mutation
2561
+ // (app settings or a var upsert) is applied. Previously validation ran
2562
+ // mid-push, after app settings could already have been updated.
2563
+ // #2645 — bounded by `--only`, for the same reason as app.toml above.
2564
+ const preflightVarsTomlPath = join(configDir, "vars.toml");
2565
+ const varsSelected = selectionIncludesAnyVar(pushSelection);
2566
+ let preflightParsedVars = null;
2567
+ if (varsSelected && existsSync(preflightVarsTomlPath)) {
2568
+ try {
2569
+ const parsed = parseTomlFile(preflightVarsTomlPath);
2570
+ // #2645 — under `--only var/<key>` only the selected entries are
2571
+ // checked. The rest of the file is not being written, and aborting
2572
+ // over an entry this push will not touch is the same over-reach as
2573
+ // running the leg at all for an unselected singleton.
2574
+ for (const e of validateVarsFile(parsed, {
2575
+ onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
2576
+ })) {
2577
+ preflightValidationErrors.push(` ${e}`);
2578
+ }
2579
+ preflightParsedVars = parsed;
2580
+ }
2581
+ catch (err) {
2582
+ preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2583
+ }
2584
+ }
2585
+ // #3182 — a function's capabilities, checked against the author's own
2586
+ // file before anything is applied. The grammar is purely local (and
2587
+ // since #3279 it keeps three kinds: `integration:<key>`,
2588
+ // `secret:<NAME>` and the high-blast opt-ins — a retired grant string
2589
+ // is refused here with the retirement); whether an `integration:`
2590
+ // names an integration that will exist is answered against the
2591
+ // tree's own `integrations/` (applied BEFORE functions) merged with
2592
+ // the integrations the server already has. If the live listing cannot
2593
+ // be fetched and the tree declares none, the target check is skipped
2594
+ // and the server — which is authoritative — has the last word.
2595
+ const preflightFunctionsDir = join(configDir, "functions");
2596
+ if (existsSync(preflightFunctionsDir)) {
2597
+ const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter(isFunctionDeclarationFile));
2598
+ if (functionFiles.length > 0) {
2599
+ const treeTargets = configObjectKeysFromTree(configDir);
2600
+ let knownTargets = null;
2601
+ try {
2602
+ const integrations = await client.listIntegrations(resolvedAppId, {
2603
+ status: "active",
2604
+ });
2605
+ knownTargets = mergeKnownGrantTargets({
2606
+ integrations: new Set((integrations?.items ?? [])
2607
+ .map((row) => String(row?.integrationKey ?? ""))
2608
+ .filter(Boolean)),
2609
+ }, treeTargets);
2610
+ }
2611
+ catch {
2612
+ knownTargets =
2613
+ treeTargets.integrations.size > 0 ? treeTargets : null;
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
+ }
2624
+ for (const file of functionFiles) {
2625
+ const filePath = join(preflightFunctionsDir, file);
2626
+ let tomlData;
2627
+ try {
2628
+ tomlData = parseTomlFile(filePath);
2629
+ }
2630
+ catch (err) {
2631
+ preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
2632
+ continue;
2633
+ }
2634
+ for (const message of functionGrantErrors({
2635
+ filePath,
2636
+ tomlData,
2637
+ knownTargets,
2638
+ })) {
2639
+ preflightValidationErrors.push(` ${message}`);
2640
+ }
2641
+ // #3320 — and everything else the file decides on its own: the
2642
+ // `entry`, the mode the two spellings resolve to, the trigger
2643
+ // rules, and a webhook trigger's signing-secret requirement and
2644
+ // reference shape. These used to be raised inside the apply loop,
2645
+ // after webhooks, integrations, vars and database types had
2646
+ // already been written — the partial tree this issue is about.
2647
+ // Bounded by the same `selectFiles` above, so `--only` keeps its
2648
+ // posture (#2645).
2649
+ const functionKey = tomlData?.function?.key || basename(file, ".toml");
2650
+ for (const message of functionTomlPreflightErrors({
2651
+ filePath,
2652
+ functionKey,
2653
+ tomlData,
2654
+ })) {
2655
+ preflightValidationErrors.push(` ${filePath}: ${message}`);
2656
+ }
2657
+ // #3482 — the retired runtime keys. NOT refusals through phase 4:
2658
+ // the file pushes with every other key applied, and the author
2659
+ // hears which line to delete before phase 5 refuses it. On
2660
+ // stderr, like every other advisory, so a scripted push reading
2661
+ // stdout is unaffected. Inside this loop rather than over the
2662
+ // whole tree, so `--only` bounds the advice as it bounds the
2663
+ // refusals (#2645).
2664
+ for (const message of functionTomlIgnoredKeyWarnings({
2665
+ functionKey,
2666
+ tomlData,
2667
+ })) {
2668
+ warn(` ${message}`);
2669
+ }
2670
+ }
2671
+ }
2672
+ }
2673
+ // #2644 — reject any key the vendored definition does not recognize,
2674
+ // for every migrated configuration type. Same posture the `[workflow]`
2675
+ // check above has, generalized: a mistyped key fails here, in the
2676
+ // preflight pass, so nothing is applied and no request is issued —
2677
+ // rather than being dropped on the way to the server and reading as a
2678
+ // successful push that changed nothing.
2679
+ // #2880 — the same collector `config diff` runs, so the two commands
2680
+ // cannot disagree about which files are pushable.
2681
+ for (const rejected of collectConfigFileValidationErrors(configDir)) {
2682
+ for (const message of rejected.messages) {
2683
+ preflightValidationErrors.push(` ${message}`);
2684
+ }
2685
+ }
2686
+ // #3626 §4 — one advisory line per prompt file still written in the flat
2687
+ // chat spelling, beside the `celContextAccess` notice above. The push
2688
+ // continues and the keys are applied; `config pull` rewrites the file, so a
2689
+ // single pull-then-push clears the notice. Printed even under `--dry-run`,
2690
+ // which never reaches the apply — the notice is about the FILE.
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;
2695
+ if (existsSync(preflightPromptsDir)) {
2696
+ for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
2697
+ const filePath = join(preflightPromptsDir, file);
2698
+ let tomlData;
2699
+ try {
2700
+ tomlData = parseTomlFile(filePath);
2701
+ }
2702
+ catch {
2703
+ // A file that does not parse is reported by the collector above.
2704
+ continue;
2705
+ }
2706
+ const notice = promptFlatChatKeyWarning(filePath, tomlData);
2707
+ if (notice)
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
+ }
2732
+ }
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
+ }
2739
+ // The `<key>.tests/*.toml` sidecars, which sit beside the block they
2740
+ // test rather than in a type directory of their own. Same posture,
2741
+ // reached through the registry's `test-case` surface — and through the
2742
+ // same collector `config diff` runs per sidecar (#2880), so neither
2743
+ // command can refuse a file the other calls Synced.
2744
+ for (const filePath of testCaseTomlFiles(configDir)) {
2745
+ let tomlData;
2746
+ try {
2747
+ tomlData = parseTomlFile(filePath);
2748
+ }
2749
+ catch {
2750
+ // Reported by the per-block test-case loop with its own message.
2751
+ continue;
2752
+ }
2753
+ for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
2754
+ preflightValidationErrors.push(` ${message}`);
2755
+ }
2756
+ }
2757
+ if (preflightValidationErrors.length > 0) {
2758
+ throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2759
+ preflightValidationErrors.join("\n"));
2760
+ }
2761
+ return { parsedVars: preflightParsedVars };
2762
+ }
2141
2763
  /**
2142
2764
  * Every per-entity config file whose TOML `config push` would refuse (#2880
2143
2765
  * criterion 1, symmetry).
@@ -2153,13 +2775,440 @@ function localConfigRowKey(label, tomlData, file) {
2153
2775
  * whose spelling is not the declared type. A file that does not parse is left
2154
2776
  * to the per-type loop that reads it, which names the parse error itself.
2155
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
+ }
2813
+ /**
2814
+ * A prompt file's KIND errors — #3626, decidable from the file alone.
2815
+ *
2816
+ * Three refusals, all of them the same rule seen from different angles: a key
2817
+ * set both flat and under `[configs.chat]` (the hoist will not guess), a
2818
+ * `[[configs]]` entry carrying the block of the OTHER kind, and a malformed
2819
+ * decisions declaration. The last two come from `resolvePromptKind`, the same
2820
+ * vendored function the admin routes validate with, so the CLI's refusal and
2821
+ * the server's cannot disagree — and this one lands in the preflight, before
2822
+ * the first mutating call.
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
+ }
2842
+ export function promptKindFileErrors(filePath, tomlData) {
2843
+ const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
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
+ }
2874
+ for (const [index, entry] of entries.entries()) {
2875
+ const hoisted = hoistDeprecatedChatKeys(entry);
2876
+ for (const key of hoisted.conflicts) {
2877
+ errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
2878
+ `[configs.chat]; keep the [configs.chat] spelling`);
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
+ }
2912
+ const chat = hoisted.entry.chat ?? {};
2913
+ const decisions = hoisted.entry.decisions ?? {};
2914
+ // #3626 — the FIRST entry of a chat prompt carries the create seed's
2915
+ // placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
2916
+ // file names no template, which is how a prompt authored without one has
2917
+ // always created; refusing it here would break a file that pushed before
2918
+ // the kind existed. An entry that really names none on the UPDATE path is
2919
+ // still refused, by the server, with this same message.
2920
+ //
2921
+ // CR3626-005 — the fallback is `||`, not `??`, because that is what
2922
+ // `buildPromptCreatePayload` applies (`seed.userPromptTemplate ||
2923
+ // "{{ input }}"`). `config create prompt` scaffolds an EMPTY
2924
+ // `userPromptTemplate`, so reading absence more narrowly than the builder
2925
+ // does refused the file this CLI had just written, for a value the create
2926
+ // would have replaced with the placeholder anyway.
2927
+ const seedsTemplate = index === 0 && fileKind === "chat";
2928
+ const userPromptTemplate = seedsTemplate
2929
+ ? chat.userPromptTemplate || "{{ input }}"
2930
+ : chat.userPromptTemplate;
2931
+ const resolution = resolvePromptKind({
2932
+ kind: tomlData?.prompt?.kind,
2933
+ provider: hoisted.entry.provider,
2934
+ model: hoisted.entry.model,
2935
+ config: {
2936
+ systemPrompt: chat.systemPrompt,
2937
+ userPromptTemplate,
2938
+ temperature: chat.temperature,
2939
+ topP: chat.topP,
2940
+ maxTokens: chat.maxTokens,
2941
+ outputFormat: chat.outputFormat,
2942
+ outputSchema: chat.outputSchema,
2943
+ reasoningEffort: chat.reasoningEffort,
2944
+ reasoningBudget: chat.reasoningBudget,
2945
+ strictOutput: chat.strictOutput,
2946
+ questions: decisions.questions,
2947
+ },
2948
+ });
2949
+ if (resolution.ok === false) {
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;
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);
3139
+ }
3140
+ return errors;
3141
+ }
3142
+ /**
3143
+ * The immutable-`kind` refusal for a prompt update the comparison gate did not
3144
+ * decide — #3626, CR3626-001.
3145
+ *
3146
+ * `kind` is create-only (D3626-008), and the gate refuses a change to it on
3147
+ * every path that compares the two sides. Two update paths do not compare: a
3148
+ * prompt whose DETAIL read failed falls back to the manifest byte-hash gate,
3149
+ * which `--force` writes straight through, and a prompt ADOPTED after a
3150
+ * create's 409 is re-issued as an update without a comparison at all. Both
3151
+ * would otherwise PATCH the file's schemas, access rule and metadata onto a
3152
+ * row of the other kind, and only then have its configs refused against the
3153
+ * kind the update cannot change.
3154
+ *
3155
+ * Returns the gate's own outcome shape, so `recordDeclined` prints the same
3156
+ * two values and the same remedy — create the prompt under a new key.
3157
+ *
3158
+ * `null` when the kinds agree and when the server's kind is simply not KNOWN:
3159
+ * a push that could not read live state at all has nothing to compare, and
3160
+ * that path's contract (`decideDegradedPush`, #2880) is already "decline
3161
+ * unless the operator forces it", stated for every field at once.
3162
+ */
3163
+ export function promptKindImmutableOutcome(storedKind, localDoc) {
3164
+ if (!storedKind)
3165
+ return null;
3166
+ const localKind = promptKindOf(localDoc?.prompt);
3167
+ if (localKind === storedKind)
3168
+ return null;
3169
+ return {
3170
+ action: "immutable",
3171
+ // The hashes are the gate's evidence for WHICH side moved, and this
3172
+ // refusal does not depend on that: the field cannot be written either way.
3173
+ localHash: "",
3174
+ remoteHash: "",
3175
+ fields: [{ field: "kind", local: localKind, server: storedKind }],
3176
+ immutableFields: ["kind"],
3177
+ };
3178
+ }
3179
+ /**
3180
+ * The one deprecation notice a prompt file using the FLAT chat spelling earns
3181
+ * — #3626 §4, beside the `celContextAccess` notice's precedent.
3182
+ *
3183
+ * Advisory: the push continues and the keys are applied. `null` when the file
3184
+ * is already written in the grouped spelling, so a migrated tree is silent.
3185
+ */
3186
+ export function promptFlatChatKeyWarning(filePath, tomlData) {
3187
+ const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
3188
+ const moved = new Set();
3189
+ for (const entry of entries) {
3190
+ for (const key of hoistDeprecatedChatKeys(entry).moved)
3191
+ moved.add(key);
3192
+ }
3193
+ if (moved.size === 0)
3194
+ return null;
3195
+ const named = [...moved].sort().join(", ");
3196
+ return (`${filePath}: ${named} ${moved.size === 1 ? "is" : "are"} written flat ` +
3197
+ "under [[configs]]; this spelling is deprecated — move them under " +
3198
+ "[configs.chat] (`config pull` rewrites the file). Rejecting the flat " +
3199
+ "spelling is tracked in #3642.");
3200
+ }
2156
3201
  export function collectConfigFileValidationErrors(configDir) {
2157
3202
  const found = [];
2158
3203
  for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
2159
3204
  const typeDir = join(configDir, dir);
2160
3205
  if (!existsSync(typeDir))
2161
3206
  continue;
2162
- for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
3207
+ for (const file of readdirSync(typeDir).filter((f) =>
3208
+ // #3455 — `functions/` holds one GENERATED `.toml` beside the
3209
+ // declarations: the project's document schema, which is not a config
3210
+ // object and would be read as a function named after the file.
3211
+ dir === "functions" ? isFunctionDeclarationFile(f) : f.endsWith(".toml"))) {
2163
3212
  const filePath = join(typeDir, file);
2164
3213
  let tomlData;
2165
3214
  try {
@@ -2184,6 +3233,12 @@ export function collectConfigFileValidationErrors(configDir) {
2184
3233
  for (const table of surface.tables) {
2185
3234
  messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
2186
3235
  }
3236
+ // #3626 — the prompt kind's own file-decidable refusals, run by the same
3237
+ // collector so `config push` and `config diff` cannot disagree about
3238
+ // which prompt files are pushable.
3239
+ if (surface.label === "prompt") {
3240
+ messages.push(...promptKindFileErrors(filePath, tomlData));
3241
+ }
2187
3242
  if (messages.length === 0)
2188
3243
  continue;
2189
3244
  found.push({
@@ -2196,6 +3251,104 @@ export function collectConfigFileValidationErrors(configDir) {
2196
3251
  }
2197
3252
  return found;
2198
3253
  }
3254
+ /**
3255
+ * Every `functions/<key>.toml` whose file-decidable errors `config push` would
3256
+ * refuse (#3320).
3257
+ *
3258
+ * A second collector rather than a branch inside
3259
+ * `collectConfigFileValidationErrors`, and deliberately so: push calls that one
3260
+ * UNBOUNDED over the whole tree, which is right for the checks it runs (a
3261
+ * mistyped key anywhere means the tree is not pushable) and wrong for these — a
3262
+ * `config push --only webhook/<key>` must not be blocked by a broken function
3263
+ * the operator did not select (#2645). The push path therefore runs these
3264
+ * inside its own `selectFiles`-bounded function loop, and `config diff`, which
3265
+ * compares the whole tree by construction, runs them from here.
3266
+ *
3267
+ * A file that does not parse is skipped: the per-type loop that reads it names
3268
+ * the parse error itself, and reporting it twice helps nobody.
3269
+ */
3270
+ export function collectFunctionTomlValidationErrors(configDir) {
3271
+ const found = [];
3272
+ const functionsDir = join(configDir, "functions");
3273
+ if (!existsSync(functionsDir))
3274
+ return found;
3275
+ for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
3276
+ const filePath = join(functionsDir, file);
3277
+ let tomlData;
3278
+ try {
3279
+ tomlData = parseTomlFile(filePath);
3280
+ }
3281
+ catch {
3282
+ continue;
3283
+ }
3284
+ // The DECLARED key, like the apply loop and `--only` selection: a file
3285
+ // whose name and `[function].key` differ is one object, not two.
3286
+ const key = tomlData?.function?.key || basename(file, ".toml");
3287
+ const messages = functionTomlPreflightErrors({
3288
+ filePath,
3289
+ functionKey: key,
3290
+ tomlData,
3291
+ });
3292
+ if (messages.length === 0)
3293
+ continue;
3294
+ found.push({ type: "function", key, filePath, messages });
3295
+ }
3296
+ return found;
3297
+ }
3298
+ /**
3299
+ * The retired runtime keys every `functions/*.toml` in the tree still carries
3300
+ * — #3482, the warning half of the collector above.
3301
+ *
3302
+ * Separate from the errors because it IS separate: a file carrying one of
3303
+ * these pushes, with every other key applied, so it is not a row `config diff`
3304
+ * calls invalid. It is a line an author reads on stderr from both commands, so
3305
+ * the two keep reading identically (the #2880 symmetry the errors collector
3306
+ * exists for, applied to advice rather than refusals).
3307
+ */
3308
+ export function collectFunctionTomlIgnoredKeyWarnings(configDir) {
3309
+ const found = [];
3310
+ const functionsDir = join(configDir, "functions");
3311
+ if (!existsSync(functionsDir))
3312
+ return found;
3313
+ for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
3314
+ const filePath = join(functionsDir, file);
3315
+ let tomlData;
3316
+ try {
3317
+ tomlData = parseTomlFile(filePath);
3318
+ }
3319
+ catch {
3320
+ continue;
3321
+ }
3322
+ const key = tomlData?.function?.key || basename(file, ".toml");
3323
+ found.push(...functionTomlIgnoredKeyWarnings({ functionKey: key, tomlData }));
3324
+ }
3325
+ return found;
3326
+ }
3327
+ /**
3328
+ * The collectors' rows, one per diff row (#2880 symmetry, #3320).
3329
+ *
3330
+ * Two collectors reach the same function file — the definition-driven one
3331
+ * every per-entity type runs, and the function preflight — and a file can
3332
+ * break both at once (a mistyped `entrty`, which then leaves no `entry`).
3333
+ * Push reports every message from both, so an overlay that let one row's
3334
+ * messages REPLACE the other's would drop a diagnostic push prints, which is
3335
+ * exactly the asymmetry #2880 forbids. Merging by row keeps the two commands
3336
+ * reading identically.
3337
+ */
3338
+ export function mergeConfigFileValidationErrors(collected) {
3339
+ const byRow = new Map();
3340
+ for (const rejected of collected) {
3341
+ const rowId = `${rejected.type}${rejected.key}`;
3342
+ const merged = byRow.get(rowId);
3343
+ if (!merged) {
3344
+ byRow.set(rowId, { ...rejected, messages: [...rejected.messages] });
3345
+ continue;
3346
+ }
3347
+ // Collection order is the message order, the same order push prints.
3348
+ merged.messages.push(...rejected.messages);
3349
+ }
3350
+ return [...byRow.values()];
3351
+ }
2199
3352
  /**
2200
3353
  * Every `<key>.tests/*.toml` sidecar under a config directory.
2201
3354
  *
@@ -2292,6 +3445,17 @@ export function workflowPushSendsUpdate(configDir, key, parsedWorkflowToml, sync
2292
3445
  *
2293
3446
  * Returns `null` when the name is present.
2294
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
+ }
2295
3459
  export function workflowUpdateNameError(filePath, workflow) {
2296
3460
  const name = workflow?.name;
2297
3461
  if (typeof name === "string" && name.trim() !== "")
@@ -3154,6 +4318,114 @@ export function diffProjectedRecords(local, remote) {
3154
4318
  }
3155
4319
  return diffs;
3156
4320
  }
4321
+ /**
4322
+ * The paths at which two projected records disagree, named the way the FILE
4323
+ * spells them (#3267) — what a `config diff` Modified row prints.
4324
+ *
4325
+ * `diffProjectedRecords` answers at the granularity of the projection, and
4326
+ * for a database type that is too coarse to act on: the 27 operations arrive
4327
+ * as one `operations` extra, the whole `[models.*]` tree as one `schema`
4328
+ * string. A row that read Modified for a reason the operator could not see —
4329
+ * an empty params set spelled two ways, an operation block the file declares
4330
+ * twice — was undiagnosable from the CLI. So this walks INTO each differing
4331
+ * field: a keyed child collection (`spec.childKeys`) is compared row by row
4332
+ * and named `operations["getInstitution"].params`, with a row only one side
4333
+ * has or that the file repeats named as such; a TOML-string field
4334
+ * (`spec.tomlFields`) is parsed on both sides and named by the path inside it
4335
+ * (`schema (models.Institution.fields.name.required)`); everything else is
4336
+ * walked as a value to its first differing leaf.
4337
+ *
4338
+ * Paths come back in projected-key order, keyed rows in key order, so the
4339
+ * list is stable across runs, and every leaf is its own entry — a schema
4340
+ * that differs in seven model fields is seven paths, so the caller's "+N
4341
+ * more" cap counts them the way it counts operations. A value the walk
4342
+ * cannot descend into (an array of a different length, a scalar against an
4343
+ * object) is named at the level it stopped.
4344
+ */
4345
+ export function describeProjectedDifferences(spec, local, remote) {
4346
+ const paths = [];
4347
+ for (const { field, local: l, server: r } of diffProjectedRecords(local, remote)) {
4348
+ const childKey = spec.childKeys?.[field];
4349
+ if (childKey && Array.isArray(l) && Array.isArray(r)) {
4350
+ paths.push(...keyedChildDifferences(field, childKey, l, r));
4351
+ continue;
4352
+ }
4353
+ if (spec.tomlFields?.includes(field) && typeof l === "string" && typeof r === "string") {
4354
+ let inner;
4355
+ try {
4356
+ inner = valueDifferences("", parseConfigToml(l), parseConfigToml(r));
4357
+ }
4358
+ catch {
4359
+ // A side that does not parse is named as the whole field below.
4360
+ }
4361
+ if (inner) {
4362
+ if (inner.length === 0) {
4363
+ paths.push(field);
4364
+ }
4365
+ else {
4366
+ paths.push(...inner.map((path) => `${field} (${path})`));
4367
+ }
4368
+ continue;
4369
+ }
4370
+ }
4371
+ paths.push(...valueDifferences(field, l, r));
4372
+ }
4373
+ return paths;
4374
+ }
4375
+ /** The leaf paths under `path` at which two values differ (see above). */
4376
+ function valueDifferences(path, a, b) {
4377
+ if (canonicalJsonStringify(a) === canonicalJsonStringify(b))
4378
+ return [];
4379
+ const join = (key) => (path ? `${path}.${key}` : key);
4380
+ if (isPlainObject(a) && isPlainObject(b)) {
4381
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
4382
+ return keys.flatMap((key) => valueDifferences(join(key), a[key], b[key]));
4383
+ }
4384
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
4385
+ return a.flatMap((item, i) => valueDifferences(`${path}[${i}]`, item, b[i]));
4386
+ }
4387
+ return [path || "(document)"];
4388
+ }
4389
+ /**
4390
+ * A keyed child collection's differences, row by row. A key the file repeats
4391
+ * is named as the cause rather than compared: the server keys rows by it, so
4392
+ * only one of the blocks landed and no push can make the two sides agree
4393
+ * while the file says two things under one name.
4394
+ */
4395
+ function keyedChildDifferences(field, key, local, remote) {
4396
+ const label = (k) => `${field}[${JSON.stringify(k)}]`;
4397
+ const localByKey = new Map();
4398
+ for (const row of local) {
4399
+ const k = String(row?.[key] ?? "");
4400
+ localByKey.set(k, [...(localByKey.get(k) ?? []), row]);
4401
+ }
4402
+ const remoteByKey = new Map(remote.map((row) => [String(row?.[key] ?? ""), row]));
4403
+ const keys = [...new Set([...localByKey.keys(), ...remoteByKey.keys()])].sort();
4404
+ const paths = [];
4405
+ for (const k of keys) {
4406
+ const rows = localByKey.get(k);
4407
+ const remoteRow = remoteByKey.get(k);
4408
+ if (rows && rows.length > 1) {
4409
+ paths.push(`${label(k)} (declared ${rows.length} times locally)`);
4410
+ }
4411
+ else if (!rows) {
4412
+ paths.push(`${label(k)} (server only)`);
4413
+ }
4414
+ else if (remoteRow === undefined) {
4415
+ paths.push(`${label(k)} (local only)`);
4416
+ }
4417
+ else {
4418
+ paths.push(...valueDifferences(label(k), rows[0], remoteRow));
4419
+ }
4420
+ }
4421
+ return paths;
4422
+ }
4423
+ function isPlainObject(value) {
4424
+ return (value !== null &&
4425
+ typeof value === "object" &&
4426
+ !Array.isArray(value) &&
4427
+ typeof value.toJSON !== "function");
4428
+ }
3157
4429
  /**
3158
4430
  * The differing fields the definition says an UPDATE does not accept (#2731 B8).
3159
4431
  *
@@ -3172,6 +4444,10 @@ export function classifyPushChange(input) {
3172
4444
  const { localHash, remoteHash, baselineHash, legacy } = input;
3173
4445
  if (localHash === remoteHash)
3174
4446
  return "unchanged";
4447
+ // Ahead of both the baseline and the legacy inference, which are ways of
4448
+ // GUESSING what this states outright.
4449
+ if (input.serverUnchangedSinceSync === true)
4450
+ return "local-edited";
3175
4451
  if (baselineHash) {
3176
4452
  if (remoteHash === baselineHash)
3177
4453
  return "local-edited";
@@ -3335,6 +4611,43 @@ const CONFIG_DIFF_SPECS = {
3335
4611
  serialize: (record) => serializeEmailTemplate(record),
3336
4612
  describe: (entity) => `email template "${entity?.emailType}"`,
3337
4613
  },
4614
+ // #3179 — server functions. The compared entity spans the header AND the
4615
+ // keys the immutable version carries (`entry`, `mode`, `limits`, …),
4616
+ // because an author writes one file and a version is never authored
4617
+ // separately: comparing only the header would report a changed entry point as
4618
+ // Synced. The SOURCE bytes are a second axis the table cannot express — they
4619
+ // are compared by `envelopeHash`, which is also what the server dedupes on.
4620
+ "function": {
4621
+ label: "function",
4622
+ table: SERVER_FUNCTION_TABLE,
4623
+ parse: (doc) => functionDiffEntity(doc),
4624
+ // `extra` is the ACTIVE config version, which is where the remote side's
4625
+ // entry/mode/limits live; the header row does not carry them.
4626
+ serialize: (record, _maps, extra) => serializeFunction(record, extra),
4627
+ // The version-carried keys are not fields of the header table, so they
4628
+ // travel as extras — present on both sides or absent on both.
4629
+ extras: (entity) => ({
4630
+ // #3317 — `inputSchema` / `outputSchema` compared as the values they
4631
+ // encode, the rule prompts have had since #2748. A schema reaches the
4632
+ // two sides in either spelling (a native `[function.inputSchema]` table,
4633
+ // or the JSON string the server stores and the fallback pull writes for
4634
+ // a schema TOML cannot hold), and neither spelling is a difference.
4635
+ ...jsonFieldsForDiff(SERVER_FUNCTION_TABLE, entity),
4636
+ entry: entity.entry ?? null,
4637
+ // #3281 — the RESOLVED mode (`functionDiffEntity` reads both spellings),
4638
+ // so a legacy `durable = true` file and a server serializing `mode`
4639
+ // compare equal.
4640
+ mode: entity.mode ?? null,
4641
+ limits: entity.limits ?? null,
4642
+ // #3182 — the grants are version-carried too, and a widened grant set is
4643
+ // exactly the change a reviewer must not read as Synced. Sorted because
4644
+ // the row stores a set.
4645
+ capabilities: Array.isArray(entity.capabilities)
4646
+ ? [...entity.capabilities].map((c) => String(c)).sort()
4647
+ : [],
4648
+ }),
4649
+ describe: (entity) => `function "${entity?.functionKey}"`,
4650
+ },
3338
4651
  // #2757 — webhooks join the registry comparator for the same reason email
3339
4652
  // templates did: `config diff` reported existence only, so an edit that
3340
4653
  // rerouted a webhook to a different `workflowKey` read as Synced while push
@@ -3411,7 +4724,17 @@ const CONFIG_DIFF_SPECS = {
3411
4724
  // (`[prompt].key`) is still read here, because it is what `displayName`
3412
4725
  // falls back to. Diff injects the resolved key into a file that omits it,
3413
4726
  // exactly as push derives it from the file name.
3414
- parse: (doc) => buildPromptPushPayload(doc?.prompt?.key ?? "", doc),
4727
+ //
4728
+ // #3626, SO3626-008 — projected in CREATE mode. `kind` is `writableOn:
4729
+ // ["create"]`, and `buildPayloadFromToml` drops a field not writable in the
4730
+ // mode, so an update-mode projection would leave a CHANGED kind out of the
4731
+ // compared surface entirely: `findImmutableFieldDiffs` would never see it,
4732
+ // diff would call the file Synced, and push would start applying the
4733
+ // prompt's other changes before the server refused a config against the
4734
+ // unchanged kind. The blob-bucket comparator projects in create mode for
4735
+ // exactly this reason (`ttlTier`, below). PATCH payloads still omit `kind`
4736
+ // — this is the comparison's projection, not push's.
4737
+ parse: (doc) => buildPromptPushPayload(doc?.prompt?.key ?? "", doc, { mode: "create" }),
3415
4738
  serialize: (record) => serializePrompt(record),
3416
4739
  // Read from the DOCUMENT, not the parsed entity: `[[configs]]` is a sibling
3417
4740
  // table of `[prompt]`, and the prompt's own push payload does not carry it
@@ -3608,6 +4931,8 @@ const CONFIG_DIFF_SPECS = {
3608
4931
  subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
3609
4932
  _unresolvedRuleSetName: entity._unresolvedRuleSetName,
3610
4933
  }),
4934
+ childKeys: { operations: "name", subscriptions: "subscriptionKey" },
4935
+ tomlFields: ["schema"],
3611
4936
  ruleSetRef: true,
3612
4937
  describe: (entity) => `database type "${entity?.databaseType}"`,
3613
4938
  },
@@ -3702,9 +5027,11 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
3702
5027
  const localHash = computeExpandedContentHash(pair.local);
3703
5028
  if (remoteHash === localHash)
3704
5029
  return { status: "exists" };
5030
+ // #3267 — WHICH field, ahead of the remedy, so a row that differs for a
5031
+ // reason the operator cannot see in the file is diagnosable from the CLI.
3705
5032
  return {
3706
5033
  status: "modified",
3707
- hint: "run `config pull` to rewrite the local TOML to match running state",
5034
+ hint: namedDifferences(describeProjectedDifferences(spec, pair.local, pair.remote)) + "run `config pull` to rewrite the local TOML to match running state",
3708
5035
  };
3709
5036
  }
3710
5037
  catch {
@@ -3713,6 +5040,24 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
3713
5040
  return { status: "exists", hint: "content not compared (comparison failed)" };
3714
5041
  }
3715
5042
  }
5043
+ /**
5044
+ * How many differing paths a Modified row names before saying "+N more"
5045
+ * (#3267). Enough to see the shape of a difference at a glance; the row is
5046
+ * one line of a whole-tree diff, not the place to print 27 operations.
5047
+ */
5048
+ const NAMED_DIFFERENCE_LIMIT = 5;
5049
+ /**
5050
+ * The `differs in …; ` prefix of a Modified row's hint (#3267), or nothing
5051
+ * when the walk could name no path — the hash still differs, so the row is
5052
+ * still Modified, but the hint then says only what it always said.
5053
+ */
5054
+ function namedDifferences(paths) {
5055
+ if (paths.length === 0)
5056
+ return "";
5057
+ const shown = paths.slice(0, NAMED_DIFFERENCE_LIMIT).join(", ");
5058
+ const more = paths.length - NAMED_DIFFERENCE_LIMIT;
5059
+ return `differs in ${shown}${more > 0 ? ` (+${more} more)` : ""}; `;
5060
+ }
3716
5061
  /** Hash a LOCAL config file's definition-projected field set (#2644). */
3717
5062
  export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
3718
5063
  return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
@@ -3774,17 +5119,32 @@ export function decidePushForConfig(input) {
3774
5119
  fields,
3775
5120
  };
3776
5121
  }
5122
+ // The recorded server timestamp still being the live one. It is the legacy
5123
+ // path's inference signal, and — where the row's timestamp covers the whole
5124
+ // projection — #3317's outright statement that the server did not move.
5125
+ const liveModifiedAtMatchesStored = entry?.modifiedAt !== undefined &&
5126
+ live.modifiedAt !== undefined &&
5127
+ entry.modifiedAt === live.modifiedAt;
5128
+ // The recorded version pointer still being the live one — or no pointer
5129
+ // recorded, which is no claim. A pointer that moved is a server change by
5130
+ // definition, whatever the two timestamps say (#3317: the baseline's
5131
+ // timestamp comes from a refetch a concurrent activation can precede).
5132
+ const liveActiveConfigMatchesStored = entry?.activeConfigId === undefined ||
5133
+ (live.record?.activeConfigId !== undefined &&
5134
+ live.record?.activeConfigId !== null &&
5135
+ String(entry.activeConfigId) === String(live.record.activeConfigId));
3777
5136
  const direction = classifyPushChange({
3778
5137
  localHash,
3779
5138
  remoteHash,
3780
5139
  baselineHash: entry?.semanticHash,
5140
+ serverUnchangedSinceSync: input.serverModifiedAtCoversProjection === true &&
5141
+ liveModifiedAtMatchesStored &&
5142
+ liveActiveConfigMatchesStored,
3781
5143
  legacy: {
3782
5144
  localBytesMatchStoredContentHash: entry?.contentHash !== undefined &&
3783
5145
  localFileHash !== undefined &&
3784
5146
  entry.contentHash === localFileHash,
3785
- liveModifiedAtMatchesStored: entry?.modifiedAt !== undefined &&
3786
- live.modifiedAt !== undefined &&
3787
- entry.modifiedAt === live.modifiedAt,
5147
+ liveModifiedAtMatchesStored,
3788
5148
  },
3789
5149
  });
3790
5150
  // #2909 — the adopt, for a type whose push path adopts by key. `entry` is
@@ -4152,7 +5512,7 @@ export function configSemanticHash(label, filePath, maps) {
4152
5512
  /**
4153
5513
  * The sync state `config push` writes into (#2731 B6, closing #2374).
4154
5514
  *
4155
- * `loadSyncState` answers `null` for an absent `.primitive-sync.json`, and
5515
+ * `loadSyncState` answers `null` for an absent sync-state baseline, and
4156
5516
  * every per-entity state write in the push loops is guarded by `if (syncState)`
4157
5517
  * — so a first push created every resource and recorded none of them. The next
4158
5518
  * push then re-entered the create path for everything and depended on 409
@@ -4594,6 +5954,231 @@ selects = () => true) {
4594
5954
  : { ok: false };
4595
5955
  return { scriptEntities, count: written, presence, listOk: true, serverKeys };
4596
5956
  }
5957
+ /**
5958
+ * The manifest row `config push` records for a function it just pushed
5959
+ * (#3317), and whether the server had already moved past that push.
5960
+ *
5961
+ * The header is refetched AFTER the version push, so the row's `modifiedAt`
5962
+ * is the live timestamp — but the version pointer and the envelope are the
5963
+ * push's own: the manifest names what THIS run activated, not whatever the
5964
+ * refetch happened to find. A concurrent activation landing between the push
5965
+ * and the refetch is exactly the case those two must not be conflated in:
5966
+ * recording the refetched pointer would make the other operator's version the
5967
+ * baseline the local file is then read as a plain edit of, and the next push
5968
+ * would roll it back without `--force`. Recording the pushed pointer beside
5969
+ * the live timestamp is what `decidePushForConfig` reads as "the pointer
5970
+ * moved" — a refusal, until the operator pulls or forces.
5971
+ *
5972
+ * `movedTo` names the version the refetched header points at when it is not
5973
+ * the pushed one, so the caller can say so at the moment it is known rather
5974
+ * than leaving it for the next push to report.
5975
+ */
5976
+ export function pushedFunctionBaseline(input) {
5977
+ const { refreshed, pushedVersion } = input;
5978
+ const pushedConfigId = pushedVersion?.configId !== undefined && pushedVersion?.configId !== null
5979
+ ? String(pushedVersion.configId)
5980
+ : undefined;
5981
+ const liveConfigId = refreshed?.activeConfigId !== undefined && refreshed?.activeConfigId !== null
5982
+ ? String(refreshed.activeConfigId)
5983
+ : undefined;
5984
+ const movedTo = pushedConfigId !== undefined &&
5985
+ liveConfigId !== undefined &&
5986
+ liveConfigId !== pushedConfigId
5987
+ ? liveConfigId
5988
+ : null;
5989
+ return {
5990
+ entity: {
5991
+ id: input.functionId,
5992
+ modifiedAt: refreshed?.modifiedAt ||
5993
+ input.existingModifiedAt ||
5994
+ new Date().toISOString(),
5995
+ contentHash: input.contentHash,
5996
+ semanticHash: refreshed
5997
+ ? hashRemoteConfigForDiff(input.spec, refreshed, input.maps, pushedVersion ?? undefined)
5998
+ : undefined,
5999
+ envelopeHash: input.envelopeHash,
6000
+ activeConfigId: pushedConfigId,
6001
+ sources: input.sources,
6002
+ },
6003
+ movedTo,
6004
+ };
6005
+ }
6006
+ /**
6007
+ * Pull server-side `ServerFunction` rows into `functions/` (#3179).
6008
+ *
6009
+ * Two things make this different from every other type's pull:
6010
+ *
6011
+ * 1. **A function is more than its file.** The active config VERSION holds an
6012
+ * envelope with the authored TOML bytes and every source file, so the pull
6013
+ * writes `functions/<key>.toml` plus the TypeScript beside it, and records
6014
+ * the source paths so prune can remove the code with the function.
6015
+ * 2. **Bytes, not a re-serialization.** The stored TOML is written verbatim,
6016
+ * so comments, key order and line endings survive the round trip (F-009).
6017
+ * A function that has never had code pushed has no stored bytes; that one
6018
+ * gets a canonical serialization of its header, which is still a pushable
6019
+ * file.
6020
+ *
6021
+ * A stored source path is never trusted: `planFunctionPull` refuses one that
6022
+ * normalizes outside the tree, and the refusals are reported rather than
6023
+ * swallowed.
6024
+ */
6025
+ export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
6026
+ const functionsDir = join(configDir, "functions");
6027
+ ensureDir(functionsDir);
6028
+ const functionEntities = {};
6029
+ /** What each pulled version says about the document schema — CR3455-001. */
6030
+ const pulledSchemas = [];
6031
+ let items = [];
6032
+ try {
6033
+ const result = await client.listFunctions(appId);
6034
+ items = result.items || [];
6035
+ }
6036
+ catch {
6037
+ return {
6038
+ functionEntities,
6039
+ count: 0,
6040
+ presence: { ok: false },
6041
+ listOk: false,
6042
+ serverKeys: [],
6043
+ items: [],
6044
+ };
6045
+ }
6046
+ let detailsOk = true;
6047
+ let written = 0;
6048
+ for (const record of items) {
6049
+ const key = record?.functionKey;
6050
+ if (!key)
6051
+ continue;
6052
+ if (!selects(key))
6053
+ continue;
6054
+ // #2803 — a tombstone is not exported as a file. Its key stays reserved,
6055
+ // and the archived-holder message is what says so on the next push.
6056
+ if (String(record.status ?? "active") === "archived")
6057
+ continue;
6058
+ const filePath = join(functionsDir, `${key}.toml`);
6059
+ let entity = {
6060
+ id: record.functionId,
6061
+ modifiedAt: record.modifiedAt || new Date().toISOString(),
6062
+ };
6063
+ if (!record.activeConfigId) {
6064
+ // No code pushed yet: the header is all there is to write.
6065
+ // The logger carries #1446's one warning: a schema holding a `null`
6066
+ // anywhere cannot be a native table, so it is written as a JSON string
6067
+ // and the operator is told which key that happened to.
6068
+ writeFileSync(filePath, serializeFunction(record, undefined, logger));
6069
+ entity.contentHash = computeFileHash(filePath);
6070
+ entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
6071
+ functionEntities[key] = entity;
6072
+ written += 1;
6073
+ logger(` Wrote functions/${key}.toml (no version pushed yet)`);
6074
+ continue;
6075
+ }
6076
+ let active;
6077
+ let envelope;
6078
+ let newest = null;
6079
+ try {
6080
+ // #3462 — the point read. The listing is a page now, so scanning it for
6081
+ // the id would miss a rolled-back function's active version past page
6082
+ // one; the separate one-row read below is only asking whether a NEWER
6083
+ // version exists, which is what the message after the write says.
6084
+ active = record.activeConfigId
6085
+ ? await client.getFunctionConfig(appId, record.functionId, String(record.activeConfigId))
6086
+ : null;
6087
+ if (active) {
6088
+ envelope = await client.getFunctionConfigEnvelope(appId, record.functionId, active.configId);
6089
+ const page = await client.listFunctionConfigs(appId, record.functionId, {
6090
+ limit: 1,
6091
+ });
6092
+ newest = (page.items || [])[0] ?? null;
6093
+ }
6094
+ }
6095
+ catch {
6096
+ detailsOk = false;
6097
+ logger(` Skipped functions/${key}.toml (could not fetch its version)`);
6098
+ continue;
6099
+ }
6100
+ if (!active || !envelope) {
6101
+ detailsOk = false;
6102
+ logger(` Skipped functions/${key}.toml (active version not readable)`);
6103
+ continue;
6104
+ }
6105
+ // #3455 / CR3455-001 — the generated schema copy is ONE file the whole
6106
+ // tree resolves through, so this plan does not decide it. Every pulled
6107
+ // version's answer is collected and the copy is settled once, after the
6108
+ // loop; otherwise the last function applied would delete or overwrite the
6109
+ // copy an earlier one needs.
6110
+ const plan = planFunctionPull({
6111
+ configDir,
6112
+ functionKey: key,
6113
+ envelope,
6114
+ includeDocumentSchema: false,
6115
+ });
6116
+ pulledSchemas.push({
6117
+ functionKey: key,
6118
+ documentSchema: typeof envelope?.documentSchema === "string" && envelope.documentSchema
6119
+ ? envelope.documentSchema
6120
+ : null,
6121
+ });
6122
+ for (const refusal of plan.refusals)
6123
+ logger(` ${refusal}`);
6124
+ // #3482 — the retired runtime keys this pull removed on the way out. Not
6125
+ // a refusal: the file IS written, with every other byte unchanged, and
6126
+ // this is what says so rather than editing a developer's file in silence.
6127
+ for (const note of plan.notes)
6128
+ logger(` ${note}`);
6129
+ const applied = applyFunctionPull(plan);
6130
+ for (const refusal of applied.refusals)
6131
+ logger(` ${refusal}`);
6132
+ entity.contentHash = computeFileHash(filePath);
6133
+ entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, active);
6134
+ entity.envelopeHash = envelope.envelopeHash ?? active.envelopeHash;
6135
+ entity.activeConfigId = active.configId;
6136
+ // Only the files that were really written: a path the write step refused
6137
+ // is not one prune may later delete on this function's behalf.
6138
+ entity.sources = applied.writtenSources;
6139
+ functionEntities[key] = entity;
6140
+ written += 1;
6141
+ logger(` Wrote functions/${key}.toml and ${applied.writtenSources.length} source file(s)`);
6142
+ // #3462 — pull writes the ACTIVE version's bytes, which is right: the tree
6143
+ // should describe what runs. On a rolled-back function that silently
6144
+ // discards a newer version's code from the tree, so the line names both
6145
+ // versions and both remedies rather than leaving the author to discover it
6146
+ // at the next push.
6147
+ const newerLine = newerVersionLine(newest, active.configId, (value) => value);
6148
+ if (newerLine) {
6149
+ logger(` ${pullNewerVersionLine(key, String(active.configId), newest, (value) => value)}`);
6150
+ }
6151
+ }
6152
+ const serverKeys = items
6153
+ .filter((f) => String(f?.status ?? "active") !== "archived")
6154
+ .map((f) => f?.functionKey)
6155
+ .filter(Boolean);
6156
+ // #3455 / CR3455-001 — the tree's one generated schema copy, settled from
6157
+ // every version this pull read. Removing it is only safe when the pull read
6158
+ // them ALL: a selector, or a function whose version could not be fetched,
6159
+ // leaves a version whose schema this pull knows nothing about.
6160
+ const schemaPlan = planDocumentSchemaPull({
6161
+ configDir,
6162
+ functions: pulledSchemas,
6163
+ coversEveryFunction: detailsOk && serverKeys.every((k) => selects(k)),
6164
+ });
6165
+ for (const warning of schemaPlan.warnings)
6166
+ logger(` ${warning}`);
6167
+ const schemaApplied = applyFunctionPull(schemaPlan);
6168
+ for (const refusal of schemaApplied.refusals)
6169
+ logger(` ${refusal}`);
6170
+ const presence = detailsOk
6171
+ ? { ok: true, serverKeys }
6172
+ : { ok: false };
6173
+ return {
6174
+ functionEntities,
6175
+ count: written,
6176
+ presence,
6177
+ listOk: true,
6178
+ serverKeys,
6179
+ items,
6180
+ };
6181
+ }
4597
6182
  /**
4598
6183
  * Every test case a block has, draining the cursor (#2769).
4599
6184
  *
@@ -6089,26 +7674,19 @@ function readSelection(options) {
6089
7674
  * `registerConfigCommands` created rather than making one of its own.
6090
7675
  *
6091
7676
  * The module keeps its name and its ~50 exported helpers: `sync.ts`,
6092
- * `resolveSyncDir`, `.primitive-sync.json` and `.primitive/sync/<env>/<appId>/`
7677
+ * `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
6093
7678
  * are internal identifiers, deliberately left alone (#2759 scope).
6094
7679
  */
6095
7680
  export function registerConfigSyncCommands(sync) {
6096
7681
  // Init
6097
7682
  sync
6098
7683
  .command("init")
7684
+ .allowExcessArguments(false)
6099
7685
  .description("Initialize the config directory structure")
6100
- .argument("[app-id]", "App ID (uses current app if not specified)")
6101
- .option("--app <app-id>", "App ID")
6102
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6103
- .action(async (appId, options) => {
6104
- const resolvedAppId = resolveAppId(appId, options);
6105
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6106
- if (isAutoResolvedSyncDir(options.dir)) {
6107
- info(`Using per-environment config directory: ${configDir}`);
6108
- const legacyHint = checkLegacySyncMigration(configDir);
6109
- if (legacyHint)
6110
- warn(legacyHint);
6111
- }
7686
+ .action(async (options) => {
7687
+ const resolvedAppId = getCurrentAppId();
7688
+ const configDir = resolveSyncDir();
7689
+ info(`Using per-environment config directory: ${configDir}`);
6112
7690
  ensureDir(configDir);
6113
7691
  ensureDir(join(configDir, "integrations"));
6114
7692
  ensureDir(join(configDir, "webhooks"));
@@ -6117,6 +7695,7 @@ export function registerConfigSyncCommands(sync) {
6117
7695
  ensureDir(join(configDir, "prompts"));
6118
7696
  ensureDir(join(configDir, "workflows"));
6119
7697
  ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
7698
+ ensureDir(join(configDir, "functions")); // #3179 — server functions
6120
7699
  ensureDir(join(configDir, "database-type-configs"));
6121
7700
  ensureDir(join(configDir, "rule-sets"));
6122
7701
  ensureDir(join(configDir, "group-type-configs"));
@@ -6136,10 +7715,8 @@ export function registerConfigSyncCommands(sync) {
6136
7715
  // Pull
6137
7716
  sync
6138
7717
  .command("pull")
7718
+ .allowExcessArguments(false)
6139
7719
  .description("Download configuration from server to TOML files")
6140
- .argument("[app-id]", "App ID (uses current app if not specified)")
6141
- .option("--app <app-id>", "App ID")
6142
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6143
7720
  .option("--json", "Output as JSON instead of writing files")
6144
7721
  .option("--only <selectors>", "Fetch just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else in the slot is left as it is.",
6145
7722
  // Collect rather than replace, the same way push does: commander keeps
@@ -6147,14 +7724,11 @@ export function registerConfigSyncCommands(sync) {
6147
7724
  // narrow the pull to one object and report success.
6148
7725
  (value, previous) => [...(previous ?? []), value], [])
6149
7726
  .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
6150
- .action(async (appId, options) => {
6151
- const resolvedAppId = resolveAppId(appId, options);
6152
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6153
- if (isAutoResolvedSyncDir(options.dir) && !options.json) {
7727
+ .action(async (options) => {
7728
+ const resolvedAppId = getCurrentAppId();
7729
+ const configDir = resolveSyncDir();
7730
+ if (!options.json) {
6154
7731
  info(`Using per-environment config directory: ${configDir}`);
6155
- const legacyHint = checkLegacySyncMigration(configDir);
6156
- if (legacyHint)
6157
- warn(legacyHint);
6158
7732
  }
6159
7733
  // Issue #2760: pull writes the renamed directory and leaves the old one
6160
7734
  // untouched beside it, which reads as a successful pull that lost the
@@ -6354,10 +7928,7 @@ export function registerConfigSyncCommands(sync) {
6354
7928
  // perform it without a recoverable backup. No-op on a fresh/empty
6355
7929
  // sync dir (nothing to back up). Prune to 28 days after success.
6356
7930
  try {
6357
- const snapshotsRoot = resolveSnapshotsRoot({
6358
- appId: resolvedAppId,
6359
- userDir: options.dir,
6360
- });
7931
+ const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
6361
7932
  const snapshot = createSnapshot(configDir, snapshotsRoot);
6362
7933
  if (snapshot) {
6363
7934
  info(` Snapshot: ${snapshot.path}`);
@@ -6368,7 +7939,7 @@ export function registerConfigSyncCommands(sync) {
6368
7939
  error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
6369
7940
  error("Aborting pull before any local file is modified. " +
6370
7941
  "Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
6371
- "or pass a writable --dir.");
7942
+ "and that the project's .primitive/ is writable.");
6372
7943
  process.exit(1);
6373
7944
  }
6374
7945
  // Ensure directories exist
@@ -6380,6 +7951,7 @@ export function registerConfigSyncCommands(sync) {
6380
7951
  ensureDir(join(configDir, "prompts"));
6381
7952
  ensureDir(join(configDir, "workflows"));
6382
7953
  ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
7954
+ ensureDir(join(configDir, "functions")); // #3179 — server functions
6383
7955
  ensureDir(join(configDir, "database-type-configs"));
6384
7956
  ensureDir(join(configDir, "rule-sets"));
6385
7957
  ensureDir(join(configDir, "group-type-configs"));
@@ -6735,6 +8307,20 @@ export function registerConfigSyncCommands(sync) {
6735
8307
  if (pulledScriptsCount > 0) {
6736
8308
  info(` Pulled ${pulledScriptsCount} transform(s)`);
6737
8309
  }
8310
+ // Pull server functions — #3179. Writes `functions/<key>.toml` from the
8311
+ // AUTHORED bytes stored in the active version's envelope, plus every
8312
+ // source file beside it, so the round trip is byte-identical.
8313
+ const { functionEntities, count: pulledFunctionsCount, presence: functionPresence, listOk: functionListOk, serverKeys: functionServerKeys, items: functionTombstoneCandidates, } = await pullFunctions(client, resolvedAppId, configDir, info, (key) => {
8314
+ if (!pullSelection)
8315
+ return true;
8316
+ if (!selectionIncludesKey(pullSelection, "function", key))
8317
+ return false;
8318
+ matchedSelectors.add(`function/${key}`);
8319
+ return true;
8320
+ });
8321
+ if (pulledFunctionsCount > 0) {
8322
+ info(` Pulled ${pulledFunctionsCount} function(s)`);
8323
+ }
6738
8324
  // Build lookup maps for test case serialization
6739
8325
  const configIdToName = new Map();
6740
8326
  const promptIdToKey = new Map();
@@ -6922,6 +8508,21 @@ export function registerConfigSyncCommands(sync) {
6922
8508
  };
6923
8509
  info(` Wrote database-type-configs/${filename}`);
6924
8510
  }
8511
+ // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
8512
+ // they are not in the envelope, and a fresh clone must end up with the
8513
+ // same three files a push would have written. Written here, after the
8514
+ // database types, so the declaration describes what this pull wrote.
8515
+ try {
8516
+ const artifacts = writeFunctionTypeArtifacts(configDir);
8517
+ if (artifacts.length > 0) {
8518
+ info(` Wrote function types: ${artifacts.join(", ")}`);
8519
+ }
8520
+ }
8521
+ catch (err) {
8522
+ if (!(err instanceof FunctionSchemaCodegenError))
8523
+ throw err;
8524
+ warn(` Function types not generated: ${err.message}`);
8525
+ }
6925
8526
  // Write rule sets
6926
8527
  const ruleSetEntities = {};
6927
8528
  for (const ruleSet of ruleSets) {
@@ -7013,6 +8614,7 @@ export function registerConfigSyncCommands(sync) {
7013
8614
  ? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
7014
8615
  : { ok: false },
7015
8616
  scripts: scriptPresence,
8617
+ functions: functionPresence,
7016
8618
  emailTemplates: emailTemplatePresence,
7017
8619
  databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
7018
8620
  ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
@@ -7079,11 +8681,36 @@ export function registerConfigSyncCommands(sync) {
7079
8681
  for (const key of plan.unsafeKeys) {
7080
8682
  warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
7081
8683
  }
8684
+ // #3179 — the source files the SURVIVORS still own. Two functions can
8685
+ // import the same module, and both record it; deleting it with one
8686
+ // would leave the other unbuildable while its state called it
8687
+ // current. Both the prior state and this pull's entities are read,
8688
+ // because a function created server-side since the last pull owns its
8689
+ // sources only in the fresh map.
8690
+ const pruningKeys = new Set(plan.keys);
8691
+ const retainedFiles = new Set();
8692
+ const collectRetained = (entities) => {
8693
+ for (const [entityKey, entity] of Object.entries(entities ?? {})) {
8694
+ if (pruningKeys.has(entityKey))
8695
+ continue;
8696
+ for (const path of (entity?.sources ?? [])) {
8697
+ retainedFiles.add(path);
8698
+ }
8699
+ }
8700
+ };
8701
+ collectRetained(priorEntities[resourceType.stateSlot]);
8702
+ if (resourceType.stateSlot === "functions") {
8703
+ collectRetained(functionEntities);
8704
+ }
7082
8705
  const result = await applyPrune({
7083
8706
  configDir,
7084
8707
  resourceType,
7085
8708
  keys: plan.keys,
7086
8709
  testCaseEntities,
8710
+ // #3179 — a function's TypeScript goes with it. Only the prior sync
8711
+ // state knows where those files are, so it is what names them.
8712
+ extraFilesForKey: (key) => priorEntities[resourceType.stateSlot]?.[key]?.sources ?? [],
8713
+ retainedFiles,
7087
8714
  logger: info,
7088
8715
  });
7089
8716
  prunedCount += result.prunedKeys.length;
@@ -7099,12 +8726,14 @@ export function registerConfigSyncCommands(sync) {
7099
8726
  // the prior entry for that one key — see `preserveUnwrittenEntries`.
7100
8727
  const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
7101
8728
  preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
8729
+ preserveUnwrittenEntries(functionEntities, priorEntities.functions, functionServerKeys);
7102
8730
  preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
7103
8731
  ? emailTemplateOverrides.map((t) => t.emailType)
7104
8732
  : []);
7105
8733
  const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
7106
8734
  const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
7107
8735
  const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
8736
+ const finalFunctionEntities = keepPrior(functionListOk, functionEntities, "functions");
7108
8737
  const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
7109
8738
  const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
7110
8739
  const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
@@ -7133,6 +8762,7 @@ export function registerConfigSyncCommands(sync) {
7133
8762
  prompts: promptEntities,
7134
8763
  workflows: workflowEntities,
7135
8764
  scripts: finalScriptEntities,
8765
+ functions: finalFunctionEntities,
7136
8766
  emailTemplates: finalEmailTemplateEntities,
7137
8767
  databaseTypes: finalDatabaseTypeEntities,
7138
8768
  ruleSets: finalRuleSetEntities,
@@ -7203,6 +8833,17 @@ export function registerConfigSyncCommands(sync) {
7203
8833
  idOf: (w) => w.workflowId,
7204
8834
  }, Object.keys(priorEntities.workflows ?? {})),
7205
8835
  ],
8836
+ // #3179 — the sixth carrying type reaches the same place. An archived
8837
+ // function keeps its key in the app's SINGLE key namespace, so a
8838
+ // stranded tombstone blocks a workflow, a script or a webhook wanting
8839
+ // that key too, not just another function.
8840
+ [
8841
+ "functions",
8842
+ archivedTombstoneEntries(functionTombstoneCandidates, {
8843
+ keyOf: (f) => f.functionKey,
8844
+ idOf: (f) => f.functionId,
8845
+ }, Object.keys(priorEntities.functions ?? {})),
8846
+ ],
7206
8847
  ]) {
7207
8848
  const built = entitiesBySlot[slot];
7208
8849
  const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
@@ -7299,6 +8940,7 @@ export function registerConfigSyncCommands(sync) {
7299
8940
  prompts: promptEntities,
7300
8941
  workflows: workflowEntities,
7301
8942
  scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
8943
+ functions: Object.keys(finalFunctionEntities).length > 0 ? finalFunctionEntities : undefined,
7302
8944
  emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
7303
8945
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
7304
8946
  databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
@@ -7324,6 +8966,7 @@ export function registerConfigSyncCommands(sync) {
7324
8966
  keyValue("Prompts", prompts.length);
7325
8967
  keyValue("Workflows", workflows.length);
7326
8968
  keyValue("Transforms", pulledScriptsCount);
8969
+ keyValue("Functions", pulledFunctionsCount);
7327
8970
  keyValue("Email Templates", emailTemplates.length);
7328
8971
  keyValue("Test Cases", totalTestCases);
7329
8972
  keyValue("Database Types", databaseTypesWithOps.length);
@@ -7358,10 +9001,8 @@ export function registerConfigSyncCommands(sync) {
7358
9001
  // Push
7359
9002
  sync
7360
9003
  .command("push")
9004
+ .allowExcessArguments(false)
7361
9005
  .description("Upload local TOML configuration to server")
7362
- .argument("[app-id]", "App ID (uses current app if not specified)")
7363
- .option("--app <app-id>", "App ID")
7364
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
7365
9006
  .option("--dry-run", "Show what would be changed without applying")
7366
9007
  .option("--force", "Overwrite remote even if modified since last pull")
7367
9008
  .option("--only <selectors>", "Apply just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else about push is unchanged.",
@@ -7372,15 +9013,32 @@ export function registerConfigSyncCommands(sync) {
7372
9013
  .option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
7373
9014
  .option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
7374
9015
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
7375
- .action(async (appId, options) => {
7376
- const resolvedAppId = resolveAppId(appId, options);
7377
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
7378
- if (isAutoResolvedSyncDir(options.dir)) {
7379
- info(`Using per-environment config directory: ${configDir}`);
7380
- const legacyHint = checkLegacySyncMigration(configDir);
7381
- if (legacyHint)
7382
- warn(legacyHint);
7383
- }
9016
+ // #3320 — what this command actually guarantees, said where it is run.
9017
+ // Two stages, and only the first is all-or-nothing.
9018
+ .addHelpText("after", `
9019
+ What a push guarantees (two stages):
9020
+
9021
+ Preflight — the local tree only. Unknown keys and declared types, workflow
9022
+ lints, function grant grammar, a function's \`entry\`, the function trigger
9023
+ rules, and a webhook trigger's signing-secret requirement and reference
9024
+ shape. Any error among them aborts the whole run and nothing is applied.
9025
+ Those are the server's own rules, run against your files.
9026
+
9027
+ Apply — incremental and convergent, and there is no rollback. A rejection
9028
+ may stop the remaining work depending on the type; whatever was already
9029
+ applied stays applied; the exit status is non-zero; re-running converges.
9030
+ A refused FUNCTION is recorded and the loop continues, so the rest of the
9031
+ tree still lands.
9032
+
9033
+ Not preflighted, because the file cannot answer it: whether a referenced
9034
+ secret exists, whether a scheme's config is valid under this environment's
9035
+ policy, per-app caps, key collisions. Those surface during apply.
9036
+ `)
9037
+ .option("--no-typecheck", "Ship function code without typechecking it against the declarations this push generates (issue #3331)")
9038
+ .action(async (options) => {
9039
+ const resolvedAppId = getCurrentAppId();
9040
+ const configDir = resolveSyncDir();
9041
+ info(`Using per-environment config directory: ${configDir}`);
7384
9042
  // Issue #2760. An un-renamed checkout's `database-types/` is ignored, so
7385
9043
  // an ordinary push applies nothing from it — worth a warning. `--prune`
7386
9044
  // is the destructive case: it flags every prior-state key whose local
@@ -7405,14 +9063,6 @@ export function registerConfigSyncCommands(sync) {
7405
9063
  // `syncCallable` PATCH ordering a second implementation has broken before.
7406
9064
  // Everything else about push is unchanged.
7407
9065
  const pushSelection = readSelection(options);
7408
- if (pushSelection) {
7409
- const missing = unmatchedSelectors(pushSelection, configDir);
7410
- if (missing.length > 0) {
7411
- error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
7412
- process.exit(1);
7413
- }
7414
- info(`Applying only: ${pushSelection.raw.join(", ")}`);
7415
- }
7416
9066
  /** Narrow a type's file list to the `--only` selection. */
7417
9067
  const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
7418
9068
  /**
@@ -7446,6 +9096,29 @@ export function registerConfigSyncCommands(sync) {
7446
9096
  entities: {},
7447
9097
  };
7448
9098
  }
9099
+ // A `--only` selector that would apply nothing is refused before push
9100
+ // runs, so `--only workflow/typo` fails loudly instead of iterating empty
9101
+ // lists and reporting success.
9102
+ //
9103
+ // Checked HERE, after the baseline is loaded and after the cross-app reset
9104
+ // above, rather than beside `readSelection` where it used to sit (#3398).
9105
+ // `--prune` acts on keys the sync state tracks and NO local file declares,
9106
+ // so a file-only check refused the one command that is supposed to run with
9107
+ // the file gone: `--only workflow/household-forget --prune` reported `No
9108
+ // local file declares: workflow/household-forget` and left no way to retire
9109
+ // one tracked key without restoring its TOML (which prune would then
9110
+ // decline to consider) or dropping `--only` and pushing the whole tree.
9111
+ // The state has to be in hand to tell that key from a typo, and the
9112
+ // cross-app reset has to have happened first: it clears the entity ids, so
9113
+ // there is nothing for prune to retire and a removed key IS a typo again.
9114
+ if (pushSelection) {
9115
+ const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }));
9116
+ if (missing.length > 0) {
9117
+ error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
9118
+ process.exit(1);
9119
+ }
9120
+ info(`Applying only: ${pushSelection.raw.join(", ")}`);
9121
+ }
7449
9122
  info(`Pushing configuration for app ${resolvedAppId}...`);
7450
9123
  // The state this push STARTS from, so the failure path below can tell a
7451
9124
  // run that recorded something from one that recorded nothing (#2936).
@@ -7822,242 +9495,36 @@ export function registerConfigSyncCommands(sync) {
7822
9495
  for (const [key, id] of promptKeyToId) {
7823
9496
  pushTestCaseLookupMaps.promptIdToKey.set(id, key);
7824
9497
  }
7825
- for (const [scoped, id] of [
7826
- ...promptConfigNameToId,
7827
- ...workflowConfigNameToId,
7828
- ...scriptConfigNameToId,
7829
- ...integrationConfigNameToId,
7830
- ]) {
7831
- // The map is keyed `<blockKey>#<configName>`; the comparison names
7832
- // the config, not the block.
7833
- pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
7834
- }
7835
- };
7836
- // ── Issue #976 (fix A): front-load ALL client-side TOML validation
7837
- // before the first mutating call. Push is apply-as-you-go and processes
7838
- // entities in a fixed sequence (cron triggers are created *before*
7839
- // workflows are validated). Previously each entity's validation lived
7840
- // inside its own apply loop and aborted via `process.exit(1)` — so a
7841
- // known-bad sibling file (e.g. an invalid workflow TOML) could let an
7842
- // earlier create (a cron trigger) be applied, then abort. The
7843
- // `process.exit` also bypassed the save-on-failure catch below, so the
7844
- // orphaned create never reached `.primitive-sync.json` and the retry
7845
- // re-issued a CREATE that the server rejected with 409 forever.
7846
- //
7847
- // Running every validation up-front means a known-bad file aborts
7848
- // BEFORE anything is created. We collect all errors (not just the first)
7849
- // and THROW — so the single save-on-failure catch is the only exit path
7850
- // (no more `process.exit` bypass class). The per-loop validators below
7851
- // remain as defense-in-depth but are converted to throws too.
7852
- const preflightValidationErrors = [];
7853
- // Validate all workflow TOMLs (#685 misnested-header check and the
7854
- // #1447 system + accessRule lint).
7855
- const preflightWorkflowsDir = join(configDir, "workflows");
7856
- if (existsSync(preflightWorkflowsDir)) {
7857
- // Under `--only` the preflight validates just the selected files:
7858
- // an unrelated broken workflow elsewhere in the slot must not block
7859
- // applying the one object the operator asked for.
7860
- for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
7861
- const filePath = join(preflightWorkflowsDir, file);
7862
- try {
7863
- const tomlData = parseTomlFile(filePath);
7864
- // #1660 — skip platform-owned workflows (the reserved
7865
- // `__internal.*` namespace) BEFORE validating, mirroring the
7866
- // apply loop below. `config push` validates every workflow TOML
7867
- // here, up-front, before reaching that skip — so without this
7868
- // second skip a stale/hollow `__internal.*.toml` with an invalid
7869
- // `steps` shape would abort the whole push in preflight. Resolve
7870
- // the key the same way the apply loop does (`[workflow].key`,
7871
- // falling back to the filename) and match on the resolved key,
7872
- // never the filename alone, so a renamed file is still caught.
7873
- const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
7874
- if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
7875
- continue;
7876
- }
7877
- const tomlErrors = validateWorkflowToml(tomlData);
7878
- if (tomlErrors.length > 0) {
7879
- preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
7880
- }
7881
- // #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
7882
- // now no-ops). Printed once per file at pre-flight so the author
7883
- // sees them whether or not the push goes on to apply.
7884
- for (const note of collectWorkflowTomlNotes(tomlData)) {
7885
- console.log(` ${filePath}: ${note}`);
7886
- }
7887
- const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
7888
- if (unknownKeyError) {
7889
- preflightValidationErrors.push(` ${unknownKeyError}`);
7890
- }
7891
- // #2743 — the always-send update payload cannot carry a missing
7892
- // `[workflow].name`. That is a local TOML error like every other
7893
- // one here, so it belongs in the front-loaded pass (#976): caught
7894
- // in the apply loop it would abort AFTER earlier resources (app
7895
- // settings, cron triggers, …) had already been written, and
7896
- // `--dry-run` — which never reaches the apply — would plan an
7897
- // update the real push then refuses.
7898
- //
7899
- // Scoped to the workflows this push will actually UPDATE. A
7900
- // create falls back to the manifest key, so an absent name is
7901
- // legitimate authoring there — and once that create lands, the
7902
- // same unchanged file must keep pushing clean (the apply loop
7903
- // skips it on its content hash) instead of failing a push that
7904
- // was never going to send it.
7905
- if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, options.force)) {
7906
- const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
7907
- if (nameError) {
7908
- preflightValidationErrors.push(` ${nameError}`);
7909
- }
7910
- }
7911
- }
7912
- catch (err) {
7913
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
7914
- }
7915
- }
7916
- }
7917
- // Validate all database-type TOMLs (issue #803 subscription
7918
- // access/accessRule conflict + issue #752 $params references).
7919
- const preflightDbTypesDir = join(configDir, "database-type-configs");
7920
- if (existsSync(preflightDbTypesDir)) {
7921
- for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
7922
- const filePath = join(preflightDbTypesDir, file);
7923
- let rawToml;
7924
- let tomlData;
7925
- try {
7926
- rawToml = readFileSync(filePath, "utf-8");
7927
- tomlData = parseTomlFile(filePath);
7928
- }
7929
- catch (err) {
7930
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
7931
- continue;
7932
- }
7933
- let operations = [];
7934
- try {
7935
- ({ operations } = parseDatabaseTypeToml(tomlData));
7936
- }
7937
- catch (err) {
7938
- if (err instanceof SubscriptionAccessKeyConflictError) {
7939
- preflightValidationErrors.push(` ${file}: ${err.message}`);
7940
- continue;
7941
- }
7942
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
7943
- continue;
7944
- }
7945
- const validation = validateOperations({
7946
- filePath,
7947
- rawToml,
7948
- operations,
7949
- });
7950
- for (const e of validation.errors) {
7951
- preflightValidationErrors.push(` ${formatIssue(e)}`);
7952
- }
7953
- // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
7954
- // / `metadataAccess` gate on a database type config is a single CEL
7955
- // expression that gates BOTH read and update of the database CEL
7956
- // context — granting read implicitly grants update. Steer operators
7957
- // to resource metadata categories (separate readRule/writeRule).
7958
- // This is advisory only: the push continues and the runtime gate is
7959
- // unchanged. It fires only when the deprecated key is actually
7960
- // present, so metadata-category-only configs never trip it.
7961
- const typeSection = tomlData.type || {};
7962
- if (typeSection.celContextAccess || typeSection.metadataAccess) {
7963
- const usedKey = typeSection.celContextAccess
7964
- ? "celContextAccess"
7965
- : "metadataAccess";
7966
- warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
7967
- }
7968
- }
7969
- }
7970
- // Validate app.toml settings up-front (issue #1033). A mistyped value
7971
- // is a hard error that must abort BEFORE any mutation — same
7972
- // fail-before-mutate contract as the vars preflight below. The apply
7973
- // block re-parses for the payload.
7974
- // #2645 — bounded by `--only`, like the workflow and database-type
7975
- // preflights above. `config push --only workflow/x` is the replacement for
7976
- // `workflows update --from-file`, so it must work on a tree where
7977
- // app.toml happens to be mid-edit.
7978
- const preflightAppTomlPath = join(configDir, "app.toml");
7979
- if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
7980
- try {
7981
- const parsedApp = parseTomlFile(preflightAppTomlPath);
7982
- const appErrors = collectAppSettingsPushErrors(parsedApp, {
7983
- // #2880 DSO-001 — the same exclusion the apply block computes, so
7984
- // the preflight validates the body that will actually be sent.
7985
- excludeName: appSettingsNameExcluded({
7986
- isCrossAppPush: !!isCrossAppPush,
7987
- recordedExcludedName: syncState?.entities?.app?.excludedName,
7988
- fileName: parsedApp?.app?.name,
7989
- }),
7990
- });
7991
- for (const e of appErrors) {
7992
- preflightValidationErrors.push(` app.toml: ${e}`);
7993
- }
7994
- }
7995
- catch (err) {
7996
- preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
9498
+ for (const [scoped, id] of [
9499
+ ...promptConfigNameToId,
9500
+ ...workflowConfigNameToId,
9501
+ ...scriptConfigNameToId,
9502
+ ...integrationConfigNameToId,
9503
+ ]) {
9504
+ // The map is keyed `<blockKey>#<configName>`; the comparison names
9505
+ // the config, not the block.
9506
+ pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
7997
9507
  }
7998
- }
7999
- // Validate config vars up-front (issue #1423 review). Parse vars.toml
8000
- // and check the same key/value constraints the server enforces here, in
8001
- // the preflight pass, so an invalid entry aborts BEFORE any mutation
8002
- // (app settings or a var upsert) is applied. Previously validation ran
8003
- // mid-push, after app settings could already have been updated.
8004
- // #2645 — bounded by `--only`, for the same reason as app.toml above.
9508
+ };
9509
+ // #3375 — the front-loaded pass now lives in one exported entry point,
9510
+ // `runConfigPushPreflight`, so a rule declared `preflight` can be
9511
+ // proved to be reached from it before anything below mutates. Nothing
9512
+ // about what it checks, the order it checks it, or what it prints
9513
+ // moved with it.
9514
+ const { parsedVars: preflightParsedVars } = await runConfigPushPreflight({
9515
+ configDir,
9516
+ resolvedAppId,
9517
+ pushSelection,
9518
+ syncState,
9519
+ force: !!options.force,
9520
+ isCrossAppPush: !!isCrossAppPush,
9521
+ client,
9522
+ });
9523
+ // Re-derived, not returned: both are one-line functions of inputs the
9524
+ // entry point already took, and widening its return type to carry them
9525
+ // would make the vars apply leg depend on the preflight's internals.
8005
9526
  const preflightVarsTomlPath = join(configDir, "vars.toml");
8006
9527
  const varsSelected = selectionIncludesAnyVar(pushSelection);
8007
- let preflightParsedVars = null;
8008
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
8009
- try {
8010
- const parsed = parseTomlFile(preflightVarsTomlPath);
8011
- // #2645 — under `--only var/<key>` only the selected entries are
8012
- // checked. The rest of the file is not being written, and aborting
8013
- // over an entry this push will not touch is the same over-reach as
8014
- // running the leg at all for an unselected singleton.
8015
- for (const e of validateVarsFile(parsed, {
8016
- onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
8017
- })) {
8018
- preflightValidationErrors.push(` ${e}`);
8019
- }
8020
- preflightParsedVars = parsed;
8021
- }
8022
- catch (err) {
8023
- preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
8024
- }
8025
- }
8026
- // #2644 — reject any key the vendored definition does not recognize,
8027
- // for every migrated configuration type. Same posture the `[workflow]`
8028
- // check above has, generalized: a mistyped key fails here, in the
8029
- // preflight pass, so nothing is applied and no request is issued —
8030
- // rather than being dropped on the way to the server and reading as a
8031
- // successful push that changed nothing.
8032
- // #2880 — the same collector `config diff` runs, so the two commands
8033
- // cannot disagree about which files are pushable.
8034
- for (const rejected of collectConfigFileValidationErrors(configDir)) {
8035
- for (const message of rejected.messages) {
8036
- preflightValidationErrors.push(` ${message}`);
8037
- }
8038
- }
8039
- // The `<key>.tests/*.toml` sidecars, which sit beside the block they
8040
- // test rather than in a type directory of their own. Same posture,
8041
- // reached through the registry's `test-case` surface — and through the
8042
- // same collector `config diff` runs per sidecar (#2880), so neither
8043
- // command can refuse a file the other calls Synced.
8044
- for (const filePath of testCaseTomlFiles(configDir)) {
8045
- let tomlData;
8046
- try {
8047
- tomlData = parseTomlFile(filePath);
8048
- }
8049
- catch {
8050
- // Reported by the per-block test-case loop with its own message.
8051
- continue;
8052
- }
8053
- for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
8054
- preflightValidationErrors.push(` ${message}`);
8055
- }
8056
- }
8057
- if (preflightValidationErrors.length > 0) {
8058
- throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
8059
- preflightValidationErrors.join("\n"));
8060
- }
8061
9528
  // Fetch the current remote config vars up-front — before ANY mutation
8062
9529
  // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
8063
9530
  // to detect concurrent remote edits and to decide which already-absent
@@ -8120,6 +9587,9 @@ export function registerConfigSyncCommands(sync) {
8120
9587
  upserts: varPlan.upserts.filter((u) => wanted.has(u.key)),
8121
9588
  deletions: varPlan.deletions.filter((k) => wanted.has(k)),
8122
9589
  conflicts: varPlan.conflicts.filter((c) => wanted.has(c.key)),
9590
+ // A drifted var nobody selected is not this push's business to
9591
+ // report, the same way its writes are not this push's to make.
9592
+ drifted: varPlan.drifted.filter((d) => wanted.has(d.key)),
8123
9593
  skippedCount: 0,
8124
9594
  };
8125
9595
  }
@@ -8412,6 +9882,13 @@ export function registerConfigSyncCommands(sync) {
8412
9882
  localModifiedAt: c.localModifiedAt,
8413
9883
  });
8414
9884
  }
9885
+ // …and var drift into the shared drift accumulator (#3714), so a var
9886
+ // only the server moved prints the `DRIFT var: KEY` row every other
9887
+ // type prints for that shape. It is not a failure: nothing was
9888
+ // written and neither the exit code nor the pushed count moves.
9889
+ for (const d of varPlan.drifted) {
9890
+ drifted.push({ type: "var", key: d.key });
9891
+ }
8415
9892
  // Delete removed keys BEFORE upserting new ones (issue #1423 review
8416
9893
  // pass 3). At the server's 100-var cap, a replacement (remove one key,
8417
9894
  // add another) is a valid final state, but upserting the new key first
@@ -9692,8 +11169,14 @@ export function registerConfigSyncCommands(sync) {
9692
11169
  }
9693
11170
  // Process prompts
9694
11171
  const promptsDir = join(configDir, "prompts");
9695
- if (existsSync(promptsDir)) {
9696
- 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) => {
9697
11180
  // #2869 — prompts join the live-state reconcile (#2731 B3). #2748
9698
11181
  // gave the type diff's comparator and left push on the manifest byte
9699
11182
  // hash, so the two commands answered "changed?" from different
@@ -9714,13 +11197,26 @@ export function registerConfigSyncCommands(sync) {
9714
11197
  // manifest byte-hash gate with the type named, like every other
9715
11198
  // converted type.
9716
11199
  const livePromptIds = new Map();
11200
+ // #3626, CR3626-001 — the KIND the server holds for each prompt,
11201
+ // taken from the same list. `kind` is immutable after create
11202
+ // (D3626-008), and the comparison gate refuses a change to it — but
11203
+ // only on the paths that reach that gate. This map is what the other
11204
+ // two read: a prompt whose DETAIL read failed (degraded to the
11205
+ // byte-hash gate, which `--force` writes through) and a prompt
11206
+ // adopted by key after a 409 (which re-issues the file as an UPDATE
11207
+ // without ever comparing). Both would otherwise PATCH the prompt's
11208
+ // schemas, access rule and metadata onto a row of the other kind
11209
+ // before a config was refused against the kind that did not change.
11210
+ const livePromptKinds = new Map();
9717
11211
  let promptsLiveOk = true;
9718
11212
  if (files.length > 0) {
9719
11213
  try {
9720
11214
  const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
9721
11215
  for (const p of listed) {
9722
- if (p?.promptKey)
9723
- livePromptIds.set(p.promptKey, p.promptId);
11216
+ if (!p?.promptKey)
11217
+ continue;
11218
+ livePromptIds.set(p.promptKey, p.promptId);
11219
+ livePromptKinds.set(p.promptKey, promptKindOf(p));
9724
11220
  }
9725
11221
  }
9726
11222
  catch (err) {
@@ -9883,6 +11379,79 @@ export function registerConfigSyncCommands(sync) {
9883
11379
  // (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
9884
11380
  // sync-state entity record before calling this so the stamp lands.
9885
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
+ }
9886
11455
  const updated = await client.updatePrompt(resolvedAppId, promptId, buildPromptPushPayload(key, tomlData, { mode: "update" }), expectedModifiedAt);
9887
11456
  info(` Updated prompt: ${key}`);
9888
11457
  // Record the `modifiedAt` this PATCH just caused, so a later
@@ -9905,7 +11474,10 @@ export function registerConfigSyncCommands(sync) {
9905
11474
  try {
9906
11475
  // Fetch full prompt to get config name→ID mappings
9907
11476
  // (updatePrompt response doesn't include configs)
9908
- 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));
9909
11481
  if (fullPrompt?.configs) {
9910
11482
  for (const config of fullPrompt.configs) {
9911
11483
  promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
@@ -9919,6 +11491,10 @@ export function registerConfigSyncCommands(sync) {
9919
11491
  continue;
9920
11492
  const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
9921
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;
9922
11498
  // Update existing config
9923
11499
  try {
9924
11500
  await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
@@ -9939,6 +11515,9 @@ export function registerConfigSyncCommands(sync) {
9939
11515
  }
9940
11516
  if (newConfig?.configId) {
9941
11517
  promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
11518
+ // #3799 — the create body carried the entry's own
11519
+ // `status`, so there is no follow-up patch (#3201's is
11520
+ // retired).
9942
11521
  }
9943
11522
  }
9944
11523
  }
@@ -10031,6 +11610,18 @@ export function registerConfigSyncCommands(sync) {
10031
11610
  }
10032
11611
  };
10033
11612
  if (existingId) {
11613
+ // #3626, CR3626-001 — the kind, before anything is written. The
11614
+ // comparison gate above already refuses a changed `kind`, but it
11615
+ // only ran when the DETAIL read succeeded; a prompt degraded to
11616
+ // the byte-hash gate reaches this line with `--force` and no
11617
+ // comparison at all. The list's kind is enough to answer it, and
11618
+ // answering it here keeps the promise the gate makes: a kind
11619
+ // change is declined before the first mutating call.
11620
+ const degradedKindDecline = promptKindImmutableOutcome(livePrompt ? promptKindOf(livePrompt) : livePromptKinds.get(key), tomlData);
11621
+ if (degradedKindDecline) {
11622
+ recordDeclined("prompt", key, degradedKindDecline, existingEntry?.modifiedAt);
11623
+ continue;
11624
+ }
10034
11625
  // Update existing prompt
10035
11626
  changes.push({ type: "prompt", action: "update", key });
10036
11627
  promptKeyToId.set(key, existingId);
@@ -10123,6 +11714,19 @@ export function registerConfigSyncCommands(sync) {
10123
11714
  });
10124
11715
  adopted = true;
10125
11716
  const adoptedId = existing.promptId;
11717
+ // #3626, CR3626-001 — the adopted prompt's KIND, before the
11718
+ // re-issue writes anything. This path never reaches the
11719
+ // comparison gate (the create is what failed), so without
11720
+ // this an orphaned chat prompt would take a decisions file's
11721
+ // schemas, access rule and metadata by PATCH and only then
11722
+ // refuse its configs against the kind the update cannot
11723
+ // change. The lookup already read the record, so the answer
11724
+ // costs nothing.
11725
+ const adoptKindDecline = promptKindImmutableOutcome(promptKindOf(existing), tomlData);
11726
+ if (adoptKindDecline) {
11727
+ recordDeclined("prompt", key, adoptKindDecline);
11728
+ continue;
11729
+ }
10126
11730
  // Seed the sync-state entity record so the shared update body
10127
11731
  // can stamp modifiedAt/contentHash into it (sync state has no
10128
11732
  // entry for an orphaned prompt), and track the key→ID map.
@@ -10247,8 +11851,11 @@ export function registerConfigSyncCommands(sync) {
10247
11851
  throw wrapPromptConfigError(err, "create", key, extraName);
10248
11852
  }
10249
11853
  if (extraCreated?.configId) {
10250
- const configName = extraName;
10251
- promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
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.
10252
11859
  }
10253
11860
  }
10254
11861
  info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
@@ -10280,6 +11887,17 @@ export function registerConfigSyncCommands(sync) {
10280
11887
  activated = true;
10281
11888
  }
10282
11889
  }
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).
10283
11901
  // Stamp sync state ONLY after all create work (base prompt +
10284
11902
  // every extra config) succeeded. If an extra-config create
10285
11903
  // above threw, we never reach here, so the file hash is not
@@ -10315,6 +11933,13 @@ export function registerConfigSyncCommands(sync) {
10315
11933
  }
10316
11934
  }
10317
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);
10318
11943
  }
10319
11944
  // Process transforms (Rhai scripts) — issue #892, slice 7 of #783.
10320
11945
  //
@@ -11965,7 +13590,7 @@ export function registerConfigSyncCommands(sync) {
11965
13590
  //
11966
13591
  // But the 409 also fires when the local sync state is merely
11967
13592
  // ABSENT for a type that another admin has already FULLY
11968
- // configured (or whose `.primitive-sync.json` was not copied
13593
+ // configured (or whose sync-state baseline was not copied
11969
13594
  // alongside the TOML). PATCHing that without
11970
13595
  // `expectedModifiedAt` would silently overwrite the existing
11971
13596
  // triggers/access/schema with the local TOML — with no
@@ -12409,6 +14034,393 @@ export function registerConfigSyncCommands(sync) {
12409
14034
  }
12410
14035
  }
12411
14036
  }
14037
+ // Process server functions — #3179.
14038
+ //
14039
+ // A function pushes on TWO axes and they are deliberately independent:
14040
+ //
14041
+ // - the HEADER (`[function]`'s gate, schemas, plus the three
14042
+ // version-carried keys, all compared through the shared gate), and
14043
+ // - the CODE — the authored TOML bytes, every source file and the
14044
+ // built bundle, sent as one immutable config VERSION.
14045
+ //
14046
+ // Independent because a comment-only edit to a source file leaves the
14047
+ // header identical and must still make a version (F-003): gating the
14048
+ // code push on the header comparison would silently discard it. The
14049
+ // code axis compares `envelopeHash`, which is also what the SERVER
14050
+ // dedupes on, so an unchanged push makes no version on either side's
14051
+ // reckoning.
14052
+ //
14053
+ // The bundle is built FIRST, so a build error, a missing entry or an
14054
+ // import that escapes the config tree aborts this function before
14055
+ // anything is sent for it (behavior 12).
14056
+ const functionsPushDir = join(configDir, "functions");
14057
+ if (existsSync(functionsPushDir)) {
14058
+ // #3182 — the typed handle. Written BEFORE the bundles are built, so
14059
+ // an author who runs `config push` after editing a schema gets the
14060
+ // declaration their next edit is typed against, and written from the
14061
+ // TREE rather than from the server: the types describe the schemas
14062
+ // this push is applying. Regenerated every time and never part of the
14063
+ // envelope — `config pull` recreates them the same way.
14064
+ // #3281 — a malformed schema in a function file cannot be typed;
14065
+ // the file's own push below refuses it by name, so the generators
14066
+ // warn here and the rest of the tree still lands.
14067
+ try {
14068
+ if (!options.dryRun) {
14069
+ const artifacts = writeFunctionTypeArtifacts(configDir);
14070
+ if (artifacts.length > 0) {
14071
+ info(` Wrote function types: ${artifacts.join(", ")}`);
14072
+ }
14073
+ }
14074
+ // A tsconfig push will not rewrite (comments are the author's) or
14075
+ // cannot read is still a tsconfig no compiler loads the handle
14076
+ // through. Say so rather than report success over it (CR3182-003).
14077
+ for (const problem of functionTypeWiringProblems(configDir)) {
14078
+ warn(` functions/tsconfig.json: ${problem}`);
14079
+ }
14080
+ }
14081
+ catch (err) {
14082
+ if (!(err instanceof FunctionSchemaCodegenError))
14083
+ throw err;
14084
+ warn(` Function types not generated: ${err.message}`);
14085
+ }
14086
+ // #3331 — the compiler, over the declarations written (or, under
14087
+ // `--dry-run`, planned) just above. Built once for the tree and
14088
+ // consulted per function, so one broken file refuses itself and
14089
+ // nothing else; `--no-typecheck` is the escape hatch, and an
14090
+ // unavailable compiler warns rather than failing the push.
14091
+ let typecheckSetup = null;
14092
+ const functionTypechecker = () => {
14093
+ if (options.typecheck === false)
14094
+ return null;
14095
+ if (typecheckSetup === null) {
14096
+ typecheckSetup = createFunctionTypechecker(configDir);
14097
+ for (const warning of typecheckSetup.warnings)
14098
+ warn(` ${warning}`);
14099
+ }
14100
+ return typecheckSetup.checker;
14101
+ };
14102
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
14103
+ const functionSpec = configDiffSpec("function");
14104
+ const liveFunctions = new Map();
14105
+ let functionsLiveOk = true;
14106
+ if (files.length > 0) {
14107
+ try {
14108
+ const listed = await client.listFunctions(resolvedAppId);
14109
+ for (const f of (listed?.items ?? [])) {
14110
+ if (f?.functionKey)
14111
+ liveFunctions.set(f.functionKey, f);
14112
+ }
14113
+ }
14114
+ catch (err) {
14115
+ functionsLiveOk = false;
14116
+ warnLiveUnavailable("functions", err);
14117
+ }
14118
+ }
14119
+ /** The version a function currently runs, or null. */
14120
+ const liveActiveVersion = async (functionId, activeConfigId) => {
14121
+ if (!activeConfigId)
14122
+ return null;
14123
+ // #3462 — the point read: one call by id, and correct for a
14124
+ // rolled-back function whose active version is past page one.
14125
+ return client.getFunctionConfig(resolvedAppId, functionId, String(activeConfigId));
14126
+ };
14127
+ // #3455 — the tree's document schema is a property of the PROJECT,
14128
+ // not of one function, so an unusable `models/models.toml` is said
14129
+ // once rather than repeated for every function in the tree.
14130
+ const documentSchemaResolution = resolveDocumentSchema(configDir);
14131
+ const documentSchemaWarning = documentSchemaResolution.warning;
14132
+ let documentSchemaWarningShown = false;
14133
+ // Say ONCE that this push drops a document schema a running
14134
+ // version carries — and ask the NEXT function when this one's
14135
+ // version had nothing to lose, so a schema-less function pushed
14136
+ // first cannot silence a schema-bearing sibling. The rule and its
14137
+ // state live in `function-sync.ts`, where they are testable
14138
+ // without a tree and a server.
14139
+ const warnIfDroppingDocumentSchema = createDocumentSchemaDropWarner({
14140
+ treeCarriesSchema: documentSchemaResolution.origin !== "none",
14141
+ readConfig: (functionId, configId) => client.getFunctionConfig(resolvedAppId, functionId, configId),
14142
+ warn: (message) => warn(` ${message}`),
14143
+ });
14144
+ for (const file of files) {
14145
+ const filePath = join(functionsPushDir, file);
14146
+ const tomlData = parseTomlFile(filePath);
14147
+ const key = tomlData.function?.key || basename(file, ".toml");
14148
+ withDerivedIdentity(tomlData, "function", { key });
14149
+ const existingEntry = syncState?.entities?.functions?.[key];
14150
+ const liveFunction = liveFunctions.get(key);
14151
+ // Build first: nothing is sent for a function whose code does not
14152
+ // compile or whose imports leave the tree.
14153
+ let push;
14154
+ try {
14155
+ push = await buildFunctionConfigPush({
14156
+ configDir,
14157
+ functionKey: key,
14158
+ filePath,
14159
+ tomlData,
14160
+ });
14161
+ }
14162
+ catch (err) {
14163
+ if (!(err instanceof FunctionBuildError))
14164
+ throw err;
14165
+ applyFailures.push({
14166
+ type: "function",
14167
+ key,
14168
+ message: err.message,
14169
+ });
14170
+ continue;
14171
+ }
14172
+ // #3455 (D3455-004) — the project declares a document schema this
14173
+ // push cannot carry: over the cap, or not parsing. VISIBLE rather
14174
+ // than refused, because a broken `models/models.toml` is not a
14175
+ // reason to stop deploying code — but silence would leave an
14176
+ // author wondering why a first write still cannot seed.
14177
+ if (documentSchemaWarning && !documentSchemaWarningShown) {
14178
+ documentSchemaWarningShown = true;
14179
+ warn(` ${documentSchemaWarning}`);
14180
+ }
14181
+ // #3331 — and then the types. esbuild erases them, so a build that
14182
+ // succeeded says nothing about whether the code holds against the
14183
+ // declarations this same push generated: `ctx.user` going nullable
14184
+ // (#3279) bundles cleanly and throws on the request path. Refused
14185
+ // here, against THIS function's own sources, before anything is
14186
+ // sent for it and whether or not this is a dry run.
14187
+ const typechecker = functionTypechecker();
14188
+ if (typechecker) {
14189
+ const failure = typechecker.check(key, push.sources.map((source) => source.path));
14190
+ if (failure) {
14191
+ applyFailures.push({
14192
+ type: "function",
14193
+ key,
14194
+ message: failure.message,
14195
+ });
14196
+ continue;
14197
+ }
14198
+ }
14199
+ // The active version, needed by BOTH axes: the header comparison
14200
+ // reads `entry`/`mode`/`limits` off it, and the code axis
14201
+ // compares its `envelopeHash`.
14202
+ let activeVersion = null;
14203
+ let versionOk = functionsLiveOk;
14204
+ if (functionsLiveOk && liveFunction?.functionId) {
14205
+ try {
14206
+ activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
14207
+ }
14208
+ catch (err) {
14209
+ versionOk = false;
14210
+ warnLiveUnavailable(`function ${key}`, err);
14211
+ }
14212
+ }
14213
+ let headerAction = "skip";
14214
+ if (versionOk && functionsLiveOk) {
14215
+ const gate = decidePushForConfig({
14216
+ spec: functionSpec,
14217
+ maps: pushDiffMaps,
14218
+ localParsed: tomlData,
14219
+ live: liveFunction
14220
+ ? {
14221
+ record: liveFunction,
14222
+ // The version-carried keys the remote side is projected
14223
+ // through; the header row does not hold them.
14224
+ extra: activeVersion ?? undefined,
14225
+ modifiedAt: liveFunction.modifiedAt,
14226
+ }
14227
+ : null,
14228
+ entry: existingEntry,
14229
+ localFileHash: computeFileHash(filePath),
14230
+ force: options.force,
14231
+ // #3317 — the header row's `modifiedAt` covers everything
14232
+ // compared here: its own fields, and the `activeConfigId`
14233
+ // pointer whose repointing is the only way the immutable
14234
+ // version behind the extras can change. So an unmoved
14235
+ // timestamp proves the server stood still, and a baseline hash
14236
+ // that disagrees is a stale baseline — the reporter's CONFLICT
14237
+ // over two identical timestamps, which is also what every
14238
+ // manifest written before this issue's schema fix would hit.
14239
+ serverModifiedAtCoversProjection: true,
14240
+ // The create leg adopts by key on the server's 409 (#1006), so
14241
+ // an untracked function the server already holds is that same
14242
+ // adopt reached one step earlier.
14243
+ adoptsUntrackedByKey: true,
14244
+ });
14245
+ if (gate.action === "drift" ||
14246
+ gate.action === "conflict" ||
14247
+ gate.action === "immutable") {
14248
+ recordDeclined("function", key, gate, existingEntry?.modifiedAt);
14249
+ continue;
14250
+ }
14251
+ headerAction = gate.action === "skip"
14252
+ ? "skip"
14253
+ : liveFunction
14254
+ ? "update"
14255
+ : "create";
14256
+ }
14257
+ else {
14258
+ const degraded = decideDegradedPush({
14259
+ force: options.force,
14260
+ storedContentHash: existingEntry?.contentHash,
14261
+ currentFileHash: computeFileHash(filePath),
14262
+ });
14263
+ if (degraded.action === "decline") {
14264
+ recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
14265
+ continue;
14266
+ }
14267
+ headerAction = degraded.action === "skip"
14268
+ ? "skip"
14269
+ : liveFunction
14270
+ ? "update"
14271
+ : "create";
14272
+ }
14273
+ // The code axis. `envelopeHash` is the server's own no-op test, so
14274
+ // matching it here means the call would create nothing.
14275
+ const codeUnchanged = !!liveFunction &&
14276
+ !!activeVersion &&
14277
+ String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
14278
+ if (headerAction === "skip" && codeUnchanged) {
14279
+ skippedCount++;
14280
+ if (syncState && liveFunction?.functionId) {
14281
+ if (!syncState.entities.functions)
14282
+ syncState.entities.functions = {};
14283
+ syncState.entities.functions[key] = {
14284
+ ...(existingEntry ?? {}),
14285
+ id: liveFunction.functionId,
14286
+ modifiedAt: liveFunction.modifiedAt ||
14287
+ existingEntry?.modifiedAt ||
14288
+ new Date().toISOString(),
14289
+ contentHash: computeFileHash(filePath),
14290
+ semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
14291
+ envelopeHash: push.envelopeHash,
14292
+ activeConfigId: activeVersion?.configId,
14293
+ sources: push.sources.map((src) => src.path),
14294
+ };
14295
+ }
14296
+ continue;
14297
+ }
14298
+ changes.push({
14299
+ type: "function",
14300
+ action: liveFunction ? "update" : "create",
14301
+ key,
14302
+ });
14303
+ if (options.dryRun)
14304
+ continue;
14305
+ // Every server refusal below is recorded against THIS function and
14306
+ // the loop moves on. A single archived tombstone — or a key the
14307
+ // app's namespace already holds — must not abort a push carrying
14308
+ // unrelated changes: the run reports what it could not apply, exits
14309
+ // non-zero, and everything else in the tree still lands. That is
14310
+ // the same contract `applyFailures` gives every other type; a throw
14311
+ // here would let one refusal block a whole repository.
14312
+ let functionId = liveFunction?.functionId ?? existingEntry?.id;
14313
+ let pushedVersion;
14314
+ try {
14315
+ if (!functionId) {
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) => {
14321
+ // #1006 — a function the server already holds under this key
14322
+ // is adopted rather than re-created. The conflict may name a
14323
+ // WORKFLOW, a script or a webhook instead: those are the
14324
+ // single key namespace refusing the key, and they are not
14325
+ // adoptable, so `adoptByKeyOnCreate409` re-throws the original
14326
+ // error with its holder named.
14327
+ const existing = await adoptByKeyOnCreate409({
14328
+ err,
14329
+ kind: "function",
14330
+ key,
14331
+ lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
14332
+ matchKey: (f) => f?.functionKey === key,
14333
+ });
14334
+ return existing;
14335
+ });
14336
+ functionId = created.functionId;
14337
+ info(` Created function: ${key}`);
14338
+ }
14339
+ else if (headerAction !== "skip") {
14340
+ const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
14341
+ if (Object.keys(updatePayload).length > 0) {
14342
+ await client.updateFunction(resolvedAppId, functionId, updatePayload);
14343
+ }
14344
+ info(` Updated function: ${key}`);
14345
+ }
14346
+ // The version. `status` is NEVER in this body: pushing code to a
14347
+ // disabled function updates it and leaves it out of service
14348
+ // (#2803's resurrection guard), and a push to an archived one is
14349
+ // refused by the server with the archived-holder message — which
14350
+ // names the remedies that release its key.
14351
+ pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
14352
+ }
14353
+ catch (err) {
14354
+ applyFailures.push({
14355
+ type: "function",
14356
+ key,
14357
+ message: String(err?.message ?? err),
14358
+ });
14359
+ continue;
14360
+ }
14361
+ // #3455 (D3455-010) — did the server keep the document schema it
14362
+ // was sent? A server that predates the field drops it silently and
14363
+ // still answers 200, so without this the push reports success over
14364
+ // a version that can never seed. Recorded against THIS function and
14365
+ // NOT reported pushed; the rest of the tree still lands.
14366
+ const retention = documentSchemaRetentionFailure({
14367
+ sentDocumentSchema: typeof push.documentSchema === "string",
14368
+ builtEnvelopeHash: push.envelopeHash,
14369
+ storedEnvelopeHash: pushedVersion?.envelopeHash,
14370
+ });
14371
+ if (retention) {
14372
+ applyFailures.push({ type: "function", key, message: retention });
14373
+ continue;
14374
+ }
14375
+ await warnIfDroppingDocumentSchema(String(functionId), liveFunction?.activeConfigId
14376
+ ? String(liveFunction.activeConfigId)
14377
+ : undefined);
14378
+ if (pushedVersion?.created === false) {
14379
+ info(` Function ${key}: code unchanged (no new version)`);
14380
+ }
14381
+ else {
14382
+ info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
14383
+ }
14384
+ if (syncState) {
14385
+ if (!syncState.entities.functions)
14386
+ syncState.entities.functions = {};
14387
+ let refreshed = liveFunction;
14388
+ try {
14389
+ refreshed = await client.getFunction(resolvedAppId, String(functionId));
14390
+ }
14391
+ catch {
14392
+ // Non-fatal: the baseline falls back to what the listing said.
14393
+ // The next push reconciles it.
14394
+ }
14395
+ // #3317 — the pointer recorded is the one THIS push activated,
14396
+ // whatever the refetch found: a concurrent activation in the
14397
+ // window between the two is a server move the next push must
14398
+ // refuse over, not a baseline it silently rolls back.
14399
+ const baseline = pushedFunctionBaseline({
14400
+ functionId: String(functionId),
14401
+ refreshed,
14402
+ existingModifiedAt: existingEntry?.modifiedAt,
14403
+ contentHash: computeFileHash(filePath),
14404
+ pushedVersion,
14405
+ envelopeHash: push.envelopeHash,
14406
+ sources: push.sources.map((src) => src.path),
14407
+ spec: functionSpec,
14408
+ maps: pushDiffMaps,
14409
+ });
14410
+ if (baseline.movedTo) {
14411
+ warn(` Function ${key}: the server's active version moved to ${baseline.movedTo} ` +
14412
+ `while version ${pushedVersion?.configId} was being recorded; ` +
14413
+ "the next push will report the drift (`config pull` adopts it, `--force` overrides it).");
14414
+ }
14415
+ syncState.entities.functions[key] = baseline.entity;
14416
+ }
14417
+ }
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
+ }
12412
14424
  // Process group type configs
12413
14425
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
12414
14426
  if (existsSync(groupTypeConfigsDir)) {
@@ -12590,7 +14602,7 @@ export function registerConfigSyncCommands(sync) {
12590
14602
  // this, that CREATE re-hits the server's 409 "Config for this
12591
14603
  // group type already exists"
12592
14604
  // (group-type-config-controller.ts) on every subsequent push
12593
- // and the operator has to hand-edit `.primitive-sync.json` to
14605
+ // and the operator has to hand-edit the sync-state baseline to
12594
14606
  // recover. The shared helper detects the conflict (409 status /
12595
14607
  // "already exists" message), lists the app's configs — the list
12596
14608
  // route drains every page server-side (#1668), so the candidate
@@ -13724,21 +15736,14 @@ export function registerConfigSyncCommands(sync) {
13724
15736
  // Diff
13725
15737
  sync
13726
15738
  .command("diff")
15739
+ .allowExcessArguments(false)
13727
15740
  .description("Show differences between local and remote configuration")
13728
- .argument("[app-id]", "App ID (uses current app if not specified)")
13729
- .option("--app <app-id>", "App ID")
13730
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
13731
15741
  .option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
13732
- .option("--json", "Output as JSON (app.toml field diff, with --only app)")
13733
- .action(async (appId, options) => {
13734
- const resolvedAppId = resolveAppId(appId, options);
13735
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
13736
- if (isAutoResolvedSyncDir(options.dir)) {
13737
- info(`Using per-environment config directory: ${configDir}`);
13738
- const legacyHint = checkLegacySyncMigration(configDir);
13739
- if (legacyHint)
13740
- warn(legacyHint);
13741
- }
15742
+ .option("--json", "Output as JSON: the app.toml field diff with --only app, the compared rows otherwise")
15743
+ .action(async (options) => {
15744
+ const resolvedAppId = getCurrentAppId();
15745
+ const configDir = resolveSyncDir();
15746
+ info(`Using per-environment config directory: ${configDir}`);
13742
15747
  // Issue #2760: diff compares the renamed directory, so a legacy one is
13743
15748
  // simply invisible — an empty diff would otherwise read as "in sync".
13744
15749
  const legacyDbTypes = checkLegacyDatabaseTypesDir(configDir);
@@ -13786,7 +15791,7 @@ export function registerConfigSyncCommands(sync) {
13786
15791
  notCompared.push({ type: label, reason: gateReason(outcome.error) });
13787
15792
  return empty;
13788
15793
  };
13789
- const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
15794
+ const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, functionsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
13790
15795
  attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
13791
15796
  attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
13792
15797
  attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
@@ -13795,6 +15800,7 @@ export function registerConfigSyncCommands(sync) {
13795
15800
  attemptFetch(() => client.listCronTriggers(resolvedAppId)),
13796
15801
  attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
13797
15802
  attemptFetch(() => client.listScripts(resolvedAppId)),
15803
+ attemptFetch(() => client.listFunctions(resolvedAppId)),
13798
15804
  attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
13799
15805
  attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
13800
15806
  attemptFetch(() => client.listRuleSets(resolvedAppId)),
@@ -13810,6 +15816,7 @@ export function registerConfigSyncCommands(sync) {
13810
15816
  const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
13811
15817
  const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
13812
15818
  const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
15819
+ const functionItemsDiff = gate(functionsOutcomeDiff, "functions", { items: [] }).items || [];
13813
15820
  const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
13814
15821
  const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-type-configs", []);
13815
15822
  const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
@@ -14114,6 +16121,25 @@ export function registerConfigSyncCommands(sync) {
14114
16121
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
14115
16122
  }
14116
16123
  }
16124
+ // Server functions — #3179. Two axes, exactly as push has: the
16125
+ // header/version fields through the shared spec, and the CODE through
16126
+ // `envelopeHash`. The code axis is why a function's diff builds the
16127
+ // bundle: a comment-only edit to a source file changes nothing the
16128
+ // table can see and is still a change push will apply.
16129
+ const remoteFunctions = new Map(functionItemsDiff
16130
+ .filter((f) => f?.functionKey && String(f.status ?? "active") !== "archived")
16131
+ .map((f) => [f.functionKey, f]));
16132
+ const localFunctionsParsed = new Map();
16133
+ const functionsDirPath = join(configDir, "functions");
16134
+ if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
16135
+ for (const file of readdirSync(functionsDirPath).filter(isFunctionDeclarationFile)) {
16136
+ const filePath = join(functionsDirPath, file);
16137
+ const tomlData = parseTomlFile(filePath);
16138
+ const key = tomlData.function?.key || basename(file, ".toml");
16139
+ withDerivedIdentity(tomlData, "function", { key });
16140
+ localFunctionsParsed.set(key, { tomlData, filePath });
16141
+ }
16142
+ }
14117
16143
  // Config vars — issue #1423. The remote vars were fetched through the
14118
16144
  // ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
14119
16145
  // read only when that fetch succeeded.
@@ -14377,6 +16403,108 @@ export function registerConfigSyncCommands(sync) {
14377
16403
  differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
14378
16404
  }
14379
16405
  }
16406
+ // Server functions — #3179.
16407
+ const functionSpecDiff = configDiffSpec("function");
16408
+ const functionDiffMaps = {
16409
+ ruleSetIdToName: new Map(),
16410
+ ruleSetNameToId: new Map(),
16411
+ };
16412
+ for (const [key, local] of localFunctionsParsed) {
16413
+ const remote = remoteFunctions.get(key);
16414
+ if (!remote) {
16415
+ differences.push({
16416
+ type: "function",
16417
+ key,
16418
+ status: localOnlyStatus("functions", key),
16419
+ });
16420
+ continue;
16421
+ }
16422
+ let status = "exists";
16423
+ let hint;
16424
+ // The active version carries `entry`, `mode` and `limits`, and its
16425
+ // `envelopeHash` is the code axis.
16426
+ const versionOutcome = await attemptFetch(async () => {
16427
+ if (!remote.activeConfigId)
16428
+ return null;
16429
+ // #3462 — the point read, for the same reason as pull's.
16430
+ return client.getFunctionConfig(resolvedAppId, remote.functionId, String(remote.activeConfigId));
16431
+ });
16432
+ // #3462 — and the newest version, so a tree that matches something
16433
+ // that EXISTS can say so. One row; the listing answers newest first.
16434
+ const newestOutcome = await attemptFetch(async () => {
16435
+ const page = await client.listFunctionConfigs(resolvedAppId, remote.functionId, { limit: 1 });
16436
+ return (page.items || [])[0] ?? null;
16437
+ });
16438
+ if (!versionOutcome.ok) {
16439
+ hint = "content not compared (fetch failed)";
16440
+ }
16441
+ else {
16442
+ const active = versionOutcome.value;
16443
+ const verdict = compareLocalToRemote(functionSpecDiff, local.tomlData, remote, functionDiffMaps, active ?? undefined);
16444
+ status = verdict.status;
16445
+ hint = verdict.hint;
16446
+ if (status === "exists") {
16447
+ // The header agrees; the code is the other half. Building here is
16448
+ // what makes "config diff is clean immediately after pull" a
16449
+ // statement about the SOURCES and not just the TOML.
16450
+ try {
16451
+ const built = await buildFunctionConfigPush({
16452
+ configDir,
16453
+ functionKey: key,
16454
+ filePath: local.filePath,
16455
+ tomlData: local.tomlData,
16456
+ });
16457
+ if (!active ||
16458
+ String(active.envelopeHash ?? "") !== built.envelopeHash) {
16459
+ status = "modified";
16460
+ // #3462 — when the tree matches the NEWEST version rather
16461
+ // than the active one, the function is rolled back and the
16462
+ // generic hint sends an operator looking for an edit they
16463
+ // never made. Naming both versions says what actually
16464
+ // happened and what each command would do about it.
16465
+ const newest = newestOutcome.ok
16466
+ ? newestOutcome.value
16467
+ : null;
16468
+ hint =
16469
+ active &&
16470
+ newest &&
16471
+ String(newest.configId) !== String(active.configId) &&
16472
+ String(newest.envelopeHash ?? "") === built.envelopeHash
16473
+ ? rollbackDiffHint(String(newest.configId), String(active.configId))
16474
+ : "the built bundle or a source file differs — `config push` " +
16475
+ "creates a new immutable version";
16476
+ }
16477
+ }
16478
+ catch (err) {
16479
+ if (err instanceof FunctionBuildError) {
16480
+ // #2880 — the file is not pushable, so the row says so
16481
+ // rather than reading as Synced. This is the state a
16482
+ // function created through the API and never pushed lands
16483
+ // in: pull can only serialize the header, so the file has
16484
+ // no `entry` and the very next push refuses it. A Synced row
16485
+ // there tells the operator there is nothing to do about a
16486
+ // file nothing can apply.
16487
+ status = VALIDATION_ERROR;
16488
+ hint = err.message.split("\n")[0];
16489
+ }
16490
+ else {
16491
+ hint = "content not compared (comparison failed)";
16492
+ }
16493
+ }
16494
+ }
16495
+ }
16496
+ hint = outOfServiceHint(remote, hint);
16497
+ differences.push({ type: "function", key, status, hint });
16498
+ }
16499
+ for (const key of remoteFunctions.keys()) {
16500
+ if (!localFunctionsParsed.has(key)) {
16501
+ differences.push({
16502
+ type: "function",
16503
+ key,
16504
+ status: remoteOnlyStatus("functions", key),
16505
+ });
16506
+ }
16507
+ }
14380
16508
  // Workflows — #1175: content-aware comparison.
14381
16509
  //
14382
16510
  // For workflows present on BOTH sides we no longer stop at key
@@ -14614,7 +16742,7 @@ export function registerConfigSyncCommands(sync) {
14614
16742
  // below). Content comparison is a preview of `config pull` (pull would
14615
16743
  // rewrite the local TOML to match running state), exactly like the
14616
16744
  // workflow/transform blocks.
14617
- const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, hashLocal, hashRemote, contentComparable) => {
16745
+ const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, compare, contentComparable) => {
14618
16746
  for (const [key, parsed] of localParsed) {
14619
16747
  if (!remoteEntities.has(key)) {
14620
16748
  differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
@@ -14629,16 +16757,10 @@ export function registerConfigSyncCommands(sync) {
14629
16757
  hint = "content not compared (rule sets unavailable)";
14630
16758
  }
14631
16759
  else {
14632
- try {
14633
- if (hashLocal(parsed) !== hashRemote(remoteEntities.get(key))) {
14634
- status = "modified";
14635
- hint = "run `config pull` to rewrite the local TOML to match running state";
14636
- }
14637
- }
14638
- catch {
14639
- // A malformed local file must not abort the whole diff.
14640
- hint = "content not compared (comparison failed)";
14641
- }
16760
+ // The shared verdict names the differing field (#3267) and
16761
+ // degrades a malformed local file to "not compared" rather than
16762
+ // aborting the whole diff.
16763
+ ({ status, hint } = compare(parsed, remoteEntities.get(key)));
14642
16764
  }
14643
16765
  differences.push({ type: typeLabel, key, status, hint });
14644
16766
  }
@@ -14657,7 +16779,7 @@ export function registerConfigSyncCommands(sync) {
14657
16779
  };
14658
16780
  const compareByDefinition = (label, slot, localParsed, remoteEntities, contentComparable) => {
14659
16781
  const spec = configDiffSpec(label);
14660
- compareConfigType(label, slot, localParsed, remoteEntities, (p) => hashLocalConfigForDiff(spec, p, diffMaps), (e) => hashRemoteConfigForDiff(spec, e, diffMaps), contentComparable);
16782
+ compareConfigType(label, slot, localParsed, remoteEntities, (p, e) => compareLocalToRemote(spec, p, e, diffMaps), contentComparable);
14661
16783
  };
14662
16784
  compareByDefinition("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, true);
14663
16785
  compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
@@ -14689,20 +16811,14 @@ export function registerConfigSyncCommands(sync) {
14689
16811
  hint = "content not compared (fetch failed)";
14690
16812
  }
14691
16813
  else {
14692
- try {
14693
- const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
14694
- const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
14695
- const dbSpec = configDiffSpec("database-type-config");
14696
- const remoteHash = hashRemoteConfigForDiff(dbSpec, remote, diffMaps, { operations, subscriptions });
14697
- const localHash = hashLocalConfigForDiff(dbSpec, parsed, diffMaps);
14698
- if (remoteHash !== localHash) {
14699
- status = "modified";
14700
- hint = "run `config pull` to rewrite the local TOML to match running state";
14701
- }
14702
- }
14703
- catch {
14704
- hint = "content not compared (comparison failed)";
14705
- }
16814
+ const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
16815
+ const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
16816
+ // The shared verdict (#3267): a Modified row names the
16817
+ // operation, subscription, model field or `[type]` scalar it
16818
+ // differs in, and a comparison that throws degrades this one
16819
+ // row to "not compared". The ops/subscriptions are the remote
16820
+ // side's extra only — the file already carries its own.
16821
+ ({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
14706
16822
  }
14707
16823
  }
14708
16824
  differences.push({ type: "database-type-config", key, status, hint });
@@ -14843,7 +16959,23 @@ export function registerConfigSyncCommands(sync) {
14843
16959
  // the same preflight push runs decides these rows, and its message —
14844
16960
  // verbatim, so the two commands read identically — replaces whatever
14845
16961
  // the comparison had to say about the file.
14846
- for (const rejected of collectConfigFileValidationErrors(configDir)) {
16962
+ // #3320 — the function checks join them. Same posture, same messages:
16963
+ // a function file whose `entry`, mode, triggers or webhook
16964
+ // signing-secret the push preflight refuses is not a row this command
16965
+ // calls Synced, Modified or "local only — new".
16966
+ // Merged first: a file both collectors reject keeps every message push
16967
+ // prints, rather than one collector's hint overwriting the other's.
16968
+ // #3482 — the same advisory `config push` prints, so an author sees
16969
+ // the retired key before they push rather than only during it. Not a
16970
+ // row status: the file is pushable, and saying otherwise would make
16971
+ // `config diff` disagree with the push it predicts.
16972
+ for (const message of collectFunctionTomlIgnoredKeyWarnings(configDir)) {
16973
+ warn(` ${message}`);
16974
+ }
16975
+ for (const rejected of mergeConfigFileValidationErrors([
16976
+ ...collectConfigFileValidationErrors(configDir),
16977
+ ...collectFunctionTomlValidationErrors(configDir),
16978
+ ])) {
14847
16979
  const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
14848
16980
  const hint = rejected.messages.join("; ");
14849
16981
  if (row) {
@@ -14874,6 +17006,22 @@ export function registerConfigSyncCommands(sync) {
14874
17006
  ? selectionIncludesApp(diffSelection)
14875
17007
  : selectionIncludesKey(diffSelection, row.type, row.key));
14876
17008
  }
17009
+ // #3320 — `--json` over the whole comparison, not just `--only app`.
17010
+ // The Invalid rows this issue adds have to be readable by the thing
17011
+ // that reads a diff programmatically (an editor, a CI job), and the
17012
+ // flag already existed for the app-settings case; a run that asked for
17013
+ // JSON and got a human table was answering a different question.
17014
+ // Emitted after the `--only` filter so a scoped run reports its scope.
17015
+ if (options.json) {
17016
+ console.log(JSON.stringify({
17017
+ appId: resolvedAppId,
17018
+ configDir,
17019
+ differences,
17020
+ testCases: testCaseDiffs,
17021
+ notCompared,
17022
+ }, null, 2));
17023
+ return;
17024
+ }
14877
17025
  divider();
14878
17026
  const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
14879
17027
  const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
@@ -15002,6 +17150,41 @@ export function registerConfigSyncCommands(sync) {
15002
17150
  console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
15003
17151
  }
15004
17152
  }
17153
+ // #3182 — the generated function types are part of what a push would
17154
+ // change, so `config diff` is where their freshness is answered: an
17155
+ // editor or a CI job asks the same command it already asks "what would
17156
+ // push do?", and gets these named alongside everything else.
17157
+ let staleFunctionTypes = [];
17158
+ let wiringProblems = [];
17159
+ try {
17160
+ staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
17161
+ // Freshness is not the only way the handle stops working: a tsconfig
17162
+ // push will not rewrite can be perfectly current and still load none
17163
+ // of it. `config diff` is where CI asks, so it answers both
17164
+ // (CR3182-003).
17165
+ wiringProblems = functionTypeWiringProblems(configDir);
17166
+ }
17167
+ catch (err) {
17168
+ if (!(err instanceof FunctionSchemaCodegenError))
17169
+ throw err;
17170
+ // #3281 — a function file whose schema cannot be typed is reported
17171
+ // like a wiring problem: the generators cannot answer for the tree.
17172
+ wiringProblems = [err.message];
17173
+ }
17174
+ if (staleFunctionTypes.length > 0) {
17175
+ console.log();
17176
+ info("Function types (regenerated by `config push`):");
17177
+ for (const relative of staleFunctionTypes) {
17178
+ console.log(` ${chalk.yellow("~")} ${relative}`);
17179
+ }
17180
+ }
17181
+ if (wiringProblems.length > 0) {
17182
+ console.log();
17183
+ info("Function types (compiler wiring `config push` cannot repair):");
17184
+ for (const problem of wiringProblems) {
17185
+ console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
17186
+ }
17187
+ }
15005
17188
  divider();
15006
17189
  keyValue("Local only (new)", localOnly.length);
15007
17190
  keyValue("Local only (absent from export)", localOnlyAbsent.length);
@@ -15039,22 +17222,18 @@ export function registerConfigSyncCommands(sync) {
15039
17222
  // containing null) fall back to JSON-string per field with a log message.
15040
17223
  sync
15041
17224
  .command("migrate-toml")
17225
+ .allowExcessArguments(false)
15042
17226
  .description("Rewrite database-type and workflow TOML files to native form " +
15043
17227
  "([operations.definition] / [[operations.params]] for database types; " +
15044
17228
  "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
15045
- .argument("[app-id]", "App ID (uses current app if not specified)")
15046
- .option("--app <app-id>", "App ID")
15047
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
15048
17229
  .option("--dry-run", "Show what would change without writing files")
15049
- .action(async (appId, options) => {
15050
- // Resolve appId so we land in the per-env sync dir even when the user
15051
- // didn't pass `--dir`. The app ID itself isn't used for any API calls
15052
- // — `migrate-toml` is a purely local rewrite.
15053
- const resolvedAppId = resolveAppId(appId, options);
15054
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
15055
- if (isAutoResolvedSyncDir(options.dir)) {
15056
- info(`Using per-environment config directory: ${configDir}`);
15057
- }
17230
+ .action(async (options) => {
17231
+ // The app ID is not used for any API call — `migrate-toml` is a purely
17232
+ // local rewrite — but the environment that names it is what names the
17233
+ // tree being rewritten.
17234
+ const resolvedAppId = getCurrentAppId();
17235
+ const configDir = resolveSyncDir();
17236
+ info(`Using per-environment config directory: ${configDir}`);
15058
17237
  if (!existsSync(configDir)) {
15059
17238
  error(`Config directory not found: ${configDir}`);
15060
17239
  process.exit(1);
@@ -15204,21 +17383,16 @@ export function registerConfigSyncCommands(sync) {
15204
17383
  // proven), so re-running push/pull is unaffected.
15205
17384
  sync
15206
17385
  .command("migrate-v2")
17386
+ .allowExcessArguments(false)
15207
17387
  .description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
15208
- .argument("[app-id]", "App ID (uses current app if not specified)")
15209
- .option("--app <app-id>", "App ID")
15210
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
15211
17388
  .option("--dry-run", "Show what would change without writing files")
15212
17389
  .option("--force", "Overwrite existing block.toml files instead of skipping them")
15213
- .action(async (appId, options) => {
15214
- // Resolve appId so we land in the per-env sync dir even when the user
15215
- // didn't pass `--dir`. The app ID itself isn't used for any API calls —
15216
- // migrate-v2 is a purely local rewrite (like migrate-toml).
15217
- const resolvedAppId = resolveAppId(appId, options);
15218
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
15219
- if (isAutoResolvedSyncDir(options.dir)) {
15220
- info(`Using per-environment config directory: ${configDir}`);
15221
- }
17390
+ .action(async (options) => {
17391
+ // As with migrate-toml: a purely local rewrite, whose tree is the
17392
+ // resolved environment's.
17393
+ const resolvedAppId = getCurrentAppId();
17394
+ const configDir = resolveSyncDir();
17395
+ info(`Using per-environment config directory: ${configDir}`);
15222
17396
  if (!existsSync(configDir)) {
15223
17397
  error(`Config directory not found: ${configDir}`);
15224
17398
  process.exit(1);
@@ -15257,26 +17431,16 @@ export function registerConfigSyncCommands(sync) {
15257
17431
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
15258
17432
  sync
15259
17433
  .command("revert")
17434
+ .allowExcessArguments(false)
15260
17435
  .description("Restore the config directory from a snapshot taken before a previous pull")
15261
- .argument("[app-id]", "App ID (uses current app if not specified)")
15262
- .option("--app <app-id>", "App ID")
15263
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
15264
17436
  .option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
15265
17437
  .option("--list", "List available snapshots without restoring")
15266
17438
  .option("-y, --yes", "Skip the confirmation prompt")
15267
- .action(async (appId, options) => {
15268
- const resolvedAppId = resolveAppId(appId, options);
15269
- const configDir = resolveSyncDir({
15270
- appId: resolvedAppId,
15271
- userDir: options.dir,
15272
- });
15273
- const snapshotsRoot = resolveSnapshotsRoot({
15274
- appId: resolvedAppId,
15275
- userDir: options.dir,
15276
- });
15277
- if (isAutoResolvedSyncDir(options.dir)) {
15278
- info(`Using per-environment config directory: ${configDir}`);
15279
- }
17439
+ .action(async (options) => {
17440
+ const resolvedAppId = getCurrentAppId();
17441
+ const configDir = resolveSyncDir();
17442
+ const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
17443
+ info(`Using per-environment config directory: ${configDir}`);
15280
17444
  const snapshots = listSnapshots(snapshotsRoot);
15281
17445
  // --list, or no snapshots at all: enumerate and stop.
15282
17446
  if (options.list || snapshots.length === 0) {
@@ -15336,15 +17500,13 @@ export function registerConfigSyncCommands(sync) {
15336
17500
  }
15337
17501
  }
15338
17502
  try {
15339
- // Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
15340
- // it across the full-tree swap so we don't wipe backup history.
15341
- restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
17503
+ restoreSnapshot(target.path, configDir);
15342
17504
  }
15343
17505
  catch (err) {
15344
17506
  error(`Restore failed: ${err?.message ?? err}`);
15345
17507
  process.exit(1);
15346
17508
  }
15347
- success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
17509
+ success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
15348
17510
  info("Run 'primitive config diff' to inspect the restored state versus the server.");
15349
17511
  });
15350
17512
  }