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

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 +1293 -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 +744 -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 +1522 -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 +1589 -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 +808 -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 +7 -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 +2127 -0
  385. package/src/ControlRpcs.ts +443 -0
  386. package/src/ControlRuntime.ts +1601 -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 +2478 -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,2127 @@
1
+ /**
2
+ * In-process Control implementation over `ControlRuntime`, the flow
3
+ * registry, and the append-only journal.
4
+ *
5
+ * @since 0.1.0
6
+ */
7
+
8
+ import * as Sha256 from "@smthrs/crypto/Sha256"
9
+ import * as Fault from "@smthrs/flow/Fault"
10
+ import { Journal, JournalEvent } from "@smthrs/journal"
11
+ import { NotificationQueue } from "@smthrs/notifications"
12
+ import * as SteerPayload from "@smthrs/notifications/SteerPayload"
13
+ import { Registry } from "@smthrs/registry"
14
+ import { inputDocument } from "@smthrs/registry/Descriptor"
15
+ import { Cause, Deferred, Effect, Exit, Layer, Option, Schema, Semaphore, Stream } from "effect"
16
+ import * as Cancellation from "./Cancellation.ts"
17
+ import {
18
+ type ApprovalInput,
19
+ Control,
20
+ type ListInput,
21
+ type RunMutationInput,
22
+ type Service,
23
+ type SignalInput,
24
+ type SteerInput,
25
+ type WatchInput
26
+ } from "./Control.ts"
27
+ import {
28
+ ClaimLost,
29
+ type CodeDrift,
30
+ type ControlError,
31
+ type EnvelopeMismatch,
32
+ InvalidInput,
33
+ type LaunchFailed,
34
+ NoMatchingWait,
35
+ PersistenceError,
36
+ type PlanDenied,
37
+ type PlanDigestMismatch,
38
+ type PlanNotFound,
39
+ RunNotFound,
40
+ Unauthorized,
41
+ Unavailable
42
+ } from "./ControlError.ts"
43
+ import type { CancelRecord, Launch } from "./ControlExecutor.ts"
44
+ import { ControlExecutor } from "./ControlExecutor.ts"
45
+ import * as ControlFacts from "./ControlFacts.ts"
46
+ import { ControlRuntime, type IdPage, type IdPageRequest, launchedByMatches, type RunPage } from "./ControlRuntime.ts"
47
+ import type {
48
+ ControlEvent,
49
+ FireSummary,
50
+ IdempotencyKey,
51
+ ListResponse,
52
+ Receipt,
53
+ RunHost,
54
+ RunId,
55
+ RunSummary,
56
+ TriggerSummary,
57
+ WatchFilter
58
+ } from "./ControlSchema.ts"
59
+ import {
60
+ ApprovalInputSchema,
61
+ defaultPageSize,
62
+ ListRequest,
63
+ maxPageSize,
64
+ Principal,
65
+ ReasonedMutationInputSchema,
66
+ ResumeInputSchema,
67
+ RunInputSchema,
68
+ SignalInputSchema,
69
+ SteerInputSchema,
70
+ steerItem,
71
+ WatchCursor
72
+ } from "./ControlSchema.ts"
73
+ import * as DispatchReader from "./DispatchReader.ts"
74
+ import { schemaIssuePath } from "./internal/issues.ts"
75
+ import * as MutationBoundary from "./internal/MutationBoundary.ts"
76
+ import { alreadyApplied, canonical, mutationKey } from "./internal/planning.ts"
77
+ import * as Lineage from "./Lineage.ts"
78
+ import * as Steering from "./Steering.ts"
79
+
80
+ const sourceId = JournalEvent.SourceId.make("/control")
81
+
82
+ const snapshotPageSize = 1024
83
+ const snapshotPartitionConcurrency = 8
84
+ /** Partition ids a global watch reads per keyed inventory page. */
85
+ const partitionPageSize = 100
86
+
87
+ const unavailable = (feature: string): Unavailable =>
88
+ new Unavailable({ feature, ticket: "control-runtime-engine-integration" })
89
+
90
+ /**
91
+ * What a failed journal read during `watch` answers.
92
+ *
93
+ * A closed journal means this composition has no journal to watch, so the
94
+ * feature is unavailable. Any other code is a storage failure and keeps its
95
+ * cause, so the operator sees what the journal reported.
96
+ */
97
+ const watchReadFailed = (cause: Journal.JournalError): Unavailable | PersistenceError =>
98
+ cause.code === "journal_closed"
99
+ ? unavailable("watch")
100
+ : new PersistenceError({
101
+ operation: "watch",
102
+ message: `Reading the control journal failed (${cause.code})`,
103
+ cause
104
+ })
105
+
106
+ const accepted = (key: IdempotencyKey, runId?: RunId): Receipt =>
107
+ runId === undefined
108
+ ? { _tag: "Accepted", receiptId: key }
109
+ : { _tag: "Accepted", receiptId: key, runId }
110
+
111
+ const terminal = (status: RunSummary["status"]): boolean =>
112
+ status === "cancelled" || status === "completed" || status === "failed"
113
+
114
+ /**
115
+ * Whether a status means a process is holding the run right now.
116
+ *
117
+ * `accepted` is what a claim writes, and nothing rewrites it until the run
118
+ * settles: only `Control.run` promotes a run to `running`, and only when its
119
+ * own executor took the launch. A run restarted by `Control.resume` or by an
120
+ * approval therefore spends its whole second life `accepted`. Both statuses
121
+ * project onto the store's `running` (`SqlControlRuntime`'s `storeStatus`), so
122
+ * a lost claim against either one means a live peer owns the row — which
123
+ * release policy 5.1 answers `ClaimLost`. Asking for the literal `running` alone
124
+ * answered `Accepted` for a peer's accepted run and hid the peer.
125
+ */
126
+ const live = (status: RunSummary["status"]): boolean => status === "running" || status === "accepted"
127
+
128
+ const terminalOrAccepted = (
129
+ key: IdempotencyKey,
130
+ run: RunSummary
131
+ ): Receipt =>
132
+ terminal(run.status)
133
+ ? { _tag: "Terminal", runId: run.runId, status: run.status }
134
+ : accepted(key, run.runId)
135
+
136
+ /**
137
+ * The two paths a SERVER stamps a principal onto, and the only two an
138
+ * idempotency fingerprint may ignore.
139
+ *
140
+ * `ControlServer` overwrites `input.principal` and `input.message.principal`
141
+ * with the identity it authenticated, and the stamp carries a wall clock, so
142
+ * keeping either made the second `smithers cancel` of one run look like a
143
+ * different mutation under the same key: a bearer-authenticated retry answered
144
+ * `Conflict` instead of the cancel's own receipt.
145
+ *
146
+ * Nothing else named `principal` is stamped. The previous replacer dropped the
147
+ * key at EVERY depth, so two signals whose payloads differed only in a nested
148
+ * `principal` collided under one key and the second payload was never
149
+ * delivered.
150
+ */
151
+ const withoutStampedPrincipal = (input: unknown): unknown => {
152
+ /* v8 ignore next -- every caller passes a mutation the boundary already decoded into a struct; the guard keeps the helper total for a reader */
153
+ if (input === null || typeof input !== "object") return input
154
+ const { principal: _principal, ...rest } = input as Record<string, unknown>
155
+ const message = rest["message"]
156
+ if (message === null || typeof message !== "object") return rest
157
+ const { principal: _messagePrincipal, ...messageRest } = message as Record<string, unknown>
158
+ return { ...rest, message: messageRest }
159
+ }
160
+
161
+ /**
162
+ * What an idempotency key is bound to: one actor's stated intent.
163
+ *
164
+ * The input has already crossed the inert boundary. The principal's stable id
165
+ * and kind remain in the document while its server clock is omitted, and the
166
+ * canonical bytes are reduced to one fixed-size durable digest.
167
+ */
168
+ const fingerprint = (operation: string, principal: typeof Principal.Type, input: unknown): string =>
169
+ `control-mutation:v2:${
170
+ Sha256.digestSync(canonical({
171
+ operation,
172
+ actor: { id: principal.id, kind: principal.kind },
173
+ intent: withoutStampedPrincipal(input)
174
+ }))
175
+ }`
176
+
177
+ const json = (value: unknown): ControlEvent["payload"] => JSON.parse(JSON.stringify(value)) as ControlEvent["payload"]
178
+
179
+ /**
180
+ * The trigger provenance an admitted plan input declared, verbatim.
181
+ *
182
+ * The control plane does not know the record's shape: the flow's own input
183
+ * schema bounded it at plan time (`flows/coding/dispatch.ts` `MessageTrigger`
184
+ * is the first). Admission carries it onto the accepted record so a journal
185
+ * reader — the Steps view's trigger row (#2115) — renders what was recorded,
186
+ * never what it inferred from a prompt. Anything that is not a record is
187
+ * dropped rather than carried.
188
+ */
189
+ const declaredTrigger = (decodedInput: unknown): Record<string, unknown> | undefined => {
190
+ if (decodedInput === null || typeof decodedInput !== "object" || Array.isArray(decodedInput)) return undefined
191
+ const held = (decodedInput as Record<string, unknown>)["trigger"]
192
+ return held !== null && typeof held === "object" && !Array.isArray(held) ? held as Record<string, unknown> : undefined
193
+ }
194
+
195
+ const invalid = (issue: string): InvalidInput => new InvalidInput({ issue })
196
+
197
+ const AttributedApprovalInput = Schema.Struct({
198
+ ...ApprovalInputSchema.fields,
199
+ principal: Schema.optional(Principal)
200
+ })
201
+ const AttributedReasonedMutationInput = Schema.Struct({
202
+ ...ReasonedMutationInputSchema.fields,
203
+ principal: Schema.optional(Principal)
204
+ })
205
+ const AttributedResumeInput = Schema.Struct({
206
+ ...ResumeInputSchema.fields,
207
+ principal: Schema.optional(Principal)
208
+ })
209
+ const AttributedRunInput = Schema.Union([
210
+ Schema.Struct({
211
+ ...RunInputSchema.members[0].fields,
212
+ principal: Schema.optional(Principal),
213
+ reservedRunId: Schema.optional(Schema.NonEmptyString)
214
+ }),
215
+ Schema.Struct({ ...RunInputSchema.members[1].fields, principal: Schema.optional(Principal) })
216
+ ])
217
+ const AttributedSignalInput = Schema.Struct({
218
+ ...SignalInputSchema.fields,
219
+ principal: Schema.optional(Principal)
220
+ })
221
+
222
+ // The surrogate scan restates, at the point the DURABLE KEY is formed, what
223
+ // `MutationBoundary.admit` already refused: a lone surrogate is not a string
224
+ // SQLite and JSON round-trip identically, and this value is a primary key. The
225
+ // two refusing arms are therefore unreachable through every caller, and stay
226
+ // as the local invariant rather than as a check somebody may delete upstream.
227
+ const validIdempotencyKey = (value: string): boolean => {
228
+ if (value.length === 0 || value.length > 1024 || value.includes("\0")) return false
229
+ for (let index = 0; index < value.length; index++) {
230
+ const unit = value.charCodeAt(index)
231
+ if (unit >= 0xd800 && unit <= 0xdbff) {
232
+ const low = value.charCodeAt(++index)
233
+ /* v8 ignore next -- an unpaired high surrogate is refused by the mutation boundary first */
234
+ if (!(low >= 0xdc00 && low <= 0xdfff)) return false
235
+ continue
236
+ }
237
+ /* v8 ignore next -- a lone low surrogate is refused by the mutation boundary first */
238
+ if (unit >= 0xdc00 && unit <= 0xdfff) return false
239
+ }
240
+ return true
241
+ }
242
+
243
+ /** Admits, schema-decodes, and detaches one mutation before its first wait. */
244
+ const snapshotMutation = <A extends { readonly idempotencyKey: string }>(
245
+ operation: string,
246
+ decode: (input: unknown) => Effect.Effect<A, Schema.SchemaError>,
247
+ input: unknown
248
+ ): Effect.Effect<A, InvalidInput> =>
249
+ Effect.suspend(() => {
250
+ const admitted = MutationBoundary.admit(input)
251
+ if (!admitted.ok) return Effect.fail(invalid(`${operation}: ${admitted.complaint}`))
252
+ return decode(admitted.value).pipe(
253
+ Effect.mapError((error) => invalid(`${operation}: invalid mutation at ${schemaIssuePath(error)}`)),
254
+ Effect.flatMap((snapshot) =>
255
+ validIdempotencyKey(snapshot.idempotencyKey)
256
+ ? Effect.succeed(snapshot)
257
+ : Effect.fail(invalid(`${operation}.idempotencyKey: must be 1 to 1024 well-formed characters`))
258
+ )
259
+ )
260
+ })
261
+
262
+ const snapshotApproval = (operation: string, input: unknown) =>
263
+ snapshotMutation(operation, Schema.decodeUnknownEffect(AttributedApprovalInput), input)
264
+ const snapshotReasonedMutation = (operation: string, input: unknown) =>
265
+ snapshotMutation(operation, Schema.decodeUnknownEffect(AttributedReasonedMutationInput), input)
266
+ const snapshotResume = (input: unknown) =>
267
+ snapshotMutation("resume", Schema.decodeUnknownEffect(AttributedResumeInput), input)
268
+ const snapshotRun = (input: unknown) => snapshotMutation("run", Schema.decodeUnknownEffect(AttributedRunInput), input)
269
+ const snapshotSignal = (input: unknown) =>
270
+ snapshotMutation("signal", Schema.decodeUnknownEffect(AttributedSignalInput), input)
271
+ const snapshotSteer = (input: unknown) => snapshotMutation("steer", Schema.decodeUnknownEffect(SteerInputSchema), input)
272
+
273
+ /**
274
+ * Refuses a page size or cursor that cannot make progress.
275
+ *
276
+ * A `limit` of zero, a negative or fractional one, `NaN`, and `Infinity` all
277
+ * used to answer `{ items: [], nextCursor: String(start) }`, which is a cursor
278
+ * a client loops on forever; an unparsable cursor silently restarted at the
279
+ * first page. Both are caller mistakes, and a control plane that answers a
280
+ * mistake with a plausible-looking page is the partial behaviour rc.0 forbids.
281
+ * `ControlSchema.PageLimit` refuses the same sizes on the wire; this is the
282
+ * in-process half, which no schema decodes.
283
+ */
284
+ const pageBounds = (
285
+ cursor: string | undefined,
286
+ limit: number | undefined
287
+ ): Effect.Effect<{ readonly start: number; readonly size: number }, InvalidInput> => {
288
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > maxPageSize)) {
289
+ return Effect.fail(
290
+ invalid(`limit: must be an integer between 1 and ${maxPageSize}, received ${String(limit)}`)
291
+ )
292
+ }
293
+ if (cursor === undefined) return Effect.succeed({ start: 0, size: limit ?? defaultPageSize })
294
+ const start = Number(cursor)
295
+ return Number.isSafeInteger(start) && start >= 0
296
+ ? Effect.succeed({ start, size: limit ?? defaultPageSize })
297
+ : Effect.fail(invalid(`cursor: must be a cursor this listing returned, received ${JSON.stringify(cursor)}`))
298
+ }
299
+
300
+ const cursorNatural = Schema.Number.check(
301
+ Schema.isInt(),
302
+ Schema.isGreaterThanOrEqualTo(0),
303
+ Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)
304
+ )
305
+ const runCursor = Schema.fromJsonString(Schema.Struct({
306
+ version: Schema.Literal(1),
307
+ filters: Schema.String,
308
+ source: Schema.Union([Schema.Literal(0), Schema.Literal(1)]),
309
+ sequence: cursorNatural,
310
+ createdAt: cursorNatural,
311
+ runId: Schema.NonEmptyString
312
+ }))
313
+
314
+ const page = <A>(
315
+ values: ReadonlyArray<A>,
316
+ bounds: { readonly start: number; readonly size: number }
317
+ ): { readonly items: ReadonlyArray<A>; readonly nextCursor?: string | undefined } => {
318
+ const items = values.slice(bounds.start, bounds.start + bounds.size)
319
+ const next = bounds.start + items.length
320
+ return next < values.length ? { items, nextCursor: String(next) } : { items }
321
+ }
322
+
323
+ const eventFromEntry = (entry: JournalEvent.Entry): ControlEvent => ({
324
+ sequence: entry.seq,
325
+ kind: entry.eventType,
326
+ runId: entry.runId,
327
+ occurredAt: entry.emittedAtMs,
328
+ payload: entry.payload as ControlEvent["payload"]
329
+ })
330
+
331
+ /**
332
+ * Live in-process Control layer.
333
+ *
334
+ * Writes delegate to `ControlRuntime`; journal events are observational
335
+ * records. `watch` only replays and follows committed journal entries.
336
+ *
337
+ * @category layers
338
+ * @since 0.1.0
339
+ */
340
+ export const layer: Layer.Layer<
341
+ Control,
342
+ never,
343
+ ControlRuntime | Journal.Journal | NotificationQueue.NotificationQueue | Registry.Registry
344
+ > = Layer.effect(
345
+ Control,
346
+ Effect.gen(function*() {
347
+ const runtime = yield* ControlRuntime
348
+ const journal = yield* Journal.Journal
349
+ const notifications = yield* NotificationQueue.NotificationQueue
350
+ const registry = yield* Registry.Registry
351
+ const executor = yield* Effect.serviceOption(ControlExecutor)
352
+ // Optional like the executor: a composition without a trigger store still
353
+ // plans, runs, and lists flows and runs. Only the two trigger listings
354
+ // refuse, and they refuse with the same typed issue `layerNone` answers,
355
+ // so a caller cannot tell an omitted port from a declared empty one.
356
+ const dispatch = Option.getOrElse(
357
+ yield* Effect.serviceOption(DispatchReader.DispatchReader),
358
+ DispatchReader.makeNone
359
+ )
360
+ /**
361
+ * Whether this composition has an executor that can be asked about a run.
362
+ *
363
+ * Read once: the executor is captured when the layer is built, so the
364
+ * answer cannot change between two listings of one page.
365
+ */
366
+ const observing = Option.isSome(executor) && executor.value.readExecution !== undefined
367
+ const observe = (run: RunSummary): Effect.Effect<RunSummary, PersistenceError> =>
368
+ Effect.gen(function*() {
369
+ if (Option.isNone(executor) || executor.value.readExecution === undefined) return run
370
+ const observed = yield* executor.value.readExecution(run.runId)
371
+ if (observed._tag === "Missing") return { ...run, executionObservation: "missing" as const }
372
+ return {
373
+ ...run,
374
+ executionObservation: "observed" as const,
375
+ executionView: observed.executionView,
376
+ status: observed.status,
377
+ waitingReason: observed.waitingReason,
378
+ parentRunId: observed.parentRunId,
379
+ lineageId: observed.lineageId,
380
+ roundOrdinal: observed.roundOrdinal,
381
+ // The executor's answer replaces this plane's copy here as it does
382
+ // for every other observed field. It has to: a control plane over
383
+ // its own coordination database cannot see the executions a flow
384
+ // spawned, so a nested `HumanTask` park is a fact only the executor
385
+ // holds. Where the two share one database the runtime computes the
386
+ // same rows itself and the observation reproduces them.
387
+ pendingWaits: observed.pendingWaits
388
+ }
389
+ })
390
+ const getRun = (runId: string) => runtime.getRun(runId).pipe(Effect.flatMap(observe))
391
+
392
+ /** Fails `CodeDrift` when re-driving the run would enter code it did not start on. */
393
+ const refuseCodeDrift = (runId: RunId) =>
394
+ runtime.codeDrift(runId).pipe(Effect.flatMap((drift) => drift === undefined ? Effect.void : Effect.fail(drift)))
395
+
396
+ /**
397
+ * The run with the drift a resume would refuse, for an operator reading it.
398
+ * A drift that cannot be computed is left absent rather than failing the read.
399
+ */
400
+ const withCodeDrift = (run: RunSummary): Effect.Effect<RunSummary> =>
401
+ terminal(run.status)
402
+ ? Effect.succeed(run)
403
+ : runtime.codeDrift(run.runId).pipe(
404
+ Effect.map((drift) =>
405
+ drift === undefined ? run : {
406
+ ...run,
407
+ codeDrift: {
408
+ ...(drift.recorded === undefined ? {} : { recorded: drift.recorded }),
409
+ ...(drift.current === undefined ? {} : { current: drift.current }),
410
+ ...(drift.recordedEngine === undefined ? {} : { recordedEngine: drift.recordedEngine }),
411
+ ...(drift.currentEngine === undefined ? {} : { currentEngine: drift.currentEngine })
412
+ }
413
+ }
414
+ ),
415
+ Effect.orElseSucceed(() => run)
416
+ )
417
+
418
+ const mutationSemaphore = yield* Semaphore.make(1)
419
+
420
+ /** Journals one control event and answers its journal sequence. */
421
+ const record = (
422
+ runId: string,
423
+ eventType: string,
424
+ payload: ControlEvent["payload"]
425
+ ): Effect.Effect<number, PersistenceError> =>
426
+ // Unfenced: the control plane mutates runs it does not own — that is
427
+ // the point of a control plane — so its event records are
428
+ // first-writer-wins admissions, not owner-fenced lifecycle writes.
429
+ journal.emitDurableUnfenced(
430
+ new JournalEvent.Input({
431
+ runId: JournalEvent.RunId.make(runId),
432
+ sourceId,
433
+ eventType,
434
+ payload: json(payload)
435
+ })
436
+ ).pipe(
437
+ Effect.map((receipt) => receipt.seq),
438
+ Effect.mapError((cause) =>
439
+ new PersistenceError({
440
+ operation: eventType,
441
+ message: `Failed to persist ${eventType}`,
442
+ cause
443
+ })
444
+ )
445
+ )
446
+
447
+ const emit = (
448
+ runId: string,
449
+ eventType: string,
450
+ payload: ControlEvent["payload"]
451
+ ): Effect.Effect<void, PersistenceError> => Effect.asVoid(record(runId, eventType, payload))
452
+
453
+ /**
454
+ * Ends a run the executor was handed and could not take.
455
+ *
456
+ * `ControlExecutor.launch` fails when nothing in this composition will
457
+ * ever drive the run: no seat resolved, the flow declares none, the body
458
+ * would not load, the provider could not be constructed. The run row
459
+ * and its idempotency receipt are already durable by then. Keep that
460
+ * admission and record the failure; replaying its key must never launch
461
+ * another run. An explicit retry uses a new key.
462
+ *
463
+ * A settlement that cannot be written is logged rather than raised. The
464
+ * caller is already receiving the refusal it has to act on, and replacing
465
+ * it with a persistence error would hide which key is missing.
466
+ */
467
+ const settleUnlaunched = (
468
+ runId: RunId,
469
+ cause: string,
470
+ fault: Fault.Fault
471
+ ): Effect.Effect<void> =>
472
+ Effect.gen(function*() {
473
+ const fence = yield* runtime.claimFence(runId)
474
+ const run = yield* runtime.writeStatus(runId, fence, "failed")
475
+ yield* emit(
476
+ runId,
477
+ "control.run.failed",
478
+ json({ runId, status: "failed", cause: cause.slice(0, 4096), fault, ...ControlFacts.runFact(run) })
479
+ )
480
+ }).pipe(
481
+ journal.transact,
482
+ Effect.catchCause((failure) =>
483
+ Effect.annotateLogs(
484
+ Effect.logWarning("A refused launch could not be settled"),
485
+ { runId, cause: Cause.pretty(failure) }
486
+ )
487
+ )
488
+ )
489
+
490
+ const transact = <A, E, R>(
491
+ operation: string,
492
+ effect: Effect.Effect<A, E, R>
493
+ ): Effect.Effect<A, E | PersistenceError, R> =>
494
+ mutationSemaphore.withPermits(1)(
495
+ journal.transact(effect).pipe(
496
+ Effect.mapError((cause) =>
497
+ cause instanceof Journal.JournalError
498
+ ? new PersistenceError({
499
+ operation: `${operation}.idempotency`,
500
+ message: `Failed to commit ${operation} and its idempotency receipt atomically`,
501
+ cause
502
+ })
503
+ : cause
504
+ )
505
+ )
506
+ )
507
+
508
+ /**
509
+ * Runs one mutation under its idempotency key.
510
+ *
511
+ * `replay` is what a recorded receipt is worth on a second ask. For every
512
+ * mutation that CHANGES something — a launch, a decision, a signal — it is
513
+ * everything: the receipt is the proof the change was made once, and
514
+ * replaying it is the whole guarantee.
515
+ *
516
+ * `cancel` is the exception, and it is `replay: false`. Its receipt is an
517
+ * answer ABOUT a run, and the run can be in a different state by the time
518
+ * the operator asks again: a cancel against a run a live peer owns answers
519
+ * `Accepted` and finishes nothing, and replaying that answer as
520
+ * `AlreadyApplied` turned a run nobody could reach into a run nobody could
521
+ * ask about either — the release validation left two of them, with `smithers
522
+ * cancel` and `smithers down` both answering from the receipt and neither
523
+ * ever reaching the row. Cancellation needs no receipt to be idempotent:
524
+ * the run's own terminality is stronger, and `cancel` reads it first and
525
+ * answers `Terminal` without touching anything.
526
+ */
527
+ const mutate = <E, R>(
528
+ operation: string,
529
+ key: IdempotencyKey,
530
+ principal: typeof Principal.Type,
531
+ mutationFingerprint: string,
532
+ effect: Effect.Effect<Receipt, E, R>,
533
+ replay = true,
534
+ claimRunKey = false
535
+ ): Effect.Effect<Receipt, E | InvalidInput | PersistenceError, R> =>
536
+ transact(
537
+ operation,
538
+ Effect.gen(function*() {
539
+ const durableKey = mutationKey(operation, key, principal)
540
+ const prior = yield* runtime.lookupMutation(durableKey, mutationFingerprint)
541
+ if (prior !== undefined && (replay || prior._tag === "Conflict")) {
542
+ return prior._tag === "AlreadyApplied"
543
+ ? { ...prior, receiptId: key }
544
+ : prior
545
+ }
546
+ const claim = claimRunKey
547
+ ? yield* runtime.claimRunKey(durableKey, mutationFingerprint)
548
+ : undefined
549
+ if (claim?._tag === "Raced") return alreadyApplied(key, claim.receipt)
550
+ return yield* Effect.gen(function*() {
551
+ const receipt = yield* effect
552
+ // A key that already carries a receipt is not re-recorded: the store
553
+ // refuses to overwrite one, and the answer this call returns is the
554
+ // fresh read of the run rather than the record.
555
+ if (receipt._tag === "Parked" && claimRunKey) {
556
+ yield* runtime.releaseRunKey(durableKey)
557
+ } else if (receipt._tag !== "Parked" && prior === undefined) {
558
+ yield* runtime.recordMutation(durableKey, mutationFingerprint, receipt)
559
+ }
560
+ return receipt
561
+ }).pipe(Effect.onExit((exit) =>
562
+ claim?._tag === "Claimed" && Exit.isFailure(exit)
563
+ ? runtime.releaseRunKey(durableKey)
564
+ : Effect.void
565
+ ))
566
+ })
567
+ )
568
+
569
+ /**
570
+ * Hands a decided run's resume to whoever hosts its execution.
571
+ *
572
+ * Outside the mutation's write transaction, for `signal`'s reason: taking
573
+ * the resume up re-drives the run, and the engine's own writes would wait
574
+ * on the writer the transaction holds. A host that answers `resuming` has
575
+ * claimed the row and is driving, so its delegation is cleared here; every
576
+ * other composition leaves it standing for the host's own poll.
577
+ */
578
+ const takeUpResume = (
579
+ runId: RunId,
580
+ sequence: number
581
+ ): Effect.Effect<void, PersistenceError> =>
582
+ Option.isNone(executor)
583
+ ? Effect.void
584
+ : executor.value.resumeRun({ runId }).pipe(
585
+ Effect.flatMap((uptake) => uptake === "resuming" ? runtime.clearResume(runId, sequence) : Effect.void)
586
+ )
587
+
588
+ /**
589
+ * Hands an admitted run to the executor, then records its acknowledgment.
590
+ *
591
+ * Outside the admission's transaction: the executor may immediately read
592
+ * through another connection or fork a driver, so the run, its approval
593
+ * and its receipt are committed before any execution crosses this line.
594
+ */
595
+ const hand = (launch: Launch) =>
596
+ Effect.gen(function*() {
597
+ const acceptance = Option.isSome(executor)
598
+ ? yield* executor.value.launch(launch).pipe(
599
+ Effect.tapError((error) => settleUnlaunched(error.runId, error.message, Fault.of(error)))
600
+ )
601
+ : "pending"
602
+ yield* transact(
603
+ "run.acceptance",
604
+ Effect.gen(function*() {
605
+ // A fast executor may already have completed or parked. Never
606
+ // regress its durable outcome with the launch acknowledgment.
607
+ const current = yield* runtime.getRun(launch.run.runId)
608
+ if (current.status !== "accepted") return
609
+ const fence = yield* runtime.claimFence(current.runId)
610
+ const run = acceptance === "accepted"
611
+ ? yield* runtime.writeStatus(current.runId, fence, "running")
612
+ : yield* runtime.releasePending(current.runId, fence)
613
+ yield* emit(
614
+ run.runId,
615
+ acceptance === "accepted" ? "control.run.running" : "control.run.pending",
616
+ {
617
+ runId: run.runId,
618
+ status: run.status,
619
+ ...ControlFacts.runFact(run)
620
+ } as ControlEvent["payload"]
621
+ )
622
+ })
623
+ )
624
+ })
625
+
626
+ /**
627
+ * Launches a run whose admitting process died before handing it over.
628
+ *
629
+ * The admission and its receipt commit before {@link hand}, so a crash in
630
+ * between left the run `accepted` under its dead admitter, and a retry of
631
+ * the same key only read the receipt back: nothing ever launched it. The
632
+ * acknowledgment is what `accepted` with an owner lacks — a queued launch
633
+ * releases its owner. This process owning the run means another call here
634
+ * is between its admission and its launch; a live peer owning it is the
635
+ * same case elsewhere and keeps it (`ClaimLost`). Only a dead owner's run is
636
+ * claimed, under the run store's fence, so two retries launch it once.
637
+ */
638
+ const relaunchStranded = (runId: RunId) =>
639
+ Effect.gen(function*() {
640
+ const current = yield* runtime.getRun(runId)
641
+ if (current.status !== "accepted" || current.ownerId === undefined) return
642
+ const ours = yield* runtime.claimFence(runId).pipe(
643
+ Effect.as(true),
644
+ Effect.catchTag("/control/ClaimLost", () => Effect.succeed(false))
645
+ )
646
+ if (ours) return
647
+ yield* refuseCodeDrift(runId)
648
+ const claimed = yield* runtime.resume(runId, { scope: "launched" }).pipe(
649
+ Effect.catchTag("/control/ClaimLost", () => Effect.succeed(undefined))
650
+ )
651
+ if (claimed === undefined || claimed.status !== "accepted" || claimed.planId === undefined) return
652
+ yield* hand({ plan: yield* runtime.getPlan(claimed.planId), run: claimed })
653
+ }).pipe(
654
+ // The recorded receipt is the retry's answer either way; a relaunch
655
+ // that fails leaves the run where it was for the next retry.
656
+ Effect.catch((failure) =>
657
+ Effect.annotateLogs(
658
+ Effect.logWarning("A stranded run could not be relaunched"),
659
+ { runId, cause: failure.message }
660
+ )
661
+ )
662
+ )
663
+
664
+ const decide = (
665
+ decision: "approved" | "denied",
666
+ submitted: ApprovalInput
667
+ ) =>
668
+ Effect.gen(function*() {
669
+ const input = yield* snapshotApproval(decision, submitted)
670
+ // Check the authenticated identity before replay or any grant writes.
671
+ const principal = yield* runtime.stampPrincipal(input.principal)
672
+ return yield* decideAs(decision, input, principal).pipe(
673
+ // Every refusal leaves a durable audit record: who asked, for which
674
+ // target, under which scope. The journal row's time is when. It is
675
+ // written after the refused mutation rolled back, so it is the only
676
+ // row the refusal adds; a record that cannot be written fails the
677
+ // call rather than leaving an unaudited refusal.
678
+ Effect.catchTag("/control/Unauthorized", (refusal) =>
679
+ emit(
680
+ input.target._tag === "Plan" ? `plan:${input.target.planId}` : input.target.runId,
681
+ "control.approval.refused",
682
+ json({ decision, principal, scope: input.scope, target: input.target })
683
+ ).pipe(Effect.andThen(Effect.fail(refusal))))
684
+ )
685
+ })
686
+
687
+ const decideAs = (
688
+ decision: "approved" | "denied",
689
+ input: Effect.Success<ReturnType<typeof snapshotApproval>>,
690
+ principal: typeof Principal.Type
691
+ ) =>
692
+ Effect.gen(function*() {
693
+ // Authorization precedes target reads and idempotency replay: neither
694
+ // an old receipt nor a terminal run confers authority on this caller.
695
+ yield* runtime.authorizeApproval({ principal, target: input.target, decision, scope: input.scope })
696
+ // A decision on a settled run decides nothing, and it is read BEFORE
697
+ // the idempotency replay for `resume`'s reason: the recorded receipt
698
+ // describes the earlier call, not the run. Answering `Accepted` sent
699
+ // `smithers approve` into `awaitRun` waiting for a settlement that had
700
+ // already happened — the release validation's 120-second silent block — and
701
+ // recorded a resume delegation for a run no host may take up.
702
+ //
703
+ // A plan-level decision has no run yet, and a target whose run this
704
+ // plane cannot find is left to `lookupApproval` to refuse.
705
+ if (input.target._tag === "Node") {
706
+ const current = yield* getRun(input.target.runId).pipe(
707
+ Effect.catchTag("/control/RunNotFound", () => Effect.succeed(undefined))
708
+ )
709
+ if (current !== undefined && terminal(current.status)) {
710
+ const settled: Receipt = { _tag: "Terminal", runId: current.runId, status: current.status }
711
+ return settled
712
+ }
713
+ }
714
+ // Set by the mutation when it records a delegation, and left unset on
715
+ // the idempotency replay path — where the original call already
716
+ // delegated and the host's own poll is what takes it up.
717
+ let delegated: number | undefined
718
+ const receipt = yield* mutate(
719
+ decision,
720
+ input.idempotencyKey,
721
+ principal,
722
+ fingerprint(decision, principal, input),
723
+ Effect.gen(function*() {
724
+ // A node decision restarts the run, so it re-enters the flow's
725
+ // current code: a changed flow is refused before anything is
726
+ // resolved, and the run stays waiting for this decision.
727
+ if (input.target._tag === "Node") yield* refuseCodeDrift(input.target.runId)
728
+ const token = yield* runtime.lookupApproval(input.target)
729
+ // Resolve (and recheck authority) before installing any grant. The
730
+ // durable adapter commits all three writes in this transaction;
731
+ // the memory test adapter must also leave no grant on refusal.
732
+ yield* runtime.resolveApproval(token, decision, principal, input.scope)
733
+ if (decision === "approved") {
734
+ yield* runtime.installBulkGrant(token, input.target.envelope, input.scope)
735
+ }
736
+ yield* emit(
737
+ input.target._tag === "Plan" ? `plan:${input.target.planId}` : input.target.runId,
738
+ `control.approval.${decision}`,
739
+ json({
740
+ ...ControlFacts.approvalDecisionFact(token.tokenId, input.target),
741
+ target: input.target._tag,
742
+ scope: input.scope,
743
+ envelope: input.target.envelope,
744
+ principal
745
+ })
746
+ )
747
+ if (input.target._tag === "Plan") return accepted(input.idempotencyKey)
748
+ // A decision on a node target has to restart the run the ask parked,
749
+ // in this call. Answering without a restart left the run in
750
+ // `waiting-approval` until a second call arrived, and a denial the
751
+ // run never learns about is a denial that decided nothing.
752
+ //
753
+ // The restart is recorded, not performed, and this plane does NOT
754
+ // claim the row. `scope: "launched"` reads like a process scope and
755
+ // is not one — it is a `control_runs` lookup, a durable table every
756
+ // process over one control database shares — so claiming here took
757
+ // the row away from the host that could still drive it, and left it
758
+ // `accepted` under a process with no executor. The delegation is
759
+ // durable instead: the host takes it up on its next poll and clears
760
+ // it, and the journal entry stays as the operator's record of why
761
+ // (triage B-15).
762
+ const runId = input.target.runId
763
+ delegated = yield* runtime.requestResume(runId)
764
+ yield* emit(runId, "control.run.resumed", { runId })
765
+ return accepted(input.idempotencyKey, runId)
766
+ })
767
+ )
768
+ if (input.target._tag === "Node" && delegated !== undefined) {
769
+ yield* takeUpResume(input.target.runId, delegated)
770
+ }
771
+ return receipt
772
+ })
773
+
774
+ /**
775
+ * Restarts a parked run, by claiming it or by asking whoever owns it.
776
+ *
777
+ * A run this plane launched is this plane's to claim, and `scope:
778
+ * "launched"` is how the runtime is told to check. A run the ENGINE created
779
+ * — a child, a fork, a trampoline round — has its own driver, and claiming
780
+ * it here overwrote the engine's `state_json` and owner columns with a
781
+ * control-plane summary, after which that driver's `scheduleResume` no
782
+ * longer recognized the row: the run stayed suspended with its waiting
783
+ * reason set and its execution never returned (control-plane example 38).
784
+ *
785
+ * Both public resume spellings journal `control.run.resume`. The caller
786
+ * or a host-supplied journal subscriber must drive the execution, and
787
+ * this path never offers `executor.resumeRun`. A run a live peer is
788
+ * HOLDING — `running`, or the `accepted` a claim writes and only a
789
+ * settlement rewrites — is still `ClaimLost`: there is nothing to
790
+ * restart, and pretending otherwise would hide the peer.
791
+ *
792
+ * A run a live host PARKED is that host's to drive: claiming it would
793
+ * steal its execution (#3342), and refusing left a lease-lapsed park
794
+ * nothing could restart. The resume is handed to the host instead. The
795
+ * journal entry names the host, and a durable delegation carries its
796
+ * sequence as the operator's consent, which the host records as the
797
+ * per-release retry permission before it re-drives the run (#2982). The
798
+ * receipt's `handedTo` tells the caller it holds nothing to drive.
799
+ */
800
+ const runMutation = (
801
+ submitted: RunMutationInput
802
+ ): Effect.Effect<Receipt, RunNotFound | ClaimLost | CodeDrift | InvalidInput | PersistenceError> =>
803
+ Effect.gen(function*() {
804
+ const input = yield* snapshotResume(submitted)
805
+ // Terminality is read BEFORE the idempotency replay, as `cancel` reads
806
+ // it. A recorded receipt is the proof a restart was made once; it is
807
+ // not an answer about the run, and the run settles afterwards. The
808
+ // release validation asked `run --resume` for a completed run and was told
809
+ // `AlreadyApplied`, which describes the earlier call and says nothing
810
+ // about the run the operator named (spec item 3).
811
+ const settled = yield* getRun(input.runId)
812
+ if (terminal(settled.status)) {
813
+ return { _tag: "Terminal", runId: settled.runId, status: settled.status }
814
+ }
815
+ const principal = yield* runtime.stampPrincipal(input.principal)
816
+ return yield* mutate(
817
+ "resume",
818
+ input.idempotencyKey,
819
+ principal,
820
+ fingerprint("resume", principal, input),
821
+ Effect.gen(function*() {
822
+ const current = yield* getRun(input.runId)
823
+ if (terminal(current.status)) {
824
+ return { _tag: "Terminal", runId: current.runId, status: current.status }
825
+ }
826
+ // Every claim re-enters the flow's current code, so a changed flow
827
+ // is refused before the claim and the run stays where it was.
828
+ if (input.allowCodeDrift !== true) yield* refuseCodeDrift(input.runId)
829
+ let handedTo: RunHost | undefined
830
+ const claimed = yield* (input.allowCodeDrift === true
831
+ ? runtime.resumeAdopting(input.runId, { scope: "launched" })
832
+ : runtime.resume(input.runId, { scope: "launched" })).pipe(
833
+ Effect.catchTag("/control/ClaimLost", (error) => {
834
+ if (error.parkedBy !== undefined) {
835
+ handedTo = error.parkedBy
836
+ return Effect.succeed(undefined)
837
+ }
838
+ if (error.reason !== undefined) return Effect.fail(error)
839
+ return runtime.getRun(input.runId).pipe(Effect.flatMap((stored) =>
840
+ // A retained fork is parked in control and pending in the
841
+ // engine. Its accepted read overlay is not a live claim.
842
+ // Never replace that engine continuation with control state.
843
+ live(stored.status) ||
844
+ (live(current.status) && current.executionView?.current.status !== "pending")
845
+ ? Effect.fail(new ClaimLost({ runId: input.runId }))
846
+ : Effect.succeed(undefined)
847
+ ))
848
+ })
849
+ )
850
+ // The same attribution `cancel` writes, for the same reason: the
851
+ // contract records `reason` on the journal entry the mutation
852
+ // writes and `principal` as stamped by the runtime, and a resume
853
+ // that carried neither left an operator unable to say who restarted
854
+ // a run or why.
855
+ const sequence = yield* record(
856
+ input.runId,
857
+ "control.run.resume",
858
+ json({
859
+ runId: input.runId,
860
+ status: (claimed ?? current).status,
861
+ ...(claimed === undefined ? {} : ControlFacts.runFact(claimed)),
862
+ principal,
863
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
864
+ ...(handedTo === undefined ? {} : { handedTo })
865
+ })
866
+ )
867
+ if (handedTo !== undefined) {
868
+ yield* runtime.requestResume(input.runId, { consent: sequence })
869
+ const handedOff: Receipt = {
870
+ _tag: "Accepted",
871
+ receiptId: input.idempotencyKey,
872
+ runId: input.runId,
873
+ handedTo
874
+ }
875
+ return handedOff
876
+ }
877
+ return claimed === undefined
878
+ ? accepted(input.idempotencyKey, input.runId)
879
+ : terminalOrAccepted(input.idempotencyKey, claimed)
880
+ })
881
+ )
882
+ })
883
+
884
+ /**
885
+ * Makes a cancellation durable on the engine row through the executor.
886
+ *
887
+ * Absent executor, absent engine: the composition runs nothing, so there is
888
+ * no row to write and the local interrupt is the whole cancel. An executor
889
+ * that answers `unknown` has an engine that never heard of the run, which
890
+ * is the same situation with a different messenger.
891
+ */
892
+ const executorRequestCancel = (
893
+ runId: RunId
894
+ ): Effect.Effect<CancelRecord, PersistenceError> =>
895
+ Option.isNone(executor)
896
+ ? Effect.succeed("unknown" as const)
897
+ : executor.value.requestCancel({ runId })
898
+
899
+ /**
900
+ * Finishes the parked execution the cancel just recorded a request on.
901
+ *
902
+ * Outside the mutation's write transaction, for `takeUpResume`'s reason:
903
+ * settling a park re-enters the engine, and the engine's writes would wait
904
+ * on the writer the transaction holds — which deadlocks the cancel rather
905
+ * than slowing it. So this runs on the way out, once the request and the
906
+ * terminal control status are both committed.
907
+ */
908
+ const executorSettleCancelledPark = (
909
+ runId: RunId
910
+ ): Effect.Effect<void, PersistenceError> =>
911
+ Option.isNone(executor)
912
+ ? Effect.void
913
+ : executor.value.settleCancelledPark({ runId })
914
+
915
+ /**
916
+ * Moves this plane's row onto the status the engine already reached.
917
+ *
918
+ * A reconciliation that cannot be written is logged rather than raised, for
919
+ * `settleUnlaunched`'s reason: the caller is already receiving the engine's
920
+ * terminal receipt, which is the true answer, and a live peer holding the
921
+ * row will settle it itself.
922
+ */
923
+ const reconcileTerminal = (
924
+ runId: RunId,
925
+ status: RunSummary["status"]
926
+ ): Effect.Effect<void> =>
927
+ Effect.gen(function*() {
928
+ // Reconcile the coordination row itself. Applying the engine overlay
929
+ // here would make a stale row appear settled before it was persisted.
930
+ const current = yield* runtime.getRun(runId)
931
+ if (terminal(current.status)) return
932
+ const fence = yield* runtime.claimFence(runId).pipe(
933
+ // A parked coordination row has released its fence. Claim that row
934
+ // before copying the engine's terminal fact; a live peer still wins.
935
+ Effect.catchTag("/control/ClaimLost", () =>
936
+ runtime.resume(runId).pipe(Effect.andThen(runtime.claimFence(runId))))
937
+ )
938
+ const run = yield* runtime.writeStatus(runId, fence, status)
939
+ yield* emit(runId, `control.run.${status}`, json({ runId, status, ...ControlFacts.runFact(run) }))
940
+ }).pipe(
941
+ journal.transact,
942
+ Effect.catchCause((failure) =>
943
+ Effect.annotateLogs(
944
+ Effect.logWarning("A settled engine row could not be reconciled onto the control row"),
945
+ { runId, status, cause: Cause.pretty(failure) }
946
+ )
947
+ )
948
+ )
949
+
950
+ /**
951
+ * Resumes a parked run whose park a steer has just answered.
952
+ *
953
+ * Only two parks are the steer's to end. A run parked on `event` is
954
+ * waiting for something to arrive, and a steer is something arriving. A
955
+ * run parked on `released` lost its owner to a sweep
956
+ * (`@smthrs/engine-store` `DisasterRecovery.fence`) and nothing is coming
957
+ * to claim it, so the steer claims it.
958
+ *
959
+ * Every other park keeps waiting. An `approval`, `timer`, or `quota` park
960
+ * is waiting for a decision, a clock, or a budget that a message does not
961
+ * supply. A park with NO reason at all is an operator's own park, written
962
+ * through `ControlRuntime.writeStatus`, and it is the one park a steer must
963
+ * not end: an operator who stopped a run and then sent it a message is
964
+ * queuing the message for when they restart it, not asking for the stop to
965
+ * be undone. A park a control plane cannot explain is left alone for the
966
+ * same reason.
967
+ *
968
+ * A lost claim is not a failure here. It means another process already
969
+ * owns the run, or the run belongs to a driver this plane did not launch
970
+ * — an engine-created child keeps its park, because claiming it would
971
+ * strand it under this plane's fence where no engine re-drives it. The
972
+ * steer itself is already durable in the notification queue, so the
973
+ * owning driver delivers it at the run's next boundary. A refusal naming
974
+ * the live parked host delegates a wake to that host, so an idle retained
975
+ * module gets that boundary without transferring its fence.
976
+ */
977
+ const wake = (
978
+ run: RunSummary,
979
+ messageId: string
980
+ ): Effect.Effect<void, PersistenceError> => {
981
+ if (run.status !== "parked") return Effect.void
982
+ if (run.waitingReason !== "event" && run.waitingReason !== "released") return Effect.void
983
+ // A changed flow keeps its park: the steer stays queued, and the
984
+ // operator decides with `runs resume --allow-code-drift`.
985
+ return refuseCodeDrift(run.runId).pipe(
986
+ Effect.andThen(runtime.resume(run.runId, { scope: "launched" })),
987
+ Effect.flatMap((resumed) =>
988
+ emit(
989
+ run.runId,
990
+ "control.steer.woke",
991
+ {
992
+ runId: run.runId,
993
+ messageId,
994
+ status: resumed.status,
995
+ ...ControlFacts.runFact(resumed)
996
+ } as ControlEvent["payload"]
997
+ )
998
+ ),
999
+ // A detached live host keeps its fence. Delegate the admitted event
1000
+ // wake to it, just as a node decision does, instead of leaving an idle
1001
+ // retained module with input it will never get a boundary to drain.
1002
+ Effect.catchTag(
1003
+ "/control/ClaimLost",
1004
+ (refusal) =>
1005
+ refusal.parkedBy === undefined ? Effect.void : Effect.gen(function*() {
1006
+ const delegated = yield* runtime.requestResume(run.runId).pipe(
1007
+ Effect.catchTag("/control/InvalidInput", () => Effect.succeed(undefined))
1008
+ )
1009
+ if (delegated !== undefined) yield* emit(run.runId, "control.run.resumed", { runId: run.runId })
1010
+ })
1011
+ ),
1012
+ Effect.catchTag("/control/RunNotFound", () => Effect.void),
1013
+ Effect.catchTag(
1014
+ "/control/CodeDrift",
1015
+ (drift) =>
1016
+ Effect.annotateLogs(Effect.logWarning("A steer did not wake a run whose code changed"), {
1017
+ runId: run.runId,
1018
+ cause: drift.message
1019
+ })
1020
+ )
1021
+ )
1022
+ }
1023
+
1024
+ /**
1025
+ * A page of run summaries with their pending steer counts filled in.
1026
+ *
1027
+ * The count comes from the notification queue rather than from a column,
1028
+ * because pending is admitted minus promoted and the queue owns both
1029
+ * halves. A queue that is unavailable leaves the field absent — "not
1030
+ * known" is representable, and it is the truth — while a journal that
1031
+ * fails is a failed listing.
1032
+ */
1033
+ const withSteering = (
1034
+ runs: ReadonlyArray<RunSummary>
1035
+ ): Effect.Effect<ReadonlyArray<RunSummary>, ControlError> =>
1036
+ Effect.forEach(runs, (run) =>
1037
+ notifications.pending(run.runId).pipe(
1038
+ Effect.map((pending): RunSummary => ({
1039
+ ...run,
1040
+ steering: {
1041
+ pending: pending.filter((notification) => notification.delivery === "steer").length
1042
+ }
1043
+ })),
1044
+ Effect.catchTag("/notifications/NotificationError", () => Effect.succeed(run)),
1045
+ Effect.mapError((cause) =>
1046
+ new PersistenceError({
1047
+ operation: "control.list.steering",
1048
+ message: `Failed to read pending steering for ${run.runId}`,
1049
+ cause
1050
+ })
1051
+ )
1052
+ ))
1053
+
1054
+ /**
1055
+ * The runs every recorded fire of a trigger started. A reader answers the
1056
+ * ledger prefix a page at `cursor` needs, so the window widens until the
1057
+ * reader answers less than it could.
1058
+ */
1059
+ const triggerRuns = (triggerId: string) =>
1060
+ Effect.gen(function*() {
1061
+ let start = 0
1062
+ while (true) {
1063
+ const fires = yield* dispatch.fires({
1064
+ _tag: "fires",
1065
+ filters: { triggerId },
1066
+ limit: maxPageSize,
1067
+ ...(start === 0 ? {} : { cursor: String(start) })
1068
+ })
1069
+ if (fires.length <= start + maxPageSize) {
1070
+ return fires.flatMap((fire) => fire.triggerId === triggerId && fire.runId !== undefined ? [fire.runId] : [])
1071
+ }
1072
+ start = Math.max(fires.length, start * 2)
1073
+ }
1074
+ })
1075
+
1076
+ /**
1077
+ * Whether a restricted reader may see `runId`: only a run this plane
1078
+ * launched for the reader's own principal. A plan partition, an
1079
+ * engine-created run, and a run that does not exist are all invisible.
1080
+ */
1081
+ const readerSees = (reader: Principal, runId: string): Effect.Effect<boolean, PersistenceError> =>
1082
+ runId.startsWith("plan:")
1083
+ ? Effect.succeed(false)
1084
+ : runtime.getRun(runId).pipe(
1085
+ Effect.map((run) => launchedByMatches(run, reader)),
1086
+ Effect.catchTag("/control/RunNotFound", () => Effect.succeed(false))
1087
+ )
1088
+
1089
+ /**
1090
+ * Confines a mutation to a run its reader may see, with the rule `List`
1091
+ * and `Watch` use: another principal's run answers `RunNotFound` exactly
1092
+ * as a missing one, before any idempotency replay can describe it.
1093
+ */
1094
+ const confined = <A, E, R>(
1095
+ submitted: { readonly reader?: Principal | undefined; readonly runId: string },
1096
+ mutation: Effect.Effect<A, E, R>
1097
+ ): Effect.Effect<A, E | RunNotFound | PersistenceError, R> =>
1098
+ submitted.reader === undefined ? mutation : Effect.flatMap(
1099
+ readerSees(submitted.reader, submitted.runId),
1100
+ (visible): Effect.Effect<A, E | RunNotFound, R> =>
1101
+ visible ? mutation : Effect.fail(new RunNotFound({ runId: submitted.runId }))
1102
+ )
1103
+
1104
+ const list = (request: ListInput): Effect.Effect<ListResponse, ControlError> =>
1105
+ Effect.gen(function*() {
1106
+ if (request._tag === "executions") {
1107
+ if (request.reader !== undefined && !(yield* readerSees(request.reader, request.runId))) {
1108
+ return yield* new RunNotFound({ runId: request.runId })
1109
+ }
1110
+ yield* runtime.getRun(request.runId)
1111
+ yield* Schema.decodeUnknownEffect(ListRequest)(request).pipe(
1112
+ Effect.mapError(() => invalid("executionIds: at most 200 nonempty execution IDs are admitted"))
1113
+ )
1114
+ const batch = Option.isSome(executor) && executor.value.readExecutions !== undefined
1115
+ ? yield* executor.value.readExecutions(request)
1116
+ : {
1117
+ source: null,
1118
+ revision: null,
1119
+ snapshots: request.executionIds.map((executionId) => ({
1120
+ _tag: "Unavailable" as const,
1121
+ executionId,
1122
+ reason: "unsupported" as const
1123
+ }))
1124
+ }
1125
+ return { _tag: "executions", source: batch.source, revision: batch.revision, items: batch.snapshots }
1126
+ }
1127
+ const bounds = yield* pageBounds(request._tag === "runs" ? undefined : request.cursor, request.limit)
1128
+ if (request._tag === "plans") {
1129
+ // A plan's input and envelope are an operator's to read.
1130
+ if (request.reader !== undefined) return { _tag: "plans", items: [] }
1131
+ const result = yield* runtime.queryPlans({
1132
+ flowId: request.filters?.flowId,
1133
+ decision: request.filters?.decision,
1134
+ after: bounds.start,
1135
+ limit: bounds.size
1136
+ })
1137
+ const items = result.plans.map((plan) => ({
1138
+ card: plan.card,
1139
+ // The JSON form the durable runtime stores, whichever runtime answers.
1140
+ input: JSON.parse(JSON.stringify(plan.decodedInput ?? null)) as Schema.Json,
1141
+ decision: plan.decision
1142
+ }))
1143
+ return result.next === undefined
1144
+ ? { _tag: "plans", items }
1145
+ : { _tag: "plans", items, nextCursor: String(result.next) }
1146
+ }
1147
+
1148
+ if (request._tag === "flows") {
1149
+ const [registered, warnings] = yield* Effect.all([registry.list(), registry.warnings()])
1150
+ const available = registered.length > 0
1151
+ ? registered.map((descriptor) => ({
1152
+ flowId: descriptor.name,
1153
+ description: descriptor.description,
1154
+ ...(inputDocument(descriptor.input) === undefined
1155
+ ? {}
1156
+ : { inputSchema: inputDocument(descriptor.input)! })
1157
+ }))
1158
+ : yield* runtime.listFlows
1159
+ const result = page(available, bounds)
1160
+ const diagnostics = warnings.length === 0 ? {} : { warnings }
1161
+ return result.nextCursor === undefined
1162
+ ? { _tag: "flows", items: result.items, ...diagnostics }
1163
+ : { _tag: "flows", items: result.items, ...diagnostics, nextCursor: result.nextCursor }
1164
+ }
1165
+
1166
+ if (request._tag === "triggers") {
1167
+ // A trigger's input and active run belong to whoever configured it;
1168
+ // like a plan, it is an operator's to read.
1169
+ if (request.reader !== undefined) return { _tag: "triggers", items: [] }
1170
+ let triggers: ReadonlyArray<TriggerSummary> = yield* dispatch.list(request)
1171
+ if (request.filters?.triggerId !== undefined) {
1172
+ triggers = triggers.filter((trigger) => trigger.triggerId === request.filters?.triggerId)
1173
+ }
1174
+ if (request.filters?.flowId !== undefined) {
1175
+ triggers = triggers.filter((trigger) => trigger.flowId === request.filters?.flowId)
1176
+ }
1177
+ if (request.filters?.enabled !== undefined) {
1178
+ triggers = triggers.filter((trigger) => trigger.enabled === request.filters?.enabled)
1179
+ }
1180
+ const result = page(triggers, bounds)
1181
+ return result.nextCursor === undefined
1182
+ ? { _tag: "triggers", items: result.items }
1183
+ : { _tag: "triggers", items: result.items, nextCursor: result.nextCursor }
1184
+ }
1185
+
1186
+ if (request._tag === "fires") {
1187
+ let fires: ReadonlyArray<FireSummary> = yield* dispatch.fires(request)
1188
+ const reader = request.reader
1189
+ if (reader !== undefined) {
1190
+ // A restricted reader sees the fires that started its own runs.
1191
+ const seen = yield* Effect.forEach(fires, (fire) =>
1192
+ fire.runId === undefined ? Effect.succeed(false) : readerSees(reader, fire.runId))
1193
+ fires = fires.filter((_, index) =>
1194
+ seen[index]
1195
+ )
1196
+ }
1197
+ if (request.filters?.triggerId !== undefined) {
1198
+ fires = fires.filter((fire) => fire.triggerId === request.filters?.triggerId)
1199
+ }
1200
+ if (request.filters?.runId !== undefined) {
1201
+ fires = fires.filter((fire) => fire.runId === request.filters?.runId)
1202
+ }
1203
+ if (request.filters?.outcome !== undefined) {
1204
+ fires = fires.filter((fire) => fire.outcome === request.filters?.outcome)
1205
+ }
1206
+ const result = page(fires, bounds)
1207
+ return result.nextCursor === undefined
1208
+ ? { _tag: "fires", items: result.items }
1209
+ : { _tag: "fires", items: result.items, nextCursor: result.nextCursor }
1210
+ }
1211
+
1212
+ const filters = request.filters
1213
+ // A restricted reader lists only its own runs, whatever it asked for:
1214
+ // naming another principal's id selects nothing.
1215
+ const reader = request.reader
1216
+ if (reader !== undefined && filters?.principalId !== undefined && filters.principalId !== reader.id) {
1217
+ return { _tag: "runs", items: [] }
1218
+ }
1219
+ const launcher = reader !== undefined
1220
+ ? { id: reader.id, kind: reader.kind }
1221
+ : filters?.principalId === undefined
1222
+ ? undefined
1223
+ : { id: filters.principalId }
1224
+ const fingerprint = JSON.stringify([
1225
+ filters?.runId ?? null,
1226
+ filters?.flowId ?? null,
1227
+ filters?.status ?? null,
1228
+ filters?.parentRunId ?? null,
1229
+ filters?.lineageId ?? null,
1230
+ // Keep legacy cursor fingerprints byte-identical unless opting in.
1231
+ ...(filters?.terminal === undefined && request.order === undefined
1232
+ ? []
1233
+ : [filters?.terminal ?? null, request.order ?? null]),
1234
+ ...(filters?.since === undefined && filters?.until === undefined && filters?.triggerId === undefined
1235
+ ? []
1236
+ : [filters.since ?? null, filters.until ?? null, filters.triggerId ?? null]),
1237
+ ...(launcher === undefined ? [] : [launcher.id, launcher.kind ?? null])
1238
+ ])
1239
+ const cursor = request.cursor === undefined ? undefined : yield* Schema.decodeUnknownEffect(runCursor)(
1240
+ request.cursor
1241
+ ).pipe(Effect.mapError(() => invalid("cursor: expected a run listing cursor")))
1242
+ if (cursor !== undefined && cursor.filters !== fingerprint) {
1243
+ return yield* invalid("cursor: belongs to different run filters")
1244
+ }
1245
+ // Exact lookups retain their one-row path. Other queries select durable
1246
+ // summary fields in the adapter before observing the selected page.
1247
+ if (filters?.runId !== undefined) {
1248
+ let runs = yield* getRun(filters.runId).pipe(
1249
+ Effect.map((run) => [run]),
1250
+ Effect.catchTag("/control/RunNotFound", () => Effect.succeed<Array<RunSummary>>([]))
1251
+ )
1252
+ if (filters.flowId !== undefined) runs = runs.filter((run) => run.flowId === filters.flowId)
1253
+ if (filters.status !== undefined) runs = runs.filter((run) => run.status === filters.status)
1254
+ if (filters.terminal !== undefined) runs = runs.filter((run) => terminal(run.status) === filters.terminal)
1255
+ if (filters.parentRunId !== undefined) runs = runs.filter((run) => run.parentRunId === filters.parentRunId)
1256
+ if (filters.lineageId !== undefined) runs = runs.filter((run) => run.lineageId === filters.lineageId)
1257
+ if (launcher !== undefined) runs = runs.filter((run) => launchedByMatches(run, launcher))
1258
+ if (filters.since !== undefined) runs = runs.filter((run) => run.createdAt >= filters.since!)
1259
+ if (filters.until !== undefined) runs = runs.filter((run) => run.createdAt < filters.until!)
1260
+ if (filters.triggerId !== undefined) {
1261
+ const started = yield* triggerRuns(filters.triggerId)
1262
+ runs = runs.filter((run) => started.includes(run.runId))
1263
+ }
1264
+ return { _tag: "runs", items: yield* withSteering(yield* Effect.forEach(runs, withCodeDrift)) }
1265
+ }
1266
+ // A status filter has to select on the status a caller will READ.
1267
+ // `observe` replaces this plane's copy with the executor's, so a run
1268
+ // the engine has parked on a human wait answers `waiting-approval`
1269
+ // while the coordination row still says whatever it last recorded —
1270
+ // and the adapter's own filter dropped it before anybody looked. That
1271
+ // is what left workspace 6f2733a3's run-1 out of every
1272
+ // `status: "waiting-approval"` page while the run tree was waiting on
1273
+ // a person. The exact-lookup branch above has always filtered on the
1274
+ // observed summary; this makes the paged branch agree.
1275
+ //
1276
+ // The source therefore selects without the status and the page is
1277
+ // filled from observed rows, asking for only as many as are still
1278
+ // needed so a page never over-delivers rows its cursor has passed. A
1279
+ // filter the source cannot evaluate costs a walk, which is why the
1280
+ // walk stops at a full page or at the end of the runs.
1281
+ const postFiltered = observing && (filters?.status !== undefined || filters?.terminal !== undefined)
1282
+ const { triggerId, principalId: _principalId, ...unowned } = filters ?? {}
1283
+ const selected = launcher === undefined ? unowned : { ...unowned, launchedBy: launcher }
1284
+ const narrowed = triggerId === undefined ? selected : { ...selected, runIds: yield* triggerRuns(triggerId) }
1285
+ const sourceFilters = postFiltered
1286
+ ? Object.fromEntries(Object.entries(narrowed).filter(([key]) => key !== "status" && key !== "terminal"))
1287
+ : narrowed
1288
+ const collected: Array<RunSummary> = []
1289
+ let sourceCursor = cursor
1290
+ let sourceNext: RunPage["nextCursor"]
1291
+ while (true) {
1292
+ const result = yield* runtime.queryRuns({
1293
+ filters: sourceFilters,
1294
+ order: request.order,
1295
+ cursor: sourceCursor,
1296
+ limit: bounds.size - collected.length
1297
+ })
1298
+ const observed = yield* Effect.forEach(result.items, observe)
1299
+ for (const run of observed) {
1300
+ if (
1301
+ !postFiltered ||
1302
+ (filters?.status === undefined || run.status === filters.status) &&
1303
+ (filters?.terminal === undefined || terminal(run.status) === filters.terminal)
1304
+ ) collected.push(run)
1305
+ }
1306
+ sourceNext = result.nextCursor
1307
+ if (!postFiltered || sourceNext === undefined || collected.length >= bounds.size) break
1308
+ sourceCursor = { version: 1, filters: fingerprint, ...sourceNext }
1309
+ }
1310
+ const items = yield* withSteering(collected)
1311
+ return sourceNext === undefined
1312
+ ? { _tag: "runs", items }
1313
+ : {
1314
+ _tag: "runs",
1315
+ items,
1316
+ nextCursor: JSON.stringify({ version: 1, filters: fingerprint, ...sourceNext })
1317
+ }
1318
+ })
1319
+
1320
+ const streamForRun = (
1321
+ runId: RunId,
1322
+ filter: WatchFilter
1323
+ ): Stream.Stream<ControlEvent, ControlError> =>
1324
+ journal.stream({
1325
+ runId: JournalEvent.RunId.make(runId),
1326
+ ...(filter.afterSequence === undefined
1327
+ ? {}
1328
+ : { afterSequence: JournalEvent.Seq.make(filter.afterSequence) })
1329
+ }).pipe(
1330
+ Stream.map(eventFromEntry),
1331
+ Stream.mapError(watchReadFailed)
1332
+ )
1333
+
1334
+ /**
1335
+ * Finds the last committed sequence without walking the history. The
1336
+ * journal's public cursor is forward-only, so exponential probes first
1337
+ * bracket the tail and binary probes then pin it exactly. Only these
1338
+ * indexed one-row reads run in the transaction that fixes the cutoff.
1339
+ */
1340
+ const snapshotHighWater = (
1341
+ runId: JournalEvent.RunId
1342
+ ): Effect.Effect<JournalEvent.Seq | undefined, ControlError> =>
1343
+ journal.transact(
1344
+ Effect.gen(function*() {
1345
+ const first = yield* journal.entries({ runId, limit: 1 })
1346
+ const initial = first.entries[0]
1347
+ if (initial === undefined) return undefined
1348
+
1349
+ let lower = initial.seq as number
1350
+ let step = 1
1351
+ let upper = lower
1352
+ const maximumSequence = Number.MAX_SAFE_INTEGER - 1
1353
+ while (lower < maximumSequence) {
1354
+ const probe = Math.min(maximumSequence, lower + step - 1)
1355
+ const next = yield* journal.entries({
1356
+ runId,
1357
+ after: JournalEvent.Seq.make(probe),
1358
+ limit: 1
1359
+ })
1360
+ const entry = next.entries[0]
1361
+ if (entry === undefined) {
1362
+ upper = probe
1363
+ break
1364
+ }
1365
+ lower = entry.seq
1366
+ if (lower === maximumSequence) return entry.seq
1367
+ step = Math.min(maximumSequence - lower, step * 2)
1368
+ }
1369
+
1370
+ while (lower < upper) {
1371
+ const middle = lower + Math.ceil((upper - lower) / 2)
1372
+ const next = yield* journal.entries({
1373
+ runId,
1374
+ after: JournalEvent.Seq.make(middle - 1),
1375
+ limit: 1
1376
+ })
1377
+ const entry = next.entries[0]
1378
+ if (entry === undefined) {
1379
+ upper = middle - 1
1380
+ } else {
1381
+ lower = entry.seq
1382
+ }
1383
+ }
1384
+ return JournalEvent.Seq.make(lower)
1385
+ })
1386
+ ).pipe(Effect.mapError(watchReadFailed))
1387
+
1388
+ const snapshotForRunAt = (
1389
+ runId: RunId,
1390
+ filter: WatchFilter,
1391
+ highWater: JournalEvent.Seq | undefined
1392
+ ): Stream.Stream<ControlEvent, ControlError> => {
1393
+ const journalRunId = JournalEvent.RunId.make(runId)
1394
+ const initialAfter = filter.afterSequence === undefined
1395
+ ? undefined
1396
+ : JournalEvent.Seq.make(filter.afterSequence)
1397
+ if (highWater === undefined || (initialAfter !== undefined && initialAfter >= highWater)) {
1398
+ return Stream.empty
1399
+ }
1400
+ return Stream.paginate(initialAfter, (after) =>
1401
+ journal.entries({
1402
+ runId: journalRunId,
1403
+ ...(after === undefined ? {} : { after }),
1404
+ limit: snapshotPageSize
1405
+ }).pipe(
1406
+ Effect.map((page) => {
1407
+ const entries = page.entries.filter((entry) => entry.seq <= highWater)
1408
+ const last = entries.at(-1)
1409
+ const next = last === undefined || last.seq >= highWater || !page.hasMore
1410
+ ? Option.none<JournalEvent.Seq | undefined>()
1411
+ : Option.some<JournalEvent.Seq | undefined>(last.seq)
1412
+ return [entries, next] as const
1413
+ }),
1414
+ Effect.mapError(watchReadFailed)
1415
+ )).pipe(Stream.map(eventFromEntry))
1416
+ }
1417
+
1418
+ const snapshotForRun = (
1419
+ runId: RunId,
1420
+ filter: WatchFilter
1421
+ ): Stream.Stream<ControlEvent, ControlError> =>
1422
+ Stream.unwrap(
1423
+ Effect.map(
1424
+ snapshotHighWater(JournalEvent.RunId.make(runId)),
1425
+ (highWater) => snapshotForRunAt(runId, filter, highWater)
1426
+ )
1427
+ )
1428
+
1429
+ /**
1430
+ * Every journal partition, plans first, one inventory page at a time.
1431
+ *
1432
+ * The walk reads keys only and pulls the next page when the consumer asks
1433
+ * for it, so a global watch holds one page of ids rather than the whole
1434
+ * run table. Each inventory's first page pins its newest position and
1435
+ * every later page stops there, so the walk is finite however fast runs
1436
+ * are admitted: a partition that exists before the walk starts is listed
1437
+ * exactly once, and one created during the walk is left to the follow
1438
+ * tail.
1439
+ */
1440
+ const inventory = (
1441
+ page: (request: IdPageRequest) => Effect.Effect<IdPage, ControlError>
1442
+ ): Stream.Stream<string, ControlError> => {
1443
+ const first: Omit<IdPageRequest, "limit"> = {}
1444
+ return Stream.paginate(
1445
+ first,
1446
+ (cursor) =>
1447
+ Effect.map(
1448
+ page({ ...cursor, limit: partitionPageSize }),
1449
+ (next) =>
1450
+ [
1451
+ next.ids,
1452
+ next.next === undefined ? Option.none() : Option.some({ after: next.next, through: next.through })
1453
+ ] as const
1454
+ )
1455
+ )
1456
+ }
1457
+
1458
+ const journalPartitions: Stream.Stream<string, ControlError> = Stream.concat(
1459
+ Stream.map(inventory(runtime.pagePlanIds), (planId) => `plan:${planId}`),
1460
+ inventory(runtime.pageRunIds)
1461
+ )
1462
+
1463
+ const snapshot = (filter: WatchFilter): Stream.Stream<ControlEvent, ControlError> =>
1464
+ filter.runId !== undefined
1465
+ ? snapshotForRun(filter.runId, filter)
1466
+ : Stream.flatMap(journalPartitions, (partition) => snapshotForRun(partition, filter), {
1467
+ concurrency: snapshotPartitionConcurrency
1468
+ })
1469
+
1470
+ const entries = (filter: WatchFilter): Stream.Stream<ControlEvent, ControlError> =>
1471
+ filter.follow === false
1472
+ ? snapshot(filter)
1473
+ : filter.runId !== undefined
1474
+ ? streamForRun(filter.runId, filter)
1475
+ : Stream.unwrap(
1476
+ Effect.gen(function*() {
1477
+ const subscription = yield* journal.changes
1478
+ // Subscribe first, then pin each partition's cutoff. A row
1479
+ // committed at or before its cutoff is read from the finite
1480
+ // snapshot; one committed after it is read from the buffered tail.
1481
+ // This is a handoff, not a bounded duplicate cache, so an
1482
+ // arbitrarily long history cannot make an old overlap reappear.
1483
+ //
1484
+ // A cutoff is pinned by whichever side reaches its partition
1485
+ // first: the paged walk, or the tail when an entry arrives for a
1486
+ // partition the walk has not reached or never lists (one created
1487
+ // after the walk passed its key). That side reads the partition's
1488
+ // snapshot; the other reuses the cutoff. The map keeps one cutoff
1489
+ // per partition seen for the life of the stream.
1490
+ const cutoffs = new Map<string, Deferred.Deferred<JournalEvent.Seq | undefined, ControlError>>()
1491
+ const pin = (partition: string): Effect.Effect<JournalEvent.Seq | undefined, ControlError> => {
1492
+ const pinned = Deferred.makeUnsafe<JournalEvent.Seq | undefined, ControlError>()
1493
+ cutoffs.set(partition, pinned)
1494
+ return snapshotHighWater(JournalEvent.RunId.make(partition)).pipe(
1495
+ Effect.onExit((exit) => Deferred.done(pinned, exit))
1496
+ )
1497
+ }
1498
+ const walk = journalPartitions.pipe(
1499
+ Stream.mapEffect(
1500
+ (partition) =>
1501
+ Effect.suspend(() =>
1502
+ cutoffs.has(partition)
1503
+ ? Effect.succeed(Option.none())
1504
+ : Effect.map(pin(partition), (highWater) => Option.some([partition, highWater] as const))
1505
+ ),
1506
+ { concurrency: snapshotPartitionConcurrency }
1507
+ ),
1508
+ Stream.filter(Option.isSome),
1509
+ Stream.map((pinned) => pinned.value),
1510
+ Stream.flatMap(([partition, highWater]) => snapshotForRunAt(partition, filter, highWater), {
1511
+ concurrency: snapshotPartitionConcurrency
1512
+ })
1513
+ )
1514
+ /** The partition's cutoff, pinning it when the walk has not. */
1515
+ type Cutoff = { readonly highWater: JournalEvent.Seq | undefined; readonly claimed: boolean }
1516
+ const cutoffFor = (partition: string): Effect.Effect<Cutoff, ControlError> =>
1517
+ Effect.suspend((): Effect.Effect<Cutoff, ControlError> => {
1518
+ const known = cutoffs.get(partition)
1519
+ if (known !== undefined) {
1520
+ return Effect.map(Deferred.await(known), (highWater) => ({ highWater, claimed: false }))
1521
+ }
1522
+ return Effect.map(pin(partition), (highWater) => ({ highWater, claimed: true }))
1523
+ })
1524
+ /**
1525
+ * Detects a live tail that silently lost entries.
1526
+ *
1527
+ * `changes` is a sliding PubSub: a watcher that falls behind drops
1528
+ * committed entries with no signal, which turns this follow into a
1529
+ * permanently incomplete stream. Sequence numbers are
1530
+ * partition-local, so each tail entry is checked against the last
1531
+ * sequence this watcher saw for its partition (or the snapshot
1532
+ * cutoff for one it has not tailed yet).
1533
+ *
1534
+ * A gap is not proof of loss on its own: a rolled-back transaction
1535
+ * leaves an allocated sequence unused, and that hole is benign. The
1536
+ * durable journal disambiguates — an entry in the gap that was
1537
+ * committed but never delivered here is a real loss and fails the
1538
+ * stream, while a hole with no durable entry is skipped.
1539
+ *
1540
+ * One limitation, by design: the disambiguation reads the durable
1541
+ * journal, so an entry that was committed, dropped, and then
1542
+ * compacted away before the check runs reports as a benign hole.
1543
+ * Closing that needs a registered reader cursor, which the
1544
+ * partition-merged tail does not hold.
1545
+ */
1546
+ const seenByPartition = new Map<string, number>()
1547
+ const gapCheck = (
1548
+ partition: string,
1549
+ expected: number | undefined,
1550
+ arrived: number
1551
+ ): Effect.Effect<void, ControlError> =>
1552
+ journal.entries({
1553
+ runId: JournalEvent.RunId.make(partition),
1554
+ ...(expected === undefined ? {} : { after: JournalEvent.Seq.make(expected) }),
1555
+ limit: 1
1556
+ }).pipe(
1557
+ Effect.mapError(watchReadFailed),
1558
+ Effect.flatMap((page) => {
1559
+ const missed = page.entries[0]
1560
+ if (missed === undefined || missed.seq >= arrived) return Effect.void
1561
+ return Effect.fail(
1562
+ new PersistenceError({
1563
+ operation: "watch",
1564
+ message:
1565
+ `the live tail lost journal entries for ${partition}: sequence ${missed.seq} was committed but never delivered to this watcher`
1566
+ })
1567
+ )
1568
+ })
1569
+ )
1570
+ const trackTail = (
1571
+ entry: JournalEvent.Entry,
1572
+ cutoff: JournalEvent.Seq | undefined
1573
+ ): Effect.Effect<Option.Option<JournalEvent.Entry>, ControlError> => {
1574
+ const partition = String(entry.runId)
1575
+ const expected = seenByPartition.get(partition) ?? cutoff
1576
+ // A committed entry at or below the cursor was already delivered
1577
+ // (or is covered by the snapshot); passing it on teaches nothing.
1578
+ if (expected !== undefined && entry.seq <= expected) return Effect.succeed(Option.none())
1579
+ seenByPartition.set(partition, entry.seq)
1580
+ return expected !== undefined && entry.seq === expected + 1
1581
+ ? Effect.succeed(Option.some(entry))
1582
+ : Effect.map(gapCheck(partition, expected, entry.seq), () => Option.some(entry))
1583
+ }
1584
+ const tail = Stream.fromSubscription(subscription).pipe(
1585
+ // Every committed entry names its partition. The first one from a
1586
+ // partition nobody pinned yet pins it here and is preceded by that
1587
+ // partition's snapshot, which also recovers any earlier notice the
1588
+ // sliding buffer dropped.
1589
+ Stream.flatMap((entry) =>
1590
+ Stream.unwrap(Effect.gen(function*() {
1591
+ const partition = String(entry.runId)
1592
+ const { highWater, claimed } = yield* cutoffFor(partition)
1593
+ const history = claimed ? snapshotForRunAt(partition, filter, highWater) : Stream.empty
1594
+ if (highWater !== undefined && entry.seq <= highWater) return history
1595
+ const tracked = yield* trackTail(entry, highWater)
1596
+ return Option.isSome(tracked)
1597
+ ? Stream.concat(history, Stream.succeed(eventFromEntry(tracked.value)))
1598
+ : history
1599
+ }))
1600
+ )
1601
+ )
1602
+ // The walk already bounds its own reads, and the tail is its own
1603
+ // fiber, so snapshot work never starves the live tail. An unbounded
1604
+ // merge read every partition of an unbounded database at once,
1605
+ // which is the allocation a remote watcher could force.
1606
+ return Stream.merge(walk, tail)
1607
+ })
1608
+ )
1609
+
1610
+ /**
1611
+ * Keeps a restricted reader's watch to the runs it launched. A named run it
1612
+ * may not see fails `RunNotFound` exactly as a missing one does; a global
1613
+ * watch drops every other partition's events, deciding each partition once.
1614
+ */
1615
+ const watch = (filter: WatchInput): Stream.Stream<ControlEvent, ControlError> => {
1616
+ const { reader, ...unrestricted } = filter
1617
+ if (reader === undefined) return watchAll(unrestricted)
1618
+ if (filter.runId !== undefined) {
1619
+ const runId = filter.runId
1620
+ return Stream.unwrap(Effect.map(
1621
+ readerSees(reader, runId),
1622
+ (visible) => visible ? watchAll(unrestricted) : Stream.fail(new RunNotFound({ runId }))
1623
+ ))
1624
+ }
1625
+ const decided = new Map<string, boolean>()
1626
+ return watchAll(unrestricted).pipe(
1627
+ // A lost-tail failure names the partition and sequence it missed,
1628
+ // which may be another principal's; a restricted reader learns only
1629
+ // that its stream ended incomplete.
1630
+ Stream.mapError((error) =>
1631
+ error._tag === "/control/PersistenceError"
1632
+ ? new PersistenceError({ operation: "watch", message: "the watch lost journal entries and ended" })
1633
+ : error
1634
+ ),
1635
+ Stream.filterEffect((event) => {
1636
+ // eventFromEntry, Lineage.derive and Steering.derive retain the journal partition.
1637
+ const partition = event.runId!
1638
+ const known = decided.get(partition)
1639
+ if (known !== undefined) return Effect.succeed(known)
1640
+ return Effect.tap(
1641
+ readerSees(reader, partition),
1642
+ (visible) => Effect.sync(() => decided.set(partition, visible))
1643
+ )
1644
+ })
1645
+ )
1646
+ }
1647
+
1648
+ /** Expands each source row in stable order and checkpoints individual members. */
1649
+ const watchAll = (filter: WatchFilter): Stream.Stream<ControlEvent, ControlError> => {
1650
+ if (filter.afterSequence !== undefined && filter.runId === undefined) {
1651
+ return Stream.fail(invalid("afterSequence: a watch cursor resumes one run, so it requires runId"))
1652
+ }
1653
+ const cursor = filter.afterCursor
1654
+ if (cursor !== undefined) {
1655
+ if (filter.runId === undefined) {
1656
+ return Stream.fail(invalid("afterCursor: a watch cursor resumes one run, so it requires runId"))
1657
+ }
1658
+ if (filter.afterSequence !== undefined) {
1659
+ return Stream.fail(invalid("afterCursor: cannot be combined with afterSequence"))
1660
+ }
1661
+ if (!Schema.is(WatchCursor)(cursor)) {
1662
+ return Stream.fail(invalid("afterCursor: sequence and offset must be nonnegative safe journal integers"))
1663
+ }
1664
+ }
1665
+ // A partial checkpoint rereads only its source row. A complete one
1666
+ // starts strictly after it, so polling never rereads completed entries.
1667
+ const afterSequence = cursor === undefined ?
1668
+ filter.afterSequence
1669
+ : cursor.offset === undefined ?
1670
+ cursor.sequence
1671
+ : cursor.sequence === 0
1672
+ ? undefined
1673
+ : cursor.sequence - 1
1674
+ return entries({
1675
+ ...filter,
1676
+ afterSequence
1677
+ }).pipe(
1678
+ Stream.map((event): ReadonlyArray<ControlEvent> => {
1679
+ const lineage = Lineage.derive(event)
1680
+ const expanded = [event, ...(lineage === undefined ? [] : [lineage]), ...Steering.derive(event)]
1681
+ const checkpointed = expanded.map((member, offset): ControlEvent => ({
1682
+ ...member,
1683
+ cursor: offset === expanded.length - 1 ? { sequence: event.sequence } : { sequence: event.sequence, offset }
1684
+ }))
1685
+ return cursor?.offset !== undefined && event.sequence === cursor.sequence
1686
+ ? checkpointed.slice(cursor.offset + 1)
1687
+ : checkpointed
1688
+ }),
1689
+ Stream.flattenIterable
1690
+ )
1691
+ }
1692
+
1693
+ const service: Service = {
1694
+ plan: Effect.fn("Control.plan")((input) =>
1695
+ mutationSemaphore.withPermits(1)(
1696
+ journal.transact(Effect.gen(function*() {
1697
+ // The SQL runtime's plan, key and token writes join this transaction.
1698
+ // Memory publication cannot roll back, and older SQL writes could
1699
+ // commit without an entry, so a stored card also needs a journal check.
1700
+ const { card, created } = yield* runtime.plan(input)
1701
+ const runId = JournalEvent.RunId.make(`plan:${card.planId}`)
1702
+ if (!created) {
1703
+ let after: JournalEvent.Seq | undefined
1704
+ while (true) {
1705
+ const page = yield* journal.entries({
1706
+ runId,
1707
+ ...(after === undefined ? {} : { after }),
1708
+ limit: snapshotPageSize
1709
+ })
1710
+ if (
1711
+ page.entries.some((entry) =>
1712
+ entry.sourceId === sourceId && entry.eventType === "control.plan.created"
1713
+ )
1714
+ ) {
1715
+ return card
1716
+ }
1717
+ if (!page.hasMore) break
1718
+ after = page.entries[page.entries.length - 1]!.seq
1719
+ }
1720
+ }
1721
+ yield* emit(runId, "control.plan.created", {
1722
+ planId: card.planId,
1723
+ flowId: card.flowId,
1724
+ digest: card.digest
1725
+ })
1726
+ return card
1727
+ })).pipe(
1728
+ Effect.mapError((cause) =>
1729
+ cause instanceof Journal.JournalError
1730
+ ? new PersistenceError({
1731
+ operation: "plan",
1732
+ message: "Failed to commit plan and its creation entry atomically",
1733
+ cause
1734
+ })
1735
+ : cause
1736
+ )
1737
+ )
1738
+ ).pipe(Effect.flatMap((card) =>
1739
+ registry.warnings().pipe(Effect.map((all) => {
1740
+ const warnings = all.filter((warning) => warning.name === input.flowId)
1741
+ return warnings.length === 0 ? card : { warnings, ...card }
1742
+ }))
1743
+ ))
1744
+ ),
1745
+ run: Effect.fn("Control.run")((submitted) =>
1746
+ Effect.gen(function*() {
1747
+ const input = yield* snapshotRun(submitted)
1748
+ // One resume, one implementation. This member used to be a second
1749
+ // path with none of `Control.resume`'s fixes: it claimed without
1750
+ // `scope: "launched"`, so resuming an engine-created child overwrote
1751
+ // the engine's continuation state; it replayed a recorded receipt as
1752
+ // `AlreadyApplied` for a run that had since settled; and it journaled
1753
+ // `control.run.resumed`, which `AgentSession` reads as an approval
1754
+ // DELEGATION rather than as the claim a resume is.
1755
+ if (input._tag === "Resume") return yield* runMutation(input)
1756
+ const principal = yield* runtime.stampPrincipal(input.principal)
1757
+ let admitted: Launch | undefined
1758
+ const receipt = yield* mutate<
1759
+ | RunNotFound
1760
+ | PlanNotFound
1761
+ | PlanDenied
1762
+ | PlanDigestMismatch
1763
+ | EnvelopeMismatch
1764
+ | ClaimLost
1765
+ | LaunchFailed
1766
+ | PersistenceError,
1767
+ never
1768
+ >(
1769
+ "run",
1770
+ input.idempotencyKey,
1771
+ principal,
1772
+ fingerprint("run", principal, input),
1773
+ Effect.gen(function*() {
1774
+ const launched = yield* runtime.launch(
1775
+ input.planId,
1776
+ input.digest,
1777
+ input.envelope,
1778
+ principal,
1779
+ input.reservedRunId
1780
+ )
1781
+ if (launched._tag === "Parked") {
1782
+ return { ...launched.receipt, receiptId: input.idempotencyKey }
1783
+ }
1784
+ const plan = yield* runtime.getPlan(input.planId)
1785
+ const trigger = declaredTrigger(plan.decodedInput)
1786
+ yield* emit(
1787
+ launched.run.runId,
1788
+ "control.run.accepted",
1789
+ {
1790
+ runId: launched.run.runId,
1791
+ planId: input.planId,
1792
+ digest: input.digest,
1793
+ status: launched.run.status,
1794
+ ...(trigger === undefined ? {} : { trigger }),
1795
+ ...ControlFacts.runFact(launched.run, "created")
1796
+ } as ControlEvent["payload"]
1797
+ )
1798
+ admitted = { plan, run: launched.run }
1799
+ return {
1800
+ _tag: "Accepted",
1801
+ receiptId: input.idempotencyKey,
1802
+ runId: launched.run.runId
1803
+ }
1804
+ }),
1805
+ true,
1806
+ true
1807
+ )
1808
+ // The executor may immediately read through another connection or
1809
+ // fork a driver. Its run, approval and dedupe receipt must all be
1810
+ // committed before any execution crosses that boundary.
1811
+ if (receipt._tag === "Accepted" && admitted !== undefined) yield* hand(admitted)
1812
+ else if (receipt._tag === "AlreadyApplied" && receipt.runId !== undefined) {
1813
+ yield* relaunchStranded(receipt.runId)
1814
+ }
1815
+ return receipt
1816
+ })
1817
+ ),
1818
+ approve: Effect.fn("Control.approve")((input) => decide("approved", input)),
1819
+ deny: Effect.fn("Control.deny")((input) => decide("denied", input)),
1820
+ steer: Effect.fn("Control.steer")((submitted: SteerInput) =>
1821
+ confined(
1822
+ submitted,
1823
+ Effect.flatMap(snapshotSteer(submitted), (input) =>
1824
+ mutate(
1825
+ "steer",
1826
+ input.idempotencyKey,
1827
+ input.message.principal,
1828
+ fingerprint("steer", input.message.principal, input),
1829
+ Effect.gen(function*() {
1830
+ // Two run ids naming two runs is a caller mistake with a durable
1831
+ // consequence: the notification is admitted to `input.runId` while
1832
+ // the stored `SteerMessage.runId` names another run, so the message
1833
+ // an operator later reads says it belongs somewhere it was never
1834
+ // delivered.
1835
+ if (input.message.runId !== input.runId) {
1836
+ return yield* Effect.fail(
1837
+ invalid(
1838
+ `message.runId: must be ${JSON.stringify(input.runId)}, received ${
1839
+ JSON.stringify(input.message.runId)
1840
+ }`
1841
+ )
1842
+ )
1843
+ }
1844
+ const run = yield* getRun(input.runId)
1845
+ // A run that will never take another turn cannot be steered, and
1846
+ // storing the steer anyway would leave an operator watching a
1847
+ // message that has no boundary left to deliver it.
1848
+ if (terminal(run.status)) return { _tag: "Terminal", runId: run.runId, status: run.status }
1849
+ const item = steerItem(input.message)
1850
+ const admission = yield* notifications.admit(input.runId, {
1851
+ _tag: "human-steer",
1852
+ id: input.message.messageId,
1853
+ delivery: "steer",
1854
+ targetLineageId: input.runId,
1855
+ provenance: {
1856
+ sourceRunId: input.runId,
1857
+ sourceLineageId: input.runId,
1858
+ sourceTurn: 0,
1859
+ sourceActor: `${input.message.principal.kind}:${input.message.principal.id}`,
1860
+ ...(input.message.attribution === undefined ? {} : { attribution: input.message.attribution })
1861
+ },
1862
+ payload: SteerPayload.encode(item)
1863
+ }, input.version).pipe(
1864
+ Effect.mapError((cause) =>
1865
+ cause instanceof NotificationQueue.NotificationError ? cause : new PersistenceError({
1866
+ operation: "control.steer.notification",
1867
+ message: "Failed to admit steering notification",
1868
+ cause
1869
+ })
1870
+ )
1871
+ )
1872
+ const inputReceipt = (): Receipt => {
1873
+ if (input.version === undefined || item.kind !== "Message") {
1874
+ return accepted(input.idempotencyKey, input.runId)
1875
+ }
1876
+ const payload = admission.consumedNotification?.payload as { body?: string } | undefined
1877
+ return {
1878
+ _tag: "Accepted",
1879
+ receiptId: input.idempotencyKey,
1880
+ runId: input.runId,
1881
+ inputConsumed: admission.consumed === true,
1882
+ inputBody: payload?.body ?? item.body
1883
+ }
1884
+ }
1885
+ if (input.version !== undefined && (admission.consumed || admission.duplicate)) {
1886
+ return inputReceipt()
1887
+ }
1888
+ if (admission.decision === "rejected-full") {
1889
+ return yield* Effect.fail(
1890
+ new NotificationQueue.NotificationError({
1891
+ code: "notification_full",
1892
+ notificationId: input.message.messageId,
1893
+ message: "Steering queue is full; retry after pending notifications are delivered"
1894
+ })
1895
+ )
1896
+ }
1897
+ // `createdAt` is the caller's own stated time, and the enqueue
1898
+ // entry is the one place it is kept: `steerItem` strips the control
1899
+ // envelope before the message reaches the queue, so a field the
1900
+ // journal did not carry was a field nothing ever read.
1901
+ yield* emit(input.runId, Steering.enqueuedEventType, {
1902
+ runId: input.runId,
1903
+ messageId: input.message.messageId,
1904
+ kind: item.kind,
1905
+ createdAt: input.message.createdAt
1906
+ })
1907
+ yield* wake(run, input.message.messageId)
1908
+ return inputReceipt()
1909
+ })
1910
+ ))
1911
+ )
1912
+ ),
1913
+ signal: Effect.fn("Control.signal")((submitted: SignalInput) =>
1914
+ confined(
1915
+ submitted,
1916
+ Effect.flatMap(snapshotSignal(submitted), (input) =>
1917
+ Effect.gen(function*() {
1918
+ const principal = yield* runtime.stampPrincipal(input.principal)
1919
+ const key = fingerprint("signal", principal, input)
1920
+ const durableKey = mutationKey("signal", input.idempotencyKey, principal)
1921
+ // Admission, payload and receipt commit together before any engine
1922
+ // operation. The writer is released before completing a deferred.
1923
+ const receipt = yield* mutate(
1924
+ "signal",
1925
+ input.idempotencyKey,
1926
+ principal,
1927
+ key,
1928
+ Effect.gen(function*() {
1929
+ const current = yield* getRun(input.runId)
1930
+ if (terminal(current.status)) {
1931
+ return { _tag: "Terminal" as const, runId: current.runId, status: current.status }
1932
+ }
1933
+ // The admitting identity is stored with the command: whether the
1934
+ // signal may answer a human wait is decided at delivery, which
1935
+ // can be a replay after restart with no caller present.
1936
+ yield* runtime.admitSignal(durableKey, input.runId, input.signal, principal)
1937
+ yield* emit(input.runId, "control.signal.admitted", {
1938
+ commandId: durableKey,
1939
+ runId: input.runId,
1940
+ name: input.signal.name
1941
+ })
1942
+ return accepted(input.idempotencyKey, input.runId)
1943
+ }),
1944
+ true,
1945
+ true
1946
+ )
1947
+ if (receipt._tag !== "Accepted" && receipt._tag !== "AlreadyApplied") return receipt
1948
+ const command = yield* runtime.signalCommand(durableKey)
1949
+ if (command === undefined || command.state === "delivered" || command.state === "terminal") return receipt
1950
+ if (command.state === "rejected") {
1951
+ return yield* new NoMatchingWait({ runId: input.runId, waitName: input.signal.name })
1952
+ }
1953
+ const delivery = Option.isNone(executor) ?
1954
+ "unknown" as const
1955
+ : yield* executor.value.deliverSignal({ ...command })
1956
+ if (delivery === "no-match") {
1957
+ yield* runtime.settleSignal(durableKey, "rejected")
1958
+ return yield* new NoMatchingWait({ runId: input.runId, waitName: input.signal.name })
1959
+ }
1960
+ if (delivery === "refused") {
1961
+ yield* runtime.settleSignal(durableKey, "rejected")
1962
+ return yield* new Unauthorized({
1963
+ message: `This caller has no authority to answer "${input.signal.name}" on run ${input.runId}`
1964
+ })
1965
+ }
1966
+ if (delivery === "delivered") {
1967
+ yield* runtime.settleSignal(durableKey, "delivered")
1968
+ }
1969
+ return receipt
1970
+ }))
1971
+ )
1972
+ ),
1973
+ cancel: Effect.fn("Control.cancel")((submitted) =>
1974
+ confined(
1975
+ submitted,
1976
+ Effect.flatMap(
1977
+ snapshotReasonedMutation("cancel", submitted),
1978
+ (input) =>
1979
+ Effect.flatMap(runtime.stampPrincipal(input.principal), (principal) =>
1980
+ mutate(
1981
+ "cancel",
1982
+ input.idempotencyKey,
1983
+ principal,
1984
+ fingerprint("cancel", principal, input),
1985
+ Effect.gen(function*() {
1986
+ const current = yield* getRun(input.runId)
1987
+ // A run that has already settled cannot be cancelled, and a cancel
1988
+ // request journaled against it would be a request nothing can ever
1989
+ // act on. Answer with what actually happened to the run.
1990
+ if (terminal(current.status)) {
1991
+ yield* reconcileTerminal(input.runId, current.status)
1992
+ return { _tag: "Terminal", runId: current.runId, status: current.status }
1993
+ }
1994
+ // The durable half, and the only half that reaches a run another
1995
+ // process owns: fibers are process-local, so an interrupt can only
1996
+ // stop a run this process is driving. The executor writes
1997
+ // `cancel_requested_at_ms` on the engine row instead, and the
1998
+ // owner's cancel poll acts on it within a heartbeat.
1999
+ //
2000
+ // It runs INSIDE the mutation's transaction on purpose. An engine
2001
+ // that refuses the request rolls the whole cancel back — no
2002
+ // attribution event, no terminal control status — because a
2003
+ // control row that says `cancelled` while the engine row is still
2004
+ // running is the one state an operator can never recover from.
2005
+ //
2006
+ // It runs BEFORE the attribution event for the mirror-image
2007
+ // reason. The control row this plane read may be stale — the two
2008
+ // `flows_runs` tables are two files in the shipped CLI — and an
2009
+ // engine row that has already settled makes the cancel a request
2010
+ // nobody can act on. Attributing and transitioning it anyway is
2011
+ // exactly the terminal disagreement B-11 forbids, so the engine's
2012
+ // own status becomes the receipt and nothing else happens.
2013
+ const record = yield* executorRequestCancel(input.runId)
2014
+ if (typeof record !== "string") {
2015
+ // The engine finished the run before the request arrived. Nobody
2016
+ // cancelled anything, so no attribution is written; but leaving
2017
+ // the control row saying `running` for a run the engine settled
2018
+ // is permanent, because no verb converges it: `cancel` answers
2019
+ // `Terminal` without writing and `resume` refuses a settled run.
2020
+ // `ps` listed it live and `gc` skipped it forever. Writing the
2021
+ // ENGINE's own status is convergence, not the terminal
2022
+ // disagreement B-11 forbids, which is a control row reading
2023
+ // `cancelled` over an engine row reading `completed`.
2024
+ yield* reconcileTerminal(input.runId, record.status)
2025
+ return { _tag: "Terminal", runId: input.runId, status: record.status }
2026
+ }
2027
+ // Attribution is keyed on the request being NEWLY recorded. A
2028
+ // cancel that committed without it would be durable and anonymous,
2029
+ // and nothing afterwards could say who asked — but this mutation
2030
+ // runs with `replay: false`, so an operator asking a second time
2031
+ // re-executes it, and attributing every ask journaled one
2032
+ // `control.run.cancel-requested` per ask for one cancellation.
2033
+ // `already-requested` is the engine saying the column was set
2034
+ // before this call arrived, so the record already exists.
2035
+ //
2036
+ // It stays BEFORE the interrupt, and in the mutation's own
2037
+ // transaction.
2038
+ const prior = yield* runtime.lookupMutation(
2039
+ mutationKey("cancel", input.idempotencyKey, principal),
2040
+ fingerprint("cancel", principal, input)
2041
+ )
2042
+ // A retry after cleanup or settlement failed already committed
2043
+ // this request's attribution with its acceptance receipt.
2044
+ if (record !== "already-requested" && prior === undefined) {
2045
+ yield* emit(
2046
+ input.runId,
2047
+ Cancellation.requestedEventType,
2048
+ json({
2049
+ runId: input.runId,
2050
+ source: "control",
2051
+ principal,
2052
+ ...(input.reason === undefined ? {} : { reason: input.reason })
2053
+ })
2054
+ )
2055
+ }
2056
+ return accepted(input.idempotencyKey, input.runId)
2057
+ }),
2058
+ false
2059
+ ).pipe(
2060
+ Effect.flatMap((receipt) =>
2061
+ Effect.gen(function*() {
2062
+ if (receipt._tag !== "Accepted") return receipt
2063
+ // The request and receipt are committed before any finalizer runs.
2064
+ // Finalizers may use this same mutation permit or durable writer.
2065
+ const settle: NonNullable<Parameters<typeof runtime.interrupt>[1]> = (effect) =>
2066
+ transact(
2067
+ "cancel",
2068
+ Effect.gen(function*() {
2069
+ const run = yield* effect
2070
+ yield* emit(
2071
+ input.runId,
2072
+ `control.run.${run.status}`,
2073
+ {
2074
+ runId: input.runId,
2075
+ status: run.status,
2076
+ ...ControlFacts.runFact(run)
2077
+ } as ControlEvent["payload"]
2078
+ )
2079
+ return run
2080
+ })
2081
+ )
2082
+ const run = yield* runtime.interrupt(input.runId, settle).pipe(
2083
+ Effect.catchTag("/control/ClaimLost", () =>
2084
+ Effect.gen(function*() {
2085
+ const current = yield* getRun(input.runId)
2086
+ if (terminal(current.status)) return current
2087
+ // A live peer acts on the durable request. An unowned park
2088
+ // needs this caller to claim it and finish the cancellation.
2089
+ if (live(current.status) && current.ownerId !== undefined) return undefined
2090
+ return yield* runtime.resume(input.runId).pipe(
2091
+ Effect.andThen(runtime.interrupt(input.runId, settle)),
2092
+ Effect.catchTag("/control/ClaimLost", () => Effect.succeed(undefined))
2093
+ )
2094
+ }))
2095
+ )
2096
+ return run === undefined
2097
+ ? receipt
2098
+ : terminalOrAccepted(input.idempotencyKey, run)
2099
+ })
2100
+ ),
2101
+ // Both rows, before the process that asked goes away. The engine row
2102
+ // carries the request the moment the mutation commits, but nothing
2103
+ // drives a parked run, so the row stayed `suspended` until some
2104
+ // later long-lived engine happened to sweep it: `gc` collected the
2105
+ // run in `control.db` and skipped it in `engine.db` for fifteen
2106
+ // seconds and six commands in the release validation.
2107
+ Effect.tap(() =>
2108
+ Effect.gen(function*() {
2109
+ yield* executorSettleCancelledPark(input.runId)
2110
+ // The engine can finish between the request and local interrupt,
2111
+ // or while settling an unowned park. Its read overlay alone does
2112
+ // not persist the control row or deliver the terminal watch event.
2113
+ const current = yield* getRun(input.runId)
2114
+ if (terminal(current.status)) yield* reconcileTerminal(input.runId, current.status)
2115
+ })
2116
+ )
2117
+ ))
2118
+ )
2119
+ )
2120
+ ),
2121
+ resume: Effect.fn("Control.resume")((input) => confined(input, runMutation(input))),
2122
+ list,
2123
+ watch
2124
+ }
2125
+ return Control.of(service)
2126
+ })
2127
+ )