openlanes 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +62 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +24 -3
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +32 -10
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +26 -2
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -454
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -64
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +94 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1229 -116
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +107 -0
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +38 -105
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +236 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +39 -11
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +665 -0
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +221 -31
  182. data/lib/openlanes/live/copilot/brief.rb +128 -0
  183. data/lib/openlanes/live/copilot/cards.rb +226 -0
  184. data/lib/openlanes/live/copilot/operator.rb +259 -0
  185. data/lib/openlanes/live/copilot/slash.rb +82 -0
  186. data/lib/openlanes/live/copilot.rb +290 -0
  187. data/lib/openlanes/live/event_bus/base.rb +52 -0
  188. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  189. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  190. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  191. data/lib/openlanes/live/event_bus.rb +29 -0
  192. data/lib/openlanes/live/interviewer.rb +146 -0
  193. data/lib/openlanes/live/keys.rb +114 -0
  194. data/lib/openlanes/live/mail.rb +143 -0
  195. data/lib/openlanes/live/memory_store.rb +36 -1
  196. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  197. data/lib/openlanes/live/news.rb +61 -0
  198. data/lib/openlanes/live/node.rb +20 -0
  199. data/lib/openlanes/live/outage.rb +225 -0
  200. data/lib/openlanes/live/outage_report.rb +89 -0
  201. data/lib/openlanes/live/publisher.rb +42 -2
  202. data/lib/openlanes/live/run_record.rb +39 -1
  203. data/lib/openlanes/live/run_state.rb +19 -2
  204. data/lib/openlanes/live/session.rb +38 -4
  205. data/lib/openlanes/live/snapshot.rb +103 -42
  206. data/lib/openlanes/live/stations.rb +12 -8
  207. data/lib/openlanes/live/store.rb +36 -1
  208. data/lib/openlanes/live/subscriber.rb +93 -0
  209. data/lib/openlanes/live.rb +71 -1
  210. data/lib/openlanes/plans/blocked_file.rb +117 -0
  211. data/lib/openlanes/plans/choices.rb +228 -0
  212. data/lib/openlanes/plans/dependencies.rb +304 -0
  213. data/lib/openlanes/plans/feature.rb +75 -16
  214. data/lib/openlanes/plans/journal.rb +169 -0
  215. data/lib/openlanes/plans/last_known_state.rb +82 -0
  216. data/lib/openlanes/plans/lock.rb +171 -0
  217. data/lib/openlanes/plans/mailbox.rb +145 -4
  218. data/lib/openlanes/plans/plan_state.rb +590 -9
  219. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  220. data/lib/openlanes/plans/review_label.rb +139 -0
  221. data/lib/openlanes/plans/spec.rb +239 -3
  222. data/lib/openlanes/plans/state_machine.rb +63 -30
  223. data/lib/openlanes/plans/status.rb +60 -11
  224. data/lib/openlanes/plans/subject.rb +4 -0
  225. data/lib/openlanes/plans/tree.rb +101 -10
  226. data/lib/openlanes/plans/verdict.rb +124 -0
  227. data/lib/openlanes/plans.rb +19 -0
  228. data/lib/openlanes/presentation/api_docs.rb +76 -0
  229. data/lib/openlanes/presentation/documentation.rb +105 -12
  230. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  231. data/lib/openlanes/presentation/reporter.rb +66 -7
  232. data/lib/openlanes/presentation.rb +20 -1
  233. data/lib/openlanes/setup/check.rb +16 -0
  234. data/lib/openlanes/setup/machine.rb +179 -0
  235. data/lib/openlanes/setup.rb +34 -0
  236. data/lib/openlanes/support/clock.rb +57 -0
  237. data/lib/openlanes/support/config.rb +318 -6
  238. data/lib/openlanes/support/log.rb +206 -0
  239. data/lib/openlanes/support/refusal.rb +15 -0
  240. data/lib/openlanes/support/runtime.rb +61 -0
  241. data/lib/openlanes/support/spawner.rb +51 -0
  242. data/lib/openlanes/support/ui.rb +110 -21
  243. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  244. data/lib/openlanes/vcs/github.rb +362 -12
  245. data/lib/openlanes/vcs/label_migration.rb +132 -0
  246. data/lib/openlanes/vcs/label_sync.rb +75 -0
  247. data/lib/openlanes/vcs/publisher.rb +287 -7
  248. data/lib/openlanes/vcs/refresh.rb +337 -0
  249. data/lib/openlanes/vcs/rename_date.rb +83 -0
  250. data/lib/openlanes/vcs/review.rb +66 -0
  251. data/lib/openlanes/vcs/worktree.rb +210 -14
  252. data/lib/openlanes/vcs.rb +18 -0
  253. data/lib/openlanes/version.rb +1 -1
  254. data/lib/openlanes/web/admin_auth.rb +104 -0
  255. data/lib/openlanes/web/app.rb +213 -15
  256. data/lib/openlanes/web/context.rb +33 -9
  257. data/lib/openlanes/web/demo.rb +39 -14
  258. data/lib/openlanes/web/demo_board.rb +63 -5
  259. data/lib/openlanes/web/events.rb +137 -9
  260. data/lib/openlanes/web/launcher.rb +70 -8
  261. data/lib/openlanes/web/listen.rb +133 -0
  262. data/lib/openlanes/web/plan_files.rb +136 -0
  263. data/lib/openlanes/web/request_log.rb +108 -0
  264. data/lib/openlanes/web/server.rb +31 -10
  265. data/lib/openlanes/web/stores.rb +11 -0
  266. data/lib/openlanes/web.rb +19 -4
  267. data/lib/openlanes.rb +45 -7
  268. data/openlanes.gemspec +9 -4
  269. data/schemas/journal-event.schema.json +72 -0
  270. data/schemas/plan-state.schema.json +163 -2
  271. data/skills/openlanes/SKILL.md +2 -2
  272. data/skills/openlanes-documents/SKILL.md +4 -4
  273. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  274. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  275. data/skills/openlanes-documents/references/plan-template.md +12 -4
  276. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  277. data/skills/openlanes-run/SKILL.md +5 -5
  278. data/skills/openlanes-spec/SKILL.md +1 -1
  279. data/skills/openlanes-status/SKILL.md +1 -1
  280. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  281. data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
  282. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  283. data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
  284. data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
  285. data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
  286. data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
  287. data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
  288. data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
  289. data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
  290. data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
  291. data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
  292. data/web/dist/assets/index-Cp86u27u.css +1 -0
  293. data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
  294. data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
  295. data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
  296. data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
  297. data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
  298. data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
  299. data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
  300. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  301. data/web/dist/index.html +3 -3
  302. data/web/src/api/fixtures/board.json +203 -28
  303. metadata +1053 -881
  304. data/bin/factory-e2e +0 -157
  305. data/bin/plan-number +0 -164
  306. data/bin/setup +0 -26
  307. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  308. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  309. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  310. data/web/dist/assets/index-CI74IkWP.js +0 -198
  311. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  312. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  313. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  319. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  320. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  321. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  322. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  323. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  324. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  325. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  326. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  327. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  328. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  329. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,665 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module OpenLanes
9
+ module Live
10
+ # Chat between machines (plan 006): one per API server, relaying Redis
11
+ # pub/sub to the pages it serves. Nothing said is stored (D2): a message
12
+ # is published, every API server with a page in that chat hears it, and
13
+ # each hands it to its pages over the event stream they already hold
14
+ # (`event: chat`). The page sends by POST, as it does everything else.
15
+ #
16
+ # Two kinds of channel, named by {Keys}: a room per chat
17
+ # ({Keys#chat_room}), which every server takes through one pattern, and an
18
+ # inbox per machine ({Keys#chat_inbox}), where invites and their answers
19
+ # arrive. Both carry the Redis database in their name, because pub/sub
20
+ # does not.
21
+ #
22
+ # A participant is one page, named by the id the page keeps in its
23
+ # session storage, so a reload is the same participant. The roster is not
24
+ # stored either: a joiner announces itself, everyone already there answers
25
+ # `here`, and every server repeats `here` for its own participants each
26
+ # {HEARTBEAT}, which is also how one whose server died is noticed.
27
+ #
28
+ # The listening thread calls {#step}; the suite calls it by hand.
29
+ class Chat
30
+ # A request the page may not make as written: the API server's 422.
31
+ class Invalid < Error; end
32
+
33
+ # An invite already answered, expired or never seen: the API server's 409.
34
+ class Settled < Error; end
35
+
36
+ # Characters one message may carry (D10).
37
+ LIMIT = 4000
38
+
39
+ # Characters a name may carry.
40
+ NAME_LIMIT = 60
41
+
42
+ # Seconds an invite waits for an answer (D9).
43
+ INVITE_TTL = 60
44
+
45
+ # Seconds a page may be without its event stream before it counts as
46
+ # having left (D6): long enough for a reload or a dropped connection.
47
+ GRACE = 10
48
+
49
+ # Seconds between a server's `here` for each of its participants.
50
+ HEARTBEAT = 20
51
+
52
+ # Seconds without a word from a participant before it is taken as gone.
53
+ FORGET_AFTER = HEARTBEAT * 3
54
+
55
+ # Events kept for a page that is not reading them.
56
+ BACKLOG = 200
57
+
58
+ # Seconds the listening thread waits for a message before it looks
59
+ # after expiries and heartbeats.
60
+ POLL = 0.25
61
+
62
+ # A page id: what the page mints for itself.
63
+ PAGE = /\A[A-Za-z0-9_-]{8,64}\z/
64
+
65
+ # A chat or invite id.
66
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
67
+
68
+ # A node id, 24 hex digits.
69
+ MACHINE = /\A\h{24}\z/
70
+
71
+ # What a page tells the room it is doing.
72
+ ROOM_KINDS = %w[join here say idle back leave].freeze
73
+
74
+ # One page this server serves.
75
+ Page = Struct.new(:id, :events, :streams, :gone_at, :chat, :name, :idle)
76
+
77
+ # @param stores [#current] a store per thread, as {Web::Stores} gives
78
+ # @param keys [OpenLanes::Live::Keys]
79
+ # @param machine [String] this node's id
80
+ # @param label [String] `user@host`, what other pages show for it
81
+ # @param clock [Proc] seconds, monotonic
82
+ # @param wall [Proc] the time, for what the page shows
83
+ def initialize(stores:, keys:, machine:, label:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
84
+ wall: -> { Time.now })
85
+ @stores = stores
86
+ @keys = keys
87
+ @machine = machine
88
+ @label = label
89
+ @clock = clock
90
+ @wall = wall
91
+ @lock = Monitor.new
92
+ @pages = {}
93
+ @rooms = Hash.new { |h, k| h[k] = {} }
94
+ @incoming = {}
95
+ @outgoing = {}
96
+ @beat = nil
97
+ @running = false
98
+ @unavailable = nil
99
+ end
100
+
101
+ # @return [String]
102
+ attr_reader :machine
103
+
104
+ # ---- the listening thread ---------------------------------------------
105
+
106
+ # Listen on a thread of its own until {#stop}.
107
+ #
108
+ # @return [self]
109
+ def start
110
+ @running = true
111
+ @thread = Thread.new do
112
+ while @running
113
+ listen(POLL) or sleep 1
114
+ end
115
+ end
116
+ self
117
+ end
118
+
119
+ # Say goodbye for every page here, then stop listening.
120
+ #
121
+ # @return [void]
122
+ def stop
123
+ @running = false
124
+ @thread&.join(POLL * 4)
125
+ pages = @lock.synchronize { @pages.values.select(&:chat) }
126
+ pages.each { leave(page: it.id) }
127
+ rescue Unavailable
128
+ nil
129
+ ensure
130
+ close_subscriber
131
+ end
132
+
133
+ # Handle whatever has arrived, waiting up to +timeout+ for the first,
134
+ # then expire, prune and heartbeat, and nudge the event streams when a
135
+ # page was handed something to show.
136
+ #
137
+ # @param timeout [Float]
138
+ # @return [void]
139
+ def step(timeout = 0)
140
+ sub = subscriber
141
+ message = sub.next_message(timeout)
142
+ while message
143
+ receive(*message)
144
+ message = sub.next_message(0)
145
+ end
146
+ housekeep
147
+ nudge
148
+ end
149
+
150
+ # {#step}, kept from raising when Redis refuses the subscription (an ACL
151
+ # without `PSUBSCRIBE` answers `NOPERM`) or drops the connection. The
152
+ # board does not need the chat, so the server carries on serving it:
153
+ # one warning per outage, `unavailable` in {#state} until a step works
154
+ # again, and a fresh subscription on the next try.
155
+ #
156
+ # @param timeout [Float]
157
+ # @return [Boolean] whether the step went through
158
+ def listen(timeout = 0)
159
+ step(timeout)
160
+ @lock.synchronize { @unavailable = nil }
161
+ true
162
+ rescue Unavailable => e
163
+ close_subscriber
164
+ first = @lock.synchronize { @unavailable.nil?.tap { @unavailable = e.message } }
165
+ warn_unavailable(e.message) if first
166
+ false
167
+ end
168
+
169
+ # @return [String, nil] why the chat cannot listen, while it cannot
170
+ def unavailable = @lock.synchronize { @unavailable }
171
+
172
+ # ---- the event stream -------------------------------------------------
173
+
174
+ # A page's event stream opened.
175
+ #
176
+ # @param id [String]
177
+ # @return [void]
178
+ def attach(id)
179
+ @lock.synchronize do
180
+ page = page!(id)
181
+ page.streams += 1
182
+ page.gone_at = nil
183
+ end
184
+ end
185
+
186
+ # A page's event stream closed. It has {GRACE} to come back.
187
+ #
188
+ # @param id [String]
189
+ # @return [void]
190
+ def detach(id)
191
+ @lock.synchronize do
192
+ page = @pages[id] or return
193
+ page.streams = [page.streams - 1, 0].max
194
+ page.gone_at = now if page.streams.zero?
195
+ end
196
+ end
197
+
198
+ # Every event for +id+ since the last call, oldest first.
199
+ #
200
+ # @param id [String]
201
+ # @return [Array<Hash>] `ChatEvent`s
202
+ def drain(id) = @lock.synchronize { @pages[id]&.events&.slice!(0..) || [] }
203
+
204
+ # ---- what the page asks -----------------------------------------------
205
+
206
+ # `ChatState`: the chat this page is in, the invites open to this
207
+ # machine, the ones this page is waiting on, and, while Redis refuses
208
+ # the subscription, why the chat is unavailable.
209
+ #
210
+ # @param page [String]
211
+ # @return [Hash]
212
+ def state(page:)
213
+ @lock.synchronize do
214
+ me = page!(page)
215
+ chat = me.chat && { id: me.chat, me: participant(me), participants: roster(me.chat) }
216
+ { chat:,
217
+ invites: @incoming.values.map { it[:invite] },
218
+ outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) },
219
+ unavailable: @unavailable }
220
+ end
221
+ end
222
+
223
+ # Start a chat and invite +machine+ to it, leaving any chat the page was in.
224
+ #
225
+ # @param page [String]
226
+ # @param name [String]
227
+ # @param machine [String]
228
+ # @param label [String] the machine's `user@host`
229
+ # @return [Hash] `{chat:, invite:}`
230
+ def start_chat(page:, name:, machine:, label:)
231
+ name = name!(name)
232
+ target!(machine)
233
+ chat = SecureRandom.uuid
234
+ enter(page, chat, name)
235
+ { chat:, invite: invite(page:, machine:, label:)[:id] }
236
+ end
237
+
238
+ # Invite +machine+ into the chat this page is in (D5).
239
+ #
240
+ # @return [Hash] `{id, machine, label}`
241
+ def invite(page:, machine:, label:)
242
+ target!(machine)
243
+ me, chat = @lock.synchronize do
244
+ me = page!(page)
245
+ raise Invalid, "start a chat before inviting anyone" unless me.chat
246
+
247
+ [participant(me), me.chat]
248
+ end
249
+ id = SecureRandom.uuid
250
+ wire = { id:, chat:, from: me, machine:, label:, expires_at: (@wall.call + INVITE_TTL).utc.iso8601 }
251
+ # The receivers stay unknown until PUBLISH says: an `absent` heard
252
+ # before then is counted, never compared against a count not yet set.
253
+ sent = { id:, page:, machine:, label:, deadline: now + INVITE_TTL, receivers: nil, absent: 0 }
254
+ @lock.synchronize { @outgoing[id] = sent }
255
+ receivers = publish(@keys.chat_inbox(machine, db:), kind: "invite", invite: wire)
256
+ nobody = @lock.synchronize do
257
+ sent[:receivers] = receivers
258
+ sent[:absent] >= receivers
259
+ end
260
+ expire(id) if nobody
261
+ sent.slice(:id, :machine, :label)
262
+ end
263
+
264
+ # Accept an invite open to this machine and join its chat (D4: leaving
265
+ # the one the page was in).
266
+ #
267
+ # @return [Hash] `{chat:}`
268
+ def accept(page:, invite:, name:)
269
+ name = name!(name)
270
+ open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
271
+ settle(open[:invite], "accepted")
272
+ enter(page, open[:invite][:chat], name)
273
+ { chat: open[:invite][:chat] }
274
+ end
275
+
276
+ # @return [void]
277
+ def decline(page:, invite:)
278
+ page!(page)
279
+ open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
280
+ settle(open[:invite], "declined")
281
+ end
282
+
283
+ # Stop Chat (D6). Nothing happens for a page in no chat.
284
+ #
285
+ # @return [void]
286
+ def leave(page:)
287
+ me = @lock.synchronize do
288
+ me = @pages[page]
289
+ next nil unless me&.chat
290
+
291
+ gone = [participant(me), me.chat]
292
+ @rooms[me.chat].delete(me.id)
293
+ @rooms.delete(me.chat) if local(me.chat).one?
294
+ me.chat = nil
295
+ me.idle = false
296
+ gone
297
+ end
298
+ publish(@keys.chat_room(me.last, db:), kind: "leave", from: me.first) if me
299
+ end
300
+
301
+ # @return [Hash] `ChatMessage` as sent
302
+ def say(page:, id:, text:)
303
+ raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.to_s.match?(Command::ID)
304
+
305
+ text = text.to_s.strip
306
+ raise Invalid, "a message needs text" if text.empty?
307
+ raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
308
+
309
+ me, chat = in_chat(page)
310
+ said = { id:, text:, at: @wall.call.utc.iso8601 }
311
+ publish(@keys.chat_room(chat, db:), kind: "say", from: me, **said)
312
+ said.merge(who: me)
313
+ end
314
+
315
+ # Idle or back (D7). Only a change is announced.
316
+ #
317
+ # @return [void]
318
+ def presence(page:, idle:)
319
+ idle = idle == true
320
+ changed = @lock.synchronize do
321
+ me = page!(page)
322
+ next nil if !me.chat || me.idle == idle
323
+
324
+ me.idle = idle
325
+ [participant(me), me.chat]
326
+ end
327
+ publish(@keys.chat_room(changed.last, db:), kind: idle ? "idle" : "back", from: changed.first) if changed
328
+ end
329
+
330
+ private
331
+
332
+ # ---- receiving --------------------------------------------------------
333
+
334
+ # @return [void]
335
+ def receive(channel, payload)
336
+ data = JSON.parse(payload)
337
+ return unless data.is_a?(Hash)
338
+
339
+ if channel == inbox then mail(data)
340
+ elsif (chat = channel.delete_prefix(room_prefix)) != channel then room(chat, data)
341
+ end
342
+ rescue JSON::ParserError
343
+ nil
344
+ end
345
+
346
+ # @return [void]
347
+ def room(chat, data)
348
+ kind = data["kind"]
349
+ who = person(data["from"])
350
+ return unless ROOM_KINDS.include?(kind) && who
351
+
352
+ answer = @lock.synchronize do
353
+ next nil unless @rooms.key?(chat) && local(chat).any?
354
+
355
+ case kind
356
+ when "leave"
357
+ @rooms[chat].delete(who[:id])
358
+ post(chat, kind: "left", chat:, who:, participants: roster(chat))
359
+ when "say"
360
+ seen(chat, who)
361
+ post(chat, kind: "said", chat:, who:, id: data["id"].to_s, text: data["text"].to_s.slice(0, LIMIT), at: data["at"].to_s)
362
+ when "here"
363
+ fresh = seen(chat, who)
364
+ post(chat, kind: "roster", chat:, participants: roster(chat)) if fresh
365
+ else
366
+ seen(chat, who)
367
+ post(chat, kind: kind == "join" ? "joined" : kind, chat:, who:, participants: roster(chat))
368
+ end
369
+ kind == "join" ? local(chat).reject { it.id == who[:id] }.map { participant(it) } : []
370
+ end
371
+ Array(answer).each { publish(@keys.chat_room(chat, db:), kind: "here", from: it) }
372
+ end
373
+
374
+ # @return [void]
375
+ def mail(data)
376
+ case data["kind"]
377
+ when "invite" then invited(data["invite"])
378
+ when "absent" then absent(data["invite"].to_s)
379
+ when "settled" then settled(data["invite"].to_s, data["answer"].to_s)
380
+ end
381
+ end
382
+
383
+ # @return [void]
384
+ def invited(invite)
385
+ invite = invitation(invite) or return
386
+ reply = @lock.synchronize do
387
+ next invite[:from][:machine] if @pages.values.none? { it.streams.positive? }
388
+
389
+ @incoming[invite[:id]] = { invite:, deadline: now + INVITE_TTL + GRACE }
390
+ @pages.each_value { push(it, kind: "invited", invite:) }
391
+ nil
392
+ end
393
+ publish(@keys.chat_inbox(reply, db:), kind: "absent", invite: invite[:id]) if reply
394
+ end
395
+
396
+ # A server on the invited machine with no page open. When every one
397
+ # that heard the invite says so, nobody is there to answer (D9).
398
+ #
399
+ # @return [void]
400
+ def absent(id)
401
+ nobody = @lock.synchronize do
402
+ sent = @outgoing[id] or next false
403
+ sent[:absent] += 1
404
+ sent[:receivers] && sent[:absent] >= sent[:receivers]
405
+ end
406
+ expire(id) if nobody
407
+ end
408
+
409
+ # @return [void]
410
+ def settled(id, answer)
411
+ @lock.synchronize do
412
+ if @incoming.delete(id)
413
+ @pages.each_value { push(it, kind: "invite_closed", invite: id, answer:) }
414
+ end
415
+ sent = @outgoing.delete(id)
416
+ told(sent, answer) if sent
417
+ end
418
+ end
419
+
420
+ # The inviting page hears a decline or a silence; an accept it hears as a join.
421
+ #
422
+ # @return [void]
423
+ def told(sent, answer)
424
+ kind = { "declined" => "declined", "expired" => "unanswered" }[answer] or return
425
+ page = sent && @pages[sent[:page]] or return
426
+ push(page, kind:, invite: sent[:id], machine: sent[:machine], label: sent[:label])
427
+ end
428
+
429
+ # ---- settling invites -------------------------------------------------
430
+
431
+ # The first answer wins, from whichever page or server gives it.
432
+ #
433
+ # @return [void]
434
+ # @raise [Settled] when another answer came first
435
+ def settle(invite, answer)
436
+ unless store.set(@keys.chat_answer(invite[:id]), answer, ex: INVITE_TTL * 2, nx: true)
437
+ @lock.synchronize { @incoming.delete(invite[:id]) }
438
+ raise Settled, "that invite was already answered"
439
+ end
440
+ publish(inbox, kind: "settled", invite: invite[:id], answer:)
441
+ publish(@keys.chat_inbox(invite[:from][:machine], db:), kind: "settled", invite: invite[:id], answer:)
442
+ end
443
+
444
+ # Nobody answered in time, or nobody could. The invited machine's
445
+ # pages close their modals; this page hears "did not answer".
446
+ #
447
+ # @return [void]
448
+ def expire(id)
449
+ sent = @lock.synchronize { @outgoing[id] } or return
450
+ if store.set(@keys.chat_answer(id), "expired", ex: INVITE_TTL * 2, nx: true)
451
+ publish(@keys.chat_inbox(sent[:machine], db:), kind: "settled", invite: id, answer: "expired")
452
+ @lock.synchronize { told(@outgoing.delete(id), "expired") }
453
+ else
454
+ # Answered after all: the answer's own message says how.
455
+ answer = store.get(@keys.chat_answer(id)).to_s
456
+ @lock.synchronize { told(@outgoing.delete(id), answer) if @outgoing.key?(id) }
457
+ end
458
+ end
459
+
460
+ # ---- housekeeping -----------------------------------------------------
461
+
462
+ # @return [void]
463
+ def housekeep
464
+ t = now
465
+ gone = @lock.synchronize { @pages.values.select { it.gone_at && t - it.gone_at >= GRACE } }
466
+ gone.each do |page|
467
+ leave(page: page.id)
468
+ @lock.synchronize { @pages.delete(page.id) if page.streams.zero? }
469
+ end
470
+ @lock.synchronize { @outgoing.values.select { t >= it[:deadline] }.map { it[:id] } }.each { expire(it) }
471
+ forget_stale_invites(t)
472
+ prune(t)
473
+ heartbeat(t)
474
+ end
475
+
476
+ # An invite whose inviter's server went away never hears its expiry.
477
+ #
478
+ # @return [void]
479
+ def forget_stale_invites(at)
480
+ @lock.synchronize do
481
+ @incoming.select { |_, open| at >= open[:deadline] }.each_key do |id|
482
+ @incoming.delete(id)
483
+ @pages.each_value { push(it, kind: "invite_closed", invite: id, answer: "expired") }
484
+ end
485
+ end
486
+ end
487
+
488
+ # A participant on another server that has stopped saying `here`.
489
+ #
490
+ # @return [void]
491
+ def prune(at)
492
+ @lock.synchronize do
493
+ @rooms.each do |chat, people|
494
+ mine = local(chat).map(&:id)
495
+ stale = people.reject { |id, p| mine.include?(id) || at - p[:seen] < FORGET_AFTER }
496
+ stale.each_key { people.delete(it) }
497
+ stale.each_value { post(chat, kind: "left", chat:, who: it.except(:seen), participants: roster(chat)) }
498
+ end
499
+ end
500
+ end
501
+
502
+ # @return [void]
503
+ def heartbeat(at)
504
+ return if @beat && at - @beat < HEARTBEAT
505
+
506
+ @beat = at
507
+ mine = @lock.synchronize { @pages.values.select(&:chat).map { [participant(it), it.chat] } }
508
+ mine.each { |who, chat| publish(@keys.chat_room(chat, db:), kind: "here", from: who) }
509
+ end
510
+
511
+ # ---- helpers ----------------------------------------------------------
512
+
513
+ # @return [void]
514
+ def enter(page, chat, name)
515
+ leave(page:)
516
+ who = @lock.synchronize do
517
+ me = page!(page)
518
+ me.chat = chat
519
+ me.name = name
520
+ me.idle = false
521
+ seen(chat, participant(me))
522
+ participant(me)
523
+ end
524
+ publish(@keys.chat_room(chat, db:), kind: "join", from: who)
525
+ end
526
+
527
+ # @return [Array(Hash, String)] the page as a participant, and its chat
528
+ # @raise [Invalid] when it is in none
529
+ def in_chat(page)
530
+ @lock.synchronize do
531
+ me = page!(page)
532
+ raise Invalid, "this page is not in a chat" unless me.chat
533
+
534
+ [participant(me), me.chat]
535
+ end
536
+ end
537
+
538
+ # @return [Boolean] whether the participant is new to the roster
539
+ def seen(chat, who)
540
+ fresh = !@rooms[chat].key?(who[:id])
541
+ @rooms[chat][who[:id]] = who.merge(seen: now)
542
+ fresh
543
+ end
544
+
545
+ # @return [Array<Hash>] `ChatParticipant`s, in the order they arrived
546
+ def roster(chat) = @rooms.fetch(chat, {}).values.map { it.except(:seen) }
547
+
548
+ # @return [Array<Page>] this server's pages in +chat+
549
+ def local(chat) = @pages.values.select { it.chat == chat }
550
+
551
+ # @return [void]
552
+ def post(chat, event) = local(chat).each { push(it, event) }
553
+
554
+ # @return [void]
555
+ def push(page, event)
556
+ page.events << event
557
+ page.events.shift(page.events.size - BACKLOG) if page.events.size > BACKLOG
558
+ @pushed = true
559
+ end
560
+
561
+ # A page's events wait in this process, where an event stream blocked
562
+ # on {Keys#news} cannot see them, so a step that queued any says so on
563
+ # that channel: one nudge per step, however many it queued. Nearly
564
+ # every push happens inside a step, on the listening thread; the one
565
+ # that does not (an invite nobody was there to hear, expired on the
566
+ # request's own thread) is nudged by the next step, {POLL} later.
567
+ #
568
+ # @return [void]
569
+ def nudge
570
+ pushed = @lock.synchronize { @pushed.tap { @pushed = false } }
571
+ News.publish(store:, keys: @keys, kind: "chat") if pushed
572
+ end
573
+
574
+ # @return [Hash] `ChatParticipant`
575
+ def participant(page) = { id: page.id, name: page.name, machine: @machine, label: @label, idle: page.idle }
576
+
577
+ # @return [Page]
578
+ # @raise [Invalid]
579
+ def page!(id)
580
+ raise Invalid, "page must be 8 to 64 letters, digits, - or _" unless id.to_s.match?(PAGE)
581
+
582
+ @lock.synchronize { @pages[id] ||= Page.new(id:, events: [], streams: 0, gone_at: now, chat: nil, name: nil, idle: false) }
583
+ end
584
+
585
+ # @return [String]
586
+ # @raise [Invalid]
587
+ def name!(name)
588
+ name = name.to_s.gsub(/[[:cntrl:]]/, " ").strip
589
+ raise Invalid, "a name is needed" if name.empty?
590
+ raise Invalid, "a name is at most #{NAME_LIMIT} characters" if name.length > NAME_LIMIT
591
+
592
+ name
593
+ end
594
+
595
+ # @return [void]
596
+ # @raise [Invalid]
597
+ def target!(machine)
598
+ raise Invalid, "not a machine id" unless machine.to_s.match?(MACHINE)
599
+ raise Invalid, "this machine cannot invite itself" if machine == @machine
600
+ end
601
+
602
+ # A participant as another server sent it, or nil when it is not one.
603
+ #
604
+ # @return [Hash, nil]
605
+ def person(data)
606
+ return nil unless data.is_a?(Hash) && data["id"].to_s.match?(PAGE)
607
+
608
+ { id: data["id"].to_s, name: data["name"].to_s.slice(0, NAME_LIMIT), machine: data["machine"].to_s,
609
+ label: data["label"].to_s, idle: data["idle"] == true }
610
+ end
611
+
612
+ # An invite as another server sent it, for this machine, or nil.
613
+ #
614
+ # @return [Hash, nil]
615
+ def invitation(data)
616
+ return nil unless data.is_a?(Hash) && data["machine"] == @machine
617
+ return nil unless data["id"].to_s.match?(UUID) && data["chat"].to_s.match?(UUID)
618
+
619
+ from = person(data["from"]) or return nil
620
+ { id: data["id"], chat: data["chat"], from:, machine: @machine, label: data["label"].to_s,
621
+ expires_at: data["expires_at"].to_s }
622
+ end
623
+
624
+ # @return [Integer] receivers
625
+ def publish(channel, data) = EventBus.for(store).publish(channel, JSON.generate(data))
626
+
627
+ # @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
628
+ def subscriber = @subscriber ||= EventBus.for(store).subscribe(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
629
+
630
+ # {UI.log} alone reaches only a log file or a tap, and `openlanes web`
631
+ # sets up neither, so there the one warning an outage gets would be
632
+ # dropped and nobody would learn why the chat went dark. With nothing
633
+ # listening it goes to STDERR instead.
634
+ #
635
+ # @param reason [String]
636
+ # @return [void]
637
+ def warn_unavailable(reason)
638
+ message = "chat unavailable: #{reason}"
639
+ UI.log(message)
640
+ UI.line(message, bullet: "!") unless UI.log_path || UI.log_taps.any?
641
+ end
642
+
643
+ # @return [void]
644
+ def close_subscriber
645
+ @subscriber&.close
646
+ @subscriber = nil
647
+ end
648
+
649
+ # @return [String]
650
+ def inbox = @keys.chat_inbox(@machine, db:)
651
+
652
+ # @return [String]
653
+ def room_prefix = @keys.chat_room("", db:)
654
+
655
+ # @return [Integer]
656
+ def db = @db ||= store.db
657
+
658
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
659
+ def store = @stores.current
660
+
661
+ # @return [Float]
662
+ def now = @clock.call
663
+ end
664
+ end
665
+ end