openlanes 3.0.0 → 3.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 (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+
6
+ module OpenLanes
7
+ module Apps
8
+ # Whether a process is ready (plan 012, decision 1): something accepts a
9
+ # TCP connection on its port, and, when its `ready:` names a path, a GET
10
+ # of that path answers 2xx or 3xx.
11
+ #
12
+ # Only ever on the port the process was given. An app that moved to
13
+ # another port (Vite without `--strictPort` does) therefore never
14
+ # becomes ready, which is how a moved port fails the start rather than
15
+ # leaving the Preview tab pointing at nothing.
16
+ module Probe
17
+ # Seconds a connect, and a read, may take.
18
+ TIMEOUT = 1
19
+
20
+ module_function
21
+
22
+ # @param port [Integer]
23
+ # @param path [String, nil]
24
+ # @return [Boolean]
25
+ def ready?(port, path: nil)
26
+ return false unless listening?(port)
27
+ return true if path.nil?
28
+
29
+ status(port, path).between?(200, 399)
30
+ end
31
+
32
+ # Something accepted a connection on the port. Asking the socket for
33
+ # its peer is the part that matters: behind a network filter (Little
34
+ # Snitch on macOS) `Socket.tcp` with `connect_timeout` returns a socket
35
+ # for a refused connect, and only `getpeername` then says EINVAL. Without
36
+ # it every port on the machine reads as taken.
37
+ #
38
+ # @param port [Integer]
39
+ # @param timeout [Numeric] seconds the connect may take
40
+ # @return [Boolean]
41
+ def listening?(port, timeout: TIMEOUT)
42
+ Socket.tcp(Status::HOST, port, connect_timeout: timeout) do |socket|
43
+ socket.remote_address
44
+ true
45
+ end
46
+ rescue SystemCallError, IOError, SocketError
47
+ false
48
+ end
49
+
50
+ # @param port [Integer]
51
+ # @param path [String]
52
+ # @return [Integer] the status, 0 when nothing answered
53
+ def status(port, path)
54
+ http = Net::HTTP.new(Status::HOST, port)
55
+ http.open_timeout = TIMEOUT
56
+ http.read_timeout = TIMEOUT
57
+ http.request_get(path).code.to_i
58
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError
59
+ 0
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # One process of an app, as `.openlanes.yml` declares it under
6
+ # `app.processes` (M12).
7
+ #
8
+ # @!attribute [r] name
9
+ # @return [String] `api`, `web`; unique within the app
10
+ # @!attribute [r] start
11
+ # @return [String] a shell line, run by `sh -c` in the checkout, so
12
+ # `$PORT_WEB` expands there
13
+ # @!attribute [r] ready
14
+ # @return [String, nil] an HTTP path that must answer 2xx or 3xx; nil
15
+ # means a TCP connect is enough
16
+ # @!attribute [r] restart_on
17
+ # @return [Array<String>] `Dir.glob` patterns, relative to the checkout
18
+ # @!attribute [r] preview
19
+ # @return [Boolean] the one process the Preview tab shows
20
+ ProcessSpec = Data.define(:name, :start, :ready, :restart_on, :preview) do
21
+ def initialize(name:, start:, ready: nil, restart_on: [], preview: false) = super
22
+
23
+ # `PORT_<NAME>`: upcased, anything not a letter or digit as `_`, so
24
+ # `api-v2` is `PORT_API_V2` and a shell can expand it.
25
+ #
26
+ # @return [String]
27
+ def port_env = "PORT_#{name.upcase.gsub(/[^A-Z0-9]/, "_")}"
28
+
29
+ # @return [Boolean]
30
+ def preview? = preview == true
31
+
32
+ # @return [Hash] as `.openlanes.yml` spells it, defaults left out
33
+ def to_h
34
+ { "name" => name, "start" => start, "ready" => ready,
35
+ "restart_on" => (restart_on unless restart_on.empty?), "preview" => (true if preview?) }.compact
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_boolean"
4
+ require "json"
5
+
6
+ module OpenLanes
7
+ module Apps
8
+ # `openlanes app start` in the foreground (plan 012, B6): one plan's app
9
+ # started, kept, and stopped.
10
+ #
11
+ # Each tick it heartbeats the app's statuses into Redis for the Preview
12
+ # tab ({Listing}), restarts the processes whose `restart_on` files
13
+ # changed ({Watcher}), and applies what the tab's toolbar asked for
14
+ # (`Live::Keys#app_commands`). It ends on INT or TERM, which is what
15
+ # `openlanes app stop` sends, or on the toolbar's Stop, and either way
16
+ # stops every process and takes the app off the board.
17
+ #
18
+ # Redis is optional. Without a store the app runs just the same and the
19
+ # page simply never hears of it.
20
+ class Runner
21
+ # Seconds between ticks: the watcher's poll and the heartbeat.
22
+ INTERVAL = 1
23
+
24
+ # Commands kept on the stream; the runner reads only what arrives
25
+ # after it started, so this is only a bound on Redis.
26
+ COMMANDS_LENGTH = 100
27
+
28
+ # What the toolbar may ask for.
29
+ ACTIONS = %w[restart stop].freeze
30
+
31
+ # @param definition [OpenLanes::Apps::Definition]
32
+ # @param root [String] the checkout
33
+ # @param plan [String]
34
+ # @param branch [String]
35
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, nil]
36
+ # @param keys [OpenLanes::Live::Keys, nil]
37
+ # @param interval [Numeric]
38
+ # @param sleeper [#call] waits the given seconds
39
+ # @param stopping [Concurrent::AtomicBoolean] set to end the loop
40
+ # @param say [#call] given a line for the terminal
41
+ # @param supervisor [Hash] more arguments for {Supervisor.new}
42
+ def initialize(definition:, root:, plan:, branch:, store: nil, keys: nil, interval: INTERVAL,
43
+ sleeper: ->(seconds) { sleep seconds }, stopping: Concurrent::AtomicBoolean.new, say: ->(_) {}, supervisor: {})
44
+ @definition = definition
45
+ @plan = plan
46
+ @store = store
47
+ @keys = keys
48
+ @interval = interval
49
+ @sleeper = sleeper
50
+ @stopping = stopping
51
+ @say = say
52
+ @supervisor = Supervisor.new(definition:, root:, plan:, branch:, publisher: method(:publish), **supervisor)
53
+ @watcher = Watcher.new(root:, definition:)
54
+ end
55
+
56
+ # @return [OpenLanes::Apps::Supervisor]
57
+ attr_reader :supervisor
58
+
59
+ # @return [Concurrent::AtomicBoolean]
60
+ attr_reader :stopping
61
+
62
+ # Start the app and keep it until told to stop.
63
+ #
64
+ # @return [void]
65
+ # @raise [OpenLanes::Apps::Supervisor::StartFailed] after the failed
66
+ # statuses were published, so the tab can show why
67
+ def call
68
+ cursor = newest_command
69
+ @supervisor.start
70
+ until @stopping.true?
71
+ @watcher.changed.each { restart(it, why: "its files changed") }
72
+ cursor = commands(cursor)
73
+ heartbeat
74
+ @sleeper.call(@interval) unless @stopping.true?
75
+ end
76
+ rescue Supervisor::StartFailed
77
+ # The failed statuses stay on the board until their heartbeat runs
78
+ # out, so the tab can say why before it goes.
79
+ failed = true
80
+ raise
81
+ ensure
82
+ @supervisor.stop
83
+ live { Listing.remove(store: @store, keys: @keys, plan: @plan) } unless failed
84
+ nudge
85
+ end
86
+
87
+ # The supervisor's publisher: every transition goes out at once, with
88
+ # the whole app, since the page reads a plan's processes together.
89
+ #
90
+ # @param status [OpenLanes::Apps::Status]
91
+ # @return [void]
92
+ def publish(status)
93
+ @say.call("#{status.process} #{status.state} on port #{status.port}#{": #{status.reason}" if status.reason}")
94
+ heartbeat
95
+ nudge
96
+ end
97
+
98
+ private
99
+
100
+ # @return [void]
101
+ def heartbeat = live { Listing.write(store: @store, keys: @keys, plan: @plan, statuses: @supervisor.statuses) }
102
+
103
+ # Tells open pages the app changed. Only on a transition or a stop,
104
+ # never on the once-a-second {#heartbeat}, which changes nothing a
105
+ # page shows: an app that is merely up keeps every stream asleep.
106
+ #
107
+ # @return [void]
108
+ def nudge = live { Live::News.publish(store: @store, keys: @keys, kind: "app") }
109
+
110
+ # @return [Boolean]
111
+ def restart(name, why:)
112
+ @say.call("restarting #{name}: #{why}")
113
+ @supervisor.restart(name)
114
+ end
115
+
116
+ # Apply the toolbar's requests for this plan that arrived after +cursor+.
117
+ #
118
+ # @param cursor [String] a stream id
119
+ # @return [String] the last id read
120
+ def commands(cursor)
121
+ entries = live { @store.xread(@keys.app_commands, id: cursor) } || []
122
+ entries.each do |entry|
123
+ cursor = entry.id
124
+ command = Listing.parse(entry.fields["json"])
125
+ apply(command) if command && command["plan"] == @plan
126
+ end
127
+ cursor
128
+ end
129
+
130
+ # @param command [Hash] `plan`, `action`, `target`
131
+ # @return [void]
132
+ def apply(command)
133
+ case command["action"]
134
+ when "stop"
135
+ @say.call("stopping: asked from the page")
136
+ @stopping.make_true
137
+ when "restart"
138
+ targets(command["target"]).each { restart(it.name, why: "asked from the page") }
139
+ end
140
+ end
141
+
142
+ # `backend` is every process but the previewed one, in declared order
143
+ # (spec open question 9); a name is that process; nothing is all of them.
144
+ #
145
+ # @return [Array<OpenLanes::Apps::ProcessSpec>]
146
+ def targets(target)
147
+ case target
148
+ when nil, "" then @definition.processes
149
+ when "backend" then @definition.backend
150
+ else Array(@definition.process(target))
151
+ end
152
+ end
153
+
154
+ # Commands written before this runner started are not for it.
155
+ #
156
+ # @return [String]
157
+ def newest_command = live { @store.xrevrange(@keys.app_commands, count: 1).first&.id } || "0-0"
158
+
159
+ # Run a Redis call when there is a store, and survive one that went
160
+ # away: the app matters more than the board.
161
+ #
162
+ # @return [Object, nil]
163
+ def live
164
+ return nil unless @store
165
+
166
+ yield
167
+ rescue Live::Unavailable => e
168
+ @say.call("Redis: #{e.message}") unless @said_unavailable
169
+ @said_unavailable = true
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenLanes
6
+ module Apps
7
+ # Where one process of a running app stands, and the `<app>` object of
8
+ # the `apps` and `app` events the control room reads (plan 012, B1).
9
+ # The Preview tab draws only what arrives in this shape, so every field
10
+ # is said here once and {#to_h} is the wire.
11
+ #
12
+ # @!attribute [r] plan
13
+ # @return [String] the ordinal, `012.00`
14
+ # @!attribute [r] branch
15
+ # @return [String] the checkout's branch
16
+ # @!attribute [r] process
17
+ # @return [String] the process's name
18
+ # @!attribute [r] state
19
+ # @return [String] one of {STATES}
20
+ # @!attribute [r] port
21
+ # @return [Integer] the port it was given
22
+ # @!attribute [r] preview
23
+ # @return [Boolean] the process the tab shows
24
+ # @!attribute [r] url
25
+ # @return [String] `http://127.0.0.1:<port><ready path or />`
26
+ # @!attribute [r] framing
27
+ # @return [String, nil] `frame` or `window` once the preview process is
28
+ # ready; nil before that and on every other process
29
+ # @!attribute [r] reason
30
+ # @return [String, nil] why it failed, for a person
31
+ # @!attribute [r] at
32
+ # @return [Time] when it entered this state
33
+ Status = Data.define(:plan, :branch, :process, :state, :port, :preview, :url, :framing, :reason, :at) do
34
+ def initialize(plan:, branch:, process:, state:, port:, preview: false, url: nil, framing: nil, reason: nil, at: Time.now)
35
+ raise ArgumentError, "state #{state.inspect} is not one of #{Status::STATES.join(", ")}" unless Status::STATES.include?(state.to_s)
36
+
37
+ super(plan:, branch:, process:, state: state.to_s, port:, preview:, url: url || "http://#{Status::HOST}:#{port}/",
38
+ framing: framing&.to_s, reason:, at:)
39
+ end
40
+
41
+ # @return [Boolean]
42
+ def ready? = state == "ready"
43
+
44
+ # @return [Boolean]
45
+ def failed? = state == "failed"
46
+
47
+ # @return [Hash] the wire's `<app>`, string keys
48
+ def to_h
49
+ { "plan" => plan, "branch" => branch, "process" => process, "state" => state, "port" => port,
50
+ "preview" => preview, "url" => url, "framing" => framing, "reason" => reason, "at" => at.utc.iso8601 }
51
+ end
52
+ end
53
+
54
+ class Status
55
+ # Every state a process passes through.
56
+ STATES = %w[starting ready restarting stopped failed].freeze
57
+
58
+ # Where every app listens: nothing here is reachable from another machine.
59
+ HOST = "127.0.0.1"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # Runs one plan's app (plan 012, B4): every process in `app:` started in
6
+ # declared order, each on a port from {Ports}, the next only once the
7
+ # previous is ready, and all of them stopped together.
8
+ #
9
+ # Every process is told every port (`PORT_API`, `PORT_WEB`) before the
10
+ # first one starts, so a web process can find its API. Ports are
11
+ # reserved up front for the same reason.
12
+ #
13
+ # A process runs as `sh -c <start>` through {Execution::Child}, which
14
+ # leads a process group of its own: a dev server's watcher and workers
15
+ # are grandchildren, and only a group stop reaches them. `Child` also
16
+ # kills every group still running when this Ruby exits, which is what
17
+ # stops the app when its runner does.
18
+ #
19
+ # Each transition is handed to +publisher+ as a {Status}; the runner
20
+ # writes it to Redis for the Preview tab, and plan 014 reads the same
21
+ # {#statuses} for its report.
22
+ class Supervisor
23
+ # The app did not come up. The message is the reason, for a person;
24
+ # everything that had started has been stopped by the time it is raised.
25
+ # {#process} names the one that failed, so its {#log_tail} can be read
26
+ # after the stop: verification quotes it when it rejects (M1).
27
+ class StartFailed < Error
28
+ # @return [String, nil] the process that did not become ready
29
+ attr_reader :process
30
+
31
+ # @param message [String]
32
+ # @param process [String, nil]
33
+ def initialize(message = nil, process: nil)
34
+ super(message)
35
+ @process = process
36
+ end
37
+ end
38
+
39
+ # Seconds a process has to become ready (spec open question 10).
40
+ READY_TIMEOUT = 30
41
+
42
+ # Seconds between readiness probes.
43
+ POLL = 0.1
44
+
45
+ # Lines of a process's output kept, for the reason when it fails.
46
+ TAIL = 20
47
+
48
+ # What a process says when another took its port between the probe
49
+ # and the bind: the one failure worth a second attempt.
50
+ RACE = /EADDRINUSE|address already in use/i
51
+
52
+ # One process the supervisor started.
53
+ Running = Struct.new(:spec, :child, :waiter, :reader, :tail)
54
+
55
+ # @param definition [OpenLanes::Apps::Definition]
56
+ # @param root [String] the checkout every process runs in
57
+ # @param plan [String] the ordinal
58
+ # @param branch [String]
59
+ # @param registry [OpenLanes::Apps::Ports]
60
+ # @param publisher [#call, nil] given each {Status} as it happens
61
+ # @param probe [#ready?]
62
+ # @param framing [#check]
63
+ # @param origin [String] the control room's origin, for {Framing}
64
+ # @param ready_timeout [Numeric] seconds
65
+ # @param poll [Numeric] seconds between probes
66
+ # @param output [#call, nil] given a process's name and each line it prints
67
+ def initialize(definition:, root:, plan:, branch:, registry: Ports.new, publisher: nil, probe: Probe, framing: Framing,
68
+ origin: Framing::ORIGIN, ready_timeout: READY_TIMEOUT, poll: POLL, output: nil)
69
+ @definition = definition
70
+ @root = root
71
+ @plan = plan
72
+ @branch = branch
73
+ @registry = registry
74
+ @publisher = publisher
75
+ @probe = probe
76
+ @framing = framing
77
+ @origin = origin
78
+ @ready_timeout = ready_timeout
79
+ @poll = poll
80
+ @output = output
81
+ @running = {}
82
+ @ports = {}
83
+ @statuses = {}
84
+ @tails = {}
85
+ @mutex = Mutex.new
86
+ end
87
+
88
+ # @return [OpenLanes::Apps::Definition]
89
+ attr_reader :definition
90
+
91
+ # @return [String]
92
+ attr_reader :plan
93
+
94
+ # Start every process in order and wait for each to be ready.
95
+ #
96
+ # A start whose process lost its port to somebody else between the
97
+ # reservation and the bind is tried once more, from fresh ports.
98
+ #
99
+ # @return [self]
100
+ # @raise [OpenLanes::Apps::Supervisor::StartFailed]
101
+ def start
102
+ launch
103
+ self
104
+ rescue StartFailed => e
105
+ raise unless e.message.match?(RACE)
106
+
107
+ launch
108
+ self
109
+ end
110
+
111
+ # Stop every process, last started first, and give its port back.
112
+ #
113
+ # @return [void]
114
+ def stop
115
+ @running.keys.reverse_each { halt(it) }
116
+ @ports.each_value { @registry.release(it) }
117
+ @ports.clear
118
+ end
119
+
120
+ # Stop one process and start it again on the same port (B5): what a
121
+ # change to its `restart_on` files, or Restart backend, asks for.
122
+ # Its siblings are not touched.
123
+ #
124
+ # @param name [String]
125
+ # @return [Boolean] whether it came back ready
126
+ def restart(name)
127
+ spec = definition.process(name) or raise ArgumentError, "no process #{name.inspect} in this app"
128
+ return false unless @ports.key?(spec.name)
129
+
130
+ publish(spec, "restarting")
131
+ halt(spec.name, quietly: true)
132
+ bring_up(spec, nil)
133
+ end
134
+
135
+ # @return [Array<OpenLanes::Apps::Status>] the latest of each process, in start order
136
+ def statuses = @mutex.synchronize { definition.processes.filter_map { @statuses[it.name] } }
137
+
138
+ # @return [OpenLanes::Apps::Status, nil] the process the Preview tab shows
139
+ def preview = statuses.find(&:preview)
140
+
141
+ # @param name [String]
142
+ # @return [Integer, nil] the pid of the process's group leader
143
+ def pid(name) = @running[name.to_s]&.child&.pid
144
+
145
+ # @param name [String]
146
+ # @return [Integer, nil]
147
+ def port(name) = @ports[name.to_s]
148
+
149
+ # The last lines a process printed, kept after it is stopped. A failed
150
+ # start stops everything before it raises, and M1 wants the failing
151
+ # process's last 20 lines in the rejection, which {Status#reason}
152
+ # (five lines, for the Preview tab) is too short to carry.
153
+ #
154
+ # @param name [String]
155
+ # @param lines [Integer] at most {TAIL}
156
+ # @return [Array<String>] oldest first; empty for a process never started
157
+ def log_tail(name, lines = TAIL) = @mutex.synchronize { Array(@tails[name.to_s]).last(lines) }
158
+
159
+ private
160
+
161
+ # @return [void]
162
+ # @raise [StartFailed]
163
+ def launch
164
+ @ports = definition.processes.to_h { [it.name, @registry.reserve(root: @root, plan:, process: it.name)] }
165
+ @env = definition.processes.to_h { [it.port_env, @ports.fetch(it.name).to_s] }
166
+ definition.processes.each do |spec|
167
+ next if bring_up(spec, "starting")
168
+
169
+ reason = @statuses.fetch(spec.name).reason
170
+ stop
171
+ raise StartFailed.new(reason, process: spec.name)
172
+ end
173
+ end
174
+
175
+ # Spawn, then wait until it is ready, it exits, or time runs out.
176
+ #
177
+ # @param spec [OpenLanes::Apps::ProcessSpec]
178
+ # @param state [String, nil] what to say while it comes up; nil when
179
+ # that was said already, as a restart does before it stops the old one
180
+ # @return [Boolean] ready
181
+ def bring_up(spec, state)
182
+ publish(spec, state) if state
183
+ running = spawn(spec)
184
+ outcome = await(spec, running)
185
+ return publish(spec, "failed", reason: reason(spec, running, outcome)).then { false } unless outcome == :ready
186
+
187
+ publish(spec, "ready", framing: (@framing.check(url(spec), origin: @origin) if spec.preview?))
188
+ true
189
+ end
190
+
191
+ # @return [Running]
192
+ def spawn(spec)
193
+ child = Execution::Child.spawn(["sh", "-c", spec.start], chdir: @root, env: @env)
194
+ tail = []
195
+ @mutex.synchronize { @tails[spec.name] = tail }
196
+ reader = Thread.new { read(spec.name, child, tail) }
197
+ waiter = Thread.new { child.wait }
198
+ @running[spec.name] = Running.new(spec, child, waiter, reader, tail)
199
+ end
200
+
201
+ # @return [void]
202
+ def read(name, child, tail)
203
+ pending = +""
204
+ child.each_chunk do |text|
205
+ pending << text
206
+ while (line = pending.slice!(/\A[^\n]*\n/))
207
+ line = line.chomp
208
+ @mutex.synchronize do
209
+ tail.push(line)
210
+ tail.shift while tail.size > TAIL
211
+ end
212
+ @output&.call(name, line)
213
+ end
214
+ end
215
+ end
216
+
217
+ # @return [Symbol] :ready, :exited or :timeout
218
+ def await(spec, running)
219
+ deadline = UI.monotonic + @ready_timeout
220
+ loop do
221
+ return :exited unless running.waiter.alive?
222
+ return :ready if @probe.ready?(@ports.fetch(spec.name), path: spec.ready)
223
+ return :timeout if UI.monotonic >= deadline
224
+
225
+ sleep @poll
226
+ end
227
+ end
228
+
229
+ # @return [String]
230
+ def reason(spec, running, outcome)
231
+ port = @ports.fetch(spec.name)
232
+ said = if outcome == :exited
233
+ running.reader.join(1)
234
+ "#{spec.name} exited with status #{running.waiter.value&.exitstatus.inspect} before it was ready"
235
+ else
236
+ halt(spec.name, quietly: true)
237
+ what = spec.ready ? "2xx or 3xx on #{spec.ready}" : "there"
238
+ "#{spec.name} was given port #{port} and nothing answered #{what} within #{@ready_timeout} s"
239
+ end
240
+ lines = @mutex.synchronize { running.tail.last(5) }
241
+ lines.empty? ? said : "#{said}; it said:\n#{lines.join("\n")}"
242
+ end
243
+
244
+ # TERM the process's group, KILL what is left after the grace, and
245
+ # wait for the leader.
246
+ #
247
+ # @return [void]
248
+ def halt(name, quietly: false)
249
+ running = @running.delete(name) or return
250
+ running.child.terminate
251
+ running.waiter.join
252
+ running.reader.join(Execution::Child::DRAIN_GRACE)
253
+ # A process that failed stays failed: its reason is what a person needs.
254
+ publish(running.spec, "stopped") unless quietly || @statuses[name]&.failed?
255
+ end
256
+
257
+ # @return [String] what the tab opens: the app's root, not its health check
258
+ def url(spec) = "http://#{Status::HOST}:#{@ports.fetch(spec.name)}/"
259
+
260
+ # @return [OpenLanes::Apps::Status]
261
+ def publish(spec, state, framing: nil, reason: nil)
262
+ status = Status.new(plan:,
263
+ branch: @branch,
264
+ process: spec.name,
265
+ state:,
266
+ port: @ports.fetch(spec.name),
267
+ preview: spec.preview?,
268
+ url: url(spec),
269
+ framing:,
270
+ reason:)
271
+ @mutex.synchronize { @statuses[spec.name] = status }
272
+ @publisher&.call(status)
273
+ status
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # Which processes' `restart_on` files changed since it last looked
6
+ # (plan 012, decision 5).
7
+ #
8
+ # Polling, not a file-event gem: one more dependency for a check that
9
+ # runs once a second over a handful of globs is not worth its native
10
+ # extension, and a poll is something a spec can drive tick by tick.
11
+ #
12
+ # A file counts as changed when it appears, disappears, or its `mtime`
13
+ # or size moves. Globs are Ruby's `Dir.glob`, relative to the checkout,
14
+ # with `**/` recursive and `{a,b}` alternatives; a dotfile matches only
15
+ # a pattern that names the dot.
16
+ class Watcher
17
+ # @param root [String] the checkout the globs are relative to
18
+ # @param definition [OpenLanes::Apps::Definition]
19
+ def initialize(root:, definition:)
20
+ @root = root
21
+ @watched = definition.processes.reject { it.restart_on.empty? }
22
+ @seen = snapshot
23
+ end
24
+
25
+ # @return [Array<String>] names of processes with a changed file, in
26
+ # declared order; empty on a quiet tick
27
+ def changed
28
+ now = snapshot
29
+ names = @watched.map(&:name).reject { now[it] == @seen[it] }
30
+ @seen = now
31
+ names
32
+ end
33
+
34
+ private
35
+
36
+ # @return [Hash{String => Hash{String => Array}}] process name to path to mtime and size
37
+ def snapshot
38
+ @watched.to_h do |spec|
39
+ files = Dir.glob(spec.restart_on, File::FNM_EXTGLOB, base: @root).sort.to_h do |path|
40
+ stat = File.stat(File.join(@root, path))
41
+ [path, [stat.mtime.to_r, stat.size]]
42
+ rescue SystemCallError
43
+ [path, nil]
44
+ end
45
+ [spec.name, files]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end