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
@@ -4,6 +4,8 @@ require "ipaddr"
4
4
  require "json"
5
5
  require "rack/utils"
6
6
  require "sinatra/base"
7
+ require "newrelic_rpm"
8
+ require 'newrelic/infinite_tracing'
7
9
 
8
10
  module OpenLanes
9
11
  module Web
@@ -11,13 +13,9 @@ module OpenLanes
11
13
  # {.serving}, carries that server's {Context} as a setting, so two apps in
12
14
  # one process (the suite) never share a token or a store.
13
15
  class App < Sinatra::Base
14
- # The plan files the drawer and the preview may read. A name is looked
15
- # up in this list and never joined onto a path as typed, which is what
16
- # makes `../` meaningless here rather than merely refused.
17
- PLAN_FILES = %w[spec.md plan.md blocked.md research.md design.md].freeze
18
-
19
- # Host headers the server answers. Loopback only, so a page on another
20
- # site cannot reach it through DNS rebinding.
16
+ # Host headers the server always answers: loopback, so a page on another
17
+ # site cannot reach it through DNS rebinding. A server listening beyond
18
+ # this machine adds its own addresses and name ({Listen#permitted_hosts}).
21
19
  LOCAL_HOSTS = ["localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
22
20
 
23
21
  # What `/` says when the page was never built.
@@ -50,7 +48,9 @@ module OpenLanes
50
48
  # @return [Class] a subclass serving that context
51
49
  def serving(context)
52
50
  Class.new(self) do
51
+ use AdminAuth, credentials: context.credentials if context.credentials
53
52
  set :context, context
53
+ set :host_authorization, { permitted_hosts: LOCAL_HOSTS + context.listen.permitted_hosts }
54
54
  set :public_folder, context.dist
55
55
  end
56
56
  end
@@ -63,6 +63,58 @@ module OpenLanes
63
63
  # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
64
64
  def store = context.store
65
65
 
66
+ # @return [OpenLanes::Live::Copilot]
67
+ def copilot = Live.copilot(store:, keys: context.keys, responder: context.copilot)
68
+
69
+ # Answer +turn+ apart from this request, on a store of its own,
70
+ # since a Redis connection is never shared between threads.
71
+ #
72
+ # @param turn [OpenLanes::Live::Copilot::Turn]
73
+ # @return [void]
74
+ def answer_later(turn)
75
+ context.background.call do
76
+ Live.copilot(store: context.stores.current, keys: context.keys, responder: context.copilot).answer(turn)
77
+ ensure
78
+ context.stores.release
79
+ end
80
+ end
81
+
82
+ # @return [OpenLanes::Web::PlanFiles]
83
+ def plan_files = PlanFiles.new(plans_dir: context.plans_dir)
84
+
85
+ # The rule the page draws cars by: this machine's board lists the
86
+ # plan with at least one car. No snapshot means no engine, so no car.
87
+ #
88
+ # @param ordinal [String] e.g. "017.00"
89
+ # @return [Boolean]
90
+ def live_car?(ordinal)
91
+ plans = Live.board(store:, keys: context.keys).dig(:me, "plans") || []
92
+ plans.any? { it["ordinal"].to_s == ordinal && Array(it["cars"]).any? }
93
+ end
94
+
95
+ # @return [OpenLanes::Live::Chat]
96
+ def chat = context.chat || halt(404, json(error: "this server has no chat"))
97
+
98
+ # The request's JSON object, or a 422.
99
+ #
100
+ # @return [Hash]
101
+ def body_json
102
+ data = JSON.parse(request.body.read)
103
+ data.is_a?(Hash) ? data : halt(422, json(error: "the body is a JSON object"))
104
+ rescue JSON::ParserError => e
105
+ halt 422, json(error: "not JSON: #{e.message.lines.first.to_s.strip}")
106
+ end
107
+
108
+ # Another machine on the board, as `user@host`, so an invite names
109
+ # who it went to and is only sent to a machine that is there.
110
+ #
111
+ # @param id [String] a node id
112
+ # @return [String]
113
+ def machine_label(id)
114
+ node = Live.board(store:, keys: context.keys)[:others].map { it["node"] }.find { it["id"] == id }
115
+ node ? "#{node["user"]}@#{node["host"]}" : halt(422, json(error: "no machine #{id} is on this Redis now"))
116
+ end
117
+
66
118
  # @param data [Object]
67
119
  # @return [String]
68
120
  def json(data)
@@ -98,6 +150,26 @@ module OpenLanes
98
150
  cache_control :no_store
99
151
  end
100
152
 
153
+ error PlanFiles::NotFound do
154
+ status 404
155
+ json(error: env["sinatra.error"].message)
156
+ end
157
+
158
+ error PlanFiles::ReadOnly do
159
+ status 403
160
+ json(error: env["sinatra.error"].message)
161
+ end
162
+
163
+ error PlanFiles::Stale do
164
+ status 412
165
+ json(error: env["sinatra.error"].message)
166
+ end
167
+
168
+ error PlanFiles::FrontmatterChanged do
169
+ status 422
170
+ json(error: env["sinatra.error"].message)
171
+ end
172
+
101
173
  error Live::Unavailable do
102
174
  status 503
103
175
  json(error: env["sinatra.error"].message)
@@ -120,7 +192,7 @@ module OpenLanes
120
192
  get "/api/events" do
121
193
  content_type "text/event-stream"
122
194
  headers "Cache-Control" => "no-cache, no-transform", "X-Accel-Buffering" => "no"
123
- events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"])
195
+ events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"], page: params[:page])
124
196
  stream { |out| events.run(out) }
125
197
  end
126
198
 
@@ -136,6 +208,108 @@ module OpenLanes
136
208
  json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
137
209
  end
138
210
 
211
+ # The Preview tab's toolbar (plan 012): Restart backend, a named
212
+ # restart, or Stop, for the `openlanes app start` running that plan's
213
+ # app. Queued on the app runner's stream, not the engine's: the engine
214
+ # never runs an app, so it would never read them.
215
+ post "/api/apps/commands" do
216
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
217
+
218
+ data = body_json
219
+ plan = Plans::Ordinal.parse(data["plan"].to_s)&.to_s or halt 422, json(error: "plan must be a plan number like 012.00")
220
+ action = data["action"].to_s
221
+ halt 422, json(error: "action must be one of #{Apps::Runner::ACTIONS.join(", ")}") unless Apps::Runner::ACTIONS.include?(action)
222
+ target = data["target"]
223
+ unless target.nil? || target.to_s.match?(Apps::Definition::NAME)
224
+ halt 422, json(error: "target must be backend or a process name")
225
+ end
226
+
227
+ command = { plan:, action:, target: }
228
+ Live::EventBus.for(store).append(context.keys.app_commands, { json: JSON.generate(command) }, maxlen: Apps::Runner::COMMANDS_LENGTH)
229
+ status 202
230
+ json(command)
231
+ end
232
+
233
+ # The interview a new idea gets before its spec is written (M6). No
234
+ # interviewer, or one that failed, is not an error: the page gets no
235
+ # questions and a reason, and creates the plan as it always has.
236
+ post "/api/interview" do
237
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
238
+
239
+ idea = body_json["idea"].to_s.strip
240
+ halt 422, json(error: "interview needs an idea") if idea.empty?
241
+
242
+ interviewer = Live.interviewer
243
+ next json(questions: [], reason: "no interviewer: OPENROUTER_API_KEY is not set") unless interviewer
244
+
245
+ begin
246
+ json(questions: interviewer.call(idea:).map(&:wire))
247
+ rescue StandardError => e
248
+ json(questions: [], reason: "the interviewer failed: #{e.message.lines.first.to_s.strip}")
249
+ end
250
+ end
251
+
252
+ get "/api/copilot" do
253
+ json(copilot.history)
254
+ end
255
+
256
+ # The person's message to the copilot (W17). It is stored and accepted
257
+ # at once; the answer is written afterwards, apart from this request,
258
+ # and comes on the event stream as `event: copilot`, like a command's
259
+ # replies do. The answer is c3po-operator's ({Context#copilot}), except
260
+ # for a slash command (plan 020.00), which the engine answers.
261
+ post "/api/copilot" do
262
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
263
+
264
+ turn = copilot.say(request.body.read)
265
+ answer_later(turn) unless turn.slash?
266
+ status 202
267
+ json(turn.wire)
268
+ rescue Live::Copilot::Invalid => e
269
+ status 422
270
+ json(error: e.message)
271
+ rescue Live::Copilot::Unanswered => e
272
+ status 503
273
+ json(error: e.message)
274
+ end
275
+
276
+ # The chat between machines (plan 006): this page's chat, the invites
277
+ # open to this machine, and the ones this page waits on.
278
+ get "/api/chat" do
279
+ json(chat.state(page: params[:page]))
280
+ rescue Live::Chat::Invalid => e
281
+ status 422
282
+ json(error: e.message)
283
+ end
284
+
285
+ # What a page does in a chat. Everyone hears of it as `event: chat`
286
+ # on their own stream, this page included.
287
+ post "/api/chat/:action" do
288
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
289
+
290
+ data = body_json
291
+ page = data["page"]
292
+ machine = data["machine"].to_s
293
+ answer = case params[:action]
294
+ when "start" then chat.start_chat(page:, name: data["name"], machine:, label: machine_label(machine))
295
+ when "invite" then chat.invite(page:, machine:, label: machine_label(machine))
296
+ when "accept" then chat.accept(page:, invite: data["invite"].to_s, name: data["name"])
297
+ when "decline" then chat.decline(page:, invite: data["invite"].to_s).then { {} }
298
+ when "leave" then chat.leave(page:).then { {} }
299
+ when "say" then chat.say(page:, id: data["id"], text: data["text"])
300
+ when "presence" then chat.presence(page:, idle: data["idle"]).then { {} }
301
+ else halt 404, json(error: "no such chat action: #{params[:action]}")
302
+ end
303
+ status 202
304
+ json(answer)
305
+ rescue Live::Chat::Invalid => e
306
+ status 422
307
+ json(error: e.message)
308
+ rescue Live::Chat::Settled => e
309
+ status 409
310
+ json(error: e.message)
311
+ end
312
+
139
313
  # Demo only: the board back to the fixture, for a test that wants a
140
314
  # known start. A real engine's run has no such thing.
141
315
  post "/api/demo/reset" do
@@ -146,15 +320,39 @@ module OpenLanes
146
320
  status 204
147
321
  end
148
322
 
149
- get "/api/plans/:ordinal/files/:name" do
150
- name = PLAN_FILES.find { it == params[:name] } or halt 404, json(error: "not a plan file: #{params[:name]}")
151
- ordinal = Plans::Ordinal.parse(params[:ordinal]) or halt 404, json(error: "not a plan number")
152
- subject = Plans.tree(context.plans_dir).find(ordinal) or halt 404, json(error: "there is no plan #{ordinal}")
153
- path = File.join(subject.feature.path, name)
154
- halt 404, json(error: "#{ordinal} has no #{name}") unless File.file?(path)
323
+ # The plan files (plan 017). The page may read six names and write
324
+ # two; {PlanFiles} holds both lists and every guard but the car, so a
325
+ # name is never joined onto a path as typed.
326
+ get "/api/plans/:ordinal/files" do
327
+ json(files: plan_files.list(params[:ordinal]))
328
+ end
155
329
 
330
+ get "/api/plans/:ordinal/files/:name" do
331
+ file = plan_files.read(params[:ordinal], params[:name])
332
+ headers "ETag" => file.etag
156
333
  content_type "text/markdown", charset: "utf-8"
157
- File.read(path)
334
+ file.body
335
+ end
336
+
337
+ # A whole file, saved from the popup. The car is checked here rather
338
+ # than in {PlanFiles}, since the board is this server's to read: an
339
+ # agent working the plan would overwrite the save, or be overwritten.
340
+ put "/api/plans/:ordinal/files/:name" do
341
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
342
+
343
+ ordinal, name = params.values_at(:ordinal, :name)
344
+ plan_files.read(ordinal, name)
345
+ halt 403, json(error: "#{name} is read only") unless PlanFiles::EDITABLE.include?(name)
346
+
347
+ if_match = request.env["HTTP_IF_MATCH"].to_s.strip
348
+ halt 428, json(error: "If-Match is required; send the ETag from GET") if if_match.empty?
349
+
350
+ number = Plans::Ordinal.parse(ordinal).to_s
351
+ halt 409, json(error: "#{number} has an agent working on it; wait until it stops") if live_car?(number)
352
+
353
+ tag = plan_files.write(ordinal, name, body: request.body.read, if_match:)
354
+ headers "ETag" => tag
355
+ json(etag: tag)
158
356
  end
159
357
 
160
358
  get "/api/*" do
@@ -5,8 +5,8 @@ require "concurrent/atomic/atomic_boolean"
5
5
  module OpenLanes
6
6
  module Web
7
7
  # Everything one API server knows, fixed when it starts: where Redis is,
8
- # whose keys it reads, the token every write must carry, and how often
9
- # the event stream looks for news.
8
+ # whose keys it reads, the token every write must carry, and how long
9
+ # an event stream sleeps when nobody nudges it.
10
10
  #
11
11
  # @!attribute [r] stores
12
12
  # @return [OpenLanes::Web::Stores] a store per request thread
@@ -23,19 +23,34 @@ module OpenLanes
23
23
  # @!attribute [r] node
24
24
  # @return [OpenLanes::Live::Node, nil] this machine, for `/api/session`
25
25
  # while no engine has published a snapshot
26
- # @!attribute [r] tick
27
- # @return [Float] seconds between the event stream's looks (contract: 250 ms)
28
26
  # @!attribute [r] ping_every
29
- # @return [Float] seconds between `: ping` comments
27
+ # @return [Float] seconds between `: ping` comments, and the longest an
28
+ # event stream waits for a nudge before it looks anyway
30
29
  # @!attribute [r] lifetime
31
30
  # @return [Float, nil] seconds an event stream lives; nil for as long as
32
31
  # the browser holds it. The suite sets one so a stream ends.
33
32
  # @!attribute [r] demo
34
33
  # @return [OpenLanes::Web::Demo, nil] the demo engine, under `--demo`,
35
34
  # which `POST /api/demo/reset` puts back to the start
35
+ # @!attribute [r] copilot
36
+ # @return [#call, nil] who answers the copilot's messages (W17):
37
+ # {Live::Copilot::Operator} beside a real run, {Demo::COPILOT} under
38
+ # `--demo`, nil where nothing does, and `POST /api/copilot` says so
39
+ # @!attribute [r] background
40
+ # @return [#call] runs the block it is given apart from the request, a
41
+ # thread by default; the suite passes one that runs it when told
42
+ # @!attribute [r] chat
43
+ # @return [OpenLanes::Live::Chat, nil] the chat between machines (plan 006),
44
+ # relayed to this server's pages; nil where nothing serves it
45
+ # @!attribute [r] listen
46
+ # @return [OpenLanes::Web::Listen] the addresses served, which decide
47
+ # the Host headers answered
48
+ # @!attribute [r] credentials
49
+ # @return [OpenLanes::Web::AdminAuth::Credentials, nil] the login asked
50
+ # of every request from off this machine; nil asks for none
36
51
  # @!attribute [r] stopping
37
52
  # @return [Concurrent::AtomicBoolean] set when the server shuts down, so
38
- # open streams end within a tick
53
+ # open streams end on the `stop` nudge that follows
39
54
  Context = Data.define(:stores,
40
55
  :keys,
41
56
  :plans_dir,
@@ -43,13 +58,17 @@ module OpenLanes
43
58
  :csrf,
44
59
  :dist,
45
60
  :node,
46
- :tick,
47
61
  :ping_every,
48
62
  :lifetime,
49
63
  :demo,
64
+ :copilot,
65
+ :background,
66
+ :chat,
67
+ :listen,
68
+ :credentials,
50
69
  :stopping) do
51
- def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil,
52
- stopping: Concurrent::AtomicBoolean.new, **rest)
70
+ def initialize(dist: DIST, node: nil, ping_every: 15.0, lifetime: nil, demo: nil, copilot: nil, chat: nil,
71
+ background: Context::THREAD, listen: Listen.parse(nil), credentials: nil, stopping: Concurrent::AtomicBoolean.new, **rest)
53
72
  super
54
73
  end
55
74
 
@@ -62,5 +81,10 @@ module OpenLanes
62
81
  # @return [Boolean]
63
82
  def dist? = File.file?(index)
64
83
  end
84
+
85
+ class Context
86
+ # Work started apart from a request, on a thread of its own.
87
+ THREAD = ->(&work) { Thread.new(&work) }
88
+ end
65
89
  end
66
90
  end
@@ -19,15 +19,28 @@ module OpenLanes
19
19
  # The other machine's checkout id, fixed so its keys are stable.
20
20
  OTHER_CHECKOUT = Live::Node.digest("openlanes-demo-other", Live::Node::CHECKOUT_LENGTH)
21
21
 
22
+ # What the copilot answers in the demo, where no agent runs and the
23
+ # board is made up: one plain line, so the panel can still be tried.
24
+ COPILOT_TEXT = "This is the demo run: c3po-operator does not run here, so nothing reads this or acts on it."
25
+
26
+ # The copilot's responder under `--demo` (W17), in place of
27
+ # {Live::Copilot::Operator}.
28
+ COPILOT = lambda do |turn|
29
+ [Live::Copilot::Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: COPILOT_TEXT)]
30
+ end
31
+
22
32
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
23
33
  # @param keys [OpenLanes::Live::Keys] this node's
24
- # @param board [OpenLanes::Web::DemoBoard]
34
+ # @param board [OpenLanes::Web::DemoBoard, nil] the fixture's, by default
35
+ # @param node [OpenLanes::Live::Node, nil] a declared identity
36
+ # (`--identity`), which the board, and every reset of it, is published as
25
37
  # @param interval [Float]
26
38
  # @param clock [Proc] wall time
27
- def initialize(store:, keys:, board: DemoBoard.new, interval: INTERVAL, clock: -> { Time.now })
39
+ def initialize(store:, keys:, board: nil, node: nil, interval: INTERVAL, clock: -> { Time.now })
28
40
  @store = store
29
41
  @keys = keys
30
- @board = board
42
+ @node = node
43
+ @board = board || DemoBoard.new(node:)
31
44
  @interval = interval
32
45
  @clock = clock
33
46
  @seqs = {}
@@ -43,7 +56,7 @@ module OpenLanes
43
56
  # same board whatever the last one answered or stopped.
44
57
  #
45
58
  # @return [void]
46
- def reset = @mutex.synchronize { @board = DemoBoard.new }
59
+ def reset = @mutex.synchronize { @board = DemoBoard.new(node: @node) }
47
60
 
48
61
  # Publish and answer every {INTERVAL}, on a thread of its own.
49
62
  #
@@ -65,8 +78,9 @@ module OpenLanes
65
78
  def stop
66
79
  @running = false
67
80
  @thread&.join(@interval * 2)
68
- @store.del(@keys.snapshot, @keys.snapshot(other))
69
- @store.hdel(@keys.nodes, @keys.node, other)
81
+ names = [@keys.node, other].compact
82
+ @store.del(*names.map { @keys.snapshot(it) })
83
+ @store.hdel(@keys.nodes, *names)
70
84
  rescue Live::Unavailable
71
85
  nil
72
86
  end
@@ -81,6 +95,9 @@ module OpenLanes
81
95
  @board.advance(@clock.call)
82
96
  publish(@keys.node, @board.me)
83
97
  @board.others.each { publish(other, it) }
98
+ # The demo's board moves on every tick, so every tick is news, and
99
+ # the one nudge covers this tick's replies as well.
100
+ Live::News.publish(store: @store, keys: @keys, kind: "board")
84
101
  replies
85
102
  end
86
103
  rescue Live::Unavailable => e
@@ -90,16 +107,19 @@ module OpenLanes
90
107
 
91
108
  private
92
109
 
93
- # @return [String] the pretend other machine's node name
94
- def other = "#{@board.others.first&.dig("node", "id")}:#{OTHER_CHECKOUT}"
110
+ # @return [String, nil] the pretend other machine's node name, when this demo has one
111
+ def other = @board.others.first&.dig("node", "id")&.then { "#{it}:#{OTHER_CHECKOUT}" }
95
112
 
96
113
  # @return [void]
97
114
  def publish(name, snapshot)
98
115
  snapshot["seq"] = next_seq(name)
116
+ # The fixture names a Redis for the page to show; the demo says the
117
+ # one it is really publishing through, as the engine does.
118
+ snapshot["redis_url"] = Live.redact(@store.url)
99
119
  json = JSON.generate(snapshot)
100
- @store.multi do |tx|
120
+ Live::EventBus.for(@store).transaction do |tx|
101
121
  tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
102
- tx.xadd(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
122
+ tx.append(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
103
123
  tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
104
124
  end
105
125
  end
@@ -131,9 +151,14 @@ module OpenLanes
131
151
  command = Live::Command.parse(entry.fields["json"].to_s)
132
152
  first = @store.hget(@keys.done, command.id)
133
153
  reply = first ? parse(first) : outcome(command)
134
- @store.multi do |tx|
154
+ # A slash command is answered in the conversation too, or the page's
155
+ # copilot waits for an engine reply that never comes. Once only: a
156
+ # redelivery has its turn already.
157
+ turn = Live::Commands.echo_turn(reply) if !first && command.payload["source"] == "copilot"
158
+ Live::EventBus.for(@store).transaction do |tx|
135
159
  tx.hset(@keys.done, reply.id, reply.to_json) unless first
136
- tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
160
+ tx.append(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
161
+ tx.append(@keys.copilot, { json: turn.to_json }, maxlen: Live::Keys::COPILOT_LENGTH) if turn
137
162
  tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
138
163
  end
139
164
  reply
@@ -152,8 +177,8 @@ module OpenLanes
152
177
  # @return [OpenLanes::Live::Reply]
153
178
  def refuse(entry, reason)
154
179
  reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
155
- @store.multi do |tx|
156
- tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
180
+ Live::EventBus.for(@store).transaction do |tx|
181
+ tx.append(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
157
182
  tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
158
183
  end
159
184
  reply
@@ -10,6 +10,10 @@ module OpenLanes
10
10
  #
11
11
  # Each command kind is one method, named as {Live::Commands} names its
12
12
  # handlers, raising {Refusal} with the reason the engine would give.
13
+ #
14
+ # The fixture carries one plan hansolo approved and one whose pull
15
+ # request merged, each with `finished` set and no cars, so the demo
16
+ # shows the half-height rows a real run keeps until it restarts.
13
17
  class DemoBoard
14
18
  # A command the demo will not apply.
15
19
  class Refusal < StandardError; end
@@ -35,12 +39,20 @@ module OpenLanes
35
39
  "stop" => { "phase" => "stopped", "state" => "done" }
36
40
  }.freeze
37
41
 
42
+ # The fields of a snapshot's `node` a declared identity replaces.
43
+ IDENTITY = %w[id user host ip checkout].freeze
44
+
38
45
  # @param fixture [String] path to a `Board` JSON
39
46
  # @param random [Random]
40
- def initialize(fixture: FIXTURE, random: Random.new(4747))
47
+ # @param node [OpenLanes::Live::Node, nil] a declared identity
48
+ # (`--identity`): this board is then that machine's, and the pretend
49
+ # other machine is left to the demo that has none, so two demos on
50
+ # one Redis never publish the same node twice
51
+ def initialize(fixture: FIXTURE, random: Random.new(4747), node: nil)
41
52
  board = JSON.parse(File.read(fixture))
42
53
  @me = board.fetch("me")
43
- @others = board.fetch("others")
54
+ @others = node ? [] : board.fetch("others")
55
+ @me["node"].merge!(node.wire(heartbeat_at: Time.now).transform_keys(&:to_s).slice(*IDENTITY)) if node
44
56
  @random = random
45
57
  @ticks = 0
46
58
  end
@@ -64,7 +76,12 @@ module OpenLanes
64
76
  # @return [String, nil] a reason to show beside "done"
65
77
  # @raise [Refusal]
66
78
  def apply(command)
67
- send(Live::Commands::HANDLERS.fetch(command.kind), command)
79
+ handler = Live::Commands::HANDLERS.fetch(command.kind)
80
+ # A kind the engine gained and the demo did not would otherwise raise
81
+ # NoMethodError on the demo's thread, and the board would freeze.
82
+ raise Refusal, "the demo cannot #{command.kind}" unless respond_to?(handler, true)
83
+
84
+ send(handler, command)
68
85
  end
69
86
 
70
87
  private
@@ -227,6 +244,46 @@ module OpenLanes
227
244
  entry["id"]
228
245
  end
229
246
 
247
+ # ---- lanes (plan 020.00) ----------------------------------------------
248
+
249
+ # `/stop NNN`: every running agent on the plan stops. The plan stays on
250
+ # the board, stopped, so `/restart` has something to bring back.
251
+ #
252
+ # @return [String]
253
+ def stop_plan(command)
254
+ cars = plan!(command)["cars"].select { it["state"] == "running" }
255
+ cars.each { it.merge!(SIGNALS.fetch("stop"), "held" => false) }
256
+ "stopped #{cars.size} agent#{"s" unless cars.size == 1} on #{command.plan} and took it off the board"
257
+ end
258
+
259
+ # `/restart NNN`: the plan's stopped cars run again, unless one still does.
260
+ #
261
+ # @return [String]
262
+ def restart_plan(command)
263
+ plan = plan!(command)
264
+ working = plan["cars"].select { it["state"] == "running" }.map { it["agent"] }
265
+ raise Refusal, "#{command.plan} already has an agent: #{working.join(", ")}" unless working.empty?
266
+
267
+ # A stuck car waits on a question, and a restart answers none.
268
+ plan["cars"].select { it["state"] == "done" }.each { it.merge!("state" => "running", "phase" => "calm", "held" => false) }
269
+ "#{command.plan} restarts at the next tick"
270
+ end
271
+
272
+ # `/concurrency N`, worded as the engine words it. Lowering it stops
273
+ # nobody, as in the engine.
274
+ #
275
+ # @return [String]
276
+ def set_concurrency(command) # rubocop:disable Naming/AccessorMethodName -- the engine's handler name
277
+ limit = command.payload["limit"]
278
+ raise Refusal, "concurrency must be a whole number of at least 1, not #{limit.inspect}" unless limit.is_a?(Integer) && limit >= 1
279
+
280
+ before = @me["run"]["jobs"]
281
+ @me["run"]["jobs"] = limit
282
+ over = running(@me).size - limit
283
+ wait = over.positive? ? "; the run waits for #{over} agent#{"s" unless over == 1} to finish" : ""
284
+ "concurrency #{before} → #{limit}#{wait}"
285
+ end
286
+
230
287
  # ---- plans ------------------------------------------------------------
231
288
 
232
289
  # @return [nil]
@@ -245,7 +302,7 @@ module OpenLanes
245
302
  title = command["idea"].lines.first.to_s.strip.delete_prefix("#").strip
246
303
  @me["plans"] << plan_from({ "ordinal" => ordinal,
247
304
  "title" => title,
248
- "status" => { "key" => "new", "emoji" => "⚪️", "label" => "New" },
305
+ "status" => { "key" => "new", "emoji" => "⚪", "label" => "New" },
249
306
  "lane" => "full" })
250
307
  "created #{ordinal} #{slug(title)}"
251
308
  end
@@ -264,7 +321,8 @@ module OpenLanes
264
321
  "pr" => nil,
265
322
  "questions" => [],
266
323
  "interrupted" => nil,
267
- "claimed_by" => @me.dig("node", "id"))
324
+ "claimed_by" => @me.dig("node", "id"),
325
+ "finished" => nil)
268
326
  end
269
327
 
270
328
  # @return [String]