@k2b/cloud 0.43.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 (267) hide show
  1. package/package.json +7 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -1,7 +1,12 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import { AI_TASK_REQUEST_MAX_BYTES, AiTaskInputTooLargeError, assertAiTaskRequestSize } from "../../ai/task-contracts";
3
+ import type { AccessSubject } from "../../server/services/access";
4
+ import { type AiResolvedModelRef, AiResolvedModelRefSchema } from "../../shared/ai-models";
2
5
  import type { WorkflowJsonValue } from "../contracts";
3
6
  import { wakeWorkflowRunsWaitingOn } from "../store/runs";
7
+ import { withTransaction } from "../store/transaction";
4
8
  import { notifyWorkflowWorker } from "../store/worker-runtime";
9
+ import { WorkflowAiAdmissionError, type WorkflowAiScope, workflowAiHash } from "./policy";
5
10
  import {
6
11
  type WorkflowAiRequest,
7
12
  type WorkflowAiRequestInput,
@@ -12,17 +17,23 @@ import {
12
17
 
13
18
  export const WORKFLOW_AI_DEPENDENCY_KIND = "ai.workflow-task";
14
19
 
20
+ // Released application results use the same 24-hour horizon as admission history.
21
+ const APPLICATION_AI_TASK_RETENTION_HOURS = 24;
22
+
15
23
  type WorkflowAiTaskRow = {
16
24
  usage_user_id: string | null;
17
25
  id: string;
18
26
  app_id: string;
19
- run_id: string;
27
+ run_id: string | null;
28
+ usage_subject: AccessSubject | null;
20
29
  step_key: string;
21
30
  effect_key: string;
22
31
  kind: WorkflowAiRequest["kind"];
23
32
  request: unknown;
24
33
  input_hash: string;
25
34
  model_profile_id: string;
35
+ model_ref: unknown;
36
+ model_config_hash: string | null;
26
37
  status: WorkflowAiTaskStatus;
27
38
  output: unknown;
28
39
  usage: unknown;
@@ -51,13 +62,16 @@ const taskFromRow = (row: WorkflowAiTaskRow): WorkflowAiTask => ({
51
62
  id: row.id,
52
63
  appId: row.app_id,
53
64
  runId: row.run_id,
65
+ usageSubject: row.usage_subject ?? undefined,
54
66
  usageUserId: row.usage_user_id ?? undefined,
55
67
  stepKey: row.step_key,
56
68
  effectKey: row.effect_key,
57
69
  kind: row.kind,
58
70
  request: workflowAiRequestSchema.parse(json(row.request)) as WorkflowAiRequest,
59
71
  inputHash: row.input_hash,
60
- modelProfileId: row.model_profile_id,
72
+ modelId: row.model_profile_id,
73
+ modelRef: row.model_ref ? AiResolvedModelRefSchema.parse(row.model_ref) : undefined,
74
+ modelConfigHash: row.model_config_hash ?? undefined,
61
75
  status: row.status,
62
76
  output: json(row.output) as WorkflowJsonValue | null,
63
77
  usage: json(row.usage) as WorkflowJsonValue | null,
@@ -76,12 +90,15 @@ const TASK_COLUMNS = `
76
90
  task.id::text,
77
91
  task.app_id,
78
92
  task.run_id::text,
93
+ task.usage_subject,
79
94
  task.step_key,
80
95
  task.effect_key,
81
96
  task.kind,
82
97
  task.request,
83
98
  task.input_hash,
84
99
  task.model_profile_id,
100
+ task.model_ref,
101
+ task.model_config_hash,
85
102
  task.status,
86
103
  task.output,
87
104
  task.usage,
@@ -129,6 +146,32 @@ export const migrateWorkflowAi = async (db: SQL = sql): Promise<void> => {
129
146
  ALTER TABLE ai.workflow_task ADD CONSTRAINT workflow_task_kind_check
130
147
  CHECK (kind IN ('generate_text', 'classify', 'classify_many', 'extract_data'))
131
148
  `.simple();
149
+ await db`
150
+ ALTER TABLE ai.workflow_task ALTER COLUMN run_id DROP NOT NULL;
151
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS usage_subject JSONB;
152
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS scope_id TEXT;
153
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS source_id TEXT;
154
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS cache_key TEXT;
155
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS model_config_hash TEXT;
156
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS model_ref JSONB;
157
+ ALTER TABLE ai.workflow_task ADD COLUMN IF NOT EXISTS released_at TIMESTAMPTZ;
158
+ CREATE INDEX IF NOT EXISTS idx_ai_application_task_sources ON ai.workflow_task(app_id, scope_id, source_id)
159
+ WHERE run_id IS NULL AND released_at IS NULL;
160
+ CREATE INDEX IF NOT EXISTS idx_ai_application_task_retention ON ai.workflow_task(released_at, id)
161
+ WHERE run_id IS NULL AND released_at IS NOT NULL AND status IN ('succeeded', 'failed', 'canceled');
162
+
163
+ CREATE INDEX IF NOT EXISTS idx_ai_workflow_task_cache ON ai.workflow_task(app_id, scope_id, cache_key, completed_at DESC) WHERE status = 'succeeded';
164
+ CREATE INDEX IF NOT EXISTS idx_ai_workflow_task_active_scope ON ai.workflow_task(app_id, scope_id) WHERE status IN ('queued', 'running');
165
+ CREATE TABLE IF NOT EXISTS ai.workflow_scope_budget (
166
+ app_id TEXT NOT NULL, scope_id TEXT NOT NULL, tokens DOUBLE PRECISION NOT NULL,
167
+ refilled_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY(app_id, scope_id)
168
+ );
169
+ CREATE TABLE IF NOT EXISTS ai.workflow_scope_admission (
170
+ app_id TEXT NOT NULL, scope_id TEXT NOT NULL, source_id TEXT NOT NULL,
171
+ task_id UUID NOT NULL PRIMARY KEY, logical_key TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
172
+ );
173
+ CREATE INDEX IF NOT EXISTS idx_ai_workflow_scope_admission ON ai.workflow_scope_admission(app_id, scope_id, created_at, source_id)
174
+ `.simple();
132
175
  await db`
133
176
  CREATE INDEX IF NOT EXISTS idx_ai_workflow_task_recovery
134
177
  ON ai.workflow_task(status, updated_at, id)
@@ -136,7 +179,7 @@ export const migrateWorkflowAi = async (db: SQL = sql): Promise<void> => {
136
179
  `.simple();
137
180
  };
138
181
 
139
- const requestHash = (request: WorkflowAiRequest): string => new Bun.CryptoHasher("sha256").update(JSON.stringify(request)).digest("hex");
182
+ const requestHash = (request: WorkflowAiRequest): string => workflowAiHash(request);
140
183
 
141
184
  export const getWorkflowAiTask = async (id: string, db: SQL = sql): Promise<WorkflowAiTask | null> => {
142
185
  const rows = await db<WorkflowAiTaskRow[]>`
@@ -163,23 +206,127 @@ export const workflowAiTaskExists = async (effectKey: string, db: SQL = sql): Pr
163
206
  return Boolean(row?.exists);
164
207
  };
165
208
 
209
+ /** The application has durably settled or discarded the request; replay is no longer needed. */
210
+ export const releaseApplicationAiTaskByEffectKey = async (
211
+ input: { appId: string; scopeId: string; effectKey: string },
212
+ db: SQL = sql,
213
+ ): Promise<boolean> => {
214
+ const rows = await db<{ id: string }[]>`
215
+ UPDATE ai.workflow_task SET released_at = COALESCE(released_at, now())
216
+ WHERE app_id = ${input.appId} AND scope_id = ${input.scopeId} AND effect_key = ${input.effectKey} AND run_id IS NULL
217
+ RETURNING id::text
218
+ `;
219
+ return rows.length > 0;
220
+ };
221
+
222
+ /** Bounded owner identities for application reconciliation after a crash during deletion. */
223
+ export const listUnreleasedApplicationAiTaskSources = async (
224
+ input: { appId: string; task?: string; after?: { scopeId: string; sourceId: string } },
225
+ db: SQL = sql,
226
+ ): Promise<Array<{ scopeId: string; sourceId: string }>> =>
227
+ db`SELECT DISTINCT scope_id AS "scopeId", source_id AS "sourceId" FROM ai.workflow_task
228
+ WHERE app_id = ${input.appId} AND run_id IS NULL AND released_at IS NULL
229
+ AND (${input.task ?? null}::text IS NULL OR step_key = ${input.task ?? null})
230
+ AND scope_id IS NOT NULL AND source_id IS NOT NULL
231
+ AND (${input.after?.scopeId ?? null}::text IS NULL
232
+ OR (scope_id, source_id) > (${input.after?.scopeId ?? null}, ${input.after?.sourceId ?? null}))
233
+ ORDER BY scope_id, source_id LIMIT 100`;
234
+
235
+ /** Remove an application's task payloads when their owning scope or source is deleted. */
236
+ export const deleteApplicationAiTasks = async (
237
+ input: { appId: string; scopeId: string; sourceId?: string },
238
+ db: SQL = sql,
239
+ ): Promise<number> =>
240
+ withTransaction(db, async (tx) => {
241
+ // Serialize with task creation/cache copies. The application fences future creation.
242
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('workflow-ai-admission', 0))`;
243
+ const [row] = await tx<{ count: number }[]>`
244
+ WITH deleted AS (
245
+ DELETE FROM ai.workflow_task
246
+ WHERE app_id = ${input.appId} AND scope_id = ${input.scopeId} AND run_id IS NULL
247
+ AND (${input.sourceId ?? null}::text IS NULL OR source_id = ${input.sourceId ?? null})
248
+ RETURNING id
249
+ ) SELECT count(*)::int AS count FROM deleted
250
+ `;
251
+ // Keep admission history: deleting owner data must not reset rate/daily accounting.
252
+ return row?.count ?? 0;
253
+ });
254
+
166
255
  export const createWorkflowAiTask = async (
167
- input: { runId: string; stepKey: string; effectKey: string; request: WorkflowAiRequestInput; modelProfileId: string },
256
+ input: {
257
+ runId?: string;
258
+ /** Required for a task owned by a durable application job instead of a workflow. */
259
+ owner?: { appId: string; scopeId: string; usageSubject?: AccessSubject };
260
+ stepKey: string;
261
+ effectKey: string;
262
+ request: WorkflowAiRequestInput;
263
+ modelId: string;
264
+ modelRef?: AiResolvedModelRef;
265
+ modelConfigHash?: string;
266
+ scope?: WorkflowAiScope;
267
+ },
168
268
  db: SQL = sql,
169
269
  ): Promise<{ task: WorkflowAiTask; created: boolean }> => {
270
+ if (Boolean(input.runId) === Boolean(input.owner)) throw new Error("AI task requires exactly one workflow run or application owner.");
271
+ assertAiTaskRequestSize(input.request);
170
272
  const request = workflowAiRequestSchema.parse(input.request) as WorkflowAiRequest;
171
- const rows = await db<WorkflowAiTaskRow[]>`
172
- INSERT INTO ai.workflow_task (app_id, run_id, step_key, effect_key, kind, request, input_hash, model_profile_id)
173
- SELECT run.app_id, run.id, ${input.stepKey}, ${input.effectKey}, ${request.kind}, ${request}, ${requestHash(request)}, ${input.modelProfileId}
174
- FROM workflows.run AS run
175
- WHERE run.id = ${input.runId}::uuid
273
+ assertAiTaskRequestSize(request);
274
+ // JSONB adds separator spaces and expands numeric exponents. Measure its exact
275
+ // representation before INSERT, keeping the DB constraint as a final guard.
276
+ const [size] = await db<{ bytes: number }[]>`SELECT octet_length(${request}::jsonb::text) AS bytes`;
277
+ if (!size || size.bytes > AI_TASK_REQUEST_MAX_BYTES) throw new AiTaskInputTooLargeError();
278
+ if (input.scope && !input.modelConfigHash) throw new Error("Scoped inference requires a resolved model configuration hash.");
279
+ return withTransaction(db, async (db) => {
280
+ // This short admission transaction serializes reservations across replicas.
281
+ // It is never held during inference.
282
+ if (input.scope) await db`SELECT pg_advisory_xact_lock(hashtextextended('workflow-ai-admission', 0))`;
283
+ const existingTask = await getWorkflowAiTaskByEffectKey(input.effectKey, db);
284
+ if (existingTask) return { task: existingTask, created: false };
285
+ const run = input.owner
286
+ ? { app_id: input.owner.appId, scope_id: input.owner.scopeId }
287
+ : (
288
+ await db<{ app_id: string; scope_id: string }[]>`
289
+ SELECT app_id, scope_id FROM workflows.run WHERE id = ${input.runId ?? null}::uuid
290
+ AND cancel_requested_at IS NULL AND state <> 'canceled'
291
+ `
292
+ )[0];
293
+ if (!run) throw new Error("Workflow AI run is unavailable.");
294
+ const cacheKey = input.scope
295
+ ? workflowAiHash({
296
+ source: input.scope.sourceId,
297
+ revision: input.scope.sourceRevision,
298
+ request,
299
+ model: input.modelConfigHash,
300
+ ...(input.owner?.usageSubject ? { owner: input.owner.usageSubject } : {}),
301
+ })
302
+ : null;
303
+ let cached: WorkflowAiTask | null = null;
304
+ if (input.scope && !input.scope.recompute) {
305
+ const [hit] = await db<{ id: string }[]>`
306
+ SELECT id::text FROM ai.workflow_task
307
+ WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id} AND cache_key = ${cacheKey} AND status = 'succeeded'
308
+ ORDER BY completed_at DESC, id LIMIT 1
309
+ `;
310
+ if (hit) cached = await getWorkflowAiTask(hit.id, db);
311
+ }
312
+ if (input.scope && !cached) await reserveWorkflowAiBudget(db, run, input.scope);
313
+ const rows = await db<WorkflowAiTaskRow[]>`
314
+ INSERT INTO ai.workflow_task (app_id, run_id, usage_subject, step_key, effect_key, kind, request, input_hash, model_profile_id, model_ref, scope_id, source_id, cache_key, model_config_hash, status, output, completed_at)
315
+ VALUES (${run.app_id}, ${input.runId ?? null}::uuid, ${input.owner?.usageSubject ?? null}, ${input.stepKey}, ${input.effectKey}, ${request.kind}, ${request}, ${requestHash(request)}, ${input.modelId}, ${input.modelRef ? JSON.stringify(AiResolvedModelRefSchema.parse(input.modelRef)) : null}::text::jsonb,
316
+ ${run.scope_id}, ${input.scope?.sourceId ?? null}, ${cacheKey}, ${input.modelConfigHash ?? null},
317
+ ${cached ? "succeeded" : "queued"}, ${cached?.output ?? null}, ${cached ? new Date() : null})
176
318
  ON CONFLICT (effect_key) DO NOTHING
177
319
  RETURNING ${db.unsafe(TASK_COLUMNS.replaceAll("task.", ""))}
178
320
  `;
179
- if (rows[0]) return { task: taskFromRow(rows[0]), created: true };
180
- const existing = await getWorkflowAiTaskByEffectKey(input.effectKey, db);
181
- if (!existing) throw new Error("Workflow AI task could not be created because its run is unavailable.");
182
- return { task: existing, created: false };
321
+ if (rows[0]) {
322
+ if (input.scope && !cached)
323
+ await db`INSERT INTO ai.workflow_scope_admission(app_id, scope_id, source_id, task_id, logical_key) VALUES (${run.app_id}, ${run.scope_id}, ${input.scope.sourceId}, ${rows[0].id}::uuid, ${input.scope.recompute ? rows[0].id : cacheKey})`;
324
+ return { task: taskFromRow(rows[0]), created: true };
325
+ }
326
+ const existing = await getWorkflowAiTaskByEffectKey(input.effectKey, db);
327
+ if (!existing) throw new Error("Workflow AI task could not be created because its run is unavailable.");
328
+ return { task: existing, created: false };
329
+ });
183
330
  };
184
331
 
185
332
  export const listRecoverableWorkflowAiTaskIds = async (db: SQL = sql): Promise<string[]> => {
@@ -188,6 +335,7 @@ export const listRecoverableWorkflowAiTaskIds = async (db: SQL = sql): Promise<s
188
335
  FROM ai.workflow_task
189
336
  WHERE status IN ('queued', 'running')
190
337
  ORDER BY updated_at, id
338
+ LIMIT 100
191
339
  `;
192
340
  return rows.map((row) => row.id);
193
341
  };
@@ -203,12 +351,13 @@ export const claimWorkflowAiTask = async (id: string, db: SQL = sql): Promise<Wo
203
351
  error_code = NULL,
204
352
  error_message = NULL,
205
353
  updated_at = now()
206
- FROM workflows.run AS run
207
354
  WHERE task.id = ${id}::uuid
208
- AND run.id = task.run_id
209
- AND run.cancel_requested_at IS NULL
210
- AND run.state <> 'canceled'
211
- AND task.status IN ('queued', 'running')
355
+ AND task.cancel_requested_at IS NULL
356
+ AND (task.run_id IS NULL OR EXISTS (
357
+ SELECT 1 FROM workflows.run run WHERE run.id = task.run_id
358
+ AND run.cancel_requested_at IS NULL AND run.state <> 'canceled'
359
+ ))
360
+ AND task.status = 'queued'
212
361
  RETURNING ${db.unsafe(TASK_COLUMNS)}
213
362
  `;
214
363
  return rows[0] ? taskFromRow(rows[0]) : markWorkflowAiTaskCanceledIfRequested(id, db);
@@ -216,21 +365,24 @@ export const claimWorkflowAiTask = async (id: string, db: SQL = sql): Promise<Wo
216
365
 
217
366
  export const workflowAiTaskCancellationRequested = async (id: string, db: SQL = sql): Promise<boolean> => {
218
367
  const [row] = await db<{ canceled: boolean }[]>`
219
- SELECT run.cancel_requested_at IS NOT NULL OR run.state = 'canceled' AS canceled
368
+ SELECT task.cancel_requested_at IS NOT NULL OR run.cancel_requested_at IS NOT NULL OR COALESCE(run.state = 'canceled', false) AS canceled
220
369
  FROM ai.workflow_task AS task
221
- JOIN workflows.run AS run ON run.id = task.run_id
370
+ LEFT JOIN workflows.run AS run ON run.id = task.run_id
222
371
  WHERE task.id = ${id}::uuid
223
372
  `;
224
- return Boolean(row?.canceled);
373
+ // Owner deletion also revokes an in-flight task; do not finish a removed request.
374
+ return !row || row.canceled;
225
375
  };
226
376
 
227
- export const wakeWorkflowAiTask = async (task: Pick<WorkflowAiTask, "appId" | "id">): Promise<void> => {
377
+ export const wakeWorkflowAiTask = async (task: Pick<WorkflowAiTask, "appId" | "id"> & { runId?: string | null }): Promise<void> => {
378
+ if (task.runId === null) return;
228
379
  await wakeWorkflowRunsWaitingOn({ appId: task.appId, kind: WORKFLOW_AI_DEPENDENCY_KIND, key: task.id });
229
380
  notifyWorkflowWorker(task.appId);
230
381
  };
231
382
 
232
383
  export const markWorkflowAiTaskCanceledIfRequested = async (id: string, db: SQL = sql): Promise<WorkflowAiTask | null> => {
233
- const rows = await db<WorkflowAiTaskRow[]>`
384
+ const rows = await withTransaction(db, async (db) => {
385
+ const rows = await db<WorkflowAiTaskRow[]>`
234
386
  UPDATE ai.workflow_task AS task
235
387
  SET status = 'canceled',
236
388
  output = NULL,
@@ -240,13 +392,17 @@ export const markWorkflowAiTaskCanceledIfRequested = async (id: string, db: SQL
240
392
  cancel_requested_at = COALESCE(task.cancel_requested_at, now()),
241
393
  completed_at = now(),
242
394
  updated_at = now()
243
- FROM workflows.run AS run
244
395
  WHERE task.id = ${id}::uuid
245
- AND run.id = task.run_id
246
- AND (run.cancel_requested_at IS NOT NULL OR run.state = 'canceled')
396
+ AND (task.cancel_requested_at IS NOT NULL OR EXISTS (
397
+ SELECT 1 FROM workflows.run run WHERE run.id = task.run_id
398
+ AND (run.cancel_requested_at IS NOT NULL OR run.state = 'canceled')
399
+ ))
247
400
  AND task.status IN ('queued', 'running')
248
401
  RETURNING ${db.unsafe(TASK_COLUMNS)}
249
402
  `;
403
+ if (rows[0]) await db`UPDATE ai.workflow_scope_admission SET logical_key = NULL WHERE task_id = ${id}::uuid`;
404
+ return rows;
405
+ });
250
406
  const task = rows[0] ? taskFromRow(rows[0]) : null;
251
407
  if (task) await wakeWorkflowAiTask(task);
252
408
  return task;
@@ -269,11 +425,12 @@ export const completeWorkflowAiTask = async (
269
425
  const rows = await db<WorkflowAiTaskRow[]>`
270
426
  UPDATE ai.workflow_task AS task
271
427
  SET status = 'succeeded', output = ${output}, usage = ${usage}, completed_at = now(), updated_at = now()
272
- FROM workflows.run AS run
273
428
  WHERE task.id = ${id}::uuid
274
- AND run.id = task.run_id
275
- AND run.cancel_requested_at IS NULL
276
- AND run.state <> 'canceled'
429
+ AND task.cancel_requested_at IS NULL
430
+ AND (task.run_id IS NULL OR EXISTS (
431
+ SELECT 1 FROM workflows.run run WHERE run.id = task.run_id
432
+ AND run.cancel_requested_at IS NULL AND run.state <> 'canceled'
433
+ ))
277
434
  AND task.status = 'running'
278
435
  RETURNING ${db.unsafe(TASK_COLUMNS)}
279
436
  `;
@@ -290,7 +447,8 @@ export const failWorkflowAiTask = async (
290
447
  error: { code: string; message: string },
291
448
  db: SQL = sql,
292
449
  ): Promise<WorkflowAiTask | null> => {
293
- const rows = await db<WorkflowAiTaskRow[]>`
450
+ const rows = await withTransaction(db, async (db) => {
451
+ const rows = await db<WorkflowAiTaskRow[]>`
294
452
  UPDATE ai.workflow_task AS task
295
453
  SET status = 'failed',
296
454
  output = NULL,
@@ -299,14 +457,18 @@ export const failWorkflowAiTask = async (
299
457
  error_message = ${error.message.slice(0, 2_000)},
300
458
  completed_at = now(),
301
459
  updated_at = now()
302
- FROM workflows.run AS run
303
460
  WHERE task.id = ${id}::uuid
304
- AND run.id = task.run_id
305
- AND run.cancel_requested_at IS NULL
306
- AND run.state <> 'canceled'
461
+ AND task.cancel_requested_at IS NULL
462
+ AND (task.run_id IS NULL OR EXISTS (
463
+ SELECT 1 FROM workflows.run run WHERE run.id = task.run_id
464
+ AND run.cancel_requested_at IS NULL AND run.state <> 'canceled'
465
+ ))
307
466
  AND task.status IN ('queued', 'running')
308
467
  RETURNING ${db.unsafe(TASK_COLUMNS)}
309
468
  `;
469
+ if (rows[0]) await db`UPDATE ai.workflow_scope_admission SET logical_key = NULL WHERE task_id = ${id}::uuid`;
470
+ return rows;
471
+ });
310
472
  const task = rows[0] ? taskFromRow(rows[0]) : null;
311
473
  if (task) {
312
474
  await wakeWorkflowAiTask(task);
@@ -314,3 +476,89 @@ export const failWorkflowAiTask = async (
314
476
  }
315
477
  return markWorkflowAiTaskCanceledIfRequested(id, db);
316
478
  };
479
+
480
+ /** DB liveness is separate from NATS heartbeat: lost/expired deliveries must settle. */
481
+ export const heartbeatWorkflowAiTask = async (id: string, db: SQL = sql): Promise<void> => {
482
+ await db`UPDATE ai.workflow_task SET updated_at = now() WHERE id = ${id}::uuid AND status = 'running'`;
483
+ };
484
+
485
+ export const expireWorkflowAiTasks = async (queueTimeoutMs: number, db: SQL = sql): Promise<void> => {
486
+ const rows = await withTransaction(db, async (db) => {
487
+ const rows = await db<{ id: string; app_id: string; run_id: string | null }[]>`
488
+ WITH expired AS (
489
+ SELECT candidate.id, run.cancel_requested_at, run.state
490
+ FROM ai.workflow_task candidate LEFT JOIN workflows.run run ON run.id = candidate.run_id
491
+ WHERE (candidate.status = 'queued' AND candidate.updated_at < now() - ${queueTimeoutMs} * interval '1 millisecond')
492
+ OR (candidate.status = 'running' AND candidate.updated_at < now() - interval '30 seconds')
493
+ ORDER BY candidate.updated_at, candidate.id LIMIT 100 FOR UPDATE OF candidate SKIP LOCKED
494
+ )
495
+ UPDATE ai.workflow_task AS task
496
+ SET status = CASE WHEN task.cancel_requested_at IS NOT NULL OR run.cancel_requested_at IS NOT NULL OR run.state = 'canceled' THEN 'canceled' ELSE 'failed' END,
497
+ completed_at = now(), updated_at = now(),
498
+ error_code = CASE
499
+ WHEN task.cancel_requested_at IS NOT NULL OR run.cancel_requested_at IS NOT NULL OR run.state = 'canceled' THEN 'WORKFLOW_AI_CANCELED'
500
+ WHEN task.status = 'queued' THEN 'WORKFLOW_AI_QUEUE_TIMEOUT' ELSE 'WORKFLOW_AI_INTERRUPTED' END,
501
+ error_message = CASE WHEN task.cancel_requested_at IS NOT NULL OR run.cancel_requested_at IS NOT NULL OR run.state = 'canceled' THEN 'Workflow AI task was canceled.'
502
+ ELSE 'Workflow AI delivery expired or its worker stopped. Retry the workflow after checking capacity and provider health.' END
503
+ FROM expired AS run WHERE run.id = task.id
504
+ RETURNING task.id::text, task.app_id, task.run_id::text
505
+ `;
506
+ for (const row of rows) await db`UPDATE ai.workflow_scope_admission SET logical_key = NULL WHERE task_id = ${row.id}::uuid`;
507
+ return rows;
508
+ });
509
+ for (const row of rows) await wakeWorkflowAiTask({ id: row.id, appId: row.app_id, runId: row.run_id });
510
+ // A crash between terminal persistence and the wake must also be repaired.
511
+ const waiting = await db<{ id: string; app_id: string }[]>`
512
+ SELECT task.id::text, task.app_id
513
+ FROM workflows.step_outcome step
514
+ JOIN workflows.run run ON run.id = step.run_id
515
+ JOIN ai.workflow_task task ON task.id = CASE
516
+ WHEN step.dependency ->> 'kind' = 'ai.workflow-task' THEN (step.dependency ->> 'key')::uuid END
517
+ AND task.run_id = run.id
518
+ WHERE step.state = 'waiting' AND run.state = 'waiting'
519
+ AND step.dependency ->> 'kind' = 'ai.workflow-task'
520
+ AND task.status IN ('succeeded', 'failed', 'canceled')
521
+ ORDER BY step.run_id, step.step_key LIMIT 100
522
+ `;
523
+ for (const row of waiting) await wakeWorkflowAiTask({ id: row.id, appId: row.app_id });
524
+ await db`DELETE FROM ai.workflow_scope_admission WHERE task_id IN (
525
+ SELECT task_id FROM ai.workflow_scope_admission WHERE created_at <= now() - interval '24 hours' ORDER BY created_at LIMIT 100
526
+ )`;
527
+ await db`
528
+ WITH expired AS (
529
+ SELECT id FROM ai.workflow_task
530
+ WHERE run_id IS NULL AND status IN ('succeeded', 'failed', 'canceled')
531
+ AND released_at < now() - ${APPLICATION_AI_TASK_RETENTION_HOURS} * interval '1 hour'
532
+ AND completed_at < now() - ${APPLICATION_AI_TASK_RETENTION_HOURS} * interval '1 hour'
533
+ ORDER BY released_at, id LIMIT 100 FOR UPDATE SKIP LOCKED
534
+ )
535
+ DELETE FROM ai.workflow_task task USING expired WHERE task.id = expired.id
536
+ `;
537
+ };
538
+
539
+ const reserveWorkflowAiBudget = async (db: SQL, run: { app_id: string; scope_id: string }, scope: WorkflowAiScope): Promise<void> => {
540
+ const budget = scope.budget;
541
+ for (const value of Object.values(budget)) if (!Number.isSafeInteger(value) || value < 1) throw new Error("Invalid workflow AI budget.");
542
+ await db`DELETE FROM ai.workflow_scope_admission WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id} AND created_at <= now() - interval '24 hours'`;
543
+ const [counts] = await db<{ source_steps: number; sources: number; seen_source: boolean }[]>`
544
+ SELECT count(DISTINCT logical_key) FILTER (WHERE source_id = ${scope.sourceId})::int AS source_steps,
545
+ count(DISTINCT source_id)::int AS sources, bool_or(source_id = ${scope.sourceId}) AS seen_source
546
+ FROM ai.workflow_scope_admission WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id}
547
+ `;
548
+ if (counts && counts.source_steps >= budget.maxStepsPerSource) throw new WorkflowAiAdmissionError("WORKFLOW_AI_SOURCE_BUDGET");
549
+ if (counts && !counts.seen_source && counts.sources >= budget.sourcesPer24Hours)
550
+ throw new WorkflowAiAdmissionError("WORKFLOW_AI_DAILY_BUDGET");
551
+ const [active] = await db<{ scope: number; global: number }[]>`
552
+ SELECT count(*) FILTER (WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id})::int AS scope, count(*)::int AS global
553
+ FROM ai.workflow_task WHERE status IN ('queued', 'running') AND cache_key IS NOT NULL
554
+ `;
555
+ if (active && (active.scope >= budget.scopeConcurrent || active.global >= budget.globalConcurrent))
556
+ throw new WorkflowAiAdmissionError("WORKFLOW_AI_CAPACITY");
557
+ await db`INSERT INTO ai.workflow_scope_budget(app_id, scope_id, tokens) VALUES (${run.app_id}, ${run.scope_id}, ${budget.burst}) ON CONFLICT DO NOTHING`;
558
+ const [bucket] = await db<{ available: number }[]>`
559
+ SELECT least(${budget.burst}::double precision, tokens + extract(epoch FROM now() - refilled_at) * ${budget.tasksPerMinute} / 60) AS available
560
+ FROM ai.workflow_scope_budget WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id} FOR UPDATE
561
+ `;
562
+ if (!bucket || bucket.available < 1) throw new WorkflowAiAdmissionError("WORKFLOW_AI_RATE_LIMIT");
563
+ await db`UPDATE ai.workflow_scope_budget SET tokens = ${bucket.available - 1}, refilled_at = now() WHERE app_id = ${run.app_id} AND scope_id = ${run.scope_id}`;
564
+ };
@@ -1,4 +1,6 @@
1
1
  import type { AiTaskRequest as WorkflowAiRequest } from "../../ai/task-contracts";
2
+ import type { AccessSubject } from "../../server/services/access";
3
+ import type { AiResolvedModelRef } from "../../shared/ai-models";
2
4
  import type { WorkflowJsonValue } from "../contracts";
3
5
 
4
6
  export {
@@ -14,13 +16,17 @@ export type WorkflowAiTask = {
14
16
  usageUserId?: string;
15
17
  id: string;
16
18
  appId: string;
17
- runId: string;
19
+ runId: string | null;
20
+ /** Authenticated owner of an interactive application task; never supplied by model input. */
21
+ usageSubject?: AccessSubject;
18
22
  stepKey: string;
19
23
  effectKey: string;
20
24
  kind: WorkflowAiRequest["kind"];
21
25
  request: WorkflowAiRequest;
22
26
  inputHash: string;
23
- modelProfileId: string;
27
+ modelId: string;
28
+ modelRef?: AiResolvedModelRef;
29
+ modelConfigHash?: string;
24
30
  status: WorkflowAiTaskStatus;
25
31
  output: WorkflowJsonValue | null;
26
32
  usage: WorkflowJsonValue | null;