openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -20,10 +20,10 @@ module OpenLanes
20
20
 
21
21
  # Inherited from `run`, and meaningless here: a dry run publishes no
22
22
  # board, and a run without its page cannot be answered.
23
- @options = options.reject { %i[dry_run web until_settled].include?(it.name) }
23
+ @options = options.reject { %i[dry_run web stay until_settled].include?(it.name) }
24
24
 
25
25
  option :rescan,
26
- default: Engine::Dispatcher::RESCAN.to_s,
26
+ default: Engine::Dispatcher::RESCAN_SECONDS.to_s,
27
27
  desc: "Seconds between rescans of .plans while idle; a command from the page rescans at once"
28
28
 
29
29
  example [
@@ -33,13 +33,10 @@ module OpenLanes
33
33
  "--plan 005 # only plan 005, until stopped"
34
34
  ]
35
35
 
36
- # Exit status when Redis or the control room is not there to serve.
37
- UNAVAILABLE = 69
38
-
39
36
  # @param options [Hash]
40
37
  # @return [void]
41
38
  def call(**options)
42
- @rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN), exception: false)
39
+ @rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN_SECONDS), exception: false)
43
40
  refuse("--rescan takes a whole number of seconds, at least 1.", 64) unless @rescan&.positive?
44
41
 
45
42
  super
@@ -47,51 +44,10 @@ module OpenLanes
47
44
 
48
45
  private
49
46
 
50
- # @param _options [Hash]
51
- # @param _web [OpenLanes::Web::Launcher]
52
- # @return [Hash]
53
- def staying(_options, _web) = { stay: true, rescan: @rescan }
54
-
55
- # @return [void]
56
- def without_live(message, _options)
57
- refuse("#{message}\n\nopenlanes start needs Redis: its control room is the only way to answer the " \
58
- "agents' questions. Start redis-server, or pass --redis-url. " \
59
- "`openlanes run` goes on without it, headless.",
60
- UNAVAILABLE)
61
- end
62
-
63
- # @return [void]
64
- def without_web(web, live, _options)
65
- live.close
66
- refuse("The control room did not answer on #{web.url}, and openlanes start is no use without it. " \
67
- "Its log: #{web.log}",
68
- UNAVAILABLE)
69
- end
70
-
71
- # A project with nothing left to do opens on the idea dialog (W19).
72
- #
73
- # @return [String]
74
- def browser_url(web, tree)
75
- open = tree.subjects.reject { Engine::Dispatcher::FINISHED.include?(it.status.key) }
76
- open.empty? ? "#{web.url}#start" : web.url
77
- end
78
-
79
- # What the service did, and exit 0: stopping it on purpose is not a
80
- # failure, and the failed attempts it lived through are listed above
81
- # the box.
82
- #
83
- # @return [void]
84
- def finish(runner, attempts, options, seconds:)
85
- puts_report(attempts, Engine.tally(attempts:, seconds:).render)
86
- unless quiet?(options)
87
- blocked = runner.blocked.map { it.feature.ordinal }
88
- lines = ["Stopped. Unfinished plans keep their state; openlanes start picks them up again."]
89
- lines << "Still waiting for answers: #{blocked.join(", ")}" unless blocked.empty?
90
- success("#{attempts.size} invocation#{"s" unless attempts.size == 1}, " \
91
- "#{attempts.count(&:advanced?)} advanced\n\n#{lines.join("\n")}")
92
- end
93
- exit(0)
94
- end
47
+ # @param tree [OpenLanes::Plans::Tree]
48
+ # @param options [Hash]
49
+ # @return [OpenLanes::Engine::StartWorkflow]
50
+ def workflow(tree, options) = Engine.start(tree:, origin:, **options.except(:rescan), rescan: @rescan)
95
51
  end
96
52
  end
97
53
  end
@@ -13,7 +13,7 @@ module OpenLanes
13
13
  # @param options [Hash]
14
14
  # @return [OpenLanes::Plans::PlanState]
15
15
  def plan_state_for(options)
16
- tree = tree_for(options)
16
+ tree = tree_for(options).everything
17
17
  ordinal = Plans::Ordinal.parse(options[:plan].to_s)
18
18
  subject = ordinal && tree.find(ordinal)
19
19
  refuse("No plan #{options[:plan]} in #{tree.dir}.\n\nKnown: #{tree.ordinals.join(", ")}", 66) unless subject
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ module State
6
+ # `openlanes state resume` — what an interrupted agent leaves for
7
+ # whoever runs next, most likely itself.
8
+ #
9
+ # It used to be a `RESUME: ...` mail, which the next round had to read
10
+ # and believe. This is a record with fixed fields, kept in `state.json`
11
+ # under `last-known-state`, one per agent.
12
+ class Resume < Base
13
+ include Locating
14
+
15
+ desc "Record where an interrupted agent stood, for the next round to resume from"
16
+
17
+ option :plan, required: true, desc: "Which plan: NNN or NNN.MM"
18
+ option :agent, required: true, desc: "The agent recording"
19
+ option :round, required: true, desc: "Which round this is, as the prompt states it"
20
+ option :status, default: "Interrupted", values: Plans::Ledger::STATUSES, desc: "Where the agent stands"
21
+ option :done, desc: "What is finished, comma-separated"
22
+ option :remaining, desc: "What is not, comma-separated"
23
+ option :next_step, desc: "The exact first thing to do on resuming"
24
+ option :files, desc: "Files touched, comma-separated"
25
+
26
+ example [
27
+ %(--plan 003 --agent luke-backend --round 1 --done "B1,B2" --remaining "B3" --next-step "run rspec spec/openlanes/live" --files lib/openlanes/live/mail.rb)
28
+ ]
29
+
30
+ # @param options [Hash]
31
+ # @return [void]
32
+ def call(**options)
33
+ state = plan_state_for(options)
34
+ entry = begin
35
+ state.record_last_known(options[:agent],
36
+ Plans::LastKnownState.new(source: "agent",
37
+ round: Integer(options[:round].to_s, exception: false) || refuse("--round must be a number", 64),
38
+ status: options[:status],
39
+ done: list(options[:done]),
40
+ remaining: list(options[:remaining]),
41
+ next_step: options[:next_step],
42
+ files_touched: list(options[:files])))
43
+ rescue OpenLanes::Error => e
44
+ refuse(e.message, 64)
45
+ end
46
+
47
+ return if quiet?(options)
48
+
49
+ UI.line("#{options[:agent]}: #{entry.status}, round #{entry.round}, resume record " \
50
+ "in #{File.basename(state.dir)}/#{Plans::PlanState::FILENAME}")
51
+ end
52
+
53
+ private
54
+
55
+ # @param text [String, nil]
56
+ # @return [Array<String>]
57
+ def list(text) = text.to_s.split(",").map(&:strip).reject(&:empty?)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -20,7 +20,7 @@ module OpenLanes
20
20
  example [
21
21
  "--plan 003 --agent luke-backend --round 1 --status Started",
22
22
  "--plan 003 --agent hansolo-reviewer --round 1 --status Completed --note approved",
23
- "--plan 003 --agent yoda-writer --round 1 --status Completed --next palpatine-planner"
23
+ "--plan 003 --agent yoda-writer --round 1 --status Completed --next luthen-rael-planner"
24
24
  ]
25
25
 
26
26
  # @param options [Hash]
@@ -3,24 +3,9 @@
3
3
  module OpenLanes
4
4
  module CLI
5
5
  # `openlanes unblock NNN…` — hand a stopped plan to the agent that
6
- # drains its `blocked.md`.
7
- #
8
- # Deliberately not part of `run`. ⭕️ and 🅱️ are {StateMachine::SETTLED}, so
9
- # the loop never offers a blocked plan to anybody, and that is the property
10
- # that makes the state mean anything: the plan waits for a human, and no
11
- # agent quietly decides otherwise. Answers arriving is not a fact the tool
12
- # can observe, so a human typing this command *is* the signal, and there is
13
- # nothing else that could produce it.
6
+ # drains its `blocked.md`. Why this is a command of its own rather than
7
+ # part of `run` is told in {Lifecycle::UnblockWorkflow}.
14
8
  class Unblock < Base
15
- # The agent that knows the shape of `blocked.md`.
16
- DEFAULT_AGENT = "lando-broker"
17
-
18
- # What each token turned out to be, for whoever has to fix it.
19
- PROBLEMS = {
20
- missing: "no plan of that number",
21
- not_blocked: "no blocked.md, so there is nothing to drain"
22
- }.freeze
23
-
24
9
  desc "Fold answered blocks into a plan's documents and retire blocked.md"
25
10
 
26
11
  argument :plans,
@@ -29,7 +14,9 @@ module OpenLanes
29
14
  desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
30
15
 
31
16
  dry_run_option "Invoke no agent; print the questions still open"
32
- option :agent, default: DEFAULT_AGENT, desc: "Hand the folder to a different agent"
17
+ option :agent,
18
+ default: OpenLanes::Lifecycle::UnblockWorkflow::DEFAULT_AGENT,
19
+ desc: "Hand the folder to a different agent"
33
20
  option :root, desc: "Repository root the agent works in (default: the .plans parent)"
34
21
 
35
22
  example [
@@ -42,193 +29,12 @@ module OpenLanes
42
29
  # @param options [Hash]
43
30
  # @return [void]
44
31
  def call(plans:, **options)
45
- tree = tree_for(options)
46
- quiet?(options)
47
- unblocker = Lifecycle.unblocker(tree:,
48
- agent: agent_for(options),
49
- root: options[:root],
50
- commit: !dry_run?(options),
51
- executor: Execution.executor(root: options[:root] || File.dirname(tree.dir),
52
- dry_run: dry_run?(options)))
53
-
54
- targets = unblocker.resolve(plans)
55
- refused(targets, tree, options)
56
- subjects = targets.select(&:drainable?).map(&:subject)
57
- exit(worst(targets, [])) if subjects.empty?
58
-
59
- credentials_warning if !dry_run?(options) && !quiet?(options)
60
- preflight(subjects, unblocker, options)
61
-
62
- outcomes = Execution.on_interrupt { unblocker.call(subjects) }
63
- outcomes.each { |outcome| report(outcome, options) }
64
- footer(outcomes, options) unless quiet?(options)
65
- exit(worst(targets, outcomes))
66
- end
67
-
68
- private
69
-
70
- # @param options [Hash]
71
- # @return [OpenLanes::Agents::Agent]
72
- def agent_for(options)
73
- name = options.fetch(:agent, DEFAULT_AGENT)
74
- agents = OpenLanes::Agents.registry
75
- agents.find(name) or
76
- refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
77
- end
78
-
79
- # A number that names no folder, or a folder that was never stopped, is
80
- # refused rather than skipped. Whoever typed this believes an answer has
81
- # arrived; running against a folder nobody blocked and saying nothing is
82
- # how you come back later to a plan nobody touched and no record of why.
83
- #
84
- # Every bad token is reported, not just the first, because stopping at
85
- # the first one hides the rest of the answer.
86
- #
87
- # @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
88
- # @param tree [OpenLanes::Plans::Tree]
89
- # @param options [Hash]
90
- # @return [void]
91
- def refused(targets, tree, options)
92
- problems = targets.reject(&:drainable?)
93
- return if problems.empty?
94
-
95
- problems.each do |target|
96
- state = target.subject ? target.subject.status.key : "unknown"
97
- puts "#{target.token}\t#{state}\t0 open\t#{PROBLEMS.fetch(target.problem)}"
98
- end
99
- return if quiet?(options)
100
-
101
- error("#{problems.size} of the plans named cannot be drained:\n\n" +
102
- problems.map { |t| " #{t.token} — #{PROBLEMS.fetch(t.problem)}" }.join("\n") +
103
- "\n\nThe tree holds: #{tree.ordinals.join(", ")}")
104
- end
105
-
106
- # What each folder is waiting on, said BEFORE the agent is invoked.
107
- #
108
- # This is the half that was missing. A real run hands the folder to an
109
- # agent that can take a quarter of an hour, and until it came back the
110
- # terminal showed nothing at all — a run that had found nothing to do and
111
- # a run still working looked exactly alike.
112
- #
113
- # @param subjects [Array<OpenLanes::Plans::Subject>]
114
- # @param unblocker [OpenLanes::Lifecycle::Unblocker]
115
- # @param options [Hash]
116
- # @return [void]
117
- def preflight(subjects, _unblocker, options)
118
- return if quiet?(options)
119
-
120
- subjects.each do |subject|
121
- questions = Lifecycle::Unblocker.questions(subject)
122
- answered = questions.count(&:answered)
123
- say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
124
- "#{subject.feature.title} — #{summary(questions.size, answered)}")
125
- detail(subject, questions)
126
- end
127
- end
128
-
129
- # @param open [Integer]
130
- # @param answered [Integer]
131
- # @return [String]
132
- def summary(open, answered)
133
- return "nothing open" if open.zero?
134
-
135
- "#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
136
- end
137
-
138
- # @param subject [OpenLanes::Plans::Subject]
139
- # @param questions [Array<OpenLanes::Lifecycle::Unblocker::Question>]
140
- # @return [void]
141
- def detail(subject, questions)
142
- if subject.unreadable_block?
143
- say(" #{paint("blocked.md names no `## B<n>` question, so nothing here can be drained", :red)}",
144
- bullet: " ")
145
- say(" #{paint("Number each open question `## B1`, `## B2`, and each answer `## A1`, `## A2`.", :yellow)}",
146
- bullet: " ")
147
- elsif questions.empty?
148
- say(" #{paint("nothing left open", :green)}", bullet: " ")
149
- else
150
- questions.each { |question| say(" #{paint(question.to_s, :yellow)}", bullet: " ") }
151
- end
152
- end
153
-
154
- # One plan, after the fact: the deliverable line on STDOUT, and what
155
- # changed on STDERR.
156
- #
157
- # @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
158
- # @param options [Hash]
159
- # @return [void]
160
- def report(outcome, options)
161
- moved = movement(outcome, !dry_run?(options))
162
- puts "#{outcome.ordinal}\t#{outcome.ok ? outcome.subject.status.key : "failed"}\t" \
163
- "#{outcome.after.size} open\t#{moved}\t#{outcome.note}"
164
- # A dry run changed nothing, so the preflight above it is still the
165
- # whole truth. Printing the same three lines again reads as a second
166
- # pass that found the same thing, which is not what happened.
167
- return if quiet?(options) || dry_run?(options)
168
-
169
- say("#{paint(outcome.ordinal.to_s, :bright_black)} #{outcome.subject.status.emoji} " \
170
- "#{outcome.subject.feature.title} — #{paint(moved, outcome.ok ? :green : :red)}")
171
- detail(outcome.subject, outcome.after)
172
- end
173
-
174
- # What the file says happened, rather than what the agent claims. The
175
- # questions are counted off disk on both sides of the run.
176
- #
177
- # @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
178
- # @param commit [Boolean]
179
- # @return [String]
180
- def movement(outcome, commit)
181
- return "failed" unless outcome.ok
182
- return "not attempted" unless commit
183
- return "blocked.md retired" if outcome.cleared?
184
- return "nothing folded" if outcome.folded.empty?
185
-
186
- "folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
187
- end
188
-
189
- # @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
190
- # @param options [Hash]
191
- # @return [void]
192
- def footer(outcomes, options)
193
- failed = outcomes.reject(&:ok)
194
-
195
- if dry_run?(options)
196
- warn("Dry run: no agent was invoked, and #{outcomes.size} " \
197
- "plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
198
- "Re-run without --dry-run to fold in whatever has been answered.")
199
- return
200
- end
201
-
202
- return error(failed.map { |o| "#{o.ordinal}: #{o.note}" }.join("\n")) unless failed.empty?
203
-
204
- cleared = outcomes.count(&:cleared?)
205
- folded = outcomes.sum { |o| o.folded.size }
206
- waiting = outcomes.sum { |o| o.after.size }
207
- success("#{folded} question#{"s" unless folded == 1} folded in · " \
208
- "#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
209
- "#{waiting} still waiting on a human." +
210
- (if folded.zero?
211
- "\n\nNothing moved. An answer has to be written into blocked.md as its own " \
212
- "`## A<n>` section, answering the `## B<n>` of the same number, before " \
213
- "there is anything to fold."
214
- else
215
- ""
216
- end))
217
- end
218
-
219
- # The most serious thing that happened, as an exit status. A tree that
220
- # was partly drained still exits non-zero when part of it could not be.
221
- #
222
- # @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
223
- # @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
224
- # @return [Integer]
225
- def worst(targets, outcomes)
226
- problems = targets.map(&:problem)
227
- return 66 if problems.include?(:missing)
228
- return 65 if problems.include?(:not_blocked)
229
- return 1 unless outcomes.all?(&:ok)
230
-
231
- 0
32
+ workflow = OpenLanes::Lifecycle.unblock(tree: tree_for(options),
33
+ agent: options.fetch(:agent, OpenLanes::Lifecycle::UnblockWorkflow::DEFAULT_AGENT),
34
+ root: options[:root],
35
+ dry_run: dry_run?(options),
36
+ quiet: quiet?(options))
37
+ exit(unwrap(workflow.call(plans)))
232
38
  end
233
39
  end
234
40
  end
@@ -6,11 +6,13 @@ module OpenLanes
6
6
  # starts one beside the engine; this is that server, and with `--demo`
7
7
  # the same server over a pretend engine, for Cypress and front-end work.
8
8
  class Web < Base
9
- desc "Serve the control room page and its API on 127.0.0.1"
9
+ desc "Serve the control room page and its API, on 127.0.0.1 unless told otherwise"
10
10
 
11
11
  option :port,
12
+ aliases: ["-p"],
12
13
  default: OpenLanes::Web::DEFAULT_PORT.to_s,
13
14
  desc: "Port to listen on"
15
+ listen_options
14
16
  option :redis_url,
15
17
  desc: "Redis the engine publishes to (default: $OPENLANES_REDIS_URL, else .openlanes.yml redis_url, else $REDIS_URL, " \
16
18
  "else #{Live::DEFAULT_URL})"
@@ -23,11 +25,19 @@ module OpenLanes
23
25
  default: false,
24
26
  desc: "Serve a sample run that moves and answers commands, with no agents. " \
25
27
  "Uses Redis when it answers, an in-memory store when it does not"
28
+ option :identity,
29
+ desc: "With --demo: register as USER@HOST instead of this login and host, so a second demo on " \
30
+ "another port is another machine on the same Redis"
31
+ log_options
26
32
 
27
33
  example [
28
34
  " # serve the live run of this checkout",
29
35
  "--demo --no-open # the sample run, for Cypress and npm run dev",
30
- "--port 4848 # somewhere other than 11000"
36
+ "--port 4848 # somewhere other than 11000",
37
+ "-I --admin-credentials admin:s3cret # reachable from other machines, behind a login",
38
+ "-i 127.0.0.1,192.168.1.20 -p 4848 # loopback and one LAN address",
39
+ "--bind 0.0.0.0 --admin-credentials admin:s3cret # every interface, behind a login",
40
+ "--demo --port 11101 --identity demo@second-machine # a second machine for the chat"
31
41
  ]
32
42
 
33
43
  # @param options [Hash]
@@ -35,16 +45,27 @@ module OpenLanes
35
45
  def call(**options)
36
46
  tree = tree_for(options)
37
47
  root = File.dirname(tree.dir)
48
+ logging(options, root:)
49
+ listen = listen_for(options)
50
+ credentials = credentials_for(options, listen)
38
51
  url = Live.redis_url(option: redis_url_option(options), config: Config.current)
52
+ node = node_for(root, options)
39
53
  stores = stores_for(url, options)
40
- node = Live::Node.current(root:)
41
54
  keys = Live.keys(root:, node:)
42
55
  keys = keys.with(repo: "demo/#{keys.repo}") if options[:demo]
43
- demo = OpenLanes::Web.demo(store: stores.current, keys:).start if options[:demo]
56
+ demo = OpenLanes::Web.demo(store: stores.current, keys:, node: declared(node, options)).start if options[:demo]
44
57
  copilot = demo ? OpenLanes::Web::Demo::COPILOT : copilot_for(root:, tree:, stores:, keys:)
45
- context = OpenLanes::Web.context(stores:, keys:, plans_dir: tree.dir, url: stores.current.url, node:, demo:, copilot:)
46
- server = OpenLanes::Web.server(app: OpenLanes::Web.app(context), context:, port: options[:port])
47
- serve(server, options, keys:, node:, url: stores.current.url)
58
+ context = OpenLanes::Web.context(stores:,
59
+ keys:,
60
+ plans_dir: tree.dir,
61
+ url: stores.current.url,
62
+ node:,
63
+ demo:,
64
+ copilot:,
65
+ listen:,
66
+ credentials:)
67
+ server = OpenLanes::Web.server(app: OpenLanes::Web.app(context), context:, port: options[:port], listen:)
68
+ serve(server, options, keys:, node:, url: stores.current.url, credentials:)
48
69
  ensure
49
70
  demo&.stop
50
71
  copilot.stop if copilot.respond_to?(:stop)
@@ -52,6 +73,19 @@ module OpenLanes
52
73
 
53
74
  private
54
75
 
76
+ # Logs as `web`, to the file the engine named in this process's
77
+ # environment when the engine spawned it, else to the same default the
78
+ # engine would pick, so a server started by hand still joins the run's log.
79
+ #
80
+ # @param options [Hash]
81
+ # @param root [String]
82
+ # @return [OpenLanes::Log::Settings]
83
+ def logging(options, root:)
84
+ Log.setup(Log::Settings.from(options, root:), name: "web")
85
+ rescue Log::Invalid => e
86
+ refuse(e.message, 64)
87
+ end
88
+
55
89
  # `--redis-url`, else the URL `openlanes run` hands its server through the
56
90
  # environment, so a password never shows in `ps`. Both are passed to
57
91
  # {Live.redis_url} as the option, which keeps the rest of its order.
@@ -63,6 +97,27 @@ module OpenLanes
63
97
  [options[:redis_url], env[OpenLanes::Web::Launcher::REDIS_URL_ENV]].find { !it.to_s.strip.empty? }
64
98
  end
65
99
 
100
+ # This machine, or under `--demo` the one `--identity` declares. A live
101
+ # server is refused one: its node id is what claims and commands are
102
+ # keyed by, and a run under a borrowed name would answer for a machine
103
+ # it is not.
104
+ #
105
+ # @param root [String]
106
+ # @param options [Hash]
107
+ # @return [OpenLanes::Live::Node]
108
+ def node_for(root, options)
109
+ identity = options[:identity].to_s.strip
110
+ return Live::Node.current(root:) if identity.empty?
111
+
112
+ refuse("--identity is for the demo only; pass --demo with it.", 64) unless options[:demo]
113
+ Live::Node.declared(identity, root:)
114
+ rescue Live::Node::Invalid => e
115
+ refuse("--identity: #{e.message}", 64)
116
+ end
117
+
118
+ # @return [OpenLanes::Live::Node, nil] the node the demo publishes as, when declared
119
+ def declared(node, options) = options[:identity].to_s.strip.empty? ? nil : node
120
+
66
121
  # @return [OpenLanes::Web::Stores]
67
122
  def stores_for(url, options)
68
123
  store = Live.connect(url)
@@ -89,22 +144,43 @@ module OpenLanes
89
144
  # Listen until INT or TERM.
90
145
  #
91
146
  # @return [void]
92
- def serve(server, options, keys:, node:, url:)
147
+ def serve(server, options, keys:, node:, url:, credentials:)
93
148
  begin
94
149
  server.start
95
150
  rescue Errno::EADDRINUSE
96
151
  refuse("Port #{options[:port]} is taken: another `openlanes web` may already be serving. " \
97
152
  "Pass --port to use another.",
98
153
  69)
154
+ rescue Errno::EADDRNOTAVAIL
155
+ refuse("--ips #{server.listen.ips.join(",")}: an address is not one of this machine's.", 69)
99
156
  end
100
157
  %w[INT TERM].each { |name| Signal.trap(name) { Thread.new { server.stop } } }
101
158
  unless quiet?(options)
102
159
  info("Control room: #{server.url}#{" (demo)" if options[:demo]}\n" \
160
+ "#{"Also on: #{(server.urls - [server.url]).join(", ")}\n" if server.listen.public?}" \
103
161
  "Redis: #{Live.mask(url)} as #{node.label} (#{keys.node}), repository #{keys.repo}")
104
162
  end
163
+ exposed(server, credentials)
164
+ Log.logger.info("listening", url: server.url, urls: server.urls, demo: options[:demo], repo: keys.repo)
105
165
  OpenLanes::Web.open(server.url) if options[:open]
106
166
  server.wait
107
167
  end
168
+
169
+ # Said even under --quiet, and logged: listening beyond this machine
170
+ # hands anyone with the login the controls, and that is never silent.
171
+ # {#credentials_for} has already refused a public server without one.
172
+ #
173
+ # @param server [OpenLanes::Web::Server]
174
+ # @param credentials [OpenLanes::Web::AdminAuth::Credentials]
175
+ # @return [void]
176
+ def exposed(server, credentials)
177
+ return unless server.listen.public?
178
+
179
+ ips = server.listen.public_ips.join(", ")
180
+ warn("Listening on #{ips}, behind a login as #{credentials.user}: anyone with it who can reach this " \
181
+ "machine on port #{server.port} can stop agents, answer their questions and message them.")
182
+ Log.logger.warn("listening beyond loopback", ips: server.listen.public_ips, port: server.port, user: credentials.user)
183
+ end
108
184
  end
109
185
  end
110
186
  end
@@ -18,7 +18,7 @@ module OpenLanes
18
18
  example [
19
19
  "002 # create/seed the worktree for plan 002.00",
20
20
  "002.01 # create/seed the worktree for plan 002.01",
21
- "002.00-⚪️ → update-dsl # find the plan by its folder name",
21
+ "002.00━━⚪━━❯update-dsl # find the plan by its folder name",
22
22
  "--skip-seed 003 # create the worktree but don't seed it"
23
23
  ]
24
24
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ # `openlanes worktree prune` — remove the plan worktrees whose pull
6
+ # request merged into the default branch and whose work landed with it.
7
+ #
8
+ # The engine retires a plan's checkout as it sees the merge (see
9
+ # {Engine::Merging}). This is for the ones it never saw: a run that
10
+ # crashed, a merge made while no engine was up, checkouts from before
11
+ # retirement existed. The rules are {Vcs::Worktree#retire}'s, so a
12
+ # checkout with uncommitted work, unmerged commits or somebody's lock
13
+ # in it stays, and the table says why.
14
+ class WorktreePrune < Base
15
+ desc "Remove plan worktrees whose pull request merged and whose work is on main"
16
+
17
+ dry_run_option "Remove nothing; print what would be removed and why the rest stay"
18
+
19
+ example [
20
+ " # remove every worktree whose work landed",
21
+ "-n # show what would be removed, and why the rest stay"
22
+ ]
23
+
24
+ # @param options [Hash]
25
+ # @return [void]
26
+ def call(**options)
27
+ worktree = OpenLanes::Vcs.worktree(root: OpenLanes::Vcs::Worktree.main_root(File.dirname(tree_for(options).dir)))
28
+ refuse("#{worktree.root} is not a git repository", 66) unless worktree.repository?
29
+
30
+ github = OpenLanes::Vcs.github
31
+ locks = OpenLanes::Engine.agent_locks(worktree.root)
32
+ held = ->(path) { locks&.held_in(path)&.to_s }
33
+ rows = worktree.list.map { row(worktree, github, it, held, dry_run?(options)) }
34
+ return UI.line("No plan worktrees under #{worktree.dir}.") if rows.empty? && !quiet?(options)
35
+
36
+ puts table([%w[WORKTREE BRANCH PR RESULT], *rows]) unless quiet?(options)
37
+ return unless dry_run?(options)
38
+
39
+ count = rows.count { it.last.start_with?("would be removed") }
40
+ dry_run_footer(count, "removal#{"s" unless count == 1}")
41
+ end
42
+
43
+ private
44
+
45
+ # @return [Array<String>] one table row
46
+ def row(worktree, github, path, held, dry_run)
47
+ name = File.basename(path)
48
+ branch = worktree.branch_of(path)
49
+ return [name, "(detached)", "", "kept: detached HEAD, no branch to match a pull request"] if branch.empty?
50
+
51
+ pull = github.merged_pull_for(branch)
52
+ return [name, branch, "", "kept: no merged pull request"] unless pull
53
+
54
+ retired = worktree.retire(path, merge_commit: pull[:merge_commit], base: pull[:base], held:, dry_run:)
55
+ [name, branch, "##{pull[:number]}", result(retired)]
56
+ rescue OpenLanes::Error => e
57
+ [name, branch.to_s, "", "kept: #{e.message.lines.first.to_s.strip}"]
58
+ end
59
+
60
+ # @param retired [OpenLanes::Vcs::Worktree::Retirement]
61
+ # @return [String]
62
+ def result(retired)
63
+ return "removed (#{retired.sha[0, 8]}): #{retired.reason}" if retired.removed?
64
+ return retired.reason if retired.reason.start_with?("would be removed")
65
+
66
+ "kept: #{retired.reason}"
67
+ end
68
+
69
+ # @param rows [Array<Array<String>>]
70
+ # @return [String]
71
+ def table(rows)
72
+ widths = rows.transpose.map { |column| column.map { it.to_s.length }.max }
73
+ rows.map { |row| row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip }.join("\n")
74
+ end
75
+ end
76
+ end
77
+ end