@smthrs/control 0.0.0-stage → 1.0.0-rc.3

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 (418) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -2
  4. package/dist/cjs/ApprovalAuthority.d.ts +73 -0
  5. package/dist/cjs/ApprovalAuthority.d.ts.map +1 -0
  6. package/dist/cjs/ApprovalAuthority.js +62 -0
  7. package/dist/cjs/ApprovalAuthority.js.map +7 -0
  8. package/dist/cjs/Cancellation.d.ts +107 -0
  9. package/dist/cjs/Cancellation.d.ts.map +1 -0
  10. package/dist/cjs/Cancellation.js +72 -0
  11. package/dist/cjs/Cancellation.js.map +7 -0
  12. package/dist/cjs/Channels.d.ts +170 -0
  13. package/dist/cjs/Channels.d.ts.map +1 -0
  14. package/dist/cjs/Channels.js +278 -0
  15. package/dist/cjs/Channels.js.map +7 -0
  16. package/dist/cjs/Control.d.ts +202 -0
  17. package/dist/cjs/Control.d.ts.map +1 -0
  18. package/dist/cjs/Control.js +47 -0
  19. package/dist/cjs/Control.js.map +7 -0
  20. package/dist/cjs/ControlClient.d.ts +52 -0
  21. package/dist/cjs/ControlClient.d.ts.map +1 -0
  22. package/dist/cjs/ControlClient.js +191 -0
  23. package/dist/cjs/ControlClient.js.map +7 -0
  24. package/dist/cjs/ControlError.d.ts +318 -0
  25. package/dist/cjs/ControlError.d.ts.map +1 -0
  26. package/dist/cjs/ControlError.js +249 -0
  27. package/dist/cjs/ControlError.js.map +7 -0
  28. package/dist/cjs/ControlExecutor.d.ts +372 -0
  29. package/dist/cjs/ControlExecutor.d.ts.map +1 -0
  30. package/dist/cjs/ControlExecutor.js +123 -0
  31. package/dist/cjs/ControlExecutor.js.map +7 -0
  32. package/dist/cjs/ControlFacts.d.ts +454 -0
  33. package/dist/cjs/ControlFacts.d.ts.map +1 -0
  34. package/dist/cjs/ControlFacts.js +261 -0
  35. package/dist/cjs/ControlFacts.js.map +7 -0
  36. package/dist/cjs/ControlLive.d.ts +23 -0
  37. package/dist/cjs/ControlLive.d.ts.map +1 -0
  38. package/dist/cjs/ControlLive.js +1280 -0
  39. package/dist/cjs/ControlLive.js.map +7 -0
  40. package/dist/cjs/ControlRpcs.d.ts +1204 -0
  41. package/dist/cjs/ControlRpcs.d.ts.map +1 -0
  42. package/dist/cjs/ControlRpcs.js +247 -0
  43. package/dist/cjs/ControlRpcs.js.map +7 -0
  44. package/dist/cjs/ControlRuntime.d.ts +635 -0
  45. package/dist/cjs/ControlRuntime.d.ts.map +1 -0
  46. package/dist/cjs/ControlRuntime.js +740 -0
  47. package/dist/cjs/ControlRuntime.js.map +7 -0
  48. package/dist/cjs/ControlSchema.d.ts +2642 -0
  49. package/dist/cjs/ControlSchema.d.ts.map +1 -0
  50. package/dist/cjs/ControlSchema.js +634 -0
  51. package/dist/cjs/ControlSchema.js.map +7 -0
  52. package/dist/cjs/ControlServer.d.ts +51 -0
  53. package/dist/cjs/ControlServer.d.ts.map +1 -0
  54. package/dist/cjs/ControlServer.js +121 -0
  55. package/dist/cjs/ControlServer.js.map +7 -0
  56. package/dist/cjs/Credential.d.ts +136 -0
  57. package/dist/cjs/Credential.d.ts.map +1 -0
  58. package/dist/cjs/Credential.js +168 -0
  59. package/dist/cjs/Credential.js.map +7 -0
  60. package/dist/cjs/CredentialCipher.d.ts +90 -0
  61. package/dist/cjs/CredentialCipher.d.ts.map +1 -0
  62. package/dist/cjs/CredentialCipher.js +45 -0
  63. package/dist/cjs/CredentialCipher.js.map +7 -0
  64. package/dist/cjs/CredentialStore.d.ts +97 -0
  65. package/dist/cjs/CredentialStore.d.ts.map +1 -0
  66. package/dist/cjs/CredentialStore.js +81 -0
  67. package/dist/cjs/CredentialStore.js.map +7 -0
  68. package/dist/cjs/DispatchReader.d.ts +112 -0
  69. package/dist/cjs/DispatchReader.d.ts.map +1 -0
  70. package/dist/cjs/DispatchReader.js +45 -0
  71. package/dist/cjs/DispatchReader.js.map +7 -0
  72. package/dist/cjs/Health.d.ts +333 -0
  73. package/dist/cjs/Health.d.ts.map +1 -0
  74. package/dist/cjs/Health.js +311 -0
  75. package/dist/cjs/Health.js.map +7 -0
  76. package/dist/cjs/JevSessionChecker.d.ts +57 -0
  77. package/dist/cjs/JevSessionChecker.d.ts.map +1 -0
  78. package/dist/cjs/JevSessionChecker.js +113 -0
  79. package/dist/cjs/JevSessionChecker.js.map +7 -0
  80. package/dist/cjs/Lineage.d.ts +131 -0
  81. package/dist/cjs/Lineage.d.ts.map +1 -0
  82. package/dist/cjs/Lineage.js +81 -0
  83. package/dist/cjs/Lineage.js.map +7 -0
  84. package/dist/cjs/Migrations.d.ts +34 -0
  85. package/dist/cjs/Migrations.d.ts.map +1 -0
  86. package/dist/cjs/Migrations.js +60 -0
  87. package/dist/cjs/Migrations.js.map +7 -0
  88. package/dist/cjs/Monitor.d.ts +282 -0
  89. package/dist/cjs/Monitor.d.ts.map +1 -0
  90. package/dist/cjs/Monitor.js +283 -0
  91. package/dist/cjs/Monitor.js.map +7 -0
  92. package/dist/cjs/ScopedToken.d.ts +193 -0
  93. package/dist/cjs/ScopedToken.d.ts.map +1 -0
  94. package/dist/cjs/ScopedToken.js +135 -0
  95. package/dist/cjs/ScopedToken.js.map +7 -0
  96. package/dist/cjs/SqlControlRuntime.d.ts +161 -0
  97. package/dist/cjs/SqlControlRuntime.d.ts.map +1 -0
  98. package/dist/cjs/SqlControlRuntime.js +1521 -0
  99. package/dist/cjs/SqlControlRuntime.js.map +7 -0
  100. package/dist/cjs/SqlCredentialStore.d.ts +43 -0
  101. package/dist/cjs/SqlCredentialStore.d.ts.map +1 -0
  102. package/dist/cjs/SqlCredentialStore.js +113 -0
  103. package/dist/cjs/SqlCredentialStore.js.map +7 -0
  104. package/dist/cjs/Steering.d.ts +69 -0
  105. package/dist/cjs/Steering.d.ts.map +1 -0
  106. package/dist/cjs/Steering.js +49 -0
  107. package/dist/cjs/Steering.js.map +7 -0
  108. package/dist/cjs/SystemFlows.d.ts +223 -0
  109. package/dist/cjs/SystemFlows.d.ts.map +1 -0
  110. package/dist/cjs/SystemFlows.js +195 -0
  111. package/dist/cjs/SystemFlows.js.map +7 -0
  112. package/dist/cjs/WebCryptoCipher.d.ts +49 -0
  113. package/dist/cjs/WebCryptoCipher.d.ts.map +1 -0
  114. package/dist/cjs/WebCryptoCipher.js +129 -0
  115. package/dist/cjs/WebCryptoCipher.js.map +7 -0
  116. package/dist/cjs/WebhookChannel.d.ts +113 -0
  117. package/dist/cjs/WebhookChannel.d.ts.map +1 -0
  118. package/dist/cjs/WebhookChannel.js +98 -0
  119. package/dist/cjs/WebhookChannel.js.map +7 -0
  120. package/dist/cjs/index.d.ts +160 -0
  121. package/dist/cjs/index.d.ts.map +1 -0
  122. package/dist/cjs/index.js +91 -0
  123. package/dist/cjs/index.js.map +7 -0
  124. package/dist/cjs/internal/MutationBoundary.d.ts +27 -0
  125. package/dist/cjs/internal/MutationBoundary.d.ts.map +1 -0
  126. package/dist/cjs/internal/MutationBoundary.js +50 -0
  127. package/dist/cjs/internal/MutationBoundary.js.map +7 -0
  128. package/dist/cjs/internal/activeFibers.d.ts +12 -0
  129. package/dist/cjs/internal/activeFibers.d.ts.map +1 -0
  130. package/dist/cjs/internal/activeFibers.js +30 -0
  131. package/dist/cjs/internal/activeFibers.js.map +7 -0
  132. package/dist/cjs/internal/issues.d.ts +28 -0
  133. package/dist/cjs/internal/issues.d.ts.map +1 -0
  134. package/dist/cjs/internal/issues.js +34 -0
  135. package/dist/cjs/internal/issues.js.map +7 -0
  136. package/dist/cjs/internal/planning.d.ts +347 -0
  137. package/dist/cjs/internal/planning.d.ts.map +1 -0
  138. package/dist/cjs/internal/planning.js +137 -0
  139. package/dist/cjs/internal/planning.js.map +7 -0
  140. package/dist/cjs/internal/sqlSchemaErrors.d.ts +18 -0
  141. package/dist/cjs/internal/sqlSchemaErrors.d.ts.map +1 -0
  142. package/dist/cjs/internal/sqlSchemaErrors.js +40 -0
  143. package/dist/cjs/internal/sqlSchemaErrors.js.map +7 -0
  144. package/dist/cjs/migrations/0001_control_tables.d.ts +18 -0
  145. package/dist/cjs/migrations/0001_control_tables.d.ts.map +1 -0
  146. package/dist/cjs/migrations/0001_control_tables.js +115 -0
  147. package/dist/cjs/migrations/0001_control_tables.js.map +7 -0
  148. package/dist/cjs/migrations/0002_run_keys.d.ts +15 -0
  149. package/dist/cjs/migrations/0002_run_keys.d.ts.map +1 -0
  150. package/dist/cjs/migrations/0002_run_keys.js +44 -0
  151. package/dist/cjs/migrations/0002_run_keys.js.map +7 -0
  152. package/dist/cjs/migrations/0003_signal_commands.d.ts +13 -0
  153. package/dist/cjs/migrations/0003_signal_commands.d.ts.map +1 -0
  154. package/dist/cjs/migrations/0003_signal_commands.js +50 -0
  155. package/dist/cjs/migrations/0003_signal_commands.js.map +7 -0
  156. package/dist/cjs/migrations/0004_approval_decisions.d.ts +13 -0
  157. package/dist/cjs/migrations/0004_approval_decisions.d.ts.map +1 -0
  158. package/dist/cjs/migrations/0004_approval_decisions.js +45 -0
  159. package/dist/cjs/migrations/0004_approval_decisions.js.map +7 -0
  160. package/dist/cjs/migrations/0005_signal_principals.d.ts +18 -0
  161. package/dist/cjs/migrations/0005_signal_principals.d.ts.map +1 -0
  162. package/dist/cjs/migrations/0005_signal_principals.js +45 -0
  163. package/dist/cjs/migrations/0005_signal_principals.js.map +7 -0
  164. package/dist/cjs/migrations/0006_run_principals.d.ts +18 -0
  165. package/dist/cjs/migrations/0006_run_principals.d.ts.map +1 -0
  166. package/dist/cjs/migrations/0006_run_principals.js +49 -0
  167. package/dist/cjs/migrations/0006_run_principals.js.map +7 -0
  168. package/dist/cjs/migrations/0007_resume_consent.d.ts +18 -0
  169. package/dist/cjs/migrations/0007_resume_consent.d.ts.map +1 -0
  170. package/dist/cjs/migrations/0007_resume_consent.js +46 -0
  171. package/dist/cjs/migrations/0007_resume_consent.js.map +7 -0
  172. package/dist/cjs/package.json +1 -0
  173. package/dist/cjs/test/TestControl.d.ts +19 -0
  174. package/dist/cjs/test/TestControl.d.ts.map +1 -0
  175. package/dist/cjs/test/TestControl.js +62 -0
  176. package/dist/cjs/test/TestControl.js.map +7 -0
  177. package/dist/esm/ApprovalAuthority.d.ts +73 -0
  178. package/dist/esm/ApprovalAuthority.d.ts.map +1 -0
  179. package/dist/esm/ApprovalAuthority.js +72 -0
  180. package/dist/esm/ApprovalAuthority.js.map +1 -0
  181. package/dist/esm/Cancellation.d.ts +107 -0
  182. package/dist/esm/Cancellation.d.ts.map +1 -0
  183. package/dist/esm/Cancellation.js +116 -0
  184. package/dist/esm/Cancellation.js.map +1 -0
  185. package/dist/esm/Channels.d.ts +170 -0
  186. package/dist/esm/Channels.d.ts.map +1 -0
  187. package/dist/esm/Channels.js +312 -0
  188. package/dist/esm/Channels.js.map +1 -0
  189. package/dist/esm/Control.d.ts +202 -0
  190. package/dist/esm/Control.d.ts.map +1 -0
  191. package/dist/esm/Control.js +42 -0
  192. package/dist/esm/Control.js.map +1 -0
  193. package/dist/esm/ControlClient.d.ts +52 -0
  194. package/dist/esm/ControlClient.d.ts.map +1 -0
  195. package/dist/esm/ControlClient.js +217 -0
  196. package/dist/esm/ControlClient.js.map +1 -0
  197. package/dist/esm/ControlError.d.ts +318 -0
  198. package/dist/esm/ControlError.d.ts.map +1 -0
  199. package/dist/esm/ControlError.js +359 -0
  200. package/dist/esm/ControlError.js.map +1 -0
  201. package/dist/esm/ControlExecutor.d.ts +372 -0
  202. package/dist/esm/ControlExecutor.d.ts.map +1 -0
  203. package/dist/esm/ControlExecutor.js +212 -0
  204. package/dist/esm/ControlExecutor.js.map +1 -0
  205. package/dist/esm/ControlFacts.d.ts +454 -0
  206. package/dist/esm/ControlFacts.d.ts.map +1 -0
  207. package/dist/esm/ControlFacts.js +324 -0
  208. package/dist/esm/ControlFacts.js.map +1 -0
  209. package/dist/esm/ControlLive.d.ts +23 -0
  210. package/dist/esm/ControlLive.d.ts.map +1 -0
  211. package/dist/esm/ControlLive.js +1585 -0
  212. package/dist/esm/ControlLive.js.map +1 -0
  213. package/dist/esm/ControlRpcs.d.ts +1204 -0
  214. package/dist/esm/ControlRpcs.d.ts.map +1 -0
  215. package/dist/esm/ControlRpcs.js +299 -0
  216. package/dist/esm/ControlRpcs.js.map +1 -0
  217. package/dist/esm/ControlRuntime.d.ts +635 -0
  218. package/dist/esm/ControlRuntime.d.ts.map +1 -0
  219. package/dist/esm/ControlRuntime.js +807 -0
  220. package/dist/esm/ControlRuntime.js.map +1 -0
  221. package/dist/esm/ControlSchema.d.ts +2642 -0
  222. package/dist/esm/ControlSchema.d.ts.map +1 -0
  223. package/dist/esm/ControlSchema.js +1030 -0
  224. package/dist/esm/ControlSchema.js.map +1 -0
  225. package/dist/esm/ControlServer.d.ts +51 -0
  226. package/dist/esm/ControlServer.d.ts.map +1 -0
  227. package/dist/esm/ControlServer.js +145 -0
  228. package/dist/esm/ControlServer.js.map +1 -0
  229. package/dist/esm/Credential.d.ts +136 -0
  230. package/dist/esm/Credential.d.ts.map +1 -0
  231. package/dist/esm/Credential.js +190 -0
  232. package/dist/esm/Credential.js.map +1 -0
  233. package/dist/esm/CredentialCipher.d.ts +90 -0
  234. package/dist/esm/CredentialCipher.d.ts.map +1 -0
  235. package/dist/esm/CredentialCipher.js +56 -0
  236. package/dist/esm/CredentialCipher.js.map +1 -0
  237. package/dist/esm/CredentialStore.d.ts +97 -0
  238. package/dist/esm/CredentialStore.d.ts.map +1 -0
  239. package/dist/esm/CredentialStore.js +101 -0
  240. package/dist/esm/CredentialStore.js.map +1 -0
  241. package/dist/esm/DispatchReader.d.ts +112 -0
  242. package/dist/esm/DispatchReader.d.ts.map +1 -0
  243. package/dist/esm/DispatchReader.js +76 -0
  244. package/dist/esm/DispatchReader.js.map +1 -0
  245. package/dist/esm/Health.d.ts +333 -0
  246. package/dist/esm/Health.d.ts.map +1 -0
  247. package/dist/esm/Health.js +400 -0
  248. package/dist/esm/Health.js.map +1 -0
  249. package/dist/esm/JevSessionChecker.d.ts +57 -0
  250. package/dist/esm/JevSessionChecker.d.ts.map +1 -0
  251. package/dist/esm/JevSessionChecker.js +108 -0
  252. package/dist/esm/JevSessionChecker.js.map +1 -0
  253. package/dist/esm/Lineage.d.ts +131 -0
  254. package/dist/esm/Lineage.d.ts.map +1 -0
  255. package/dist/esm/Lineage.js +174 -0
  256. package/dist/esm/Lineage.js.map +1 -0
  257. package/dist/esm/Migrations.d.ts +34 -0
  258. package/dist/esm/Migrations.d.ts.map +1 -0
  259. package/dist/esm/Migrations.js +53 -0
  260. package/dist/esm/Migrations.js.map +1 -0
  261. package/dist/esm/Monitor.d.ts +282 -0
  262. package/dist/esm/Monitor.d.ts.map +1 -0
  263. package/dist/esm/Monitor.js +415 -0
  264. package/dist/esm/Monitor.js.map +1 -0
  265. package/dist/esm/ScopedToken.d.ts +193 -0
  266. package/dist/esm/ScopedToken.d.ts.map +1 -0
  267. package/dist/esm/ScopedToken.js +224 -0
  268. package/dist/esm/ScopedToken.js.map +1 -0
  269. package/dist/esm/SqlControlRuntime.d.ts +161 -0
  270. package/dist/esm/SqlControlRuntime.d.ts.map +1 -0
  271. package/dist/esm/SqlControlRuntime.js +1756 -0
  272. package/dist/esm/SqlControlRuntime.js.map +1 -0
  273. package/dist/esm/SqlCredentialStore.d.ts +43 -0
  274. package/dist/esm/SqlCredentialStore.d.ts.map +1 -0
  275. package/dist/esm/SqlCredentialStore.js +97 -0
  276. package/dist/esm/SqlCredentialStore.js.map +1 -0
  277. package/dist/esm/Steering.d.ts +69 -0
  278. package/dist/esm/Steering.d.ts.map +1 -0
  279. package/dist/esm/Steering.js +89 -0
  280. package/dist/esm/Steering.js.map +1 -0
  281. package/dist/esm/SystemFlows.d.ts +223 -0
  282. package/dist/esm/SystemFlows.d.ts.map +1 -0
  283. package/dist/esm/SystemFlows.js +198 -0
  284. package/dist/esm/SystemFlows.js.map +1 -0
  285. package/dist/esm/WebCryptoCipher.d.ts +49 -0
  286. package/dist/esm/WebCryptoCipher.d.ts.map +1 -0
  287. package/dist/esm/WebCryptoCipher.js +123 -0
  288. package/dist/esm/WebCryptoCipher.js.map +1 -0
  289. package/dist/esm/WebhookChannel.d.ts +113 -0
  290. package/dist/esm/WebhookChannel.d.ts.map +1 -0
  291. package/dist/esm/WebhookChannel.js +109 -0
  292. package/dist/esm/WebhookChannel.js.map +1 -0
  293. package/dist/esm/index.d.ts +160 -0
  294. package/dist/esm/index.d.ts.map +1 -0
  295. package/dist/esm/index.js +160 -0
  296. package/dist/esm/index.js.map +1 -0
  297. package/dist/esm/internal/MutationBoundary.d.ts +27 -0
  298. package/dist/esm/internal/MutationBoundary.d.ts.map +1 -0
  299. package/dist/esm/internal/MutationBoundary.js +40 -0
  300. package/dist/esm/internal/MutationBoundary.js.map +1 -0
  301. package/dist/esm/internal/activeFibers.d.ts +12 -0
  302. package/dist/esm/internal/activeFibers.d.ts.map +1 -0
  303. package/dist/esm/internal/activeFibers.js +17 -0
  304. package/dist/esm/internal/activeFibers.js.map +1 -0
  305. package/dist/esm/internal/issues.d.ts +28 -0
  306. package/dist/esm/internal/issues.d.ts.map +1 -0
  307. package/dist/esm/internal/issues.js +35 -0
  308. package/dist/esm/internal/issues.js.map +1 -0
  309. package/dist/esm/internal/planning.d.ts +347 -0
  310. package/dist/esm/internal/planning.d.ts.map +1 -0
  311. package/dist/esm/internal/planning.js +199 -0
  312. package/dist/esm/internal/planning.js.map +1 -0
  313. package/dist/esm/internal/sqlSchemaErrors.d.ts +18 -0
  314. package/dist/esm/internal/sqlSchemaErrors.d.ts.map +1 -0
  315. package/dist/esm/internal/sqlSchemaErrors.js +38 -0
  316. package/dist/esm/internal/sqlSchemaErrors.js.map +1 -0
  317. package/dist/esm/migrations/0001_control_tables.d.ts +18 -0
  318. package/dist/esm/migrations/0001_control_tables.d.ts.map +1 -0
  319. package/dist/esm/migrations/0001_control_tables.js +96 -0
  320. package/dist/esm/migrations/0001_control_tables.js.map +1 -0
  321. package/dist/esm/migrations/0002_run_keys.d.ts +15 -0
  322. package/dist/esm/migrations/0002_run_keys.d.ts.map +1 -0
  323. package/dist/esm/migrations/0002_run_keys.js +22 -0
  324. package/dist/esm/migrations/0002_run_keys.js.map +1 -0
  325. package/dist/esm/migrations/0003_signal_commands.d.ts +13 -0
  326. package/dist/esm/migrations/0003_signal_commands.d.ts.map +1 -0
  327. package/dist/esm/migrations/0003_signal_commands.js +26 -0
  328. package/dist/esm/migrations/0003_signal_commands.js.map +1 -0
  329. package/dist/esm/migrations/0004_approval_decisions.d.ts +13 -0
  330. package/dist/esm/migrations/0004_approval_decisions.d.ts.map +1 -0
  331. package/dist/esm/migrations/0004_approval_decisions.js +25 -0
  332. package/dist/esm/migrations/0004_approval_decisions.js.map +1 -0
  333. package/dist/esm/migrations/0005_signal_principals.d.ts +18 -0
  334. package/dist/esm/migrations/0005_signal_principals.d.ts.map +1 -0
  335. package/dist/esm/migrations/0005_signal_principals.js +27 -0
  336. package/dist/esm/migrations/0005_signal_principals.js.map +1 -0
  337. package/dist/esm/migrations/0006_run_principals.d.ts +18 -0
  338. package/dist/esm/migrations/0006_run_principals.d.ts.map +1 -0
  339. package/dist/esm/migrations/0006_run_principals.js +30 -0
  340. package/dist/esm/migrations/0006_run_principals.js.map +1 -0
  341. package/dist/esm/migrations/0007_resume_consent.d.ts +18 -0
  342. package/dist/esm/migrations/0007_resume_consent.d.ts.map +1 -0
  343. package/dist/esm/migrations/0007_resume_consent.js +27 -0
  344. package/dist/esm/migrations/0007_resume_consent.js.map +1 -0
  345. package/dist/esm/test/TestControl.d.ts +19 -0
  346. package/dist/esm/test/TestControl.d.ts.map +1 -0
  347. package/dist/esm/test/TestControl.js +30 -0
  348. package/dist/esm/test/TestControl.js.map +1 -0
  349. package/docs/README.md +189 -0
  350. package/docs/api.md +982 -0
  351. package/docs/concepts/authority.md +109 -0
  352. package/docs/concepts/cancellation.md +129 -0
  353. package/docs/concepts/lineage.md +132 -0
  354. package/docs/concepts/ownership.md +139 -0
  355. package/docs/concepts/projections.md +203 -0
  356. package/docs/concepts/receipts.md +128 -0
  357. package/docs/guides/approvals.md +284 -0
  358. package/docs/guides/cancel-and-resume.md +162 -0
  359. package/docs/guides/durable-storage.md +147 -0
  360. package/docs/guides/implement-an-executor.md +173 -0
  361. package/docs/guides/ingest-a-webhook.md +177 -0
  362. package/docs/guides/list-runs.md +160 -0
  363. package/docs/guides/monitor-runs.md +176 -0
  364. package/docs/guides/observe-health.md +147 -0
  365. package/docs/guides/postgres-tests.md +5 -0
  366. package/docs/guides/serve-over-rpc.md +220 -0
  367. package/docs/guides/signal-a-run.md +53 -0
  368. package/docs/guides/steer-a-run.md +138 -0
  369. package/docs/guides/store-credentials.md +164 -0
  370. package/docs/guides/testing.md +139 -0
  371. package/docs/guides/watch-a-run.md +154 -0
  372. package/docs/installation.md +106 -0
  373. package/docs/quickstart.md +163 -0
  374. package/docs/troubleshooting.md +208 -0
  375. package/package.json +405 -3
  376. package/src/ApprovalAuthority.ts +114 -0
  377. package/src/Cancellation.ts +172 -0
  378. package/src/Channels.ts +493 -0
  379. package/src/Control.ts +337 -0
  380. package/src/ControlClient.ts +319 -0
  381. package/src/ControlError.ts +378 -0
  382. package/src/ControlExecutor.ts +490 -0
  383. package/src/ControlFacts.ts +383 -0
  384. package/src/ControlLive.ts +2113 -0
  385. package/src/ControlRpcs.ts +443 -0
  386. package/src/ControlRuntime.ts +1597 -0
  387. package/src/ControlSchema.ts +1380 -0
  388. package/src/ControlServer.ts +182 -0
  389. package/src/Credential.ts +310 -0
  390. package/src/CredentialCipher.ts +110 -0
  391. package/src/CredentialStore.ts +152 -0
  392. package/src/DispatchReader.ts +122 -0
  393. package/src/Health.ts +591 -0
  394. package/src/JevSessionChecker.ts +127 -0
  395. package/src/Lineage.ts +203 -0
  396. package/src/Migrations.ts +56 -0
  397. package/src/Monitor.ts +600 -0
  398. package/src/ScopedToken.ts +306 -0
  399. package/src/SqlControlRuntime.ts +2476 -0
  400. package/src/SqlCredentialStore.ts +148 -0
  401. package/src/Steering.ts +96 -0
  402. package/src/SystemFlows.ts +225 -0
  403. package/src/WebCryptoCipher.ts +169 -0
  404. package/src/WebhookChannel.ts +166 -0
  405. package/src/index.ts +188 -0
  406. package/src/internal/MutationBoundary.ts +46 -0
  407. package/src/internal/activeFibers.ts +22 -0
  408. package/src/internal/issues.ts +40 -0
  409. package/src/internal/planning.ts +262 -0
  410. package/src/internal/sqlSchemaErrors.ts +37 -0
  411. package/src/migrations/0001_control_tables.ts +99 -0
  412. package/src/migrations/0002_run_keys.ts +23 -0
  413. package/src/migrations/0003_signal_commands.ts +27 -0
  414. package/src/migrations/0004_approval_decisions.ts +25 -0
  415. package/src/migrations/0005_signal_principals.ts +27 -0
  416. package/src/migrations/0006_run_principals.ts +31 -0
  417. package/src/migrations/0007_resume_consent.ts +28 -0
  418. package/src/test/TestControl.ts +47 -0
@@ -0,0 +1,2476 @@
1
+ /**
2
+ * Durable `ControlRuntime` over `@smthrs/journal`'s fenced `RunStore` and a
3
+ * SQL database.
4
+ *
5
+ * `layerMemory` models the production seams but keeps everything in a `Map`, so
6
+ * nothing it decides survives the process. This adapter supplies the durable
7
+ * boundary the header of `ControlRuntime` describes.
8
+ *
9
+ * ## Where ownership lives
10
+ *
11
+ * The run lifecycle is not re-implemented here. `RunStore` already owns it, and
12
+ * it is the piece that is hard to get right: every ownership move is a single
13
+ * SQL compare-and-swap, so a stale writer loses the `UPDATE` rather than
14
+ * racing a read-then-write. This module maps the control plane's vocabulary
15
+ * onto it:
16
+ *
17
+ * | control status | `RunStore` status | ownership |
18
+ * | --- | --- | --- |
19
+ * | `accepted`, `running` | `running` | held by this process |
20
+ * | `accepted` after an executor declines | `suspended` | released |
21
+ * | `parked`, `waiting-approval` | `suspended` | released |
22
+ * | `cancelled` / `completed` / `failed` | same | released, terminal |
23
+ *
24
+ * The authoritative `RunSummary` is written into the row's `state_json` by the
25
+ * same fenced `UPDATE` that moves the status, so a projection can never be read
26
+ * out of step with the lifecycle. `RunStore` has no list operation, so
27
+ * `control_runs` is kept as a plain id index and each summary is read back
28
+ * through the store rather than duplicated.
29
+ *
30
+ * ## Fences
31
+ *
32
+ * A fence is a serialized `OwnerId`. `hostId` and `pid` identify the process;
33
+ * the `nonce` is regenerated on **every** claim, so a fence taken before a
34
+ * park is not the fence held after the resume that follows it, and the stale
35
+ * one is refused by the CAS. This is the `rangeID`-style monotonic fence from
36
+ * `reference/temporal`'s history service, narrowed to a per-run token.
37
+ *
38
+ * ## Browser safety
39
+ *
40
+ * Nothing here imports `node:*`. Identity comes from `globalThis.crypto`, and
41
+ * persistence is the driver-neutral SQL contract, so this module runs anywhere
42
+ * `@smthrs/database` has a driver. Fibers are the exception and are
43
+ * deliberately process-local: a fiber is a live continuation, not a row, and
44
+ * cancellation is interruption of the fiber that this process owns.
45
+ *
46
+ * @since 0.1.0
47
+ */
48
+
49
+ import * as Dialect from "@smthrs/database/Dialect"
50
+ import { DurableWriter } from "@smthrs/database/DurableWriter"
51
+ import * as DurableWrites from "@smthrs/database/DurableWriter"
52
+ import { Ownership, RunStore } from "@smthrs/run-store"
53
+ import { Clock, Crypto, Effect, Fiber, Layer, Option, Schema } from "effect"
54
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
55
+ import type * as SqlError from "effect/unstable/sql/SqlError"
56
+ import * as ApprovalAuthority from "./ApprovalAuthority.ts"
57
+ import * as Attribution from "./Cancellation.ts"
58
+ import type { PlanInput } from "./Control.ts"
59
+ import {
60
+ AlreadyResolved,
61
+ ClaimLost,
62
+ type CodeDrift,
63
+ EnvelopeMismatch,
64
+ FlowNotFound,
65
+ InvalidInput,
66
+ PersistenceError,
67
+ PlanDenied,
68
+ PlanDigestMismatch,
69
+ PlanNotFound,
70
+ RunNotFound,
71
+ Unauthorized
72
+ } from "./ControlError.ts"
73
+ import * as ControlExecutor from "./ControlExecutor.ts"
74
+ import type {
75
+ ApprovalToken,
76
+ BulkGrant,
77
+ IdPage,
78
+ IdPageRequest,
79
+ LaunchResult,
80
+ MemoryFlow,
81
+ PlanPage,
82
+ PlanQuery,
83
+ RunCursor,
84
+ RunQuery,
85
+ Service,
86
+ StoredPlan
87
+ } from "./ControlRuntime.ts"
88
+ import { ApprovalDecision, ControlRuntime, idPageLimit, make } from "./ControlRuntime.ts"
89
+ import {
90
+ ApprovalTarget,
91
+ type Cancellation,
92
+ Envelope,
93
+ GrantScope,
94
+ type IdempotencyKey,
95
+ type PendingWait,
96
+ PlanCard,
97
+ PlanDecision,
98
+ Principal,
99
+ Receipt,
100
+ type RunId,
101
+ type RunStatus,
102
+ RunSummary,
103
+ SignalPayload
104
+ } from "./ControlSchema.ts"
105
+ import * as ActiveFibers from "./internal/activeFibers.ts"
106
+ import { canonicalIssue, cappedIssue, schemaIssuePath } from "./internal/issues.ts"
107
+ import {
108
+ accepted,
109
+ adoptedCode,
110
+ alreadyApplied,
111
+ budgeted,
112
+ canonical,
113
+ codeDriftOf,
114
+ emptyEnvelope,
115
+ planCard,
116
+ planFingerprint,
117
+ sameEnvelope
118
+ } from "./internal/planning.ts"
119
+ import { causeMessages, missingTable } from "./internal/sqlSchemaErrors.ts"
120
+ import * as Lineage from "./Lineage.ts"
121
+ import * as Migrations from "./Migrations.ts"
122
+ import { plannable } from "./SystemFlows.ts"
123
+
124
+ /**
125
+ * A flow the durable runtime can plan.
126
+ *
127
+ * The same shape the memory runtime takes, so a composition can hand the same
128
+ * catalog to either.
129
+ *
130
+ * @category models
131
+ * @since 0.1.0
132
+ */
133
+ export type DurableFlow = MemoryFlow
134
+
135
+ /**
136
+ * Durable runtime configuration.
137
+ *
138
+ * `owner` is the process identity every claim is stamped with. When omitted,
139
+ * one valid synthetic identity is minted for this runtime only, so separately
140
+ * constructed runtimes cannot cross each other's fences. Hosts that can
141
+ * report their real process identity should still supply it so liveness probes
142
+ * can reason about the operating-system process.
143
+ *
144
+ * @category models
145
+ * @since 0.1.0
146
+ */
147
+ export interface Options {
148
+ readonly flows?: ReadonlyArray<DurableFlow> | undefined
149
+ /**
150
+ * Reads the current flow catalog for each plan and listing. When supplied,
151
+ * this replaces `flows`, including the default system catalog. A host with
152
+ * a refreshable registry should return one complete snapshot per call.
153
+ * Existing plans retain the definition they were approved against.
154
+ */
155
+ readonly loadFlows?: (() => Effect.Effect<ReadonlyArray<DurableFlow>, PersistenceError>) | undefined
156
+ /**
157
+ * Reads each flow's code as it is on disk now, for the drift check that runs
158
+ * before a run is re-driven and for the code an allowed drift adopts. A host
159
+ * whose `loadFlows` answers from a cached snapshot supplies this, so a flow
160
+ * edited or deleted after the run parked is refused before the claim rather
161
+ * than found by the executor after it. Defaults to `loadFlows`.
162
+ */
163
+ readonly currentFlows?: (() => Effect.Effect<ReadonlyArray<DurableFlow>, PersistenceError>) | undefined
164
+ /** Verifies a durable pinned source closure before consenting to resume it. */
165
+ readonly pinnedFlow?:
166
+ | ((flowId: string, digest: string) => Effect.Effect<boolean | undefined, PersistenceError>)
167
+ | undefined
168
+ /**
169
+ * Makes one flow's code on disk now the code this host executes, for a
170
+ * resume the operator allowed to drift, and answers the identity the host
171
+ * can now run, or `undefined` when it can run none. A host whose executor
172
+ * serves a loaded snapshot supplies this: recording a digest its executor
173
+ * does not hold accepted the resume and then failed the run (#2740).
174
+ * Defaults to the flow's entry in `currentFlows`.
175
+ */
176
+ readonly adoptFlow?:
177
+ | ((flowId: string, runId: string) => Effect.Effect<DurableFlow | undefined, PersistenceError>)
178
+ | undefined
179
+ readonly owner?: Ownership.OwnerId | undefined
180
+ /**
181
+ * Whether the process a running run's owner names is still working. With
182
+ * it, `resume` takes over a running run whose owner is gone (a host killed
183
+ * mid-run) once that owner's lease has expired, instead of answering
184
+ * `ClaimLost`; the run store verifies the expired lease. Without it a
185
+ * running run belongs to its recorded owner for good.
186
+ */
187
+ readonly isAlive?: Ownership.LivenessCheck | undefined
188
+ readonly principal?: Omit<Principal, "stampedAt"> | undefined
189
+ readonly approvalAuthority?: ApprovalAuthority.Service | undefined
190
+ /** The engine version stamped on every run this runtime starts. */
191
+ readonly engineVersion?: string | undefined
192
+ }
193
+
194
+ const persistence = (operation: string) => (cause: unknown): PersistenceError =>
195
+ new PersistenceError({
196
+ operation,
197
+ message: `Control runtime failed to ${operation}`,
198
+ cause
199
+ })
200
+
201
+ const storedFailure = (location: string, path: string, reason: string): PersistenceError =>
202
+ new PersistenceError({
203
+ operation: `decode ${location}`,
204
+ message: `Control runtime could not decode ${location}: ${cappedIssue(path, reason)}`
205
+ })
206
+
207
+ const decodeStoredValue = <S extends Schema.Top>(
208
+ location: string,
209
+ schema: S,
210
+ value: unknown
211
+ ): Effect.Effect<S["Type"], PersistenceError, S["DecodingServices"]> =>
212
+ Schema.decodeUnknownEffect(schema)(value).pipe(
213
+ Effect.mapError((error) =>
214
+ storedFailure(location, schemaIssuePath(error), "stored value does not match its schema")
215
+ )
216
+ )
217
+
218
+ const decodeStoredJson = <S extends Schema.Top>(
219
+ location: string,
220
+ schema: S,
221
+ json: string
222
+ ): Effect.Effect<S["Type"], PersistenceError, S["DecodingServices"]> =>
223
+ Effect.try({
224
+ try: () => JSON.parse(json) as unknown,
225
+ catch: () => storedFailure(location, "$", "stored value is not valid JSON")
226
+ }).pipe(Effect.flatMap((value) => decodeStoredValue(location, schema, value)))
227
+
228
+ /** The admitting principal a signal command recorded, spread onto the command. */
229
+ /** `summary` with the launcher its run's launch index recorded, if any. */
230
+ const withLauncher = (
231
+ summary: RunSummary,
232
+ launcher: { readonly id: string; readonly kind: string } | undefined
233
+ ): RunSummary => launcher === undefined ? summary : { ...summary, launchedBy: { id: launcher.id, kind: launcher.kind } }
234
+
235
+ const signalPrincipal = (
236
+ json: string | null
237
+ ): Effect.Effect<{ readonly principal?: Principal }, PersistenceError> =>
238
+ json === null ? Effect.succeed({}) : Effect.map(
239
+ decodeStoredJson("control_signal_commands.principal_json", Principal, json),
240
+ (principal) => ({ principal })
241
+ )
242
+
243
+ /** A random identifier that does not depend on any Node API. */
244
+ const randomId = (): string => globalThis.crypto.randomUUID()
245
+
246
+ const terminal = (status: RunStatus): boolean => status === "cancelled" || status === "completed" || status === "failed"
247
+
248
+ /**
249
+ * The deepest nesting the wait walk climbs.
250
+ *
251
+ * It matches `@smthrs/engine-store` `waitingTreeMaxDepth` and exists for the
252
+ * same reason: authored nesting is a handful of executions deep, and the cap
253
+ * bounds the read of a tree whose edges were corrupted outside the engine.
254
+ */
255
+ const maxWaitTreeDepth = 64
256
+
257
+ /**
258
+ * Whether this database lacks the engine's wait-tree schema.
259
+ *
260
+ * Run-store installs the wait and trampoline columns before the engine
261
+ * installs spawn edges and `execution_flow`. A control-only database can
262
+ * lack the latter without making its ordinary run listings unreadable.
263
+ */
264
+ const missingWaitTreeSchema = (cause: unknown): boolean =>
265
+ missingTable("flows_runs")(cause) || missingTable("flows_run_parents")(cause) ||
266
+ causeMessages(cause).some((message) => message.includes("execution_flow"))
267
+
268
+ /**
269
+ * The question a park declared, as JSON, or nothing.
270
+ *
271
+ * The column is the wait's own text under a `json_valid` check, so text that
272
+ * does not parse is text nothing wrote: it reads as a park that declared no
273
+ * question rather than failing the listing it appears in.
274
+ */
275
+ const declaredRequest = (value: string | null): { readonly request?: typeof Schema.Json.Type } => {
276
+ if (value === null) return {}
277
+ try {
278
+ return { request: JSON.parse(value) as typeof Schema.Json.Type }
279
+ } catch {
280
+ return {}
281
+ }
282
+ }
283
+
284
+ /** The `RunStore` status the control plane's status projects onto. */
285
+ const storeStatus = (status: RunStatus): RunStore.RunStatus => {
286
+ switch (status) {
287
+ case "accepted":
288
+ case "running":
289
+ return "running"
290
+ case "parked":
291
+ case "waiting-approval":
292
+ return "suspended"
293
+ default:
294
+ return status
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Whether two identities are the same *process*.
300
+ *
301
+ * The nonce is deliberately excluded: it is the per-claim fence, so a process
302
+ * that re-claims a run has a new nonce but is still the same owner.
303
+ */
304
+ const sameProcess = (left: Ownership.OwnerId, right: Ownership.OwnerId): boolean =>
305
+ left.hostId === right.hostId && left.pid === right.pid
306
+
307
+ interface PlanRow {
308
+ readonly planId: string
309
+ readonly cardJson: string
310
+ readonly decodedInputJson: string
311
+ readonly decision: string
312
+ }
313
+
314
+ interface TokenRow {
315
+ readonly tokenId: string
316
+ readonly targetJson: string
317
+ readonly resolved: number
318
+ readonly decisionPrincipalJson: string | null
319
+ readonly decisionJson: string | null
320
+ }
321
+
322
+ interface ApprovalIdentity {
323
+ readonly targetTag: ApprovalTarget["_tag"]
324
+ readonly runId: string
325
+ readonly targetId: string
326
+ }
327
+
328
+ const approvalIdentity = (target: ApprovalTarget): ApprovalIdentity =>
329
+ target._tag === "Plan"
330
+ ? { targetTag: target._tag, runId: "", targetId: target.planId }
331
+ : { targetTag: target._tag, runId: target.runId, targetId: target.requestId }
332
+
333
+ const sameApprovalIdentity = (left: ApprovalTarget, right: ApprovalTarget): boolean =>
334
+ left._tag === "Plan"
335
+ ? right._tag === "Plan" && left.planId === right.planId
336
+ : right._tag === "Node" && left.runId === right.runId && left.requestId === right.requestId
337
+
338
+ const tokenFromRow = (
339
+ row: TokenRow,
340
+ target: ApprovalTarget
341
+ ): Effect.Effect<ApprovalToken, PersistenceError> =>
342
+ Effect.gen(function*() {
343
+ const decision = row.decisionJson === null
344
+ ? { _tag: "Pending" as const }
345
+ : yield* decodeStoredJson("control_tokens.decision_json", ApprovalDecision, row.decisionJson)
346
+ if (row.decisionJson === null && row.resolved === 1) {
347
+ return yield* new PersistenceError({
348
+ operation: "recover an approval decision",
349
+ message:
350
+ "This legacy approval erased its decision. Preserve the database for review; create a new run and request a new approval. It cannot safely resume through this gate."
351
+ })
352
+ }
353
+ const expectedPrincipal = decision._tag === "Pending" ? undefined : decision.decisionPrincipal
354
+ const storedPrincipal = row.decisionPrincipalJson === null
355
+ ? undefined
356
+ : yield* decodeStoredJson("control_tokens.decision_principal_json", Principal, row.decisionPrincipalJson)
357
+ if (
358
+ row.resolved !== (decision._tag === "Pending" ? 0 : 1) ||
359
+ JSON.stringify(storedPrincipal) !== JSON.stringify(expectedPrincipal)
360
+ ) {
361
+ return yield* storedFailure(
362
+ "control_tokens.decision_json",
363
+ "$",
364
+ "approval decision disagrees with its resolution"
365
+ )
366
+ }
367
+ return {
368
+ tokenId: row.tokenId,
369
+ target,
370
+ ...decision
371
+ }
372
+ })
373
+
374
+ /** Plans read per statement while a filtered plan page fills. */
375
+ const planScanBatch = 200
376
+
377
+ const CancelRequestPayload = Schema.Struct({
378
+ principal: Schema.optional(Principal),
379
+ reason: Schema.optional(Schema.String)
380
+ })
381
+
382
+ const EngineInterruptionPayload = Schema.Struct({
383
+ outcome: Schema.optional(Schema.String),
384
+ interruptedAtMs: Schema.optional(Schema.Number)
385
+ })
386
+
387
+ // Control only projects an engine-owned row's flow name. Importing the full
388
+ // engine state schema would add an engine-store runtime dependency, so this
389
+ // validates exactly the field this package reads and permits the remaining
390
+ // engine-owned fields to pass through the struct decoder unused.
391
+ const EngineStateProjection = Schema.Struct({ flowName: Schema.NonEmptyString })
392
+
393
+ /**
394
+ * Creates every control-plane table.
395
+ *
396
+ * `RunStore`'s own migrations are the journal package's business and are
397
+ * applied by its layer. A read-only client installs nothing.
398
+ *
399
+ * @category migrations
400
+ * @since 0.1.0
401
+ */
402
+ export const migrate: Effect.Effect<void, PersistenceError, SqlClient.SqlClient> = Effect.gen(function*() {
403
+ const sql = yield* SqlClient.SqlClient
404
+ // A read-only observer reads the schema it finds and installs nothing.
405
+ if (Dialect.isReadOnly(sql)) return
406
+ // A standalone runtime cannot record control's high-offset migration first:
407
+ // that high-water mark would make later journal and run-store sets look
408
+ // skipped. The idempotent bootstrap keeps standalone construction safe. The
409
+ // cross-package follow-up is for the host to compose `Migrations.set` beside
410
+ // the journal and run-store sets before it constructs any adapter.
411
+ // Reuse the canonical migration set and the shared transaction/retry policy,
412
+ // without advancing the shared migration ledger during standalone bootstrap.
413
+ yield* DurableWrites.make(sql).write(
414
+ Effect.forEach(Object.values(Migrations.set.migrations), (migration) => migration, { discard: true })
415
+ )
416
+ }).pipe(Effect.mapError(persistence("migrate")))
417
+
418
+ /**
419
+ * Constructs a durable runtime over the ambient database and run store.
420
+ *
421
+ * Not exported under this name: `make` below is the single public constructor.
422
+ * Exporting both put two names for one function on the package's public
423
+ * surface, and only one of them was documented.
424
+ */
425
+ const makeRuntime = (
426
+ options: Options = {}
427
+ ): Effect.Effect<
428
+ Service,
429
+ PersistenceError,
430
+ Crypto.Crypto | DurableWriter | SqlClient.SqlClient | RunStore.RunStore
431
+ > => {
432
+ // Snapshot before the Effect starts: construction options are caller-owned
433
+ // and may be mutated while migrations or service acquisition are suspended.
434
+ const owner: Ownership.OwnerId = options.owner === undefined
435
+ ? Object.freeze({
436
+ hostId: `control-runtime-${randomId()}`,
437
+ pid: 1,
438
+ nonce: randomId()
439
+ })
440
+ : Object.freeze({ ...options.owner })
441
+ const isAlive = options.isAlive
442
+ const approvalAuthority = options.approvalAuthority ?? ApprovalAuthority.local
443
+ const authorizeApproval = approvalAuthority.authorize.bind(approvalAuthority)
444
+
445
+ return Effect.gen(function*() {
446
+ const crypto = yield* Crypto.Crypto
447
+ const writer = yield* DurableWriter
448
+ const runStore = yield* RunStore.RunStore
449
+ yield* migrate
450
+ const sql = yield* Effect.service(SqlClient.SqlClient)
451
+
452
+ const configuredFlows = options.flows ?? plannable.map((entry): DurableFlow => ({
453
+ flowId: entry.flowId,
454
+ description: `Reserved ${entry.verb} system flow`,
455
+ deployClass: entry.deployClass,
456
+ envelope: emptyEnvelope
457
+ }))
458
+ const readFlows = (options.loadFlows === undefined
459
+ ? Effect.succeed(configuredFlows)
460
+ : Effect.suspend(options.loadFlows)).pipe(
461
+ Effect.map((entries) => new Map(entries.map((flow) => [flow.flowId, flow] as const)))
462
+ )
463
+ const readCurrentFlows = options.currentFlows === undefined
464
+ ? readFlows
465
+ : Effect.suspend(options.currentFlows).pipe(
466
+ Effect.map((entries) => new Map(entries.map((flow) => [flow.flowId, flow] as const)))
467
+ )
468
+ const readAdoptedFlow = (flowId: string, runId: string) =>
469
+ options.adoptFlow === undefined
470
+ ? readCurrentFlows.pipe(Effect.map((flows) => flows.get(flowId)))
471
+ : Effect.suspend(() => options.adoptFlow!(flowId, runId))
472
+
473
+ // Fibers are live continuations, not rows. A restarted process legitimately
474
+ // has none, and interrupting a run it does not own is the other process's
475
+ // job — so this map is process-local by design, not by omission.
476
+ const fibers = new Map<RunId, Fiber.Fiber<unknown, unknown>>()
477
+ let pendingSignalCursor = 0
478
+
479
+ const now = Clock.currentTimeMillis
480
+
481
+ const query = <A>(operation: string) => (effect: Effect.Effect<ReadonlyArray<A>, unknown>) =>
482
+ effect.pipe(Effect.mapError(persistence(operation)))
483
+
484
+ /**
485
+ * Runs a read that may fail on a missing table or column, and is caught.
486
+ *
487
+ * Inside an enclosing transaction the read takes a savepoint, because a
488
+ * failed statement aborts a PostgreSQL transaction and the catch could not
489
+ * recover it. Outside one it runs bare: on SQLite a top-level transaction
490
+ * is `BEGIN IMMEDIATE`, which takes the write lock for a read and fails a
491
+ * contender with `SQLITE_BUSY` while another plane holds the claim.
492
+ */
493
+ const probe = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError.SqlError, R> =>
494
+ Effect.flatMap(
495
+ Effect.serviceOption(sql.transactionService),
496
+ (enclosing): Effect.Effect<A, E | SqlError.SqlError, R> =>
497
+ Option.isSome(enclosing) ? sql.withTransaction(effect) : effect
498
+ )
499
+
500
+ /** Allocates the next value of a durable counter inside one transaction. */
501
+ const nextSequence = (name: string): Effect.Effect<number, PersistenceError> =>
502
+ writer.write(Effect.gen(function*() {
503
+ yield* sql`INSERT INTO control_sequences (name, value) VALUES (${name}, 0) ON CONFLICT (name) DO NOTHING`
504
+ const rows = yield* sql<{ readonly value: number }>`
505
+ UPDATE control_sequences SET value = value + 1
506
+ WHERE name = ${name} AND value < ${Number.MAX_SAFE_INTEGER} RETURNING value
507
+ `
508
+ const value = Number(rows[0]?.value)
509
+ if (!Number.isSafeInteger(value) || value < 1) {
510
+ return yield* Effect.fail(new Error(`Sequence ${name} did not return a positive safe integer`))
511
+ }
512
+ return value
513
+ })).pipe(Effect.mapError(persistence("allocate a sequence")))
514
+
515
+ const readPlan = (planId: string): Effect.Effect<Option.Option<PlanRow>, PersistenceError> =>
516
+ sql<PlanRow>`
517
+ SELECT plan_id AS "planId", card_json AS "cardJson",
518
+ decoded_input_json AS "decodedInputJson", decision
519
+ FROM control_plans WHERE plan_id = ${planId}
520
+ `.pipe(query("read a plan"), Effect.map((rows) => Option.fromNullishOr(rows[0])))
521
+
522
+ const storedPlan = (row: PlanRow): Effect.Effect<StoredPlan, PersistenceError> =>
523
+ Effect.all({
524
+ card: decodeStoredJson("control_plans.card_json", PlanCard, row.cardJson),
525
+ decodedInput: decodeStoredJson("control_plans.decoded_input_json", Schema.Json, row.decodedInputJson),
526
+ decision: decodeStoredValue("control_plans.decision", PlanDecision, row.decision)
527
+ })
528
+
529
+ const requirePlan = (planId: string): Effect.Effect<PlanRow, PlanNotFound | PersistenceError> =>
530
+ Effect.flatMap(
531
+ readPlan(planId),
532
+ Option.match({
533
+ onNone: () => Effect.fail(new PlanNotFound({ planId })),
534
+ onSome: Effect.succeed
535
+ })
536
+ )
537
+
538
+ /**
539
+ * Reads a run row, translating a missing row into `RunNotFound` and every
540
+ * other store failure into `PersistenceError` — never a defect.
541
+ */
542
+ const requireRow = (runId: RunId): Effect.Effect<RunStore.RunRow, RunNotFound | PersistenceError> =>
543
+ runStore.get(runId).pipe(
544
+ Effect.mapError((error) =>
545
+ error.code === "not_found_row"
546
+ ? new RunNotFound({ runId })
547
+ : persistence("read a run")(error)
548
+ )
549
+ )
550
+
551
+ /**
552
+ * The control status a store status projects back onto.
553
+ *
554
+ * The forward map is lossy — `accepted` and `running` both store as
555
+ * `running` — so a run this plane did not launch is reported under the
556
+ * status the store can actually prove.
557
+ */
558
+ const controlStatus = (status: RunStore.RunStatus): RunStatus => {
559
+ switch (status) {
560
+ case "pending":
561
+ return "accepted"
562
+ case "running":
563
+ return "running"
564
+ case "suspended":
565
+ return "parked"
566
+ default:
567
+ return status
568
+ }
569
+ }
570
+
571
+ type DecodedRunState =
572
+ | { readonly _tag: "Control"; readonly summary: RunSummary }
573
+ | { readonly _tag: "Engine"; readonly flowName: string }
574
+
575
+ /**
576
+ * Decodes one state row once, then validates the projection its keys name.
577
+ * A control summary and an engine state share the column but not a schema.
578
+ */
579
+ const decodeRunState = (stateJson: string): Effect.Effect<DecodedRunState, PersistenceError> =>
580
+ Effect.gen(function*() {
581
+ const parsed = yield* decodeStoredJson("flows_runs.state_json", Schema.Json, stateJson)
582
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
583
+ const candidate = parsed as Readonly<Record<string, unknown>>
584
+ if ("runId" in candidate || "flowId" in candidate || "status" in candidate) {
585
+ return {
586
+ _tag: "Control",
587
+ summary: yield* decodeStoredValue("flows_runs.state_json as RunSummary", RunSummary, parsed)
588
+ }
589
+ }
590
+ }
591
+ const engine = yield* decodeStoredValue(
592
+ "flows_runs.state_json as engine state",
593
+ EngineStateProjection,
594
+ parsed
595
+ )
596
+ return { _tag: "Engine", flowName: engine.flowName }
597
+ })
598
+
599
+ const optional = <A>(value: A | null | undefined): { readonly value?: A } =>
600
+ value === null || value === undefined ? {} : { value }
601
+
602
+ /**
603
+ * How much of the database one projection needs to read.
604
+ *
605
+ * A page needs only its selected runs and their ancestor chains. Cascade
606
+ * attribution walks ancestors and stops, so a page or a single-run
607
+ * mutation does not project unrelated runs, and no read projects them all.
608
+ */
609
+ type IndexScope = ReadonlyArray<string>
610
+
611
+ /** `WHERE` material narrowing a column to a scope. */
612
+ const within = (column: string, scope: IndexScope) => sql.in(column, scope as Array<string>)
613
+
614
+ /**
615
+ * The two facts a run row cannot tell about itself.
616
+ */
617
+ interface AncestryIndex {
618
+ /** Run ids a `fork-created` marker names. */
619
+ readonly forked: ReadonlySet<string>
620
+ /** The run that spawned each child, by child id. */
621
+ readonly spawnedBy: ReadonlyMap<string, string>
622
+ /** What each parked run is waiting for, by run id. */
623
+ readonly waitingFor: ReadonlyMap<string, string>
624
+ /** Open human waits in each run's tree, by the run that contains them. */
625
+ readonly humanWaits: ReadonlyMap<string, ReadonlyArray<PendingWait>>
626
+ /** Who cancelled each cancelled run, by run id. */
627
+ readonly cancellations: ReadonlyMap<string, Cancellation>
628
+ /** The outstanding resume delegation of each run that has one. */
629
+ readonly pendingResumes: ReadonlyMap<string, number>
630
+ }
631
+
632
+ /**
633
+ * Projects a run row onto a control summary, ancestry included.
634
+ *
635
+ * Ancestry reaches the row from two different places, because the engine
636
+ * records two different relationships. `parent_run_id` is the trampoline
637
+ * chain — the round before this one — and it is the only ancestry a run
638
+ * row carries. A run another run SPAWNED records nothing in its own row:
639
+ * the edge lives in `flows_run_parents`, which is the subflow DAG cycle
640
+ * detection walks (`packages/smithers/flows/run-store/src/migrations/0002_lineage.ts`).
641
+ * A projection that read the column alone would report every child of
642
+ * every run as an orphan.
643
+ *
644
+ * The column wins when both exist, which is the case for round 1 of a run
645
+ * that was itself spawned: the round's nearest ancestor is the round
646
+ * before it, not the run that spawned round 0.
647
+ *
648
+ * @param row the run row
649
+ * @param ancestry the fork markers and spawn edges of the whole database
650
+ */
651
+ const baseSummary = (row: RunStore.RunRow, state: DecodedRunState): RunSummary =>
652
+ state._tag === "Control"
653
+ ? state.summary
654
+ : {
655
+ runId: row.runId,
656
+ flowId: state.flowName,
657
+ status: controlStatus(row.status),
658
+ createdAt: row.createdAtMs,
659
+ updatedAt: row.finishedAtMs ?? row.startedAtMs ?? row.createdAtMs
660
+ }
661
+
662
+ const summaryFrom = (
663
+ row: RunStore.RunRow,
664
+ ancestry: AncestryIndex
665
+ ): Effect.Effect<RunSummary, PersistenceError> =>
666
+ Effect.gen(function*() {
667
+ const base = withLauncher(baseSummary(row, yield* decodeRunState(row.stateJson)), yield* launcherOf(row.runId))
668
+ const parentRunId = optional(row.parentRunId).value ?? ancestry.spawnedBy.get(row.runId)
669
+ const lineageId = optional(row.lineageId).value
670
+ const roundOrdinal = optional(row.roundOrdinal).value
671
+ const origin = Lineage.originOf({
672
+ ...(parentRunId === undefined ? {} : { parentRunId }),
673
+ ...(roundOrdinal === undefined ? {} : { roundOrdinal }),
674
+ forked: ancestry.forked.has(row.runId)
675
+ })
676
+ const waitingReason = ancestry.waitingFor.get(row.runId)
677
+ const cancellation = ancestry.cancellations.get(row.runId)
678
+ const pendingResume = ancestry.pendingResumes.get(row.runId)
679
+ const pendingWaits = ancestry.humanWaits.get(row.runId)
680
+ return {
681
+ ...base,
682
+ // A run whose tree holds an open human wait is waiting on a human,
683
+ // however nested the row that holds it and whether or not its own row
684
+ // has flipped to parked yet: a parent awaiting a `.child()` is
685
+ // blocked on that child's question either way. Rolling the status up
686
+ // is what lets every existing `status: "waiting-approval"` filter —
687
+ // the gateway inbox, `smithers approvals list`, the diagnosis card —
688
+ // find a `HumanTask` parked on a descendant.
689
+ //
690
+ // Only ATTACHED waits reach here. A `detach` spawn outlives the run
691
+ // that started it (`@smthrs/engine-store` `RunState.onParentExit`),
692
+ // so the walk stops at one rather than telling a reader that a run
693
+ // which can proceed cannot.
694
+ ...(pendingWaits === undefined ? {} : { pendingWaits }),
695
+ ...(pendingWaits === undefined || !pendingWaits.some((wait) => wait.reason === ControlExecutor.humanWaitReason) || terminal(base.status)
696
+ ? {}
697
+ : { status: "waiting-approval" as const }),
698
+ ...(pendingResume === undefined ? {} : { pendingResume }),
699
+ ...(parentRunId === undefined ? {} : { parentRunId }),
700
+ ...(lineageId === undefined ? {} : { lineageId }),
701
+ ...(roundOrdinal === undefined ? {} : { roundOrdinal }),
702
+ ...(origin === undefined ? {} : { origin }),
703
+ ...(waitingReason === undefined ? {} : { waitingReason }),
704
+ ...(cancellation === undefined ? {} : { cancellation })
705
+ }
706
+ })
707
+
708
+ /**
709
+ * The runs a `fork-created` marker names.
710
+ *
711
+ * Time travel writes the marker on the forked child's own journal, which
712
+ * is the only evidence separating a fork from an ordinary child: both
713
+ * record `parent_run_id`. A composition whose journal is not this database
714
+ * has no journal table here at all, and the honest answer there is "no
715
+ * fork evidence" — not a failed projection — so exactly that one failure
716
+ * is folded into the empty set.
717
+ *
718
+ * Every other failure is reported. A locked database, a corrupt page, or
719
+ * a table that exists but no longer answers this question would otherwise
720
+ * report every fork in the deployment as an ordinary child, silently and
721
+ * for as long as the condition lasted.
722
+ */
723
+ const forkedRunIds = (scope: IndexScope): Effect.Effect<ReadonlySet<string>, PersistenceError> =>
724
+ sql<{ readonly runId: string }>`
725
+ SELECT DISTINCT run_id AS "runId" FROM flows_journal_events
726
+ WHERE event_type = ${Lineage.forkCreatedEventType} AND ${within("run_id", scope)}
727
+ `.pipe(
728
+ Effect.map((rows) => new Set(rows.map((row) => row.runId)) as ReadonlySet<string>),
729
+ Effect.catchIf(
730
+ missingTable("flows_journal_events"),
731
+ () => Effect.succeed(new Set<string>() as ReadonlySet<string>)
732
+ ),
733
+ Effect.mapError(persistence("read fork markers"))
734
+ )
735
+
736
+ /**
737
+ * The run that spawned each child, by child id.
738
+ *
739
+ * `seq` is the engine's store-global insertion order, so the FIRST edge is
740
+ * the creating parent. A diamond's later parents are edges too, and a
741
+ * summary names one ancestor, so the creating one is the one it names.
742
+ *
743
+ * Missing table, missing evidence, exactly as with the fork markers: a
744
+ * control plane over a database with no engine state in it observes runs
745
+ * that spawned nothing.
746
+ */
747
+ const spawnedBy = (scope: IndexScope): Effect.Effect<ReadonlyMap<string, string>, PersistenceError> =>
748
+ sql<{
749
+ readonly childId: string
750
+ readonly parentId: string
751
+ }>`
752
+ SELECT child_id AS "childId", parent_id AS "parentId"
753
+ FROM flows_run_parents WHERE ${within("child_id", scope)} ORDER BY seq DESC
754
+ `.pipe(
755
+ probe,
756
+ // Descending, so the lowest `seq` is written last and wins the key.
757
+ Effect.map((rows) => new Map(rows.map((row) => [row.childId, row.parentId])) as ReadonlyMap<string, string>),
758
+ Effect.catchIf(
759
+ missingTable("flows_run_parents"),
760
+ () => Effect.succeed(new Map<string, string>() as ReadonlyMap<string, string>)
761
+ ),
762
+ Effect.mapError(persistence("read spawn edges"))
763
+ )
764
+
765
+ /**
766
+ * What each parked run is waiting for, by run id.
767
+ *
768
+ * The engine writes `waiting_reason` on the run row when it parks a run
769
+ * (`packages/smithers/flows/engine-store/src/DurableEngineState.ts` `park`), and clears
770
+ * it on the wake. The control plane reads it and never writes it: a park
771
+ * belongs to whoever is holding the run, and the projection reports the
772
+ * hold rather than deciding it.
773
+ *
774
+ * The reason separates the parks a steer can end from the parks it
775
+ * cannot, so `Control.steer` needs it on the summary and not only in the
776
+ * engine's own store.
777
+ */
778
+ const waitingFor = (scope: IndexScope): Effect.Effect<ReadonlyMap<string, string>, PersistenceError> =>
779
+ sql<{
780
+ readonly runId: string
781
+ readonly waitingReason: string
782
+ }>`
783
+ SELECT run_id AS "runId", waiting_reason AS "waitingReason"
784
+ FROM flows_runs WHERE waiting_reason IS NOT NULL AND ${within("run_id", scope)}
785
+ `.pipe(
786
+ Effect.map((rows) => new Map(rows.map((row) => [row.runId, row.waitingReason])) as ReadonlyMap<string, string>),
787
+ Effect.mapError(persistence("read waiting reasons"))
788
+ )
789
+
790
+ /**
791
+ * Open human waits in each run's tree, by every run that contains one.
792
+ *
793
+ * The walk goes UP, not down. Approval parks are rare and run trees are
794
+ * shallow, so start from the parked rows and climb every spawn and
795
+ * trampoline edge; starting from every run in scope and descending would
796
+ * re-walk the whole forest to find the same few rows. A wait therefore
797
+ * appears under its own execution AND under every ancestor of it, which
798
+ * is exactly what "does this run tree owe anybody an answer" asks.
799
+ *
800
+ * The scope filters the ANCESTOR, not the parked row: a listing wants the
801
+ * waits of the runs it is about to return, wherever those waits are held.
802
+ *
803
+ * Collapse paths to each wait/ancestor pair at their shortest depth so
804
+ * shared descendants and cycles cannot duplicate or reorder a wait.
805
+ * A database without the engine's wait-tree schema observes no nested
806
+ * waits rather than failing every listing.
807
+ */
808
+ const humanWaits = (scope: IndexScope): Effect.Effect<
809
+ ReadonlyMap<string, ReadonlyArray<PendingWait>>,
810
+ PersistenceError
811
+ > =>
812
+ sql<{
813
+ readonly ancestorId: string
814
+ readonly runId: string
815
+ readonly flowId: string | null
816
+ readonly waitingReason: string
817
+ readonly waitingToken: string | null
818
+ readonly waitingRequest: string | null
819
+ readonly createdAtMs: number
820
+ readonly depth: number
821
+ }>`
822
+ WITH RECURSIVE human_waits(wait_run_id, ancestor_id, depth) AS (
823
+ SELECT run_id, run_id, 0 FROM flows_runs
824
+ WHERE waiting_reason IN (${ControlExecutor.humanWaitReason}, 'event')
825
+ AND waiting_token IS NOT NULL
826
+ AND status NOT IN ('completed', 'failed', 'cancelled')
827
+ UNION
828
+ SELECT human_waits.wait_run_id, parent.run_id, human_waits.depth + 1
829
+ FROM flows_runs step JOIN human_waits ON step.run_id = human_waits.ancestor_id
830
+ JOIN flows_runs parent ON parent.run_id IN (
831
+ SELECT parent_id FROM flows_run_parents WHERE child_id = step.run_id
832
+ UNION ALL
833
+ SELECT step.parent_run_id WHERE step.parent_run_id IS NOT NULL
834
+ )
835
+ WHERE human_waits.depth < ${maxWaitTreeDepth}
836
+ AND (parent.run_id = step.parent_run_id
837
+ OR COALESCE(${Dialect.jsonText(sql, sql`step.state_json`, "$.onParentExit")}, 'cancel') <> 'detach')
838
+ ), reachable(wait_run_id, ancestor_id, depth) AS (
839
+ SELECT wait_run_id, ancestor_id, MIN(depth) FROM human_waits GROUP BY wait_run_id, ancestor_id
840
+ )
841
+ SELECT
842
+ reachable.ancestor_id AS "ancestorId",
843
+ reachable.depth AS "depth",
844
+ parked.run_id AS "runId",
845
+ parked.execution_flow AS "flowId",
846
+ parked.waiting_reason AS "waitingReason",
847
+ parked.waiting_token AS "waitingToken",
848
+ parked.waiting_request AS "waitingRequest",
849
+ parked.created_at_ms AS "createdAtMs"
850
+ FROM reachable JOIN flows_runs parked ON parked.run_id = reachable.wait_run_id
851
+ WHERE ${within("reachable.ancestor_id", scope)}
852
+ ORDER BY reachable.ancestor_id, reachable.depth, parked.created_at_ms, parked.run_id
853
+ `.pipe(
854
+ probe,
855
+ Effect.map((rows) => {
856
+ const index = new Map<string, Array<PendingWait>>()
857
+ for (const row of rows) {
858
+ const built = ControlExecutor.pendingWaitOf({
859
+ runId: row.runId,
860
+ reason: row.waitingReason,
861
+ token: row.waitingToken,
862
+ createdAt: row.createdAtMs,
863
+ ...(row.flowId === null ? {} : { flowId: row.flowId }),
864
+ ...declaredRequest(row.waitingRequest)
865
+ })
866
+ if (built === undefined) continue
867
+ const waits = index.get(row.ancestorId) ?? []
868
+ waits.push(built)
869
+ index.set(row.ancestorId, waits)
870
+ }
871
+ return index
872
+ }),
873
+ Effect.catchIf(
874
+ missingWaitTreeSchema,
875
+ () => Effect.succeed(new Map<string, ReadonlyArray<PendingWait>>())
876
+ ),
877
+ Effect.mapError(persistence("read nested human waits"))
878
+ )
879
+
880
+ /**
881
+ * The attributed cancel requests this plane journaled, by run id.
882
+ *
883
+ * The FIRST entry for a run wins. A cancel is idempotent, so a repeat asks
884
+ * for something that already happened; the request that caused the
885
+ * cancellation is the one that gets to name the principal and the reason.
886
+ */
887
+ const cancelRequests = (
888
+ scope: IndexScope
889
+ ): Effect.Effect<ReadonlyMap<string, Attribution.Request>, PersistenceError> =>
890
+ sql<{
891
+ readonly runId: string
892
+ readonly emittedAtMs: number
893
+ readonly payloadJson: string
894
+ }>`
895
+ SELECT run_id AS "runId", emitted_at_ms AS "emittedAtMs", payload_json AS "payloadJson"
896
+ FROM flows_journal_events
897
+ WHERE event_type = ${Attribution.requestedEventType} AND ${within("run_id", scope)}
898
+ ORDER BY run_id, seq
899
+ `.pipe(
900
+ Effect.catchIf(
901
+ missingTable("flows_journal_events"),
902
+ () =>
903
+ Effect.succeed(
904
+ new Array<{ readonly runId: string; readonly emittedAtMs: number; readonly payloadJson: string }>()
905
+ )
906
+ ),
907
+ Effect.mapError(persistence("read cancel requests")),
908
+ Effect.flatMap((rows) =>
909
+ Effect.gen(function*() {
910
+ const requests = new Map<string, Attribution.Request>()
911
+ for (const row of rows) {
912
+ if (requests.has(row.runId)) continue
913
+ const payload = yield* decodeStoredJson(
914
+ "flows_journal_events.payload_json for control.run.cancel-requested",
915
+ CancelRequestPayload,
916
+ row.payloadJson
917
+ )
918
+ requests.set(row.runId, {
919
+ requestedAt: Number(row.emittedAtMs),
920
+ ...(payload.principal === undefined ? {} : { principal: payload.principal }),
921
+ ...(payload.reason === undefined ? {} : { reason: payload.reason })
922
+ })
923
+ }
924
+ return requests
925
+ })
926
+ )
927
+ )
928
+
929
+ /**
930
+ * When the engine journaled each run's interruption.
931
+ *
932
+ * The engine writes this record in the same transaction as the `cancelled`
933
+ * transition (`packages/smithers/flows/engine-store/src/internal/RunDriver.ts`), so it is
934
+ * the moment a cancellation actually took, as opposed to the moment
935
+ * somebody asked. A run cancelled by a peer process that never wrote a
936
+ * request column still has this.
937
+ */
938
+ const engineInterruptions = (scope: IndexScope): Effect.Effect<ReadonlyMap<string, number>, PersistenceError> =>
939
+ sql<{
940
+ readonly runId: string
941
+ readonly payloadJson: string
942
+ }>`
943
+ SELECT run_id AS "runId", payload_json AS "payloadJson"
944
+ FROM flows_journal_events
945
+ WHERE event_type = ${Attribution.interruptedEventType} AND ${within("run_id", scope)}
946
+ `.pipe(
947
+ Effect.catchIf(
948
+ missingTable("flows_journal_events"),
949
+ () => Effect.succeed(new Array<{ readonly runId: string; readonly payloadJson: string }>())
950
+ ),
951
+ Effect.mapError(persistence("read engine interruptions")),
952
+ Effect.flatMap((rows) =>
953
+ Effect.gen(function*() {
954
+ const cancelled = new Map<string, number>()
955
+ for (const row of rows) {
956
+ const payload = yield* decodeStoredJson(
957
+ "flows_journal_events.payload_json for flows.engine.interrupted",
958
+ EngineInterruptionPayload,
959
+ row.payloadJson
960
+ )
961
+ if (payload.outcome !== "cancelled") continue
962
+ cancelled.set(row.runId, Number(payload.interruptedAtMs ?? 0))
963
+ }
964
+ return cancelled
965
+ })
966
+ )
967
+ )
968
+
969
+ /**
970
+ * The ancestry and cancel columns of every run row.
971
+ *
972
+ * Cascade is a fact about a run's ancestors, so the attribution cannot be
973
+ * decided a row at a time: the request that cancelled a child may be three
974
+ * rounds up the chain.
975
+ */
976
+ const cancelEvidence = (scope: IndexScope): Effect.Effect<
977
+ ReadonlyArray<{
978
+ readonly runId: string
979
+ readonly parentRunId: string | null
980
+ readonly cancelRequestedAtMs: number | null
981
+ }>,
982
+ PersistenceError
983
+ > =>
984
+ sql<{
985
+ readonly runId: string
986
+ readonly parentRunId: string | null
987
+ readonly cancelRequestedAtMs: number | null
988
+ }>`
989
+ SELECT run_id AS "runId", parent_run_id AS "parentRunId",
990
+ cancel_requested_at_ms AS "cancelRequestedAtMs"
991
+ FROM flows_runs WHERE ${within("run_id", scope)}
992
+ `.pipe(Effect.mapError(persistence("read cancel evidence")))
993
+
994
+ /** Every cancelled run's attribution in the scope, folded in one pass. */
995
+ const cancellations = (scope: IndexScope): Effect.Effect<ReadonlyMap<string, Cancellation>, PersistenceError> =>
996
+ Effect.map(
997
+ Effect.all({
998
+ rows: cancelEvidence(scope),
999
+ requests: cancelRequests(scope),
1000
+ interrupted: engineInterruptions(scope),
1001
+ spawnedBy: spawnedBy(scope)
1002
+ }),
1003
+ ({ interrupted, requests, rows, spawnedBy }) =>
1004
+ Attribution.attribute({
1005
+ runs: rows.map((row) => {
1006
+ const parentRunId = row.parentRunId ?? spawnedBy.get(row.runId)
1007
+ const cancelledAt = interrupted.get(row.runId)
1008
+ return {
1009
+ runId: row.runId,
1010
+ ...(parentRunId === undefined || parentRunId === null ? {} : { parentRunId }),
1011
+ ...(row.cancelRequestedAtMs === null ? {} : { cancelRequestedAt: Number(row.cancelRequestedAtMs) }),
1012
+ ...(cancelledAt === undefined ? {} : { cancelledAt })
1013
+ }
1014
+ }),
1015
+ requests
1016
+ })
1017
+ )
1018
+
1019
+ /**
1020
+ * The outstanding resume delegation of each run in the scope.
1021
+ *
1022
+ * Read from `control_run_resumes` rather than from the journal because the
1023
+ * question is "what has not been taken up yet", which a log of what was
1024
+ * asked cannot answer without a per-run cursor.
1025
+ */
1026
+ const pendingResumeIndex = (scope: IndexScope): Effect.Effect<ReadonlyMap<string, number>, PersistenceError> =>
1027
+ sql<{ readonly runId: string; readonly requestedSeq: number }>`
1028
+ SELECT run_id AS "runId", requested_seq AS "requestedSeq"
1029
+ FROM control_run_resumes WHERE ${within("run_id", scope)}
1030
+ `.pipe(
1031
+ Effect.map((rows) =>
1032
+ new Map(rows.map((row) => [row.runId, Number(row.requestedSeq)] as const)) as ReadonlyMap<string, number>
1033
+ ),
1034
+ Effect.mapError(persistence("read pending resumes"))
1035
+ )
1036
+
1037
+ /** Every index a projection needs over one scope, read together. */
1038
+ const ancestryIndex = (scope: IndexScope): Effect.Effect<AncestryIndex, PersistenceError> =>
1039
+ Effect.map(
1040
+ Effect.all({
1041
+ forked: forkedRunIds(scope),
1042
+ spawnedBy: spawnedBy(scope),
1043
+ waitingFor: waitingFor(scope),
1044
+ humanWaits: humanWaits(scope),
1045
+ cancellations: cancellations(scope),
1046
+ pendingResumes: pendingResumeIndex(scope)
1047
+ }),
1048
+ (index): AncestryIndex => index
1049
+ )
1050
+
1051
+ /**
1052
+ * One run and every ancestor above it, nearest first.
1053
+ *
1054
+ * The trampoline chain is one recursive read over `parent_run_id`. A
1055
+ * SPAWNED run records nothing in its own row, so when a chain runs out the
1056
+ * spawn edge is looked up and the walk continues from there — one extra
1057
+ * read per nesting level, and subflow nesting is shallow where a
1058
+ * trampoline is long. The visited set makes corrupt ancestry terminate
1059
+ * instead of taking the control plane down with it.
1060
+ */
1061
+ const ancestorChain = (runId: RunId): Effect.Effect<ReadonlyArray<string>, PersistenceError> =>
1062
+ Effect.gen(function*() {
1063
+ const chain: Array<string> = []
1064
+ const visited = new Set<string>()
1065
+ let start: string | undefined = runId
1066
+ while (start !== undefined && !visited.has(start)) {
1067
+ const rows = yield* sql<{ readonly runId: string; readonly parentRunId: string | null }>`
1068
+ WITH RECURSIVE ancestry(run_id, parent_run_id) AS (
1069
+ SELECT run_id, parent_run_id FROM flows_runs WHERE run_id = ${start}
1070
+ UNION
1071
+ SELECT runs.run_id, runs.parent_run_id
1072
+ FROM flows_runs runs JOIN ancestry ON runs.run_id = ancestry.parent_run_id
1073
+ )
1074
+ SELECT run_id AS "runId", parent_run_id AS "parentRunId" FROM ancestry
1075
+ `.pipe(Effect.mapError(persistence("walk a run's ancestry")))
1076
+ if (rows.length === 0) {
1077
+ // No row at all: the caller's own `requireRow` reports that.
1078
+ chain.push(start)
1079
+ break
1080
+ }
1081
+ let last: string | undefined
1082
+ for (const row of rows) {
1083
+ if (visited.has(row.runId)) continue
1084
+ visited.add(row.runId)
1085
+ chain.push(row.runId)
1086
+ if (row.parentRunId === null) last = row.runId
1087
+ }
1088
+ // The chain ended at a row naming no parent. A run somebody SPAWNED
1089
+ // records its parent in the edge table instead, so the walk
1090
+ // continues from there.
1091
+ const spawn = last === undefined ? undefined : (yield* spawnedBy([last])).get(last)
1092
+ start = spawn
1093
+ }
1094
+ return chain
1095
+ })
1096
+
1097
+ /**
1098
+ * One run row as a control summary, whoever wrote the row.
1099
+ *
1100
+ * `flows_runs.state_json` is `@smthrs/run-store`'s column and it has two
1101
+ * writers: this plane stores a `RunSummary` there, and the engine stores
1102
+ * its own `RunState` (`version`, `flowName`, `payload`). Decoding the
1103
+ * column as a `RunSummary` outright therefore failed
1104
+ * `PersistenceError` for every engine-created run before the caller could
1105
+ * reach the answer it was owed: `interrupt` reads the summary before it
1106
+ * asks who owns the row, so cancelling a run the ENGINE owns reported a
1107
+ * corrupt database instead of the `ClaimLost` that lets `Control.cancel`
1108
+ * fall back to the durable request, and `resume` failed the same way
1109
+ * before it could delegate to the owning driver.
1110
+ *
1111
+ * `baseSummary` already projects both shapes for listings. Sharing it here
1112
+ * keeps one answer for one row: a control row decodes to the summary it
1113
+ * stored, and an engine row is projected from the columns the run store
1114
+ * owns. Nothing writes back through this path except a transition this
1115
+ * plane's own fence authorized, which is a control row by construction.
1116
+ */
1117
+ const summaryOf = (row: RunStore.RunRow): Effect.Effect<RunSummary, PersistenceError> =>
1118
+ Effect.zipWith(
1119
+ decodeRunState(row.stateJson),
1120
+ launcherOf(row.runId),
1121
+ (state, launcher) => withLauncher(baseSummary(row, state), launcher)
1122
+ )
1123
+
1124
+ const snapshotOf = (row: RunStore.RunRow): RunStore.RunSnapshot => ({
1125
+ status: row.status,
1126
+ owner: row.owner,
1127
+ heartbeatAtMs: row.heartbeatAtMs
1128
+ })
1129
+
1130
+ // A type predicate, not a plain boolean: a row this process owns has a
1131
+ // non-null owner by construction, and the callers hand `row.owner` straight
1132
+ // to the fenced transitions.
1133
+ const ownedByUs = (
1134
+ row: RunStore.RunRow
1135
+ ): row is RunStore.RunRow & { readonly owner: Ownership.OwnerId } =>
1136
+ row.status === "running" && row.owner !== null && sameProcess(row.owner, owner)
1137
+
1138
+ /**
1139
+ * Moves a run this process owns to a new control status, writing the
1140
+ * projection in the same compare-and-swap. A lost fence is `ClaimLost`.
1141
+ */
1142
+ const transition = (
1143
+ runId: RunId,
1144
+ claim: Ownership.OwnerId,
1145
+ summary: RunSummary,
1146
+ status: RunStatus
1147
+ ): Effect.Effect<RunSummary, RunNotFound | ClaimLost | PersistenceError> =>
1148
+ Effect.gen(function*() {
1149
+ const timestamp = yield* now
1150
+ const next: RunSummary = {
1151
+ ...summary,
1152
+ status,
1153
+ updatedAt: timestamp,
1154
+ ...(storeStatus(status) === "running" ? {} : { ownerId: undefined }),
1155
+ // A park releases the owner columns, so the row itself stops saying
1156
+ // which process is hosting the execution. The fence it was parked
1157
+ // under is kept instead: it is what lets the host recognize its own
1158
+ // park, and every other process tell that the execution belongs to
1159
+ // one it cannot see (triage B-15). Any other status ends the park,
1160
+ // so it ends the record with it.
1161
+ parkedBy: storeStatus(status) === "suspended" ? JSON.stringify(claim) : undefined
1162
+ }
1163
+ const outcome = yield* runStore.transitionOwned(
1164
+ runId,
1165
+ claim,
1166
+ storeStatus(status),
1167
+ JSON.stringify(next)
1168
+ ).pipe(Effect.mapError(persistence("transition a run")))
1169
+ if (outcome._tag === "NotFound") return yield* Effect.fail(new RunNotFound({ runId }))
1170
+ if (outcome._tag !== "Transitioned") return yield* Effect.fail(new ClaimLost({ runId }))
1171
+ return next
1172
+ })
1173
+
1174
+ /**
1175
+ * Evidence that a running row's owner is gone, from the configured
1176
+ * liveness check, or `undefined` when there is no check or the owner is
1177
+ * alive. A pid is evidence only on its own host; elsewhere the claim
1178
+ * rests on the expired lease the run store verifies.
1179
+ */
1180
+ const deadOwner = (
1181
+ row: RunStore.RunRow
1182
+ ): Effect.Effect<Ownership.LivenessEvidence | undefined> =>
1183
+ Effect.gen(function*() {
1184
+ if (isAlive === undefined || row.owner === null) return undefined
1185
+ const nowMs = yield* now
1186
+ const alive = yield* isAlive(row.owner, { claimant: owner, heartbeatAtMs: row.heartbeatAtMs, nowMs })
1187
+ if (alive) return undefined
1188
+ return {
1189
+ expectedOwner: row.owner,
1190
+ checkedAtMs: nowMs,
1191
+ kind: Ownership.sameHostIncarnation(row.owner, owner)
1192
+ ? "same-host-pid-dead" as const
1193
+ : "lease-expired" as const
1194
+ }
1195
+ })
1196
+
1197
+ /**
1198
+ * Takes ownership of a suspended or pending run under a fresh nonce, or
1199
+ * of a running one whose owner `evidence` says is gone.
1200
+ */
1201
+ const claim = (
1202
+ runId: RunId,
1203
+ row: RunStore.RunRow,
1204
+ evidence?: Ownership.LivenessEvidence | undefined,
1205
+ adopted?: Pick<RunSummary, "executionDigest" | "engineVersion"> | undefined
1206
+ ): Effect.Effect<RunSummary, RunNotFound | ClaimLost | PersistenceError> =>
1207
+ Effect.gen(function*() {
1208
+ const timestamp = evidence?.checkedAtMs ?? (yield* now)
1209
+ const claimant: Ownership.OwnerId = { ...owner, nonce: randomId() }
1210
+ const outcome = yield* runStore.claimAndOwn(runId, snapshotOf(row), claimant, timestamp, evidence).pipe(
1211
+ Effect.mapError(persistence("claim a run"))
1212
+ )
1213
+ if (outcome._tag === "NotFound") return yield* Effect.fail(new RunNotFound({ runId }))
1214
+ if (outcome._tag !== "Activated") return yield* Effect.fail(new ClaimLost({ runId }))
1215
+ const summary = yield* summaryOf(row)
1216
+ return yield* transition(runId, claimant, {
1217
+ ...summary,
1218
+ ...adopted,
1219
+ ownerId: JSON.stringify(claimant)
1220
+ }, "accepted")
1221
+ })
1222
+
1223
+ /**
1224
+ * The run's summary with the code identity it started on.
1225
+ *
1226
+ * A row with no control summary of its own (a trampoline round, or a fork
1227
+ * or child the engine wrote) records no identity, and checking it against
1228
+ * nothing let it resume on any code. It inherits the identity of its
1229
+ * nearest ancestor of the same flow that recorded one.
1230
+ */
1231
+ const recordedCode = (row: RunStore.RunRow): Effect.Effect<RunSummary, PersistenceError> =>
1232
+ Effect.gen(function*() {
1233
+ const summary = yield* summaryOf(row)
1234
+ const seen = new Set<string>([row.runId])
1235
+ let parentId = optional(row.parentRunId).value ?? summary.parentRunId
1236
+ while (
1237
+ summary.executionDigest === undefined && summary.engineVersion === undefined &&
1238
+ parentId !== undefined && !seen.has(parentId)
1239
+ ) {
1240
+ seen.add(parentId)
1241
+ const parentRow = yield* requireRow(parentId).pipe(
1242
+ Effect.catchTag("/control/RunNotFound", () => Effect.succeed(undefined))
1243
+ )
1244
+ if (parentRow === undefined) break
1245
+ const parent = yield* summaryOf(parentRow)
1246
+ if (parent.flowId !== summary.flowId) break
1247
+ if (parent.executionDigest !== undefined || parent.engineVersion !== undefined) {
1248
+ return { ...summary, executionDigest: parent.executionDigest, engineVersion: parent.engineVersion }
1249
+ }
1250
+ parentId = optional(parentRow.parentRunId).value ?? parent.parentRunId
1251
+ }
1252
+ return summary
1253
+ })
1254
+
1255
+ /**
1256
+ * The code identity an allowed drift records, read before the claim: a
1257
+ * flow this host cannot run leaves the run where it was rather than
1258
+ * accepted and then failed (#2740).
1259
+ */
1260
+ const adoptable = (
1261
+ row: RunStore.RunRow
1262
+ ): Effect.Effect<Pick<RunSummary, "executionDigest" | "engineVersion">, CodeDrift | PersistenceError> =>
1263
+ Effect.gen(function*() {
1264
+ const summary = yield* summaryOf(row)
1265
+ const flow = yield* readAdoptedFlow(summary.flowId, summary.runId)
1266
+ const gone = flow === undefined
1267
+ ? codeDriftOf(yield* recordedCode(row), undefined, options.engineVersion)
1268
+ : undefined
1269
+ if (gone !== undefined) return yield* gone
1270
+ return adoptedCode(summary, flow, options.engineVersion)
1271
+ })
1272
+
1273
+ /** `resume`, recording the identity `adopt` answers on the claimed run when given. */
1274
+ const resumeRun = <E = never>(
1275
+ runId: RunId,
1276
+ scope: "launched" | "any" | undefined,
1277
+ adopt?:
1278
+ | ((row: RunStore.RunRow) => Effect.Effect<Pick<RunSummary, "executionDigest" | "engineVersion">, E>)
1279
+ | undefined
1280
+ ): Effect.Effect<RunSummary, RunNotFound | ClaimLost | PersistenceError | E> =>
1281
+ Effect.gen(function*() {
1282
+ const row = yield* requireRow(runId)
1283
+ const summary = yield* summaryOf(row)
1284
+ if (terminal(summary.status)) return summary
1285
+ // Start-or-join: owning the run already means resume is a no-op.
1286
+ if (ownedByUs(row)) return summary
1287
+ // Parking clears ownership, but the detached host can still be alive.
1288
+ // Public resume may take its park only after a same-host dead-pid
1289
+ // probe; otherwise the caller would steal and interrupt its execution.
1290
+ // The refusal names that host, so `Control.resume` hands the resume
1291
+ // to it instead (#3342). Trusted host claims remain able to reclaim
1292
+ // their engine's execution.
1293
+ if (scope === "launched" && row.status === "suspended" && summary.parkedBy !== undefined) {
1294
+ const parkedOwner = yield* decodeStoredJson("parked host", Ownership.OwnerId, summary.parkedBy)
1295
+ if (!sameProcess(parkedOwner, owner)) {
1296
+ const alive = isAlive === undefined || !Ownership.sameHostIncarnation(parkedOwner, owner)
1297
+ ? true
1298
+ : yield* isAlive(parkedOwner, { claimant: owner, heartbeatAtMs: row.heartbeatAtMs, nowMs: yield* now })
1299
+ if (alive) {
1300
+ return yield* new ClaimLost({
1301
+ runId,
1302
+ reason: "The host that parked this run is still alive or its liveness is unknown",
1303
+ parkedBy: { hostId: parkedOwner.hostId, pid: parkedOwner.pid }
1304
+ })
1305
+ }
1306
+ }
1307
+ }
1308
+ // Every public Control resume and steer wake uses launched scope.
1309
+ // Engine-created runs keep their continuation and driver. Unrestricted
1310
+ // claims are a trusted low-level capability for hosts that can drive
1311
+ // the execution; node approval delegates through requestResume instead.
1312
+ if (scope === "launched") {
1313
+ const indexed = yield* sql`SELECT run_id FROM control_runs WHERE run_id = ${runId}`.pipe(
1314
+ Effect.mapError(persistence("read the launch index"))
1315
+ )
1316
+ if (indexed.length === 0) return yield* new ClaimLost({ runId })
1317
+ }
1318
+ // A run owned by a live peer is theirs to drive. A run whose owner is
1319
+ // gone is taken over, with the evidence the run store checks.
1320
+ if (row.status === "running") {
1321
+ const evidence = yield* deadOwner(row)
1322
+ return evidence === undefined
1323
+ ? yield* new ClaimLost({ runId })
1324
+ : yield* claim(runId, row, evidence, adopt === undefined ? undefined : yield* adopt(row))
1325
+ }
1326
+ return yield* claim(runId, row, undefined, adopt === undefined ? undefined : yield* adopt(row))
1327
+ })
1328
+
1329
+ // Match summaryFrom's durable fields in SQL. Only the selected ids are
1330
+ // decoded or expanded into ancestry; one extra key determines continuation.
1331
+ const runPageKeys = (
1332
+ request: RunQuery,
1333
+ includeSpawn: boolean = true,
1334
+ includeWaitRollup: boolean = true
1335
+ ): Effect.Effect<
1336
+ ReadonlyArray<RunCursor>,
1337
+ PersistenceError
1338
+ > => {
1339
+ const filters = request.filters
1340
+ const source = sql`CASE WHEN indexed.created_seq IS NULL THEN 1 ELSE 0 END`
1341
+ const sequence = sql`COALESCE(indexed.created_seq, 0)`
1342
+ const controlState = sql`(${Dialect.jsonText(sql, sql`runs.state_json`, "$.runId")} IS NOT NULL
1343
+ OR ${Dialect.jsonText(sql, sql`runs.state_json`, "$.flowId")} IS NOT NULL
1344
+ OR ${Dialect.jsonText(sql, sql`runs.state_json`, "$.status")} IS NOT NULL)`
1345
+ const flowId = sql`CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql`runs.state_json`, "$.flowId")}
1346
+ ELSE ${Dialect.jsonText(sql, sql`runs.state_json`, "$.flowName")} END`
1347
+ const ownStatus = sql`CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql`runs.state_json`, "$.status")}
1348
+ ELSE CASE runs.status WHEN 'pending' THEN 'accepted' WHEN 'suspended' THEN 'parked' ELSE runs.status END END`
1349
+ // The listing filter has to agree with the summary the listing returns.
1350
+ // `summaryFrom` rolls a run tree's open human waits up onto the root's
1351
+ // status, so the SQL that decides which runs a `status:
1352
+ // "waiting-approval"` page contains must roll them up too — otherwise
1353
+ // the inbox filter skips exactly the rows the inbox is for, which is how
1354
+ // run-3 stayed invisible while parked on `coding-clarification`.
1355
+ const status = includeWaitRollup
1356
+ ? sql`CASE
1357
+ WHEN runs.status NOT IN ('completed', 'failed', 'cancelled')
1358
+ AND runs.run_id IN (SELECT ancestorId FROM human_wait_ancestry)
1359
+ THEN 'waiting-approval' ELSE ${ownStatus} END`
1360
+ : ownStatus
1361
+ const storedParent = sql`CASE WHEN ${controlState} THEN ${
1362
+ Dialect.jsonText(sql, sql`runs.state_json`, "$.parentRunId")
1363
+ } END`
1364
+ const storedLineage = sql`CASE WHEN ${controlState} THEN ${
1365
+ Dialect.jsonText(sql, sql`runs.state_json`, "$.lineageId")
1366
+ } END`
1367
+ const parent = includeSpawn
1368
+ ? sql`COALESCE(runs.parent_run_id,
1369
+ (SELECT parent_id FROM flows_run_parents WHERE child_id = runs.run_id ORDER BY seq LIMIT 1),
1370
+ ${storedParent})`
1371
+ : sql`COALESCE(runs.parent_run_id, ${storedParent})`
1372
+ const lineage = sql`COALESCE(runs.lineage_id, ${storedLineage})`
1373
+ const after = request.cursor
1374
+ const conditions = [sql`1 = 1`]
1375
+ if (filters?.flowId !== undefined) conditions.push(sql`${flowId} = ${filters.flowId}`)
1376
+ if (filters?.status !== undefined) conditions.push(sql`${status} = ${filters.status}`)
1377
+ if (filters?.terminal !== undefined) {
1378
+ const isTerminal = sql`${status} IN ('completed', 'failed', 'cancelled')`
1379
+ conditions.push(filters.terminal ? isTerminal : sql`NOT (${isTerminal})`)
1380
+ }
1381
+ if (filters?.parentRunId !== undefined) conditions.push(sql`${parent} = ${filters.parentRunId}`)
1382
+ if (filters?.lineageId !== undefined) conditions.push(sql`${lineage} = ${filters.lineageId}`)
1383
+ if (filters?.since !== undefined) conditions.push(sql`runs.created_at_ms >= ${filters.since}`)
1384
+ if (filters?.until !== undefined) conditions.push(sql`runs.created_at_ms < ${filters.until}`)
1385
+ if (filters?.launchedBy !== undefined) {
1386
+ conditions.push(sql`indexed.principal_id = ${filters.launchedBy.id}`)
1387
+ if (filters.launchedBy.kind !== undefined) {
1388
+ conditions.push(sql`indexed.principal_kind = ${filters.launchedBy.kind}`)
1389
+ }
1390
+ }
1391
+ if (filters?.runIds !== undefined) {
1392
+ // One JSON parameter, so a trigger's whole ledger never meets the bind-variable limit.
1393
+ const ids = JSON.stringify([...new Set(filters.runIds)])
1394
+ conditions.push(
1395
+ sql.onDialectOrElse({
1396
+ /* v8 ignore next -- PostgreSQL adapter; covered by //packages/smithers/control:postgresInventory */
1397
+ pg: () => sql`runs.run_id IN (SELECT jsonb_array_elements_text(${ids}::jsonb))`,
1398
+ orElse: () => sql`runs.run_id IN (SELECT value FROM json_each(${ids}))`
1399
+ })
1400
+ )
1401
+ }
1402
+ if (after !== undefined) {
1403
+ conditions.push(
1404
+ request.order === "newest"
1405
+ ? sql`(runs.created_at_ms, ${source}, ${sequence}, runs.run_id) <
1406
+ (${after.createdAt}, ${after.source}, ${after.sequence}, ${after.runId})`
1407
+ : request.order === "oldest"
1408
+ ? sql`(runs.created_at_ms, ${source}, ${sequence}, runs.run_id) >
1409
+ (${after.createdAt}, ${after.source}, ${after.sequence}, ${after.runId})`
1410
+ : sql`(${source}, ${sequence}, runs.created_at_ms, runs.run_id) >
1411
+ (${after.source}, ${after.sequence}, ${after.createdAt}, ${after.runId})`
1412
+ )
1413
+ }
1414
+ // Computed once for the whole page rather than per row: the set of runs
1415
+ // with an open human wait at or below them, climbed from the few parked
1416
+ // rows instead of descended from every run.
1417
+ const waitAncestry = includeWaitRollup
1418
+ ? sql`WITH RECURSIVE human_wait_ancestry(ancestorId, depth) AS (
1419
+ SELECT run_id, 0 FROM flows_runs
1420
+ WHERE waiting_reason = ${ControlExecutor.humanWaitReason}
1421
+ AND status NOT IN ('completed', 'failed', 'cancelled')
1422
+ UNION
1423
+ SELECT parent.run_id, human_wait_ancestry.depth + 1
1424
+ FROM flows_runs step JOIN human_wait_ancestry ON step.run_id = human_wait_ancestry.ancestorId
1425
+ JOIN flows_runs parent ON parent.run_id IN (
1426
+ SELECT parent_id FROM flows_run_parents WHERE child_id = step.run_id
1427
+ UNION ALL
1428
+ SELECT step.parent_run_id WHERE step.parent_run_id IS NOT NULL
1429
+ )
1430
+ WHERE human_wait_ancestry.depth < ${maxWaitTreeDepth}
1431
+ AND (parent.run_id = step.parent_run_id
1432
+ OR COALESCE(${Dialect.jsonText(sql, sql`step.state_json`, "$.onParentExit")}, 'cancel') <> 'detach')
1433
+ )`
1434
+ : sql.literal("")
1435
+ return sql<RunCursor>`
1436
+ ${waitAncestry}
1437
+ SELECT runs.run_id AS "runId", ${source} AS source, ${sequence} AS sequence,
1438
+ runs.created_at_ms AS "createdAt"
1439
+ FROM flows_runs AS runs LEFT JOIN control_runs AS indexed ON indexed.run_id = runs.run_id
1440
+ WHERE ${sql.and(conditions)}
1441
+ ORDER BY ${
1442
+ request.order === "newest"
1443
+ ? sql`runs.created_at_ms DESC, ${source} DESC, ${sequence} DESC, runs.run_id DESC`
1444
+ : request.order === "oldest"
1445
+ ? sql`runs.created_at_ms, ${source}, ${sequence}, runs.run_id`
1446
+ : sql`${source}, ${sequence}, runs.created_at_ms, runs.run_id`
1447
+ }
1448
+ LIMIT ${request.limit + 1}
1449
+ `.pipe(
1450
+ probe,
1451
+ Effect.catchIf(
1452
+ (error) => includeSpawn && filters?.parentRunId !== undefined && missingTable("flows_run_parents")(error),
1453
+ () => runPageKeys(request, false, includeWaitRollup)
1454
+ ),
1455
+ // Without the engine's wait-tree schema, retain the ordinary page.
1456
+ Effect.catchIf(
1457
+ (error) => includeWaitRollup && missingWaitTreeSchema(error),
1458
+ () => runPageKeys(request, includeSpawn, false)
1459
+ ),
1460
+ Effect.mapError(persistence("query runs"))
1461
+ )
1462
+ }
1463
+
1464
+ /**
1465
+ * One inventory page of `table` by `rowid`: the insertion-ordered row key
1466
+ * both dialects index (SQLite's b-tree key, PostgreSQL's identity column),
1467
+ * so a page is one seek and `limit + 1` rows however large the table is.
1468
+ * The first page pins `through` to the newest row, so a walk ends while
1469
+ * inserts continue.
1470
+ */
1471
+ const pageByRowId = (
1472
+ request: IdPageRequest,
1473
+ table: "flows_runs" | "control_plans",
1474
+ column: "run_id" | "plan_id",
1475
+ operation: string
1476
+ ): Effect.Effect<IdPage, InvalidInput | PersistenceError> =>
1477
+ Effect.gen(function*() {
1478
+ yield* idPageLimit(request.limit)
1479
+ const through = request.through ?? Number(
1480
+ (yield* sql<{ readonly newest: number | string | null }>`
1481
+ SELECT MAX(rowid) AS newest FROM ${sql.literal(table)}
1482
+ `.pipe(query(operation)))[0]?.newest ?? 0
1483
+ )
1484
+ const rows = yield* sql<{ readonly id: string; readonly position: number | string }>`
1485
+ SELECT ${sql.literal(column)} AS id, rowid AS position FROM ${sql.literal(table)}
1486
+ WHERE rowid > ${request.after ?? 0} AND rowid <= ${through}
1487
+ ORDER BY rowid LIMIT ${request.limit + 1}
1488
+ `.pipe(query(operation))
1489
+ const selected = rows.slice(0, request.limit)
1490
+ return rows.length > request.limit
1491
+ ? { ids: selected.map((row) => row.id), next: Number(selected.at(-1)!.position), through }
1492
+ : { ids: selected.map((row) => row.id), through }
1493
+ })
1494
+
1495
+ const pagePlanIds = (request: IdPageRequest) => pageByRowId(request, "control_plans", "plan_id", "page plans")
1496
+
1497
+ /**
1498
+ * One page of stored plans by `rowid`. The decision narrows in SQL; the flow
1499
+ * is read from the stored card, so a page reads batches until it fills or
1500
+ * the table ends.
1501
+ */
1502
+ const queryPlans = (request: PlanQuery): Effect.Effect<PlanPage, InvalidInput | PersistenceError> =>
1503
+ Effect.gen(function*() {
1504
+ yield* idPageLimit(request.limit)
1505
+ const plans: Array<StoredPlan> = []
1506
+ let after = request.after ?? 0
1507
+ while (true) {
1508
+ const conditions = [
1509
+ sql`rowid > ${after}`,
1510
+ ...(request.decision === undefined ? [] : [sql`decision = ${request.decision}`])
1511
+ ]
1512
+ const rows = yield* sql<PlanRow & { readonly position: number | string }>`
1513
+ SELECT plan_id AS "planId", card_json AS "cardJson",
1514
+ decoded_input_json AS "decodedInputJson", decision, rowid AS position
1515
+ FROM control_plans WHERE ${sql.and(conditions)}
1516
+ ORDER BY rowid LIMIT ${planScanBatch}
1517
+ `.pipe(query("page plans"))
1518
+ for (const [index, row] of rows.entries()) {
1519
+ after = Number(row.position)
1520
+ const plan = yield* storedPlan(row)
1521
+ if (request.flowId !== undefined && plan.card.flowId !== request.flowId) continue
1522
+ plans.push(plan)
1523
+ if (plans.length === request.limit) {
1524
+ return index < rows.length - 1 || rows.length === planScanBatch ? { plans, next: after } : { plans }
1525
+ }
1526
+ }
1527
+ if (rows.length < planScanBatch) return { plans }
1528
+ }
1529
+ })
1530
+
1531
+ /**
1532
+ * The recorded launcher of a run this plane launched. The launch index is
1533
+ * written once, so it outlives the control summary the engine replaces in
1534
+ * `flows_runs.state_json` when it takes the run over.
1535
+ */
1536
+ const launcherOf = (
1537
+ runId: RunId
1538
+ ): Effect.Effect<{ readonly id: string; readonly kind: string } | undefined, PersistenceError> =>
1539
+ Effect.map(
1540
+ sql<{ readonly id: string | null; readonly kind: string | null }>`
1541
+ SELECT principal_id AS id, principal_kind AS kind FROM control_runs WHERE run_id = ${runId}
1542
+ `.pipe(query("read a run launcher")),
1543
+ (rows) => {
1544
+ const row = rows[0]
1545
+ return row === undefined || row.id === null || row.kind === null ? undefined : { id: row.id, kind: row.kind }
1546
+ }
1547
+ )
1548
+
1549
+ const messages = <S extends Schema.Top>(
1550
+ runId: RunId,
1551
+ kind: "signal",
1552
+ schema: S
1553
+ ): Effect.Effect<ReadonlyArray<S["Type"]>, PersistenceError, S["DecodingServices"]> =>
1554
+ sql<{ readonly payloadJson: string }>`
1555
+ SELECT payload_json AS "payloadJson" FROM control_run_messages
1556
+ WHERE run_id = ${runId} AND kind = ${kind} ORDER BY seq
1557
+ `.pipe(
1558
+ query("read run messages"),
1559
+ Effect.flatMap((rows) =>
1560
+ Effect.forEach(
1561
+ rows,
1562
+ (row) => decodeStoredJson(`control_run_messages.payload_json as ${kind}`, schema, row.payloadJson)
1563
+ )
1564
+ )
1565
+ )
1566
+
1567
+ const appendMessage = (
1568
+ runId: RunId,
1569
+ kind: "signal",
1570
+ payload: unknown
1571
+ ): Effect.Effect<void, RunNotFound | PersistenceError> =>
1572
+ Effect.gen(function*() {
1573
+ yield* requireRow(runId)
1574
+ yield* sql`
1575
+ INSERT INTO control_run_messages (run_id, kind, payload_json)
1576
+ VALUES (${runId}, ${kind}, ${JSON.stringify(payload)})
1577
+ `.pipe(Effect.mapError(persistence("append a run message")))
1578
+ })
1579
+
1580
+ const service = make({
1581
+ authorizeApproval,
1582
+ plan: Effect.fn("SqlControlRuntime.plan")(function*(input: PlanInput) {
1583
+ const flow = (yield* readFlows).get(input.flowId)
1584
+ if (flow === undefined) {
1585
+ return yield* new FlowNotFound({ flowId: input.flowId })
1586
+ }
1587
+ const requestFingerprint = yield* Effect.try({
1588
+ try: () => planFingerprint(input),
1589
+ catch: (cause) => new InvalidInput({ issue: canonicalIssue(cause) })
1590
+ })
1591
+ if (input.idempotencyKey !== undefined) {
1592
+ const prior = yield* sql<{ readonly fingerprint: string; readonly planId: string }>`
1593
+ SELECT fingerprint, plan_id AS "planId" FROM control_plan_keys
1594
+ WHERE idempotency_key = ${input.idempotencyKey}
1595
+ `.pipe(query("read a plan key"))
1596
+ const found = prior[0]
1597
+ if (found !== undefined) {
1598
+ if (found.fingerprint !== requestFingerprint) {
1599
+ return yield* new InvalidInput({
1600
+ issue: `idempotency key ${input.idempotencyKey} was used for another plan`
1601
+ })
1602
+ }
1603
+ const stored = yield* readPlan(found.planId)
1604
+ if (Option.isSome(stored)) {
1605
+ const decoded = yield* storedPlan(stored.value)
1606
+ return { card: decoded.card, created: false }
1607
+ }
1608
+ }
1609
+ }
1610
+ const decoded = yield* (flow.decode?.(input.input) ?? Effect.try({
1611
+ try: () => {
1612
+ canonical(input.input)
1613
+ return input.input
1614
+ },
1615
+ catch: (cause) => new InvalidInput({ issue: canonicalIssue(cause) })
1616
+ }))
1617
+ const planId = `plan-${yield* nextSequence("plan")}`
1618
+ const handoff = flow.plan === undefined ? undefined : yield* flow.plan(decoded, planId)
1619
+ const card = yield* planCard({
1620
+ planId,
1621
+ flowId: input.flowId,
1622
+ decodedInput: decoded,
1623
+ envelope: budgeted(flow.envelope, input.budget),
1624
+ deployClass: flow.deployClass,
1625
+ executionDigest: flow.executionDigest,
1626
+ handoff,
1627
+ idempotencyKey: input.idempotencyKey
1628
+ }).pipe(Effect.provideService(Crypto.Crypto, crypto))
1629
+ const identity = approvalIdentity(card.approval.target)
1630
+ // The key row is claimed FIRST, and the claim is a conditional insert
1631
+ // followed by a read of whoever holds it. `idempotency_key` is the
1632
+ // primary key, so a bare insert made two runtimes planning under one
1633
+ // key a race the loser lost with a constraint violation surfaced as
1634
+ // `PersistenceError`, instead of the winner's card the key promises.
1635
+ // Under Control.plan this write joins the journal transaction, so the
1636
+ // card, key, token and creation entry commit or roll back together.
1637
+ const outcome = yield* writer.write(Effect.gen(function*() {
1638
+ if (input.idempotencyKey !== undefined) {
1639
+ yield* sql`
1640
+ INSERT INTO control_plan_keys (idempotency_key, fingerprint, plan_id)
1641
+ VALUES (${input.idempotencyKey}, ${requestFingerprint}, ${planId})
1642
+ ON CONFLICT (idempotency_key) DO NOTHING
1643
+ `
1644
+ const settled = yield* sql<{ readonly fingerprint: string; readonly planId: string }>`
1645
+ SELECT fingerprint, plan_id AS "planId" FROM control_plan_keys
1646
+ WHERE idempotency_key = ${input.idempotencyKey}
1647
+ `
1648
+ const holder = settled[0]
1649
+ if (holder !== undefined && holder.planId !== planId) {
1650
+ return { _tag: "raced", holder } as const
1651
+ }
1652
+ }
1653
+ // An explicit null input is stored as JSON text in the non-null column.
1654
+ const decodedJson = JSON.stringify(decoded ?? null)
1655
+ yield* sql`
1656
+ INSERT INTO control_plans (plan_id, card_json, decoded_input_json, decision)
1657
+ VALUES (${planId}, ${JSON.stringify(card)}, ${decodedJson}, 'pending')
1658
+ `
1659
+ yield* sql`
1660
+ INSERT INTO control_tokens (
1661
+ target_tag, run_id, target_id, token_id, target_json, resolved, decision_principal_json
1662
+ )
1663
+ VALUES (
1664
+ ${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${planId},
1665
+ ${JSON.stringify(card.approval.target)}, 0, NULL
1666
+ )
1667
+ `
1668
+ return { _tag: "stored" } as const
1669
+ })).pipe(Effect.mapError(persistence("store a plan")))
1670
+ if (outcome._tag === "raced") {
1671
+ if (outcome.holder.fingerprint !== requestFingerprint) {
1672
+ return yield* new InvalidInput({
1673
+ issue: `idempotency key ${String(input.idempotencyKey)} was used for another plan`
1674
+ })
1675
+ }
1676
+ const stored = yield* readPlan(outcome.holder.planId)
1677
+ if (Option.isNone(stored)) {
1678
+ return yield* new PersistenceError({
1679
+ operation: "read a plan",
1680
+ message: `plan key ${String(input.idempotencyKey)} names plan ${outcome.holder.planId}, which is absent`
1681
+ })
1682
+ }
1683
+ const decodedHolder = yield* storedPlan(stored.value)
1684
+ return { card: decodedHolder.card, created: false }
1685
+ }
1686
+ return { card, created: true }
1687
+ }),
1688
+ getPlan: Effect.fn("SqlControlRuntime.getPlan")((planId: string) =>
1689
+ Effect.flatMap(requirePlan(planId), storedPlan)
1690
+ ),
1691
+ pagePlanIds,
1692
+ queryPlans,
1693
+ lookupApproval: Effect.fn("SqlControlRuntime.lookupApproval")(function*(target: ApprovalTarget) {
1694
+ const tokenId = target._tag === "Plan" ? target.planId : target.requestId
1695
+ const identity = approvalIdentity(target)
1696
+ const rows = yield* sql<TokenRow>`
1697
+ SELECT token_id AS "tokenId", target_json AS "targetJson", resolved,
1698
+ decision_principal_json AS "decisionPrincipalJson", decision_json AS "decisionJson"
1699
+ FROM control_tokens
1700
+ WHERE target_tag = ${identity.targetTag}
1701
+ AND run_id = ${identity.runId}
1702
+ AND target_id = ${identity.targetId}
1703
+ `.pipe(query("read an approval token"))
1704
+ const row = rows[0]
1705
+ if (row === undefined) {
1706
+ return yield* (target._tag === "Node"
1707
+ ? new RunNotFound({ runId: target.runId })
1708
+ : new PlanNotFound({ planId: target.planId }))
1709
+ }
1710
+ const stored = yield* decodeStoredJson("control_tokens.target_json", ApprovalTarget, row.targetJson)
1711
+ // The composite columns select the requested identity. The decoded copy
1712
+ // must agree too, or a rewritten JSON blob could smuggle a foreign
1713
+ // target back into the token after lookup.
1714
+ if (!sameApprovalIdentity(stored, target)) {
1715
+ return yield* new PersistenceError({
1716
+ operation: "validate an approval token",
1717
+ message: "The stored approval target does not match its identity"
1718
+ })
1719
+ }
1720
+ if (stored.digest !== target.digest) {
1721
+ return yield* new PlanDigestMismatch({
1722
+ planId: tokenId,
1723
+ expected: stored.digest,
1724
+ actual: target.digest
1725
+ })
1726
+ }
1727
+ if (!sameEnvelope(stored.envelope, target.envelope)) {
1728
+ return yield* new EnvelopeMismatch({
1729
+ planId: tokenId,
1730
+ expected: canonical(stored.envelope),
1731
+ actual: canonical(target.envelope)
1732
+ })
1733
+ }
1734
+ const token = yield* tokenFromRow(row, stored)
1735
+ if (token._tag !== "Pending") return yield* new AlreadyResolved({ requestId: tokenId })
1736
+ return token
1737
+ }),
1738
+ registerApproval: Effect.fn("SqlControlRuntime.registerApproval")(function*(
1739
+ target: Extract<ApprovalTarget, { readonly _tag: "Node" }>
1740
+ ) {
1741
+ yield* requireRow(target.runId)
1742
+ const identity = approvalIdentity(target)
1743
+ yield* sql`
1744
+ INSERT INTO control_tokens (
1745
+ target_tag, run_id, target_id, token_id, target_json, resolved, decision_principal_json
1746
+ )
1747
+ VALUES (
1748
+ ${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${target.requestId},
1749
+ ${JSON.stringify(target)}, 0, NULL
1750
+ )
1751
+ ON CONFLICT (target_tag, run_id, target_id) DO NOTHING
1752
+ `.pipe(Effect.mapError(persistence("register an approval token")))
1753
+ const rows = yield* sql<TokenRow>`
1754
+ SELECT token_id AS "tokenId", target_json AS "targetJson", resolved,
1755
+ decision_principal_json AS "decisionPrincipalJson", decision_json AS "decisionJson"
1756
+ FROM control_tokens
1757
+ WHERE target_tag = ${identity.targetTag}
1758
+ AND run_id = ${identity.runId}
1759
+ AND target_id = ${identity.targetId}
1760
+ `.pipe(query("read an approval token"))
1761
+ const row = rows[0]
1762
+ if (row === undefined) {
1763
+ return yield* Effect.fail(
1764
+ new PersistenceError({
1765
+ operation: "register an approval token",
1766
+ message: "A registered approval token could not be read back"
1767
+ })
1768
+ )
1769
+ }
1770
+ const stored = yield* decodeStoredJson("control_tokens.target_json", ApprovalTarget, row.targetJson)
1771
+ if (!sameApprovalIdentity(stored, target)) {
1772
+ return yield* new PersistenceError({
1773
+ operation: "validate an approval token",
1774
+ message: "The stored approval target does not match its identity"
1775
+ })
1776
+ }
1777
+ if (stored.digest !== target.digest) {
1778
+ return yield* new PlanDigestMismatch({
1779
+ planId: target.requestId,
1780
+ expected: stored.digest,
1781
+ actual: target.digest
1782
+ })
1783
+ }
1784
+ if (!sameEnvelope(stored.envelope, target.envelope)) {
1785
+ return yield* new EnvelopeMismatch({
1786
+ planId: target.requestId,
1787
+ expected: canonical(stored.envelope),
1788
+ actual: canonical(target.envelope)
1789
+ })
1790
+ }
1791
+ return yield* tokenFromRow(row, stored)
1792
+ }),
1793
+ installBulkGrant: Effect.fn("SqlControlRuntime.installBulkGrant")(function*(
1794
+ token: ApprovalToken,
1795
+ envelope: Envelope,
1796
+ scope
1797
+ ) {
1798
+ const timestamp = yield* now
1799
+ const identity = approvalIdentity(token.target)
1800
+ // The envelope is installed whole. Splitting it into capabilities here
1801
+ // would let a partial grant exist, which is exactly what the bulk-grant
1802
+ // rule forbids.
1803
+ yield* sql`
1804
+ INSERT INTO control_grants (
1805
+ target_tag, run_id, target_id, token_id, envelope_json, scope, installed_at_ms
1806
+ )
1807
+ VALUES (
1808
+ ${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${token.tokenId},
1809
+ ${JSON.stringify(envelope)}, ${scope}, ${timestamp}
1810
+ )
1811
+ ON CONFLICT (target_tag, run_id, target_id) DO NOTHING
1812
+ `.pipe(Effect.mapError(persistence("install a grant")))
1813
+ }),
1814
+ resolveApproval: Effect.fn("SqlControlRuntime.resolveApproval")(function*(
1815
+ token: ApprovalToken,
1816
+ decision: "approved" | "denied",
1817
+ principal: Principal,
1818
+ scope: GrantScope = "once"
1819
+ ) {
1820
+ // Caller-owned objects can change while the clock or writer yields.
1821
+ // Bind both representations of the principal, and the plan update,
1822
+ // to the same captured request rather than reading the caller again.
1823
+ const requested = yield* Effect.try({
1824
+ try: () => structuredClone({ target: token.target, principal, tokenId: token.tokenId }),
1825
+ catch: () =>
1826
+ new PersistenceError({
1827
+ operation: "record an approval decision",
1828
+ message: "The approval request cannot be captured"
1829
+ })
1830
+ })
1831
+ const identity = approvalIdentity(requested.target)
1832
+ const requestedPrincipal = requested.principal
1833
+ const tokenId = requested.tokenId
1834
+ const decidedAt = yield* now
1835
+ const answer = yield* decodeStoredValue(
1836
+ "approval decision",
1837
+ ApprovalDecision,
1838
+ decision === "approved"
1839
+ ? { _tag: "Approved", decisionPrincipal: requestedPrincipal, decidedAt, scope }
1840
+ : { _tag: "Denied", decisionPrincipal: requestedPrincipal, decidedAt }
1841
+ )
1842
+ // Exactly once: the guard is in the UPDATE, so two concurrent decisions
1843
+ // cannot both observe an unresolved token.
1844
+ const resolved = yield* writer.write(Effect.gen(function*() {
1845
+ yield* authorizeApproval({ principal: requestedPrincipal, target: requested.target, decision, scope })
1846
+ const rows = yield* sql<{ readonly tokenId: string }>`
1847
+ UPDATE control_tokens
1848
+ SET resolved = 1, decision_principal_json = ${JSON.stringify(requestedPrincipal)},
1849
+ decision_json = ${JSON.stringify(answer)}
1850
+ WHERE target_tag = ${identity.targetTag}
1851
+ AND run_id = ${identity.runId}
1852
+ AND target_id = ${identity.targetId}
1853
+ AND resolved = 0
1854
+ RETURNING token_id AS "tokenId"
1855
+ `
1856
+ if (rows.length === 0) return false
1857
+ if (identity.targetTag === "Plan") {
1858
+ yield* sql`UPDATE control_plans SET decision = ${decision} WHERE plan_id = ${identity.targetId}`
1859
+ }
1860
+ return true
1861
+ })).pipe(Effect.mapError((error) =>
1862
+ error instanceof Unauthorized || error instanceof PersistenceError
1863
+ ? error
1864
+ : persistence("resolve an approval")(error)
1865
+ ))
1866
+ if (!resolved) return yield* new AlreadyResolved({ requestId: tokenId })
1867
+ }),
1868
+ launch: Effect.fn("SqlControlRuntime.launch")(function*(
1869
+ planId: string,
1870
+ requestedDigest: string,
1871
+ envelope: Envelope,
1872
+ principal?: Principal | undefined,
1873
+ reservedRunId?: RunId | undefined
1874
+ ) {
1875
+ const row = yield* requirePlan(planId)
1876
+ const plan = yield* storedPlan(row)
1877
+ if (plan.card.digest !== requestedDigest) {
1878
+ return yield* new PlanDigestMismatch({
1879
+ planId,
1880
+ expected: plan.card.digest,
1881
+ actual: requestedDigest
1882
+ })
1883
+ }
1884
+ if (!sameEnvelope(plan.card.envelope, envelope)) {
1885
+ return yield* new EnvelopeMismatch({
1886
+ planId,
1887
+ expected: canonical(plan.card.envelope),
1888
+ actual: canonical(envelope)
1889
+ })
1890
+ }
1891
+ if (plan.decision === "pending") {
1892
+ const parked: LaunchResult = {
1893
+ _tag: "Parked",
1894
+ receipt: {
1895
+ _tag: "Parked",
1896
+ receiptId: `launch:${planId}`,
1897
+ planId,
1898
+ status: "waiting-approval"
1899
+ }
1900
+ }
1901
+ return parked
1902
+ }
1903
+ if (plan.decision !== "approved") return yield* new PlanDenied({ planId })
1904
+
1905
+ const sequence = yield* nextSequence("run")
1906
+ const runId = reservedRunId ?? `run-${sequence}`
1907
+ const timestamp = yield* now
1908
+ const claimant: Ownership.OwnerId = { ...owner, nonce: randomId() }
1909
+ const summary: RunSummary = {
1910
+ runId,
1911
+ flowId: plan.card.flowId,
1912
+ status: "accepted",
1913
+ planId,
1914
+ planDigest: plan.card.digest,
1915
+ ...(plan.card.executionDigest === undefined ? {} : { executionDigest: plan.card.executionDigest }),
1916
+ ...(options.engineVersion === undefined ? {} : { engineVersion: options.engineVersion }),
1917
+ ownerId: JSON.stringify(claimant),
1918
+ ...(plan.card.envelope.budget.deadline === undefined
1919
+ ? {}
1920
+ : { deadlineAt: timestamp + plan.card.envelope.budget.deadline }),
1921
+ createdAt: timestamp,
1922
+ updatedAt: timestamp
1923
+ }
1924
+ yield* runStore.create(runId, JSON.stringify(summary)).pipe(
1925
+ Effect.mapError(persistence("create a run"))
1926
+ )
1927
+ yield* sql`INSERT INTO control_runs (run_id, created_seq, principal_id, principal_kind)
1928
+ VALUES (${runId}, ${sequence}, ${principal?.id ?? null}, ${principal?.kind ?? null})`.pipe(
1929
+ Effect.mapError(persistence("index a run"))
1930
+ )
1931
+ const outcome = yield* runStore.claimAndOwn(
1932
+ runId,
1933
+ { status: "pending", owner: null, heartbeatAtMs: null },
1934
+ claimant,
1935
+ timestamp
1936
+ ).pipe(Effect.mapError(persistence("claim a new run")))
1937
+ if (outcome._tag !== "Activated") return yield* new ClaimLost({ runId })
1938
+ const started: LaunchResult = {
1939
+ _tag: "Started",
1940
+ receipt: accepted(`launch:${planId}:${runId}`, runId),
1941
+ run: withLauncher(summary, principal)
1942
+ }
1943
+ return started
1944
+ }),
1945
+ getRun: Effect.fn("SqlControlRuntime.getRun")((runId: RunId) =>
1946
+ Effect.gen(function*() {
1947
+ const row = yield* requireRow(runId)
1948
+ return yield* summaryFrom(row, yield* ancestryIndex(yield* ancestorChain(runId)))
1949
+ })
1950
+ ),
1951
+ /**
1952
+ * Every durable run in `flows_runs` insertion order: `control_runs`
1953
+ * indexes only the runs this plane launched, while a child, a fork, and a
1954
+ * later trampoline round are created by the engine straight into
1955
+ * `flows_runs`. One indexed seek per page, with no row decoded, so a
1956
+ * caller that walks every run holds one page of ids at a time.
1957
+ */
1958
+ pageRunIds: (request) => pageByRowId(request, "flows_runs", "run_id", "page runs"),
1959
+ queryRuns: Effect.fn("SqlControlRuntime.queryRuns")(function*(request) {
1960
+ if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 500) {
1961
+ return yield* new InvalidInput({ issue: "limit: must be an integer between 1 and 500" })
1962
+ }
1963
+ const keys = yield* runPageKeys(request)
1964
+ const selected = keys.slice(0, request.limit)
1965
+ if (selected.length === 0) return { items: [] }
1966
+ const chains = yield* Effect.forEach(selected, (key) => ancestorChain(key.runId))
1967
+ const ancestry = yield* ancestryIndex([...new Set(chains.flat())])
1968
+ const summaries = yield* Effect.forEach(selected, (key) =>
1969
+ requireRow(key.runId).pipe(
1970
+ Effect.flatMap((row) => Effect.map(summaryFrom(row, ancestry), Option.some)),
1971
+ Effect.catchTag("/control/RunNotFound", () => Effect.succeed(Option.none<RunSummary>()))
1972
+ ))
1973
+ return {
1974
+ items: summaries.filter(Option.isSome).map((summary) => summary.value),
1975
+ ...(keys.length > request.limit ? { nextCursor: selected[selected.length - 1]! } : {})
1976
+ }
1977
+ }),
1978
+ listFlows: Effect.fn("SqlControlRuntime.listFlows")(() =>
1979
+ Effect.map(readFlows, (flows) =>
1980
+ Array.from(flows.values(), (flow) => ({
1981
+ flowId: flow.flowId,
1982
+ description: flow.description
1983
+ })))
1984
+ )(),
1985
+ deliverSignal: Effect.fn("SqlControlRuntime.deliverSignal")((runId: RunId, signal: SignalPayload) =>
1986
+ // Durable delivery, and deliberately no resumption: a signal records a
1987
+ // fact, it does not decide who runs next.
1988
+ appendMessage(runId, "signal", signal)
1989
+ ),
1990
+ admitSignal: Effect.fn("SqlControlRuntime.admitSignal")(function*(commandId, runId, signal, principal) {
1991
+ yield* requireRow(runId)
1992
+ const payloadJson = JSON.stringify(signal)
1993
+ const principalJson = principal === undefined ? null : JSON.stringify(principal)
1994
+ yield* writer.write(sql`INSERT INTO control_signal_commands (command_id, run_id, payload_json, principal_json)
1995
+ VALUES (${commandId}, ${runId}, ${payloadJson}, ${principalJson}) ON CONFLICT(command_id) DO NOTHING`).pipe(
1996
+ Effect.mapError(persistence("admit signal command"))
1997
+ )
1998
+ }),
1999
+ signalCommand: Effect.fn("SqlControlRuntime.signalCommand")(function*(commandId) {
2000
+ const rows = yield* sql<
2001
+ {
2002
+ commandId: string
2003
+ runId: string
2004
+ payloadJson: string
2005
+ token: string | null
2006
+ state: "pending" | "delivered" | "rejected" | "terminal"
2007
+ principalJson: string | null
2008
+ }
2009
+ >`
2010
+ SELECT command_id AS "commandId", run_id AS "runId", payload_json AS "payloadJson", wait_token AS token, state,
2011
+ principal_json AS "principalJson"
2012
+ FROM control_signal_commands WHERE command_id = ${commandId}`.pipe(query("read signal command"))
2013
+ const row = rows[0]
2014
+ if (row === undefined) return undefined
2015
+ return {
2016
+ commandId: row.commandId,
2017
+ runId: row.runId,
2018
+ token: row.token,
2019
+ state: row.state,
2020
+ signal: yield* decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson),
2021
+ ...yield* signalPrincipal(row.principalJson)
2022
+ }
2023
+ }),
2024
+ pendingSignals: Effect.gen(function*() {
2025
+ const read = (after: number) =>
2026
+ sql<
2027
+ {
2028
+ seq: number
2029
+ commandId: string
2030
+ runId: string
2031
+ payloadJson: string
2032
+ token: string | null
2033
+ state: "pending"
2034
+ principalJson: string | null
2035
+ }
2036
+ >`
2037
+ SELECT seq, command_id AS "commandId", run_id AS "runId", payload_json AS "payloadJson", wait_token AS token, state,
2038
+ principal_json AS "principalJson"
2039
+ FROM control_signal_commands WHERE state = 'pending' AND seq > ${after} ORDER BY seq LIMIT 100`.pipe(
2040
+ query("read pending signals")
2041
+ )
2042
+ let rows = yield* read(pendingSignalCursor)
2043
+ if (rows.length === 0 && pendingSignalCursor !== 0) rows = yield* read(0)
2044
+ pendingSignalCursor = rows.at(-1)?.seq ?? 0
2045
+ const decoded = yield* Effect.forEach(
2046
+ rows,
2047
+ (row) =>
2048
+ Effect.all([
2049
+ decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson),
2050
+ signalPrincipal(row.principalJson)
2051
+ ]).pipe(
2052
+ Effect.map(([signal, principal]) =>
2053
+ Option.some({
2054
+ commandId: row.commandId,
2055
+ runId: row.runId,
2056
+ token: row.token,
2057
+ state: row.state,
2058
+ signal,
2059
+ ...principal
2060
+ })
2061
+ ),
2062
+ Effect.catch((error) =>
2063
+ writer.write(
2064
+ sql`UPDATE control_signal_commands SET state = 'rejected' WHERE command_id = ${row.commandId} AND state = 'pending'`
2065
+ ).pipe(
2066
+ Effect.mapError(persistence("quarantine malformed signal")),
2067
+ Effect.andThen(
2068
+ Effect.logWarning("Malformed admitted signal rejected", { commandId: row.commandId, error })
2069
+ ),
2070
+ Effect.as(Option.none())
2071
+ )
2072
+ )
2073
+ )
2074
+ )
2075
+ return decoded.filter(Option.isSome).map((item) => item.value)
2076
+ }),
2077
+ bindSignal: Effect.fn("SqlControlRuntime.bindSignal")((commandId, token) =>
2078
+ writer.write(Effect.gen(function*() {
2079
+ yield* sql`UPDATE control_signal_commands SET wait_token = ${token} WHERE command_id = ${commandId} AND wait_token IS NULL AND state = 'pending' AND NOT EXISTS (SELECT 1 FROM control_signal_commands WHERE wait_token = ${token})`
2080
+ const rows = yield* sql<
2081
+ { token: string | null }
2082
+ >`SELECT wait_token AS token FROM control_signal_commands WHERE command_id = ${commandId}`
2083
+ if (rows[0] === undefined) {
2084
+ return yield* new PersistenceError({
2085
+ operation: "bind signal",
2086
+ message: `No pending signal command ${commandId}`
2087
+ })
2088
+ }
2089
+ return rows[0].token
2090
+ })).pipe(Effect.mapError(persistence("bind signal")))
2091
+ ),
2092
+ settleSignal: Effect.fn("SqlControlRuntime.settleSignal")((commandId, state) =>
2093
+ writer.write(
2094
+ sql`UPDATE control_signal_commands SET state = ${state} WHERE command_id = ${commandId} AND state = 'pending'`
2095
+ ).pipe(Effect.asVoid, Effect.mapError(persistence("settle signal")))
2096
+ ),
2097
+ deliveredSignals: Effect.fn("SqlControlRuntime.deliveredSignals")(function*(runId: RunId) {
2098
+ yield* requireRow(runId)
2099
+ const legacy = yield* messages(runId, "signal", SignalPayload)
2100
+ const rows = yield* sql<
2101
+ { payloadJson: string }
2102
+ >`SELECT payload_json AS "payloadJson" FROM control_signal_commands WHERE run_id = ${runId} AND state != 'rejected' ORDER BY seq`
2103
+ .pipe(query("read admitted signals"))
2104
+ return [
2105
+ ...legacy,
2106
+ ...yield* Effect.forEach(
2107
+ rows,
2108
+ (row) => decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson)
2109
+ )
2110
+ ]
2111
+ }),
2112
+ requestResume: Effect.fn("SqlControlRuntime.requestResume")(function*(
2113
+ runId: RunId,
2114
+ options?: { readonly consent?: number | undefined } | undefined
2115
+ ) {
2116
+ const summary = yield* summaryOf(yield* requireRow(runId))
2117
+ // A settled run has no host left to take the delegation up: recording
2118
+ // one anyway leaves an orphaned row that `pendingResumes` filters out
2119
+ // of every poll but nothing ever clears.
2120
+ if (terminal(summary.status)) {
2121
+ return yield* new InvalidInput({
2122
+ issue: `run ${runId} is ${summary.status} and cannot take a resume`
2123
+ })
2124
+ }
2125
+ const sequence = yield* nextSequence("resume")
2126
+ const timestamp = yield* now
2127
+ const consent = options?.consent ?? null
2128
+ // A delegation that arrives before the host took up an operator's
2129
+ // resume keeps that consent; only a newer explicit resume replaces it.
2130
+ yield* writer.write(sql`
2131
+ INSERT INTO control_run_resumes (run_id, requested_seq, requested_at_ms, consent_seq)
2132
+ VALUES (${runId}, ${sequence}, ${timestamp}, ${consent})
2133
+ ON CONFLICT (run_id) DO UPDATE SET
2134
+ requested_seq = excluded.requested_seq,
2135
+ requested_at_ms = excluded.requested_at_ms,
2136
+ consent_seq = COALESCE(excluded.consent_seq, control_run_resumes.consent_seq)
2137
+ `).pipe(Effect.mapError(persistence("record a resume delegation")))
2138
+ return sequence
2139
+ }),
2140
+ // Terminal runs are filtered in SQL: a delegation nobody will ever take
2141
+ // up must not keep appearing in every host's poll.
2142
+ pendingResumes: sql<
2143
+ {
2144
+ readonly runId: string
2145
+ readonly requestedSeq: number
2146
+ readonly requestedAtMs: number
2147
+ readonly consentSeq: number | null
2148
+ }
2149
+ >`
2150
+ SELECT resumes.run_id AS "runId",
2151
+ resumes.requested_seq AS "requestedSeq",
2152
+ resumes.requested_at_ms AS "requestedAtMs",
2153
+ resumes.consent_seq AS "consentSeq"
2154
+ FROM control_run_resumes AS resumes
2155
+ JOIN flows_runs AS runs ON runs.run_id = resumes.run_id
2156
+ WHERE runs.status NOT IN ('completed', 'failed', 'cancelled')
2157
+ ORDER BY resumes.requested_seq
2158
+ `.pipe(
2159
+ query("read pending resumes"),
2160
+ Effect.map((rows) =>
2161
+ rows.map((row) => ({
2162
+ runId: row.runId,
2163
+ sequence: Number(row.requestedSeq),
2164
+ requestedAtMs: Number(row.requestedAtMs),
2165
+ ...(row.consentSeq === null ? {} : { consent: Number(row.consentSeq) })
2166
+ }))
2167
+ )
2168
+ ),
2169
+ clearResume: Effect.fn("SqlControlRuntime.clearResume")((runId: RunId, sequence: number) =>
2170
+ writer.write(sql`
2171
+ DELETE FROM control_run_resumes WHERE run_id = ${runId} AND requested_seq = ${sequence}
2172
+ `).pipe(Effect.mapError(persistence("clear a resume delegation")), Effect.asVoid)
2173
+ ),
2174
+ registerFiber: Effect.fn("SqlControlRuntime.registerFiber")(function*(
2175
+ runId: RunId,
2176
+ fiber: Fiber.Fiber<unknown, unknown>
2177
+ ) {
2178
+ yield* requireRow(runId)
2179
+ ActiveFibers.register(fibers, runId, fiber)
2180
+ }),
2181
+ interrupt: Effect.fn("SqlControlRuntime.interrupt")(function*(runId: RunId, settle = (effect) => effect) {
2182
+ const row = yield* requireRow(runId)
2183
+ const summary = yield* summaryOf(row)
2184
+ // Terminality is asked FIRST, as `resume` asks it. A settled run has
2185
+ // released its owner, so `ownedByUs` is false for every process
2186
+ // including the one that ran it, and asking ownership first answered
2187
+ // `ClaimLost` — "somebody else has it" — for a run that had simply
2188
+ // finished. Its caller has a `Terminal` receipt for exactly this.
2189
+ if (terminal(summary.status)) return summary
2190
+ if (!ownedByUs(row)) return yield* new ClaimLost({ runId })
2191
+ const fiber = fibers.get(runId)
2192
+ // Cancellation is fiber interruption, not a flag anyone polls.
2193
+ if (fiber !== undefined) yield* Fiber.interrupt(fiber)
2194
+ if (fibers.get(runId) === fiber) fibers.delete(runId)
2195
+ // Only reconciliation holds the writer. Re-read after finalizers and
2196
+ // retain the original fence so cleanup cannot transfer this cancel to
2197
+ // a replacement owner or overwrite a terminal outcome.
2198
+ return yield* settle(
2199
+ writer.write(Effect.gen(function*() {
2200
+ const current = yield* summaryOf(yield* requireRow(runId))
2201
+ if (terminal(current.status)) return current
2202
+ return yield* transition(runId, row.owner, current, "cancelled")
2203
+ })).pipe(
2204
+ Effect.catchTag(
2205
+ "@smthrs/database/DatabaseError",
2206
+ (error) => Effect.fail(persistence("settle an interrupted run")(error))
2207
+ )
2208
+ )
2209
+ )
2210
+ }),
2211
+ codeDrift: Effect.fn("SqlControlRuntime.codeDrift")(function*(runId: RunId) {
2212
+ const summary = yield* recordedCode(yield* requireRow(runId))
2213
+ const current = (yield* readCurrentFlows).get(summary.flowId)
2214
+ const pinned = summary.executionDigest === undefined || options.pinnedFlow === undefined
2215
+ ? undefined :
2216
+ yield* options.pinnedFlow(summary.flowId, summary.executionDigest)
2217
+ return codeDriftOf(
2218
+ summary,
2219
+ pinned === true ?
2220
+ { executionDigest: summary.executionDigest }
2221
+ : pinned === false
2222
+ ? undefined
2223
+ : current,
2224
+ options.engineVersion
2225
+ )
2226
+ }),
2227
+ recordedCode: Effect.fn("SqlControlRuntime.recordedCode")(function*(runId: RunId) {
2228
+ const { executionDigest, engineVersion } = yield* recordedCode(yield* requireRow(runId))
2229
+ return { executionDigest, engineVersion }
2230
+ }),
2231
+ resume: Effect.fn("SqlControlRuntime.resume")((
2232
+ runId: RunId,
2233
+ resumeOptions?: { readonly scope?: "launched" | "any" | undefined } | undefined
2234
+ ) => resumeRun(runId, resumeOptions?.scope)),
2235
+ resumeAdopting: Effect.fn("SqlControlRuntime.resumeAdopting")((
2236
+ runId: RunId,
2237
+ resumeOptions?: { readonly scope?: "launched" | "any" | undefined } | undefined
2238
+ ) => resumeRun(runId, resumeOptions?.scope, adoptable)),
2239
+ claimFence: Effect.fn("SqlControlRuntime.claimFence")(function*(runId: RunId) {
2240
+ const row = yield* requireRow(runId)
2241
+ if (!ownedByUs(row)) return yield* new ClaimLost({ runId })
2242
+ return JSON.stringify(row.owner)
2243
+ }),
2244
+ releasePending: Effect.fn("SqlControlRuntime.releasePending")(function*(runId: RunId, fence: string) {
2245
+ const row = yield* requireRow(runId)
2246
+ const presented = yield* decodeStoredJson("control fence", Ownership.OwnerId, fence).pipe(
2247
+ Effect.mapError(() => new ClaimLost({ runId }))
2248
+ )
2249
+ const timestamp = yield* now
2250
+ const next: RunSummary = {
2251
+ ...yield* summaryOf(row),
2252
+ status: "accepted",
2253
+ ownerId: undefined,
2254
+ parkedBy: undefined,
2255
+ updatedAt: timestamp
2256
+ }
2257
+ const outcome = yield* runStore.transitionOwned(
2258
+ runId,
2259
+ presented,
2260
+ "suspended",
2261
+ JSON.stringify(next)
2262
+ ).pipe(Effect.mapError(persistence("release a pending run")))
2263
+ if (outcome._tag === "NotFound") return yield* new RunNotFound({ runId })
2264
+ if (outcome._tag !== "Transitioned") return yield* new ClaimLost({ runId })
2265
+ return next
2266
+ }),
2267
+ writeStatus: Effect.fn("SqlControlRuntime.writeStatus")(function*(
2268
+ runId: RunId,
2269
+ fence: string,
2270
+ status: RunStatus
2271
+ ) {
2272
+ const row = yield* requireRow(runId)
2273
+ const presented = yield* decodeStoredJson("control fence", Ownership.OwnerId, fence).pipe(
2274
+ Effect.mapError(() => new ClaimLost({ runId }))
2275
+ )
2276
+ return yield* transition(runId, presented, yield* summaryOf(row), status)
2277
+ }),
2278
+ /**
2279
+ * The submitted identity wins, and only the clock is the runtime's.
2280
+ *
2281
+ * `Control.RunMutationInput` states the order: the runtime "supplies its
2282
+ * own principal when the caller names none". The submitted one is the
2283
+ * identity the server authenticated at its boundary, so a composition
2284
+ * default that overrode it would rename every remote operator to
2285
+ * whatever this process was built with.
2286
+ */
2287
+ stampPrincipal: Effect.fn("SqlControlRuntime.stampPrincipal")(function*(submitted?: Principal | undefined) {
2288
+ const timestamp = yield* now
2289
+ return {
2290
+ id: submitted?.id ?? options.principal?.id ?? "local",
2291
+ kind: submitted?.kind ?? options.principal?.kind ?? "operator",
2292
+ stampedAt: timestamp
2293
+ }
2294
+ }),
2295
+ lookupMutation: Effect.fn("SqlControlRuntime.lookupMutation")(function*(
2296
+ key: IdempotencyKey,
2297
+ fingerprint: string
2298
+ ) {
2299
+ const rows = yield* sql<{ readonly fingerprint: string; readonly receiptJson: string }>`
2300
+ SELECT fingerprint, receipt_json AS "receiptJson" FROM control_mutations WHERE mutation_key = ${key}
2301
+ `.pipe(query("read a mutation"))
2302
+ const row = rows[0]
2303
+ if (row === undefined) return undefined
2304
+ if (row.fingerprint !== fingerprint) {
2305
+ return { _tag: "Conflict" as const, message: `idempotency key ${key} was used for another mutation` }
2306
+ }
2307
+ const receipt = yield* decodeStoredJson("control_mutations.receipt_json", Receipt, row.receiptJson)
2308
+ return alreadyApplied(key, receipt)
2309
+ }),
2310
+ recordMutation: Effect.fn("SqlControlRuntime.recordMutation")((
2311
+ key: IdempotencyKey,
2312
+ fingerprint: string,
2313
+ receipt: Receipt
2314
+ ) =>
2315
+ writer.write(Effect.gen(function*() {
2316
+ yield* sql`
2317
+ INSERT INTO control_mutations (mutation_key, fingerprint, receipt_json)
2318
+ VALUES (${key}, ${fingerprint}, ${JSON.stringify(receipt)})
2319
+ ON CONFLICT (mutation_key) DO NOTHING
2320
+ `
2321
+ const rows = yield* sql<{ readonly fingerprint: string; readonly receiptJson: string }>`
2322
+ SELECT fingerprint, receipt_json AS "receiptJson"
2323
+ FROM control_mutations WHERE mutation_key = ${key}
2324
+ `
2325
+ const stored = rows[0]
2326
+ if (
2327
+ stored === undefined || stored.fingerprint !== fingerprint || stored.receiptJson !== JSON.stringify(receipt)
2328
+ ) {
2329
+ return yield* Effect.fail(
2330
+ new PersistenceError({
2331
+ operation: "record a mutation",
2332
+ message: `Idempotency key ${key} was already settled by another mutation`
2333
+ })
2334
+ )
2335
+ }
2336
+ })).pipe(
2337
+ Effect.asVoid,
2338
+ Effect.mapError((cause) =>
2339
+ cause instanceof PersistenceError ? cause : persistence("record a mutation")(cause)
2340
+ )
2341
+ )
2342
+ ),
2343
+ claimRunKey: Effect.fn("SqlControlRuntime.claimRunKey")((
2344
+ key: IdempotencyKey,
2345
+ fingerprint: string
2346
+ ) => {
2347
+ const claimant = randomId()
2348
+ return writer.write(Effect.gen(function*() {
2349
+ yield* sql`
2350
+ INSERT INTO control_run_keys (idempotency_key, fingerprint, claimant)
2351
+ VALUES (${key}, ${fingerprint}, ${claimant})
2352
+ ON CONFLICT (idempotency_key) DO NOTHING
2353
+ `
2354
+ const holders = yield* sql<{
2355
+ readonly fingerprint: string
2356
+ readonly claimant: string
2357
+ }>`
2358
+ SELECT fingerprint, claimant FROM control_run_keys
2359
+ WHERE idempotency_key = ${key}
2360
+ `
2361
+ const holder = holders[0]
2362
+ if (holder === undefined) {
2363
+ return yield* new PersistenceError({
2364
+ operation: "claim a run key",
2365
+ message: `Run key ${key} disappeared while it was being claimed`
2366
+ })
2367
+ }
2368
+ if (holder.fingerprint !== fingerprint) {
2369
+ return yield* new InvalidInput({
2370
+ issue: `idempotency key ${key} was used for another run`
2371
+ })
2372
+ }
2373
+ if (holder.claimant === claimant) return { _tag: "Claimed" as const }
2374
+
2375
+ // Serialized writers make the winner's key and receipt visible in
2376
+ // one commit. Seeing its key without its receipt is therefore
2377
+ // corruption (or a claim written by an older, non-atomic build), not
2378
+ // permission to launch a second run.
2379
+ const rows = yield* sql<{ readonly fingerprint: string; readonly receiptJson: string }>`
2380
+ SELECT fingerprint, receipt_json AS "receiptJson"
2381
+ FROM control_mutations WHERE mutation_key = ${key}
2382
+ `
2383
+ const record = rows[0]
2384
+ if (record === undefined || record.fingerprint !== fingerprint) {
2385
+ return yield* new PersistenceError({
2386
+ operation: "claim a run key",
2387
+ message: `Run key ${key} has no matching settled receipt`
2388
+ })
2389
+ }
2390
+ const receipt = yield* decodeStoredJson("control_mutations.receipt_json", Receipt, record.receiptJson)
2391
+ return { _tag: "Raced" as const, receipt }
2392
+ })).pipe(
2393
+ Effect.mapError((cause) =>
2394
+ cause instanceof InvalidInput || cause instanceof PersistenceError
2395
+ ? cause
2396
+ : persistence("claim a run key")(cause)
2397
+ )
2398
+ )
2399
+ }),
2400
+ releaseRunKey: Effect.fn("SqlControlRuntime.releaseRunKey")((key: IdempotencyKey) =>
2401
+ writer.write(sql`
2402
+ DELETE FROM control_run_keys WHERE idempotency_key = ${key}
2403
+ `).pipe(
2404
+ Effect.asVoid,
2405
+ Effect.mapError(persistence("release a run key"))
2406
+ )
2407
+ ),
2408
+ grants: Effect.fn("SqlControlRuntime.grants")(() =>
2409
+ sql<{
2410
+ readonly tokenId: string
2411
+ readonly envelopeJson: string
2412
+ readonly scope: string
2413
+ readonly installedAtMs: number
2414
+ }>`
2415
+ SELECT token_id AS "tokenId", envelope_json AS "envelopeJson",
2416
+ scope, installed_at_ms AS "installedAtMs"
2417
+ FROM control_grants ORDER BY installed_at_ms, target_tag, run_id, target_id
2418
+ `.pipe(
2419
+ query("list grants"),
2420
+ Effect.flatMap((rows) =>
2421
+ Effect.forEach(rows, (row): Effect.Effect<BulkGrant, PersistenceError> =>
2422
+ Effect.all({
2423
+ envelope: decodeStoredJson("control_grants.envelope_json", Envelope, row.envelopeJson),
2424
+ scope: decodeStoredValue("control_grants.scope", GrantScope, row.scope)
2425
+ }).pipe(
2426
+ Effect.map(({ envelope, scope }) => ({
2427
+ tokenId: row.tokenId,
2428
+ envelope,
2429
+ scope,
2430
+ installedAt: Number(row.installedAtMs)
2431
+ }))
2432
+ ))
2433
+ )
2434
+ )
2435
+ )()
2436
+ })
2437
+ return service
2438
+ })
2439
+ }
2440
+
2441
+ /**
2442
+ * Provides a durable runtime over the ambient database and run store.
2443
+ *
2444
+ * @category layers
2445
+ * @since 0.1.0
2446
+ */
2447
+ export const layer = (
2448
+ options: Options = {}
2449
+ ): Layer.Layer<
2450
+ ControlRuntime,
2451
+ PersistenceError,
2452
+ Crypto.Crypto | DurableWriter | SqlClient.SqlClient | RunStore.RunStore
2453
+ > => Layer.effect(ControlRuntime)(makeRuntime(options))
2454
+
2455
+ /**
2456
+ * Provides a durable runtime and the run store it needs over the ambient
2457
+ * database.
2458
+ *
2459
+ * @category layers
2460
+ * @since 0.1.0
2461
+ */
2462
+ export const layerWithStore = (
2463
+ options: Options = {}
2464
+ ): Layer.Layer<
2465
+ ControlRuntime,
2466
+ PersistenceError,
2467
+ Crypto.Crypto | DurableWriter | SqlClient.SqlClient
2468
+ > => layer(options).pipe(Layer.provideMerge(RunStore.layer))
2469
+
2470
+ /**
2471
+ * Constructs a durable runtime over the ambient database and run store.
2472
+ *
2473
+ * @category constructors
2474
+ * @since 0.1.0
2475
+ */
2476
+ export { makeRuntime as make }