@k2b/cloud 0.6.0-rc.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 (540) hide show
  1. package/README.md +98 -0
  2. package/package.json +128 -0
  3. package/public/logo.svg +1 -0
  4. package/scripts/app-favicon.test.ts +49 -0
  5. package/scripts/app-favicon.ts +51 -0
  6. package/scripts/build.ts +205 -0
  7. package/scripts/preload.ts +164 -0
  8. package/scripts/sync-recovery-smoke.ts +200 -0
  9. package/src/_internal/app-favicon.ts +2 -0
  10. package/src/_internal/app-presentation.ts +61 -0
  11. package/src/_internal/app-workspace-state.ts +24 -0
  12. package/src/_internal/bounded-json.ts +47 -0
  13. package/src/_internal/build-metadata.ts +9 -0
  14. package/src/_internal/capabilities.ts +1118 -0
  15. package/src/_internal/define-app.ts +801 -0
  16. package/src/_internal/heartbeat.ts +167 -0
  17. package/src/_internal/help-catalog.ts +241 -0
  18. package/src/_internal/help.ts +95 -0
  19. package/src/_internal/nats-connection.ts +34 -0
  20. package/src/_internal/page-responses.ts +40 -0
  21. package/src/_internal/process-sync.ts +117 -0
  22. package/src/_internal/readiness.ts +3 -0
  23. package/src/_internal/registry-validation.ts +136 -0
  24. package/src/_internal/registry-watch.ts +52 -0
  25. package/src/_internal/registry.ts +338 -0
  26. package/src/_internal/runtime-compatibility.ts +64 -0
  27. package/src/_internal/runtime-context.ts +84 -0
  28. package/src/_internal/runtime-watcher.ts +78 -0
  29. package/src/_internal/static-assets.ts +86 -0
  30. package/src/_internal/status-preserving-ssr.ts +49 -0
  31. package/src/access/PermissionEditor.tsx +486 -0
  32. package/src/access/ResourceApiKeys.tsx +271 -0
  33. package/src/access/messages.ts +121 -0
  34. package/src/access/ui.ts +16 -0
  35. package/src/account/Avatar.tsx +28 -0
  36. package/src/account/EntitySearch.tsx +363 -0
  37. package/src/account/avatar-upload-dialog.tsx +197 -0
  38. package/src/account/avatar-upload.ts +149 -0
  39. package/src/account/ui.ts +3 -0
  40. package/src/ai/admin.ts +47 -0
  41. package/src/ai/approval-routes.ts +130 -0
  42. package/src/ai/approvals.ts +139 -0
  43. package/src/ai/assistant-models.ts +61 -0
  44. package/src/ai/attachments.ts +40 -0
  45. package/src/ai/browser.ts +92 -0
  46. package/src/ai/capabilities.ts +714 -0
  47. package/src/ai/capability-execution.ts +151 -0
  48. package/src/ai/chat/blocks.render.test.tsx +1108 -0
  49. package/src/ai/chat/blocks.tsx +730 -0
  50. package/src/ai/chat/builtin-tools.tsx +428 -0
  51. package/src/ai/chat/capability-table.tsx +87 -0
  52. package/src/ai/chat/composer-adapter.ts +169 -0
  53. package/src/ai/chat/file-tools.tsx +81 -0
  54. package/src/ai/chat/message-actions.tsx +370 -0
  55. package/src/ai/chat/message-utils.ts +479 -0
  56. package/src/ai/chat/messages.ts +43 -0
  57. package/src/ai/chat/presentation.tsx +395 -0
  58. package/src/ai/chat/primitives.tsx +3 -0
  59. package/src/ai/chat/tool-disclosure.tsx +41 -0
  60. package/src/ai/chat/turn-navigator-utils.ts +28 -0
  61. package/src/ai/chat/turn-navigator.tsx +164 -0
  62. package/src/ai/chat/user-message.tsx +149 -0
  63. package/src/ai/chat/visual-tools.tsx +435 -0
  64. package/src/ai/chat/web-tools.tsx +202 -0
  65. package/src/ai/chat-task-contracts.ts +113 -0
  66. package/src/ai/chat-tasks.ts +952 -0
  67. package/src/ai/client/controller.ts +1211 -0
  68. package/src/ai/client/file-source.ts +95 -0
  69. package/src/ai/client/live-connection.ts +140 -0
  70. package/src/ai/client/projection.ts +178 -0
  71. package/src/ai/client/transport.ts +111 -0
  72. package/src/ai/compaction.ts +182 -0
  73. package/src/ai/credentials.ts +124 -0
  74. package/src/ai/default-tool-contracts.ts +108 -0
  75. package/src/ai/default-tools.ts +127 -0
  76. package/src/ai/enrich.ts +280 -0
  77. package/src/ai/executor.ts +1624 -0
  78. package/src/ai/fetch-file-tool.ts +313 -0
  79. package/src/ai/file-context.ts +92 -0
  80. package/src/ai/file-media-type.ts +28 -0
  81. package/src/ai/file-mount.ts +16 -0
  82. package/src/ai/file-tools.ts +566 -0
  83. package/src/ai/files-store.ts +390 -0
  84. package/src/ai/firecrawl-tools.ts +305 -0
  85. package/src/ai/grids-skill.ts +126 -0
  86. package/src/ai/http.ts +265 -0
  87. package/src/ai/index.ts +337 -0
  88. package/src/ai/kit-skill.ts +22 -0
  89. package/src/ai/limits.ts +5 -0
  90. package/src/ai/live-events.ts +289 -0
  91. package/src/ai/live-messages.ts +45 -0
  92. package/src/ai/live-outbox.ts +102 -0
  93. package/src/ai/live-routes.ts +412 -0
  94. package/src/ai/live.ts +2 -0
  95. package/src/ai/maintenance.ts +302 -0
  96. package/src/ai/markdown-pdf-tool.ts +95 -0
  97. package/src/ai/memories.ts +605 -0
  98. package/src/ai/memory-learning-runs.ts +222 -0
  99. package/src/ai/memory-learning.ts +619 -0
  100. package/src/ai/memory-tool.ts +120 -0
  101. package/src/ai/memory-workflow-evidence.ts +158 -0
  102. package/src/ai/migrate.ts +2082 -0
  103. package/src/ai/model-access-migrate.ts +30 -0
  104. package/src/ai/model-access.ts +191 -0
  105. package/src/ai/personal-agent.ts +6 -0
  106. package/src/ai/prefs.ts +72 -0
  107. package/src/ai/project-tool.ts +94 -0
  108. package/src/ai/projects-routes.ts +277 -0
  109. package/src/ai/projects.ts +902 -0
  110. package/src/ai/protocol.ts +279 -0
  111. package/src/ai/provider.ts +73 -0
  112. package/src/ai/public-projection.ts +25 -0
  113. package/src/ai/resource-markers.ts +31 -0
  114. package/src/ai/resource-refs.ts +70 -0
  115. package/src/ai/routes.ts +1131 -0
  116. package/src/ai/runtime-tools.ts +39 -0
  117. package/src/ai/runtime.ts +544 -0
  118. package/src/ai/settings.ts +529 -0
  119. package/src/ai/short-id.ts +45 -0
  120. package/src/ai/skill-catalog.ts +73 -0
  121. package/src/ai/skill-format.ts +145 -0
  122. package/src/ai/skill-seeds.ts +430 -0
  123. package/src/ai/skill-tool.ts +81 -0
  124. package/src/ai/skills-routes.ts +143 -0
  125. package/src/ai/skills.ts +934 -0
  126. package/src/ai/solid.ts +27 -0
  127. package/src/ai/store.ts +3113 -0
  128. package/src/ai/stream.ts +308 -0
  129. package/src/ai/structured-runs.ts +66 -0
  130. package/src/ai/structured.ts +186 -0
  131. package/src/ai/system-prompt.ts +170 -0
  132. package/src/ai/task-prompt.ts +22 -0
  133. package/src/ai/timeline.ts +148 -0
  134. package/src/ai/tool-audit.ts +177 -0
  135. package/src/ai/tool-result-budget.ts +16 -0
  136. package/src/ai/tools.ts +155 -0
  137. package/src/ai/types.ts +967 -0
  138. package/src/ai/ui.tsx +39 -0
  139. package/src/ai/usage-migrate.ts +81 -0
  140. package/src/ai/usage.ts +315 -0
  141. package/src/ai/validate.ts +54 -0
  142. package/src/ai/vision-tool.ts +79 -0
  143. package/src/api/accounts-entities.ts +174 -0
  144. package/src/api/admin-ai-projects.ts +74 -0
  145. package/src/api/admin-ai-skills.ts +76 -0
  146. package/src/api/admin-ai-usage.ts +42 -0
  147. package/src/api/admin-core-settings.ts +508 -0
  148. package/src/api/admin-identity.ts +148 -0
  149. package/src/api/admin-lifecycle.ts +206 -0
  150. package/src/api/admin-linux-identities.ts +66 -0
  151. package/src/api/announcements.ts +131 -0
  152. package/src/api/app-approval.ts +129 -0
  153. package/src/api/apps.ts +51 -0
  154. package/src/api/auth/schemas.ts +54 -0
  155. package/src/api/auth.ts +370 -0
  156. package/src/api/capabilities.ts +658 -0
  157. package/src/api/help-messages.ts +19 -0
  158. package/src/api/help.ts +78 -0
  159. package/src/api/index.ts +108 -0
  160. package/src/api/mcp.ts +744 -0
  161. package/src/api/me-rail.ts +45 -0
  162. package/src/api/me.ts +753 -0
  163. package/src/api/search/schemas.ts +79 -0
  164. package/src/api/search.bench.ts +185 -0
  165. package/src/api/search.ts +407 -0
  166. package/src/api/sync-ops.ts +79 -0
  167. package/src/api/widgets.ts +143 -0
  168. package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
  169. package/src/browser/CloudResourceSearch.tsx +791 -0
  170. package/src/browser/app-approval-vault.ts +305 -0
  171. package/src/browser/app-approval.ts +234 -0
  172. package/src/browser/live-websocket.ts +268 -0
  173. package/src/browser/locale-preference.ts +25 -0
  174. package/src/browser/notification-target.ts +27 -0
  175. package/src/browser/notification-timeout.ts +14 -0
  176. package/src/browser/notifications.ts +181 -0
  177. package/src/browser/resource-clipboard.ts +70 -0
  178. package/src/browser/resource-picker.tsx +39 -0
  179. package/src/browser/resource-search-messages.ts +55 -0
  180. package/src/browser/resource-search.ts +27 -0
  181. package/src/capabilities/client.ts +149 -0
  182. package/src/capabilities/response.ts +39 -0
  183. package/src/capabilities/server.ts +215 -0
  184. package/src/capabilities/signals.ts +4 -0
  185. package/src/capabilities/testing.ts +12 -0
  186. package/src/capabilities/types.ts +32 -0
  187. package/src/cli/access.ts +499 -0
  188. package/src/cli/account.ts +382 -0
  189. package/src/cli/admin/account-administration.ts +45 -0
  190. package/src/cli/admin/account-categories.ts +49 -0
  191. package/src/cli/admin/ai-usage.ts +118 -0
  192. package/src/cli/admin/app-sign-in.ts +49 -0
  193. package/src/cli/admin/data.ts +343 -0
  194. package/src/cli/admin/documentation.ts +34 -0
  195. package/src/cli/admin/gateway.ts +155 -0
  196. package/src/cli/admin/index.ts +61 -0
  197. package/src/cli/admin/instance.ts +250 -0
  198. package/src/cli/admin/jobs.ts +223 -0
  199. package/src/cli/admin/legal.ts +122 -0
  200. package/src/cli/admin/linux.ts +53 -0
  201. package/src/cli/admin/logs.ts +324 -0
  202. package/src/cli/admin/metrics.ts +142 -0
  203. package/src/cli/admin/nats.ts +167 -0
  204. package/src/cli/admin/notifications.ts +516 -0
  205. package/src/cli/admin/shared.ts +113 -0
  206. package/src/cli/admin/sync.ts +188 -0
  207. package/src/cli/admin/telemetry.ts +314 -0
  208. package/src/cli/admin/webhooks.ts +222 -0
  209. package/src/cli/admin/workflows.ts +367 -0
  210. package/src/cli/apps.ts +40 -0
  211. package/src/cli/capabilities.ts +239 -0
  212. package/src/cli/commands.ts +604 -0
  213. package/src/cli/index.ts +61 -0
  214. package/src/cli/locale.ts +31 -0
  215. package/src/cli/output.ts +58 -0
  216. package/src/clients/core.ts +20 -0
  217. package/src/config/env.ts +54 -0
  218. package/src/config/index.ts +6 -0
  219. package/src/contracts/account-categories.ts +62 -0
  220. package/src/contracts/announcements.ts +121 -0
  221. package/src/contracts/app-approval.ts +197 -0
  222. package/src/contracts/app.ts +162 -0
  223. package/src/contracts/capabilities.ts +484 -0
  224. package/src/contracts/index.ts +14 -0
  225. package/src/contracts/notification-live.ts +66 -0
  226. package/src/contracts/notification-types.ts +235 -0
  227. package/src/contracts/posix.ts +69 -0
  228. package/src/contracts/profile.ts +85 -0
  229. package/src/contracts/rail-preferences.ts +53 -0
  230. package/src/contracts/registry.ts +110 -0
  231. package/src/contracts/resource-clipboard.ts +33 -0
  232. package/src/contracts/settings-types.ts +98 -0
  233. package/src/contracts/shared.ts +408 -0
  234. package/src/contracts/user-notifications.ts +101 -0
  235. package/src/contracts/widgets.ts +183 -0
  236. package/src/desktop/index.ts +704 -0
  237. package/src/desktop/solid.tsx +938 -0
  238. package/src/index.ts +58 -0
  239. package/src/server/actor.ts +41 -0
  240. package/src/server/api/index.ts +2 -0
  241. package/src/server/api/respond.ts +96 -0
  242. package/src/server/api-client.ts +54 -0
  243. package/src/server/app-context.ts +39 -0
  244. package/src/server/help.ts +187 -0
  245. package/src/server/index.ts +89 -0
  246. package/src/server/locale.ts +55 -0
  247. package/src/server/middleware/auth.ts +386 -0
  248. package/src/server/middleware/index.ts +28 -0
  249. package/src/server/middleware/invocation.ts +55 -0
  250. package/src/server/middleware/middleware.ts +65 -0
  251. package/src/server/middleware/openapi.ts +126 -0
  252. package/src/server/middleware/rate-limit.ts +125 -0
  253. package/src/server/middleware/request-logger.ts +41 -0
  254. package/src/server/middleware/route-template.ts +46 -0
  255. package/src/server/middleware/runtime.ts +22 -0
  256. package/src/server/middleware/settings.ts +55 -0
  257. package/src/server/middleware/validator.ts +49 -0
  258. package/src/server/middleware/workload.ts +22 -0
  259. package/src/server/ratelimit.ts +151 -0
  260. package/src/server/request-cookies.ts +18 -0
  261. package/src/server/services/access.ts +644 -0
  262. package/src/server/services/freeipa/client.ts +137 -0
  263. package/src/server/services/freeipa/index.ts +9 -0
  264. package/src/server/services/freeipa/session.ts +123 -0
  265. package/src/server/services/freeipa/test-certificates.ts +44 -0
  266. package/src/server/services/freeipa/tls.ts +44 -0
  267. package/src/server/services/freeipa/transport.ts +116 -0
  268. package/src/server/services/freeipa/util.ts +59 -0
  269. package/src/server/services/geo.ts +198 -0
  270. package/src/server/services/index.ts +48 -0
  271. package/src/server/services/pagination.ts +22 -0
  272. package/src/server/services/services.ts +13 -0
  273. package/src/server/time.ts +33 -0
  274. package/src/services/account-category-policy.ts +51 -0
  275. package/src/services/account-lifecycle/audit.ts +41 -0
  276. package/src/services/account-lifecycle/index.ts +940 -0
  277. package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
  278. package/src/services/account-lifecycle/notification-sender.ts +13 -0
  279. package/src/services/account-lifecycle/scheduler.ts +506 -0
  280. package/src/services/account-model.ts +22 -0
  281. package/src/services/accounts/app.ts +1671 -0
  282. package/src/services/accounts/authz.ts +44 -0
  283. package/src/services/accounts/avatar.ts +120 -0
  284. package/src/services/accounts/base-group.ts +11 -0
  285. package/src/services/accounts/base-user.ts +46 -0
  286. package/src/services/accounts/duplicate-emails.ts +82 -0
  287. package/src/services/accounts/entities.ts +622 -0
  288. package/src/services/accounts/group-sql.ts +106 -0
  289. package/src/services/accounts/groups.ts +272 -0
  290. package/src/services/accounts/index.ts +14 -0
  291. package/src/services/accounts/ipa-data.ts +66 -0
  292. package/src/services/accounts/lifecycle.ts +2 -0
  293. package/src/services/accounts/local-groups.ts +499 -0
  294. package/src/services/accounts/model.ts +129 -0
  295. package/src/services/accounts/notification-sender.ts +31 -0
  296. package/src/services/accounts/posix.ts +386 -0
  297. package/src/services/accounts/request-policy.ts +11 -0
  298. package/src/services/accounts/switching.ts +126 -0
  299. package/src/services/accounts/users.ts +745 -0
  300. package/src/services/announcements/index.ts +224 -0
  301. package/src/services/app-approval.ts +498 -0
  302. package/src/services/audit/index.ts +438 -0
  303. package/src/services/auth-flows/index.ts +15 -0
  304. package/src/services/auth-flows/ipa.ts +197 -0
  305. package/src/services/auth-flows/magic-link.ts +185 -0
  306. package/src/services/auth-flows/notification-sender.ts +10 -0
  307. package/src/services/auth-flows/password-reset.ts +268 -0
  308. package/src/services/auth-flows/proxy-return.ts +49 -0
  309. package/src/services/document-extraction.ts +148 -0
  310. package/src/services/freeipa-config.ts +179 -0
  311. package/src/services/gateway.ts +186 -0
  312. package/src/services/identity/constants.ts +18 -0
  313. package/src/services/identity/index.ts +52 -0
  314. package/src/services/identity/invocation-actor.ts +120 -0
  315. package/src/services/identity/invocation-authority.ts +53 -0
  316. package/src/services/identity/invocation-operations.ts +15 -0
  317. package/src/services/identity/invocation-token.ts +276 -0
  318. package/src/services/identity/key-config.ts +41 -0
  319. package/src/services/identity/key-ring.ts +530 -0
  320. package/src/services/identity/metrics.ts +29 -0
  321. package/src/services/identity/routes.ts +18 -0
  322. package/src/services/identity/runtime-config.ts +88 -0
  323. package/src/services/identity/session-token.ts +152 -0
  324. package/src/services/identity/signer-refresh.ts +44 -0
  325. package/src/services/identity/workload-auth.ts +64 -0
  326. package/src/services/index.ts +187 -0
  327. package/src/services/ipa/auth.ts +216 -0
  328. package/src/services/ipa/connection.ts +57 -0
  329. package/src/services/ipa/effective-groups.ts +70 -0
  330. package/src/services/ipa/groups.ts +722 -0
  331. package/src/services/ipa/guard.ts +17 -0
  332. package/src/services/ipa/index.ts +17 -0
  333. package/src/services/ipa/posix.ts +19 -0
  334. package/src/services/ipa/profile.ts +132 -0
  335. package/src/services/ipa/runtime.ts +28 -0
  336. package/src/services/ipa/search.ts +154 -0
  337. package/src/services/ipa/service-account.ts +15 -0
  338. package/src/services/ipa/sync-planning.ts +100 -0
  339. package/src/services/ipa/sync.ts +862 -0
  340. package/src/services/ipa/users.ts +806 -0
  341. package/src/services/legal-consent.ts +76 -0
  342. package/src/services/logging/index.ts +477 -0
  343. package/src/services/logging/redaction.ts +20 -0
  344. package/src/services/logging/trace.ts +1231 -0
  345. package/src/services/mandates/index.ts +855 -0
  346. package/src/services/mandates/policy.ts +85 -0
  347. package/src/services/network-security.ts +31 -0
  348. package/src/services/notifications/batches.ts +722 -0
  349. package/src/services/notifications/browser.ts +252 -0
  350. package/src/services/notifications/catalog.ts +113 -0
  351. package/src/services/notifications/channels.ts +104 -0
  352. package/src/services/notifications/dispatcher.ts +139 -0
  353. package/src/services/notifications/email.ts +118 -0
  354. package/src/services/notifications/index.ts +633 -0
  355. package/src/services/notifications/live.ts +50 -0
  356. package/src/services/notifications/observability.ts +407 -0
  357. package/src/services/notifications/platform.ts +421 -0
  358. package/src/services/notifications/runtime.ts +120 -0
  359. package/src/services/notifications/user.ts +248 -0
  360. package/src/services/notifications/web-push-transport.ts +135 -0
  361. package/src/services/oauth-tokens.ts +209 -0
  362. package/src/services/outbox.ts +187 -0
  363. package/src/services/pdf/gotenberg.ts +290 -0
  364. package/src/services/pdf/index.ts +43 -0
  365. package/src/services/pdf/markdown.ts +209 -0
  366. package/src/services/pdf/template-preview.ts +122 -0
  367. package/src/services/postgres.ts +76 -0
  368. package/src/services/providers/index.ts +27 -0
  369. package/src/services/providers/local/auth.ts +51 -0
  370. package/src/services/providers/local/index.ts +4 -0
  371. package/src/services/providers/local/users.ts +263 -0
  372. package/src/services/rail-preferences.ts +27 -0
  373. package/src/services/runtime-lifecycle.ts +191 -0
  374. package/src/services/secrets.ts +10 -0
  375. package/src/services/service-account-credentials.ts +891 -0
  376. package/src/services/service-accounts.ts +184 -0
  377. package/src/services/session/index.ts +236 -0
  378. package/src/services/session/test-fixture.ts +18 -0
  379. package/src/services/session/user.ts +144 -0
  380. package/src/services/settings/api.ts +61 -0
  381. package/src/services/settings/app.ts +86 -0
  382. package/src/services/settings/core-settings.ts +796 -0
  383. package/src/services/settings/crypto.ts +69 -0
  384. package/src/services/settings/defaults.ts +374 -0
  385. package/src/services/settings/index.ts +215 -0
  386. package/src/services/settings/namespace.ts +9 -0
  387. package/src/services/settings/snapshot.ts +49 -0
  388. package/src/services/settings/store.ts +241 -0
  389. package/src/services/settings/templates.ts +4 -0
  390. package/src/services/sync-ops.ts +403 -0
  391. package/src/services/topic-cursor.ts +22 -0
  392. package/src/services/weather/forecast.ts +323 -0
  393. package/src/services/weather/geo.ts +119 -0
  394. package/src/services/weather/index.ts +67 -0
  395. package/src/services/weather/location.ts +24 -0
  396. package/src/services/weather/locations.ts +133 -0
  397. package/src/services/weather/migrate.ts +45 -0
  398. package/src/services/weather/settings.ts +66 -0
  399. package/src/services/weather/short-id.ts +57 -0
  400. package/src/services/weather/types.ts +61 -0
  401. package/src/services/weather/ui.ts +50 -0
  402. package/src/services/webauthn.ts +385 -0
  403. package/src/shared/account-action-notice.ts +70 -0
  404. package/src/shared/account-display.ts +15 -0
  405. package/src/shared/account-session.ts +15 -0
  406. package/src/shared/ai-platform-prompt.ts +134 -0
  407. package/src/shared/ai-usage.ts +49 -0
  408. package/src/shared/app-presentation.ts +48 -0
  409. package/src/shared/app-url.ts +12 -0
  410. package/src/shared/branding.ts +14 -0
  411. package/src/shared/capability-messages.ts +70 -0
  412. package/src/shared/email-html.ts +54 -0
  413. package/src/shared/format.ts +91 -0
  414. package/src/shared/help.ts +68 -0
  415. package/src/shared/icons.ts +400 -0
  416. package/src/shared/index.ts +54 -0
  417. package/src/shared/locale.ts +48 -0
  418. package/src/shared/login-method.ts +44 -0
  419. package/src/shared/markdown/client.ts +130 -0
  420. package/src/shared/markdown/extensions/code.ts +105 -0
  421. package/src/shared/markdown/extensions/guided-help.ts +100 -0
  422. package/src/shared/markdown/extensions/images.ts +79 -0
  423. package/src/shared/markdown/extensions/info-blocks.ts +97 -0
  424. package/src/shared/markdown/extensions/katex.ts +120 -0
  425. package/src/shared/markdown/extensions/links.ts +43 -0
  426. package/src/shared/markdown/extensions/mark.ts +48 -0
  427. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  428. package/src/shared/markdown/extensions/tables.ts +109 -0
  429. package/src/shared/markdown/extensions/task-list.ts +53 -0
  430. package/src/shared/markdown/formula.ts +1187 -0
  431. package/src/shared/markdown/index.ts +193 -0
  432. package/src/shared/markdown/shared.ts +36 -0
  433. package/src/shared/mock-cover.ts +130 -0
  434. package/src/shared/network-address.ts +106 -0
  435. package/src/shared/redirect.ts +56 -0
  436. package/src/shared/template-rendering.ts +140 -0
  437. package/src/shared/theme.ts +68 -0
  438. package/src/shared/time.ts +13 -0
  439. package/src/ssr/AdminLayout.tsx +53 -0
  440. package/src/ssr/AdminSidebar.tsx +50 -0
  441. package/src/ssr/AppLaunchpad.island.tsx +236 -0
  442. package/src/ssr/AppWorkspaceController.island.tsx +21 -0
  443. package/src/ssr/BrowserNotifications.island.tsx +135 -0
  444. package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
  445. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
  446. package/src/ssr/GlobalSearchDialog.tsx +35 -0
  447. package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
  448. package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
  449. package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
  450. package/src/ssr/Layout.render.test.tsx +148 -0
  451. package/src/ssr/Layout.tsx +292 -0
  452. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  453. package/src/ssr/LayoutFooter.tsx +22 -0
  454. package/src/ssr/LayoutHeader.tsx +81 -0
  455. package/src/ssr/LayoutHelp.tsx +869 -0
  456. package/src/ssr/LayoutPreferences.island.tsx +36 -0
  457. package/src/ssr/LayoutRail.tsx +58 -0
  458. package/src/ssr/MinimalLayout.tsx +46 -0
  459. package/src/ssr/PageError.tsx +29 -0
  460. package/src/ssr/ProfilePreferences.island.tsx +92 -0
  461. package/src/ssr/RailApps.island.tsx +61 -0
  462. package/src/ssr/RailEditor.tsx +293 -0
  463. package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
  464. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  465. package/src/ssr/admin-active-link.ts +47 -0
  466. package/src/ssr/admin-navigation.ts +78 -0
  467. package/src/ssr/app-appearance.ts +23 -0
  468. package/src/ssr/app-navigation.ts +41 -0
  469. package/src/ssr/help-messages.ts +104 -0
  470. package/src/ssr/index.ts +14 -0
  471. package/src/ssr/islands/SearchBar.island.tsx +80 -0
  472. package/src/ssr/islands/index.ts +16 -0
  473. package/src/ssr/layout-context.ts +17 -0
  474. package/src/ssr/layout-help-markdown.ts +9 -0
  475. package/src/ssr/layout-help-navigation.ts +24 -0
  476. package/src/ssr/layout-help-url.ts +5 -0
  477. package/src/ssr/layout-runtime.ts +22 -0
  478. package/src/ssr/page-error-messages.ts +25 -0
  479. package/src/ssr/platform-messages.ts +135 -0
  480. package/src/ssr/preference-controller.ts +44 -0
  481. package/src/ssr/profile-preferences-messages.ts +29 -0
  482. package/src/ssr/rail-context.ts +21 -0
  483. package/src/ssr/rail-messages.ts +66 -0
  484. package/src/ssr/rail-navigation.ts +60 -0
  485. package/src/ssr/runtime.ts +31 -0
  486. package/src/ssr/url-filter.ts +127 -0
  487. package/src/styles/base-popover.css +36 -0
  488. package/src/styles/effects.css +851 -0
  489. package/src/styles/global.css +268 -0
  490. package/src/styles/input.css +58 -0
  491. package/src/styles/tokens.css +230 -0
  492. package/src/styles/utilities-buttons.css +405 -0
  493. package/src/styles/utilities-data.css +33 -0
  494. package/src/styles/utilities-feedback.css +232 -0
  495. package/src/styles/utilities-layout.css +603 -0
  496. package/src/styles/utilities-markdown-editor.css +525 -0
  497. package/src/styles/utilities-markdown-table.css +234 -0
  498. package/src/styles/utilities-navigation.css +1054 -0
  499. package/src/types/ambient.d.ts +24 -0
  500. package/src/workflows/ai/index.ts +17 -0
  501. package/src/workflows/ai/runtime.ts +286 -0
  502. package/src/workflows/ai/store.ts +314 -0
  503. package/src/workflows/ai/types.ts +111 -0
  504. package/src/workflows/ai-actions.ts +269 -0
  505. package/src/workflows/authoring.ts +88 -0
  506. package/src/workflows/builtins.ts +193 -0
  507. package/src/workflows/contracts.ts +280 -0
  508. package/src/workflows/definition.ts +334 -0
  509. package/src/workflows/editor.ts +113 -0
  510. package/src/workflows/index.ts +71 -0
  511. package/src/workflows/language/binder.ts +45 -0
  512. package/src/workflows/language/canonical.ts +29 -0
  513. package/src/workflows/language/compiler.ts +396 -0
  514. package/src/workflows/language/expressions.ts +34 -0
  515. package/src/workflows/language/index.ts +20 -0
  516. package/src/workflows/language/references.ts +55 -0
  517. package/src/workflows/language/schema.ts +134 -0
  518. package/src/workflows/language/strict-yaml.ts +131 -0
  519. package/src/workflows/manifest.ts +24 -0
  520. package/src/workflows/module.ts +12 -0
  521. package/src/workflows/runtime/coordinator.ts +115 -0
  522. package/src/workflows/runtime/executor.ts +1003 -0
  523. package/src/workflows/runtime/index.ts +60 -0
  524. package/src/workflows/runtime/instant.ts +8 -0
  525. package/src/workflows/runtime/ports.ts +213 -0
  526. package/src/workflows/runtime/schedule.ts +203 -0
  527. package/src/workflows/runtime/trigger.ts +37 -0
  528. package/src/workflows/runtime/wake.ts +13 -0
  529. package/src/workflows/store/actions.ts +438 -0
  530. package/src/workflows/store/budget.ts +145 -0
  531. package/src/workflows/store/definitions.ts +345 -0
  532. package/src/workflows/store/events.ts +383 -0
  533. package/src/workflows/store/index.ts +107 -0
  534. package/src/workflows/store/observability.ts +594 -0
  535. package/src/workflows/store/runs.ts +975 -0
  536. package/src/workflows/store/transaction.ts +23 -0
  537. package/src/workflows/store/worker.ts +439 -0
  538. package/src/workflows/testing/conformance.ts +72 -0
  539. package/src/workflows/testing/index.ts +11 -0
  540. package/src/workflows/testing/process-fixtures.ts +275 -0
@@ -0,0 +1,975 @@
1
+ /**
2
+ * The kernel's run store.
3
+ *
4
+ * Grids and Mail each wrote their own version of this — 904 and 717 lines that
5
+ * agreed on almost everything and differed in the places that matter: Mail
6
+ * leased a target while Grids leased a run, Mail fenced with a generation *and*
7
+ * a token while Grids used the generation alone, and Mail's `heartbeat`
8
+ * silently ignored the run it was passed.
9
+ *
10
+ * Execution is "find the first step with no recorded outcome, run it, record
11
+ * it". Crash recovery is that same loop, which is why there is no separate
12
+ * recovery path below: a claim on a run whose lease expired resumes it, and
13
+ * the journal makes the steps that already happened free to skip.
14
+ *
15
+ * The fence is `execution_generation`. Every claim increments it, so a worker
16
+ * that lost its lease writes with a stale generation and every one of its
17
+ * updates matches zero rows. Nothing here trusts wall-clock ordering.
18
+ */
19
+ import { type SQL, sql } from "bun";
20
+ import type { WorkflowBoundPlan, WorkflowDependency, WorkflowInvocationMode, WorkflowJsonValue, WorkflowRunState } from "../contracts";
21
+ import type {
22
+ WorkflowCoordinatorFinishState,
23
+ WorkflowCoordinatorLeaseState,
24
+ WorkflowCoordinatorPort,
25
+ WorkflowCoordinatorReleaseState,
26
+ } from "../runtime/coordinator";
27
+ import { WorkflowLeaseLostError } from "../runtime/executor";
28
+ import type {
29
+ WorkflowHeartbeatOutcome,
30
+ WorkflowRestoredStep,
31
+ WorkflowRuntimeRepositoryPort,
32
+ WorkflowRuntimeRunIdentity,
33
+ WorkflowRuntimeStepIdentity,
34
+ WorkflowRuntimeStepResult,
35
+ } from "../runtime/ports";
36
+ import { withTransaction } from "./transaction";
37
+
38
+ /** How long a claim survives without a heartbeat. */
39
+ export const WORKFLOW_RUN_LEASE_MS = 120_000;
40
+
41
+ /** How long a released run waits before it can be picked up again. */
42
+ const RELEASE_BACKOFF_MS = 5_000;
43
+ const RELEASE_MAX_BACKOFF_MS = 5 * 60_000;
44
+
45
+ /** A repeatedly crashing/retryable run eventually becomes an operator-visible failure. */
46
+ export const WORKFLOW_RUN_MAX_CONSECUTIVE_FAILURES = 8;
47
+
48
+ /** A run state that no longer moves on its own. */
49
+ const TERMINAL_RUN_STATES: readonly WorkflowRunState[] = ["succeeded", "failed", "canceled", "needs_attention"];
50
+
51
+ export type WorkflowRunClaim = {
52
+ runId: string;
53
+ executionGeneration: number;
54
+ mode: WorkflowInvocationMode;
55
+ workflowId: string;
56
+ workflowVersionId: string;
57
+ sourceHash: string;
58
+ idempotencyKey: string;
59
+ appId: string;
60
+ scopeId: string;
61
+ /** The pinned plan. Read from the version the run points at, never the workflow's current one. */
62
+ plan: WorkflowBoundPlan;
63
+ inputs: Record<string, WorkflowJsonValue>;
64
+ context: Record<string, WorkflowJsonValue>;
65
+ authorization: WorkflowJsonValue;
66
+ /**
67
+ * The event's logical time, not the worker's clock. A replay after a crash
68
+ * must see the same "now" the first attempt did, or a step reading the date
69
+ * stops being a function of its inputs.
70
+ */
71
+ occurredAt: Date;
72
+ parentRunId: string | null;
73
+ parentStepKey: string | null;
74
+ attempt: number;
75
+ consecutiveFailures: number;
76
+ };
77
+
78
+ /** The outcome a run settles into. `waiting` means it parked and will be woken. */
79
+ export type WorkflowRunResult =
80
+ | { state: "succeeded"; result?: WorkflowJsonValue; message?: string }
81
+ | { state: "failed"; error: WorkflowJsonValue }
82
+ | { state: "needs_attention"; error: WorkflowJsonValue }
83
+ | { state: "canceled"; message?: string }
84
+ | { state: "waiting" };
85
+
86
+ type ClaimRow = {
87
+ id: string;
88
+ execution_generation: string;
89
+ mode: WorkflowInvocationMode;
90
+ workflow_id: string;
91
+ workflow_version_id: string;
92
+ source_hash: string;
93
+ idempotency_key: string;
94
+ app_id: string;
95
+ scope_id: string;
96
+ plan: WorkflowBoundPlan;
97
+ inputs: Record<string, WorkflowJsonValue>;
98
+ context: Record<string, WorkflowJsonValue>;
99
+ authorization_snapshot: WorkflowJsonValue;
100
+ occurred_at: Date;
101
+ parent_run_id: string | null;
102
+ parent_step_key: string | null;
103
+ attempt: number;
104
+ consecutive_failures: number;
105
+ };
106
+
107
+ const toClaim = (row: ClaimRow): WorkflowRunClaim => ({
108
+ runId: row.id,
109
+ executionGeneration: Number(row.execution_generation),
110
+ mode: row.mode,
111
+ workflowId: row.workflow_id,
112
+ workflowVersionId: row.workflow_version_id,
113
+ sourceHash: row.source_hash,
114
+ idempotencyKey: row.idempotency_key,
115
+ appId: row.app_id,
116
+ scopeId: row.scope_id,
117
+ plan: row.plan,
118
+ inputs: row.inputs,
119
+ context: row.context,
120
+ authorization: row.authorization_snapshot,
121
+ occurredAt: row.occurred_at,
122
+ parentRunId: row.parent_run_id,
123
+ parentStepKey: row.parent_step_key,
124
+ attempt: row.attempt,
125
+ consecutiveFailures: row.consecutive_failures,
126
+ });
127
+
128
+ /**
129
+ * Why an update matched no rows.
130
+ *
131
+ * The distinction is not cosmetic: a cancelled run must stop, a stale one must
132
+ * abandon its work silently because someone else already owns it, and treating
133
+ * either as the other either duplicates effects or strands runs.
134
+ */
135
+ const diagnoseLostLease = async (
136
+ db: SQL,
137
+ runId: string,
138
+ executionGeneration: number,
139
+ ): Promise<{ state: "stale" } | { state: "canceled"; message?: string }> => {
140
+ const [row] = await db<{ state: WorkflowRunState; execution_generation: string; cancel_requested_at: Date | null }[]>`
141
+ SELECT state, execution_generation, cancel_requested_at FROM workflows.run WHERE id = ${runId}
142
+ `;
143
+ if (!row) return { state: "stale" };
144
+ const ours = Number(row.execution_generation) === executionGeneration;
145
+ if (ours && (row.cancel_requested_at !== null || row.state === "canceled")) return { state: "canceled" };
146
+ return { state: "stale" };
147
+ };
148
+
149
+ // ─── Creation ────────────────────────────────────────────────────────────────
150
+
151
+ export type NewWorkflowRun = {
152
+ appId: string;
153
+ scopeId: string;
154
+ workflowId: string;
155
+ workflowVersionId: string;
156
+ mode: WorkflowInvocationMode;
157
+ authorization: WorkflowJsonValue;
158
+ idempotencyKey: string;
159
+ occurredAt: Date;
160
+ inputs?: Record<string, WorkflowJsonValue>;
161
+ /** Read by the plan as context.*, alongside the inputs. */
162
+ context?: Record<string, WorkflowJsonValue>;
163
+ eventId?: string;
164
+ /** Set together, or the run is not a child of anything. */
165
+ parentRunId?: string;
166
+ parentStepKey?: string;
167
+ };
168
+
169
+ /**
170
+ * Queues a run, or returns the existing one for the same idempotency key.
171
+ *
172
+ * Returning the existing id rather than raising is what lets a retried request
173
+ * — a webhook redelivery, a double-clicked button — be answered with the run it
174
+ * already started instead of starting a second one.
175
+ */
176
+ export const createWorkflowRun = async (run: NewWorkflowRun, options: { db?: SQL } = {}): Promise<string> => {
177
+ const db = options.db ?? sql;
178
+ const [row] = await db<{ id: string }[]>`
179
+ INSERT INTO workflows.run (
180
+ app_id, scope_id, workflow_id, workflow_version_id, event_id, parent_run_id, parent_step_key,
181
+ mode, inputs, context, authorization_snapshot, idempotency_key, occurred_at
182
+ )
183
+ SELECT
184
+ w.app_id, w.scope_id, w.id, v.id,
185
+ ${run.eventId ?? null}::uuid, ${run.parentRunId ?? null}::uuid, ${run.parentStepKey ?? null},
186
+ ${run.mode}, ${run.inputs ?? {}}, ${run.context ?? {}}, ${run.authorization},
187
+ ${run.idempotencyKey}, ${run.occurredAt}
188
+ FROM workflows.workflow AS w
189
+ JOIN workflows.version AS v ON v.workflow_id = w.id
190
+ WHERE w.id = ${run.workflowId}::uuid
191
+ AND v.id = ${run.workflowVersionId}::uuid
192
+ AND w.app_id = ${run.appId}
193
+ AND w.scope_id = ${run.scopeId}
194
+ AND (
195
+ ${run.parentRunId ?? null}::uuid IS NULL
196
+ OR EXISTS (
197
+ SELECT 1 FROM workflows.run AS parent
198
+ WHERE parent.id = ${run.parentRunId ?? null}::uuid
199
+ AND parent.app_id = w.app_id
200
+ AND parent.scope_id = w.scope_id
201
+ )
202
+ )
203
+ ON CONFLICT (workflow_id, mode, idempotency_key) DO UPDATE SET updated_at = workflows.run.updated_at
204
+ RETURNING id
205
+ `;
206
+ if (!row) throw new Error("workflow run does not match its workflow version, app, scope, or parent");
207
+ return row.id;
208
+ };
209
+
210
+ /**
211
+ * Fans out into child runs in one statement.
212
+ *
213
+ * A bulk operation over 10,000 records is 10,000 of these rather than rows in a
214
+ * targets table, so the parent's progress is a plain aggregate over its
215
+ * children and there is only one lease protocol to reason about.
216
+ */
217
+ export const createChildWorkflowRuns = async (
218
+ parent: { runId: string; stepKey: string },
219
+ children: readonly NewWorkflowRun[],
220
+ options: { db?: SQL } = {},
221
+ ): Promise<number> => {
222
+ if (children.length === 0) return 0;
223
+ const db = options.db ?? sql;
224
+ const values = children.map((child) => ({
225
+ app_id: child.appId,
226
+ scope_id: child.scopeId,
227
+ workflow_id: child.workflowId,
228
+ workflow_version_id: child.workflowVersionId,
229
+ event_id: child.eventId ?? null,
230
+ parent_run_id: parent.runId,
231
+ parent_step_key: parent.stepKey,
232
+ mode: child.mode,
233
+ inputs: child.inputs ?? {},
234
+ context: child.context ?? {},
235
+ authorization_snapshot: child.authorization,
236
+ idempotency_key: child.idempotencyKey,
237
+ occurred_at: child.occurredAt,
238
+ }));
239
+ const rows = await db<{ id: string }[]>`
240
+ INSERT INTO workflows.run ${db(values)}
241
+ ON CONFLICT (workflow_id, mode, idempotency_key) DO NOTHING
242
+ RETURNING id
243
+ `;
244
+ return rows.length;
245
+ };
246
+
247
+ /** How a parent's fan-out is doing, as one aggregate over the child index. */
248
+ export const countChildWorkflowRuns = async (
249
+ parentRunId: string,
250
+ options: { db?: SQL } = {},
251
+ ): Promise<Record<WorkflowRunState, number>> => {
252
+ const db = options.db ?? sql;
253
+ const rows = await db<{ state: WorkflowRunState; count: string }[]>`
254
+ SELECT state, count(*) AS count FROM workflows.run WHERE parent_run_id = ${parentRunId}::uuid GROUP BY state
255
+ `;
256
+ const counts = { queued: 0, running: 0, waiting: 0, succeeded: 0, failed: 0, canceled: 0, needs_attention: 0 };
257
+ for (const row of rows) counts[row.state] = Number(row.count);
258
+ return counts;
259
+ };
260
+
261
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Takes the next claimable run, or a named one.
265
+ *
266
+ * `SKIP LOCKED` is what makes several workers safe to run against the same
267
+ * queue: they step over each other's candidates instead of serialising on the
268
+ * first row.
269
+ */
270
+ export const claimWorkflowRun = async (options: {
271
+ worker: string;
272
+ runId?: string;
273
+ /** Restricts a worker to one app's runs, so a per-app process cannot drain another's. */
274
+ appId?: string;
275
+ /**
276
+ * Which mode to claim. Defaults to `execute`.
277
+ *
278
+ * A dry run asks what *would* happen; claiming one and running it through the
279
+ * execute path performs the effects it was meant to only describe.
280
+ */
281
+ mode?: WorkflowInvocationMode;
282
+ leaseMs?: number;
283
+ db?: SQL;
284
+ }): Promise<WorkflowRunClaim | null> => {
285
+ const db = options.db ?? sql;
286
+ const leaseMs = options.leaseMs ?? WORKFLOW_RUN_LEASE_MS;
287
+ const rows = await db<ClaimRow[]>`
288
+ WITH candidate AS (
289
+ SELECT id FROM workflows.run
290
+ WHERE state IN ('queued', 'running')
291
+ AND claimable_at < now()
292
+ AND cancel_requested_at IS NULL
293
+ AND (${options.runId ?? null}::uuid IS NULL OR id = ${options.runId ?? null}::uuid)
294
+ AND (${options.appId ?? null}::text IS NULL OR app_id = ${options.appId ?? null})
295
+ AND mode = ${options.mode ?? "execute"}
296
+ ORDER BY claimable_at, created_at, id
297
+ LIMIT 1
298
+ FOR UPDATE SKIP LOCKED
299
+ )
300
+ UPDATE workflows.run AS r
301
+ SET state = 'running',
302
+ execution_generation = r.execution_generation + 1,
303
+ attempt = r.attempt + 1,
304
+ lease_owner = ${options.worker},
305
+ lease_expires_at = now() + ${`${leaseMs} milliseconds`}::interval,
306
+ retry_after = NULL,
307
+ started_at = COALESCE(r.started_at, now()),
308
+ updated_at = now()
309
+ FROM candidate AS c
310
+ JOIN workflows.version AS v ON v.id = (SELECT workflow_version_id FROM workflows.run WHERE id = c.id)
311
+ WHERE r.id = c.id
312
+ RETURNING r.id, r.execution_generation, r.mode, r.workflow_id, r.workflow_version_id,
313
+ v.source_hash, r.idempotency_key, r.app_id, r.scope_id, v.plan,
314
+ r.inputs, r.context, r.authorization_snapshot, r.occurred_at, r.parent_run_id, r.parent_step_key,
315
+ r.attempt, r.consecutive_failures
316
+ `;
317
+ const row = rows[0];
318
+ return row ? toClaim(row) : null;
319
+ };
320
+
321
+ /** Extends a lease. Returns why it could not be extended, rather than throwing. */
322
+ export const renewWorkflowRunLease = async (
323
+ claim: { runId: string; executionGeneration: number },
324
+ options: { leaseMs?: number; db?: SQL } = {},
325
+ ): Promise<WorkflowCoordinatorLeaseState> => {
326
+ const db = options.db ?? sql;
327
+ const leaseMs = options.leaseMs ?? WORKFLOW_RUN_LEASE_MS;
328
+ const rows = await db<{ id: string }[]>`
329
+ UPDATE workflows.run
330
+ SET lease_expires_at = now() + ${`${leaseMs} milliseconds`}::interval, updated_at = now()
331
+ WHERE id = ${claim.runId}
332
+ AND execution_generation = ${claim.executionGeneration}
333
+ AND state = 'running'
334
+ AND cancel_requested_at IS NULL
335
+ RETURNING id
336
+ `;
337
+ if (rows.length > 0) return { state: "active" };
338
+ return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
339
+ };
340
+
341
+ /**
342
+ * Records a run's outcome and drops the lease.
343
+ *
344
+ * A `waiting` result is the exception: the run parked itself in `parkStep`, so
345
+ * this only confirms it is where the executor believes it is. Writing the state
346
+ * a second time would clear the wake deadline the park just set.
347
+ */
348
+ export const finishWorkflowRun = async (
349
+ claim: { runId: string; executionGeneration: number },
350
+ result: WorkflowRunResult,
351
+ options: { db?: SQL } = {},
352
+ ): Promise<WorkflowCoordinatorFinishState> => {
353
+ const db = options.db ?? sql;
354
+
355
+ if (result.state === "waiting") {
356
+ // A dependency signal may have arrived between parkStep and this
357
+ // coordinator acknowledgement. In that case parkStep already re-queued
358
+ // the run; the waiting attempt still finished successfully.
359
+ const rows = await db<{ id: string }[]>`
360
+ UPDATE workflows.run
361
+ SET consecutive_failures = 0, updated_at = now()
362
+ WHERE id = ${claim.runId}
363
+ AND execution_generation = ${claim.executionGeneration}
364
+ AND state IN ('waiting', 'queued')
365
+ RETURNING id
366
+ `;
367
+ if (rows.length > 0) return { state: "finished" };
368
+ return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
369
+ }
370
+
371
+ const serializedResult = result.state === "succeeded" ? JSON.stringify(result.result ?? null) : null;
372
+
373
+ const rows = await db<{ id: string }[]>`
374
+ UPDATE workflows.run
375
+ SET state = ${result.state},
376
+ result = (${serializedResult}::text)::jsonb,
377
+ result_message = ${(result.state === "succeeded" || result.state === "canceled" ? result.message : null) ?? null},
378
+ error = ${result.state === "failed" || result.state === "needs_attention" ? result.error : null},
379
+ lease_owner = NULL,
380
+ lease_expires_at = NULL,
381
+ retry_after = NULL,
382
+ consecutive_failures = 0,
383
+ wake_at = NULL,
384
+ finished_at = now(),
385
+ updated_at = now()
386
+ WHERE id = ${claim.runId}
387
+ AND execution_generation = ${claim.executionGeneration}
388
+ AND state IN ('running', 'waiting')
389
+ RETURNING id
390
+ `;
391
+ if (rows.length > 0) return { state: "finished" };
392
+ return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
393
+ };
394
+
395
+ /**
396
+ * Gives up a lease without recording an outcome — the crash path.
397
+ *
398
+ * The run goes back to `queued` and the journal makes the resumed attempt skip
399
+ * whatever already completed. The backoff exists because a run that dies on
400
+ * every attempt would otherwise be re-claimed as fast as the dispatcher loops.
401
+ */
402
+ export const releaseWorkflowRun = async (
403
+ claim: { runId: string; executionGeneration: number; consecutiveFailures?: number },
404
+ options: { backoffMs?: number; db?: SQL } = {},
405
+ ): Promise<WorkflowCoordinatorReleaseState> => {
406
+ const db = options.db ?? sql;
407
+ const backoffMs =
408
+ options.backoffMs ?? Math.min(RELEASE_BACKOFF_MS * 2 ** Math.max(0, claim.consecutiveFailures ?? 0), RELEASE_MAX_BACKOFF_MS);
409
+ const rows = await db<{ retry_after: Date }[]>`
410
+ UPDATE workflows.run
411
+ SET state = 'queued',
412
+ lease_owner = NULL,
413
+ lease_expires_at = NULL,
414
+ retry_after = now() + ${`${backoffMs} milliseconds`}::interval,
415
+ consecutive_failures = consecutive_failures + 1,
416
+ updated_at = now()
417
+ WHERE id = ${claim.runId}
418
+ AND execution_generation = ${claim.executionGeneration}
419
+ AND state = 'running'
420
+ RETURNING retry_after
421
+ `;
422
+ const row = rows[0];
423
+ if (row) return { state: "retry", retryAt: row.retry_after.toISOString() };
424
+ return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
425
+ };
426
+
427
+ /** Asks a run to stop. The worker notices on its next heartbeat. */
428
+ export const requestWorkflowRunCancel = async (runId: string, options: { db?: SQL } = {}): Promise<boolean> => {
429
+ const db = options.db ?? sql;
430
+ const rows = await db<{ id: string }[]>`
431
+ WITH RECURSIVE descendants AS (
432
+ SELECT id FROM workflows.run WHERE id = ${runId}::uuid
433
+ UNION ALL
434
+ SELECT child.id
435
+ FROM workflows.run AS child
436
+ JOIN descendants AS parent ON child.parent_run_id = parent.id
437
+ )
438
+ UPDATE workflows.run AS r
439
+ SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
440
+ state = CASE WHEN state IN ('queued', 'waiting') THEN 'canceled' ELSE state END,
441
+ finished_at = CASE WHEN state IN ('queued', 'waiting') THEN now() ELSE finished_at END,
442
+ lease_owner = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE lease_owner END,
443
+ lease_expires_at = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE lease_expires_at END,
444
+ wake_at = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE wake_at END,
445
+ updated_at = now()
446
+ WHERE r.id IN (SELECT id FROM descendants)
447
+ AND r.state NOT IN ${db(TERMINAL_RUN_STATES)}
448
+ RETURNING r.id
449
+ `;
450
+ return rows.some((row) => row.id === runId);
451
+ };
452
+
453
+ const canceledWithUncertainEffect = {
454
+ code: "WORKFLOW_EFFECT_OUTCOME_UNKNOWN",
455
+ message: "The workflow was canceled while an external effect was still unresolved.",
456
+ retryable: false,
457
+ } as const;
458
+
459
+ /**
460
+ * Settles a cancellation owned by one worker generation.
461
+ *
462
+ * A canceled run with no uncertain effect is terminal. An effect that may have
463
+ * escaped remains an operator decision instead: calling that run canceled would
464
+ * hide the exact duplicate-effect risk the journal exists to expose.
465
+ */
466
+ export const finishWorkflowRunCancel = async (
467
+ claim: { runId: string; executionGeneration: number },
468
+ options: { db?: SQL } = {},
469
+ ): Promise<{ state: "finished"; result: Extract<WorkflowRunResult, { state: "canceled" | "needs_attention" }> } | { state: "stale" }> => {
470
+ const db = options.db ?? sql;
471
+ return withTransaction(db, async (tx) => {
472
+ const [run] = await tx<{ id: string }[]>`
473
+ SELECT id FROM workflows.run
474
+ WHERE id = ${claim.runId}::uuid
475
+ AND execution_generation = ${claim.executionGeneration}
476
+ AND state = 'running'
477
+ AND cancel_requested_at IS NOT NULL
478
+ FOR UPDATE
479
+ `;
480
+ if (!run) return { state: "stale" } as const;
481
+
482
+ const unsettled = await tx<{ step_key: string }[]>`
483
+ UPDATE workflows.step_outcome
484
+ SET state = 'needs_attention',
485
+ outcome = ${{ mode: "execute", outcome: { state: "needs_attention", error: canceledWithUncertainEffect } }},
486
+ dependency = NULL,
487
+ finished_at = now(),
488
+ updated_at = now()
489
+ WHERE run_id = ${claim.runId}::uuid
490
+ AND effect_state IN ('executing', 'ambiguous')
491
+ RETURNING step_key
492
+ `;
493
+ const result =
494
+ unsettled.length > 0 ? ({ state: "needs_attention", error: canceledWithUncertainEffect } as const) : ({ state: "canceled" } as const);
495
+ await tx`
496
+ UPDATE workflows.run
497
+ SET state = ${result.state},
498
+ result = NULL,
499
+ result_message = NULL,
500
+ error = ${result.state === "needs_attention" ? result.error : null},
501
+ lease_owner = NULL,
502
+ lease_expires_at = NULL,
503
+ retry_after = NULL,
504
+ wake_at = NULL,
505
+ finished_at = now(),
506
+ updated_at = now()
507
+ WHERE id = ${claim.runId}::uuid
508
+ AND execution_generation = ${claim.executionGeneration}
509
+ AND state = 'running'
510
+ `;
511
+ return { state: "finished", result } as const;
512
+ });
513
+ };
514
+
515
+ /** Finalizes canceled runs whose worker disappeared before observing the request. */
516
+ export const finishExpiredWorkflowRunCancels = async (limit: number, options: { appId: string; db?: SQL }): Promise<number> => {
517
+ const db = options.db ?? sql;
518
+ return withTransaction(db, async (tx) => {
519
+ const rows = await tx<{ id: string; execution_generation: string }[]>`
520
+ SELECT id, execution_generation
521
+ FROM workflows.run
522
+ WHERE app_id = ${options.appId}
523
+ AND state = 'running'
524
+ AND cancel_requested_at IS NOT NULL
525
+ AND lease_expires_at < now()
526
+ ORDER BY lease_expires_at, id
527
+ LIMIT ${limit}
528
+ FOR UPDATE SKIP LOCKED
529
+ `;
530
+ for (const row of rows) {
531
+ await finishWorkflowRunCancel({ runId: row.id, executionGeneration: Number(row.execution_generation) }, { db: tx });
532
+ }
533
+ return rows.length;
534
+ });
535
+ };
536
+
537
+ /** The coordinator's view of the store, so `coordinateWorkflowExecution` drives it directly. */
538
+ export const createWorkflowCoordinatorPort = (options: {
539
+ worker: string;
540
+ runId?: string;
541
+ appId: string;
542
+ mode?: WorkflowInvocationMode;
543
+ leaseMs?: number;
544
+ }): WorkflowCoordinatorPort<void, WorkflowRunClaim, WorkflowRunResult> => ({
545
+ claim: () => claimWorkflowRun(options),
546
+ renew: (claim) => renewWorkflowRunLease(claim, { leaseMs: options.leaseMs }),
547
+ finish: (claim, result) => finishWorkflowRun(claim, result),
548
+ release: (claim) => releaseWorkflowRun(claim),
549
+ });
550
+
551
+ // ─── Scans ───────────────────────────────────────────────────────────────────
552
+
553
+ /** Runs a worker may pick up now, oldest first. */
554
+ export const listClaimableWorkflowRunIds = async (limit: number, options: { db?: SQL } = {}): Promise<string[]> => {
555
+ const db = options.db ?? sql;
556
+ const rows = await db<{ id: string }[]>`
557
+ SELECT id FROM workflows.run
558
+ WHERE state IN ('queued', 'running')
559
+ AND claimable_at < now()
560
+ AND cancel_requested_at IS NULL
561
+ ORDER BY claimable_at, created_at, id
562
+ LIMIT ${limit}
563
+ `;
564
+ return rows.map((row) => row.id);
565
+ };
566
+
567
+ /** Parked runs whose deadline has passed, so a dependency that never fires cannot strand them. */
568
+ export const wakeExpiredWorkflowRuns = async (limit: number, options: { appId?: string; db?: SQL } = {}): Promise<string[]> => {
569
+ const db = options.db ?? sql;
570
+ const rows = await db<{ id: string }[]>`
571
+ UPDATE workflows.run
572
+ SET state = 'queued', wake_at = NULL, updated_at = now()
573
+ WHERE id IN (
574
+ SELECT id FROM workflows.run
575
+ WHERE state = 'waiting' AND wake_at IS NOT NULL AND wake_at <= now()
576
+ AND (${options.appId ?? null}::text IS NULL OR app_id = ${options.appId ?? null})
577
+ ORDER BY wake_at, id
578
+ LIMIT ${limit}
579
+ FOR UPDATE SKIP LOCKED
580
+ )
581
+ RETURNING id
582
+ `;
583
+ return rows.map((row) => row.id);
584
+ };
585
+
586
+ /** Re-queues every run parked on a dependency that has now been satisfied. */
587
+ export const wakeWorkflowRunsWaitingOn = async (
588
+ dependency: { appId: string; kind: string; key: string },
589
+ options: { db?: SQL } = {},
590
+ ): Promise<string[]> => {
591
+ return withTransaction(options.db, async (tx) => {
592
+ await tx`
593
+ INSERT INTO workflows.dependency_signal (app_id, kind, key)
594
+ VALUES (${dependency.appId}, ${dependency.kind}, ${dependency.key})
595
+ ON CONFLICT (app_id, kind, key) DO UPDATE
596
+ SET updated_at = now()
597
+ `;
598
+ const rows = await tx<{ id: string }[]>`
599
+ UPDATE workflows.run AS r
600
+ SET state = 'queued', wake_at = NULL, updated_at = now()
601
+ FROM workflows.step_outcome AS s
602
+ WHERE s.run_id = r.id
603
+ AND s.state = 'waiting'
604
+ AND s.dependency ->> 'kind' = ${dependency.kind}
605
+ AND s.dependency ->> 'key' = ${dependency.key}
606
+ AND r.app_id = ${dependency.appId}
607
+ AND r.state = 'waiting'
608
+ RETURNING r.id
609
+ `;
610
+ return rows.map((row) => row.id).sort();
611
+ });
612
+ };
613
+
614
+ // ─── Journal ─────────────────────────────────────────────────────────────────
615
+
616
+ /**
617
+ * Thrown when a step writes under a generation the run has moved past.
618
+ *
619
+ * Deliberately the executor's own class rather than a second one with the same
620
+ * name: the executor rethrows this and turns every *other* error into an action
621
+ * failure, so a private copy would have a lost lease recorded as a step that
622
+ * failed — on a run some other worker now owns.
623
+ */
624
+ export { WorkflowLeaseLostError } from "../runtime/executor";
625
+
626
+ /** The `state` column mirrors the outcome's own discriminant — no second vocabulary. */
627
+ const stepState = (result: WorkflowRuntimeStepResult): string => result.outcome.state;
628
+
629
+ const stepJournal = (options: { db?: SQL } = {}): WorkflowRuntimeRepositoryPort => {
630
+ const db = options.db ?? sql;
631
+
632
+ /**
633
+ * Every journal write is fenced against the *run's* current generation, not
634
+ * the generation recorded on the step row. Matching the step's own value let
635
+ * a worker whose lease had lapsed still record an outcome, on a run someone
636
+ * else already owned — the step it had half-finished then looked complete to
637
+ * the worker that took over.
638
+ */
639
+ const startStep = async (step: WorkflowRuntimeStepIdentity): Promise<void> => {
640
+ const rows = await db<{ run_id: string }[]>`
641
+ INSERT INTO workflows.step_outcome (
642
+ run_id, step_key, source_path, iteration_path, kind, action, mode, state, execution_generation, attempt
643
+ )
644
+ SELECT ${step.runId}::uuid, ${step.key}, ${step.sourcePath}, ${step.iterationPath},
645
+ ${step.kind}, ${step.action ?? null}, ${step.mode}, 'running', ${step.executionGeneration}, 0
646
+ WHERE EXISTS (
647
+ SELECT 1 FROM workflows.run
648
+ WHERE id = ${step.runId}::uuid
649
+ AND state = 'running'
650
+ AND execution_generation = ${step.executionGeneration}
651
+ AND cancel_requested_at IS NULL
652
+ )
653
+ ON CONFLICT (run_id, step_key) DO UPDATE
654
+ SET state = 'running',
655
+ outcome = NULL,
656
+ dependency = NULL,
657
+ execution_generation = EXCLUDED.execution_generation,
658
+ attempt = workflows.step_outcome.attempt + 1,
659
+ started_at = now(),
660
+ finished_at = NULL,
661
+ updated_at = now()
662
+ RETURNING run_id
663
+ `;
664
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
665
+ };
666
+
667
+ /**
668
+ * Parks a step on something outside the run, and the run with it.
669
+ *
670
+ * Both happen in one transaction: a step marked waiting while its run stays
671
+ * `running` is a run nothing will ever wake, and a run marked waiting with no
672
+ * recorded dependency is one nothing knows how to wake.
673
+ */
674
+ const parkStep = async (step: WorkflowRuntimeStepIdentity, dependency: WorkflowDependency): Promise<void> => {
675
+ await withTransaction(options.db, async (tx) => {
676
+ const rows = await tx<{ run_id: string }[]>`
677
+ UPDATE workflows.step_outcome AS s
678
+ SET state = 'waiting', outcome = NULL, dependency = ${dependency}, finished_at = NULL, updated_at = now()
679
+ FROM workflows.run AS r
680
+ WHERE s.run_id = ${step.runId}::uuid
681
+ AND s.step_key = ${step.key}
682
+ AND r.id = s.run_id
683
+ AND r.state = 'running'
684
+ AND r.execution_generation = ${step.executionGeneration}
685
+ RETURNING s.run_id
686
+ `;
687
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
688
+
689
+ const parked = await tx<{ id: string; app_id: string }[]>`
690
+ UPDATE workflows.run
691
+ SET state = 'waiting',
692
+ wake_at = ${dependency.deadline ?? null}::timestamptz,
693
+ lease_owner = NULL,
694
+ lease_expires_at = NULL,
695
+ updated_at = now()
696
+ WHERE id = ${step.runId}::uuid AND execution_generation = ${step.executionGeneration} AND state = 'running'
697
+ RETURNING id, app_id
698
+ `;
699
+ const run = parked[0];
700
+ if (!run) throw new WorkflowLeaseLostError();
701
+
702
+ // Close wake-before-park: a signal recorded just before this transaction
703
+ // is as satisfying as one recorded just after it.
704
+ const [signal] = await tx<{ app_id: string }[]>`
705
+ SELECT app_id FROM workflows.dependency_signal
706
+ WHERE app_id = ${run.app_id}
707
+ AND kind = ${dependency.kind}
708
+ AND key = ${dependency.key}
709
+ `;
710
+ if (signal) {
711
+ await tx`
712
+ UPDATE workflows.run
713
+ SET state = 'queued', wake_at = NULL, updated_at = now()
714
+ WHERE id = ${step.runId}::uuid
715
+ AND execution_generation = ${step.executionGeneration}
716
+ AND state = 'waiting'
717
+ `;
718
+ }
719
+ });
720
+ };
721
+
722
+ const finishStep = async (step: WorkflowRuntimeStepIdentity, result: WorkflowRuntimeStepResult): Promise<void> => {
723
+ // The port's type admits a waiting outcome here; parking is the path that
724
+ // also stops the run, so route it there rather than writing half of it.
725
+ if (result.mode === "execute" && result.outcome.state === "waiting") {
726
+ await parkStep(step, result.outcome.dependency);
727
+ return;
728
+ }
729
+ const rows = await db<{ run_id: string }[]>`
730
+ UPDATE workflows.step_outcome AS s
731
+ SET state = ${stepState(result)},
732
+ outcome = ${result},
733
+ dependency = NULL,
734
+ finished_at = now(),
735
+ updated_at = now()
736
+ FROM workflows.run AS r
737
+ WHERE s.run_id = ${step.runId}::uuid
738
+ AND s.step_key = ${step.key}
739
+ AND r.id = s.run_id
740
+ AND r.state = 'running'
741
+ AND r.execution_generation = ${step.executionGeneration}
742
+ RETURNING s.run_id
743
+ `;
744
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
745
+ };
746
+
747
+ const restoreStepOutcome = async (step: WorkflowRuntimeStepIdentity): Promise<WorkflowRestoredStep | null> => {
748
+ const [row] = await db<{ outcome: WorkflowRestoredStep | null }[]>`
749
+ SELECT outcome FROM workflows.step_outcome
750
+ WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key} AND state NOT IN ('running', 'waiting')
751
+ `;
752
+ return row?.outcome ?? null;
753
+ };
754
+
755
+ const heartbeat = async (run: WorkflowRuntimeRunIdentity): Promise<WorkflowHeartbeatOutcome> =>
756
+ renewWorkflowRunLease({ runId: run.runId, executionGeneration: run.executionGeneration }, { db });
757
+
758
+ return { heartbeat, restoreStepOutcome, startStep, finishStep, parkStep };
759
+ };
760
+
761
+ /**
762
+ * The kernel's `WorkflowRuntimeRepositoryPort`.
763
+ *
764
+ * There is one of these now. Each app used to bring its own, which is how the
765
+ * two ended up storing different things under the same column name — Grids
766
+ * wrote the bare outcome and reconstructed the mode from a second column, Mail
767
+ * wrote the pair.
768
+ */
769
+ export const createWorkflowRuntimeRepository = (options: { db?: SQL } = {}): WorkflowRuntimeRepositoryPort => stepJournal(options);
770
+
771
+ // ─── Effects ─────────────────────────────────────────────────────────────────
772
+
773
+ /**
774
+ * Marks an impure step as having started its effect.
775
+ *
776
+ * Written before the effect is attempted, so a crash in between leaves the
777
+ * evidence that something may have escaped. A step whose effect is left
778
+ * `executing` is exactly the queue a replay has to be careful with.
779
+ */
780
+ export const beginWorkflowEffect = async (
781
+ step: { runId: string; key: string; executionGeneration: number },
782
+ effectKey: string,
783
+ options: { db?: SQL } = {},
784
+ ): Promise<void> => {
785
+ const db = options.db ?? sql;
786
+ const rows = await db<{ run_id: string }[]>`
787
+ UPDATE workflows.step_outcome AS s
788
+ SET effect_key = ${effectKey},
789
+ effect_state = 'executing',
790
+ effect_started_at = COALESCE(s.effect_started_at, now()),
791
+ updated_at = now()
792
+ FROM workflows.run AS r
793
+ WHERE s.run_id = ${step.runId}::uuid
794
+ AND s.step_key = ${step.key}
795
+ AND r.id = s.run_id
796
+ AND r.state = 'running'
797
+ AND r.execution_generation = ${step.executionGeneration}
798
+ RETURNING s.run_id
799
+ `;
800
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
801
+ };
802
+
803
+ /**
804
+ * Records a completed effect and what it produced, on a caller-supplied handle.
805
+ *
806
+ * Called with the transaction that performed the work, so the evidence commits
807
+ * with it: that is what lets a transactional action promise that a crash means
808
+ * it did not happen.
809
+ */
810
+ export const recordWorkflowEffect = async (
811
+ db: SQL,
812
+ step: { runId: string; key: string; executionGeneration: number },
813
+ effectKey: string,
814
+ output: WorkflowJsonValue,
815
+ ): Promise<void> => {
816
+ const rows = await db<{ run_id: string }[]>`
817
+ UPDATE workflows.step_outcome AS s
818
+ SET effect_key = ${effectKey},
819
+ effect_state = 'succeeded',
820
+ effect_output = ${output},
821
+ effect_started_at = COALESCE(s.effect_started_at, now()),
822
+ updated_at = now()
823
+ FROM workflows.run AS r
824
+ WHERE s.run_id = ${step.runId}::uuid
825
+ AND s.step_key = ${step.key}
826
+ AND r.id = s.run_id
827
+ AND r.state = 'running'
828
+ AND r.execution_generation = ${step.executionGeneration}
829
+ RETURNING s.run_id
830
+ `;
831
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
832
+ };
833
+
834
+ /** Settles an effect once its fate is known. `ambiguous` is a real answer, not a failure. */
835
+ export const settleWorkflowEffect = async (
836
+ step: { runId: string; key: string; executionGeneration: number },
837
+ state: "succeeded" | "ambiguous" | "failed",
838
+ options: { db?: SQL } = {},
839
+ ): Promise<void> => {
840
+ const db = options.db ?? sql;
841
+ const rows = await db<{ run_id: string }[]>`
842
+ UPDATE workflows.step_outcome AS s
843
+ SET effect_state = ${state}, updated_at = now()
844
+ FROM workflows.run AS r
845
+ WHERE s.run_id = ${step.runId}::uuid
846
+ AND s.step_key = ${step.key}
847
+ AND s.effect_key IS NOT NULL
848
+ AND r.id = s.run_id
849
+ AND r.state = 'running'
850
+ AND r.execution_generation = ${step.executionGeneration}
851
+ RETURNING s.run_id
852
+ `;
853
+ if (rows.length === 0) throw new WorkflowLeaseLostError();
854
+ };
855
+
856
+ export type WorkflowAttentionResolution =
857
+ | { state: "succeeded"; output?: WorkflowJsonValue }
858
+ | { state: "failed"; message: string; code?: string };
859
+
860
+ /**
861
+ * Resolves the one effect a run stopped for.
862
+ *
863
+ * The run must already be terminal needs_attention, so this cannot race a
864
+ * worker. Confirming success records the step outcome and re-queues the rest
865
+ * of the immutable plan; confirming failure settles both step and run.
866
+ */
867
+ export const resolveWorkflowRunAttention = async (
868
+ input: { runId: string; stepKey: string; resolution: WorkflowAttentionResolution },
869
+ options: { db?: SQL } = {},
870
+ ): Promise<void> => {
871
+ await withTransaction(options.db, async (tx) => {
872
+ const [row] = await tx<{ effect_state: string | null }[]>`
873
+ SELECT s.effect_state
874
+ FROM workflows.run AS r
875
+ JOIN workflows.step_outcome AS s ON s.run_id = r.id
876
+ WHERE r.id = ${input.runId}::uuid
877
+ AND r.state = 'needs_attention'
878
+ AND s.step_key = ${input.stepKey}
879
+ AND s.state = 'needs_attention'
880
+ AND s.effect_state IN ('executing', 'ambiguous')
881
+ FOR UPDATE OF r, s
882
+ `;
883
+ if (!row) throw new Error("workflow run is not awaiting resolution for that step");
884
+
885
+ if (input.resolution.state === "succeeded") {
886
+ const output = input.resolution.output ?? null;
887
+ await tx`
888
+ UPDATE workflows.step_outcome
889
+ SET state = 'completed',
890
+ outcome = ${{
891
+ mode: "execute",
892
+ outcome: { state: "completed", output },
893
+ }},
894
+ dependency = NULL,
895
+ effect_state = 'succeeded',
896
+ effect_output = ${output},
897
+ finished_at = now(),
898
+ updated_at = now()
899
+ WHERE run_id = ${input.runId}::uuid AND step_key = ${input.stepKey}
900
+ `;
901
+ // Older alpha workers journaled propagated control wrappers as attention
902
+ // outcomes. They are incomplete traversal state, not effects, so let the
903
+ // resumed run recompute only those wrappers.
904
+ await tx`
905
+ DELETE FROM workflows.step_outcome
906
+ WHERE run_id = ${input.runId}::uuid
907
+ AND step_key <> ${input.stepKey}
908
+ AND state = 'needs_attention'
909
+ AND effect_key IS NULL
910
+ `;
911
+ await tx`
912
+ UPDATE workflows.run
913
+ SET state = 'queued',
914
+ error = NULL,
915
+ result = NULL,
916
+ result_message = NULL,
917
+ retry_after = NULL,
918
+ consecutive_failures = 0,
919
+ finished_at = NULL,
920
+ updated_at = now()
921
+ WHERE id = ${input.runId}::uuid
922
+ `;
923
+ return;
924
+ }
925
+
926
+ const error = {
927
+ code: input.resolution.code ?? "WORKFLOW_EFFECT_CONFIRMED_FAILED",
928
+ message: input.resolution.message,
929
+ retryable: false,
930
+ };
931
+ await tx`
932
+ UPDATE workflows.step_outcome
933
+ SET state = 'failed',
934
+ outcome = ${{ mode: "execute", outcome: { state: "failed", error } }},
935
+ dependency = NULL,
936
+ effect_state = 'failed',
937
+ finished_at = now(),
938
+ updated_at = now()
939
+ WHERE run_id = ${input.runId}::uuid AND step_key = ${input.stepKey}
940
+ `;
941
+ await tx`
942
+ UPDATE workflows.run
943
+ SET state = 'failed', error = ${error}, finished_at = now(), updated_at = now()
944
+ WHERE id = ${input.runId}::uuid
945
+ `;
946
+ });
947
+ };
948
+
949
+ /** What an earlier attempt recorded about this step's effect, if anything. */
950
+ export const readWorkflowEffect = async (
951
+ step: { runId: string; key: string },
952
+ options: { db?: SQL } = {},
953
+ ): Promise<{ key: string; state: string; output: WorkflowJsonValue } | null> => {
954
+ const db = options.db ?? sql;
955
+ const [row] = await db<{ effect_key: string | null; effect_state: string | null; effect_output: WorkflowJsonValue }[]>`
956
+ SELECT effect_key, effect_state, effect_output FROM workflows.step_outcome
957
+ WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
958
+ `;
959
+ return row?.effect_key && row.effect_state ? { key: row.effect_key, state: row.effect_state, output: row.effect_output } : null;
960
+ };
961
+
962
+ /**
963
+ * Whether a replay may re-run this step's effect.
964
+ *
965
+ * An effect left `executing` or `ambiguous` by an earlier attempt escaped
966
+ * without telling us whether it landed. Repeating it is how a workflow sends
967
+ * the same message twice, so the answer is no and a human decides.
968
+ */
969
+ export const isWorkflowEffectReplayable = async (step: { runId: string; key: string }, options: { db?: SQL } = {}): Promise<boolean> => {
970
+ const db = options.db ?? sql;
971
+ const [row] = await db<{ effect_state: string | null }[]>`
972
+ SELECT effect_state FROM workflows.step_outcome WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
973
+ `;
974
+ return row?.effect_state !== "executing" && row?.effect_state !== "ambiguous";
975
+ };