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
@@ -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
@@ -79,6 +79,19 @@ module OpenLanes
79
79
  end
80
80
  end
81
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
+
82
95
  # @return [OpenLanes::Live::Chat]
83
96
  def chat = context.chat || halt(404, json(error: "this server has no chat"))
84
97
 
@@ -137,6 +150,26 @@ module OpenLanes
137
150
  cache_control :no_store
138
151
  end
139
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
+
140
173
  error Live::Unavailable do
141
174
  status 503
142
175
  json(error: env["sinatra.error"].message)
@@ -175,6 +208,47 @@ module OpenLanes
175
208
  json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
176
209
  end
177
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
+
178
252
  get "/api/copilot" do
179
253
  json(copilot.history)
180
254
  end
@@ -182,12 +256,13 @@ module OpenLanes
182
256
  # The person's message to the copilot (W17). It is stored and accepted
183
257
  # at once; the answer is written afterwards, apart from this request,
184
258
  # and comes on the event stream as `event: copilot`, like a command's
185
- # replies do. The answer is c3po-operator's ({Context#copilot}).
259
+ # replies do. The answer is c3po-operator's ({Context#copilot}), except
260
+ # for a slash command (plan 020.00), which the engine answers.
186
261
  post "/api/copilot" do
187
262
  halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
188
263
 
189
264
  turn = copilot.say(request.body.read)
190
- answer_later(turn)
265
+ answer_later(turn) unless turn.slash?
191
266
  status 202
192
267
  json(turn.wire)
193
268
  rescue Live::Copilot::Invalid => e
@@ -245,15 +320,39 @@ module OpenLanes
245
320
  status 204
246
321
  end
247
322
 
248
- get "/api/plans/:ordinal/files/:name" do
249
- name = PLAN_FILES.find { it == params[:name] } or halt 404, json(error: "not a plan file: #{params[:name]}")
250
- ordinal = Plans::Ordinal.parse(params[:ordinal]) or halt 404, json(error: "not a plan number")
251
- subject = Plans.tree(context.plans_dir).find(ordinal) or halt 404, json(error: "there is no plan #{ordinal}")
252
- path = File.join(subject.feature.path, name)
253
- 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
254
329
 
330
+ get "/api/plans/:ordinal/files/:name" do
331
+ file = plan_files.read(params[:ordinal], params[:name])
332
+ headers "ETag" => file.etag
255
333
  content_type "text/markdown", charset: "utf-8"
256
- 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)
257
356
  end
258
357
 
259
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,10 +23,9 @@ 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.
@@ -43,9 +42,15 @@ module OpenLanes
43
42
  # @!attribute [r] chat
44
43
  # @return [OpenLanes::Live::Chat, nil] the chat between machines (plan 006),
45
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
46
51
  # @!attribute [r] stopping
47
52
  # @return [Concurrent::AtomicBoolean] set when the server shuts down, so
48
- # open streams end within a tick
53
+ # open streams end on the `stop` nudge that follows
49
54
  Context = Data.define(:stores,
50
55
  :keys,
51
56
  :plans_dir,
@@ -53,16 +58,17 @@ module OpenLanes
53
58
  :csrf,
54
59
  :dist,
55
60
  :node,
56
- :tick,
57
61
  :ping_every,
58
62
  :lifetime,
59
63
  :demo,
60
64
  :copilot,
61
65
  :background,
62
66
  :chat,
67
+ :listen,
68
+ :credentials,
63
69
  :stopping) do
64
- def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil, copilot: nil, chat: nil,
65
- background: Context::THREAD, 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)
66
72
  super
67
73
  end
68
74
 
@@ -31,13 +31,16 @@ module OpenLanes
31
31
 
32
32
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
33
33
  # @param keys [OpenLanes::Live::Keys] this node's
34
- # @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
35
37
  # @param interval [Float]
36
38
  # @param clock [Proc] wall time
37
- 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 })
38
40
  @store = store
39
41
  @keys = keys
40
- @board = board
42
+ @node = node
43
+ @board = board || DemoBoard.new(node:)
41
44
  @interval = interval
42
45
  @clock = clock
43
46
  @seqs = {}
@@ -53,7 +56,7 @@ module OpenLanes
53
56
  # same board whatever the last one answered or stopped.
54
57
  #
55
58
  # @return [void]
56
- def reset = @mutex.synchronize { @board = DemoBoard.new }
59
+ def reset = @mutex.synchronize { @board = DemoBoard.new(node: @node) }
57
60
 
58
61
  # Publish and answer every {INTERVAL}, on a thread of its own.
59
62
  #
@@ -75,8 +78,9 @@ module OpenLanes
75
78
  def stop
76
79
  @running = false
77
80
  @thread&.join(@interval * 2)
78
- @store.del(@keys.snapshot, @keys.snapshot(other))
79
- @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)
80
84
  rescue Live::Unavailable
81
85
  nil
82
86
  end
@@ -91,6 +95,9 @@ module OpenLanes
91
95
  @board.advance(@clock.call)
92
96
  publish(@keys.node, @board.me)
93
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")
94
101
  replies
95
102
  end
96
103
  rescue Live::Unavailable => e
@@ -100,16 +107,19 @@ module OpenLanes
100
107
 
101
108
  private
102
109
 
103
- # @return [String] the pretend other machine's node name
104
- 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}" }
105
112
 
106
113
  # @return [void]
107
114
  def publish(name, snapshot)
108
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)
109
119
  json = JSON.generate(snapshot)
110
- @store.multi do |tx|
120
+ Live::EventBus.for(@store).transaction do |tx|
111
121
  tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
112
- 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)
113
123
  tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
114
124
  end
115
125
  end
@@ -141,9 +151,14 @@ module OpenLanes
141
151
  command = Live::Command.parse(entry.fields["json"].to_s)
142
152
  first = @store.hget(@keys.done, command.id)
143
153
  reply = first ? parse(first) : outcome(command)
144
- @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|
145
159
  tx.hset(@keys.done, reply.id, reply.to_json) unless first
146
- 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
147
162
  tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
148
163
  end
149
164
  reply
@@ -162,8 +177,8 @@ module OpenLanes
162
177
  # @return [OpenLanes::Live::Reply]
163
178
  def refuse(entry, reason)
164
179
  reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
165
- @store.multi do |tx|
166
- 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)
167
182
  tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
168
183
  end
169
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]
@@ -10,9 +10,15 @@ module OpenLanes
10
10
  # conversation with the copilot (W17), and a `: ping` comment so proxies and the
11
11
  # browser keep the connection open.
12
12
  #
13
- # Polled rather than blocked on: a blocking read would hold the thread's
14
- # connection for the length of the wait, and the board has to be looked
15
- # at every tick anyway.
13
+ # Pushed rather than polled: the stream holds a subscription of its own
14
+ # to {Live::Keys#news} and sleeps on it until a writer nudges ({Live::News}),
15
+ # then reads Redis exactly as it did when it looked every quarter second.
16
+ # The nudge carries no data, so the cursors below are untouched by it.
17
+ # It subscribes before its first read, so a write that lands between
18
+ # the two is read twice rather than missed. With no nudge it still looks
19
+ # once per ping, which is how it notices what nobody announces: a node
20
+ # whose snapshot expired, an app whose runner was killed. One
21
+ # subscription per stream, closed when the stream ends.
16
22
  #
17
23
  # A reply carries its stream id as the event's `id:`, so a browser that
18
24
  # reconnects sends `Last-Event-ID` and gets what it missed. The stream
@@ -37,15 +43,31 @@ module OpenLanes
37
43
  # the boundary and the stream everything after. A turn written between a
38
44
  # read the page started on its own and the stream opening fell in the
39
45
  # gap between the two and was never shown.
46
+ #
47
+ # The apps running on this machine (plan 012) open the stream as one
48
+ # `apps` event, sent only when there is one, so a project with no app
49
+ # gets the stream it always had; the page starts each stream with no
50
+ # apps. After that each process whose status changed comes as an `app`
51
+ # event. A process whose plan's entry vanished (its runner
52
+ # stopped or died) is sent once more as `stopped`, so the page never
53
+ # keeps a tab for an app that is gone.
40
54
  class Events
55
+ # How often a stream without a subscription looks again: the polling
56
+ # interval every stream used before nudges.
57
+ POLL = 0.25
58
+
59
+ # Block on the subscription for the next nudge, or, with none, poll.
60
+ WAIT = ->(news, seconds) { news ? news.next_message(seconds) : sleep([seconds, POLL].min) }
61
+
41
62
  # @param context [OpenLanes::Web::Context]
42
63
  # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
43
64
  # @param last_id [String, nil] the `Last-Event-ID` the browser sent
44
65
  # @param page [String, nil] the page's own id, `?page=` on the stream, for its chat
45
66
  # @param clock [Proc] seconds, monotonic
46
- # @param sleeper [Proc] waits the given seconds between ticks
67
+ # @param waiter [Proc] given the subscription and at most how many
68
+ # seconds to wait, returns once a nudge arrives or the time is up
47
69
  def initialize(context:, store:, last_id: nil, page: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
48
- sleeper: ->(seconds) { sleep seconds })
70
+ waiter: WAIT)
49
71
  @context = context
50
72
  @store = store
51
73
  @keys = context.keys
@@ -54,7 +76,7 @@ module OpenLanes
54
76
  @chat = context.chat
55
77
  @page = page.to_s.match?(Live::Chat::PAGE) ? page : nil
56
78
  @clock = clock
57
- @sleeper = sleeper
79
+ @waiter = waiter
58
80
  end
59
81
 
60
82
  # Write events to +out+ until the browser leaves, the server stops, or
@@ -64,6 +86,7 @@ module OpenLanes
64
86
  # @return [void]
65
87
  def run(out)
66
88
  @chat&.attach(@page) if @page
89
+ news = subscribe
67
90
  started = @clock.call
68
91
  pinged = started
69
92
  cursor = @last_id || newest_reply
@@ -73,20 +96,23 @@ module OpenLanes
73
96
  # The page is attached, so every chat event from here on is queued for
74
97
  # it: a `GET /api/chat` the page starts on this misses nothing.
75
98
  out << self.class.frame("chat_cursor", {}) if @chat && @page && !out.closed?
99
+ apps = open_apps(out)
76
100
  seqs = nil
77
101
  until over?(out, started)
78
102
  seqs = board(out, seqs)
103
+ apps = app_news(out, apps)
79
104
  cursor = replies(out, cursor)
80
105
  said = copilot(out, said)
81
106
  chat(out)
82
107
  pinged = ping(out) if @clock.call - pinged >= @context.ping_every
83
- @sleeper.call(@context.tick)
108
+ wait(news, started, pinged)
84
109
  end
85
110
  rescue Live::Unavailable => e
86
111
  # Ending the stream is the right answer: the page reconnects with
87
112
  # backoff and shows itself as reconnecting meanwhile.
88
113
  out << ": #{e.message.tr("\n", " ")}\n\n"
89
114
  ensure
115
+ news&.close
90
116
  @chat&.detach(@page) if @page
91
117
  end
92
118
 
@@ -98,11 +124,45 @@ module OpenLanes
98
124
 
99
125
  private
100
126
 
127
+ # The stream's subscription to nudges, or nil to poll. A Redis user
128
+ # whose ACL withholds SUBSCRIBE got a working polled board before
129
+ # nudges existed, and ending the stream instead would leave its page
130
+ # reconnecting forever. A Redis that is gone fails the first read
131
+ # just after, and ends the stream there.
132
+ #
133
+ # @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber, nil]
134
+ def subscribe
135
+ Live::News.subscribe(store: @store, keys: @keys)
136
+ rescue Live::Unavailable => e
137
+ UI.log("events: no nudges, polling every #{POLL}s: #{e.message}", level: :warn)
138
+ nil
139
+ end
140
+
101
141
  # @return [Boolean]
102
142
  def over?(out, started)
103
143
  out.closed? || @context.stopping.true? || (@context.lifetime && @clock.call - started >= @context.lifetime)
104
144
  end
105
145
 
146
+ # Sleep until a nudge, the next ping, or the end of the stream's
147
+ # lifetime, whichever comes first. One nudge, one look: a nudge that
148
+ # arrives while the stream is reading stays queued and wakes the next
149
+ # wait at once, so nothing written during a look is missed. Queued
150
+ # nudges are not drained first because the drain costs more than it
151
+ # saves: redis-client cannot poll without waiting, so emptying the
152
+ # queue costs {Live::Subscriber::SHORTEST} on every wake, ten times
153
+ # what an extra look costs, and a tick nudges once or twice at most.
154
+ #
155
+ # @param news [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
156
+ # @param started [Float]
157
+ # @param pinged [Float]
158
+ # @return [void]
159
+ def wait(news, started, pinged)
160
+ now = @clock.call
161
+ limits = [@context.ping_every - (now - pinged)]
162
+ limits << (@context.lifetime - (now - started)) if @context.lifetime
163
+ @waiter.call(news, [limits.min, 0].max)
164
+ end
165
+
106
166
  # @return [Array] the seqs now, after sending the board if they changed
107
167
  def board(out, seqs)
108
168
  board = Live.board(store: @store, keys: @keys)
@@ -111,6 +171,25 @@ module OpenLanes
111
171
  now
112
172
  end
113
173
 
174
+ # @return [Hash{Array(String, String) => Hash}] what was sent, by plan and process
175
+ def open_apps(out)
176
+ now = live_apps
177
+ out << self.class.frame("apps", { apps: now.values }) unless now.empty? || out.closed?
178
+ now
179
+ end
180
+
181
+ # @param sent [Hash{Array(String, String) => Hash}]
182
+ # @return [Hash{Array(String, String) => Hash}]
183
+ def app_news(out, sent)
184
+ now = live_apps
185
+ now.each { |key, app| out << self.class.frame("app", app) unless sent[key] == app }
186
+ sent.except(*now.keys).each_value { out << self.class.frame("app", it.merge("state" => "stopped")) }
187
+ now
188
+ end
189
+
190
+ # @return [Hash{Array(String, String) => Hash}]
191
+ def live_apps = Apps::Listing.read(store: @store, keys: @keys).to_h { [[it["plan"], it["process"]], it] }
192
+
114
193
  # @return [String] the last reply id sent
115
194
  def replies(out, cursor)
116
195
  Live.replies(store: @store, keys: @keys, after: cursor).each do |id, reply|