openlanes 3.0.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 (466) hide show
  1. checksums.yaml +7 -0
  2. data/agents/hansolo-reviewer.md +59 -0
  3. data/agents/lando-broker.md +70 -0
  4. data/agents/leah-researcher.md +60 -0
  5. data/agents/luke-backend.md +73 -0
  6. data/agents/palpatine-planner.md +61 -0
  7. data/agents/r2d2-mechanic.md +57 -0
  8. data/agents/rey-frontend.md +68 -0
  9. data/agents/yoda-writer.md +67 -0
  10. data/bin/factory-e2e +157 -0
  11. data/bin/plan-number +164 -0
  12. data/bin/setup +26 -0
  13. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
  14. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
  15. data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
  16. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
  17. data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
  18. data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
  19. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
  20. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
  21. data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
  22. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
  23. data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
  24. data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
  25. data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
  26. data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
  27. data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
  28. data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
  29. data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
  30. data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
  31. data/evals/cases/lando-broker/unblock-fast.yml +44 -0
  32. data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
  33. data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
  34. data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
  35. data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
  36. data/evals/cases/leah-researcher/research-deep.yml +51 -0
  37. data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
  38. data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
  39. data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
  40. data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
  41. data/evals/cases/leah-researcher/research-fast.yml +55 -0
  42. data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
  43. data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
  44. data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
  45. data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
  46. data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
  47. data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
  48. data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
  49. data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
  50. data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
  51. data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
  52. data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
  53. data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
  54. data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
  55. data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
  56. data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
  57. data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
  58. data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
  59. data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
  60. data/evals/cases/luke-backend/build-medium.yml +68 -0
  61. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
  62. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
  63. data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
  64. data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
  65. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
  66. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
  67. data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
  68. data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
  69. data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
  70. data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
  71. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
  72. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
  73. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
  74. data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
  75. data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
  76. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
  77. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
  78. data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
  79. data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
  80. data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
  81. data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
  82. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
  83. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
  84. data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
  85. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
  86. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
  87. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
  88. data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
  89. data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
  90. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
  91. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
  92. data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
  93. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
  94. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
  95. data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
  96. data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
  97. data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
  98. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
  99. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
  100. data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
  101. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
  102. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
  103. data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
  104. data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
  105. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
  106. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
  107. data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
  108. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
  109. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
  110. data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
  111. data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
  112. data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
  113. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
  114. data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
  115. data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
  116. data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
  117. data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
  118. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
  119. data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
  120. data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
  121. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
  122. data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
  123. data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
  124. data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
  125. data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
  126. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
  127. data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
  128. data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
  129. data/evals/cases/rey-frontend/ui-medium.yml +55 -0
  130. data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
  131. data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
  132. data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
  133. data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
  134. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
  135. data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
  136. data/evals/cases/yoda-writer/spec-medium.yml +40 -0
  137. data/exe/depot +1 -0
  138. data/exe/lanes +1 -0
  139. data/exe/openlanes +32 -0
  140. data/lib/openlanes/adapters/base.rb +69 -0
  141. data/lib/openlanes/adapters/claude.rb +124 -0
  142. data/lib/openlanes/adapters/codex.rb +33 -0
  143. data/lib/openlanes/adapters/model.rb +37 -0
  144. data/lib/openlanes/adapters/pi.rb +22 -0
  145. data/lib/openlanes/adapters/stream.rb +196 -0
  146. data/lib/openlanes/adapters.rb +92 -0
  147. data/lib/openlanes/agents/agent.rb +103 -0
  148. data/lib/openlanes/agents/profile.rb +57 -0
  149. data/lib/openlanes/agents/registry.rb +151 -0
  150. data/lib/openlanes/agents/roster.rb +103 -0
  151. data/lib/openlanes/agents/routing.rb +88 -0
  152. data/lib/openlanes/agents.rb +20 -0
  153. data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
  154. data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
  155. data/lib/openlanes/cli/base.rb +263 -0
  156. data/lib/openlanes/cli/create/create.rb +354 -0
  157. data/lib/openlanes/cli/docs/docs.rb +40 -0
  158. data/lib/openlanes/cli/eval/eval.rb +110 -0
  159. data/lib/openlanes/cli/index/index.rb +40 -0
  160. data/lib/openlanes/cli/init/init.rb +145 -0
  161. data/lib/openlanes/cli/linear/linear.rb +36 -0
  162. data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
  163. data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
  164. data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
  165. data/lib/openlanes/cli/mail/mail.rb +31 -0
  166. data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
  167. data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
  168. data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
  169. data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
  170. data/lib/openlanes/cli/run/run.rb +544 -0
  171. data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
  172. data/lib/openlanes/cli/start/start.rb +110 -0
  173. data/lib/openlanes/cli/state/state.rb +26 -0
  174. data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
  175. data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
  176. data/lib/openlanes/cli/states/states.rb +15 -0
  177. data/lib/openlanes/cli/unblock/unblock.rb +235 -0
  178. data/lib/openlanes/cli/version/version.rb +13 -0
  179. data/lib/openlanes/cli/web/web.rb +100 -0
  180. data/lib/openlanes/cli/worktree/worktree.rb +85 -0
  181. data/lib/openlanes/cli.rb +162 -0
  182. data/lib/openlanes/engine/board.rb +104 -0
  183. data/lib/openlanes/engine/dispatcher.rb +1297 -0
  184. data/lib/openlanes/engine/progress_log.rb +125 -0
  185. data/lib/openlanes/engine/runner.rb +224 -0
  186. data/lib/openlanes/engine/state_file.rb +190 -0
  187. data/lib/openlanes/engine/tally.rb +177 -0
  188. data/lib/openlanes/engine.rb +19 -0
  189. data/lib/openlanes/evals/case.rb +191 -0
  190. data/lib/openlanes/evals/changes.rb +67 -0
  191. data/lib/openlanes/evals/check.rb +224 -0
  192. data/lib/openlanes/evals/evidence.rb +71 -0
  193. data/lib/openlanes/evals/live.rb +140 -0
  194. data/lib/openlanes/evals/recording.rb +38 -0
  195. data/lib/openlanes/evals/run.rb +34 -0
  196. data/lib/openlanes/evals/score.rb +24 -0
  197. data/lib/openlanes/evals/scorer.rb +42 -0
  198. data/lib/openlanes/evals/verdict.rb +18 -0
  199. data/lib/openlanes/evals.rb +56 -0
  200. data/lib/openlanes/execution/checks.rb +73 -0
  201. data/lib/openlanes/execution/child.rb +228 -0
  202. data/lib/openlanes/execution/clock.rb +121 -0
  203. data/lib/openlanes/execution/control.rb +194 -0
  204. data/lib/openlanes/execution/executor.rb +849 -0
  205. data/lib/openlanes/execution/prompt.rb +455 -0
  206. data/lib/openlanes/execution/transcript.rb +531 -0
  207. data/lib/openlanes/execution.rb +19 -0
  208. data/lib/openlanes/launcher.rb +69 -0
  209. data/lib/openlanes/lifecycle/adoption.rb +197 -0
  210. data/lib/openlanes/lifecycle/brief.rb +288 -0
  211. data/lib/openlanes/lifecycle/completion.rb +58 -0
  212. data/lib/openlanes/lifecycle/creator.rb +126 -0
  213. data/lib/openlanes/lifecycle/lanes.rb +74 -0
  214. data/lib/openlanes/lifecycle/resync.rb +428 -0
  215. data/lib/openlanes/lifecycle/unblocker.rb +236 -0
  216. data/lib/openlanes/lifecycle.rb +28 -0
  217. data/lib/openlanes/linear/api.rb +224 -0
  218. data/lib/openlanes/linear/attribution.rb +185 -0
  219. data/lib/openlanes/linear/fuzzy.rb +68 -0
  220. data/lib/openlanes/linear/import.rb +314 -0
  221. data/lib/openlanes/linear/issue.rb +187 -0
  222. data/lib/openlanes/linear/mapping.rb +116 -0
  223. data/lib/openlanes/linear/push.rb +197 -0
  224. data/lib/openlanes/linear/survey.rb +174 -0
  225. data/lib/openlanes/linear/unit.rb +274 -0
  226. data/lib/openlanes/linear.rb +41 -0
  227. data/lib/openlanes/live/claims.rb +71 -0
  228. data/lib/openlanes/live/command.rb +122 -0
  229. data/lib/openlanes/live/commands.rb +505 -0
  230. data/lib/openlanes/live/keys.rb +93 -0
  231. data/lib/openlanes/live/memory_store.rb +265 -0
  232. data/lib/openlanes/live/meter.rb +51 -0
  233. data/lib/openlanes/live/node.rb +88 -0
  234. data/lib/openlanes/live/publisher.rb +179 -0
  235. data/lib/openlanes/live/repo.rb +60 -0
  236. data/lib/openlanes/live/run_record.rb +133 -0
  237. data/lib/openlanes/live/run_state.rb +101 -0
  238. data/lib/openlanes/live/session.rb +107 -0
  239. data/lib/openlanes/live/snapshot.rb +322 -0
  240. data/lib/openlanes/live/stations.rb +67 -0
  241. data/lib/openlanes/live/store.rb +277 -0
  242. data/lib/openlanes/live.rb +109 -0
  243. data/lib/openlanes/plans/dev_work.rb +56 -0
  244. data/lib/openlanes/plans/feature.rb +131 -0
  245. data/lib/openlanes/plans/index.rb +208 -0
  246. data/lib/openlanes/plans/ledger.rb +255 -0
  247. data/lib/openlanes/plans/mailbox.rb +164 -0
  248. data/lib/openlanes/plans/ordinal.rb +92 -0
  249. data/lib/openlanes/plans/plan_state.rb +291 -0
  250. data/lib/openlanes/plans/pull_request.rb +29 -0
  251. data/lib/openlanes/plans/pull_requests.rb +229 -0
  252. data/lib/openlanes/plans/spec.rb +213 -0
  253. data/lib/openlanes/plans/state_machine.rb +433 -0
  254. data/lib/openlanes/plans/status.rb +365 -0
  255. data/lib/openlanes/plans/subject.rb +133 -0
  256. data/lib/openlanes/plans/tree.rb +79 -0
  257. data/lib/openlanes/plans.rb +39 -0
  258. data/lib/openlanes/presentation/diagram.rb +149 -0
  259. data/lib/openlanes/presentation/documentation.rb +438 -0
  260. data/lib/openlanes/presentation/reporter.rb +181 -0
  261. data/lib/openlanes/presentation/viewer.rb +62 -0
  262. data/lib/openlanes/presentation.rb +23 -0
  263. data/lib/openlanes/skills/installer.rb +68 -0
  264. data/lib/openlanes/skills/skill.rb +60 -0
  265. data/lib/openlanes/skills.rb +25 -0
  266. data/lib/openlanes/support/config.rb +217 -0
  267. data/lib/openlanes/support/frontmatter.rb +38 -0
  268. data/lib/openlanes/support/markdown.rb +56 -0
  269. data/lib/openlanes/support/project.rb +79 -0
  270. data/lib/openlanes/support/ui.rb +671 -0
  271. data/lib/openlanes/vcs/github.rb +171 -0
  272. data/lib/openlanes/vcs/publisher.rb +221 -0
  273. data/lib/openlanes/vcs/worktree.rb +248 -0
  274. data/lib/openlanes/vcs.rb +19 -0
  275. data/lib/openlanes/version.rb +10 -0
  276. data/lib/openlanes/web/app.rb +176 -0
  277. data/lib/openlanes/web/context.rb +66 -0
  278. data/lib/openlanes/web/demo.rb +169 -0
  279. data/lib/openlanes/web/demo_board.rb +286 -0
  280. data/lib/openlanes/web/events.rb +104 -0
  281. data/lib/openlanes/web/launcher.rb +143 -0
  282. data/lib/openlanes/web/server.rb +77 -0
  283. data/lib/openlanes/web/stores.rb +36 -0
  284. data/lib/openlanes/web.rb +92 -0
  285. data/lib/openlanes.rb +151 -0
  286. data/openlanes.gemspec +87 -0
  287. data/schemas/plan-state.schema.json +101 -0
  288. data/skills/openlanes/SKILL.md +48 -0
  289. data/skills/openlanes-documents/SKILL.md +61 -0
  290. data/skills/openlanes-documents/references/blocked-template.md +49 -0
  291. data/skills/openlanes-documents/references/closing-templates.md +59 -0
  292. data/skills/openlanes-documents/references/plan-template.md +61 -0
  293. data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
  294. data/skills/openlanes-documents/references/spec-template.md +45 -0
  295. data/skills/openlanes-run/SKILL.md +90 -0
  296. data/skills/openlanes-setup/SKILL.md +64 -0
  297. data/skills/openlanes-spec/SKILL.md +90 -0
  298. data/skills/openlanes-status/SKILL.md +32 -0
  299. data/skills/openlanes-sync/SKILL.md +22 -0
  300. data/web/dist/assets/apl-B4CMkyY2.js +2 -0
  301. data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
  302. data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
  303. data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
  304. data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  305. data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  306. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  307. data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  308. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  309. data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  310. data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
  311. data/web/dist/assets/clike-B9uivgTg.js +2 -0
  312. data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
  313. data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
  314. data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
  315. data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
  316. data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
  317. data/web/dist/assets/crystal-SjHAIU92.js +2 -0
  318. data/web/dist/assets/css-BnMrqG3P.js +2 -0
  319. data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
  320. data/web/dist/assets/d-pRatUO7H.js +2 -0
  321. data/web/dist/assets/diff-DbItnlRl.js +2 -0
  322. data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
  323. data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
  324. data/web/dist/assets/dylan-DwRh75JA.js +2 -0
  325. data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
  326. data/web/dist/assets/ecl-Cabwm37j.js +2 -0
  327. data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
  328. data/web/dist/assets/elm-vLlmbW-K.js +2 -0
  329. data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
  330. data/web/dist/assets/factor-kuTfRLto.js +2 -0
  331. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  332. data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
  333. data/web/dist/assets/forth-Ffai-XNe.js +2 -0
  334. data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
  335. data/web/dist/assets/gas-Bneqetm1.js +2 -0
  336. data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
  337. data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
  338. data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
  339. data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
  340. data/web/dist/assets/http-DBlCnlav.js +2 -0
  341. data/web/dist/assets/idl-BEugSyMb.js +2 -0
  342. data/web/dist/assets/index-2xPC-eHt.js +2 -0
  343. data/web/dist/assets/index-8dNhRmiO.js +8 -0
  344. data/web/dist/assets/index-B7-O9ujQ.js +2 -0
  345. data/web/dist/assets/index-BdR34cXC.js +2 -0
  346. data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
  347. data/web/dist/assets/index-Byyxbh_z.js +4 -0
  348. data/web/dist/assets/index-C0VmkrO-.js +2 -0
  349. data/web/dist/assets/index-CI74IkWP.js +198 -0
  350. data/web/dist/assets/index-CYxcbpRM.js +3 -0
  351. data/web/dist/assets/index-D0rmJN3h.js +2 -0
  352. data/web/dist/assets/index-DB4QwuYn.js +2 -0
  353. data/web/dist/assets/index-DDI7zoAS.js +2 -0
  354. data/web/dist/assets/index-DGLUuEXi.js +2 -0
  355. data/web/dist/assets/index-OR-odsl9.js +2 -0
  356. data/web/dist/assets/index-P3vSkiU-.js +2 -0
  357. data/web/dist/assets/index-a4EHR-u4.css +1 -0
  358. data/web/dist/assets/index-mRV4XhJZ.js +2 -0
  359. data/web/dist/assets/index-popk9wbW.js +2 -0
  360. data/web/dist/assets/index-uM88Njdj.js +2 -0
  361. data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
  362. data/web/dist/assets/julia-DuME0IfC.js +2 -0
  363. data/web/dist/assets/livescript-BwQOo05w.js +2 -0
  364. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  365. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
  366. data/web/dist/assets/lua-VAEuO923.js +2 -0
  367. data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
  368. data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
  369. data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
  370. data/web/dist/assets/mllike-CXdrOF99.js +2 -0
  371. data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
  372. data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
  373. data/web/dist/assets/mumps-BT43cFF4.js +2 -0
  374. data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
  375. data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
  376. data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
  377. data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
  378. data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
  379. data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
  380. data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
  381. data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
  382. data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
  383. data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
  384. data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
  385. data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
  386. data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
  387. data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
  388. data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
  389. data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
  390. data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  391. data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
  392. data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  393. data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
  394. data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
  395. data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
  396. data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
  397. data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
  398. data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
  399. data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
  400. data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
  401. data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
  402. data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
  403. data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
  404. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
  405. data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
  406. data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
  407. data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  408. data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
  409. data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
  410. data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
  411. data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
  412. data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
  413. data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
  414. data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
  415. data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
  416. data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
  417. data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
  418. data/web/dist/assets/oz-BzwKVEFT.js +2 -0
  419. data/web/dist/assets/pascal--L3eBynH.js +2 -0
  420. data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
  421. data/web/dist/assets/pig-CevX1Tat.js +2 -0
  422. data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
  423. data/web/dist/assets/properties-C78fOPTZ.js +2 -0
  424. data/web/dist/assets/protobuf-ChK-085T.js +2 -0
  425. data/web/dist/assets/pug-DeIclll2.js +2 -0
  426. data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
  427. data/web/dist/assets/python-BuPzkPfP.js +2 -0
  428. data/web/dist/assets/q-pXgVlZs6.js +2 -0
  429. data/web/dist/assets/r-B6wPVr8A.js +2 -0
  430. data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
  431. data/web/dist/assets/ruby-BK5aLJev.js +2 -0
  432. data/web/dist/assets/sas-B4kiWyti.js +2 -0
  433. data/web/dist/assets/scheme-C41bIUwD.js +2 -0
  434. data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
  435. data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
  436. data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
  437. data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
  438. data/web/dist/assets/solr-DehyRSwq.js +2 -0
  439. data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
  440. data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
  441. data/web/dist/assets/sql-D0XecflT.js +2 -0
  442. data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
  443. data/web/dist/assets/stylus-B533Al4x.js +2 -0
  444. data/web/dist/assets/swift-BzpIVaGY.js +2 -0
  445. data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
  446. data/web/dist/assets/textile-CnDTJFAw.js +2 -0
  447. data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
  448. data/web/dist/assets/tiki-DGYXhP31.js +2 -0
  449. data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
  450. data/web/dist/assets/troff-wAsdV37c.js +2 -0
  451. data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
  452. data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
  453. data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
  454. data/web/dist/assets/vb-CmGdzxic.js +2 -0
  455. data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
  456. data/web/dist/assets/velocity-D8B20fx6.js +2 -0
  457. data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
  458. data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
  459. data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
  460. data/web/dist/assets/xquery-DzFWVndE.js +2 -0
  461. data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
  462. data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
  463. data/web/dist/index.html +14 -0
  464. data/web/dist/vite.svg +1 -0
  465. data/web/src/api/fixtures/board.json +512 -0
  466. metadata +907 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "rack/utils"
6
+ require "sinatra/base"
7
+
8
+ module OpenLanes
9
+ module Web
10
+ # The routes of contract.md, "HTTP". One subclass per server, built by
11
+ # {.serving}, carries that server's {Context} as a setting, so two apps in
12
+ # one process (the suite) never share a token or a store.
13
+ 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.
21
+ LOCAL_HOSTS = ["localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
22
+
23
+ # What `/` says when the page was never built.
24
+ UNBUILT = <<~HTML
25
+ <!doctype html>
26
+ <html lang="en"><head><meta charset="utf-8"><title>openlanes: page not built</title></head>
27
+ <body><h1>The control room page is not built</h1>
28
+ <p>Run <code>just web-build</code> first, then reload.</p></body></html>
29
+ HTML
30
+
31
+ configure do
32
+ set :environment, :production
33
+ set :show_exceptions, false
34
+ set :raise_errors, false
35
+ set :dump_errors, false
36
+ set :logging, false
37
+ set :static, true
38
+ set :static_cache_control, [:public, { max_age: 3600 }]
39
+ # Rack::Protection's defaults (frame options, origin check on writes,
40
+ # JSON CSRF, path traversal, IP spoofing, XSS header), answering 403.
41
+ # Sinatra's own reaction is to drop the session and carry on, which
42
+ # with no session is no reaction at all. The token is the guard the
43
+ # session would otherwise have been.
44
+ set :protection, except: %i[remote_token], reaction: :deny
45
+ set :host_authorization, { permitted_hosts: LOCAL_HOSTS }
46
+ end
47
+
48
+ class << self
49
+ # @param context [OpenLanes::Web::Context]
50
+ # @return [Class] a subclass serving that context
51
+ def serving(context)
52
+ Class.new(self) do
53
+ set :context, context
54
+ set :public_folder, context.dist
55
+ end
56
+ end
57
+ end
58
+
59
+ helpers do
60
+ # @return [OpenLanes::Web::Context]
61
+ def context = settings.context
62
+
63
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
64
+ def store = context.store
65
+
66
+ # @param data [Object]
67
+ # @return [String]
68
+ def json(data)
69
+ content_type :json
70
+ JSON.generate(data)
71
+ end
72
+
73
+ # @return [Boolean]
74
+ def csrf? = Rack::Utils.secure_compare(request.env["HTTP_X_CSRF_TOKEN"].to_s, context.csrf)
75
+
76
+ # The machine this server speaks for, as the engine last described
77
+ # it, so the session and the board never disagree about who "me" is.
78
+ #
79
+ # @return [Hash, nil]
80
+ def node
81
+ snapshot = store.get(context.keys.snapshot)
82
+ return JSON.parse(snapshot)["node"] if snapshot
83
+
84
+ context.node&.wire(heartbeat_at: Time.now)
85
+ end
86
+
87
+ # @param body [String]
88
+ # @return [String] the command's id when it has a usable one
89
+ def id_in(body)
90
+ id = JSON.parse(body)["id"].to_s
91
+ id.match?(Live::Command::ID) ? id : ""
92
+ rescue JSON::ParserError, TypeError, NoMethodError
93
+ ""
94
+ end
95
+ end
96
+
97
+ before "/api/*" do
98
+ cache_control :no_store
99
+ end
100
+
101
+ error Live::Unavailable do
102
+ status 503
103
+ json(error: env["sinatra.error"].message)
104
+ end
105
+
106
+ get "/healthz" do
107
+ store.ping
108
+ content_type :text
109
+ "ok #{Live.mask(context.url)}\n"
110
+ end
111
+
112
+ get "/api/session" do
113
+ json(csrf: context.csrf, node:, repo: context.keys.repo)
114
+ end
115
+
116
+ get "/api/board" do
117
+ json(Live.board(store:, keys: context.keys))
118
+ end
119
+
120
+ get "/api/events" do
121
+ content_type "text/event-stream"
122
+ headers "Cache-Control" => "no-cache, no-transform", "X-Accel-Buffering" => "no"
123
+ events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"])
124
+ stream { |out| events.run(out) }
125
+ end
126
+
127
+ post "/api/commands" do
128
+ body = request.body.read
129
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
130
+
131
+ reply = Live.enqueue(store:, keys: context.keys, input: body)
132
+ status 202
133
+ json(reply.wire)
134
+ rescue Live::Command::Invalid => e
135
+ status 422
136
+ json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
137
+ end
138
+
139
+ # Demo only: the board back to the fixture, for a test that wants a
140
+ # known start. A real engine's run has no such thing.
141
+ post "/api/demo/reset" do
142
+ halt 404, json(error: "no such endpoint") unless context.demo
143
+ halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
144
+
145
+ context.demo.reset
146
+ status 204
147
+ end
148
+
149
+ get "/api/plans/:ordinal/files/:name" do
150
+ name = PLAN_FILES.find { it == params[:name] } or halt 404, json(error: "not a plan file: #{params[:name]}")
151
+ ordinal = Plans::Ordinal.parse(params[:ordinal]) or halt 404, json(error: "not a plan number")
152
+ subject = Plans.tree(context.plans_dir).find(ordinal) or halt 404, json(error: "there is no plan #{ordinal}")
153
+ path = File.join(subject.feature.path, name)
154
+ halt 404, json(error: "#{ordinal} has no #{name}") unless File.file?(path)
155
+
156
+ content_type "text/markdown", charset: "utf-8"
157
+ File.read(path)
158
+ end
159
+
160
+ get "/api/*" do
161
+ halt 404, json(error: "no such endpoint")
162
+ end
163
+
164
+ # Every other path is the page's own route: the page decides what it
165
+ # means. A path naming a file is not one, so a missing asset is a 404
166
+ # rather than the page served as JavaScript.
167
+ get "/*" do
168
+ halt 404, { "Content-Type" => "text/plain" }, "not found\n" unless File.extname(request.path_info).empty?
169
+ halt 503, { "Content-Type" => "text/html; charset=utf-8" }, UNBUILT unless context.dist?
170
+
171
+ cache_control :no_cache
172
+ send_file context.index
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_boolean"
4
+
5
+ module OpenLanes
6
+ module Web
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.
10
+ #
11
+ # @!attribute [r] stores
12
+ # @return [OpenLanes::Web::Stores] a store per request thread
13
+ # @!attribute [r] keys
14
+ # @return [OpenLanes::Live::Keys] this node's
15
+ # @!attribute [r] plans_dir
16
+ # @return [String] the `.plans` directory plan files are read from
17
+ # @!attribute [r] url
18
+ # @return [String] the Redis address
19
+ # @!attribute [r] csrf
20
+ # @return [String] what `X-CSRF-Token` must equal
21
+ # @!attribute [r] dist
22
+ # @return [String] the built page
23
+ # @!attribute [r] node
24
+ # @return [OpenLanes::Live::Node, nil] this machine, for `/api/session`
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
+ # @!attribute [r] ping_every
29
+ # @return [Float] seconds between `: ping` comments
30
+ # @!attribute [r] lifetime
31
+ # @return [Float, nil] seconds an event stream lives; nil for as long as
32
+ # the browser holds it. The suite sets one so a stream ends.
33
+ # @!attribute [r] demo
34
+ # @return [OpenLanes::Web::Demo, nil] the demo engine, under `--demo`,
35
+ # which `POST /api/demo/reset` puts back to the start
36
+ # @!attribute [r] stopping
37
+ # @return [Concurrent::AtomicBoolean] set when the server shuts down, so
38
+ # open streams end within a tick
39
+ Context = Data.define(:stores,
40
+ :keys,
41
+ :plans_dir,
42
+ :url,
43
+ :csrf,
44
+ :dist,
45
+ :node,
46
+ :tick,
47
+ :ping_every,
48
+ :lifetime,
49
+ :demo,
50
+ :stopping) do
51
+ def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil,
52
+ stopping: Concurrent::AtomicBoolean.new, **rest)
53
+ super
54
+ end
55
+
56
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore] this thread's
57
+ def store = stores.current
58
+
59
+ # @return [String] the page's entry point
60
+ def index = File.join(dist, "index.html")
61
+
62
+ # @return [Boolean]
63
+ def dist? = File.file?(index)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Web
7
+ # `openlanes web --demo`: an engine with no agents. Every second it publishes
8
+ # a {DemoBoard} snapshot for this node and for one pretend other machine,
9
+ # through the same keys and in the same shape as {Live::Publisher}, and
10
+ # it answers the page's commands as {Live::Commands} does: read through
11
+ # the `engine` group, applied once, a `done` or `refused` reply written
12
+ # to `replies` and `done`, then acknowledged.
13
+ #
14
+ # Cypress and the front end's dev server run against it.
15
+ class Demo
16
+ # Seconds between snapshots.
17
+ INTERVAL = 1.0
18
+
19
+ # The other machine's checkout id, fixed so its keys are stable.
20
+ OTHER_CHECKOUT = Live::Node.digest("openlanes-demo-other", Live::Node::CHECKOUT_LENGTH)
21
+
22
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
23
+ # @param keys [OpenLanes::Live::Keys] this node's
24
+ # @param board [OpenLanes::Web::DemoBoard]
25
+ # @param interval [Float]
26
+ # @param clock [Proc] wall time
27
+ def initialize(store:, keys:, board: DemoBoard.new, interval: INTERVAL, clock: -> { Time.now })
28
+ @store = store
29
+ @keys = keys
30
+ @board = board
31
+ @interval = interval
32
+ @clock = clock
33
+ @seqs = {}
34
+ @ready = false
35
+ @running = false
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ # @return [OpenLanes::Web::DemoBoard]
40
+ def board = @mutex.synchronize { @board }
41
+
42
+ # Back to the fixture as it was, so each Cypress spec starts from the
43
+ # same board whatever the last one answered or stopped.
44
+ #
45
+ # @return [void]
46
+ def reset = @mutex.synchronize { @board = DemoBoard.new }
47
+
48
+ # Publish and answer every {INTERVAL}, on a thread of its own.
49
+ #
50
+ # @return [self]
51
+ def start
52
+ @running = true
53
+ @thread = Thread.new do
54
+ while @running
55
+ tick
56
+ sleep @interval
57
+ end
58
+ end
59
+ self
60
+ end
61
+
62
+ # Stop, and take this demo's snapshots down so no page shows them.
63
+ #
64
+ # @return [void]
65
+ def stop
66
+ @running = false
67
+ @thread&.join(@interval * 2)
68
+ @store.del(@keys.snapshot, @keys.snapshot(other))
69
+ @store.hdel(@keys.nodes, @keys.node, other)
70
+ rescue Live::Unavailable
71
+ nil
72
+ end
73
+
74
+ # One second: commands first, as the engine applies them at the top of
75
+ # its tick, then the board moves and is published.
76
+ #
77
+ # @return [Array<OpenLanes::Live::Reply>] the commands answered
78
+ def tick
79
+ @mutex.synchronize do
80
+ replies = commands
81
+ @board.advance(@clock.call)
82
+ publish(@keys.node, @board.me)
83
+ @board.others.each { publish(other, it) }
84
+ replies
85
+ end
86
+ rescue Live::Unavailable => e
87
+ UI.log("demo: #{e.message}")
88
+ []
89
+ end
90
+
91
+ private
92
+
93
+ # @return [String] the pretend other machine's node name
94
+ def other = "#{@board.others.first&.dig("node", "id")}:#{OTHER_CHECKOUT}"
95
+
96
+ # @return [void]
97
+ def publish(name, snapshot)
98
+ snapshot["seq"] = next_seq(name)
99
+ json = JSON.generate(snapshot)
100
+ @store.multi do |tx|
101
+ tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
102
+ tx.xadd(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
103
+ tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
104
+ end
105
+ end
106
+
107
+ # Carries on from the last seq in the stream, as the engine does, so a
108
+ # restarted demo never looks like the past to an open page.
109
+ #
110
+ # @return [Integer]
111
+ def next_seq(name)
112
+ @seqs[name] ||= @store.xrevrange(@keys.board(name), count: 1).first&.fields&.fetch("seq", nil).to_i
113
+ @seqs[name] += 1
114
+ end
115
+
116
+ # @return [Array<OpenLanes::Live::Reply>]
117
+ def commands
118
+ unless @ready
119
+ # "$": commands left in the stream by an earlier demo were meant for
120
+ # a board that no longer exists.
121
+ @store.xgroup_create(@keys.commands, Live::Keys::GROUP, from: "$")
122
+ @ready = true
123
+ end
124
+ @store.xreadgroup(@keys.commands, Live::Keys::GROUP, @keys.node, id: ">", count: Live::Commands::BATCH)
125
+ .map { answer(it) }
126
+ end
127
+
128
+ # @param entry [OpenLanes::Live::Store::Entry]
129
+ # @return [OpenLanes::Live::Reply]
130
+ def answer(entry)
131
+ command = Live::Command.parse(entry.fields["json"].to_s)
132
+ first = @store.hget(@keys.done, command.id)
133
+ reply = first ? parse(first) : outcome(command)
134
+ @store.multi do |tx|
135
+ tx.hset(@keys.done, reply.id, reply.to_json) unless first
136
+ tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
137
+ tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
138
+ end
139
+ reply
140
+ rescue Live::Command::Invalid => e
141
+ refuse(entry, e.message)
142
+ end
143
+
144
+ # @return [OpenLanes::Live::Reply]
145
+ def outcome(command)
146
+ reason = @board.apply(command)
147
+ Live::Reply.new(id: command.id, status: "done", reason: reason.is_a?(String) ? reason : nil, at: @clock.call)
148
+ rescue DemoBoard::Refusal => e
149
+ Live::Reply.new(id: command.id, status: "refused", reason: e.message, at: @clock.call)
150
+ end
151
+
152
+ # @return [OpenLanes::Live::Reply]
153
+ def refuse(entry, reason)
154
+ reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
155
+ @store.multi do |tx|
156
+ tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
157
+ tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
158
+ end
159
+ reply
160
+ end
161
+
162
+ # @return [OpenLanes::Live::Reply]
163
+ def parse(json)
164
+ data = JSON.parse(json)
165
+ Live::Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Web
7
+ # The demo's run, as plain snapshot hashes the shape of
8
+ # `web/src/api/fixtures/board.json`: what {Demo} publishes, and what its
9
+ # commands change. No plan folder is touched and no agent runs.
10
+ #
11
+ # Each command kind is one method, named as {Live::Commands} names its
12
+ # handlers, raising {Refusal} with the reason the engine would give.
13
+ class DemoBoard
14
+ # A command the demo will not apply.
15
+ class Refusal < StandardError; end
16
+
17
+ # Activity lines a running car cycles through, so the board moves.
18
+ CHATTER = [
19
+ "Reading the plan's work units",
20
+ "Running the suite: 412 examples",
21
+ "Editing lib/openlanes/web/app.rb",
22
+ "Writing the spec for the event stream",
23
+ "Checking rubocop: no offenses",
24
+ "Waiting on the reviewer's mailbox"
25
+ ].freeze
26
+
27
+ # The status a plan takes once its last question is answered.
28
+ UNBLOCKED = { "key" => "building", "emoji" => "🟡", "label" => "Building" }.freeze
29
+
30
+ # What each car-signal kind does to the car.
31
+ SIGNALS = {
32
+ "hold" => { "held" => true },
33
+ "wrap_up" => { "phase" => "wrap_up" },
34
+ "compact" => { "phase" => "calm" },
35
+ "stop" => { "phase" => "stopped", "state" => "done" }
36
+ }.freeze
37
+
38
+ # @param fixture [String] path to a `Board` JSON
39
+ # @param random [Random]
40
+ def initialize(fixture: FIXTURE, random: Random.new(4747))
41
+ board = JSON.parse(File.read(fixture))
42
+ @me = board.fetch("me")
43
+ @others = board.fetch("others")
44
+ @random = random
45
+ @ticks = 0
46
+ end
47
+
48
+ # @return [Hash] this node's snapshot
49
+ attr_reader :me
50
+
51
+ # @return [Array<Hash>] the other nodes' snapshots
52
+ attr_reader :others
53
+
54
+ # One second of pretend work: tokens grow, clocks run, messages turn.
55
+ #
56
+ # @param now [Time]
57
+ # @return [void]
58
+ def advance(now)
59
+ @ticks += 1
60
+ [@me, *@others].each { |snapshot| advance_snapshot(snapshot, now) }
61
+ end
62
+
63
+ # @param command [OpenLanes::Live::Command]
64
+ # @return [String, nil] a reason to show beside "done"
65
+ # @raise [Refusal]
66
+ def apply(command)
67
+ send(Live::Commands::HANDLERS.fetch(command.kind), command)
68
+ end
69
+
70
+ private
71
+
72
+ # @return [void]
73
+ def advance_snapshot(snapshot, now)
74
+ stamp = now.utc.iso8601
75
+ snapshot["at"] = stamp
76
+ snapshot["node"]["heartbeat_at"] = stamp
77
+ snapshot["plans"].each { |plan| advance_plan(plan) }
78
+ run = snapshot["run"]
79
+ run["up"] = snapshot["plans"].sum { it["up"] }
80
+ run["down"] = snapshot["plans"].sum { it["down"] }
81
+ run["agents_working"] = running(snapshot).size
82
+ end
83
+
84
+ # @return [void]
85
+ def advance_plan(plan)
86
+ plan["cars"].select { it["state"] == "running" && !it["held"] }.each do |car|
87
+ up = @random.rand(800..2400)
88
+ down = @random.rand(60..240)
89
+ car["up"] += up
90
+ car["down"] += down
91
+ plan["up"] += up
92
+ plan["down"] += down
93
+ car["elapsed"] += 1
94
+ car["remaining"] = [car["remaining"] - 1, 0].max if car["remaining"]
95
+ chatter(car) if (@ticks % 4).zero?
96
+ end
97
+ plan["elapsed"] += 1 if plan["cars"].any? { it["state"] == "running" }
98
+ end
99
+
100
+ # @return [void]
101
+ def chatter(car)
102
+ car["message"] = CHATTER[(@ticks / 4) % CHATTER.size]
103
+ car["history"] = [car["message"], *car["history"]].first(50)
104
+ end
105
+
106
+ # @return [Array<Hash>] cars still at work
107
+ def running(snapshot) = snapshot["plans"].flat_map { it["cars"] }.select { it["state"] == "running" }
108
+
109
+ # ---- questions --------------------------------------------------------
110
+
111
+ # @return [nil]
112
+ def answer(command)
113
+ plan, question = question!(command)
114
+ plan["questions"].delete(question)
115
+ unblock(plan) if plan["questions"].empty?
116
+ nil
117
+ end
118
+
119
+ # @return [nil]
120
+ def hold_blocked(command) = hold_question(command, true)
121
+
122
+ # @return [nil]
123
+ def release_hold(command) = hold_question(command, false)
124
+
125
+ # @return [nil]
126
+ def hold_question(command, held)
127
+ question!(command).last["held"] = held
128
+ nil
129
+ end
130
+
131
+ # @return [void]
132
+ def unblock(plan)
133
+ plan["status"] = UNBLOCKED.dup if %w[blocked product_blocked].include?(plan.dig("status", "key"))
134
+ plan["cars"].each { it["state"] = "running" if it["state"] == "stuck" }
135
+ end
136
+
137
+ # @return [Array(Hash, Hash)] the plan and its question
138
+ def question!(command)
139
+ plan = plan!(command)
140
+ number = command["question"][/\AB?(\d+)\z/i, 1] or raise Refusal, "#{command["question"]} is not a question number like B2"
141
+ question = plan["questions"].find { it["number"] == number.to_i } or
142
+ raise Refusal, "#{command.plan} has no open question B#{number}"
143
+ [plan, question]
144
+ end
145
+
146
+ # ---- agents -----------------------------------------------------------
147
+
148
+ # @return [String]
149
+ def resume(command)
150
+ plan = plan!(command)
151
+ interrupted = plan["interrupted"] or raise Refusal, "#{command.plan} has no interrupted agent to resume"
152
+ plan["interrupted"] = nil
153
+ "#{interrupted["agent"]} resumes on #{command.plan} at its next turn"
154
+ end
155
+
156
+ # Every agent on the plan stops and the interruption is forgotten; the
157
+ # engine also resets the worktree and sends the plan back one state,
158
+ # which a board with no folders behind it has nothing to show for.
159
+ #
160
+ # @return [String]
161
+ def discard(command)
162
+ plan = plan!(command)
163
+ plan["cars"].each { it.merge!(SIGNALS.fetch("stop")) }
164
+ plan["interrupted"] = nil
165
+ "discarded the work on #{command.plan}; its agents stopped"
166
+ end
167
+
168
+ # @return [nil]
169
+ def message(command)
170
+ plan = plan!(command)
171
+ cars = plan["cars"].select { command.agent.to_s.empty? || it["agent"] == command.agent }
172
+ raise Refusal, "no agent named, and none is working #{command.plan}" if cars.empty?
173
+
174
+ cars.each do |car|
175
+ car["history"] = ["human: #{command["text"]}", *car["history"]].first(50)
176
+ car["held"] = false
177
+ end
178
+ nil
179
+ end
180
+
181
+ # @return [nil]
182
+ def signal(command)
183
+ car!(command).merge!(SIGNALS.fetch(command.kind)).tap { it["held"] = command.kind == "hold" }
184
+ nil
185
+ end
186
+
187
+ # The demo has no session to compact; the car calms as a real one would
188
+ # once its context is shed.
189
+ #
190
+ # @return [nil]
191
+ def compact(command) = signal(command)
192
+
193
+ # @return [String]
194
+ def stop_all(_command)
195
+ cars = running(@me)
196
+ cars.each { it.merge!(SIGNALS.fetch("stop")) }
197
+ @me["run"]["status"] = "quitting"
198
+ "stopping #{cars.size} agent#{"s" unless cars.size == 1} on #{@me.dig("node", "user")}@#{@me.dig("node", "host")}"
199
+ end
200
+
201
+ # Queues the model for the next round and leaves the running one
202
+ # alone, as Live::Snapshot reports it: `next_model` until a round runs
203
+ # on it, and nil when it is the model already running.
204
+ #
205
+ # @return [String]
206
+ def set_model(command) # rubocop:disable Naming/AccessorMethodName -- named for the command kind, as in Live::Commands
207
+ car = car!(command)
208
+ model = model!(command["model"])
209
+ car["next_model"] = (model unless model == car["model"])
210
+ "#{command.agent} runs on #{model} from its next round"
211
+ end
212
+
213
+ # @return [String]
214
+ def switch_model(command)
215
+ car = car!(command)
216
+ raise Refusal, "#{command.car} is not running; use set_model for its next round" unless car["state"] == "running"
217
+
218
+ car["model"] = model!(command["model"])
219
+ # The switch is the model now, so nothing is queued behind it.
220
+ car["next_model"] = nil
221
+ "#{command.agent} compacted and carries on on #{car["model"]}"
222
+ end
223
+
224
+ # @return [String] the model's full id
225
+ def model!(name)
226
+ entry = @me["models"].find { [it["id"], it["label"]].include?(name) } or raise Refusal, "claude offers no model #{name}"
227
+ entry["id"]
228
+ end
229
+
230
+ # ---- plans ------------------------------------------------------------
231
+
232
+ # @return [nil]
233
+ def add_plan(command)
234
+ ordinal = Plans::Ordinal.parse(command["ordinal"])&.to_s or raise Refusal, "#{command["ordinal"]} is not a plan number"
235
+ entry = @me["backlog"].find { it["ordinal"] == ordinal } or raise Refusal, "there is no plan #{ordinal}"
236
+ @me["backlog"].delete(entry)
237
+ @me["plans"] << plan_from(entry)
238
+ nil
239
+ end
240
+
241
+ # @return [String]
242
+ def create_plans(command)
243
+ ordinals = [*@me["plans"], *@me["backlog"]].map { it["ordinal"].to_i }
244
+ ordinal = format("%03d.00", ordinals.max.to_i + 1)
245
+ title = command["idea"].lines.first.to_s.strip.delete_prefix("#").strip
246
+ @me["plans"] << plan_from({ "ordinal" => ordinal,
247
+ "title" => title,
248
+ "status" => { "key" => "new", "emoji" => "⚪️", "label" => "New" },
249
+ "lane" => "full" })
250
+ "created #{ordinal} #{slug(title)}"
251
+ end
252
+
253
+ # @return [Hash] a `Plan` at its status's station, with nobody on it yet
254
+ def plan_from(entry)
255
+ station = @me["stations"].find { it["key"] == entry.dig("status", "key") }&.fetch("key") || @me["stations"].first["key"]
256
+ entry.merge("slug" => slug(entry["title"]),
257
+ "station" => station,
258
+ "up" => 0,
259
+ "down" => 0,
260
+ "cost" => 0,
261
+ "started_at" => nil,
262
+ "elapsed" => 0,
263
+ "cars" => [],
264
+ "pr" => nil,
265
+ "questions" => [],
266
+ "interrupted" => nil,
267
+ "claimed_by" => @me.dig("node", "id"))
268
+ end
269
+
270
+ # @return [String]
271
+ def slug(title) = title.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
272
+
273
+ # ---- lookups ----------------------------------------------------------
274
+
275
+ # @return [Hash]
276
+ def plan!(command)
277
+ @me["plans"].find { it["ordinal"] == command.plan } or raise Refusal, "there is no plan #{command.plan}"
278
+ end
279
+
280
+ # @return [Hash]
281
+ def car!(command)
282
+ plan!(command)["cars"].find { it["key"] == command.car } or raise Refusal, "#{command.car} is not running"
283
+ end
284
+ end
285
+ end
286
+ end