@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -1,3 +1,4 @@
1
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "../../ai/inference-calls";
1
2
  import { StructuredOutputError } from "@k2b/nessi";
2
3
  import type { Job, Worker } from "@k2b/sync";
3
4
  import { z } from "zod";
@@ -96,7 +97,7 @@ export const executeWorkflowAiRequest = async (task: WorkflowAiTask, runStructur
96
97
  const request = task.request;
97
98
  const common = {
98
99
  appId: task.appId,
99
- attribution: { workflowRunId: task.runId, userId: task.usageUserId },
100
+ attribution: { workflowRunId: task.runId, stepKey: task.stepKey, userId: task.usageUserId },
100
101
  requestedModelId: task.modelProfileId,
101
102
  signal,
102
103
  temperature: 0,
@@ -159,12 +160,15 @@ export const executeWorkflowAiRequest = async (task: WorkflowAiTask, runStructur
159
160
  };
160
161
 
161
162
  const retryableError = (error: unknown): boolean => {
163
+ if (error instanceof AiBackgroundAdmissionError) return error.retryable;
164
+ if (error instanceof AiBackgroundCostError) return false;
162
165
  if (isAiSettingsError(error)) return false;
163
166
  if (error instanceof StructuredOutputError) return error.code === "loop_failed";
164
167
  return true;
165
168
  };
166
169
 
167
170
  const errorCode = (error: unknown): string => {
171
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) return error.code;
168
172
  if (isAiSettingsError(error)) return error.aiError.code.toUpperCase();
169
173
  if (error instanceof StructuredOutputError) return `WORKFLOW_AI_${error.code.toUpperCase()}`;
170
174
  return "WORKFLOW_AI_PROVIDER_ERROR";
@@ -1,3 +1,4 @@
1
+ import { notifyWorkflowWorker } from "../store/worker-runtime";
1
2
  import { type SQL, sql } from "bun";
2
3
  import type { WorkflowJsonValue } from "../contracts";
3
4
  import { wakeWorkflowRunsWaitingOn } from "../store/runs";
@@ -225,6 +226,7 @@ export const workflowAiTaskCancellationRequested = async (id: string, db: SQL =
225
226
 
226
227
  export const wakeWorkflowAiTask = async (task: Pick<WorkflowAiTask, "appId" | "id">): Promise<void> => {
227
228
  await wakeWorkflowRunsWaitingOn({ appId: task.appId, kind: WORKFLOW_AI_DEPENDENCY_KIND, key: task.id });
229
+ notifyWorkflowWorker(task.appId);
228
230
  };
229
231
 
230
232
  export const markWorkflowAiTaskCanceledIfRequested = async (id: string, db: SQL = sql): Promise<WorkflowAiTask | null> => {
@@ -139,27 +139,27 @@ export const runWorkflowAiAction = async (
139
139
  }
140
140
  };
141
141
 
142
- export const planWorkflowAiAction = async (
142
+ export const workflowAiActionCost = async (
143
143
  ctx: Pick<WorkflowActionContext, "effectKey">,
144
- summary: string,
145
144
  injected?: Pick<WorkflowAiActionDependencies, "exists">,
146
- ): Promise<WorkflowPlannedEffect> => {
145
+ ): Promise<Record<string, number>> => {
147
146
  const deps = injected ?? (await dependencies());
148
- const exists = await deps.exists(ctx.effectKey);
149
- return {
150
- summary,
151
- ...(exists ? {} : { consumes: { maxAiCalls: 1 } }),
152
- issues: ["AI output is not available during a dry run."],
153
- };
147
+ return (await deps.exists(ctx.effectKey)) ? {} : { maxAiCalls: 1 };
154
148
  };
155
149
 
150
+ const planWorkflowAiAction = async (summary: string): Promise<WorkflowPlannedEffect> => ({
151
+ summary,
152
+ issues: ["AI output is not available during a dry run."],
153
+ });
154
+
156
155
  export const AI_WORKFLOW_ACTIONS = {
157
156
  aiExtractData: workflowAction.idempotent<typeof extractDataConfig, WorkflowJsonValue>({
158
157
  label: "AI extract structured data",
159
158
  description: "Extracts one bounded, validated object from supplied input.",
160
159
  outputType: "core.value",
161
160
  config: extractDataConfig,
162
- plan: (ctx) => planWorkflowAiAction(ctx, "Extract structured data with AI."),
161
+ cost: (ctx) => workflowAiActionCost(ctx),
162
+ plan: () => planWorkflowAiAction("Extract structured data with AI."),
163
163
  run: (ctx, values) =>
164
164
  runWorkflowAiAction(ctx, {
165
165
  kind: "extract_data",
@@ -190,7 +190,8 @@ export const AI_WORKFLOW_ACTIONS = {
190
190
  saveAs: identifier("Variable name for the generated text."),
191
191
  },
192
192
  },
193
- plan: (ctx) => planWorkflowAiAction(ctx, "Generate text with AI."),
193
+ cost: (ctx) => workflowAiActionCost(ctx),
194
+ plan: () => planWorkflowAiAction("Generate text with AI."),
194
195
  run: (ctx, values) =>
195
196
  runWorkflowAiAction(ctx, {
196
197
  kind: "generate_text",
@@ -214,7 +215,8 @@ export const AI_WORKFLOW_ACTIONS = {
214
215
  saveAs: identifier("Variable name for the selected choice."),
215
216
  },
216
217
  },
217
- plan: (ctx) => planWorkflowAiAction(ctx, "Classify one value with AI."),
218
+ cost: (ctx) => workflowAiActionCost(ctx),
219
+ plan: () => planWorkflowAiAction("Classify one value with AI."),
218
220
  run: (ctx, values) =>
219
221
  runWorkflowAiAction(ctx, {
220
222
  kind: "classify",
@@ -254,7 +256,8 @@ export const AI_WORKFLOW_ACTIONS = {
254
256
  saveAs: identifier("Variable name for the selected choices."),
255
257
  },
256
258
  },
257
- plan: (ctx) => planWorkflowAiAction(ctx, "Classify multiple values with AI."),
259
+ cost: (ctx) => workflowAiActionCost(ctx),
260
+ plan: () => planWorkflowAiAction("Classify multiple values with AI."),
258
261
  run: (ctx, values) =>
259
262
  runWorkflowAiAction(ctx, {
260
263
  kind: "classify_many",
@@ -136,8 +136,6 @@ export type WorkflowActionResult<Output> =
136
136
  export type WorkflowPlannedEffect = {
137
137
  /** Human-readable summary shown in the dry-run view. */
138
138
  summary: string;
139
- /** Counts consumed from the effect budget, keyed by budget dimension. */
140
- consumes?: Record<string, number>;
141
139
  /**
142
140
  * What the step would produce.
143
141
  *
@@ -202,8 +200,10 @@ export type WorkflowActionContext = {
202
200
  tx?: SQL;
203
201
  /** Stable across replays of the same step — the key an idempotent effect uses. */
204
202
  effectKey: string;
205
- /** Keeps a long-running action's lease alive. */
206
- heartbeat(): Promise<void>;
203
+ /** Keeps a long-running action's lease alive. Pass the application's current
204
+ * transaction to fence its writes against cancellation and worker takeover
205
+ * until commit. Do not call an ambient heartbeat while holding that fence. */
206
+ heartbeat(tx?: SQL): Promise<void>;
207
207
  };
208
208
 
209
209
  // ─── Action definition ───────────────────────────────────────────────────────
@@ -218,6 +218,8 @@ type RunHook<Config, Output> = (ctx: WorkflowActionContext, config: Config) => P
218
218
  */
219
219
  type AuthorizeHook<Config> = (ctx: WorkflowActionContext, config: Config) => Promise<boolean>;
220
220
  type PlanHook<Config> = (ctx: WorkflowActionContext, config: Config) => Promise<WorkflowPlannedEffect>;
221
+ /** Cheap, side-effect-free budget counts; shared by execution and dry runs. */
222
+ type CostHook<Config> = (ctx: WorkflowActionContext, config: Config) => Record<string, number> | Promise<Record<string, number>>;
221
223
 
222
224
  /**
223
225
  * `NoInfer` because `run` alone defines what an action produces. Reconcile
@@ -238,6 +240,7 @@ export type WorkflowActionDefinition<Effect extends WorkflowEffectClass, Schema
238
240
  run: RunHook<FromFieldSchema<Schema>, Output>;
239
241
  /** What a dry run reports. Required for everything that leaves the process. */
240
242
  plan?: PlanHook<FromFieldSchema<Schema>>;
243
+ cost?: CostHook<FromFieldSchema<Schema>>;
241
244
  /** Asks afterwards whether an ambiguous effect landed. */
242
245
  reconcile?: ReconcileHook<Output>;
243
246
  /** Re-checks permission at the moment of the effect, on the effect's own handle. */
@@ -250,6 +253,7 @@ type ActionBase<Schema extends ObjectSchema> = {
250
253
  description: string;
251
254
  config: Schema;
252
255
  outputType?: string;
256
+ cost?: CostHook<FromFieldSchema<Schema>>;
253
257
  };
254
258
 
255
259
  /**
@@ -270,7 +274,7 @@ type ActionBase<Schema extends ObjectSchema> = {
270
274
  export const workflowAction = {
271
275
  /** Deterministic given inputs and prior outcomes. Reading mutable state is not pure. */
272
276
  pure: <const Schema extends ObjectSchema, Output = void>(
273
- definition: ActionBase<Schema> & {
277
+ definition: Omit<ActionBase<Schema>, "cost"> & {
274
278
  run: RunHook<FromFieldSchema<Schema>, Output>;
275
279
  },
276
280
  ): WorkflowActionDefinition<"pure", Schema, Output> => ({ ...definition, effect: "pure" }),
@@ -327,6 +331,7 @@ export type ErasedWorkflowAction = {
327
331
  outputType?: string;
328
332
  run: RunHook<never, unknown>;
329
333
  plan?: PlanHook<never>;
334
+ cost?: CostHook<never>;
330
335
  reconcile?: ReconcileHook<unknown>;
331
336
  authorize?: AuthorizeHook<never>;
332
337
  };
@@ -7,13 +7,13 @@
7
7
  * started, settle it afterwards. Both apps did, slightly differently, which is
8
8
  * how one of them ended up with no budget at all.
9
9
  *
10
- * The budget is charged from the action's own `plan` hook — the same hook a dry
10
+ * The budget is charged from the action's own `cost` hook — the same hook a dry
11
11
  * run uses. That is the point: preflight and execution cannot disagree about
12
12
  * what an action costs, because one function answers for both. Mail's known
13
13
  * defect was exactly that divergence, and here it is not a bug to fix but a
14
14
  * shape that cannot occur.
15
15
  */
16
- import type { SQL } from "bun";
16
+ import { SQL } from "bun";
17
17
  import { type WorkflowDependency, type WorkflowJsonValue, type WorkflowStepOutcome, workflowPathKey } from "../contracts";
18
18
  import type { ErasedWorkflowAction, WorkflowActionContext } from "../definition";
19
19
  import type { DefinedWorkflowModule } from "../module";
@@ -25,7 +25,16 @@ import type {
25
25
  WorkflowExecuteActionPort,
26
26
  } from "../runtime/ports";
27
27
  import { budgetError, budgetRootRunId, chargeWorkflowEffectBudget } from "./budget";
28
- import { beginWorkflowEffect, readWorkflowEffect, recordWorkflowEffect, settleWorkflowEffect } from "./runs";
28
+ import {
29
+ beginWorkflowEffect,
30
+ markWorkflowStepBudgetCharged,
31
+ readWorkflowEffect,
32
+ recordWorkflowEffect,
33
+ renewWorkflowRunLease,
34
+ settleWorkflowEffect,
35
+ WorkflowLeaseLostError,
36
+ workflowStepBudgetCharged,
37
+ } from "./runs";
29
38
  import { withTransaction } from "./transaction";
30
39
 
31
40
  /**
@@ -99,8 +108,14 @@ const actionContext = (
99
108
  resolveReference: (reference: string, ...path: Array<string | number>): Promise<WorkflowJsonValue | undefined> =>
100
109
  ctx.resolveReference(reference, configPath(path)),
101
110
  variableSnapshot: (): Record<string, WorkflowJsonValue> => ctx.variables.snapshot?.() ?? {},
102
- heartbeat: async (): Promise<void> => {
111
+ heartbeat: async (transaction?: SQL): Promise<void> => {
112
+ if (!transaction) return ctx.heartbeat();
113
+ // The generation check and UPDATE lock live on the caller's transaction,
114
+ // so takeover/cancellation cannot interleave with its domain writes.
115
+ const lease = await renewWorkflowRunLease(ctx.run, { db: transaction });
116
+ if (lease.state === "active") return;
103
117
  await ctx.heartbeat();
118
+ throw new WorkflowLeaseLostError();
104
119
  },
105
120
  };
106
121
  };
@@ -170,7 +185,7 @@ export type WorkflowActionPortOptions = {
170
185
  /**
171
186
  * Runs one declared action, with everything its effect class implies.
172
187
  *
173
- * The order is the contract: plan, then charge, then mark, then act, then
188
+ * The order is the contract: estimate costs, charge, mark, act, then
174
189
  * settle. Charging before acting is the only ordering that cannot overspend,
175
190
  * and marking before acting is the only one that leaves evidence when the
176
191
  * process dies mid-effect.
@@ -186,20 +201,22 @@ const runDeclaredAction = async (
186
201
  const effectKey = workflowEffectKey(ctx.run.runId, ctx.step.key);
187
202
  const journal = options.journal ?? kernelEffectJournal(options.db);
188
203
 
189
- /**
190
- * Asks the action what it would do, and charges the run for it.
191
- *
192
- * Only reached when there is a budget to charge: the answer is otherwise
193
- * discarded, and `plan` is allowed to be expensive — an HTTP action resolves
194
- * its target to check it is safe to call.
195
- */
204
+ /** Charge declared costs without building a dry-run preview. */
196
205
  const charge = async (db?: SQL): Promise<Extract<WorkflowStepOutcome, { state: "failed" }> | null> => {
197
- if (options.budget === false || !action.plan) return null;
198
- const planned = await action.plan(actionContext(ctx, step, effectKey), config as never);
199
- if (!planned.consumes || Object.keys(planned.consumes).length === 0) return null;
206
+ if (options.budget === false || !action.cost) return null;
207
+ const consumes = await action.cost(actionContext(ctx, step, effectKey, db), config as never);
208
+ if (Object.keys(consumes).length === 0) return null;
200
209
  const handle = db ?? options.db;
201
- const root = await budgetRootRunId(ctx.run.runId, { ...(handle ? { db: handle } : {}) });
202
- const charged = await chargeWorkflowEffectBudget(root, planned.consumes, { ...(handle ? { db: handle } : {}) });
210
+ // Once per step, not once per attempt: a step that parked on a dependency
211
+ // runs again to observe the effect it already paid for, and charging that
212
+ // resume refuses the automation it already performed.
213
+ const charged = await withTransaction(handle, async (tx) => {
214
+ if (await workflowStepBudgetCharged(tx, journalStep)) return { state: "ok" as const, used: {} };
215
+ const root = await budgetRootRunId(ctx.run.runId, { db: tx });
216
+ const outcome = await chargeWorkflowEffectBudget(root, consumes, { db: tx });
217
+ if (outcome.state === "ok") await markWorkflowStepBudgetCharged(tx, journalStep);
218
+ return outcome;
219
+ });
203
220
  if (charged.state !== "exceeded") return null;
204
221
  const error = budgetError(charged);
205
222
  return {
@@ -282,6 +299,14 @@ const runDeclaredAction = async (
282
299
  return { state: "waiting", dependency: error.dependency } satisfies WorkflowStepOutcome;
283
300
  if (error instanceof WorkflowTransactionalFailure)
284
301
  return { state: "failed", error: reportedError(error.failure) } satisfies WorkflowStepOutcome;
302
+ // These PostgreSQL errors guarantee that this transaction did not commit.
303
+ // Classify only after rollback; connection failures have an ambiguous
304
+ // commit outcome and must not enter this retry path.
305
+ if (error instanceof SQL.PostgresError && (error.errno === "40001" || error.errno === "40P01"))
306
+ return {
307
+ state: "failed",
308
+ error: asError("Transaction conflicted; retrying the step.", true, "WORKFLOW_TRANSACTION_CONFLICT"),
309
+ } satisfies WorkflowStepOutcome;
285
310
  throw error;
286
311
  });
287
312
  }
@@ -405,13 +430,12 @@ export const createWorkflowDryRunPort = (module: DefinedWorkflowModule): Workflo
405
430
  if (!action.plan) return { state: "unsupported" as const, reason: `${name} cannot be planned` };
406
431
 
407
432
  const planned = await action.plan(context, config as never);
433
+ const consumes = await action.cost?.(context, config as never);
408
434
  applySaveAs(ctx, step, (planned.output ?? undefined) as WorkflowJsonValue | undefined);
409
435
  return {
410
436
  state: "planned" as const,
411
437
  output: (planned.output ?? null) as WorkflowJsonValue,
412
- effects: [
413
- { action: name, summary: planned.summary, ...(planned.consumes ? { consumes: planned.consumes } : {}) } as WorkflowJsonValue,
414
- ],
438
+ effects: [{ action: name, summary: planned.summary, ...(consumes ? { consumes } : {}) } as WorkflowJsonValue],
415
439
  // An issue names the step it belongs to, so the dry-run view can point
416
440
  // at what could not be determined rather than listing loose strings.
417
441
  ...(planned.issues?.length
@@ -105,3 +105,5 @@ export {
105
105
  type WorkflowWorkerOptions,
106
106
  type WorkflowWorkerOutcome,
107
107
  } from "./worker";
108
+
109
+ export { createWorkflowWorker, notifyWorkflowWorker } from "./worker-runtime";
@@ -831,6 +831,42 @@ export const recordWorkflowEffect = async (
831
831
  if (rows.length === 0) throw new WorkflowLeaseLostError();
832
832
  };
833
833
 
834
+ /**
835
+ * Whether this step's effect budget was already charged.
836
+ *
837
+ * A step is executed more than once by design: it parks on a dependency and
838
+ * runs again when that dependency fires, and the second attempt adopts the
839
+ * effect the first one performed instead of performing it again. Charging per
840
+ * attempt therefore refused exactly the automations whose allowance is sized
841
+ * per effect — `maxMoves: 1` was spent by the move, and the resume that only
842
+ * observed its result was rejected as overspending.
843
+ *
844
+ * A run whose journal lives outside this table — an app adopting declared
845
+ * actions before its runs do — has no row here and keeps the per-attempt
846
+ * behaviour rather than silently going uncharged.
847
+ */
848
+ export const workflowStepBudgetCharged = async (db: SQL, step: { runId: string; key: string }): Promise<boolean> => {
849
+ const [row] = await db<{ budget_charged_at: Date | null }[]>`
850
+ SELECT budget_charged_at FROM workflows.step_outcome
851
+ WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
852
+ `;
853
+ return Boolean(row?.budget_charged_at);
854
+ };
855
+
856
+ /**
857
+ * Records that this step paid for its effect.
858
+ *
859
+ * Written on the handle that charged, so a charge that unwinds — a
860
+ * transactional action that ends up waiting — takes this with it.
861
+ */
862
+ export const markWorkflowStepBudgetCharged = async (db: SQL, step: { runId: string; key: string }): Promise<void> => {
863
+ await db`
864
+ UPDATE workflows.step_outcome
865
+ SET budget_charged_at = now(), updated_at = now()
866
+ WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key} AND budget_charged_at IS NULL
867
+ `;
868
+ };
869
+
834
870
  /** Settles an effect once its fate is known. `ambiguous` is a real answer, not a failure. */
835
871
  export const settleWorkflowEffect = async (
836
872
  step: { runId: string; key: string; executionGeneration: number },
@@ -0,0 +1,55 @@
1
+ /** Bounded claim loops. A free slot never waits for another slot's action. */
2
+ export const createWorkflowWorkerPool = (options: {
3
+ concurrency: number;
4
+ /** True when a run was handled; false when no run was claimable. */
5
+ run(): Promise<boolean>;
6
+ onError(error: unknown): void;
7
+ }) => {
8
+ if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
9
+ throw new RangeError("Workflow concurrency must be a positive integer");
10
+ }
11
+ const tasks = new Set<Promise<void>>();
12
+ let accepting = false;
13
+ let generation = 0;
14
+
15
+ const wake = (): void => {
16
+ if (!accepting) return;
17
+ generation += 1;
18
+ while (tasks.size < options.concurrency) {
19
+ let observed = generation;
20
+ let failed = false;
21
+ const task = Promise.resolve().then(async () => {
22
+ try {
23
+ while (accepting) {
24
+ observed = generation;
25
+ const handled = await options.run();
26
+ // A wake received during an empty claim needs another attempt.
27
+ if (!handled && observed === generation) return;
28
+ }
29
+ } catch (error) {
30
+ failed = true;
31
+ options.onError(error);
32
+ // Recovery wakes us again; never spin on a failing database.
33
+ }
34
+ });
35
+ tasks.add(task);
36
+ const finished = () => {
37
+ tasks.delete(task);
38
+ if (!failed && observed !== generation) wake();
39
+ };
40
+ void task.then(finished, finished);
41
+ }
42
+ };
43
+
44
+ return {
45
+ start: (): void => {
46
+ accepting = true;
47
+ wake();
48
+ },
49
+ wake,
50
+ stop: async (): Promise<void> => {
51
+ accepting = false;
52
+ await Promise.allSettled([...tasks]);
53
+ },
54
+ };
55
+ };
@@ -0,0 +1,90 @@
1
+ import { lazySync } from "../../_internal/process-sync";
2
+ import { logger } from "../../services/logging";
3
+ import { createRuntimeLifecycle, superviseRuntimeTask } from "../../services/runtime-lifecycle";
4
+ import { createWorkflowWorkerPool } from "./worker-pool";
5
+
6
+ const log = logger("workflows:worker");
7
+ const wakeTopic = lazySync((sync) =>
8
+ sync.topic<null>({
9
+ id: "cloud:workflow-wake",
10
+ owner: "cloud",
11
+ retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
12
+ maxPayloadBytes: 2000,
13
+ }),
14
+ );
15
+ const localWorkers = new Map<string, Set<() => void>>();
16
+
17
+ /** Call after the application's transaction commits. Postgres remains the work queue. */
18
+ export const notifyWorkflowWorker = (appId: string): void => {
19
+ for (const wake of localWorkers.get(appId) ?? []) wake();
20
+ // Hints are best effort. Neither broker latency nor failure delays an accepted request.
21
+ void Promise.resolve()
22
+ .then(() => wakeTopic().publish({ tenantId: appId, data: null }))
23
+ .catch((error) => {
24
+ log.warn("Workflow wake notification failed", { appId, error: String(error) });
25
+ });
26
+ };
27
+
28
+ /** Event-driven claims plus a recovery scan for lost hints, deadlines and expired leases. */
29
+ export const createWorkflowWorker = (options: {
30
+ appId: string;
31
+ concurrency: number;
32
+ run(): Promise<boolean>;
33
+ recover(): Promise<unknown>;
34
+ }) => {
35
+ const report = (error: unknown) => log.warn("Workflow worker failed", { appId: options.appId, error: String(error) });
36
+ const pool = createWorkflowWorkerPool({ ...options, onError: report });
37
+ let controller: AbortController | undefined;
38
+ let reader: Promise<void> | undefined;
39
+ let timer: ReturnType<typeof setInterval> | undefined;
40
+ let recovery: Promise<void> | undefined;
41
+ let requested = 0;
42
+ const wake = () => {
43
+ if (!controller || controller.signal.aborted) return;
44
+ pool.wake();
45
+ requested += 1;
46
+ recovery ??= Promise.resolve().then(async () => {
47
+ try {
48
+ let observed: number;
49
+ do {
50
+ observed = requested;
51
+ await options.recover();
52
+ pool.wake();
53
+ } while (!controller?.signal.aborted && observed !== requested);
54
+ } catch (error) {
55
+ report(error);
56
+ } finally {
57
+ recovery = undefined;
58
+ }
59
+ });
60
+ };
61
+ return createRuntimeLifecycle({
62
+ start: async () => {
63
+ controller = new AbortController();
64
+ const workers = localWorkers.get(options.appId) ?? new Set();
65
+ workers.add(wake);
66
+ localWorkers.set(options.appId, workers);
67
+ pool.start();
68
+ reader = superviseRuntimeTask({
69
+ name: "Workflow wake reader",
70
+ signal: controller.signal,
71
+ onError: ({ error }) => report(error),
72
+ run: async (signal) => {
73
+ for await (const _event of wakeTopic().live({ tenantId: options.appId, signal })) wake();
74
+ },
75
+ });
76
+ // Keep the existing one-second recovery bound; execution does not wait for it.
77
+ timer = setInterval(wake, 1_000);
78
+ wake();
79
+ },
80
+ stop: async () => {
81
+ clearInterval(timer);
82
+ controller?.abort();
83
+ const workers = localWorkers.get(options.appId);
84
+ workers?.delete(wake);
85
+ if (workers?.size === 0) localWorkers.delete(options.appId);
86
+ await Promise.all([pool.stop(), recovery, reader]);
87
+ controller = undefined;
88
+ },
89
+ });
90
+ };
@@ -1,49 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { renderAppFavicon, tablerIconName, writeAppFavicon } from "./app-favicon";
6
-
7
- describe("app favicon", () => {
8
- test("extracts the documented Tabler icon class", () => {
9
- expect(tablerIconName("ti ti-mail")).toBe("mail");
10
- expect(() => tablerIconName("mail")).toThrow('such as "ti ti-mail"');
11
- expect(() => tablerIconName("ti ti-mail extra")).toThrow('such as "ti ti-mail"');
12
- });
13
-
14
- test("renders a large icon with the Cloud gradient", () => {
15
- const source = '<svg viewBox="0 0 24 24"><path d="M3 7h18" /></svg>';
16
-
17
- const svg = renderAppFavicon(source);
18
- expect(svg).toContain('<linearGradient id="cloud-icon"');
19
- expect(svg).toContain(".start { stop-color: #3b82f6; }");
20
- expect(svg).toContain(".end { stop-color: #1d4ed8; }");
21
- expect(svg).toContain("@media (prefers-color-scheme: dark)");
22
- expect(svg).toContain(".start { stop-color: #f0f6ff; }");
23
- expect(svg).toContain(".end { stop-color: #1f8bff; }");
24
- expect(svg).toContain('stroke="url(#cloud-icon)"');
25
- expect(svg).toContain('stroke-width="2"');
26
- expect(svg).not.toContain("<rect");
27
- expect(svg).not.toContain("<filter");
28
- expect(svg).toContain('<path d="M3 7h18" />');
29
- });
30
-
31
- test("writes a declared Tabler icon and rejects an unknown one", async () => {
32
- const publicDir = await mkdtemp(join(tmpdir(), "cloud-app-favicon-"));
33
- try {
34
- const target = await writeAppFavicon({ publicDir, appId: "mail", icon: "ti ti-mail" });
35
- const svg = await readFile(target, "utf8");
36
- expect(svg).toContain('stroke="url(#cloud-icon)"');
37
- expect(svg).toContain('d="M3 7l9 6l9 -6"');
38
-
39
- const tableTarget = await writeAppFavicon({ publicDir, appId: "grids", icon: "ti ti-table" });
40
- expect(await readFile(tableTarget, "utf8")).toContain('d="M3 10h18"');
41
-
42
- await expect(writeAppFavicon({ publicDir, appId: "missing", icon: "ti ti-definitely-not-real" })).rejects.toThrow(
43
- 'Unknown Tabler app icon "ti ti-definitely-not-real"',
44
- );
45
- } finally {
46
- await rm(publicDir, { recursive: true, force: true });
47
- }
48
- });
49
- });