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,291 @@
1
+ /**
2
+ * Workflows (Spec §16): status, priority, limits, usage; detail with runtime
3
+ * overrides, manual start (source item + parameter form from the schema) and
4
+ * dry-run (`unknown / not evaluated` for unexecuted checks).
5
+ */
6
+ import { ApiError, api, qs, seg } from '../api.js';
7
+ import { h, replaceChildren, rerender } from '../dom.js';
8
+ import { createRefresher } from '../../shared/throttle.js';
9
+ import { buildRuntimeBody, buildStartBody } from '../../shared/params.js';
10
+ import { checkValueLabel, decisionTone, formatDuration } from '../../shared/format.js';
11
+ import { buildHash } from '../../shared/route.js';
12
+ import { isStateChange } from '../page.js';
13
+ import { act, badge, button, errorBox, externalLink, isEditing, jsonBlock, kv, loading, routeLink, runLink, runOutcomeBadges, section, table, text, time, toast } from '../ui.js';
14
+ function timeoutText(t) {
15
+ const o = (t ?? {});
16
+ const fmt = (v) => (typeof v === 'number' ? formatDuration(v) : 'none');
17
+ return `max ${fmt(o.maxDurationMs)}, idle ${fmt(o.idleTimeoutMs)}`;
18
+ }
19
+ function triggerText(wf) {
20
+ const t = wf.trigger;
21
+ if (t.type === 'source')
22
+ return `source: ${t.source ?? '?'}`;
23
+ if (t.type === 'schedule')
24
+ return `schedule: ${t.cron ?? '?'} (${t.timezone ?? 'UTC'})`;
25
+ return t.type;
26
+ }
27
+ function overridden(wf, key) {
28
+ if (wf.overrides[key] === undefined)
29
+ return null;
30
+ return badge('override', 'info', `YAML value: ${String(wf.configured[key])} (runtime overrides are lost on restart/reload)`);
31
+ }
32
+ function enabledBadge(wf) {
33
+ return h('span', null, wf.enabled ? badge('active', 'ok') : badge('inactive', 'muted'), overridden(wf, 'enabled'));
34
+ }
35
+ export const workflowsPage = (ctx) => {
36
+ ctx.setTitle('Workflows');
37
+ const body = h('div', null, loading());
38
+ const el = h('div', { class: 'page' }, h('h1', null, 'Workflows'), body);
39
+ const load = async () => {
40
+ try {
41
+ const res = await api.get('/api/v1/workflows');
42
+ rerender(body, table(['Workflow', 'Trigger', 'Status', 'Priority', 'Active / max', 'Blocks', 'Last planning / next slot'], res.workflows.map((wf) => [
43
+ h('span', null, routeLink(['workflows', wf.id], wf.name), h('br'), h('small', { class: 'muted' }, wf.id)),
44
+ triggerText(wf),
45
+ enabledBadge(wf),
46
+ h('span', null, String(wf.priority), overridden(wf, 'priority')),
47
+ h('span', null, `${wf.activeRuns} / ${wf.maxConcurrent}`, overridden(wf, 'maxConcurrent')),
48
+ wf.activeBlocks > 0 ? routeLink(['blocked'], badge(String(wf.activeBlocks), 'warn')) : '0',
49
+ wf.cron
50
+ ? h('span', null, 'next ', time(wf.cron.nextSlot))
51
+ : wf.lastPlanning
52
+ ? h('span', null, time(wf.lastPlanning.at), ` ${wf.lastPlanning.status}`, wf.lastPlanning.error ? h('div', { class: 'error-text' }, wf.lastPlanning.error) : ` · ${wf.lastPlanning.polled} polled, ${wf.lastPlanning.started} started`)
53
+ : text(null),
54
+ ]), { empty: 'No workflows configured.' }));
55
+ }
56
+ catch (err) {
57
+ replaceChildren(body, errorBox(err));
58
+ }
59
+ };
60
+ const refresher = createRefresher(load, 2000);
61
+ void load();
62
+ return {
63
+ el,
64
+ onLive: (msg) => {
65
+ if (isStateChange(msg) && (msg.type !== 'run.event' || msg.payload.type === 'run.status'))
66
+ refresher.trigger();
67
+ },
68
+ onResync: () => void refresher.now(),
69
+ dispose: () => refresher.dispose(),
70
+ };
71
+ };
72
+ // ---------------------------------------------------------------------------
73
+ // Dry-run rendering
74
+ function checkRow(c) {
75
+ const v = checkValueLabel(c.value);
76
+ const what = c.kind === 'shell_filter' ? 'shell filter' : `${c.kind.replace('_', ' ')} #${c.index + 1}`;
77
+ return h('li', null, badge(v.label, v.tone), ' ', what, ': ', h('code', null, c.command), c.error ? h('span', { class: 'error-text' }, ` – ${c.error}`) : null);
78
+ }
79
+ export function traceNode(t) {
80
+ const v = checkValueLabel(t.value);
81
+ switch (t.kind) {
82
+ case 'all':
83
+ case 'any':
84
+ return h('li', null, badge(v.label, v.tone), ` ${t.kind}`, h('ul', { class: 'trace' }, t.children.map(traceNode)));
85
+ case 'not':
86
+ return h('li', null, badge(v.label, v.tone), ' not', h('ul', { class: 'trace' }, traceNode(t.child)));
87
+ case 'predicate':
88
+ return h('li', null, badge(v.label, v.tone), ' ', h('code', null, `${t.field} ${t.operator} ${JSON.stringify(t.expected)}`), t.actual !== undefined ? h('span', { class: 'muted' }, ` (actual ${JSON.stringify(t.actual)})`) : null, t.reason ? h('span', { class: 'muted' }, ` – ${t.reason}`) : null);
89
+ case 'invalid':
90
+ return h('li', null, badge(v.label, v.tone), ` invalid rule – ${t.reason}`);
91
+ }
92
+ }
93
+ export function renderDryRun(r) {
94
+ const parts = [];
95
+ parts.push(kv([
96
+ ['Generated', time(r.generatedAt)],
97
+ ['Enabled', r.enabled ? badge('yes', 'ok') : badge('no', 'muted')],
98
+ ['Workflow priority', String(r.workflowPriority)],
99
+ ['Capacity', `workflow ${r.capacity.workflow.active}/${r.capacity.workflow.max} · global ${r.capacity.global.active}/${r.capacity.global.max}`],
100
+ ['Automatic starts', r.gate ? (r.gate.canStartAutomatically ? badge('possible', 'ok') : badge(`not possible (${r.gate.reason ?? 'unknown'})`, 'warn')) : text(null)],
101
+ ]));
102
+ parts.push(h('p', { class: 'hint' }, 'Dry-run: no workers, no lifecycle actions, no shell filters/conditions. Checks that were not executed are shown as "unknown / not evaluated", never as pass.'));
103
+ if (r.conditions.length)
104
+ parts.push(h('h3', null, 'Workflow conditions'), h('ul', { class: 'checks' }, r.conditions.map(checkRow)));
105
+ if (r.source) {
106
+ parts.push(h('h3', null, `Source ${r.source.sourceId}: ${r.source.polled} item(s) polled`));
107
+ if (r.source.error)
108
+ parts.push(h('div', { class: 'alert alert-error' }, `Source error: ${r.source.error}`));
109
+ parts.push(h('p', null, `${r.summary.candidates} candidate(s): ${r.summary.wouldStart} would start, ${r.summary.pendingChecks} pending checks, ${r.summary.notStartable} not startable`), table(['Item', 'Priority', 'Decision', 'Details'], r.candidates.map((c) => [
110
+ h('span', null, externalLink(c.url, c.itemKey), c.title ? h('div', null, c.title) : null),
111
+ `${c.workflowPriority} / ${c.itemPriority}${c.matchedPriorityRule !== null ? ` (rule ${c.matchedPriorityRule + 1})` : ''}`,
112
+ h('span', null, badge(c.decision, decisionTone(c.decision)), c.reason ? h('div', { class: 'muted' }, c.reason) : null, c.runId ? h('div', null, runLink(c.runId)) : null),
113
+ c.filter || c.shellFilter || c.itemConditions.length
114
+ ? h('details', null, h('summary', null, 'checks'), c.filter ? h('ul', { class: 'trace' }, traceNode(c.filter)) : null, c.shellFilter || c.itemConditions.length ? h('ul', { class: 'checks' }, c.shellFilter ? checkRow(c.shellFilter) : null, c.itemConditions.map(checkRow)) : null)
115
+ : text(null),
116
+ ]), { empty: 'No candidates.' }));
117
+ }
118
+ if (r.cron) {
119
+ parts.push(h('h3', null, `Schedule ${r.cron.cron} (${r.cron.timezone})`), kv([
120
+ ['Next slot', h('span', null, badge(r.cron.nextSlotForecast, decisionTone(r.cron.nextSlotForecast)), r.cron.forecastReason ? ` – ${r.cron.forecastReason}` : '')],
121
+ ['Next slots', r.cron.nextSlots.length ? h('ul', { class: 'plain' }, r.cron.nextSlots.map((s) => h('li', null, time(s)))) : text(null)],
122
+ ]), r.cron.recentFirings.length
123
+ ? table(['Slot', 'Decision', 'Run', 'Reason'], r.cron.recentFirings.map((f) => [time(f.scheduledFor), badge(f.decision, decisionTone(f.decision)), f.runId ? runLink(f.runId) : text(null), text(f.reason)]))
124
+ : h('p', { class: 'muted' }, 'No recent slots.'));
125
+ }
126
+ if (r.notes.length)
127
+ parts.push(h('h3', null, 'Notes'), h('ul', null, r.notes.map((n) => h('li', null, n))));
128
+ return h('div', { class: 'dry-run' }, parts);
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Detail
132
+ export const workflowPage = (ctx, id) => {
133
+ ctx.setTitle(`Workflow ${id}`);
134
+ const header = h('div', null, loading());
135
+ const info = h('div', { class: 'grid' });
136
+ const overridesBox = h('section', { class: 'card' }, h('h2', null, 'Runtime overrides'));
137
+ const startBox = h('section', { class: 'card' }, h('h2', null, 'Manual start'));
138
+ const dryRunOut = h('div');
139
+ const dryBtn = button('Run dry-run', () => void dryRun(), { kind: 'primary' });
140
+ const dryBox = h('section', { class: 'card span-all' }, h('h2', null, 'Dry-run'), h('div', { class: 'actions' }, dryBtn), dryRunOut);
141
+ const runsBox = h('section', { class: 'card span-all' }, h('h2', null, 'Recent runs'), loading());
142
+ const configBox = h('section', { class: 'card span-all' });
143
+ const el = h('div', { class: 'page' }, h('p', { class: 'crumbs' }, routeLink(['workflows'], '← Workflows')), header, h('div', { class: 'grid' }, info, overridesBox, startBox, dryBox, runsBox, configBox));
144
+ let formsBuilt = false;
145
+ const dryRun = async () => {
146
+ replaceChildren(dryRunOut, loading());
147
+ const res = await act(dryBtn, () => api.post(`/api/v1/workflows/${seg(id)}/dry-run`));
148
+ replaceChildren(dryRunOut, res ? renderDryRun(res) : h('p', { class: 'muted' }, 'Dry-run failed.'));
149
+ };
150
+ const buildOverrides = (wf) => {
151
+ const enabled = h('select', { class: 'input', id: 'ov-enabled' }, h('option', { value: '', selected: wf.overrides.enabled === undefined }, `YAML (${wf.configured.enabled ? 'active' : 'inactive'})`), h('option', { value: 'true', selected: wf.overrides.enabled === true }, 'active'), h('option', { value: 'false', selected: wf.overrides.enabled === false }, 'inactive'));
152
+ const priority = h('input', { class: 'input input-narrow', id: 'ov-priority', type: 'number', step: 'any', value: wf.overrides.priority ?? '', placeholder: String(wf.configured.priority) });
153
+ const maxC = h('input', { class: 'input input-narrow', id: 'ov-max', type: 'number', min: 1, step: 1, value: wf.overrides.maxConcurrent ?? '', placeholder: String(wf.configured.maxConcurrent) });
154
+ const errors = h('div');
155
+ const save = button('Apply', () => {
156
+ const res = buildRuntimeBody({ enabled: enabled.value, priority: priority.value, maxConcurrent: maxC.value });
157
+ if (!res.ok) {
158
+ replaceChildren(errors, h('div', { class: 'alert alert-error' }, res.errors.map((e) => h('div', null, `${e.field}: ${e.message}`))));
159
+ return;
160
+ }
161
+ errors.replaceChildren();
162
+ void act(save, () => api.patch(`/api/v1/workflows/${seg(id)}/runtime`, res.body), 'Overrides applied').then(() => refresher.now());
163
+ }, { kind: 'primary' });
164
+ const reset = button('Reset all to YAML', () => {
165
+ enabled.value = '';
166
+ priority.value = '';
167
+ maxC.value = '';
168
+ void act(reset, () => api.patch(`/api/v1/workflows/${seg(id)}/runtime`, { enabled: null, priority: null, maxConcurrent: null }), 'Overrides removed').then(() => refresher.now());
169
+ });
170
+ overridesBox.append(h('div', { class: 'form' }, h('label', { class: 'field', for: 'ov-enabled' }, h('span', null, 'Enabled'), enabled), h('label', { class: 'field' }, h('span', null, 'Priority'), priority), h('label', { class: 'field' }, h('span', null, 'Max parallel'), maxC)), errors, h('div', { class: 'actions' }, save, reset), h('p', { class: 'hint' }, 'Empty = YAML value. Overrides are not persistent: they are lost on restart, and a configuration reload resets affected overrides to the YAML values.'));
171
+ };
172
+ const buildStart = (wf) => {
173
+ const fields = {};
174
+ const item = h('input', { class: 'input', type: 'text', id: 'start-item', placeholder: 'https://github.com/owner/repo/issues/7 or #7', maxlength: 2000 });
175
+ const rows = [];
176
+ if (wf.trigger.type === 'source')
177
+ rows.push(h('label', { class: 'field' }, h('span', null, 'Source item (required)'), item));
178
+ for (const [name, def] of Object.entries(wf.parameters)) {
179
+ let input;
180
+ const dflt = def.default === null || def.default === undefined ? null : String(def.default);
181
+ if (def.type === 'enum' || def.type === 'boolean') {
182
+ const values = def.type === 'enum' ? (def.values ?? []) : ['true', 'false'];
183
+ input = h('select', { class: 'input' }, h('option', { value: '' }, dflt !== null ? `default (${dflt})` : def.required ? '— choose —' : '(none)'), values.map((v) => h('option', { value: v }, v)));
184
+ }
185
+ else {
186
+ input = h('input', { class: 'input', type: def.type === 'number' ? 'number' : 'text', step: def.type === 'number' ? 'any' : null, placeholder: dflt ?? '' });
187
+ }
188
+ fields[name] = input;
189
+ rows.push(h('label', { class: 'field' }, h('span', null, name, def.required ? ' *' : '', h('small', { class: 'muted' }, ` ${def.type}`)), input, def.description ? h('small', { class: 'muted' }, def.description) : null));
190
+ }
191
+ const errors = h('div');
192
+ const startBtn = button('Start run', () => {
193
+ const values = {};
194
+ for (const [k, f] of Object.entries(fields))
195
+ values[k] = f.value;
196
+ const res = buildStartBody(wf.trigger.type, item.value, wf.parameters, values);
197
+ if (!res.ok) {
198
+ replaceChildren(errors, h('div', { class: 'alert alert-error' }, res.errors.map((e) => h('div', null, `${e.field}: ${e.message}`))));
199
+ return;
200
+ }
201
+ errors.replaceChildren();
202
+ void (async () => {
203
+ startBtn.disabled = true;
204
+ try {
205
+ const r = await api.post(`/api/v1/workflows/${seg(id)}/start`, res.body);
206
+ toast(`Run ${r.run.id} started`, 'ok');
207
+ ctx.navigate(buildHash(['runs', r.run.id], { tab: 'logs' }));
208
+ }
209
+ catch (err) {
210
+ const parts = [errorBox(err)];
211
+ if (err instanceof ApiError && err.code === 'lock_conflict') {
212
+ const holder = err.details?.holderRunId;
213
+ if (typeof holder === 'string')
214
+ parts.push(h('p', null, 'Active run: ', runLink(holder)));
215
+ }
216
+ replaceChildren(errors, parts);
217
+ }
218
+ finally {
219
+ startBtn.disabled = false;
220
+ }
221
+ })();
222
+ }, { kind: 'primary' });
223
+ startBox.append(h('p', { class: 'hint' }, 'A manual start bypasses filters, conditions, limits, blocks and pause – but not the active lock of an item.'), rows.length ? h('div', { class: 'form' }, rows) : h('p', { class: 'muted' }, 'No parameters.'), errors, h('div', { class: 'actions' }, startBtn));
224
+ };
225
+ const load = async () => {
226
+ if (isEditing(el)) {
227
+ setTimeout(() => refresher.trigger(), 2000);
228
+ return;
229
+ }
230
+ try {
231
+ const [wf, runs] = await Promise.all([
232
+ api.get(`/api/v1/workflows/${seg(id)}`),
233
+ api.get(`/api/v1/runs${qs({ workflow: id, limit: 10 })}`),
234
+ ]);
235
+ replaceChildren(header, h('h1', null, wf.name, ' ', h('small', { class: 'muted' }, wf.id)));
236
+ rerender(info, section('Status', kv([
237
+ ['Status', enabledBadge(wf)],
238
+ ['Trigger', triggerText(wf)],
239
+ ['Priority', h('span', null, String(wf.priority), overridden(wf, 'priority'))],
240
+ ['Max parallel', h('span', null, String(wf.maxConcurrent), overridden(wf, 'maxConcurrent'))],
241
+ ['Current usage', `${wf.activeRuns} active`],
242
+ ['Active blocks', wf.activeBlocks > 0 ? routeLink(['blocked'], badge(String(wf.activeBlocks), 'warn')) : '0'],
243
+ ['Identity', text(wf.identity)],
244
+ ['Command', h('code', { class: 'wrap' }, [wf.execute.command, ...wf.execute.args.map(String)].join(' '))],
245
+ ['Timeout', timeoutText(wf.execute.timeout)],
246
+ ['Secrets', wf.execute.secrets.length ? wf.execute.secrets.join(', ') : text(null)],
247
+ ['Filter', `${wf.hasFilter ? 'declarative' : 'none'}${wf.hasShellFilter ? ' + shell filter' : ''}; conditions: ${wf.conditions.workflow} workflow, ${wf.conditions.item} item`],
248
+ ['Max technical failures', String(wf.maxTechnicalFailures)],
249
+ ['File', h('code', null, wf.file)],
250
+ ...(wf.cron
251
+ ? [
252
+ ['Next slot', time(wf.cron.nextSlot)],
253
+ ['Last slot', wf.cron.lastFiring ? h('span', null, time(wf.cron.lastFiring.scheduledFor), ' ', badge(wf.cron.lastFiring.decision, decisionTone(wf.cron.lastFiring.decision))) : text(null)],
254
+ ]
255
+ : []),
256
+ ...(wf.lastPlanning
257
+ ? [['Last planning', h('span', null, time(wf.lastPlanning.at), ` ${wf.lastPlanning.status} · ${wf.lastPlanning.polled} polled · ${wf.lastPlanning.candidates} candidates · ${wf.lastPlanning.started} started`, wf.lastPlanning.error ? h('div', { class: 'error-text' }, wf.lastPlanning.error) : null)]]
258
+ : []),
259
+ ])));
260
+ if (!formsBuilt) {
261
+ formsBuilt = true;
262
+ buildOverrides(wf);
263
+ buildStart(wf);
264
+ replaceChildren(configBox, h('h2', null, 'Configuration'), h('details', null, h('summary', null, 'Declarative filter'), jsonBlock(wf.filter ?? null)), h('details', null, h('summary', null, 'Lifecycle hooks'), jsonBlock(wf.hooks ?? null)), h('details', null, h('summary', null, 'Last planning diagnosis'), jsonBlock(wf.diagnosis ?? null)));
265
+ }
266
+ rerender(runsBox, h('h2', null, `Recent runs (${runs.total})`), table(['Run', 'Trigger', 'Outcome', 'Started', 'Duration', 'Message'], runs.runs.map((r) => [runLink(r.id), r.triggerType, runOutcomeBadges(r), time(r.startedAt ?? r.createdAt), formatDuration(r.durationMs), text(r.active ? r.statusMessage : (r.resultMessage ?? r.finalReason))]), { empty: 'No runs yet.' }), runs.total > runs.runs.length ? h('p', null, routeLink(['runs'], 'All runs of this workflow →', { workflow: id })) : null);
267
+ }
268
+ catch (err) {
269
+ replaceChildren(header, errorBox(err));
270
+ }
271
+ };
272
+ const refresher = createRefresher(load, 1500);
273
+ void load();
274
+ return {
275
+ el,
276
+ onLive: (msg) => {
277
+ if (msg.type === 'config.reloaded') {
278
+ // Parameter schema/overrides may have changed: rebuild the forms.
279
+ formsBuilt = false;
280
+ overridesBox.replaceChildren(h('h2', null, 'Runtime overrides'));
281
+ startBox.replaceChildren(h('h2', null, 'Manual start'));
282
+ refresher.trigger();
283
+ return;
284
+ }
285
+ if (isStateChange(msg) && (msg.type !== 'run.event' || msg.payload.type === 'run.status'))
286
+ refresher.trigger();
287
+ },
288
+ onResync: () => void refresher.now(),
289
+ dispose: () => refresher.dispose(),
290
+ };
291
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared UI widgets: badges, links, tables, JSON blocks, toasts and the
3
+ * custom confirmation dialog (no `alert`/`confirm`/`prompt`).
4
+ */
5
+ import { h } from './dom.js';
6
+ import { ApiError } from './api.js';
7
+ import { buildHash } from '../shared/route.js';
8
+ import { formatDateTime, formatRelative, prettyJson, runBadges, safeHttpUrl } from '../shared/format.js';
9
+ /** Small colored label. */
10
+ export function badge(label, tone = 'muted', title) {
11
+ return h('span', { class: `badge badge-${tone}`, title: title ?? null }, label);
12
+ }
13
+ /** Badges of a run outcome (business failure / technical error / cancelled / lifecycle failed). */
14
+ export function runOutcomeBadges(run) {
15
+ return h('span', { class: 'badges' }, runBadges(run).map((b) => badge(b.label, b.tone, b.title)));
16
+ }
17
+ /** Link to an internal route. */
18
+ export function routeLink(segments, text, query = {}) {
19
+ return h('a', { href: buildHash(segments, query) }, text);
20
+ }
21
+ /** Link to a run detail. */
22
+ export function runLink(id, text) {
23
+ return routeLink(['runs', id], h('code', null, text ?? id));
24
+ }
25
+ /** External link (only http(s); otherwise plain text). */
26
+ export function externalLink(url, text) {
27
+ const safe = safeHttpUrl(url);
28
+ if (!safe)
29
+ return h('span', { class: 'muted' }, text ?? url ?? '—');
30
+ return h('a', { href: safe, target: '_blank', rel: 'noopener noreferrer' }, text ?? safe);
31
+ }
32
+ /** Timestamp with absolute time and relative tooltip. */
33
+ export function time(iso) {
34
+ if (!iso)
35
+ return h('span', { class: 'muted' }, '—');
36
+ return h('time', { datetime: iso, title: `${iso} (${formatRelative(iso, Date.now())})` }, formatDateTime(iso));
37
+ }
38
+ /** Plain text or a muted dash. */
39
+ export function text(value) {
40
+ if (value === null || value === undefined || value === '')
41
+ return h('span', { class: 'muted' }, '—');
42
+ return h('span', null, typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value));
43
+ }
44
+ /** JSON in a scrollable pre block. */
45
+ export function jsonBlock(value, maxChars) {
46
+ if (value === null || value === undefined)
47
+ return h('span', { class: 'muted' }, '—');
48
+ return h('pre', { class: 'json' }, prettyJson(value, maxChars));
49
+ }
50
+ /** Key/value list. */
51
+ export function kv(rows) {
52
+ return h('dl', { class: 'kv' }, rows.map(([k, v]) => [h('dt', null, k), h('dd', null, v)]));
53
+ }
54
+ /** Card section with title. */
55
+ export function section(title, ...children) {
56
+ return h('section', { class: 'card' }, h('h2', null, title), ...children);
57
+ }
58
+ function isHeaderCell(x) {
59
+ return typeof x === 'object' && x !== null && 'cell' in x && 'attrs' in x;
60
+ }
61
+ /** Table with header and rows (cells are already safe nodes/strings). */
62
+ export function table(headers, rows, opts = {}) {
63
+ if (rows.length === 0)
64
+ return h('p', { class: 'empty' }, opts.empty ?? 'Nothing here.');
65
+ return h('div', { class: 'table-wrap' }, h('table', { class: opts.className ?? null }, h('thead', null, h('tr', null, headers.map((x) => (isHeaderCell(x) ? h('th', { scope: 'col', ...x.attrs }, x.cell) : h('th', { scope: 'col' }, x))))), h('tbody', null, rows.map((r) => h('tr', null, r.map((c) => h('td', null, c)))))));
66
+ }
67
+ /** Button. */
68
+ export function button(label, onClick, opts = {}) {
69
+ return h('button', { type: 'button', class: `btn btn-${opts.kind ?? 'plain'}`, disabled: opts.disabled ?? false, title: opts.title ?? null, on: { click: () => onClick() } }, label);
70
+ }
71
+ /** Error message box. */
72
+ export function errorBox(err) {
73
+ const msg = err instanceof ApiError ? `${err.message} (${err.code})` : err instanceof Error ? err.message : String(err);
74
+ const details = err instanceof ApiError && err.details ? jsonBlock(err.details, 20_000) : null;
75
+ return h('div', { class: 'alert alert-error', role: 'alert' }, h('strong', null, 'Error: '), msg, details);
76
+ }
77
+ /** True while the user edits a form control inside `root` (live refreshes are postponed). */
78
+ export function isEditing(root) {
79
+ const a = document.activeElement;
80
+ return !!a && root.contains(a) && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT');
81
+ }
82
+ /** Loading placeholder. */
83
+ export function loading() {
84
+ return h('p', { class: 'muted loading' }, 'Loading…');
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Toasts
88
+ let toastHost = null;
89
+ /** Show a short message (auto-hides). */
90
+ export function toast(message, tone = 'info') {
91
+ if (!toastHost) {
92
+ toastHost = h('div', { class: 'toasts', role: 'status', 'aria-live': 'polite' });
93
+ document.body.appendChild(toastHost);
94
+ }
95
+ const el = h('div', { class: `toast toast-${tone}` }, message);
96
+ toastHost.appendChild(el);
97
+ setTimeout(() => el.remove(), tone === 'error' ? 8000 : 4000);
98
+ }
99
+ /** Toast for an error (API error text included). */
100
+ export function toastError(err) {
101
+ const msg = err instanceof ApiError ? `${err.message} (${err.code})` : err instanceof Error ? err.message : String(err);
102
+ toast(msg, 'error');
103
+ }
104
+ /** Modal confirmation built on `<dialog>`; resolves when closed. */
105
+ export function confirmDialog(opts) {
106
+ return new Promise((resolve) => {
107
+ const input = opts.input
108
+ ? h('input', { type: 'text', class: 'input', placeholder: opts.input.placeholder ?? null, maxlength: 500, 'aria-label': opts.input.label })
109
+ : null;
110
+ const checkbox = opts.checkbox ? h('input', { type: 'checkbox' }) : null;
111
+ const dialog = h('dialog', { class: 'dialog', 'aria-labelledby': 'dialog-title' });
112
+ let result = { ok: false, value: '', checked: false };
113
+ const close = (ok) => {
114
+ result = { ok, value: input?.value.trim() ?? '', checked: checkbox?.checked ?? false };
115
+ dialog.close();
116
+ };
117
+ const form = h('form', { method: 'dialog', on: { submit: (ev) => { ev.preventDefault(); close(true); } } }, h('h2', { id: 'dialog-title' }, opts.title), h('div', { class: 'dialog-body' }, opts.message), input ? h('label', { class: 'field' }, h('span', null, opts.input.label), input) : null, checkbox ? h('label', { class: 'check' }, checkbox, ' ', opts.checkbox.label) : null, h('div', { class: 'dialog-actions' }, h('button', { type: 'button', class: 'btn btn-plain', on: { click: () => close(false) } }, 'Cancel'), h('button', { type: 'submit', class: `btn ${opts.danger ? 'btn-danger' : 'btn-primary'}` }, opts.confirmLabel)));
118
+ dialog.appendChild(form);
119
+ dialog.addEventListener('close', () => {
120
+ dialog.remove();
121
+ resolve(result);
122
+ });
123
+ dialog.addEventListener('cancel', () => {
124
+ result = { ok: false, value: '', checked: false };
125
+ });
126
+ document.body.appendChild(dialog);
127
+ dialog.showModal();
128
+ (input ?? form.querySelector('button.btn-plain'))?.focus();
129
+ });
130
+ }
131
+ /**
132
+ * Run a mutating action with an in-flight-disabled button and toast feedback.
133
+ * Returns the API result or `undefined` on error.
134
+ */
135
+ export async function act(btn, fn, success) {
136
+ if (btn)
137
+ btn.disabled = true;
138
+ try {
139
+ const res = await fn();
140
+ if (success)
141
+ toast(success, 'ok');
142
+ return res;
143
+ }
144
+ catch (err) {
145
+ toastError(err);
146
+ return undefined;
147
+ }
148
+ finally {
149
+ if (btn)
150
+ btn.disabled = false;
151
+ }
152
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Minimal, XSS-safe element builder (Spec §20.5). Works against a structural
3
+ * `DocLike` so it runs in the browser (`document`) and in unit tests (fake
4
+ * document) alike.
5
+ *
6
+ * Rules:
7
+ * - String/number children always become text nodes (`createTextNode`), never HTML.
8
+ * - Attribute values are set via `setAttribute` only; event-handler attributes
9
+ * (`on*`), `style`, `srcdoc` and friends are rejected.
10
+ * - URL attributes (`href`, `src`, `action`, …) accept only absolute
11
+ * `http(s)` URLs or internal hash routes (`#/…`); anything else is dropped.
12
+ * - Event listeners are attached with `addEventListener` via the `on` option.
13
+ */
14
+ import { isInternalHref, safeHttpUrl } from './format.js';
15
+ const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'poster', 'cite', 'data', 'background', 'ping']);
16
+ const FORBIDDEN_ATTRS = new Set(['style', 'srcdoc', 'srcset', 'is']);
17
+ const ATTR_NAME = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$/;
18
+ /** Tags the builder refuses to create (script execution / embedding). */
19
+ const FORBIDDEN_TAGS = new Set(['script', 'iframe', 'object', 'embed', 'frame', 'frameset', 'base', 'meta', 'link', 'style']);
20
+ /**
21
+ * Decide whether an attribute may be set and with which value.
22
+ * Returns `null` if the attribute must be dropped.
23
+ */
24
+ export function sanitizeAttribute(name, value) {
25
+ const n = name.toLowerCase();
26
+ if (!ATTR_NAME.test(n) || n.startsWith('on') || FORBIDDEN_ATTRS.has(n))
27
+ return null;
28
+ if (URL_ATTRS.has(n)) {
29
+ if (isInternalHref(value))
30
+ return value;
31
+ return safeHttpUrl(value);
32
+ }
33
+ return value;
34
+ }
35
+ /** Create an element builder for `doc`. */
36
+ export function createH(doc) {
37
+ const append = (parent, child) => {
38
+ if (child === null || child === undefined || child === false)
39
+ return;
40
+ if (Array.isArray(child)) {
41
+ for (const c of child)
42
+ append(parent, c);
43
+ return;
44
+ }
45
+ if (typeof child === 'string' || typeof child === 'number') {
46
+ parent.appendChild(doc.createTextNode(String(child)));
47
+ return;
48
+ }
49
+ parent.appendChild(child);
50
+ };
51
+ return (tag, opts, ...children) => {
52
+ const t = tag.toLowerCase();
53
+ if (!/^[a-z][a-z0-9-]*$/.test(t) || FORBIDDEN_TAGS.has(t))
54
+ throw new Error(`h(): tag <${tag}> is not allowed`);
55
+ const el = doc.createElement(t);
56
+ if (opts) {
57
+ for (const [name, raw] of Object.entries(opts)) {
58
+ if (name === 'on')
59
+ continue;
60
+ if (raw === null || raw === undefined || raw === false)
61
+ continue;
62
+ if (typeof raw === 'object' || typeof raw === 'function')
63
+ continue;
64
+ const value = raw === true ? '' : String(raw);
65
+ const safe = sanitizeAttribute(name === 'className' ? 'class' : name, value);
66
+ if (safe !== null)
67
+ el.setAttribute(name === 'className' ? 'class' : name.toLowerCase(), safe);
68
+ }
69
+ if (opts.on)
70
+ for (const [type, listener] of Object.entries(opts.on))
71
+ el.addEventListener(type, listener);
72
+ }
73
+ for (const c of children)
74
+ append(el, c);
75
+ return el;
76
+ };
77
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Event list of the run detail: a contiguous REST cursor over the run's
3
+ * events (`sequence` 1, 2, 3, …) plus live events from SSE.
4
+ *
5
+ * - The detail endpoint returns only the first 500 events (`eventsTruncated`);
6
+ * further pages come from `GET /runs/:id/events?after=<cursor>`.
7
+ * - A live event is appended only if it directly continues the loaded range
8
+ * (`sequence === cursor + 1`) and nothing is pending on the server. Otherwise
9
+ * the list is marked as having newer events and the user loads them via REST
10
+ * from the cursor – so no range is ever skipped.
11
+ * - At most `maxRows` events are kept; the oldest are dropped (counted).
12
+ */
13
+ /** Default cap of rows kept in the view. */
14
+ export const EVENT_ROWS_MAX = 5000;
15
+ /** See module comment. */
16
+ export class EventList {
17
+ maxRows;
18
+ /** Highest sequence of the contiguous loaded range (0 = none). */
19
+ cursor = 0;
20
+ /** True if the server may have events after `cursor` that are not loaded. */
21
+ pending = false;
22
+ /** Number of oldest events dropped from the view. */
23
+ dropped = 0;
24
+ rows = 0;
25
+ /** Highest sequence seen live (also while not appended). */
26
+ liveMax = 0;
27
+ constructor(maxRows = EVENT_ROWS_MAX) {
28
+ this.maxRows = maxRows;
29
+ }
30
+ /** Number of rows currently kept. */
31
+ get size() {
32
+ return this.rows;
33
+ }
34
+ /** Query for the next REST page. */
35
+ nextQuery(limit = 1000) {
36
+ return { after: this.cursor, limit };
37
+ }
38
+ /**
39
+ * Apply a REST page that was requested with `after = cursor` (initial
40
+ * detail events: `after = 0`). Events must be ordered by sequence.
41
+ * A page that does not start at the cursor (stale response) is ignored.
42
+ */
43
+ page(events, hasMore, after) {
44
+ if (after !== this.cursor)
45
+ return { append: [], drop: 0 };
46
+ const append = [];
47
+ for (const e of events) {
48
+ if (e.sequence <= this.cursor)
49
+ continue;
50
+ append.push(e);
51
+ this.cursor = e.sequence;
52
+ }
53
+ // Live events seen beyond the loaded range (e.g. while this page was in flight) keep `pending`.
54
+ this.pending = hasMore || this.liveMax > this.cursor;
55
+ return this.add(append);
56
+ }
57
+ /**
58
+ * Apply a live event. Returns `null` if it was not appended: either a
59
+ * duplicate, or it does not continue the loaded range (then `pending` is set
60
+ * and the view should offer "load newer events").
61
+ */
62
+ live(e) {
63
+ if (e.sequence > this.liveMax)
64
+ this.liveMax = e.sequence;
65
+ if (e.sequence <= this.cursor)
66
+ return null;
67
+ if (this.pending || e.sequence !== this.cursor + 1) {
68
+ this.pending = true;
69
+ return null;
70
+ }
71
+ this.cursor = e.sequence;
72
+ return this.add([e]);
73
+ }
74
+ /** Mark that events may have been missed (e.g. after an SSE reconnect). */
75
+ markPending() {
76
+ this.pending = true;
77
+ }
78
+ add(append) {
79
+ this.rows += append.length;
80
+ let drop = 0;
81
+ if (this.rows > this.maxRows) {
82
+ drop = this.rows - this.maxRows;
83
+ this.rows = this.maxRows;
84
+ this.dropped += drop;
85
+ }
86
+ return { append, drop };
87
+ }
88
+ }