@k2b/cloud 0.42.0 → 0.44.0

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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -36,15 +36,27 @@ const EXACT_EXPRESSION = /^\$\{\{\s*([^{}]+?)\s*\}\}$/;
36
36
 
37
37
  class RuntimeVariableScope implements WorkflowVariableScope {
38
38
  readonly #values = new Map<string, WorkflowJsonValue>();
39
+ readonly #unknown = new Set<string>();
39
40
 
40
41
  constructor(
41
42
  initial: Record<string, WorkflowJsonValue> = {},
42
43
  readonly parent?: RuntimeVariableScope,
44
+ public dependsOnUnknown: boolean = parent?.dependsOnUnknown ?? false,
43
45
  ) {
44
46
  for (const [name, value] of Object.entries(initial)) this.#values.set(name, value);
45
47
  }
46
48
 
49
+ markUnknown(name: string): void {
50
+ this.#values.delete(name);
51
+ this.#unknown.add(name);
52
+ }
53
+
54
+ isUnknown(name: string): boolean {
55
+ return this.#unknown.has(name) || (!this.#values.has(name) && this.parent?.isUnknown(name) === true);
56
+ }
57
+
47
58
  get(name: string): WorkflowJsonValue | undefined {
59
+ if (this.#unknown.has(name)) return undefined;
48
60
  return this.#values.has(name) ? this.#values.get(name) : this.parent?.get(name);
49
61
  }
50
62
 
@@ -53,18 +65,18 @@ class RuntimeVariableScope implements WorkflowVariableScope {
53
65
  }
54
66
 
55
67
  set(name: string, value: WorkflowJsonValue): void {
68
+ this.#unknown.delete(name);
56
69
  this.#values.set(name, value);
57
70
  }
58
71
 
59
72
  snapshot(): Record<string, WorkflowJsonValue> {
60
- return {
61
- ...(this.parent?.snapshot() ?? {}),
62
- ...Object.fromEntries(this.#values),
63
- };
73
+ const values = { ...(this.parent?.snapshot() ?? {}), ...Object.fromEntries(this.#values) };
74
+ for (const name of this.#unknown) delete values[name];
75
+ return values;
64
76
  }
65
77
 
66
- child(initial: Record<string, WorkflowJsonValue> = {}): RuntimeVariableScope {
67
- return new RuntimeVariableScope(initial, this);
78
+ child(initial: Record<string, WorkflowJsonValue> = {}, unknown = this.dependsOnUnknown): RuntimeVariableScope {
79
+ return new RuntimeVariableScope(initial, this, unknown);
68
80
  }
69
81
  }
70
82
 
@@ -93,11 +105,18 @@ export class WorkflowRetryableStepError extends Error {
93
105
  }
94
106
  }
95
107
 
96
- class WorkflowValueError extends Error {}
108
+ export class WorkflowValueError extends Error {
109
+ constructor(
110
+ message: string,
111
+ readonly code = "WORKFLOW_VALUE_UNAVAILABLE",
112
+ ) {
113
+ super(message);
114
+ }
115
+ }
97
116
 
98
- class WorkflowValueWaiting extends Error {
117
+ export class WorkflowValueWaiting extends Error {
99
118
  constructor(
100
- reference: string,
119
+ readonly reference: string,
101
120
  readonly dependency: WorkflowDependency,
102
121
  ) {
103
122
  super(`workflow reference "${reference}" is waiting`);
@@ -189,7 +208,10 @@ const resolveLocalReference = (state: RuntimeState, scope: RuntimeVariableScope,
189
208
  if (rootName === "inputs") root = state.options.invocation.inputs;
190
209
  else if (rootName === "bindings") root = state.options.plan.bindings;
191
210
  else if (rootName === "context") root = state.options.invocation.context ?? {};
192
- else root = scope.get(rootName);
211
+ else {
212
+ if (scope.isUnknown(rootName)) return { state: "waiting", dependency: { kind: "workflow.dry-run-value", key: rootName } };
213
+ root = scope.get(rootName);
214
+ }
193
215
  const value = root === undefined || segments.length === 0 ? root : readWorkflowValuePath(root, segments);
194
216
  return value === undefined ? { state: "missing" } : { state: "resolved", value };
195
217
  };
@@ -201,7 +223,8 @@ const resolveReference = async (
201
223
  path: Array<string | number> = [],
202
224
  ): Promise<WorkflowValueResolution> => {
203
225
  const local = () => resolveLocalReference(state, scope, reference);
204
- if (!state.options.values) return local();
226
+ const localValue = local();
227
+ if (localValue.state === "waiting" || !state.options.values) return localValue;
205
228
  const resolved = await state.options.values.resolve({
206
229
  reference,
207
230
  path,
@@ -297,12 +320,43 @@ const evaluateCondition = async (
297
320
  if (waiting) throw waiting;
298
321
  return false;
299
322
  }
300
- if (condition.operator === "not") return !(await evaluateCondition(state, scope, condition.condition, [...path, "not"]));
323
+ if (condition.operator === "not") {
324
+ const matched = !(await evaluateCondition(state, scope, condition.condition, [...path, "not"]));
325
+ if (state.options.mode === "dryRun") await emit(state, { type: "condition.evaluated", run: state.run, path, matched });
326
+ return matched;
327
+ }
301
328
  if (condition.operator === "exists") {
302
329
  const resolved = await resolveReference(state, scope, condition.reference, [...path, "exists"]);
303
330
  if (resolved.state === "waiting") throw new WorkflowValueWaiting(condition.reference, resolved.dependency);
304
331
  return resolved.state === "resolved" && resolved.value !== null && resolved.value !== "";
305
332
  }
333
+ if (
334
+ condition.operator === "greaterThan" ||
335
+ condition.operator === "lessThan" ||
336
+ condition.operator === "atLeast" ||
337
+ condition.operator === "atMost"
338
+ ) {
339
+ const numbers: number[] = [];
340
+ for (const [index, operand] of condition.operands.entries()) {
341
+ const expression = typeof operand === "string" ? EXACT_EXPRESSION.exec(operand)?.[1]?.trim() : undefined;
342
+ let value: WorkflowJsonValue;
343
+ if (expression && expression !== "now()") {
344
+ const resolved = await resolveReference(state, scope, expression, [...path, condition.operator, index]);
345
+ if (resolved.state === "waiting") throw new WorkflowValueWaiting(expression, resolved.dependency);
346
+ if (resolved.state === "missing") return false;
347
+ value = resolved.value;
348
+ } else {
349
+ value = await evaluateValue(state, scope, operand, [...path, condition.operator, index]);
350
+ }
351
+ if (typeof value !== "number" || !Number.isFinite(value)) return false;
352
+ numbers.push(value);
353
+ }
354
+ const [left, right] = numbers;
355
+ if (condition.operator === "greaterThan") return left! > right!;
356
+ if (condition.operator === "lessThan") return left! < right!;
357
+ if (condition.operator === "atLeast") return left! >= right!;
358
+ return left! <= right!;
359
+ }
306
360
  const left = await evaluateValue(state, scope, condition.operands[0], [...path, condition.operator, 0]);
307
361
  const right = await evaluateValue(state, scope, condition.operands[1], [...path, condition.operator, 1]);
308
362
  if (condition.operator === "equals") return jsonEqual(left, right);
@@ -363,7 +417,7 @@ const actionContext = (
363
417
  };
364
418
  return state.options.mode === "execute"
365
419
  ? ({ ...common, mode: "execute" } as WorkflowExecuteActionContext)
366
- : ({ ...common, mode: "dryRun" } as WorkflowDryRunActionContext);
420
+ : ({ ...common, mode: "dryRun", dependsOnUnknown: scope.dependsOnUnknown } as WorkflowDryRunActionContext);
367
421
  };
368
422
 
369
423
  const invalidRestoration = (state: RuntimeState, step: WorkflowRuntimeStepIdentity, message: string): Flow =>
@@ -449,11 +503,18 @@ const restoreCompletedIfDescendants = async (
449
503
  irStep: Extract<WorkflowIrStep, { kind: "if" }>,
450
504
  iterationPath: number[],
451
505
  persistedTraversal: unknown,
506
+ unknown: boolean,
452
507
  ): Promise<Flow> => {
453
508
  const traversal = persistedIfTraversal(persistedTraversal);
454
509
  for (const branch of traversal.branches) {
455
- const flow = await restoreCompletedSteps(state, scope.child(), branch === "then" ? irStep.then : irStep.else, iterationPath);
456
- if (flow.state !== "continue") return flow;
510
+ const flow = await restoreCompletedSteps(
511
+ state,
512
+ scope.child({}, scope.dependsOnUnknown || unknown),
513
+ branch === "then" ? irStep.then : irStep.else,
514
+ iterationPath,
515
+ );
516
+ if (unknown && flow.state === "terminal_planned") scope.dependsOnUnknown = true;
517
+ else if (flow.state !== "continue") return flow;
457
518
  }
458
519
  return { state: "continue" };
459
520
  };
@@ -464,13 +525,15 @@ const restoreCompletedSwitchDescendants = async (
464
525
  irStep: Extract<WorkflowIrStep, { kind: "switch" }>,
465
526
  iterationPath: number[],
466
527
  persistedTraversal: unknown,
528
+ unknown: boolean,
467
529
  ): Promise<Flow> => {
468
530
  const traversal = persistedSwitchTraversal(irStep, persistedTraversal);
469
531
  for (const branch of traversal.branches) {
470
532
  const steps = branch === "default" ? irStep.default : irStep.cases[branch]?.steps;
471
533
  if (!steps) throw new WorkflowValueError(`completed switch step references unavailable branch "${branch}"`);
472
- const flow = await restoreCompletedSteps(state, scope.child(), steps, iterationPath);
473
- if (flow.state !== "continue") return flow;
534
+ const flow = await restoreCompletedSteps(state, scope.child({}, scope.dependsOnUnknown || unknown), steps, iterationPath);
535
+ if (unknown && flow.state === "terminal_planned") scope.dependsOnUnknown = true;
536
+ else if (flow.state !== "continue") return flow;
474
537
  }
475
538
  return { state: "continue" };
476
539
  };
@@ -481,6 +544,7 @@ const restoreCompletedForEachDescendants = async (
481
544
  irStep: Extract<WorkflowIrStep, { kind: "forEach" }>,
482
545
  iterationPath: number[],
483
546
  persistedTraversal: unknown,
547
+ unknown: boolean,
484
548
  ): Promise<Flow> => {
485
549
  const traversal = persistedForEachTraversal(persistedTraversal);
486
550
  const limit = loopItemLimit(state);
@@ -488,11 +552,11 @@ const restoreCompletedForEachDescendants = async (
488
552
  throw new WorkflowValueError(`completed forEach traversal has ${traversal.items.length} items; limit is ${limit}`);
489
553
  }
490
554
  for (let index = 0; index < traversal.items.length; index += 1) {
491
- const flow = await restoreCompletedSteps(state, scope.child({ [irStep.alias]: traversal.items[index]! }), irStep.steps, [
492
- ...iterationPath,
493
- index,
494
- ]);
495
- if (flow.state !== "continue") return flow;
555
+ const child = scope.child({ [irStep.alias]: traversal.items[index]! }, scope.dependsOnUnknown || unknown);
556
+ if (unknown) child.markUnknown(irStep.alias);
557
+ const flow = await restoreCompletedSteps(state, child, irStep.steps, [...iterationPath, index]);
558
+ if (unknown && flow.state === "terminal_planned") scope.dependsOnUnknown = true;
559
+ else if (flow.state !== "continue") return flow;
496
560
  }
497
561
  return { state: "continue" };
498
562
  };
@@ -503,13 +567,14 @@ const restoreCompletedControlDescendants = (
503
567
  irStep: Exclude<WorkflowIrStep, { kind: "action" }>,
504
568
  iterationPath: number[],
505
569
  persistedTraversal: WorkflowControlTraversal | undefined,
570
+ unknown = false,
506
571
  ): Promise<Flow> => {
507
572
  if (!persistedTraversal) {
508
573
  throw new WorkflowValueError(`completed ${irStep.kind} step is missing its persisted control traversal`);
509
574
  }
510
- if (irStep.kind === "if") return restoreCompletedIfDescendants(state, scope, irStep, iterationPath, persistedTraversal);
511
- if (irStep.kind === "switch") return restoreCompletedSwitchDescendants(state, scope, irStep, iterationPath, persistedTraversal);
512
- return restoreCompletedForEachDescendants(state, scope, irStep, iterationPath, persistedTraversal);
575
+ if (irStep.kind === "if") return restoreCompletedIfDescendants(state, scope, irStep, iterationPath, persistedTraversal, unknown);
576
+ if (irStep.kind === "switch") return restoreCompletedSwitchDescendants(state, scope, irStep, iterationPath, persistedTraversal, unknown);
577
+ return restoreCompletedForEachDescendants(state, scope, irStep, iterationPath, persistedTraversal, unknown);
513
578
  };
514
579
 
515
580
  const restoreCompletedState = async (
@@ -524,7 +589,14 @@ const restoreCompletedState = async (
524
589
  return restoreCompletedControlDescendants(state, scope, irStep, step.iterationPath, restored.outcome.control);
525
590
  }
526
591
  if (restored.mode === "dryRun" && restored.outcome.state === "planned") {
527
- return restoreCompletedControlDescendants(state, scope, irStep, step.iterationPath, restored.outcome.control);
592
+ return restoreCompletedControlDescendants(
593
+ state,
594
+ scope,
595
+ irStep,
596
+ step.iterationPath,
597
+ restored.outcome.control,
598
+ restored.outcome.issues?.some((issue) => issue.step.key === step.key) ?? false,
599
+ );
528
600
  }
529
601
  return { state: "continue" };
530
602
  }
@@ -539,6 +611,16 @@ const restoreCompletedState = async (
539
611
  return invalidRestoration(state, step, `planner for action "${irStep.action}" is unavailable while restoring step "${step.key}"`);
540
612
  await handler.restoreCompleted?.(actionContext(state, scope, step) as WorkflowDryRunActionContext, irStep, restored.outcome);
541
613
  }
614
+ if (restored.mode === "dryRun") {
615
+ const savedName = irStep.action === "setVariable" ? irStep.config.name : irStep.config.saveAs;
616
+ if (
617
+ typeof savedName === "string" &&
618
+ (restored.outcome.state === "indeterminate" ||
619
+ restored.outcome.state === "unsupported" ||
620
+ (restored.outcome.state === "planned" && restored.outcome.output === undefined && restored.outcome.issues?.length))
621
+ )
622
+ scope.markUnknown(savedName);
623
+ }
542
624
  return { state: "continue" };
543
625
  };
544
626
 
@@ -639,6 +721,15 @@ const evaluateAction = async (
639
721
  const outcome: WorkflowPlanningOutcome = handler
640
722
  ? await handler.plan(actionContext(state, scope, step) as WorkflowDryRunActionContext, irStep)
641
723
  : { state: "unsupported", reason: `action "${irStep.action}" has no dry-run handler` };
724
+ const savedName = irStep.action === "setVariable" ? irStep.config.name : irStep.config.saveAs;
725
+ if (
726
+ typeof savedName === "string" &&
727
+ (outcome.state === "indeterminate" ||
728
+ outcome.state === "unsupported" ||
729
+ (outcome.state === "planned" && outcome.output === undefined && outcome.issues?.length))
730
+ ) {
731
+ scope.markUnknown(savedName);
732
+ }
642
733
  if (outcome.state === "planned" || outcome.state === "terminal") {
643
734
  state.effects.push(...outcome.effects);
644
735
  state.issues.push(...restoredIssues(state, outcome.issues));
@@ -729,8 +820,9 @@ const analyzeUnknownBranches = async (
729
820
  const branches =
730
821
  irStep.kind === "if" ? [irStep.then, irStep.else] : [...irStep.cases.map((candidate) => candidate.steps), irStep.default];
731
822
  for (const branch of branches) {
732
- const flow = await runSteps(state, branch, scope.child(), iterationPath);
823
+ const flow = await runSteps(state, branch, scope.child({}, true), iterationPath);
733
824
  if (flow.state === "canceled") return flow;
825
+ if (flow.state === "terminal_planned") scope.dependsOnUnknown = true;
734
826
  }
735
827
  return { state: "continue" };
736
828
  };
@@ -786,10 +878,13 @@ const evaluateControl = async (
786
878
  if (state.options.mode !== "dryRun") throw new WorkflowValueError(reason);
787
879
  state.issues.push({ state: "indeterminate", reason, step: stepIdentity(state, irStep, iterationPath) });
788
880
  control = { kind: "forEach", items: [null] };
789
- flow = await runSteps(state, irStep.steps, scope.child({ [irStep.alias]: null }), [...iterationPath, 0]);
881
+ const unknownScope = scope.child({}, true);
882
+ unknownScope.markUnknown(irStep.alias);
883
+ flow = await runSteps(state, irStep.steps, unknownScope, [...iterationPath, 0]);
790
884
  if (flow.state === "canceled") {
791
885
  return { flow, result: resultForHaltedControl(state, flow, effectsStart, issuesStart) };
792
886
  }
887
+ if (flow.state === "terminal_planned") scope.dependsOnUnknown = true;
793
888
  flow = { state: "continue" };
794
889
  return { flow, result: resultForCompletedControl(state, undefined, control, effectsStart, issuesStart) };
795
890
  }
@@ -842,6 +937,10 @@ const evaluateStep = async (
842
937
  };
843
938
  }
844
939
  if (error instanceof WorkflowValueWaiting) {
940
+ if (state.options.mode === "dryRun" && irStep.kind === "action") {
941
+ const name = irStep.action === "setVariable" ? irStep.config.name : irStep.config.saveAs;
942
+ if (typeof name === "string") scope.markUnknown(name);
943
+ }
845
944
  if (state.options.mode === "execute") {
846
945
  const outcome: WorkflowStepOutcome = { state: "waiting", dependency: error.dependency };
847
946
  return { flow: { state: "waiting", dependency: error.dependency, step }, result: { mode: "execute", outcome } };
@@ -850,16 +949,16 @@ const evaluateStep = async (
850
949
  return { flow: { state: "indeterminate", reason: outcome.reason, step }, result: { mode: "dryRun", outcome } };
851
950
  }
852
951
  if (state.options.mode === "dryRun") {
952
+ if (irStep.kind === "action") {
953
+ const name = irStep.action === "setVariable" ? irStep.config.name : irStep.config.saveAs;
954
+ if (typeof name === "string") scope.markUnknown(name);
955
+ }
853
956
  const outcome: WorkflowPlanningOutcome = { state: "indeterminate", reason: errorMessage(error) };
854
957
  return { flow: { state: "indeterminate", reason: outcome.reason, step }, result: { mode: "dryRun", outcome } };
855
958
  }
856
959
  const outcome: WorkflowStepOutcome = {
857
960
  state: "failed",
858
- error: executionError(
859
- error instanceof WorkflowValueError ? "WORKFLOW_VALUE_UNAVAILABLE" : "WORKFLOW_ACTION_ERROR",
860
- errorMessage(error),
861
- false,
862
- ),
961
+ error: executionError(error instanceof WorkflowValueError ? error.code : "WORKFLOW_ACTION_ERROR", errorMessage(error), false),
863
962
  };
864
963
  return { flow: { state: "failed", error: outcome.error, step }, result: { mode: "execute", outcome } };
865
964
  }
@@ -14,6 +14,8 @@ export {
14
14
  executeWorkflowPlan,
15
15
  WorkflowLeaseLostError,
16
16
  WorkflowRetryableStepError,
17
+ WorkflowValueError,
18
+ WorkflowValueWaiting,
17
19
  } from "./executor";
18
20
  export type {
19
21
  WorkflowActionStep,
@@ -78,7 +78,10 @@ type WorkflowActionContextBase<Mode extends WorkflowInvocationMode> = {
78
78
  };
79
79
 
80
80
  export type WorkflowExecuteActionContext = WorkflowActionContextBase<"execute">;
81
- export type WorkflowDryRunActionContext = WorkflowActionContextBase<"dryRun">;
81
+ export type WorkflowDryRunActionContext = WorkflowActionContextBase<"dryRun"> & {
82
+ /** The containing branch cannot be selected without an unavailable value. */
83
+ dependsOnUnknown?: boolean;
84
+ };
82
85
 
83
86
  export type WorkflowExecuteActionHandler = {
84
87
  execute(context: WorkflowExecuteActionContext, step: WorkflowActionStep): Promise<WorkflowStepOutcome>;
@@ -120,6 +123,7 @@ export interface WorkflowDryRunActionPort {
120
123
  * carried a copy of it would be one more thing to keep in step.
121
124
  */
122
125
  export type WorkflowTraceEvent =
126
+ | { type: "condition.evaluated"; run: WorkflowRuntimeRunIdentity; path: Array<string | number>; matched: boolean }
123
127
  | { type: "run.started"; run: WorkflowRuntimeRunIdentity }
124
128
  | { type: "step.started"; step: WorkflowRuntimeStepIdentity }
125
129
  | { type: "step.restored"; step: WorkflowRuntimeStepIdentity; restored: WorkflowRestoredStep }
@@ -434,7 +434,7 @@ export const createWorkflowDryRunPort = (module: DefinedWorkflowModule): Workflo
434
434
  applySaveAs(ctx, step, (planned.output ?? undefined) as WorkflowJsonValue | undefined);
435
435
  return {
436
436
  state: "planned" as const,
437
- output: (planned.output ?? null) as WorkflowJsonValue,
437
+ ...(planned.outputUnavailable ? {} : { output: planned.output ?? null }),
438
438
  effects: [{ action: name, summary: planned.summary, ...(consumes ? { consumes } : {}) } as WorkflowJsonValue],
439
439
  // An issue names the step it belongs to, so the dry-run view can point
440
440
  // at what could not be determined rather than listing loose strings.
@@ -145,6 +145,11 @@ export const deleteWorkflowScope = async (scope: { appId: string; scopeId: strin
145
145
  DELETE FROM workflows.workflow WHERE app_id = ${scope.appId} AND scope_id = ${scope.scopeId} RETURNING id
146
146
  `;
147
147
  await tx`DELETE FROM workflows.event WHERE app_id = ${scope.appId} AND scope_id = ${scope.scopeId}`;
148
+ const [ai] = await tx<{ present: boolean }[]>`SELECT to_regclass('ai.workflow_scope_budget') IS NOT NULL AS present`;
149
+ if (ai?.present) {
150
+ await tx`DELETE FROM ai.workflow_scope_budget WHERE app_id = ${scope.appId} AND scope_id = ${scope.scopeId}`;
151
+ await tx`DELETE FROM ai.workflow_scope_admission WHERE app_id = ${scope.appId} AND scope_id = ${scope.scopeId}`;
152
+ }
148
153
  return rows.length;
149
154
  });
150
155
  };
@@ -24,7 +24,7 @@ import type {
24
24
  WorkflowCoordinatorPort,
25
25
  WorkflowCoordinatorReleaseState,
26
26
  } from "../runtime/coordinator";
27
- import { WorkflowLeaseLostError } from "../runtime/executor";
27
+ import { WorkflowLeaseLostError, WorkflowRetryableStepError } from "../runtime/executor";
28
28
  import type {
29
29
  WorkflowHeartbeatOutcome,
30
30
  WorkflowRestoredStep,
@@ -401,7 +401,7 @@ export const finishWorkflowRun = async (
401
401
  */
402
402
  export const releaseWorkflowRun = async (
403
403
  claim: { runId: string; executionGeneration: number; consecutiveFailures?: number },
404
- options: { backoffMs?: number; db?: SQL } = {},
404
+ options: { backoffMs?: number; db?: SQL; error?: WorkflowJsonValue } = {},
405
405
  ): Promise<WorkflowCoordinatorReleaseState> => {
406
406
  const db = options.db ?? sql;
407
407
  const backoffMs =
@@ -413,6 +413,7 @@ export const releaseWorkflowRun = async (
413
413
  lease_expires_at = NULL,
414
414
  retry_after = now() + ${`${backoffMs} milliseconds`}::interval,
415
415
  consecutive_failures = consecutive_failures + 1,
416
+ error = ${options.error ?? null},
416
417
  updated_at = now()
417
418
  WHERE id = ${claim.runId}
418
419
  AND execution_generation = ${claim.executionGeneration}
@@ -545,7 +546,8 @@ export const createWorkflowCoordinatorPort = (options: {
545
546
  claim: () => claimWorkflowRun(options),
546
547
  renew: (claim) => renewWorkflowRunLease(claim, { leaseMs: options.leaseMs }),
547
548
  finish: (claim, result) => finishWorkflowRun(claim, result),
548
- release: (claim) => releaseWorkflowRun(claim),
549
+ release: (claim, error) =>
550
+ releaseWorkflowRun(claim, { ...(error instanceof WorkflowRetryableStepError ? { error: error.executionError } : {}) }),
549
551
  });
550
552
 
551
553
  // ─── Scans ───────────────────────────────────────────────────────────────────
@@ -184,10 +184,15 @@ export const runOneWorkflow = async (options: WorkflowWorkerOptions & { runId?:
184
184
  const incompatible = await validateCurrentModule(claim, options.module);
185
185
  if (incompatible) return incompatible;
186
186
  if (claim.consecutiveFailures >= WORKFLOW_RUN_MAX_CONSECUTIVE_FAILURES) {
187
+ const [prior] = await db<{ code: string | null; message: string | null }[]>`
188
+ SELECT error ->> 'code' AS code, error ->> 'message' AS message FROM workflows.run WHERE id = ${claim.runId}::uuid
189
+ `;
190
+ const aiCode = prior?.code?.startsWith("WORKFLOW_AI_") ? prior.code : null;
187
191
  const error = {
188
- code: "WORKFLOW_RETRY_EXHAUSTED",
189
- message: `workflow failed ${claim.consecutiveFailures} consecutive attempts`,
192
+ code: aiCode ?? "WORKFLOW_RETRY_EXHAUSTED",
193
+ message: `${aiCode && prior?.message ? `${prior.message} ` : ""}Workflow failed ${claim.consecutiveFailures} consecutive attempts. Inspect the source or retry after capacity recovers.`,
190
194
  retryable: false,
195
+ ...(aiCode ? { details: { retryExhausted: true } } : {}),
191
196
  };
192
197
  await db`
193
198
  UPDATE workflows.step_outcome AS s
@@ -1,43 +0,0 @@
1
- import { sql } from "bun";
2
- import { coreSettings } from "../services";
3
- import { audit } from "../services/audit";
4
- import { invalidateSettingsCache, set as setSetting } from "../services/settings";
5
- import { decryptValue } from "../services/settings/crypto";
6
- import { type AiModelPricing, AiModelPricingSchema } from "../shared/ai-costs";
7
- import { AiQuotaError } from "./quotas";
8
- import { parseAiModelProfiles } from "./settings";
9
-
10
- /** Change prices only, preserving credentials and model access in their own stores. */
11
- export async function setAiModelPricing(id: string, pricing: AiModelPricing | null, expected: AiModelPricing | null, actorId: string) {
12
- if (pricing) AiModelPricingSchema.parse(pricing);
13
- const key = "ai.model_profiles_json";
14
- const fallback = await coreSettings.get<string>(key);
15
- const result = await sql.begin(async (db) => {
16
- const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`;
17
- const raw = row ? await decryptValue(row.value) : fallback;
18
- if (typeof raw !== "string") throw new Error("Invalid model configuration.");
19
- const parsed = parseAiModelProfiles(raw);
20
- if (parsed.error) throw new Error(parsed.error.message);
21
- const profile = parsed.profiles.find((profile) => profile.id === id);
22
- if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
23
- if (profile.capabilities.includes("transcription")) throw new AiQuotaError("quota_conflict", "Audio pricing is not supported.");
24
- if (JSON.stringify(profile.pricing ?? null) !== JSON.stringify(expected))
25
- throw new AiQuotaError("quota_conflict", "Model prices changed. Read them again before updating.");
26
- if (pricing) profile.pricing = pricing;
27
- else delete profile.pricing;
28
- await setSetting(key, JSON.stringify(parsed.profiles), db);
29
- await audit.record(
30
- {
31
- action: "ai.model.pricing",
32
- outcome: "allowed",
33
- actor: { userId: actorId },
34
- target: { type: "ai-model", id },
35
- metadata: { pricing },
36
- },
37
- db,
38
- );
39
- return { id, pricing: profile.pricing ?? null };
40
- });
41
- await invalidateSettingsCache([key]);
42
- return result;
43
- }
@@ -1,98 +0,0 @@
1
- import { sql } from "bun";
2
- import { coreSettings } from "../services";
3
- import { audit } from "../services/audit";
4
- import { invalidateSettingsCache, set as setSetting } from "../services/settings";
5
- import { decryptValue } from "../services/settings/crypto";
6
- import {
7
- type AiModelRequestSettings,
8
- type AiModelRequestSettingsUpdate,
9
- AiModelRequestSettingsUpdateSchema,
10
- } from "../shared/ai-model-request-settings";
11
- import { AiQuotaError } from "./quotas";
12
- import { getAiRequestHeaders, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "./request-headers";
13
- import { parseAiModelProfiles } from "./settings";
14
- import type { AiModelProfile } from "./types";
15
-
16
- const key = "ai.model_profiles_json";
17
- export class AiModelRequestSettingsInvalid extends Error {}
18
-
19
- const readSnapshot = async (id: string, fallback: string, db: typeof sql, lock = false) => {
20
- const rows = lock
21
- ? await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`
22
- : await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key}`;
23
- const raw = rows[0] ? await decryptValue(rows[0].value) : fallback;
24
- if (typeof raw !== "string") throw new AiModelRequestSettingsInvalid("Invalid model configuration.");
25
- const parsed = parseAiModelProfiles(raw);
26
- if (parsed.error) throw new AiModelRequestSettingsInvalid(parsed.error.fields?.[key] ?? parsed.error.message);
27
- const profile = parsed.profiles.find((profile) => profile.id === id);
28
- if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
29
- const [headerRow] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id=${id}`;
30
- // Hash ciphertext, never secret values. Any settings/header change invalidates
31
- // the snapshot, including replacement of a value under an unchanged name.
32
- const revision = new Bun.CryptoHasher("sha256").update(JSON.stringify([rows[0]?.value ?? raw, headerRow?.secret ?? null])).digest("hex");
33
- const requestHeaderNames = Object.keys(await getAiRequestHeaders(id, db)).sort();
34
- return { profiles: parsed.profiles, profile, settings: present(profile, requestHeaderNames, revision) };
35
- };
36
- const present = (profile: AiModelProfile, requestHeaderNames: string[], revision: string): AiModelRequestSettings => ({
37
- id: profile.id,
38
- reasoningEffort: profile.reasoningEffort ?? null,
39
- extraBody: profile.extraBody ?? null,
40
- requestHeaderNames,
41
- revision,
42
- });
43
-
44
- export const getAiModelRequestSettings = async (id: string): Promise<AiModelRequestSettings> =>
45
- (await readSnapshot(id, await coreSettings.get<string>(key), sql)).settings;
46
-
47
- /** One transaction changes the non-secret profile and encrypted headers; audit contains names only. */
48
- export const setAiModelRequestSettings = async (
49
- id: string,
50
- input: AiModelRequestSettingsUpdate,
51
- actorId: string,
52
- ): Promise<AiModelRequestSettings> => {
53
- const data = AiModelRequestSettingsUpdateSchema.parse(input);
54
- const fallback = await coreSettings.get<string>(key);
55
- const result = await sql.begin(async (db) => {
56
- const current = await readSnapshot(id, fallback, db, true);
57
- if (current.settings.revision !== data.expected)
58
- throw new AiQuotaError("quota_conflict", "Model request settings changed. Read them again before updating.");
59
- const nextProfile = { ...current.profile };
60
- if (Object.hasOwn(data, "reasoningEffort")) {
61
- if (data.reasoningEffort) nextProfile.reasoningEffort = data.reasoningEffort;
62
- else delete nextProfile.reasoningEffort;
63
- }
64
- if (Object.hasOwn(data, "extraBody")) {
65
- if (data.extraBody) nextProfile.extraBody = data.extraBody;
66
- else delete nextProfile.extraBody;
67
- }
68
- const validated = parseAiModelProfiles(JSON.stringify([nextProfile]));
69
- if (validated.error) throw new AiModelRequestSettingsInvalid(validated.error.fields?.[key] ?? validated.error.message);
70
- const headers = data.clearRequestHeaders ? {} : await getAiRequestHeaders(id, db);
71
- const plan = planAiProfileRequestHeaders({
72
- currentProfiles: [current.profile],
73
- nextProfiles: [nextProfile],
74
- existingNames: { [id]: Object.keys(headers) },
75
- submitted: data.requestHeaders === undefined ? [] : [{ profileId: id, patch: data.requestHeaders }],
76
- });
77
- if (plan.error) throw new AiModelRequestSettingsInvalid(plan.error);
78
- // This endpoint owns one profile only. Never prune the other profiles.
79
- const keepOthers = current.profiles.filter((profile) => profile.id !== id).map((profile) => profile.id);
80
- plan.keepHeaderProfileIds = [...keepOthers, ...(data.clearRequestHeaders ? [] : plan.keepHeaderProfileIds)];
81
- await storeAiRequestHeaderPlan(plan, db);
82
- await setSetting(key, JSON.stringify(current.profiles.map((profile) => (profile.id === id ? validated.profiles[0] : profile))), db);
83
- const saved = (await readSnapshot(id, fallback, db)).settings;
84
- await audit.record(
85
- {
86
- action: "ai.model.request-settings",
87
- outcome: "allowed",
88
- actor: { userId: actorId },
89
- target: { type: "ai-model", id },
90
- metadata: { reasoningEffort: saved.reasoningEffort, extraBody: saved.extraBody, requestHeaderNames: saved.requestHeaderNames },
91
- },
92
- db,
93
- );
94
- return saved;
95
- });
96
- await invalidateSettingsCache([key]);
97
- return result;
98
- };