openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ class Copilot
6
+ # The three things a person may tell the run from the copilot's box
7
+ # (plan 020.00): `/stop NNN`, `/restart NNN` and `/concurrency N`. Each
8
+ # becomes an engine {Command} on the same stream the page's buttons use
9
+ # (W13), so c3po-operator stays read-only and the commands get the
10
+ # gate's idempotency, replies and logging for nothing.
11
+ #
12
+ # The grammar lives here and nowhere else: the page draws a hint but
13
+ # parses nothing, so page and server cannot disagree on what a command is.
14
+ module Slash
15
+ # What a slash message that is not one of the three is answered with.
16
+ #
17
+ # @!attribute [r] text
18
+ # @return [String] the usage, then why this message was not a command
19
+ Help = Data.define(:text)
20
+
21
+ # The usage, which the help turn starts with.
22
+ USAGE = <<~TEXT.chomp
23
+ Slash commands:
24
+ /stop NNN — stop every agent on plan NNN and take it off the board
25
+ /restart NNN — start plan NNN again when it is on the board with no agent
26
+ /concurrency N — let N agents run at once; lowering it waits for agents to finish
27
+ TEXT
28
+
29
+ # Word to the command kind it queues.
30
+ PLAN_KINDS = { "stop" => "stop_plan", "restart" => "restart_plan" }.freeze
31
+
32
+ # @param text [String] the person's message, stripped
33
+ # @param id [String] the message id, which becomes the command's, so
34
+ # the engine's reply and its copilot turn name the message
35
+ # @return [OpenLanes::Live::Command, Help, nil] nil when +text+ is not
36
+ # a slash message, so it goes to the copilot as before
37
+ def self.parse(text, id:)
38
+ return unless text.start_with?("/")
39
+
40
+ word, *args = text.delete_prefix("/").split
41
+ word = word.to_s.downcase
42
+ if PLAN_KINDS.key?(word)
43
+ plan_command(word, args, id)
44
+ elsif word == "concurrency"
45
+ concurrency(args, id)
46
+ else
47
+ help("/#{word} is not one of them.")
48
+ end
49
+ end
50
+
51
+ # @return [OpenLanes::Live::Command, Help]
52
+ def self.plan_command(word, args, id)
53
+ plan = Plans::Ordinal.parse(args.first) if args.size == 1
54
+ return help("/#{word} needs a plan number, like /#{word} 20 or /#{word} 020.00.") unless plan
55
+
56
+ command(id, PLAN_KINDS.fetch(word), plan: plan.to_s)
57
+ end
58
+
59
+ # @return [OpenLanes::Live::Command, Help]
60
+ def self.concurrency(args, id)
61
+ limit = args.first.to_i if args.size == 1 && args.first.match?(/\A\d+\z/)
62
+ return help("/concurrency needs a whole number of at least 1, like /concurrency 3.") unless limit&.positive?
63
+
64
+ command(id, "set_concurrency", limit:)
65
+ end
66
+
67
+ # @return [OpenLanes::Live::Command]
68
+ def self.command(id, kind, plan: nil, **payload)
69
+ Command.parse("id" => id,
70
+ "kind" => kind,
71
+ "plan" => plan,
72
+ "payload" => payload.transform_keys(&:to_s).merge("source" => "copilot"))
73
+ end
74
+
75
+ # @return [Help]
76
+ def self.help(why) = Help.new(text: "#{USAGE}\n\n#{why}")
77
+
78
+ private_class_method :plan_command, :concurrency, :command, :help
79
+ end
80
+ end
81
+ end
82
+ end
@@ -59,17 +59,35 @@ module OpenLanes
59
59
  # whole of it would keep the page from showing the message, or that
60
60
  # the copilot is reading, until the answer was already written.
61
61
  #
62
+ # A slash command ({Slash}, plan 020.00) is not the copilot's to
63
+ # answer: it is queued for this node's engine under the message's id,
64
+ # and the engine's reply comes back into the conversation as turn
65
+ # `"<id>.1"`. A slash message that is not a command is answered here,
66
+ # at once, with the usage. Neither reaches the responder, so a server
67
+ # with no copilot still takes them, and {Turn#slash?} tells the caller
68
+ # not to {#answer} them.
69
+ #
62
70
  # @param input [String, Hash] `CopilotMessage`: `{id, text}`
63
71
  # @return [OpenLanes::Live::Copilot::Turn] the person's turn
64
72
  # @raise [OpenLanes::Live::Copilot::Invalid] for the API server's 422
65
- # @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would answer
73
+ # @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would
74
+ # answer: no copilot for plain text, no live engine for a command
66
75
  def say(input)
67
76
  turn = message(input)
77
+ slash = Slash.parse(turn.text, id: turn.id)
68
78
  # Refused before it is stored: a message kept where nothing will ever
69
79
  # answer it reads, on the page, as a copilot that is merely slow.
70
- raise Unanswered, "the copilot is not running in this server" unless @responder
80
+ raise Unanswered, "the copilot is not running in this server" unless slash || @responder
81
+ if slash.is_a?(Command) && !engine?
82
+ raise Unanswered, "no engine is running to apply #{turn.text.split.first}"
83
+ end
71
84
 
72
- turn.tap { append(it) }
85
+ append(turn)
86
+ case slash
87
+ when Command then EventBus.for(@store).append(@keys.commands, { json: JSON.generate(slash.wire) })
88
+ when Slash::Help then append(Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: slash.text))
89
+ end
90
+ turn
73
91
  end
74
92
 
75
93
  # Store whatever the responder answers to +turn+. A responder that
@@ -136,12 +154,24 @@ module OpenLanes
136
154
  @store.xrevrange(@keys.copilot, count:).reverse.map { JSON.parse(it.fields["json"]) }
137
155
  end
138
156
 
157
+ # Whether this node's engine is there to read {Keys#commands}: its
158
+ # snapshot key lives only while it publishes (`EX 30`), so a command
159
+ # queued without one would sit unanswered and the page would read on.
160
+ #
161
+ # @return [Boolean]
162
+ def engine? = !@store.get(@keys.snapshot).nil?
163
+
139
164
  # @return [Hash, nil]
140
165
  def latest_usage(turns) = turns.reverse_each.map { it["usage"] }.find(&:itself)
141
166
 
167
+ # Written here, in the API server rather than by the Dispatcher, so the
168
+ # open streams are nudged here too: a turn has no other way to reach a
169
+ # stream that waits on {Keys#news}.
170
+ #
142
171
  # @return [void]
143
172
  def append(turn)
144
- @store.xadd(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
173
+ EventBus.for(@store).append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
174
+ News.publish(store: @store, keys: @keys, kind: "copilot")
145
175
  end
146
176
  end
147
177
 
@@ -174,6 +204,10 @@ module OpenLanes
174
204
  # @return [Hash] as the wire carries it
175
205
  def wire = { id:, from:, kind:, text:, at: at.utc.iso8601, usage: }.merge(fields.transform_keys(&:to_sym))
176
206
 
207
+ # @return [Boolean] whether this is the person's slash message, which
208
+ # the engine or {Slash} answers and the copilot never does
209
+ def slash? = from == "you" && text.start_with?("/")
210
+
177
211
  # @return [String]
178
212
  def to_json(*) = JSON.generate(wire)
179
213
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # What every bus answers, delegated to the store behind it.
7
+ class Base
8
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
9
+ attr_reader :store
10
+
11
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
12
+ def initialize(store)
13
+ @store = store
14
+ freeze
15
+ end
16
+
17
+ # @param channel [String]
18
+ # @param payload [String]
19
+ # @return [Integer] how many subscriptions received it
20
+ def publish(channel, payload) = store.publish(channel, payload)
21
+
22
+ # A subscription of its own; the caller closes it.
23
+ #
24
+ # @param channels [Array<String>]
25
+ # @param patterns [Array<String>]
26
+ # @return [#next_message, #close]
27
+ def subscribe(channels: [], patterns: []) = store.subscriber(channels:, patterns:)
28
+
29
+ # A durable append, in stream order.
30
+ #
31
+ # @param stream [String]
32
+ # @param fields [Hash]
33
+ # @param maxlen [Integer, nil] approximate trim
34
+ # @return [String] the entry id
35
+ def append(stream, fields, maxlen: nil) = store.xadd(stream, fields, maxlen:)
36
+
37
+ # The appends and writes the block makes, applied atomically.
38
+ #
39
+ # @yieldparam tx [OpenLanes::Live::EventBus::Transaction]
40
+ # @return [void]
41
+ def transaction
42
+ store.multi { |raw| yield Transaction.new(raw) }
43
+ end
44
+
45
+ # The database pub/sub channel names carry (see {Keys#news}).
46
+ #
47
+ # @return [Integer]
48
+ def db = store.db
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # The bus for the suite and `--demo`, over {MemoryStore}: one process,
7
+ # one keyspace, the same contract.
8
+ class MemoryBus < Base
9
+ # @param store [OpenLanes::Live::MemoryStore]
10
+ def initialize(store = MemoryStore.new) = super
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # The production bus, over {Store}: Redis pub/sub and streams.
7
+ class RedisBus < Base; end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ module EventBus
6
+ # One {Base#transaction} block's writes: {#append} for the streams, and
7
+ # the store's other writes ({Store::WRITES}) beside them, so a site that
8
+ # sets a key and appends an entry together keeps doing both atomically.
9
+ class Transaction
10
+ # @param raw [OpenLanes::Live::Store::Transaction]
11
+ def initialize(raw)
12
+ @raw = raw
13
+ end
14
+
15
+ # @param stream [String]
16
+ # @param fields [Hash]
17
+ # @param maxlen [Integer, nil]
18
+ # @return [nil]
19
+ def append(stream, fields, maxlen: nil) = @raw.xadd(stream, fields, maxlen:)
20
+
21
+ (Store::WRITES - %i[xadd]).each do |name|
22
+ define_method(name) { |*args, **kwargs| @raw.public_send(name, *args, **kwargs) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # The port every publish, subscribe and stream append goes through.
6
+ #
7
+ # Engine, Live and Web used to call `store.publish`, `store.subscriber`
8
+ # and `store.xadd` directly, on whichever of {Store} and {MemoryStore}
9
+ # they were handed, so the two shared a duck type nobody declared. A bus
10
+ # names that surface, and the stores become its backing: {RedisBus} over
11
+ # {Store}, {MemoryBus} over {MemoryStore}. Key layout, stream ids and the
12
+ # wire format are the store's, unchanged, because web cursors rely on them.
13
+ module EventBus
14
+ module_function
15
+
16
+ # The bus over +store+, or +store+ itself when it already is one.
17
+ #
18
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, Base]
19
+ # @return [Base]
20
+ def for(store)
21
+ case store
22
+ when Base then store
23
+ when MemoryStore then MemoryBus.new(store)
24
+ else RedisBus.new(store)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # Grills a new idea before its spec.md is written (M6): the questions the
8
+ # agents downstream would otherwise block to ask, each as a short context
9
+ # paragraph and lettered options with exactly one recommended. The page
10
+ # asks them one at a time, and the answers reach {Commands#create_plans}
11
+ # as `payload.decisions`, which writes them into the spec.
12
+ #
13
+ # One call to a small model on OpenRouter, as {ChoiceConverter} makes,
14
+ # and every question is checked the same way: written with
15
+ # {Plans::Choices.write} and read back with {Plans::Choices.parse}. A
16
+ # question that does not survive the trip is dropped, not the interview:
17
+ # four good questions are worth asking without the fifth.
18
+ #
19
+ # A seam: the suite sets {Live.interviewer} to nil, and examples about it
20
+ # hand it an adapter double, so nothing in the tests reaches OpenRouter.
21
+ class Interviewer
22
+ # The most questions one idea is asked. A person is interviewed while
23
+ # they wait, so the list stays short (M0).
24
+ LIMIT = 5
25
+
26
+ # Seconds a person waits for the questions before the plan is created
27
+ # without them. The HTTP client alone allows 10 to connect and 30 to
28
+ # read, and a Puma thread and the dialog are held for all of it.
29
+ TIMEOUT = 20
30
+
31
+ # What the model reads with the idea.
32
+ PROMPT = <<~PROMPT.freeze
33
+ A person described a software feature they want built. Agents will turn it into a spec, a plan and
34
+ code without being able to ask anything, so interview the person now. Return `questions`: an array
35
+ of at most #{LIMIT} objects, the decisions that most change what gets built and that the idea leaves open.
36
+ Ask nothing the idea already answers. Each object is:
37
+
38
+ - `context`: one short paragraph saying what must be decided and why it matters.
39
+ - `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
40
+ answer on one short line. Exactly one has `"recommended": true`: the one you would choose.
41
+
42
+ Never add an option for "something else" or "type something instead": the page has a text box.
43
+ An idea that leaves nothing open gets an empty array.
44
+ PROMPT
45
+
46
+ # One question, ready for the page.
47
+ #
48
+ # @!attribute [r] context
49
+ # @return [String]
50
+ # @!attribute [r] options
51
+ # @return [Array<String>] labels, in order
52
+ # @!attribute [r] recommended
53
+ # @return [Integer] the index of the recommended option
54
+ Question = Data.define(:context, :options, :recommended) do
55
+ # @return [Hash] as `POST /api/interview` sends it
56
+ def wire = { context:, options:, recommended: }
57
+ end
58
+
59
+ # An interviewer when OPENROUTER_API_KEY is set, nil otherwise.
60
+ #
61
+ # @param env [#[]]
62
+ # @param config [OpenLanes::Config]
63
+ # @return [OpenLanes::Live::Interviewer, nil]
64
+ def self.from_env(env = ENV, config: Config.current)
65
+ key = env["OPENROUTER_API_KEY"].to_s.strip
66
+ key.empty? ? nil : new(api_key: key, model: config.questions_model || ChoiceConverter::DEFAULT_MODEL)
67
+ end
68
+
69
+ # The model's questions, each cleaned the way {ChoiceConverter.render}
70
+ # cleans one, keeping those that survive.
71
+ #
72
+ # @param output [Hash] `questions`, as the adapter parsed it
73
+ # @return [Array<OpenLanes::Live::Interviewer::Question>] at most {LIMIT}
74
+ def self.render(output)
75
+ output = output.to_h.transform_keys(&:to_sym)
76
+ Array(output[:questions]).filter_map { question(it) }.first(LIMIT)
77
+ end
78
+
79
+ # @param raw [Object]
80
+ # @return [OpenLanes::Live::Interviewer::Question, nil]
81
+ def self.question(raw)
82
+ return unless raw.is_a?(Hash)
83
+
84
+ body = ChoiceConverter.render(raw)
85
+ choices = Plans::Choices.parse(body)
86
+ Question.new(context: choices.context,
87
+ options: choices.options.map(&:label),
88
+ recommended: choices.options.index(&:recommended))
89
+ rescue ChoiceConverter::Invalid
90
+ nil
91
+ end
92
+ private_class_method :question
93
+
94
+ # @param api_key [String, nil] OpenRouter's; only read to build the default adapter
95
+ # @param model [String] an OpenRouter model id
96
+ # @param adapter [#call, nil] answers `call(node, answers)` with a Hash
97
+ # holding `questions`; built on first use when nil
98
+ # @param timeout [Numeric] seconds before the interview is given up
99
+ def initialize(api_key: nil, model: ChoiceConverter::DEFAULT_MODEL, adapter: nil, timeout: TIMEOUT)
100
+ @api_key = api_key
101
+ @model = model
102
+ @adapter = adapter
103
+ @timeout = timeout
104
+ @mutex = Mutex.new
105
+ end
106
+
107
+ # @return [String]
108
+ attr_reader :model
109
+
110
+ # Blocks for at most {TIMEOUT} seconds.
111
+ #
112
+ # @param idea [String] Markdown, as the idea dialog sends it
113
+ # @return [Array<OpenLanes::Live::Interviewer::Question>]
114
+ # @raise [StandardError] whatever the adapter raises: no network, a 4xx,
115
+ # bad JSON, or Timeout::Error past the limit
116
+ def call(idea:)
117
+ output = Timeout.timeout(@timeout, Timeout::Error, "no questions within #{@timeout}s") do
118
+ adapter.call(node, { idea: idea.to_s })
119
+ end
120
+ self.class.render(output)
121
+ end
122
+
123
+ private
124
+
125
+ def adapter
126
+ @mutex.synchronize do
127
+ @adapter ||= begin
128
+ require "inquirex-llm"
129
+ Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
130
+ end
131
+ end
132
+ end
133
+
134
+ def node
135
+ @node ||= begin
136
+ require "inquirex-llm"
137
+ Inquirex::LLM::Node.new(id: :interview,
138
+ verb: :extract,
139
+ prompt: PROMPT,
140
+ schema: Inquirex::LLM::Schema.new(questions: :array),
141
+ from_steps: %i[idea])
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -62,6 +62,14 @@ module OpenLanes
62
62
  # @return [String]
63
63
  def run(of = node) = "#{base}:#{of}:run"
64
64
 
65
+ # Pub/sub: `{json}`, one line of this node's {Engine::EventJournal},
66
+ # mirrored after it is fsync'd. The file is the record; a subscriber
67
+ # that misses a message reads the file.
68
+ #
69
+ # @param of [String]
70
+ # @return [String]
71
+ def journal(of = node) = "#{base}:#{of}:journal"
72
+
65
73
  # Stream: `{json}`, one `CopilotTurn`: the conversation with this
66
74
  # node's copilot (W17), the person's messages and its answers in the
67
75
  # order they were said. {Copilot} writes and reads it.
@@ -79,6 +87,23 @@ module OpenLanes
79
87
  # @return [String]
80
88
  def copilot_session(of = node) = "#{base}:#{of}:copilot-session"
81
89
 
90
+ # Stream: `{json}`, one mailbox message and the plan it belongs to,
91
+ # what `openlanes mail send` publishes after writing `state.json`
92
+ # (plan 003, B3). One stream per checkout; the plan rides in the entry,
93
+ # and each plan reads it through a group of its own ({Keys#mail_group}).
94
+ #
95
+ # @param of [String]
96
+ # @return [String]
97
+ def mail(of = node) = "#{base}:#{of}:mail"
98
+
99
+ # The consumer group one plan drains {#mail} through. Every group sees
100
+ # every entry, so a plan's harness thread acks what it read, its own
101
+ # or not, without taking anything from another plan's.
102
+ #
103
+ # @param plan [#to_s] e.g. "003.00"
104
+ # @return [String]
105
+ def mail_group(plan) = "mail:#{plan}"
106
+
82
107
  # Pub/sub channel: one chat's messages, joins, leaves and idle notices
83
108
  # (plan 006). Redis delivers a `PUBLISH` to every database's
84
109
  # subscribers, so the database is part of every chat channel's name: a
@@ -116,6 +141,33 @@ module OpenLanes
116
141
  # @return [String]
117
142
  def chat_base(db) = "#{base}:chat:#{db}"
118
143
 
144
+ # Pub/sub channel: "look again", one per project, published by
145
+ # whoever wrote something an open page shows ({News}). Every node's
146
+ # engine on the project publishes here, because every page shows every
147
+ # node's board. The database is in the name for the reason it is in
148
+ # {#chat_room}'s: a `PUBLISH` reaches every database's subscribers.
149
+ #
150
+ # @param db [Integer] the Redis database the nodes share
151
+ # @return [String]
152
+ def news(db:) = "#{base}:news:#{db}"
153
+
154
+ # Hash: plan ordinal to that plan's running app, its process statuses
155
+ # and a heartbeat ({Apps::Listing}). One per machine, not per
156
+ # checkout: the app runs in a plan's worktree and the control room in
157
+ # the main checkout, and both are this machine.
158
+ #
159
+ # @param machine [String] a node id, 24 hex digits of `user@host`
160
+ # @return [String]
161
+ def apps(machine = node.split(":").first) = "#{base}:apps:#{machine}"
162
+
163
+ # Stream: `{json}`, one request from the Preview tab's toolbar
164
+ # (restart, stop), read by `openlanes app start`. Not {#commands}: the
165
+ # engine never runs an app, so it would never read them.
166
+ #
167
+ # @param machine [String]
168
+ # @return [String]
169
+ def app_commands(machine = node.split(":").first) = "#{apps(machine)}:commands"
170
+
119
171
  # String: the node name holding a plan, `SET NX EX`.
120
172
  #
121
173
  # @param ordinal [#to_s] e.g. "004.00"
@@ -138,6 +190,10 @@ module OpenLanes
138
190
  # can scroll back through, not an archive.
139
191
  COPILOT_LENGTH = 500
140
192
 
193
+ # Seconds a message stays in {#mail}. `state.json` is the record; the
194
+ # stream only has to outlast a harness tick, and an hour is generous.
195
+ MAIL_TTL = 3600
196
+
141
197
  # Seconds a snapshot outlives its last refresh.
142
198
  SNAPSHOT_TTL = 30
143
199
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # Mail between agents, over Redis as well as through `state.json`
9
+ # (plan 003, B3).
10
+ #
11
+ # `state.json` stays the record: `openlanes mail send` writes it first
12
+ # and publishes here only when Redis answers, so a run without Redis
13
+ # loses nothing. What the stream adds is delivery to the harness thread
14
+ # beside each agent, which drains it once a second (B4) instead of
15
+ # re-reading every plan's file.
16
+ #
17
+ # A stream with a consumer group per plan, not pub/sub: a message
18
+ # published while nobody drains waits for the next drain instead of
19
+ # vanishing. Every publish trims what is older than {Keys::MAIL_TTL}
20
+ # and renews the key's expiry, so the stream never outgrows an hour.
21
+ #
22
+ # Delivery is acked once the drain's block kept it, never before: an
23
+ # entry acked ahead of a failed write would be gone from the stream with
24
+ # only a log line left. An entry not kept stays pending and is offered
25
+ # again on the next drain. An ack says the bytes arrived, nothing more: a message is read when its recipient runs `openlanes mail ack`,
26
+ # and that lands in `state.json`, never here.
27
+ class Mail
28
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
29
+ # @param keys [OpenLanes::Live::Keys]
30
+ # @param now [Proc] wall time, which the trim's cutoff is taken from
31
+ def initialize(store:, keys:, now: -> { Time.now })
32
+ @store = store
33
+ @keys = keys
34
+ @now = now
35
+ end
36
+
37
+ # Publish one message that `state.json` already holds.
38
+ #
39
+ # @param plan [#to_s] the plan's number, e.g. "003.00"
40
+ # @param message [OpenLanes::Plans::Mailbox::Message, Hash]
41
+ # @return [void]
42
+ def publish(plan:, message:)
43
+ payload = JSON.generate("plan" => plan.to_s, "message" => wire(message))
44
+ EventBus.for(@store).transaction do |tx|
45
+ tx.append(@keys.mail, { json: payload })
46
+ tx.xtrim(@keys.mail, minid: cutoff)
47
+ tx.expire(@keys.mail, Keys::MAIL_TTL)
48
+ end
49
+ end
50
+
51
+ # Every message for +plan+ that this plan's group has not had kept
52
+ # yet: first the ones offered before and left pending, then the new.
53
+ # Entries for other plans, and entries that are not messages, are
54
+ # acked in this plan's group straight away, which leaves other plans'
55
+ # groups untouched.
56
+ #
57
+ # With a block, each message is yielded and acked only when the block
58
+ # returns truthy; one it returns false for, or raises on, stays pending
59
+ # for the next drain. Without one, each is acked as it is returned.
60
+ #
61
+ # @param plan [#to_s]
62
+ # @param consumer [String] who is draining, e.g. the agent's name
63
+ # @yieldparam message [Hash] as `state.json` holds it
64
+ # @yieldreturn [Boolean] whether it was kept
65
+ # @return [Array<Hash>] messages as `state.json` holds them, oldest
66
+ # first; with a block, the ones left pending
67
+ def drain(plan:, consumer:)
68
+ group = @keys.mail_group(plan)
69
+ @store.expire(@keys.mail, Keys::MAIL_TTL) if @store.xgroup_create(@keys.mail, group)
70
+ entries = @store.xreadgroup(@keys.mail, group, consumer, id: "0") + @store.xreadgroup(@keys.mail, group, consumer)
71
+ entries.filter_map do |entry|
72
+ message = message_in(entry, plan.to_s)
73
+ kept = message.nil? || !block_given? || yield(message)
74
+ @store.xack(@keys.mail, group, entry.id) if kept
75
+ message if message && (kept ? !block_given? : true)
76
+ end
77
+ end
78
+
79
+ # Which plans have mail in the stream after +cursor+, so a drain can
80
+ # reach a plan with nobody running on it: a worktree's `mail send` has
81
+ # no other way into the main checkout's `state.json`, and the stream
82
+ # forgets it after {Keys::MAIL_TTL}.
83
+ #
84
+ # @param cursor [String] the last stream id already looked at, "0" for
85
+ # all of it
86
+ # @return [Array(Array<String>, String)] the plans, and the new cursor
87
+ def plans_since(cursor)
88
+ plans = []
89
+ loop do
90
+ entries = @store.xread(@keys.mail, id: cursor)
91
+ break if entries.empty?
92
+
93
+ cursor = entries.last.id
94
+ plans.concat(entries.filter_map { plan_in(it) })
95
+ end
96
+ [plans.uniq, cursor]
97
+ end
98
+
99
+ private
100
+
101
+ # The oldest id a publish keeps: {Keys::MAIL_TTL} seconds ago.
102
+ #
103
+ # @return [String]
104
+ def cutoff = "#{((@now.call.to_f - Keys::MAIL_TTL) * 1000).to_i}-0"
105
+
106
+ # @param message [OpenLanes::Plans::Mailbox::Message, Hash]
107
+ # @return [Hash] string keys, `at` in ISO 8601 as `state.json` has it
108
+ def wire(message)
109
+ hash = message.to_h.transform_keys(&:to_s)
110
+ hash.merge("at" => iso8601(hash["at"])).compact
111
+ end
112
+
113
+ # @param at [String, Time, nil]
114
+ # @return [String, nil]
115
+ def iso8601(at)
116
+ return at.iso8601 if at.respond_to?(:iso8601)
117
+
118
+ Time.parse(at.to_s).iso8601
119
+ rescue ArgumentError
120
+ at&.to_s
121
+ end
122
+
123
+ # @param entry [OpenLanes::Live::Store::Entry]
124
+ # @return [String, nil] the plan the entry is for
125
+ def plan_in(entry)
126
+ data = JSON.parse(entry.fields["json"].to_s)
127
+ data["plan"] if data.is_a?(Hash) && data["plan"].is_a?(String)
128
+ rescue JSON::ParserError
129
+ nil
130
+ end
131
+
132
+ # @param entry [OpenLanes::Live::Store::Entry]
133
+ # @param plan [String]
134
+ # @return [Hash, nil] the message, when the entry is one and for +plan+
135
+ def message_in(entry, plan)
136
+ data = JSON.parse(entry.fields["json"].to_s)
137
+ data["message"] if data.is_a?(Hash) && data["plan"] == plan && data["message"].is_a?(Hash)
138
+ rescue JSON::ParserError
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end