openlanes 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module CLI
5
+ # `openlanes setup` — get this machine ready to run the factory: the
6
+ # gem's `Brewfile`, then the claude CLI, `gh auth` and Redis.
7
+ #
8
+ # Standalone, since a machine is ready or not whichever directory it
9
+ # was asked from.
10
+ class Setup < Base
11
+ standalone!
12
+ desc "Install the tools openlanes needs (brew bundle) and check claude, gh auth and Redis"
13
+
14
+ dry_run_option "Install nothing; list what would be installed and run the checks"
15
+ option :redis_url, desc: "Redis to check (default: .openlanes.yml redis_url, else $REDIS_URL, else #{Live::DEFAULT_URL})"
16
+
17
+ example [
18
+ " # install what is missing, then check",
19
+ "-n # list what would be installed, change nothing"
20
+ ]
21
+
22
+ # @param options [Hash]
23
+ # @return [void]
24
+ def call(**options)
25
+ quiet?(options)
26
+ machine = machine(options)
27
+ outcome = machine.call(dry_run: dry_run?(options))
28
+ preview(machine, outcome) if dry_run?(options)
29
+ $stdout.write(OpenLanes::Setup.render(outcome.checks))
30
+ refuse("This machine is not ready: run each fix above, then openlanes setup again.", 69) unless outcome.ok?
31
+ end
32
+
33
+ private
34
+
35
+ # A named seam, so a spec can stand in for brew, gh and Redis.
36
+ #
37
+ # @param options [Hash]
38
+ # @return [OpenLanes::Setup::Machine]
39
+ def machine(options) = OpenLanes::Setup.machine(redis_url: options[:redis_url])
40
+
41
+ # @return [void]
42
+ def preview(machine, outcome)
43
+ install = outcome.missing.empty? ? "nothing" : outcome.missing.join(", ")
44
+ warn("Dry run — nothing was installed.\n" \
45
+ "Would install: #{install}\n" \
46
+ "#{"Would run: #{machine.bundle_command.join(" ")}\n" unless outcome.missing.empty?}" \
47
+ "Checks: #{outcome.checks.map(&:name).join(", ")}")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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,78 +33,21 @@ 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
- # The progress log, echoed to the terminal: the terminal is where a
40
- # person watches `start` from when the page is closed, and it takes
41
- # no input.
42
- #
43
36
  # @param options [Hash]
44
37
  # @return [void]
45
38
  def call(**options)
46
- @rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN), exception: false)
39
+ @rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN_SECONDS), exception: false)
47
40
  refuse("--rescan takes a whole number of seconds, at least 1.", 64) unless @rescan&.positive?
48
41
 
49
- tap = UI.tap_log(lambda { |message, fields|
50
- next if UI.quiet
51
-
52
- Kernel.warn(Engine::ProgressLog.render(message, **fields.slice(:plan, :status, :agent, :seconds, :round)))
53
- })
54
42
  super
55
- ensure
56
- UI.untap_log(tap) if tap
57
43
  end
58
44
 
59
45
  private
60
46
 
61
- # @param _options [Hash]
62
- # @param _web [OpenLanes::Web::Launcher]
63
- # @return [Hash]
64
- def staying(_options, _web) = { stay: true, rescan: @rescan }
65
-
66
- # @return [void]
67
- def without_live(message, _options)
68
- refuse("#{message}\n\nopenlanes start needs Redis: its control room is the only way to answer the " \
69
- "agents' questions. Start redis-server, or pass --redis-url. " \
70
- "`openlanes run` goes on without it, headless.",
71
- UNAVAILABLE)
72
- end
73
-
74
- # @return [void]
75
- def without_web(web, live, _options)
76
- live.close
77
- refuse("The control room did not answer on #{web.url}, and openlanes start is no use without it. " \
78
- "Its log: #{web.log}",
79
- UNAVAILABLE)
80
- end
81
-
82
- # A project with nothing left to do opens on the idea dialog (W19).
83
- #
84
- # @return [String]
85
- def browser_url(web, tree)
86
- open = tree.subjects.reject { Engine::Dispatcher::FINISHED.include?(it.status.key) }
87
- open.empty? ? "#{web.url}#start" : web.url
88
- end
89
-
90
- # What the service did, and exit 0: stopping it on purpose is not a
91
- # failure, and the failed attempts it lived through are listed above
92
- # the box.
93
- #
94
- # @return [void]
95
- def finish(runner, attempts, options, seconds:)
96
- puts ""
97
- puts attempt_lines(attempts)
98
- puts("", Engine.tally(attempts:, seconds:).render)
99
- unless quiet?(options)
100
- blocked = runner.blocked.map { it.feature.ordinal }
101
- lines = ["Stopped. Unfinished plans keep their state; openlanes start picks them up again."]
102
- lines << "Still waiting for answers: #{blocked.join(", ")}" unless blocked.empty?
103
- success("#{attempts.size} invocation#{"s" unless attempts.size == 1}, " \
104
- "#{attempts.count(&:advanced?)} advanced\n\n#{lines.join("\n")}")
105
- end
106
- exit(0)
107
- 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)
108
51
  end
109
52
  end
110
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,21 +45,47 @@ 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]
44
- context = OpenLanes::Web.context(stores:, keys:, plans_dir: tree.dir, url: stores.current.url, node:, demo:)
45
- server = OpenLanes::Web.server(app: OpenLanes::Web.app(context), context:, port: options[:port])
46
- serve(server, options, keys:, node:, url: stores.current.url)
56
+ demo = OpenLanes::Web.demo(store: stores.current, keys:, node: declared(node, options)).start if options[:demo]
57
+ copilot = demo ? OpenLanes::Web::Demo::COPILOT : copilot_for(root:, tree:, stores:, keys:)
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:)
47
69
  ensure
48
70
  demo&.stop
71
+ copilot.stop if copilot.respond_to?(:stop)
49
72
  end
50
73
 
51
74
  private
52
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
+
53
89
  # `--redis-url`, else the URL `openlanes run` hands its server through the
54
90
  # environment, so a password never shows in `ps`. Both are passed to
55
91
  # {Live.redis_url} as the option, which keeps the rest of its order.
@@ -61,6 +97,27 @@ module OpenLanes
61
97
  [options[:redis_url], env[OpenLanes::Web::Launcher::REDIS_URL_ENV]].find { !it.to_s.strip.empty? }
62
98
  end
63
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
+
64
121
  # @return [OpenLanes::Web::Stores]
65
122
  def stores_for(url, options)
66
123
  store = Live.connect(url)
@@ -76,25 +133,54 @@ module OpenLanes
76
133
  OpenLanes::Web::Stores.shared(Live.memory_store)
77
134
  end
78
135
 
136
+ # c3po-operator, answering in this server (W17). It runs beside the
137
+ # page rather than in the engine so it answers with no run going too.
138
+ #
139
+ # @return [OpenLanes::Live::Copilot::Operator]
140
+ def copilot_for(root:, tree:, stores:, keys:)
141
+ Live::Copilot::Operator.new(root:, plans_dir: tree.dir, stores:, keys:)
142
+ end
143
+
79
144
  # Listen until INT or TERM.
80
145
  #
81
146
  # @return [void]
82
- def serve(server, options, keys:, node:, url:)
147
+ def serve(server, options, keys:, node:, url:, credentials:)
83
148
  begin
84
149
  server.start
85
150
  rescue Errno::EADDRINUSE
86
151
  refuse("Port #{options[:port]} is taken: another `openlanes web` may already be serving. " \
87
152
  "Pass --port to use another.",
88
153
  69)
154
+ rescue Errno::EADDRNOTAVAIL
155
+ refuse("--ips #{server.listen.ips.join(",")}: an address is not one of this machine's.", 69)
89
156
  end
90
157
  %w[INT TERM].each { |name| Signal.trap(name) { Thread.new { server.stop } } }
91
158
  unless quiet?(options)
92
159
  info("Control room: #{server.url}#{" (demo)" if options[:demo]}\n" \
160
+ "#{"Also on: #{(server.urls - [server.url]).join(", ")}\n" if server.listen.public?}" \
93
161
  "Redis: #{Live.mask(url)} as #{node.label} (#{keys.node}), repository #{keys.repo}")
94
162
  end
163
+ exposed(server, credentials)
164
+ Log.logger.info("listening", url: server.url, urls: server.urls, demo: options[:demo], repo: keys.repo)
95
165
  OpenLanes::Web.open(server.url) if options[:open]
96
166
  server.wait
97
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
98
184
  end
99
185
  end
100
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