adispatchd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -2
  3. package/dist/api/errors.d.ts +27 -0
  4. package/dist/api/errors.js +80 -0
  5. package/dist/api/errors.js.map +1 -0
  6. package/dist/api/index.d.ts +10 -0
  7. package/dist/api/index.js +11 -0
  8. package/dist/api/index.js.map +1 -0
  9. package/dist/api/logs.d.ts +30 -0
  10. package/dist/api/logs.js +88 -0
  11. package/dist/api/logs.js.map +1 -0
  12. package/dist/api/serialize.d.ts +65 -0
  13. package/dist/api/serialize.js +135 -0
  14. package/dist/api/serialize.js.map +1 -0
  15. package/dist/api/server.d.ts +89 -0
  16. package/dist/api/server.js +751 -0
  17. package/dist/api/server.js.map +1 -0
  18. package/dist/api/sse.d.ts +90 -0
  19. package/dist/api/sse.js +225 -0
  20. package/dist/api/sse.js.map +1 -0
  21. package/dist/app.d.ts +173 -0
  22. package/dist/app.js +488 -0
  23. package/dist/app.js.map +1 -0
  24. package/dist/cli/config-validate.d.ts +6 -0
  25. package/dist/cli/config-validate.js +39 -0
  26. package/dist/cli/config-validate.js.map +1 -0
  27. package/dist/cli/control.d.ts +31 -0
  28. package/dist/cli/control.js +186 -0
  29. package/dist/cli/control.js.map +1 -0
  30. package/dist/cli/http-client.d.ts +57 -0
  31. package/dist/cli/http-client.js +174 -0
  32. package/dist/cli/http-client.js.map +1 -0
  33. package/dist/cli/index.d.ts +14 -0
  34. package/dist/cli/index.js +14 -0
  35. package/dist/cli/index.js.map +1 -0
  36. package/dist/cli/init-templates.d.ts +20 -0
  37. package/dist/cli/init-templates.js +275 -0
  38. package/dist/cli/init-templates.js.map +1 -0
  39. package/dist/cli/init.d.ts +16 -0
  40. package/dist/cli/init.js +107 -0
  41. package/dist/cli/init.js.map +1 -0
  42. package/dist/cli/main.d.ts +2 -0
  43. package/dist/cli/main.js +25 -0
  44. package/dist/cli/main.js.map +1 -0
  45. package/dist/cli/program.d.ts +102 -0
  46. package/dist/cli/program.js +133 -0
  47. package/dist/cli/program.js.map +1 -0
  48. package/dist/cli/service.d.ts +27 -0
  49. package/dist/cli/service.js +252 -0
  50. package/dist/cli/service.js.map +1 -0
  51. package/dist/cli/start.d.ts +22 -0
  52. package/dist/cli/start.js +133 -0
  53. package/dist/cli/start.js.map +1 -0
  54. package/dist/cli/workflow-dry-run.d.ts +13 -0
  55. package/dist/cli/workflow-dry-run.js +144 -0
  56. package/dist/cli/workflow-dry-run.js.map +1 -0
  57. package/dist/config/field-catalog.d.ts +33 -0
  58. package/dist/config/field-catalog.js +172 -0
  59. package/dist/config/field-catalog.js.map +1 -0
  60. package/dist/config/filter-schema.d.ts +15 -0
  61. package/dist/config/filter-schema.js +110 -0
  62. package/dist/config/filter-schema.js.map +1 -0
  63. package/dist/config/index.d.ts +15 -0
  64. package/dist/config/index.js +16 -0
  65. package/dist/config/index.js.map +1 -0
  66. package/dist/config/issues.d.ts +27 -0
  67. package/dist/config/issues.js +37 -0
  68. package/dist/config/issues.js.map +1 -0
  69. package/dist/config/loader.d.ts +32 -0
  70. package/dist/config/loader.js +250 -0
  71. package/dist/config/loader.js.map +1 -0
  72. package/dist/config/manager.d.ts +153 -0
  73. package/dist/config/manager.js +276 -0
  74. package/dist/config/manager.js.map +1 -0
  75. package/dist/config/model.d.ts +334 -0
  76. package/dist/config/model.js +2 -0
  77. package/dist/config/model.js.map +1 -0
  78. package/dist/config/normalize.d.ts +20 -0
  79. package/dist/config/normalize.js +200 -0
  80. package/dist/config/normalize.js.map +1 -0
  81. package/dist/config/parameters.d.ts +37 -0
  82. package/dist/config/parameters.js +71 -0
  83. package/dist/config/parameters.js.map +1 -0
  84. package/dist/config/public.d.ts +6 -0
  85. package/dist/config/public.js +18 -0
  86. package/dist/config/public.js.map +1 -0
  87. package/dist/config/schemas.d.ts +538 -0
  88. package/dist/config/schemas.js +321 -0
  89. package/dist/config/schemas.js.map +1 -0
  90. package/dist/config/snapshot.d.ts +38 -0
  91. package/dist/config/snapshot.js +57 -0
  92. package/dist/config/snapshot.js.map +1 -0
  93. package/dist/config/validate.d.ts +33 -0
  94. package/dist/config/validate.js +346 -0
  95. package/dist/config/validate.js.map +1 -0
  96. package/dist/core/clock.d.ts +59 -0
  97. package/dist/core/clock.js +133 -0
  98. package/dist/core/clock.js.map +1 -0
  99. package/dist/core/duration.d.ts +19 -0
  100. package/dist/core/duration.js +93 -0
  101. package/dist/core/duration.js.map +1 -0
  102. package/dist/core/errors.d.ts +62 -0
  103. package/dist/core/errors.js +72 -0
  104. package/dist/core/errors.js.map +1 -0
  105. package/dist/core/ids.d.ts +57 -0
  106. package/dist/core/ids.js +88 -0
  107. package/dist/core/ids.js.map +1 -0
  108. package/dist/core/index.d.ts +13 -0
  109. package/dist/core/index.js +14 -0
  110. package/dist/core/index.js.map +1 -0
  111. package/dist/core/logger.d.ts +100 -0
  112. package/dist/core/logger.js +207 -0
  113. package/dist/core/logger.js.map +1 -0
  114. package/dist/core/paths.d.ts +84 -0
  115. package/dist/core/paths.js +138 -0
  116. package/dist/core/paths.js.map +1 -0
  117. package/dist/core/template.d.ts +136 -0
  118. package/dist/core/template.js +261 -0
  119. package/dist/core/template.js.map +1 -0
  120. package/dist/core/types.d.ts +544 -0
  121. package/dist/core/types.js +172 -0
  122. package/dist/core/types.js.map +1 -0
  123. package/dist/core/worker-protocol.d.ts +102 -0
  124. package/dist/core/worker-protocol.js +75 -0
  125. package/dist/core/worker-protocol.js.map +1 -0
  126. package/dist/executors/classify.d.ts +25 -0
  127. package/dist/executors/classify.js +54 -0
  128. package/dist/executors/classify.js.map +1 -0
  129. package/dist/executors/dispatcher.d.ts +35 -0
  130. package/dist/executors/dispatcher.js +68 -0
  131. package/dist/executors/dispatcher.js.map +1 -0
  132. package/dist/executors/env.d.ts +43 -0
  133. package/dist/executors/env.js +66 -0
  134. package/dist/executors/env.js.map +1 -0
  135. package/dist/executors/event-parser.d.ts +41 -0
  136. package/dist/executors/event-parser.js +106 -0
  137. package/dist/executors/event-parser.js.map +1 -0
  138. package/dist/executors/index.d.ts +12 -0
  139. package/dist/executors/index.js +13 -0
  140. package/dist/executors/index.js.map +1 -0
  141. package/dist/executors/log-capture.d.ts +57 -0
  142. package/dist/executors/log-capture.js +155 -0
  143. package/dist/executors/log-capture.js.map +1 -0
  144. package/dist/executors/process-identity.d.ts +124 -0
  145. package/dist/executors/process-identity.js +283 -0
  146. package/dist/executors/process-identity.js.map +1 -0
  147. package/dist/executors/shell-executor.d.ts +35 -0
  148. package/dist/executors/shell-executor.js +478 -0
  149. package/dist/executors/shell-executor.js.map +1 -0
  150. package/dist/executors/stream-redactor.d.ts +40 -0
  151. package/dist/executors/stream-redactor.js +89 -0
  152. package/dist/executors/stream-redactor.js.map +1 -0
  153. package/dist/executors/workspace.d.ts +10 -0
  154. package/dist/executors/workspace.js +47 -0
  155. package/dist/executors/workspace.js.map +1 -0
  156. package/dist/filters/evaluate.d.ts +77 -0
  157. package/dist/filters/evaluate.js +187 -0
  158. package/dist/filters/evaluate.js.map +1 -0
  159. package/dist/filters/fields.d.ts +35 -0
  160. package/dist/filters/fields.js +75 -0
  161. package/dist/filters/fields.js.map +1 -0
  162. package/dist/filters/index.d.ts +11 -0
  163. package/dist/filters/index.js +12 -0
  164. package/dist/filters/index.js.map +1 -0
  165. package/dist/filters/iso-date.d.ts +10 -0
  166. package/dist/filters/iso-date.js +42 -0
  167. package/dist/filters/iso-date.js.map +1 -0
  168. package/dist/filters/predicate.d.ts +24 -0
  169. package/dist/filters/predicate.js +178 -0
  170. package/dist/filters/predicate.js.map +1 -0
  171. package/dist/filters/priority.d.ts +51 -0
  172. package/dist/filters/priority.js +91 -0
  173. package/dist/filters/priority.js.map +1 -0
  174. package/dist/filters/pushdown.d.ts +12 -0
  175. package/dist/filters/pushdown.js +52 -0
  176. package/dist/filters/pushdown.js.map +1 -0
  177. package/dist/filters/shell-check.d.ts +110 -0
  178. package/dist/filters/shell-check.js +304 -0
  179. package/dist/filters/shell-check.js.map +1 -0
  180. package/dist/identities/github-app.d.ts +55 -0
  181. package/dist/identities/github-app.js +160 -0
  182. package/dist/identities/github-app.js.map +1 -0
  183. package/dist/identities/index.d.ts +6 -0
  184. package/dist/identities/index.js +7 -0
  185. package/dist/identities/index.js.map +1 -0
  186. package/dist/identities/jwt.d.ts +24 -0
  187. package/dist/identities/jwt.js +56 -0
  188. package/dist/identities/jwt.js.map +1 -0
  189. package/dist/lifecycle/context.d.ts +106 -0
  190. package/dist/lifecycle/context.js +170 -0
  191. package/dist/lifecycle/context.js.map +1 -0
  192. package/dist/lifecycle/engine.d.ts +185 -0
  193. package/dist/lifecycle/engine.js +551 -0
  194. package/dist/lifecycle/engine.js.map +1 -0
  195. package/dist/lifecycle/errors.d.ts +48 -0
  196. package/dist/lifecycle/errors.js +71 -0
  197. package/dist/lifecycle/errors.js.map +1 -0
  198. package/dist/lifecycle/index.d.ts +9 -0
  199. package/dist/lifecycle/index.js +9 -0
  200. package/dist/lifecycle/index.js.map +1 -0
  201. package/dist/lifecycle/performers.d.ts +66 -0
  202. package/dist/lifecycle/performers.js +179 -0
  203. package/dist/lifecycle/performers.js.map +1 -0
  204. package/dist/notifications/index.d.ts +6 -0
  205. package/dist/notifications/index.js +7 -0
  206. package/dist/notifications/index.js.map +1 -0
  207. package/dist/notifications/service.d.ts +115 -0
  208. package/dist/notifications/service.js +364 -0
  209. package/dist/notifications/service.js.map +1 -0
  210. package/dist/persistence/actions.d.ts +101 -0
  211. package/dist/persistence/actions.js +196 -0
  212. package/dist/persistence/actions.js.map +1 -0
  213. package/dist/persistence/blocks.d.ts +85 -0
  214. package/dist/persistence/blocks.js +140 -0
  215. package/dist/persistence/blocks.js.map +1 -0
  216. package/dist/persistence/cron-firings.d.ts +55 -0
  217. package/dist/persistence/cron-firings.js +70 -0
  218. package/dist/persistence/cron-firings.js.map +1 -0
  219. package/dist/persistence/database.d.ts +60 -0
  220. package/dist/persistence/database.js +124 -0
  221. package/dist/persistence/database.js.map +1 -0
  222. package/dist/persistence/errors.d.ts +39 -0
  223. package/dist/persistence/errors.js +67 -0
  224. package/dist/persistence/errors.js.map +1 -0
  225. package/dist/persistence/events.d.ts +72 -0
  226. package/dist/persistence/events.js +83 -0
  227. package/dist/persistence/events.js.map +1 -0
  228. package/dist/persistence/index.d.ts +18 -0
  229. package/dist/persistence/index.js +19 -0
  230. package/dist/persistence/index.js.map +1 -0
  231. package/dist/persistence/instance-lock.d.ts +26 -0
  232. package/dist/persistence/instance-lock.js +117 -0
  233. package/dist/persistence/instance-lock.js.map +1 -0
  234. package/dist/persistence/lock-keys.d.ts +16 -0
  235. package/dist/persistence/lock-keys.js +41 -0
  236. package/dist/persistence/lock-keys.js.map +1 -0
  237. package/dist/persistence/notifications.d.ts +113 -0
  238. package/dist/persistence/notifications.js +213 -0
  239. package/dist/persistence/notifications.js.map +1 -0
  240. package/dist/persistence/persistence.d.ts +46 -0
  241. package/dist/persistence/persistence.js +42 -0
  242. package/dist/persistence/persistence.js.map +1 -0
  243. package/dist/persistence/retention.d.ts +46 -0
  244. package/dist/persistence/retention.js +68 -0
  245. package/dist/persistence/retention.js.map +1 -0
  246. package/dist/persistence/runs.d.ts +215 -0
  247. package/dist/persistence/runs.js +417 -0
  248. package/dist/persistence/runs.js.map +1 -0
  249. package/dist/persistence/schema.d.ts +16 -0
  250. package/dist/persistence/schema.js +183 -0
  251. package/dist/persistence/schema.js.map +1 -0
  252. package/dist/persistence/sqlite.d.ts +49 -0
  253. package/dist/persistence/sqlite.js +131 -0
  254. package/dist/persistence/sqlite.js.map +1 -0
  255. package/dist/runs/controller.d.ts +307 -0
  256. package/dist/runs/controller.js +1078 -0
  257. package/dist/runs/controller.js.map +1 -0
  258. package/dist/runs/errors.d.ts +20 -0
  259. package/dist/runs/errors.js +26 -0
  260. package/dist/runs/errors.js.map +1 -0
  261. package/dist/runs/event-bus.d.ts +106 -0
  262. package/dist/runs/event-bus.js +105 -0
  263. package/dist/runs/event-bus.js.map +1 -0
  264. package/dist/runs/execution.d.ts +39 -0
  265. package/dist/runs/execution.js +146 -0
  266. package/dist/runs/execution.js.map +1 -0
  267. package/dist/runs/index.d.ts +12 -0
  268. package/dist/runs/index.js +13 -0
  269. package/dist/runs/index.js.map +1 -0
  270. package/dist/runs/retention.d.ts +64 -0
  271. package/dist/runs/retention.js +232 -0
  272. package/dist/runs/retention.js.map +1 -0
  273. package/dist/runs/workspace.d.ts +20 -0
  274. package/dist/runs/workspace.js +51 -0
  275. package/dist/runs/workspace.js.map +1 -0
  276. package/dist/scheduler/checks.d.ts +41 -0
  277. package/dist/scheduler/checks.js +111 -0
  278. package/dist/scheduler/checks.js.map +1 -0
  279. package/dist/scheduler/cron.d.ts +32 -0
  280. package/dist/scheduler/cron.js +83 -0
  281. package/dist/scheduler/cron.js.map +1 -0
  282. package/dist/scheduler/dry-run.d.ts +42 -0
  283. package/dist/scheduler/dry-run.js +167 -0
  284. package/dist/scheduler/dry-run.js.map +1 -0
  285. package/dist/scheduler/errors.d.ts +22 -0
  286. package/dist/scheduler/errors.js +22 -0
  287. package/dist/scheduler/errors.js.map +1 -0
  288. package/dist/scheduler/format.d.ts +3 -0
  289. package/dist/scheduler/format.js +73 -0
  290. package/dist/scheduler/format.js.map +1 -0
  291. package/dist/scheduler/index.d.ts +13 -0
  292. package/dist/scheduler/index.js +14 -0
  293. package/dist/scheduler/index.js.map +1 -0
  294. package/dist/scheduler/planner.d.ts +88 -0
  295. package/dist/scheduler/planner.js +255 -0
  296. package/dist/scheduler/planner.js.map +1 -0
  297. package/dist/scheduler/scheduler.d.ts +160 -0
  298. package/dist/scheduler/scheduler.js +680 -0
  299. package/dist/scheduler/scheduler.js.map +1 -0
  300. package/dist/scheduler/state.d.ts +26 -0
  301. package/dist/scheduler/state.js +25 -0
  302. package/dist/scheduler/state.js.map +1 -0
  303. package/dist/scheduler/types.d.ts +197 -0
  304. package/dist/scheduler/types.js +3 -0
  305. package/dist/scheduler/types.js.map +1 -0
  306. package/dist/service/index.d.ts +6 -0
  307. package/dist/service/index.js +7 -0
  308. package/dist/service/index.js.map +1 -0
  309. package/dist/service/install.d.ts +93 -0
  310. package/dist/service/install.js +250 -0
  311. package/dist/service/install.js.map +1 -0
  312. package/dist/service/unit.d.ts +50 -0
  313. package/dist/service/unit.js +171 -0
  314. package/dist/service/unit.js.map +1 -0
  315. package/dist/sources/github/adapter.d.ts +99 -0
  316. package/dist/sources/github/adapter.js +336 -0
  317. package/dist/sources/github/adapter.js.map +1 -0
  318. package/dist/sources/github/fields.d.ts +8 -0
  319. package/dist/sources/github/fields.js +72 -0
  320. package/dist/sources/github/fields.js.map +1 -0
  321. package/dist/sources/github/http.d.ts +100 -0
  322. package/dist/sources/github/http.js +288 -0
  323. package/dist/sources/github/http.js.map +1 -0
  324. package/dist/sources/github/index.d.ts +6 -0
  325. package/dist/sources/github/index.js +7 -0
  326. package/dist/sources/github/index.js.map +1 -0
  327. package/dist/sources/github/pushdown.d.ts +14 -0
  328. package/dist/sources/github/pushdown.js +47 -0
  329. package/dist/sources/github/pushdown.js.map +1 -0
  330. package/dist/sources/github/refs.d.ts +17 -0
  331. package/dist/sources/github/refs.js +61 -0
  332. package/dist/sources/github/refs.js.map +1 -0
  333. package/dist/sources/index.d.ts +6 -0
  334. package/dist/sources/index.js +7 -0
  335. package/dist/sources/index.js.map +1 -0
  336. package/dist/ui/index.d.ts +12 -0
  337. package/dist/ui/index.js +28 -0
  338. package/dist/ui/index.js.map +1 -0
  339. package/dist/ui/public/app.css +239 -0
  340. package/dist/ui/public/favicon.svg +1 -0
  341. package/dist/ui/public/index.html +27 -0
  342. package/dist/ui/public/js/client/api.js +68 -0
  343. package/dist/ui/public/js/client/app.js +146 -0
  344. package/dist/ui/public/js/client/dom.js +34 -0
  345. package/dist/ui/public/js/client/page.js +9 -0
  346. package/dist/ui/public/js/client/pages/actions.js +35 -0
  347. package/dist/ui/public/js/client/pages/blocked.js +58 -0
  348. package/dist/ui/public/js/client/pages/config.js +126 -0
  349. package/dist/ui/public/js/client/pages/dashboard.js +175 -0
  350. package/dist/ui/public/js/client/pages/run.js +367 -0
  351. package/dist/ui/public/js/client/pages/runs.js +72 -0
  352. package/dist/ui/public/js/client/pages/views.js +80 -0
  353. package/dist/ui/public/js/client/pages/workflows.js +291 -0
  354. package/dist/ui/public/js/client/types.js +1 -0
  355. package/dist/ui/public/js/client/ui.js +152 -0
  356. package/dist/ui/public/js/shared/dom.js +77 -0
  357. package/dist/ui/public/js/shared/events.js +88 -0
  358. package/dist/ui/public/js/shared/format.js +213 -0
  359. package/dist/ui/public/js/shared/logs.js +136 -0
  360. package/dist/ui/public/js/shared/params.js +90 -0
  361. package/dist/ui/public/js/shared/route.js +36 -0
  362. package/dist/ui/public/js/shared/sort.js +70 -0
  363. package/dist/ui/public/js/shared/sse.js +193 -0
  364. package/dist/ui/public/js/shared/throttle.js +65 -0
  365. package/dist/views/index.d.ts +6 -0
  366. package/dist/views/index.js +7 -0
  367. package/dist/views/index.js.map +1 -0
  368. package/dist/views/service.d.ts +131 -0
  369. package/dist/views/service.js +264 -0
  370. package/dist/views/service.js.map +1 -0
  371. package/docs/api.md +239 -0
  372. package/docs/configuration-reference.md +1785 -0
  373. package/docs/cron-semantics.md +96 -0
  374. package/docs/manual-tests/github-fixture/config.yaml +30 -0
  375. package/docs/manual-tests/github-fixture/helpers.sh +93 -0
  376. package/docs/manual-tests/github-fixture/identities.yaml +18 -0
  377. package/docs/manual-tests/github-fixture/scripts/test-worker.sh +100 -0
  378. package/docs/manual-tests/github-fixture/sources.yaml +13 -0
  379. package/docs/manual-tests/github-fixture/views/dependencies.yaml +17 -0
  380. package/docs/manual-tests/github-fixture/views/needs-maintainer.yaml +17 -0
  381. package/docs/manual-tests/github-fixture/workflows/coder-test.yaml +76 -0
  382. package/docs/manual-tests/github-fixture/workflows/commenter-test.yaml +63 -0
  383. package/docs/manual-tests/github-fixture/workflows/reviewer-test.yaml +60 -0
  384. package/docs/manual-tests/github.md +1308 -0
  385. package/docs/manual-tests/process-supervision.md +64 -0
  386. package/docs/manual-tests/systemd.md +68 -0
  387. package/docs/operations.md +271 -0
  388. package/docs/worker-protocol.md +145 -0
  389. package/examples/config/config.yaml +35 -0
  390. package/examples/config/identities.yaml +18 -0
  391. package/examples/config/sources.yaml +13 -0
  392. package/examples/config/views/needs-maintainer.yaml +15 -0
  393. package/examples/config/workflows/coder-project-a.yaml +86 -0
  394. package/examples/config/workflows/deploy.yaml +15 -0
  395. package/examples/config/workflows/nightly-maintenance.yaml +13 -0
  396. package/package.json +75 -4
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Scheduler (Spec §5.5, §7, §14, §19): polling loop for source workflows,
3
+ * cron trigger, manual start and dry-run.
4
+ *
5
+ * Invariants:
6
+ * - Planning steps never overlap (serialized chain); the next poll is armed
7
+ * only after the previous step finished.
8
+ * - No persistent candidate queue: every step reads the sources again.
9
+ * - The final capacity check and the reservation run synchronously (no
10
+ * `await` in between), so concurrent cron firings / manual starts in the
11
+ * same process cannot overcommit the limits. Active runs of all triggers
12
+ * (incl. manual and `recovery_pending`) count towards the limits.
13
+ * - Automatic starts only when the run controller allows them (ready after
14
+ * recovery, not paused, not draining/stopping).
15
+ * - Cron: at most one run per slot (`cron_firings` primary key + slot lock);
16
+ * every handled slot gets exactly one `cron_firings` record; missed slots
17
+ * are recorded as `skipped_missed` and never caught up.
18
+ */
19
+ import { errorMessage, noopLogger, systemClock, systemTimers, } from '../core/index.js';
20
+ import { UnknownWorkflowError, automaticParameterValues } from '../config/index.js';
21
+ import { ShellCheckRunner, sortCandidates } from '../filters/index.js';
22
+ import { BlockedError, LockConflictError, } from '../persistence/index.js';
23
+ import { SchedulerChecks } from './checks.js';
24
+ import { dueSlots, parseCron } from './cron.js';
25
+ import { dryRunWorkflow } from './dry-run.js';
26
+ import { SchedulerError } from './errors.js';
27
+ import { checkEligibility, prepareSourceWorkflow, verifyItemFor } from './planner.js';
28
+ import { repositoryState } from './state.js';
29
+ import { MAX_DIAGNOSED_CANDIDATES, } from './types.js';
30
+ /** Cap of a single timer delay; longer waits are re-armed (also absorbs wall-clock jumps). */
31
+ const MAX_TIMER_DELAY_MS = 60 * 60_000;
32
+ /** Skip decisions recorded only once per series (state change), not per slot. */
33
+ const QUIET_DECISIONS = new Set([
34
+ 'skipped_disabled',
35
+ 'skipped_paused',
36
+ 'skipped_not_ready',
37
+ 'skipped_draining',
38
+ ]);
39
+ /** Bound for scanning missed cron slots. */
40
+ const MAX_CRON_SCAN = 100_000;
41
+ /** See module docs. One instance per process, created after the run controller's recovery. */
42
+ export class Scheduler {
43
+ config;
44
+ repos;
45
+ controller;
46
+ sources;
47
+ state;
48
+ shellChecks;
49
+ checks;
50
+ clock;
51
+ timers;
52
+ logger;
53
+ misfireToleranceMs;
54
+ maxRecordedMissed;
55
+ started = false;
56
+ stopped = false;
57
+ pollTimer;
58
+ stepChain = Promise.resolve();
59
+ inflight = new Set();
60
+ diagnoses = new Map();
61
+ lastReport = null;
62
+ cron = new Map();
63
+ unsubscribeConfig;
64
+ constructor(options) {
65
+ this.config = options.config;
66
+ this.repos = options.repositories;
67
+ this.controller = options.controller;
68
+ this.sources = options.sources;
69
+ this.clock = options.clock ?? systemClock;
70
+ this.timers = options.timers ?? systemTimers;
71
+ this.logger = (options.logger ?? noopLogger).child({ component: 'scheduler' });
72
+ this.state = repositoryState(this.repos);
73
+ // Check timeouts use real timers; the clock drives the condition cache TTL.
74
+ this.shellChecks = options.shellChecks ?? new ShellCheckRunner({ clock: this.clock, logger: this.logger });
75
+ this.checks = new SchedulerChecks(this.shellChecks, () => this.clock.now());
76
+ this.misfireToleranceMs = options.cronMisfireToleranceMs ?? 60_000;
77
+ this.maxRecordedMissed = options.maxRecordedMissedSlots ?? 100;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Lifecycle
81
+ /**
82
+ * Start the polling loop (first step immediately) and arm the cron timers
83
+ * (missed slots since the last persisted firing are recorded as
84
+ * `skipped_missed`). Call after `RunController.recover()`.
85
+ */
86
+ start() {
87
+ if (this.started || this.stopped)
88
+ return;
89
+ this.started = true;
90
+ this.unsubscribeConfig = this.config.onChange((event) => {
91
+ if (event.kind !== 'reload')
92
+ return;
93
+ // Changed commands/TTLs must not be served from the old cache.
94
+ this.shellChecks.invalidate();
95
+ this.syncCron();
96
+ for (const id of [...this.diagnoses.keys()]) {
97
+ const wf = event.config.workflows[id];
98
+ if (!wf || wf.trigger.type !== 'source')
99
+ this.diagnoses.delete(id);
100
+ }
101
+ });
102
+ this.syncCron();
103
+ this.loopStep();
104
+ this.logger.info('scheduler started', { pollingIntervalMs: this.config.current.global.scheduler.pollingIntervalMs });
105
+ }
106
+ /** Stop polling and cron timers; waits for running planning steps / cron handlers. Idempotent. */
107
+ async stop() {
108
+ this.stopped = true;
109
+ if (this.pollTimer)
110
+ this.timers.clearTimeout(this.pollTimer);
111
+ this.pollTimer = undefined;
112
+ for (const state of this.cron.values())
113
+ this.disarm(state);
114
+ this.unsubscribeConfig?.();
115
+ this.unsubscribeConfig = undefined;
116
+ await this.whenIdle();
117
+ }
118
+ /** Resolves once no planning step and no cron handler is in flight (tests, shutdown). */
119
+ async whenIdle() {
120
+ while (this.inflight.size > 0)
121
+ await Promise.allSettled([...this.inflight]);
122
+ }
123
+ track(p) {
124
+ this.inflight.add(p);
125
+ const remove = () => {
126
+ this.inflight.delete(p);
127
+ };
128
+ p.then(remove, remove);
129
+ return p;
130
+ }
131
+ /** One loop iteration: planning step, then arm the next poll (never overlapping). */
132
+ loopStep() {
133
+ this.planOnce()
134
+ .catch((err) => this.logger.error('planning step failed', { error: errorMessage(err) }))
135
+ .finally(() => this.armPoll(this.config.current.global.scheduler.pollingIntervalMs));
136
+ }
137
+ armPoll(delayMs) {
138
+ if (this.stopped)
139
+ return;
140
+ if (this.pollTimer)
141
+ this.timers.clearTimeout(this.pollTimer);
142
+ this.pollTimer = this.timers.setTimeout(() => {
143
+ this.pollTimer = undefined;
144
+ this.loopStep();
145
+ }, delayMs);
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Gate / capacity
149
+ gateReason() {
150
+ if (this.controller.canStartAutomatically())
151
+ return null;
152
+ const s = this.controller.status;
153
+ if (!s.ready)
154
+ return 'not_ready';
155
+ if (s.draining || s.stopping)
156
+ return 'draining';
157
+ if (s.paused)
158
+ return 'paused';
159
+ return 'not_ready';
160
+ }
161
+ gateInfo() {
162
+ const reason = this.gateReason();
163
+ return { canStartAutomatically: reason === null, reason };
164
+ }
165
+ /** Limit check with live effective values (overrides apply immediately). */
166
+ capacityProblem(workflowId) {
167
+ const globalMax = this.config.maxConcurrent;
168
+ const globalActive = this.state.countActive();
169
+ if (globalActive >= globalMax) {
170
+ return { decision: 'global_limit', reason: `global limit reached (${globalActive}/${globalMax} active)` };
171
+ }
172
+ const max = this.config.getWorkflow(workflowId)?.maxConcurrent ?? 1;
173
+ const active = this.state.countActive(workflowId);
174
+ if (active >= max)
175
+ return { decision: 'workflow_limit', reason: `workflow limit reached (${active}/${max} active)` };
176
+ return null;
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Planning step (Spec §7)
180
+ /**
181
+ * Run one planning step for all source workflows. Steps are serialized
182
+ * (a call during a running step runs after it). Never rejects for source
183
+ * or check problems – they are part of the diagnosis.
184
+ */
185
+ planOnce() {
186
+ const step = this.stepChain.then(() => this.planningStep());
187
+ this.stepChain = step.catch(() => undefined);
188
+ return this.track(step);
189
+ }
190
+ async planningStep() {
191
+ const startedAt = this.clock.now().toISOString();
192
+ const gate = this.stopped ? 'draining' : this.gateReason();
193
+ if (gate) {
194
+ const report = { startedAt, finishedAt: startedAt, skipped: gate, startedRunIds: [], workflows: [] };
195
+ this.lastReport = report;
196
+ return report;
197
+ }
198
+ const cfg = this.config.current;
199
+ const workflows = this.config.listWorkflows().filter((w) => w.config.trigger.type === 'source');
200
+ const listCache = new Map();
201
+ const conditionMemo = new Map();
202
+ // Steps 1–3 per workflow in parallel; a failing source only affects its workflows.
203
+ const prepared = await Promise.all(workflows.map(async (w) => {
204
+ if (!w.enabled) {
205
+ const sourceId = w.config.trigger.type === 'source' ? w.config.trigger.source : '';
206
+ return {
207
+ diagnosis: {
208
+ workflowId: w.config.id,
209
+ at: startedAt,
210
+ status: 'disabled',
211
+ error: null,
212
+ sourceId,
213
+ polled: 0,
214
+ pushdown: null,
215
+ conditions: [],
216
+ candidates: [],
217
+ omittedCandidates: 0,
218
+ startedRunIds: [],
219
+ },
220
+ candidates: [],
221
+ rejected: [],
222
+ };
223
+ }
224
+ // Never rejects: source problems are part of the diagnosis.
225
+ return prepareSourceWorkflow(w.config, w.priority, { sources: this.sources, at: startedAt, listCache });
226
+ }));
227
+ // Step 3: deterministic order across all workflows; steps 4–7 per candidate.
228
+ const ordered = sortCandidates(prepared.flatMap((p) => p.candidates));
229
+ const startedRunIds = [];
230
+ for (const candidate of ordered) {
231
+ try {
232
+ const runId = await this.considerCandidate(candidate, cfg, conditionMemo);
233
+ if (runId)
234
+ startedRunIds.push(runId);
235
+ }
236
+ catch (err) {
237
+ candidate.diagnosis.decision = 'start_failed';
238
+ candidate.diagnosis.reason = errorMessage(err);
239
+ this.logger.error('candidate failed unexpectedly', { workflowId: candidate.workflowId, item: candidate.itemKey, error: errorMessage(err) });
240
+ }
241
+ }
242
+ // Diagnoses (last planning step per workflow, in memory).
243
+ const byWorkflow = new Map(prepared.map((p) => [p.diagnosis.workflowId, p]));
244
+ for (const p of prepared) {
245
+ const diag = p.diagnosis;
246
+ const memo = conditionMemo.get(diag.workflowId);
247
+ if (memo)
248
+ diag.conditions = (await memo).checks;
249
+ const all = [...ordered.filter((c) => c.workflowId === diag.workflowId).map((c) => c.diagnosis), ...p.rejected];
250
+ diag.candidates = all.slice(0, MAX_DIAGNOSED_CANDIDATES);
251
+ diag.omittedCandidates = all.length - diag.candidates.length;
252
+ diag.startedRunIds = all.filter((c) => c.decision === 'started' && c.runId).map((c) => c.runId);
253
+ this.diagnoses.set(diag.workflowId, diag);
254
+ }
255
+ for (const id of [...this.diagnoses.keys()])
256
+ if (!byWorkflow.has(id))
257
+ this.diagnoses.delete(id);
258
+ const report = {
259
+ startedAt,
260
+ finishedAt: this.clock.now().toISOString(),
261
+ skipped: null,
262
+ startedRunIds,
263
+ workflows: prepared.map((p) => p.diagnosis),
264
+ };
265
+ this.lastReport = report;
266
+ if (startedRunIds.length > 0)
267
+ this.logger.info('planning step started runs', { runs: startedRunIds });
268
+ return report;
269
+ }
270
+ /** Steps 4–7 for one candidate; returns the started run ID. */
271
+ async considerCandidate(candidate, cfg, conditionMemo) {
272
+ const diag = candidate.diagnosis;
273
+ const set = (decision, reason) => {
274
+ diag.decision = decision;
275
+ diag.reason = reason;
276
+ return null;
277
+ };
278
+ // Free slot? (checked first so that a full limit costs no API calls / checks)
279
+ const early = this.capacityProblem(candidate.workflowId);
280
+ if (early)
281
+ return set(early.decision, early.reason);
282
+ const eligibility = await checkEligibility(candidate, {
283
+ state: this.state,
284
+ checks: this.checks,
285
+ workflowConditions: (wf) => {
286
+ let memo = conditionMemo.get(wf.id);
287
+ if (!memo) {
288
+ memo = this.checks.workflowConditions(wf);
289
+ conditionMemo.set(wf.id, memo);
290
+ }
291
+ return memo;
292
+ },
293
+ });
294
+ if (!eligibility.eligible)
295
+ return null;
296
+ // --- synchronous section: re-validate and reserve atomically -------------
297
+ if (this.stopped)
298
+ return set('gate_closed', 'scheduler is stopping');
299
+ const gate = this.gateReason();
300
+ if (gate)
301
+ return set('gate_closed', `automatic starts not possible: ${gate}`);
302
+ const eff = this.config.getWorkflow(candidate.workflowId);
303
+ if (!eff || !eff.enabled || this.config.current.workflowHashes[candidate.workflowId] !== cfg.workflowHashes[candidate.workflowId]) {
304
+ return set('workflow_unavailable', 'workflow was disabled, removed or changed during the planning step');
305
+ }
306
+ const capacity = this.capacityProblem(candidate.workflowId);
307
+ if (capacity)
308
+ return set(capacity.decision, capacity.reason);
309
+ let run;
310
+ try {
311
+ run = this.repos.runs.reserve({
312
+ workflowId: candidate.workflowId,
313
+ triggerType: 'source',
314
+ sourceId: candidate.item.sourceId,
315
+ sourceItemKey: candidate.itemKey,
316
+ sourceUrl: candidate.item.url,
317
+ configSnapshot: this.config.snapshotWorkflow(candidate.workflowId),
318
+ parameters: automaticParameterValues(eff.config),
319
+ failIfBlocked: true,
320
+ });
321
+ }
322
+ catch (err) {
323
+ if (err instanceof LockConflictError) {
324
+ diag.runId = err.holderRunId;
325
+ return set('locked', `run ${err.holderRunId} of this workflow is active for the item`);
326
+ }
327
+ if (err instanceof BlockedError) {
328
+ diag.blockId = err.blockId;
329
+ return set('blocked', `retry block ${err.blockId}`);
330
+ }
331
+ throw err;
332
+ }
333
+ // --- end of synchronous section ------------------------------------------
334
+ diag.runId = run.id;
335
+ try {
336
+ const verifyItem = verifyItemFor(candidate.workflow, candidate.adapter);
337
+ this.controller.startRun(run, verifyItem ? { verifyItem } : {});
338
+ }
339
+ catch (err) {
340
+ // The controller finalizes a refused reserved run itself (lock released).
341
+ return set('start_failed', `run ${run.id} could not be started: ${errorMessage(err)}`);
342
+ }
343
+ set('started', `run ${run.id} started`);
344
+ return run.id;
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Cron (Spec §5.5, §7)
348
+ /** Arm/re-arm cron timers for the schedule workflows of the active configuration. */
349
+ syncCron() {
350
+ if (this.stopped)
351
+ return;
352
+ const now = this.clock.now();
353
+ const seen = new Set();
354
+ for (const wf of Object.values(this.config.current.workflows)) {
355
+ if (wf.trigger.type !== 'schedule')
356
+ continue;
357
+ seen.add(wf.id);
358
+ const existing = this.cron.get(wf.id);
359
+ if (existing && existing.expression === wf.trigger.cron && existing.timezone === wf.trigger.timezone)
360
+ continue;
361
+ let schedule;
362
+ try {
363
+ schedule = parseCron(wf.trigger.cron, wf.trigger.timezone);
364
+ }
365
+ catch (err) {
366
+ this.logger.error('invalid cron expression', { workflowId: wf.id, error: errorMessage(err) });
367
+ continue;
368
+ }
369
+ if (existing)
370
+ this.disarm(existing);
371
+ // A changed schedule starts fresh at "now"; a newly seen workflow catches
372
+ // up from its latest persisted firing (slots missed during downtime).
373
+ const latest = existing ? null : this.repos.cronFirings.latest(wf.id);
374
+ const state = {
375
+ workflowId: wf.id,
376
+ expression: wf.trigger.cron,
377
+ timezone: wf.trigger.timezone,
378
+ schedule,
379
+ timer: undefined,
380
+ nextSlot: null,
381
+ lastHandled: latest ? new Date(latest.scheduledFor) : now,
382
+ catchUp: latest !== null,
383
+ quietDecision: latest && QUIET_DECISIONS.has(latest.decision) ? latest.decision : null,
384
+ chain: Promise.resolve(),
385
+ lastConditions: [],
386
+ removed: false,
387
+ };
388
+ this.cron.set(wf.id, state);
389
+ this.enqueueCron(state);
390
+ }
391
+ for (const [id, state] of [...this.cron]) {
392
+ if (seen.has(id))
393
+ continue;
394
+ this.disarm(state);
395
+ state.removed = true;
396
+ this.cron.delete(id);
397
+ }
398
+ }
399
+ disarm(state) {
400
+ if (state.timer)
401
+ this.timers.clearTimeout(state.timer);
402
+ state.timer = undefined;
403
+ }
404
+ enqueueCron(state) {
405
+ const next = state.chain.then(() => this.processDue(state));
406
+ state.chain = next.catch((err) => {
407
+ this.logger.error('cron handling failed', { workflowId: state.workflowId, error: errorMessage(err) });
408
+ });
409
+ void this.track(state.chain);
410
+ }
411
+ armCronTimer(state) {
412
+ if (this.stopped || state.removed)
413
+ return;
414
+ const now = this.clock.now();
415
+ const from = state.lastHandled.getTime() > now.getTime() ? state.lastHandled : now;
416
+ const next = state.schedule.next(from);
417
+ state.nextSlot = next;
418
+ if (!next)
419
+ return;
420
+ const delay = Math.min(Math.max(0, next.getTime() - now.getTime()), MAX_TIMER_DELAY_MS);
421
+ state.timer = this.timers.setTimeout(() => {
422
+ state.timer = undefined;
423
+ this.enqueueCron(state);
424
+ }, delay);
425
+ }
426
+ /** Handle all slots in `(lastHandled, now]`: the latest within tolerance fires, the rest are missed. */
427
+ async processDue(state) {
428
+ if (this.stopped || state.removed)
429
+ return;
430
+ this.disarm(state);
431
+ try {
432
+ await this.decideDue(state);
433
+ }
434
+ finally {
435
+ // Always re-arm: one failing slot (e.g. a DB error) must not stop the schedule.
436
+ this.armCronTimer(state);
437
+ }
438
+ }
439
+ async decideDue(state) {
440
+ const now = this.clock.now();
441
+ const due = dueSlots(state.schedule, state.lastHandled, now, { keep: this.maxRecordedMissed + 1, maxScan: MAX_CRON_SCAN });
442
+ const catchUp = state.catchUp;
443
+ state.catchUp = false;
444
+ if (due.slots.length > 0) {
445
+ const latest = due.slots[due.slots.length - 1];
446
+ const fire = now.getTime() - latest.getTime() <= this.misfireToleranceMs;
447
+ const missed = fire ? due.slots.slice(0, -1) : due.slots;
448
+ const notRecorded = due.total - due.slots.length;
449
+ missed.forEach((slot, i) => {
450
+ let reason = catchUp
451
+ ? state.quietDecision
452
+ ? `slot passed while the orchestrator was not running (or while still ${state.quietDecision})`
453
+ : 'slot passed while the orchestrator was not running'
454
+ : `slot handled ${Math.round((now.getTime() - slot.getTime()) / 1000)}s late (tolerance ${Math.round(this.misfireToleranceMs / 1000)}s)`;
455
+ if (i === 0 && (notRecorded > 0 || due.scanLimited))
456
+ reason += ` (+${due.scanLimited ? 'at least ' : ''}${notRecorded} older missed slot(s) not recorded)`;
457
+ this.recordFiring(state, slot, 'skipped_missed', reason);
458
+ });
459
+ if (missed.length > 0) {
460
+ this.logger.warn('cron slots missed (not caught up)', { workflowId: state.workflowId, missed: missed.length + notRecorded });
461
+ }
462
+ state.lastHandled = latest;
463
+ if (fire)
464
+ await this.handleSlot(state, latest);
465
+ }
466
+ }
467
+ /**
468
+ * Persist a slot decision. Quiet skips (disabled / gate closed) are written
469
+ * only for the first slot of a series; later slots of the same series only
470
+ * advance `lastHandled` (no row flood for frequent schedules).
471
+ */
472
+ recordFiring(state, slot, decision, reason) {
473
+ const workflowId = state.workflowId;
474
+ const quiet = QUIET_DECISIONS.has(decision);
475
+ if (quiet && state.quietDecision === decision) {
476
+ this.logger.debug('cron slot (series continues, not recorded)', { workflowId, slot: slot.toISOString(), decision });
477
+ return;
478
+ }
479
+ const text = quiet ? `${reason ?? decision}; further slots are not recorded while this state lasts` : reason;
480
+ const { inserted } = this.repos.cronFirings.record({ workflowId, scheduledFor: slot, decision, reason: text });
481
+ state.quietDecision = quiet ? decision : null;
482
+ if (inserted)
483
+ this.logger.info('cron slot', { workflowId, slot: slot.toISOString(), decision, reason: text });
484
+ }
485
+ /** Decide one due slot: start a run or record why not. */
486
+ async handleSlot(state, slot) {
487
+ const workflowId = state.workflowId;
488
+ if (this.repos.cronFirings.get(workflowId, slot))
489
+ return; // already handled (e.g. before a restart)
490
+ const pre = this.slotPrecondition(workflowId);
491
+ if (pre)
492
+ return this.recordFiring(state, slot, pre.decision, pre.reason);
493
+ const eff = this.config.getWorkflow(workflowId);
494
+ if (eff.config.conditions.workflow.length > 0) {
495
+ const outcome = await this.checks.workflowConditions(eff.config);
496
+ state.lastConditions = outcome.checks;
497
+ if (outcome.value !== true) {
498
+ const f = outcome.failed;
499
+ const what = `workflow condition ${f?.index ?? '?'} (${f?.command ?? ''})`;
500
+ return outcome.value === false
501
+ ? this.recordFiring(state, slot, 'skipped_condition', `${what} is false`)
502
+ : this.recordFiring(state, slot, 'skipped_condition_unknown', `${what} is unknown: ${f?.error ?? 'check error'}`);
503
+ }
504
+ if (this.stopped)
505
+ return;
506
+ const again = this.slotPrecondition(workflowId);
507
+ if (again)
508
+ return this.recordFiring(state, slot, again.decision, again.reason);
509
+ }
510
+ // Synchronous: record + reserve in one transaction (at most one run per slot).
511
+ let run;
512
+ try {
513
+ run = this.repos.transaction(() => {
514
+ const { inserted } = this.repos.cronFirings.record({ workflowId, scheduledFor: slot, decision: 'started', reason: null });
515
+ if (!inserted)
516
+ return null;
517
+ const reserved = this.repos.runs.reserve({
518
+ workflowId,
519
+ triggerType: 'schedule',
520
+ scheduledFor: slot,
521
+ configSnapshot: this.config.snapshotWorkflow(workflowId),
522
+ parameters: automaticParameterValues(eff.config),
523
+ failIfBlocked: true,
524
+ });
525
+ this.repos.cronFirings.update(workflowId, slot, { runId: reserved.id });
526
+ return reserved;
527
+ });
528
+ }
529
+ catch (err) {
530
+ if (err instanceof BlockedError)
531
+ return this.recordFiring(state, slot, 'skipped_blocked', `retry block ${err.blockId}`);
532
+ if (err instanceof LockConflictError)
533
+ return this.recordFiring(state, slot, 'skipped_lock', `slot lock held by run ${err.holderRunId}`);
534
+ this.logger.error('cron reservation failed', { workflowId, slot: slot.toISOString(), error: errorMessage(err) });
535
+ return this.recordFiring(state, slot, 'start_failed', errorMessage(err));
536
+ }
537
+ if (!run)
538
+ return;
539
+ state.quietDecision = null;
540
+ this.logger.info('cron slot', { workflowId, slot: slot.toISOString(), decision: 'started', runId: run.id });
541
+ try {
542
+ this.controller.startRun(run);
543
+ }
544
+ catch (err) {
545
+ this.repos.cronFirings.update(workflowId, slot, { decision: 'start_failed', reason: errorMessage(err) });
546
+ }
547
+ }
548
+ /** Synchronous checks before a slot may start (enabled, gate, limits, block). */
549
+ slotPrecondition(workflowId) {
550
+ const eff = this.config.getWorkflow(workflowId);
551
+ if (!eff || eff.config.trigger.type !== 'schedule')
552
+ return { decision: 'skipped_disabled', reason: 'workflow no longer scheduled' };
553
+ if (!eff.enabled)
554
+ return { decision: 'skipped_disabled', reason: 'workflow is disabled' };
555
+ const gate = this.stopped ? 'draining' : this.gateReason();
556
+ if (gate)
557
+ return { decision: `skipped_${gate}`, reason: `automatic starts not possible (${gate})` };
558
+ const capacity = this.capacityProblem(workflowId);
559
+ if (capacity)
560
+ return { decision: 'skipped_limit', reason: capacity.reason };
561
+ const block = this.state.activeBlock(workflowId, null);
562
+ if (block)
563
+ return { decision: 'skipped_blocked', reason: `retry block ${block.id}: ${block.reason}` };
564
+ return null;
565
+ }
566
+ // ---------------------------------------------------------------------------
567
+ // Manual start (Spec §5.5, §7)
568
+ /**
569
+ * Start a workflow manually. Bypasses filters, conditions, retry blocks,
570
+ * priorities and limits (and `enabled`/pause), but never the active lock of
571
+ * the workflow+item (`LockConflictError`, HTTP 409). The run counts towards
572
+ * the limits of automatic starts.
573
+ *
574
+ * @throws UnknownWorkflowError, SchedulerError, LockConflictError
575
+ */
576
+ async startManual(workflowId, request = {}) {
577
+ const eff = this.config.getWorkflow(workflowId);
578
+ if (!eff)
579
+ throw new UnknownWorkflowError(workflowId);
580
+ this.assertAcceptsManual();
581
+ const workflow = eff.config;
582
+ const ref = request.itemRef?.trim() ?? '';
583
+ if (workflow.trigger.type !== 'source' && ref) {
584
+ throw new SchedulerError('manual_item_not_supported', `Workflow '${workflowId}' (${workflow.trigger.type}) has no source items`);
585
+ }
586
+ const params = this.config.validateParameters(workflowId, request.parameters);
587
+ if (!params.ok) {
588
+ throw new SchedulerError('invalid_parameters', `Invalid parameters for workflow '${workflowId}'`, { errors: params.errors });
589
+ }
590
+ let item = null;
591
+ if (workflow.trigger.type === 'source') {
592
+ const sourceId = workflow.trigger.source;
593
+ if (!ref)
594
+ throw new SchedulerError('manual_item_required', `Workflow '${workflowId}' needs an item reference (URL or ID)`);
595
+ const adapter = this.sources(sourceId);
596
+ if (!adapter)
597
+ throw new SchedulerError('source_unavailable', `Source '${sourceId}' is not available`, { sourceId });
598
+ const itemId = adapter.parseItemRef(ref);
599
+ if (itemId === null) {
600
+ throw new SchedulerError('manual_item_invalid_ref', `'${ref}' is not an item reference of source '${sourceId}'`, { sourceId, itemRef: ref });
601
+ }
602
+ let found;
603
+ try {
604
+ found = await adapter.getItem(itemId);
605
+ }
606
+ catch (err) {
607
+ throw new SchedulerError('source_unavailable', `Reading item ${itemId} from '${sourceId}' failed: ${errorMessage(err)}`, { sourceId, itemId }, err);
608
+ }
609
+ if (!found)
610
+ throw new SchedulerError('manual_item_not_found', `Item ${itemId} not found in source '${sourceId}'`, { sourceId, itemId });
611
+ item = { sourceId, key: found.key, url: found.url };
612
+ }
613
+ // Synchronous from here: re-check acceptance, reserve (lock only, no block/limit check), start.
614
+ this.assertAcceptsManual();
615
+ const values = params.values;
616
+ const run = this.repos.runs.reserve({
617
+ workflowId,
618
+ triggerType: 'manual',
619
+ sourceId: item?.sourceId ?? null,
620
+ sourceItemKey: item?.key ?? null,
621
+ sourceUrl: item?.url ?? null,
622
+ configSnapshot: this.config.snapshotWorkflow(workflowId),
623
+ parameters: Object.keys(values).length > 0 ? values : null,
624
+ failIfBlocked: false,
625
+ });
626
+ this.logger.info('manual run reserved', { workflowId, runId: run.id, item: item?.key ?? null });
627
+ const handle = this.controller.startRun(run);
628
+ return { run, done: handle.done };
629
+ }
630
+ assertAcceptsManual() {
631
+ if (this.stopped || !this.controller.canStartManually()) {
632
+ const s = this.controller.status;
633
+ const reason = this.stopped ? 'scheduler stopped' : !s.ready ? 'recovery not finished' : 'draining / shutting down';
634
+ throw new SchedulerError('scheduler_not_accepting', `Manual starts are currently not possible: ${reason}`, { reason });
635
+ }
636
+ }
637
+ // ---------------------------------------------------------------------------
638
+ // Dry-run and diagnostics (GUI/API)
639
+ /** Dry-run a workflow (no reservation, no actions, no shell checks). @throws UnknownWorkflowError */
640
+ dryRun(workflowId, options = {}) {
641
+ return dryRunWorkflow({
642
+ config: this.config,
643
+ sources: this.sources,
644
+ state: this.state,
645
+ clock: this.clock,
646
+ gate: () => this.gateInfo(),
647
+ ...(options.signal ? { signal: options.signal } : {}),
648
+ }, workflowId);
649
+ }
650
+ /** Last planning diagnosis of a source workflow (`undefined` before its first step). */
651
+ lastDiagnosis(workflowId) {
652
+ return this.diagnoses.get(workflowId);
653
+ }
654
+ /** Last planning diagnoses of all source workflows, sorted by workflow ID. */
655
+ listDiagnoses() {
656
+ return [...this.diagnoses.values()].sort((a, b) => (a.workflowId < b.workflowId ? -1 : a.workflowId > b.workflowId ? 1 : 0));
657
+ }
658
+ /** Summary of the last planning step (`null` before the first one). */
659
+ lastPlanningStep() {
660
+ return this.lastReport;
661
+ }
662
+ /** Cron status of all schedule workflows, sorted by workflow ID. */
663
+ cronStatus() {
664
+ return [...this.cron.values()]
665
+ .map((s) => {
666
+ const latest = this.repos.cronFirings.latest(s.workflowId);
667
+ return {
668
+ workflowId: s.workflowId,
669
+ cron: s.expression,
670
+ timezone: s.timezone,
671
+ enabled: this.config.getWorkflow(s.workflowId)?.enabled ?? false,
672
+ nextSlot: s.nextSlot?.toISOString() ?? null,
673
+ lastFiring: latest,
674
+ lastConditions: s.lastConditions,
675
+ };
676
+ })
677
+ .sort((a, b) => (a.workflowId < b.workflowId ? -1 : a.workflowId > b.workflowId ? 1 : 0));
678
+ }
679
+ }
680
+ //# sourceMappingURL=scheduler.js.map