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
@@ -44,6 +44,18 @@ module OpenLanes
44
44
  # @!attribute [r] slash_commands
45
45
  # @return [Boolean] keep the CLI's slash commands even when lean, which
46
46
  # only the `/compact` sent into a resumed session needs
47
+ # @!attribute [r] dirs
48
+ # @return [Array<String>] directories beyond {#root} the agent may read,
49
+ # such as the trace directory the copilot reads transcripts from
50
+ # @!attribute [r] brief
51
+ # @return [Boolean] whether the agent reports through the CLI's
52
+ # one-line status tool, which only a car on the board has a use for
53
+ # @!attribute [r] tools
54
+ # @return [Array<String>, nil] the only built-in tools the agent has, with
55
+ # no settings file able to add one; nil leaves the CLI's own set
56
+ # @!attribute [r] system
57
+ # @return [String, nil] standing instructions, sent as system prompt on
58
+ # every invocation so a compacted conversation does not lose them
47
59
  Invocation = Data.define(:prompt,
48
60
  :root,
49
61
  :model,
@@ -54,9 +66,14 @@ module OpenLanes
54
66
  :network,
55
67
  :lean,
56
68
  :resume,
57
- :slash_commands) do
69
+ :slash_commands,
70
+ :dirs,
71
+ :brief,
72
+ :tools,
73
+ :system) do
58
74
  def initialize(allowed_tools: [], denied_tools: [], denied_commands: [], network: false, lean: true,
59
- model: nil, effort: nil, resume: nil, slash_commands: false, **rest)
75
+ model: nil, effort: nil, resume: nil, slash_commands: false, dirs: [], brief: true, tools: nil, system: nil,
76
+ **rest)
60
77
  super
61
78
  end
62
79
  end
@@ -81,6 +98,13 @@ module OpenLanes
81
98
  klass.new
82
99
  end
83
100
 
101
+ # The lean-start flags of one CLI, so a caller building its own argv
102
+ # never names an adapter class.
103
+ #
104
+ # @param name [String, Symbol, nil]
105
+ # @return [Array<String>]
106
+ def lean_flags(name = DEFAULT) = self.for(name).lean_flags
107
+
84
108
  # @param name [String, Symbol, nil]
85
109
  # @return [Boolean]
86
110
  def known?(name) = registry.key?((name || DEFAULT).to_s)
@@ -89,11 +89,12 @@ module OpenLanes
89
89
  # @return [Boolean]
90
90
  def serves?(lane) = lanes.empty? || lanes.include?(lane.to_sym)
91
91
 
92
- # The word after the hyphen: `researcher`, `backend`. What the screen's
93
- # agent column shows, the name being too long for it.
92
+ # The word after the last hyphen: `researcher`, `backend`, and `planner`
93
+ # for `luthen-rael-planner`, whose name has two words before its role.
94
+ # What the screen's agent column shows, the name being too long for it.
94
95
  #
95
96
  # @return [String]
96
- def role = name.split("-", 2).last.to_s
97
+ def role = name.split("-").last.to_s
97
98
  end
98
99
 
99
100
  # More rounds than this costs tokens and buys nothing: an agent that has
@@ -27,6 +27,13 @@ module OpenLanes
27
27
  # folder by what its files justify.
28
28
  BY_CONTENTS = "by contents"
29
29
 
30
+ # What bb8-resolver is offered: no plan state, but the stopped rebases
31
+ # the harness hands it (D10). An empty cell read as "does nothing".
32
+ REBASES = "stopped rebases, from the harness"
33
+
34
+ # It writes, so "read-only" would be false; it moves no plan either.
35
+ STAGES_ONLY = "stages, never moves a plan"
36
+
30
37
  # @param agents [OpenLanes::Agents::Registry]
31
38
  def initialize(agents: Registry.new)
32
39
  @agents = agents
@@ -50,7 +57,7 @@ module OpenLanes
50
57
  # A state as its words alone, deliberately without the emoji that every
51
58
  # other rendering of a state carries. The table widget pads a cell to a
52
59
  # width it measures in characters, while the terminal draws a status emoji
53
- # in two cells; the variation-selector ones (⭕️ 🅱️ ⚪️ ⭐️ 🕰️) are two
60
+ # in two cells; the variation-selector ones (⭕ 🛑 ⚪ ⭐ ⏪) are two
54
61
  # characters drawn in two cells, so no single count is right for all of
55
62
  # them. A row carrying one lands short or long and the frame stops lining
56
63
  # up. The label says the same thing unambiguously, and is worth more than
@@ -66,12 +73,17 @@ module OpenLanes
66
73
 
67
74
  # @param agent [OpenLanes::Agents::Agent]
68
75
  # @return [String]
69
- def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
76
+ def handles(agent)
77
+ return REBASES if agent.name == Engine::Rebase::RESOLVER
78
+
79
+ agent.handles.map { |key| state(key) }.join(", ")
80
+ end
70
81
 
71
82
  # @param agent [OpenLanes::Agents::Agent]
72
83
  # @return [String]
73
84
  def advances(agent)
74
85
  return state(agent.advances_to) if agent.advances_to
86
+ return STAGES_ONLY if agent.name == Engine::Rebase::RESOLVER
75
87
  return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| Plans::StateMachine::SETTLED.include?(key) }
76
88
 
77
89
  READ_ONLY
@@ -16,6 +16,13 @@ module OpenLanes
16
16
  # and "no front-end units" starts nobody.
17
17
  UNIT_HEADING = /^[ \t]{0,3}\#{2,3}[ \t]+\S/
18
18
 
19
+ # A work unit inside a section of `plan.md`: the section is the `##`,
20
+ # so only a deeper heading is a unit.
21
+ SECTION_UNIT_HEADING = /^[ \t]{0,3}\#{3,4}[ \t]+\S/
22
+
23
+ # Any second-level heading, which ends the section before it.
24
+ SECTION_HEADING = /^[ \t]{0,3}\#\#[ \t]+(.+?)[ \t#]*$/
25
+
19
26
  module_function
20
27
 
21
28
  # @param agent [OpenLanes::Agents::Agent]
@@ -36,7 +43,7 @@ module OpenLanes
36
43
  #
37
44
  # {Agent#needs} only ever chooses between agents. When lanes and
38
45
  # switches leave one candidate, that one runs: a plan parked at 🎨 is
39
- # rey-frontend's to finish whatever `plan-frontend.md` says, and an
46
+ # rey-frontend's to finish whatever `plan.md`'s `## Frontend` says, and an
40
47
  # operator who typed `--agent rey-frontend` meant it.
41
48
  #
42
49
  # @param agents [Array<OpenLanes::Agents::Agent>]
@@ -79,10 +86,43 @@ module OpenLanes
79
86
  # @return [Boolean]
80
87
  def reviewable?(agent, subject) = agent.phase.to_s != "review" || subject.pull_requests.any?(&:open?)
81
88
 
89
+ # Whether +need+ holds a work unit. A need is a file (`plan-frontend.md`)
90
+ # or a section of one (`plan.md#frontend`: the `## Frontend` section,
91
+ # matched without regard to case). Units moved into `plan.md` in plan
92
+ # 003; a folder planned before that still holds `plan-<section>.md`, and
93
+ # is read from there when `plan.md` has no such section, so no folder
94
+ # needs migrating.
95
+ #
82
96
  # @param subject [OpenLanes::Plans::Subject]
83
- # @param file [String]
97
+ # @param need [String]
84
98
  # @return [Boolean]
85
- def units?(subject, file) = Plans::Ledger.stripped(subject.read(file)).match?(UNIT_HEADING)
99
+ def units?(subject, need)
100
+ file, section = need.split("#", 2)
101
+ return Plans::Ledger.stripped(subject.read(file)).match?(UNIT_HEADING) unless section
102
+
103
+ body = section_of(Plans::Ledger.stripped(subject.read(file)), section)
104
+ return body.match?(SECTION_UNIT_HEADING) if body
105
+
106
+ units?(subject, "plan-#{section.downcase}.md")
107
+ end
108
+
109
+ # @param text [String]
110
+ # @param name [String]
111
+ # @return [String, nil] the lines under `## name`, up to the next `##`;
112
+ # nil when there is no such heading
113
+ def section_of(text, name)
114
+ body = nil
115
+ text.each_line do |line|
116
+ if (heading = SECTION_HEADING.match(line))
117
+ break if body
118
+
119
+ body = +"" if heading[1].strip.casecmp?(name)
120
+ elsif body
121
+ body << line
122
+ end
123
+ end
124
+ body
125
+ end
86
126
  end
87
127
  end
88
128
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # The `app:` block of `.openlanes.yml`: what kind of app a project builds
6
+ # and the processes that run it, in the order they start.
7
+ #
8
+ # Written by luke on a plan's first build (M12) and only ever read here.
9
+ # It is new surface that every project will copy, so anything it does not
10
+ # know is refused with the file named, exactly as {Config} refuses an
11
+ # unknown top-level key: a `restart-on:` typo silently ignored is a
12
+ # watcher that never fires and nobody can say why.
13
+ #
14
+ # @!attribute [r] kind
15
+ # @return [String, nil] free text, `web` or `desktop` (M11); recorded,
16
+ # not interpreted here
17
+ # @!attribute [r] processes
18
+ # @return [Array<OpenLanes::Apps::ProcessSpec>] in start order
19
+ Definition = Data.define(:kind, :processes) do
20
+ # @return [OpenLanes::Apps::ProcessSpec, nil] the process marked
21
+ # `preview: true`; nil when none is, which means no Preview tab
22
+ def preview = processes.find(&:preview?)
23
+
24
+ # @param name [String]
25
+ # @return [OpenLanes::Apps::ProcessSpec, nil]
26
+ def process(name) = processes.find { it.name == name.to_s }
27
+
28
+ # Every process the Preview tab's Restart backend button restarts:
29
+ # all but the one it shows, in declared order.
30
+ #
31
+ # @return [Array<OpenLanes::Apps::ProcessSpec>]
32
+ def backend = processes.reject(&:preview?)
33
+
34
+ # @return [Hash] as `.openlanes.yml` spells it
35
+ def to_h = { "kind" => kind, "processes" => processes.map(&:to_h) }.compact
36
+ end
37
+
38
+ class Definition
39
+ # Keys the `app:` mapping may hold.
40
+ KEYS = %w[kind processes].freeze
41
+
42
+ # Keys each process may hold.
43
+ PROCESS_KEYS = %w[name start ready restart_on preview].freeze
44
+
45
+ # A process name: what `PORT_<NAME>`, the Preview tab and `target:`
46
+ # all carry, so nothing a shell or a URL would trip over.
47
+ NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
48
+
49
+ class << self
50
+ # @param value [Object] the parsed `app:` value
51
+ # @param path [String] the file, for every error
52
+ # @return [OpenLanes::Apps::Definition]
53
+ # @raise [OpenLanes::Error] naming the file and what is wrong
54
+ def parse(value, path:)
55
+ fail_with(path, "app: must be a mapping with processes:") unless value.is_a?(Hash)
56
+
57
+ data = value.transform_keys(&:to_s)
58
+ unknown!(path, "app", data.keys - KEYS, KEYS)
59
+ kind = data["kind"]
60
+ fail_with(path, "app.kind: #{kind.inspect} is not a word like web") unless kind.nil? || kind.is_a?(String)
61
+
62
+ list = data["processes"]
63
+ fail_with(path, "app.processes: must be a list of at least one process") unless list.is_a?(Array) && !list.empty?
64
+
65
+ processes = list.each_with_index.map { |entry, index| process(entry, index, path) }
66
+ checked(new(kind: kind&.strip, processes:), path)
67
+ end
68
+
69
+ private
70
+
71
+ # @return [OpenLanes::Apps::ProcessSpec]
72
+ def process(entry, index, path)
73
+ where = "app.processes[#{index}]"
74
+ fail_with(path, "#{where}: must be a mapping with name: and start:") unless entry.is_a?(Hash)
75
+
76
+ data = entry.transform_keys(&:to_s)
77
+ unknown!(path, where, data.keys - PROCESS_KEYS, PROCESS_KEYS)
78
+ name = data["name"]
79
+ fail_with(path, "#{where}: needs name:, letters, digits, - or _") unless name.is_a?(String) && name.match?(NAME)
80
+
81
+ ProcessSpec.new(name:,
82
+ start: start(data["start"], "app.processes.#{name}", path),
83
+ ready: ready(data["ready"], "app.processes.#{name}", path),
84
+ restart_on: restart_on(data["restart_on"], "app.processes.#{name}", path),
85
+ preview: preview(data["preview"], "app.processes.#{name}", path))
86
+ end
87
+
88
+ # @return [String]
89
+ def start(value, where, path)
90
+ return value.strip if value.is_a?(String) && !value.strip.empty?
91
+
92
+ fail_with(path, "#{where}: needs start:, the shell line that runs it")
93
+ end
94
+
95
+ # @return [String, nil]
96
+ def ready(value, where, path)
97
+ return nil if value.nil?
98
+ return value if value.is_a?(String) && value.start_with?("/")
99
+
100
+ fail_with(path, "#{where}.ready: #{value.inspect} is not an HTTP path like /up")
101
+ end
102
+
103
+ # @return [Array<String>]
104
+ def restart_on(value, where, path)
105
+ globs = value.is_a?(String) ? [value] : value
106
+ return [] if globs.nil?
107
+ return globs if globs.is_a?(Array) && globs.all? { it.is_a?(String) && !it.strip.empty? }
108
+
109
+ fail_with(path, "#{where}.restart_on: must be a glob or a list of globs")
110
+ end
111
+
112
+ # @return [Boolean]
113
+ def preview(value, where, path)
114
+ return value == true if [true, false, nil].include?(value)
115
+
116
+ fail_with(path, "#{where}.preview: #{value.inspect} is not true or false")
117
+ end
118
+
119
+ # Two processes that one name, one `PORT_` variable or two preview
120
+ # flags would confuse.
121
+ #
122
+ # @return [OpenLanes::Apps::Definition]
123
+ def checked(definition, path)
124
+ names = definition.processes.map(&:name)
125
+ twice = names.select { names.count(it) > 1 }.uniq
126
+ fail_with(path, "app.processes: #{twice.join(", ")} named twice") unless twice.empty?
127
+
128
+ envs = definition.processes.group_by(&:port_env).select { _2.size > 1 }
129
+ envs.each do |env, specs|
130
+ fail_with(path, "app.processes: #{specs.map(&:name).join(" and ")} would both be #{env}")
131
+ end
132
+
133
+ previews = definition.processes.select(&:preview?)
134
+ if previews.size > 1
135
+ fail_with(path, "app.processes: only one process may be preview: true, not #{previews.map(&:name).join(" and ")}")
136
+ end
137
+ definition
138
+ end
139
+
140
+ # @return [void]
141
+ def unknown!(path, where, unknown, known)
142
+ return if unknown.empty?
143
+
144
+ fail_with(path, "#{where}: unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; known: #{known.join(", ")}")
145
+ end
146
+
147
+ # @return [void] never returns
148
+ def fail_with(path, message) = raise(Error, "#{path}: #{message}")
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module OpenLanes
7
+ module Apps
8
+ # Whether the Preview tab may show an app in an iframe, or must open it in
9
+ # a window of its own (M13, plan 012 item 11).
10
+ #
11
+ # The page cannot find out: a cross-origin frame's DOM is closed to it
12
+ # and a refused frame still fires `load`. So the runner asks the app
13
+ # itself, once its preview process is ready and after each restart, and
14
+ # reads the two headers that refuse framing:
15
+ #
16
+ # - `X-Frame-Options`: `DENY` refuses everyone, and `SAMEORIGIN` refuses
17
+ # the control room too, whose origin differs from the app's by port.
18
+ # `ALLOW-FROM` is obsolete and ignored by browsers, so it frames.
19
+ # - `Content-Security-Policy: frame-ancestors`: refuses unless one of its
20
+ # sources matches the control room's origin. `'self'` is the app's own
21
+ # origin, so it refuses too.
22
+ #
23
+ # Either one refusing sends the tab to a window. Browsers let
24
+ # `frame-ancestors` override `X-Frame-Options`; this stays on the
25
+ # cautious side, because a window always works and a refused frame is a
26
+ # blank tab nobody can explain.
27
+ module Framing
28
+ # The control room's origin unless told otherwise.
29
+ ORIGIN = "http://#{Status::HOST}:11000".freeze
30
+
31
+ # Default ports, for a source that names none.
32
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
33
+
34
+ module_function
35
+
36
+ # @param headers [Hash{String => String, Array<String>}] any case
37
+ # @param origin [String] the page that embeds it
38
+ # @return [Symbol] `:frame` or `:window`
39
+ def verdict(headers, origin: ORIGIN)
40
+ values = headers.to_h { |name, value| [name.to_s.downcase, Array(value).join(",")] }
41
+ refused = x_frame_refuses?(values["x-frame-options"]) || ancestors_refuse?(values["content-security-policy"], URI(origin))
42
+ refused ? :window : :frame
43
+ end
44
+
45
+ # One GET of the URL the tab would open, then {.verdict}. An app that
46
+ # cannot be asked opens in a window: it may work there, and an iframe
47
+ # would show nothing either way.
48
+ #
49
+ # @param url [String]
50
+ # @param origin [String]
51
+ # @return [Symbol]
52
+ def check(url, origin: ORIGIN)
53
+ uri = URI(url)
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.open_timeout = Probe::TIMEOUT
56
+ http.read_timeout = Probe::TIMEOUT
57
+ response = http.request_get(uri.request_uri)
58
+ verdict(response.to_hash, origin:)
59
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError, URI::Error
60
+ :window
61
+ end
62
+
63
+ # @param value [String, nil]
64
+ # @return [Boolean]
65
+ def x_frame_refuses?(value)
66
+ value.to_s.split(",").map { it.strip.upcase }.intersect?(%w[DENY SAMEORIGIN])
67
+ end
68
+
69
+ # Every policy is enforced, so any one whose `frame-ancestors` leaves
70
+ # the origin out refuses it. A policy without the directive allows
71
+ # framing: it has no `default-src` fallback.
72
+ #
73
+ # @param value [String, nil] one or more policies, comma-joined
74
+ # @param origin [URI::Generic]
75
+ # @return [Boolean]
76
+ def ancestors_refuse?(value, origin)
77
+ value.to_s.split(",").any? do |policy|
78
+ directive = policy.split(";").map(&:strip).find { it.downcase.start_with?("frame-ancestors") }
79
+ directive && directive.split[1..].none? { allows?(it, origin) }
80
+ end
81
+ end
82
+
83
+ # @param source [String] one source expression
84
+ # @param origin [URI::Generic]
85
+ # @return [Boolean]
86
+ def allows?(source, origin)
87
+ text = source.strip.downcase
88
+ return true if text == "*"
89
+ return text.delete_suffix(":") == origin.scheme if text.match?(/\A[a-z][a-z0-9+.-]*:\z/)
90
+ return false if text.start_with?("'")
91
+
92
+ host_allows?(text, origin)
93
+ end
94
+
95
+ # @return [Boolean]
96
+ def host_allows?(text, origin)
97
+ match = text.match(%r{\A(?:(?<scheme>[a-z][a-z0-9+.-]*)://)?(?<host>[^:/]+)(?::(?<port>\d+|\*))?(?:/.*)?\z})
98
+ return false unless match
99
+ return false if match[:scheme] && match[:scheme] != origin.scheme
100
+ return false unless host_matches?(match[:host], origin.host)
101
+
102
+ port = match[:port]
103
+ port == "*" || (port || DEFAULT_PORTS[match[:scheme] || origin.scheme]).to_i == origin.port
104
+ end
105
+
106
+ # @return [Boolean]
107
+ def host_matches?(pattern, host)
108
+ return host == pattern unless pattern.start_with?("*.")
109
+
110
+ host.end_with?(pattern.delete_prefix("*"))
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Apps
7
+ # The apps running on this machine, as the control room reads them from
8
+ # Redis: one hash per machine (`Live::Keys#apps`), a field per plan, its
9
+ # value the plan's process statuses and a heartbeat.
10
+ #
11
+ # Per machine rather than per checkout, because the control room runs in
12
+ # the main checkout and an app runs in the plan's worktree; per machine
13
+ # rather than per repository, because `127.0.0.1:11101` means nothing on
14
+ # another one.
15
+ #
16
+ # A runner killed with KILL never removes its field, so a field whose
17
+ # heartbeat is older than {TTL} is dropped by whoever reads it, the way
18
+ # `Live.board` drops a node that stopped heartbeating.
19
+ module Listing
20
+ # Seconds a plan's entry outlives its last heartbeat.
21
+ TTL = 30
22
+
23
+ module_function
24
+
25
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
26
+ # @param keys [OpenLanes::Live::Keys]
27
+ # @param plan [String]
28
+ # @param statuses [Array<OpenLanes::Apps::Status>]
29
+ # @param now [Time]
30
+ # @return [void]
31
+ def write(store:, keys:, plan:, statuses:, now: Time.now)
32
+ store.hset(keys.apps, plan, JSON.generate("heartbeat" => now.to_f, "processes" => statuses.map(&:to_h)))
33
+ end
34
+
35
+ # @return [void]
36
+ def remove(store:, keys:, plan:) = store.hdel(keys.apps, plan)
37
+
38
+ # Every live process, oldest plan first, in each plan's start order.
39
+ #
40
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
41
+ # @param keys [OpenLanes::Live::Keys]
42
+ # @param now [Time]
43
+ # @return [Array<Hash>] `<app>` hashes, as {Status#to_h} wrote them
44
+ def read(store:, keys:, now: Time.now)
45
+ entries = store.hgetall(keys.apps).transform_values { parse(it) }
46
+ stale = entries.select { |_, entry| entry.nil? || now.to_f - entry["heartbeat"].to_f > TTL }.keys
47
+ store.hdel(keys.apps, *stale) unless stale.empty?
48
+ entries.except(*stale).sort.flat_map { |_, entry| Array(entry["processes"]) }
49
+ end
50
+
51
+ # @return [Hash, nil] nil for a field that is not ours to read
52
+ def parse(json)
53
+ data = JSON.parse(json.to_s)
54
+ data.is_a?(Hash) ? data : nil
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "socket"
6
+ require "time"
7
+
8
+ module OpenLanes
9
+ module Apps
10
+ # Every port an app holds on this machine, in one file (plan 012).
11
+ #
12
+ # One file per machine, not per checkout: a worktree isolates files, not
13
+ # ports, and two worktrees of one app asking for 11100 each would both
14
+ # be told yes by anything that only looked at its own checkout.
15
+ #
16
+ # A port is reserved under an exclusive `flock` and also probed, both by
17
+ # connecting to it and by binding it: something that never registered
18
+ # here (a dev server started by hand, the control room) still holds it.
19
+ # The probe and the spawn that follows it leave a gap no lock covers, so
20
+ # {Supervisor} retries a start that lost that race.
21
+ #
22
+ # An entry belongs to the pid that reserved it. Once that process is
23
+ # gone the entry counts as free, which is how a runner killed with KILL
24
+ # gives its ports back without anyone cleaning up after it.
25
+ class Ports
26
+ # The first port an app is given (M12).
27
+ FLOOR = 11_100
28
+
29
+ # The last. Not configurable: per-plan ranges are a non-goal.
30
+ CEILING = 11_999
31
+
32
+ # Overrides where the registry lives; the suite and the Cypress run
33
+ # point it at a temp file.
34
+ FILE_ENV = "OPENLANES_PORTS_FILE"
35
+
36
+ # Every port in the range is held.
37
+ class Exhausted < Error; end
38
+
39
+ class << self
40
+ # `$OPENLANES_PORTS_FILE`, else `${XDG_STATE_HOME:-~/.local/state}/openlanes/ports.json`.
41
+ #
42
+ # @param env [Hash]
43
+ # @return [String]
44
+ def default_path(env = ENV)
45
+ return env[FILE_ENV] unless env[FILE_ENV].to_s.strip.empty?
46
+
47
+ state = env["XDG_STATE_HOME"].to_s.strip
48
+ File.join(state.empty? ? File.expand_path("~/.local/state") : state, "openlanes", "ports.json")
49
+ end
50
+
51
+ # Nothing answers on the port and it can be bound on 127.0.0.1. The
52
+ # connect catches a listener on another address, which macOS lets a
53
+ # SO_REUSEADDR bind on 127.0.0.1 sit beside.
54
+ #
55
+ # @param port [Integer]
56
+ # @return [Boolean]
57
+ def free?(port)
58
+ return false if answering?(port)
59
+
60
+ TCPServer.new(Status::HOST, port).close
61
+ true
62
+ rescue SystemCallError
63
+ false
64
+ end
65
+
66
+ # @param port [Integer]
67
+ # @return [Boolean]
68
+ def answering?(port) = Probe.listening?(port, timeout: 0.2)
69
+
70
+ # @param pid [Integer]
71
+ # @return [Boolean]
72
+ def alive?(pid)
73
+ Process.kill(0, pid.to_i)
74
+ true
75
+ rescue Errno::ESRCH, RangeError
76
+ false
77
+ rescue Errno::EPERM
78
+ true
79
+ end
80
+ end
81
+
82
+ # @param path [String] the registry file
83
+ # @param owner [Integer] whose reservations these are
84
+ # @param floor [Integer]
85
+ # @param ceiling [Integer]
86
+ # @param free [#call] a port to whether it can be taken
87
+ # @param alive [#call] a pid to whether it still runs
88
+ def initialize(path: self.class.default_path, owner: Process.pid, floor: FLOOR, ceiling: CEILING,
89
+ free: self.class.method(:free?), alive: self.class.method(:alive?))
90
+ @path = path
91
+ @owner = owner
92
+ @floor = floor
93
+ @ceiling = ceiling
94
+ @free = free
95
+ @alive = alive
96
+ end
97
+
98
+ # @return [String]
99
+ attr_reader :path
100
+
101
+ # The lowest port at or above the floor that nobody holds.
102
+ #
103
+ # @param root [String] the checkout the app runs in
104
+ # @param plan [String]
105
+ # @param process [String]
106
+ # @return [Integer]
107
+ # @raise [OpenLanes::Apps::Ports::Exhausted]
108
+ def reserve(root:, plan:, process:)
109
+ locked do |entries|
110
+ port = (@floor..@ceiling).find { !entries.key?(it) && @free.call(it) }
111
+ raise Exhausted, "every port from #{@floor} to #{@ceiling} is taken" unless port
112
+
113
+ entries[port] = { "owner" => @owner, "root" => root, "plan" => plan, "process" => process, "at" => Time.now.utc.iso8601 }
114
+ port
115
+ end
116
+ end
117
+
118
+ # @param port [Integer]
119
+ # @return [void]
120
+ def release(port) = locked { it.delete(port.to_i) }
121
+
122
+ # Everything one process holds.
123
+ #
124
+ # @param pid [Integer]
125
+ # @return [Array<Integer>] the ports released
126
+ def release_owner(pid = @owner)
127
+ locked do |entries|
128
+ entries.select { |_, entry| entry["owner"] == pid }.keys.tap { |ports| ports.each { entries.delete(it) } }
129
+ end
130
+ end
131
+
132
+ # Every live reservation, by port.
133
+ #
134
+ # @return [Hash{Integer => Hash}] `owner`, `root`, `plan`, `process`, `at`
135
+ def entries = locked(&:dup)
136
+
137
+ private
138
+
139
+ # Read, hand the live entries to the block, write back what it left.
140
+ # Dead owners' entries are dropped on the way.
141
+ #
142
+ # @yieldparam entries [Hash{Integer => Hash}]
143
+ # @return [Object] the block's value
144
+ def locked
145
+ FileUtils.mkdir_p(File.dirname(path))
146
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
147
+ file.flock(File::LOCK_EX)
148
+ entries = parse(file.read).select { |_, entry| @alive.call(entry["owner"]) }
149
+ yield(entries).tap do
150
+ file.rewind
151
+ file.truncate(0)
152
+ file.write(JSON.pretty_generate(entries.sort.to_h { [it.first.to_s, it.last] }))
153
+ end
154
+ end
155
+ end
156
+
157
+ # @return [Hash{Integer => Hash}]
158
+ def parse(text)
159
+ data = text.strip.empty? ? {} : JSON.parse(text)
160
+ data.is_a?(Hash) ? data.to_h { |port, entry| [port.to_i, entry] }.select { |_, entry| entry.is_a?(Hash) } : {}
161
+ rescue JSON::ParserError
162
+ # A file somebody broke by hand is a registry with nothing in it; the
163
+ # probes still keep a taken port from being handed out twice.
164
+ {}
165
+ end
166
+ end
167
+ end
168
+ end