openlanes 3.0.1 → 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 (319) 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 +4 -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 +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  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 +7 -0
  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 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  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 +84 -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 +1175 -117
  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 +6 -6
  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 +12 -120
  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 +223 -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 +17 -13
  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 +60 -11
  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 +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "socket"
4
5
  require "tmpdir"
5
6
 
6
7
  module OpenLanes
@@ -26,19 +27,35 @@ module OpenLanes
26
27
  # Seconds {#stop} gives the server after TERM before KILL.
27
28
  STOP_WITHIN = 5.0
28
29
 
30
+ # Ports {#start} tries, from the one asked for, before giving up.
31
+ PORTS_TRIED = 10
32
+
29
33
  # @param root [String] the checkout the run works in
30
34
  # @param plans_dir [String]
31
35
  # @param port [Integer]
32
36
  # @param redis_url [String]
33
- # @param log [String] where the server's output goes
37
+ # @param log [String] where the server's raw STDOUT and STDERR go: a
38
+ # crash before its logger is up, and Puma's own complaints
39
+ # @param logging [OpenLanes::Log::Settings, nil] where the server logs
40
+ # its requests; the engine's own, so both processes write one file
34
41
  # @param ready_within [Float]
35
42
  # @param probe [Proc, nil] answers whether the server is up; `/healthz` by default
36
- def initialize(root:, plans_dir:, port:, redis_url:, log: default_log(root), ready_within: READY_WITHIN, probe: nil)
43
+ # @param listen [OpenLanes::Web::Listen] the addresses the server listens on
44
+ # @param credentials [OpenLanes::Web::AdminAuth::Credentials, nil] its
45
+ # login for requests from off this machine, handed over in {#env}
46
+ # @param spawner [#spawn] what starts the server process
47
+ def initialize(root:, plans_dir:, port:, redis_url:, log: default_log(root), logging: Log.settings,
48
+ ready_within: READY_WITHIN, probe: nil, listen: Listen.parse(nil), credentials: nil,
49
+ spawner: Spawner::System)
37
50
  @root = root
51
+ @spawner = spawner
38
52
  @plans_dir = plans_dir
39
53
  @port = Integer(port)
40
54
  @redis_url = redis_url
55
+ @listen = listen
56
+ @credentials = credentials
41
57
  @log = log
58
+ @logging = logging
42
59
  @ready_within = ready_within
43
60
  @probe = probe || method(:healthy?)
44
61
  end
@@ -49,22 +66,46 @@ module OpenLanes
49
66
  # @return [Integer, nil]
50
67
  attr_reader :pid
51
68
 
69
+ # @return [Integer] the port asked for, or the free one {#start} moved to
70
+ attr_reader :port
71
+
72
+ # Loopback, where the login is never asked. Every server it launches
73
+ # answers there: on loopback by default, and on a wildcard under -I.
74
+ #
52
75
  # @return [String]
53
76
  def url = "http://#{HOST}:#{@port}"
54
77
 
55
- # @return [Array<String>] what is spawned; the Redis URL is in {#env}, never here
78
+ # @return [Array<String>] what is spawned; the Redis URL and the
79
+ # credentials are in {#env}, never here
56
80
  def argv
57
- [EXE, "web", "--port", @port.to_s, "--no-open", "--dir", @plans_dir]
81
+ ips = @listen.public? ? ["--ips", @listen.ips.join(",")] : []
82
+ [EXE, "web", "--port", @port.to_s, "--no-open", "--dir", @plans_dir, *ips]
58
83
  end
59
84
 
85
+ # The Redis URL, the login, and where to log. A server left to find its
86
+ # own log would resolve `log/development.log` from its own root and
87
+ # defaults, and a `--log` typed on `run` would move the engine's lines
88
+ # but not the server's.
89
+ #
60
90
  # @return [Hash{String => String}] added to the server's environment
61
- def env = { REDIS_URL_ENV => @redis_url }
91
+ def env
92
+ login = @credentials ? { AdminAuth::ENV_NAME => "#{@credentials.user}:#{@credentials.password}" } : {}
93
+ { REDIS_URL_ENV => @redis_url, **login, **(@logging ? @logging.env : {}) }
94
+ end
62
95
 
63
- # Spawn the server and wait for it to answer.
96
+ # Spawn the server on the first free port from the one asked for, and
97
+ # wait for it to answer. A taken port is skipped before spawning, never
98
+ # probed: a leftover `openlanes web` for another project answers
99
+ # `/healthz` there, and the run once declared that server its own and
100
+ # opened the browser on someone else's board.
64
101
  #
65
102
  # @return [self]
66
103
  def start
67
- @pid = Process.spawn(env, *argv, chdir: @root, pgroup: true, in: File::NULL, %i[out err] => [@log, "a"])
104
+ free = free_port
105
+ return taken unless free
106
+
107
+ @port = free
108
+ @pid = @spawner.spawn(env, *argv, chdir: @root, pgroup: true, in: File::NULL, %i[out err] => [@log, "a"])
68
109
  @ready = wait_until_ready
69
110
  self
70
111
  end
@@ -88,6 +129,27 @@ module OpenLanes
88
129
 
89
130
  private
90
131
 
132
+ # @return [Integer, nil]
133
+ def free_port = (@port...(@port + PORTS_TRIED)).find { free?(it) }
134
+
135
+ # @param port [Integer]
136
+ # @return [Boolean] whether nothing listens there
137
+ def free?(port)
138
+ TCPServer.new(HOST, port).close
139
+ true
140
+ rescue SystemCallError
141
+ false
142
+ end
143
+
144
+ # Every port tried is taken: said in the log {#start}'s caller points at.
145
+ #
146
+ # @return [self]
147
+ def taken
148
+ File.write(@log, "Ports #{@port}-#{@port + PORTS_TRIED - 1} are all taken. Pass --port to use another.\n", mode: "a")
149
+ @ready = false
150
+ self
151
+ end
152
+
91
153
  # @return [Boolean]
92
154
  def wait_until_ready
93
155
  deadline = monotonic + @ready_within
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "socket"
5
+
6
+ module OpenLanes
7
+ module Web
8
+ # The addresses one server listens on, and what follows from them: the
9
+ # Host headers it answers, the URLs it prints, the one a browser opens.
10
+ #
11
+ # Loopback, {HOST}, unless told otherwise (W3). The page can stop agents
12
+ # and answer their questions, and has no login, so listening beyond this
13
+ # machine is something a person asks for by name, and is warned about.
14
+ #
15
+ # listen = Listen.parse("0.0.0.0")
16
+ # listen.public? # => true
17
+ # listen.urls(11_000) # => ["http://192.168.1.20:11000", ...]
18
+ # listen.browse_url(11_000) # => "http://127.0.0.1:11000"
19
+ Listen = Data.define(:ips) do
20
+ # @return [Boolean] whether anything off this machine can reach it
21
+ def public? = ips.any? { !IPAddr.new(it).loopback? }
22
+
23
+ # @return [Array<String>] the addresses past loopback, as given
24
+ def public_ips = ips.reject { IPAddr.new(it).loopback? }
25
+
26
+ # Where this machine's browser opens the page: loopback, which every
27
+ # wildcard listener also answers on, else the first address given.
28
+ #
29
+ # @param port [Integer]
30
+ # @return [String]
31
+ def browse_url(port)
32
+ ip = ips.find { Listen.wildcard?(it) || IPAddr.new(it).loopback? } || ips.first
33
+ Listen.url(if Listen.wildcard?(ip)
34
+ IPAddr.new(ip).ipv6? ? "::1" : HOST
35
+ else
36
+ ip
37
+ end,
38
+ port)
39
+ end
40
+
41
+ # Every URL the page answers on, a wildcard spelled out as this
42
+ # machine's own addresses, since `http://0.0.0.0` opens nothing.
43
+ #
44
+ # @param port [Integer]
45
+ # @param addresses [Array<String>] this machine's interface addresses
46
+ # @return [Array<String>]
47
+ def urls(port, addresses: Listen.addresses) = reached(addresses).map { Listen.url(it, port) }
48
+
49
+ # The Host headers answered beside loopback, which {App} always
50
+ # permits: each address listened on, a wildcard's interfaces, and this
51
+ # machine's name. Anything else is refused, so a page on another site
52
+ # still cannot reach the server through DNS rebinding.
53
+ #
54
+ # @param addresses [Array<String>]
55
+ # @param hostname [String]
56
+ # @return [Array<String, IPAddr>]
57
+ def permitted_hosts(addresses: Listen.addresses, hostname: Socket.gethostname)
58
+ return [] unless public?
59
+
60
+ names = [hostname, hostname.split(".").first].uniq
61
+ names + reached(addresses).map { IPAddr.new(it) }
62
+ end
63
+
64
+ private
65
+
66
+ # @param addresses [Array<String>]
67
+ # @return [Array<String>] the addresses listened on, each wildcard
68
+ # replaced by the interface addresses it answers on
69
+ def reached(addresses) = ips.flat_map { Listen.wildcard?(it) ? Listen.covered(it, addresses) : [it] }.uniq
70
+ end
71
+
72
+ class Listen
73
+ # Raised for an address that is not one.
74
+ class Invalid < OpenLanes::Error; end
75
+
76
+ # What `-I` stands for.
77
+ EVERYWHERE = "0.0.0.0"
78
+
79
+ class << self
80
+ # An address a wildcard given beside it already covers is dropped:
81
+ # Linux refuses to bind `127.0.0.1` on a port `0.0.0.0` holds, even
82
+ # with SO_REUSEADDR, and the EADDRINUSE read as "port taken".
83
+ #
84
+ # @param text [String, nil] comma-separated addresses; blank for {HOST}
85
+ # @return [Listen]
86
+ # @raise [Invalid] naming the address that is not one
87
+ def parse(text)
88
+ ips = text.to_s.split(",").map(&:strip).reject(&:empty?)
89
+ ips = [HOST] if ips.empty?
90
+ ips.each do |ip|
91
+ IPAddr.new(ip)
92
+ rescue IPAddr::Error
93
+ raise Invalid, "#{ip.inspect} is not an IP address"
94
+ end
95
+ ips = ips.uniq
96
+ wildcards = ips.select { wildcard?(it) }
97
+ new(ips: ips.reject { |ip| wildcards.any? { it != ip && covered(it, [ip]).any? } })
98
+ end
99
+
100
+ # @param ip [String]
101
+ # @return [Boolean] whether it means every interface
102
+ def wildcard?(ip) = IPAddr.new(ip).to_i.zero?
103
+
104
+ # @param ip [String]
105
+ # @param port [Integer]
106
+ # @return [String]
107
+ def url(ip, port) = "http://#{IPAddr.new(ip).ipv6? ? "[#{ip}]" : ip}:#{port}"
108
+
109
+ # The interface addresses a wildcard answers on. `0.0.0.0` is IPv4
110
+ # only; `::` is both, because Ruby's TCPServer leaves IPV6_V6ONLY
111
+ # off, so Linux and macOS hand it IPv4 clients too. Treating `::` as
112
+ # IPv6 alone refused an IPv4 LAN client's Host header with a 403.
113
+ #
114
+ # @param wildcard [String] `0.0.0.0` or `::`
115
+ # @param addresses [Array<String>]
116
+ # @return [Array<String>]
117
+ def covered(wildcard, addresses)
118
+ return addresses if IPAddr.new(wildcard).ipv6?
119
+
120
+ addresses.select { IPAddr.new(it).ipv4? }
121
+ end
122
+
123
+ # This machine's interface addresses, loopback first, link-local
124
+ # IPv6 left out: it needs a zone to be dialed, which no URL carries.
125
+ #
126
+ # @return [Array<String>]
127
+ def addresses
128
+ Socket.ip_address_list.reject(&:ipv6_linklocal?).map(&:ip_address).sort_by { IPAddr.new(it).loopback? ? 0 : 1 }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module OpenLanes
8
+ module Web
9
+ # The plan files the page may read, and the two it may write (plan 017).
10
+ #
11
+ # A name is looked up in {READABLE} and never joined onto a path as typed,
12
+ # which is what makes `../` meaningless here rather than merely refused.
13
+ # This knows nothing of HTTP or of cars: {App} maps each refusal to a
14
+ # status and checks the board before it calls {#write}.
15
+ class PlanFiles
16
+ # What the drawer lists and the preview reads, in the order listed.
17
+ READABLE = %w[spec.md plan.md contract.md blocked.md research.md design.md].freeze
18
+
19
+ # What the popup may save. `blocked.md` is appended by the `answer`
20
+ # command, and the build documents belong to the agents that own them.
21
+ EDITABLE = %w[spec.md plan.md].freeze
22
+
23
+ # Not a readable name, not a plan, or not a file in that plan.
24
+ class NotFound < OpenLanes::Error; end
25
+
26
+ # A readable name the page may not write.
27
+ class ReadOnly < OpenLanes::Error; end
28
+
29
+ # The file changed since the page read it.
30
+ class Stale < OpenLanes::Error; end
31
+
32
+ # The body's frontmatter block is not the one on disk. Frontmatter
33
+ # routes agents (`lane`, `frontend`, `depth`), so a crafted save must
34
+ # not reach it even when the page would never send one.
35
+ class FrontmatterChanged < OpenLanes::Error; end
36
+
37
+ # One file as read.
38
+ #
39
+ # @!attribute body [String] the raw bytes
40
+ # @!attribute etag [String] a strong, quoted ETag of those bytes
41
+ Document = Data.define(:body, :etag)
42
+
43
+ class << self
44
+ # @param body [String]
45
+ # @return [String] `"<sha256 hex>"`, quotes included, as HTTP sends it
46
+ def etag(body) = %("#{Digest::SHA256.hexdigest(body.b)}")
47
+ end
48
+
49
+ # @param plans_dir [String] the `.plans` directory
50
+ def initialize(plans_dir:)
51
+ @plans_dir = plans_dir
52
+ end
53
+
54
+ # @param ordinal [String] a plan number, as typed in the URL
55
+ # @return [Array<String>] readable names that exist, in {READABLE} order
56
+ # @raise [NotFound] when the ordinal is not a plan
57
+ def list(ordinal)
58
+ _, folder = folder_for(ordinal)
59
+ READABLE.select { File.file?(File.join(folder, it)) }
60
+ end
61
+
62
+ # @param ordinal [String]
63
+ # @param name [String]
64
+ # @return [Document]
65
+ # @raise [NotFound]
66
+ def read(ordinal, name)
67
+ body = File.binread(path_for(ordinal, name)).force_encoding(Encoding::UTF_8)
68
+ Document.new(body:, etag: self.class.etag(body))
69
+ end
70
+
71
+ # Replace a whole file, guarded three ways: an editable name, the
72
+ # ETag the page read, and the frontmatter block unchanged.
73
+ #
74
+ # @param ordinal [String]
75
+ # @param name [String]
76
+ # @param body [String] the whole new file
77
+ # @param if_match [String] the ETag the page read
78
+ # @return [String] the new ETag
79
+ # @raise [NotFound, ReadOnly, Stale, FrontmatterChanged]
80
+ def write(ordinal, name, body:, if_match:)
81
+ path = path_for(ordinal, name)
82
+ raise ReadOnly, "#{name} is read only" unless EDITABLE.include?(name)
83
+
84
+ current = read(ordinal, name)
85
+ raise Stale, "#{name} changed since it was read; reload it" unless current.etag == if_match.to_s.strip
86
+ raise FrontmatterChanged, "the frontmatter of #{name} is locked" unless frontmatter(current.body) == frontmatter(body)
87
+
88
+ replace(path, body)
89
+ self.class.etag(body)
90
+ end
91
+
92
+ private
93
+
94
+ # The raw frontmatter block, or nil when the file has none. Compared as
95
+ # bytes, not as parsed YAML, so a reordered key or a changed comment is
96
+ # a change too.
97
+ #
98
+ # @param content [String]
99
+ # @return [String, nil]
100
+ def frontmatter(content) = Frontmatter::PATTERN.match(content.b)&.then { it[1] }
101
+
102
+ # @param path [String]
103
+ # @param body [String]
104
+ # @return [void]
105
+ def replace(path, body)
106
+ temp = File.join(File.dirname(path), ".#{File.basename(path)}.#{SecureRandom.hex(6)}.tmp")
107
+ File.binwrite(temp, body)
108
+ File.rename(temp, path)
109
+ ensure
110
+ FileUtils.rm_f(temp) if temp && File.exist?(temp)
111
+ end
112
+
113
+ # @param ordinal [String]
114
+ # @param name [String]
115
+ # @return [String]
116
+ # @raise [NotFound]
117
+ def path_for(ordinal, name)
118
+ known = READABLE.find { it == name } or raise NotFound, "not a plan file: #{name}"
119
+ number, folder = folder_for(ordinal)
120
+ path = File.join(folder, known)
121
+ raise NotFound, "#{number} has no #{known}" unless File.file?(path)
122
+
123
+ path
124
+ end
125
+
126
+ # @param ordinal [String]
127
+ # @return [Array(Plans::Ordinal, String)] the plan number and its folder
128
+ # @raise [NotFound]
129
+ def folder_for(ordinal)
130
+ number = Plans::Ordinal.parse(ordinal.to_s) or raise NotFound, "not a plan number"
131
+ subject = Plans.tree(@plans_dir).find(number) or raise NotFound, "there is no plan #{number}"
132
+ [number, subject.feature.path]
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/body_proxy"
4
+ require "rack/utils"
5
+
6
+ module OpenLanes
7
+ module Web
8
+ # One log entry per request, through {Log}, wrapped around the whole app
9
+ # so a request Rack::Protection or host authorization turns away is
10
+ # logged too.
11
+ #
12
+ # Not Sinatra's `logging` or Rack::CommonLogger: both write Apache-style
13
+ # text to `rack.errors`, which is the launcher's raw output file rather
14
+ # than the log the engine writes, and neither knows a page from a request.
15
+ #
16
+ # The event stream is the exception to "one entry per request". It stays
17
+ # open as long as the page does, so it is logged twice: when it opens, and
18
+ # when Puma closes its body, with how long it was open. Logged once, at
19
+ # the end, a page left open all day would not appear until evening.
20
+ #
21
+ # Static assets and `/healthz` are logged at debug: the launcher polls
22
+ # `/healthz` ten times a second while it waits, and a page load fetches a
23
+ # dozen assets, so at info they would bury the API calls.
24
+ class RequestLog
25
+ # A response of this type is a stream that outlives its request.
26
+ STREAM = "text/event-stream"
27
+
28
+ # Paths logged at info. Everything else is the page's own files.
29
+ API = %r{\A/api(/|\z)}
30
+
31
+ # @param app [#call]
32
+ # @param logger [#info, #debug, #error, nil] {Log.logger} when nil, read
33
+ # per request so a {Log.setup} after this was built still applies
34
+ # @param clock [#call] monotonic seconds
35
+ def initialize(app, logger: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
36
+ @app = app
37
+ @logger = logger
38
+ @clock = clock
39
+ end
40
+
41
+ # @param env [Hash] the Rack environment
42
+ # @return [Array] status, headers and body, the body wrapped for a stream
43
+ def call(env)
44
+ started = @clock.call
45
+ status, headers, body = @app.call(env)
46
+ return [status, headers, opened(env, status, started, body)] if stream?(headers)
47
+
48
+ write(level(env), env, "#{line(env)} #{status}", status:, started:)
49
+ [status, headers, body]
50
+ rescue StandardError => e
51
+ write(:error, env, "#{line(env)} raised #{e.class}", status: 500, started:, exception: e)
52
+ raise
53
+ end
54
+
55
+ private
56
+
57
+ # Logs the stream opening, and hands back a body that logs its close.
58
+ #
59
+ # @return [Rack::BodyProxy]
60
+ def opened(env, status, started, body)
61
+ write(:info, env, "#{line(env)} stream opened", status:)
62
+ Rack::BodyProxy.new(body) { write(:info, env, "#{line(env)} stream closed", status:, started:) }
63
+ end
64
+
65
+ # @param headers [Hash]
66
+ # @return [Boolean]
67
+ def stream?(headers)
68
+ type = headers["content-type"] || headers["Content-Type"]
69
+ type.to_s.start_with?(STREAM)
70
+ end
71
+
72
+ # @param env [Hash]
73
+ # @return [Symbol]
74
+ def level(env) = env["PATH_INFO"].to_s.match?(API) ? :info : :debug
75
+
76
+ # @param env [Hash]
77
+ # @return [String] e.g. "GET /api/board"
78
+ def line(env) = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
79
+
80
+ # The page's own id, from `?page=`, so the requests one browser tab
81
+ # made can be picked out of several tabs' worth.
82
+ #
83
+ # @param env [Hash]
84
+ # @return [String, nil]
85
+ def page(env)
86
+ id = Rack::Utils.parse_query(env["QUERY_STRING"].to_s)["page"]
87
+ id.is_a?(String) && !id.empty? ? id : nil
88
+ end
89
+
90
+ # @param level [Symbol]
91
+ # @param env [Hash]
92
+ # @param message [String]
93
+ # @param status [Integer]
94
+ # @param started [Float, nil] when given, the entry carries the duration since
95
+ # @param exception [Exception, nil]
96
+ # @return [void]
97
+ def write(level, env, message, status:, started: nil, exception: nil)
98
+ payload = { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_i, page: page(env) }.compact
99
+ entry = { message:, payload:, exception: }.compact
100
+ entry[:duration] = ((@clock.call - started) * 1000).round(1) if started
101
+ (@logger || Log.logger).public_send(level, **entry)
102
+ rescue StandardError
103
+ # A log that cannot be written is no reason to fail the request.
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
@@ -7,7 +7,8 @@ require "puma/server"
7
7
  module OpenLanes
8
8
  module Web
9
9
  # The app on Puma, multi-threaded because every open page holds one
10
- # thread for its event stream, and bound to {HOST} only.
10
+ # thread for its event stream, and bound to {HOST} unless a {Listen}
11
+ # names other addresses.
11
12
  #
12
13
  # server = Web.server(app:, context:, port: 11000).start
13
14
  # server.url # => "http://127.0.0.1:11000"
@@ -18,29 +19,31 @@ module OpenLanes
18
19
  MAX_THREADS = 64
19
20
 
20
21
  # Seconds Puma waits for open streams after {#stop}. They notice the
21
- # context's `stopping` within a tick; this is the backstop.
22
+ # context's `stopping` on the `stop` nudge {#stop} publishes; this is
23
+ # the backstop, for a Redis that went away and took the nudge with it.
22
24
  SHUTDOWN_AFTER = 2
23
25
 
24
26
  # @param app [#call]
25
27
  # @param context [OpenLanes::Web::Context]
26
28
  # @param port [Integer] 0 for any free port
27
- # @param host [String]
29
+ # @param listen [OpenLanes::Web::Listen] the addresses to listen on
28
30
  # @param log [IO] where Puma writes its errors
29
- def initialize(app:, context:, port: DEFAULT_PORT, host: HOST, log: $stderr)
31
+ def initialize(app:, context:, port: DEFAULT_PORT, listen: Listen.parse(nil), log: $stderr)
30
32
  @app = app
31
33
  @context = context
32
34
  @port = Integer(port)
33
- @host = host
35
+ @listen = listen
34
36
  @log = log
35
37
  end
36
38
 
37
- # @return [String]
38
- attr_reader :host
39
+ # @return [OpenLanes::Web::Listen]
40
+ attr_reader :listen
39
41
 
40
42
  # Listen, and serve on Puma's own threads.
41
43
  #
42
44
  # @return [self]
43
45
  # @raise [Errno::EADDRINUSE] when the port is taken
46
+ # @raise [Errno::EADDRNOTAVAIL] when an address is not this machine's
44
47
  def start
45
48
  @puma = Puma::Server.new(@app,
46
49
  nil,
@@ -48,17 +51,29 @@ module OpenLanes
48
51
  max_threads: MAX_THREADS,
49
52
  force_shutdown_after: SHUTDOWN_AFTER,
50
53
  log_writer: Puma::LogWriter.new(File.open(File::NULL, "w"), @log))
51
- @puma.add_tcp_listener(@host, @port)
54
+ # Port 0 is resolved by the first listener, and the rest share it, so
55
+ # every address answers on the one port {#url} reports.
56
+ @listen.ips.each { @puma.add_tcp_listener(it, @puma.connected_ports.first || @port) }
52
57
  @thread = @puma.run
53
58
  @context.chat&.start
54
59
  self
60
+ rescue SystemCallError
61
+ # A later address failing leaves the earlier ones bound; Puma is not
62
+ # running yet, so close its sockets here rather than leak them to
63
+ # whoever retries in this process.
64
+ @puma&.binder&.close
65
+ @puma = nil
66
+ raise
55
67
  end
56
68
 
57
69
  # @return [Integer] the port listened on, the real one when 0 was asked
58
70
  def port = @puma&.connected_ports&.first || @port
59
71
 
60
- # @return [String]
61
- def url = "http://#{@host}:#{port}"
72
+ # @return [String] where this machine's browser opens the page
73
+ def url = @listen.browse_url(port)
74
+
75
+ # @return [Array<String>] every address the page answers on
76
+ def urls = @listen.urls(port)
62
77
 
63
78
  # Block until the server stops.
64
79
  #
@@ -70,6 +85,10 @@ module OpenLanes
70
85
  # @return [void]
71
86
  def stop
72
87
  @context.stopping.make_true
88
+ # Wakes every stream blocked waiting for news, here and on any other
89
+ # server following this project, so ours see `stopping` now rather
90
+ # than at their next ping. The others look, find nothing, and sleep.
91
+ Live::News.publish(store: @context.store, keys: @context.keys, kind: "stop")
73
92
  @context.chat&.stop
74
93
  @puma&.stop(true)
75
94
  @context.stores.close
data/lib/openlanes/web.rb CHANGED
@@ -25,8 +25,8 @@ module OpenLanes
25
25
  # Where the server listens unless told otherwise (contract.md).
26
26
  DEFAULT_PORT = 11_000
27
27
 
28
- # Loopback only: the page can stop agents, and nothing off this machine
29
- # may reach it (W3).
28
+ # Loopback by default: the page can stop agents and has no login, so
29
+ # nothing off this machine reaches it unless `--ips` or `-I` says so (W3).
30
30
  HOST = "127.0.0.1"
31
31
 
32
32
  module_function
@@ -54,9 +54,11 @@ module OpenLanes
54
54
  Live::Chat.new(stores:, keys:, machine:, label: node&.label || machine, **)
55
55
  end
56
56
 
57
+ # The routes, with every request logged on the way out (see {RequestLog}).
58
+ #
57
59
  # @param context [OpenLanes::Web::Context]
58
- # @return [Class] a Rack application
59
- def app(context) = App.serving(context)
60
+ # @return [#call] a Rack application
61
+ def app(context) = RequestLog.new(App.serving(context))
60
62
 
61
63
  # @param app [#call]
62
64
  # @param context [OpenLanes::Web::Context]