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
@@ -6,32 +6,77 @@ module OpenLanes
6
6
  module Web
7
7
  # One page's event stream (contract.md, `GET /api/events`): a `board`
8
8
  # event whenever any node's snapshot `seq` changes, a `reply` event for
9
- # each reply the engine writes, and a `: ping` comment so proxies and the
9
+ # each reply the engine writes, a `copilot` event for each turn of the
10
+ # conversation with the copilot (W17), and a `: ping` comment so proxies and the
10
11
  # browser keep the connection open.
11
12
  #
12
- # Polled rather than blocked on: a blocking read would hold the thread's
13
- # connection for the length of the wait, and the board has to be looked
14
- # at every tick anyway.
13
+ # Pushed rather than polled: the stream holds a subscription of its own
14
+ # to {Live::Keys#news} and sleeps on it until a writer nudges ({Live::News}),
15
+ # then reads Redis exactly as it did when it looked every quarter second.
16
+ # The nudge carries no data, so the cursors below are untouched by it.
17
+ # It subscribes before its first read, so a write that lands between
18
+ # the two is read twice rather than missed. With no nudge it still looks
19
+ # once per ping, which is how it notices what nobody announces: a node
20
+ # whose snapshot expired, an app whose runner was killed. One
21
+ # subscription per stream, closed when the stream ends.
15
22
  #
16
23
  # A reply carries its stream id as the event's `id:`, so a browser that
17
24
  # reconnects sends `Last-Event-ID` and gets what it missed. The stream
18
25
  # opens with a `cursor` event carrying the id it starts from, so a
19
26
  # browser that drops before the first reply still has one to send;
20
27
  # without it, replies written during that gap were skipped.
28
+ #
29
+ # A `chat` event (plan 006) carries no `id:` either, and like the
30
+ # copilot's the stream opens with a `chat_cursor` when it carries a
31
+ # page's chat, so the page reads `GET /api/chat` only once relaying has
32
+ # begun. Nor is there a cursor id to carry: the chat between
33
+ # machines keeps no history (D2), so there is nothing to catch up on.
34
+ # The stream is how {Live::Chat} knows the page is still open; when it
35
+ # closes, the page has {Live::Chat::GRACE} to reconnect before it counts
36
+ # as having left (D6).
37
+ #
38
+ # A copilot turn carries no `id:`, so it never moves that cursor. The
39
+ # conversation has a boundary of its own instead: the stream samples the
40
+ # newest turn, says so in a `copilot_cursor` event, and from then on
41
+ # sends every turn after it. The page reads `GET /api/copilot` when that
42
+ # event arrives, never before it, so the read covers everything up to
43
+ # the boundary and the stream everything after. A turn written between a
44
+ # read the page started on its own and the stream opening fell in the
45
+ # gap between the two and was never shown.
46
+ #
47
+ # The apps running on this machine (plan 012) open the stream as one
48
+ # `apps` event, sent only when there is one, so a project with no app
49
+ # gets the stream it always had; the page starts each stream with no
50
+ # apps. After that each process whose status changed comes as an `app`
51
+ # event. A process whose plan's entry vanished (its runner
52
+ # stopped or died) is sent once more as `stopped`, so the page never
53
+ # keeps a tab for an app that is gone.
21
54
  class Events
55
+ # How often a stream without a subscription looks again: the polling
56
+ # interval every stream used before nudges.
57
+ POLL = 0.25
58
+
59
+ # Block on the subscription for the next nudge, or, with none, poll.
60
+ WAIT = ->(news, seconds) { news ? news.next_message(seconds) : sleep([seconds, POLL].min) }
61
+
22
62
  # @param context [OpenLanes::Web::Context]
23
63
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
24
64
  # @param last_id [String, nil] the `Last-Event-ID` the browser sent
65
+ # @param page [String, nil] the page's own id, `?page=` on the stream, for its chat
25
66
  # @param clock [Proc] seconds, monotonic
26
- # @param sleeper [Proc] waits the given seconds between ticks
27
- def initialize(context:, store:, last_id: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
28
- sleeper: ->(seconds) { sleep seconds })
67
+ # @param waiter [Proc] given the subscription and at most how many
68
+ # seconds to wait, returns once a nudge arrives or the time is up
69
+ def initialize(context:, store:, last_id: nil, page: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
70
+ waiter: WAIT)
29
71
  @context = context
30
72
  @store = store
31
73
  @keys = context.keys
74
+ @copilot = Live.copilot(store:, keys: @keys)
32
75
  @last_id = last_id.to_s.match?(/\A\d+-\d+\z/) ? last_id : nil
76
+ @chat = context.chat
77
+ @page = page.to_s.match?(Live::Chat::PAGE) ? page : nil
33
78
  @clock = clock
34
- @sleeper = sleeper
79
+ @waiter = waiter
35
80
  end
36
81
 
37
82
  # Write events to +out+ until the browser leaves, the server stops, or
@@ -40,21 +85,35 @@ module OpenLanes
40
85
  # @param out [#<<, #closed?]
41
86
  # @return [void]
42
87
  def run(out)
88
+ @chat&.attach(@page) if @page
89
+ news = subscribe
43
90
  started = @clock.call
44
91
  pinged = started
45
92
  cursor = @last_id || newest_reply
46
93
  out << self.class.frame("cursor", { id: cursor }, id: cursor)
94
+ said = @copilot.newest
95
+ out << self.class.frame("copilot_cursor", { id: said }) unless out.closed?
96
+ # The page is attached, so every chat event from here on is queued for
97
+ # it: a `GET /api/chat` the page starts on this misses nothing.
98
+ out << self.class.frame("chat_cursor", {}) if @chat && @page && !out.closed?
99
+ apps = open_apps(out)
47
100
  seqs = nil
48
101
  until over?(out, started)
49
102
  seqs = board(out, seqs)
103
+ apps = app_news(out, apps)
50
104
  cursor = replies(out, cursor)
105
+ said = copilot(out, said)
106
+ chat(out)
51
107
  pinged = ping(out) if @clock.call - pinged >= @context.ping_every
52
- @sleeper.call(@context.tick)
108
+ wait(news, started, pinged)
53
109
  end
54
110
  rescue Live::Unavailable => e
55
111
  # Ending the stream is the right answer: the page reconnects with
56
112
  # backoff and shows itself as reconnecting meanwhile.
57
113
  out << ": #{e.message.tr("\n", " ")}\n\n"
114
+ ensure
115
+ news&.close
116
+ @chat&.detach(@page) if @page
58
117
  end
59
118
 
60
119
  # @param event [String]
@@ -65,11 +124,45 @@ module OpenLanes
65
124
 
66
125
  private
67
126
 
127
+ # The stream's subscription to nudges, or nil to poll. A Redis user
128
+ # whose ACL withholds SUBSCRIBE got a working polled board before
129
+ # nudges existed, and ending the stream instead would leave its page
130
+ # reconnecting forever. A Redis that is gone fails the first read
131
+ # just after, and ends the stream there.
132
+ #
133
+ # @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber, nil]
134
+ def subscribe
135
+ Live::News.subscribe(store: @store, keys: @keys)
136
+ rescue Live::Unavailable => e
137
+ UI.log("events: no nudges, polling every #{POLL}s: #{e.message}", level: :warn)
138
+ nil
139
+ end
140
+
68
141
  # @return [Boolean]
69
142
  def over?(out, started)
70
143
  out.closed? || @context.stopping.true? || (@context.lifetime && @clock.call - started >= @context.lifetime)
71
144
  end
72
145
 
146
+ # Sleep until a nudge, the next ping, or the end of the stream's
147
+ # lifetime, whichever comes first. One nudge, one look: a nudge that
148
+ # arrives while the stream is reading stays queued and wakes the next
149
+ # wait at once, so nothing written during a look is missed. Queued
150
+ # nudges are not drained first because the drain costs more than it
151
+ # saves: redis-client cannot poll without waiting, so emptying the
152
+ # queue costs {Live::Subscriber::SHORTEST} on every wake, ten times
153
+ # what an extra look costs, and a tick nudges once or twice at most.
154
+ #
155
+ # @param news [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
156
+ # @param started [Float]
157
+ # @param pinged [Float]
158
+ # @return [void]
159
+ def wait(news, started, pinged)
160
+ now = @clock.call
161
+ limits = [@context.ping_every - (now - pinged)]
162
+ limits << (@context.lifetime - (now - started)) if @context.lifetime
163
+ @waiter.call(news, [limits.min, 0].max)
164
+ end
165
+
73
166
  # @return [Array] the seqs now, after sending the board if they changed
74
167
  def board(out, seqs)
75
168
  board = Live.board(store: @store, keys: @keys)
@@ -78,6 +171,25 @@ module OpenLanes
78
171
  now
79
172
  end
80
173
 
174
+ # @return [Hash{Array(String, String) => Hash}] what was sent, by plan and process
175
+ def open_apps(out)
176
+ now = live_apps
177
+ out << self.class.frame("apps", { apps: now.values }) unless now.empty? || out.closed?
178
+ now
179
+ end
180
+
181
+ # @param sent [Hash{Array(String, String) => Hash}]
182
+ # @return [Hash{Array(String, String) => Hash}]
183
+ def app_news(out, sent)
184
+ now = live_apps
185
+ now.each { |key, app| out << self.class.frame("app", app) unless sent[key] == app }
186
+ sent.except(*now.keys).each_value { out << self.class.frame("app", it.merge("state" => "stopped")) }
187
+ now
188
+ end
189
+
190
+ # @return [Hash{Array(String, String) => Hash}]
191
+ def live_apps = Apps::Listing.read(store: @store, keys: @keys).to_h { [[it["plan"], it["process"]], it] }
192
+
81
193
  # @return [String] the last reply id sent
82
194
  def replies(out, cursor)
83
195
  Live.replies(store: @store, keys: @keys, after: cursor).each do |id, reply|
@@ -87,6 +199,22 @@ module OpenLanes
87
199
  cursor
88
200
  end
89
201
 
202
+ # @return [String] the last copilot turn's stream id sent
203
+ def copilot(out, said)
204
+ @copilot.since(said).each do |id, turn|
205
+ out << self.class.frame("copilot", turn)
206
+ said = id
207
+ end
208
+ said
209
+ end
210
+
211
+ # @return [void]
212
+ def chat(out)
213
+ return unless @chat && @page
214
+
215
+ @chat.drain(@page).each { out << self.class.frame("chat", it) }
216
+ end
217
+
90
218
  # @return [Float] when it pinged
91
219
  def ping(out)
92
220
  out << ": ping\n\n"
@@ -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
@@ -137,7 +199,7 @@ module OpenLanes
137
199
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
200
 
139
201
  # @return [String]
140
- def default_log(root) = File.join(Dir.tmpdir, "openlanes-web-#{File.basename(root)}.log")
202
+ def default_log(root) = Engine::ProgressLog.default_path(root, kind: "web")
141
203
  end
142
204
  end
143
205
  end
@@ -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