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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # Keeps the {RunState} past an engine restart (W6). Redis holds the live
8
+ # copy under {Keys#run}; `.plans/openlanes-state.json` holds the one a
9
+ # person can read without `redis-cli` and the one that survives Redis
10
+ # being flushed. Both carry the state's `seq`.
11
+ #
12
+ # On the run's first tick, after the dispatcher has loaded its state
13
+ # file, the newer copy is taken: normally Redis, the file when Redis came
14
+ # back empty or behind. Queued models go back to the executor, which is
15
+ # what applies them. From then on each tick writes whichever copy is
16
+ # behind, and {Commands} writes Redis in the same `MULTI` as a command's
17
+ # reply, so an applied command and its effect on the run state land
18
+ # together.
19
+ class RunRecord
20
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
21
+ # @param keys [OpenLanes::Live::Keys]
22
+ # @param run_state [OpenLanes::Live::RunState]
23
+ def initialize(store:, keys:, run_state:)
24
+ @store = store
25
+ @keys = keys
26
+ @run_state = run_state
27
+ @restored = false
28
+ @in_redis = nil
29
+ @in_file = nil
30
+ @state_file = nil
31
+ @warned = false
32
+ end
33
+
34
+ # @return [Proc] for {Engine::Dispatcher#before_tick}
35
+ def to_proc = method(:call).to_proc
36
+
37
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
38
+ # @return [void]
39
+ def call(dispatcher)
40
+ restore(dispatcher) unless @restored
41
+ flush
42
+ end
43
+
44
+ # Take back the newer copy, and hand its models to the executor.
45
+ #
46
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
47
+ # @return [void]
48
+ def restore(dispatcher)
49
+ @restored = true
50
+ @state_file = dispatcher.state_file
51
+ redis = read_redis
52
+ file = @state_file&.live
53
+ newer = [redis, file].compact.max_by { it["seq"].to_i }
54
+ # A copy that is missing is behind, unless both are: then there is
55
+ # nothing to keep until a person changes something.
56
+ @in_redis = redis ? redis["seq"].to_i : (newer && -1) || 0
57
+ @in_file = file ? file["seq"].to_i : (newer && -1) || 0
58
+ return unless newer
59
+
60
+ @run_state.restore(newer)
61
+ @run_state.models.each do |key, model|
62
+ ordinal, agent = key.split("/", 2)
63
+ dispatcher.prefer_model(ordinal, agent, model)
64
+ end
65
+ end
66
+
67
+ # Bring both copies up to the state's `seq`. The file is written by the
68
+ # dispatcher's own save at the end of the tick, and by {#close}.
69
+ #
70
+ # @return [void]
71
+ def flush
72
+ seq = @run_state.seq
73
+ @store.multi { |tx| write(tx) } unless @in_redis == seq
74
+ mirror
75
+ rescue Unavailable => e
76
+ unavailable(e)
77
+ end
78
+
79
+ # The Redis half, inside someone else's `MULTI`.
80
+ #
81
+ # @param transaction [#hset]
82
+ # @return [void]
83
+ def write(transaction)
84
+ data = @run_state.to_h
85
+ transaction.hset(@keys.run, "seq", data["seq"].to_s)
86
+ transaction.hset(@keys.run, "json", JSON.generate(data))
87
+ @in_redis = data["seq"]
88
+ end
89
+
90
+ # The run is over: the file gets the last word, saved now rather than
91
+ # at a tick that is not coming.
92
+ #
93
+ # @return [void]
94
+ def close
95
+ flush
96
+ @state_file&.save
97
+ end
98
+
99
+ private
100
+
101
+ # @return [void]
102
+ def mirror
103
+ return unless @state_file && @in_file != @run_state.seq
104
+
105
+ data = @run_state.to_h
106
+ @state_file.live = data
107
+ @in_file = data["seq"]
108
+ end
109
+
110
+ # @return [Hash, nil]
111
+ def read_redis
112
+ seq, json = @store.hgetall(@keys.run).values_at("seq", "json")
113
+ return nil unless json
114
+
115
+ JSON.parse(json).merge("seq" => seq.to_i)
116
+ rescue JSON::ParserError
117
+ nil
118
+ rescue Unavailable => e
119
+ unavailable(e)
120
+ nil
121
+ end
122
+
123
+ # @param error [OpenLanes::Live::Unavailable]
124
+ # @return [void]
125
+ def unavailable(error)
126
+ return if @warned
127
+
128
+ @warned = true
129
+ UI.log("run state not kept in Redis: #{error.message}")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # What a person has said about the run that the plan folders do not
6
+ # record: the questions they are holding, the agents they asked to hold,
7
+ # and the models chosen for agents' next rounds.
8
+ #
9
+ # Held in the engine, and kept by {RunRecord} in Redis and in
10
+ # `.plans/openlanes-state.json` (W6), so an engine that restarts still
11
+ # knows them. Every change moves {#seq} on by one, which is how the
12
+ # record tells which of its two copies is newer. A model choice also
13
+ # lives in the executor, which is what applies it; this copy is what the
14
+ # page is shown and what a restart hands back to the executor.
15
+ class RunState
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @held_questions = []
19
+ @held_cars = []
20
+ @models = {}
21
+ @seq = 0
22
+ end
23
+
24
+ # @param id [String] `"<ordinal>/B<n>"`
25
+ # @param held [Boolean]
26
+ # @return [void]
27
+ def hold_question(id, held: true) = toggle(@held_questions, id, held)
28
+
29
+ # @param id [String]
30
+ # @return [Boolean]
31
+ def question_held?(id) = @mutex.synchronize { @held_questions.include?(id) }
32
+
33
+ # @param key [String] `"<ordinal>/<agent>"`
34
+ # @param held [Boolean]
35
+ # @return [void]
36
+ def hold_car(key, held: true) = toggle(@held_cars, key, held)
37
+
38
+ # @param key [String]
39
+ # @return [Boolean]
40
+ def car_held?(key) = @mutex.synchronize { @held_cars.include?(key) }
41
+
42
+ # @param key [String] `"<ordinal>/<agent>"`
43
+ # @param model [String, nil] nil forgets the choice
44
+ # @return [void]
45
+ def choose_model(key, model)
46
+ @mutex.synchronize do
47
+ next if @models[key] == model
48
+
49
+ model ? @models[key] = model : @models.delete(key)
50
+ @seq += 1
51
+ end
52
+ end
53
+
54
+ # @param key [String]
55
+ # @return [String, nil] the model queued for that agent's next round
56
+ def model_for(key) = @mutex.synchronize { @models[key] }
57
+
58
+ # @return [Hash{String => String}] `"<ordinal>/<agent>"` to model, a copy
59
+ def models = @mutex.synchronize { @models.dup }
60
+
61
+ # @return [Integer] changes made since the run's state was first recorded
62
+ def seq = @mutex.synchronize { @seq }
63
+
64
+ # As {RunRecord} keeps it.
65
+ #
66
+ # @return [Hash]
67
+ def to_h
68
+ @mutex.synchronize do
69
+ { "seq" => @seq, "held_questions" => @held_questions.dup, "held_cars" => @held_cars.dup,
70
+ "models" => @models.dup }
71
+ end
72
+ end
73
+
74
+ # Take back what {#to_h} wrote, sequence number included.
75
+ #
76
+ # @param data [Hash]
77
+ # @return [self]
78
+ def restore(data)
79
+ @mutex.synchronize do
80
+ @held_questions = Array(data["held_questions"]).map(&:to_s)
81
+ @held_cars = Array(data["held_cars"]).map(&:to_s)
82
+ @models = (data["models"].is_a?(Hash) ? data["models"] : {}).to_h { |k, v| [k.to_s, v.to_s] }
83
+ @seq = data["seq"].to_i
84
+ end
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ # @return [void]
91
+ def toggle(list, item, on)
92
+ @mutex.synchronize do
93
+ next if list.include?(item) == on
94
+
95
+ on ? list.push(item) : list.delete(item)
96
+ @seq += 1
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # One engine's live run: its node and keys, its claims, the publisher
6
+ # that writes its board and the reader that applies its commands, built
7
+ # once and handed to the runner.
8
+ #
9
+ # live = Live.session(store:, root:, tree:, agents:)
10
+ # runner = Engine.runner(..., on_board: live.on_board, claims: live.claims)
11
+ # runner.call { |dispatcher| live.attach(dispatcher) }
12
+ # live.close
13
+ class Session
14
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
15
+ # @param root [String] the checkout
16
+ # @param tree [OpenLanes::Plans::Tree]
17
+ # @param agents [OpenLanes::Agents::Registry]
18
+ # @param project [String, nil] the header's name (W18); the root's name by default
19
+ # @param node [OpenLanes::Live::Node]
20
+ # @param repo [String]
21
+ # @param clock [Proc] wall time
22
+ def initialize(store:, root:, tree:, agents:, project: nil, node: Node.current(root:), repo: Repo.for(root),
23
+ clock: -> { Time.now })
24
+ @store = store
25
+ @node = node
26
+ @keys = Keys.new(repo:, node: node.name)
27
+ @run_state = RunState.new
28
+ @record = RunRecord.new(store:, keys: @keys, run_state: @run_state)
29
+ @claims = Claims.new(store:, keys: @keys)
30
+ @publisher = Publisher.new(store:,
31
+ keys: @keys,
32
+ node:,
33
+ tree:,
34
+ agents:,
35
+ claims: @claims,
36
+ run_state: @run_state,
37
+ project: project || File.basename(root),
38
+ clock:)
39
+ @commands = Commands.new(store:,
40
+ keys: @keys,
41
+ node:,
42
+ run_state: @run_state,
43
+ record: @record,
44
+ claims: @claims,
45
+ clock:)
46
+ end
47
+
48
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
49
+ attr_reader :store
50
+
51
+ # @return [OpenLanes::Live::Node]
52
+ attr_reader :node
53
+
54
+ # @return [OpenLanes::Live::Keys]
55
+ attr_reader :keys
56
+
57
+ # @return [OpenLanes::Live::Claims]
58
+ attr_reader :claims
59
+
60
+ # @return [OpenLanes::Live::Publisher]
61
+ attr_reader :publisher
62
+
63
+ # @return [OpenLanes::Live::Commands]
64
+ attr_reader :commands
65
+
66
+ # @return [OpenLanes::Live::RunState]
67
+ attr_reader :run_state
68
+
69
+ # @return [OpenLanes::Live::RunRecord]
70
+ attr_reader :record
71
+
72
+ # @return [Proc] the runner's `on_board:`
73
+ def on_board = @publisher.to_proc
74
+
75
+ # Wire the run in: the run state a previous engine left is taken back
76
+ # on the first tick, commands are applied at the top of each tick, and
77
+ # the log is heard from now on.
78
+ #
79
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
80
+ # @return [void]
81
+ def attach(dispatcher)
82
+ @publisher.listen
83
+ @publisher.on_lost { |ordinals| dispatcher.fence(ordinals) }
84
+ dispatcher.before_tick(&@record)
85
+ dispatcher.before_tick(&@commands)
86
+ end
87
+
88
+ # The run is over: the run state is written down, claims go back, and
89
+ # this node's snapshot goes at once rather than when it expires, so
90
+ # other pages stop showing it.
91
+ #
92
+ # @return [void]
93
+ def close
94
+ @record.close
95
+ @publisher.close
96
+ @claims.release_all
97
+ @store.del(@keys.snapshot)
98
+ @store.hdel(@keys.nodes, @node.name)
99
+ rescue Unavailable
100
+ nil
101
+ end
102
+
103
+ # @return [String] where, with the password masked, and as whom
104
+ def describe = "#{Live.mask(@store.url)} as #{@node.label} (#{@node.name})"
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # Everything the page shows about this node, as one hash in exactly the
6
+ # shape of `Snapshot` in `web/src/api/types.ts`.
7
+ #
8
+ # Built from what already says it: the dispatcher's {Engine::Board} for
9
+ # who is running and what they spent, the `.plans` tree for each plan's
10
+ # state, questions and pull requests, the registry for the stations, the
11
+ # adapter for the models and their prices. Nothing here is a second copy
12
+ # of any of them. `spec/openlanes/live/snapshot_spec.rb` holds the keys
13
+ # to the page's fixture, level by level, so a field renamed on one side
14
+ # fails the other's suite.
15
+ class Snapshot
16
+ # How an agent marks a question as answerable with Yes or No (W30), on
17
+ # its own line under the question's heading. The agents' prompt shows
18
+ # it, and {#questions} looks for it; neither spells it twice.
19
+ YES_NO_MARK = "answers: yes, no"
20
+
21
+ # {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
22
+ YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
23
+
24
+ # Any Markdown heading, which ends the body of the question above it.
25
+ HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
26
+
27
+ # Plan states finished with, kept off "Choose an existing plan".
28
+ FINISHED = %i[approved deployed discarded].freeze
29
+
30
+ # @param board [OpenLanes::Engine::Board]
31
+ # @param tree [OpenLanes::Plans::Tree]
32
+ # @param agents [OpenLanes::Agents::Registry]
33
+ # @param node [OpenLanes::Live::Node]
34
+ # @param repo [String]
35
+ # @param project [String]
36
+ # @param description [String, nil]
37
+ # @param seq [Integer]
38
+ # @param at [Time]
39
+ # @param started_at [Time, nil] the run's start, wall clock
40
+ # @param claims [OpenLanes::Live::Claims, nil]
41
+ # @param run_state [OpenLanes::Live::RunState]
42
+ # @param meter [OpenLanes::Live::Meter]
43
+ # @param log [Array<Hash>] `LogLine`s, newest last
44
+ # @param adapter [OpenLanes::Adapters::Base] whose models the page offers
45
+ def initialize(board:, tree:, agents:, node:, repo:, project:, seq:, description: nil, at: Time.now,
46
+ started_at: nil, claims: nil, run_state: RunState.new, meter: Meter.new, log: [],
47
+ adapter: Adapters.for(Adapters::DEFAULT))
48
+ @board = board
49
+ @tree = tree
50
+ @agents = agents
51
+ @node = node
52
+ @repo = repo
53
+ @project = project
54
+ @description = description
55
+ @seq = seq
56
+ @at = at
57
+ @started_at = started_at
58
+ @claims = claims
59
+ @run_state = run_state
60
+ @meter = meter
61
+ @log = log
62
+ @adapter = adapter
63
+ end
64
+
65
+ # @return [Hash]
66
+ def to_h
67
+ subjects = @tree.reload.subjects
68
+ mine = ours(subjects)
69
+ { seq: @seq,
70
+ at: iso(@at),
71
+ node: @node.wire(heartbeat_at: @at, me: true),
72
+ repo: @repo,
73
+ project: @project,
74
+ description: @description,
75
+ run: run(mine),
76
+ stations: Stations.all(@agents).map(&:wire),
77
+ plans: mine,
78
+ backlog: backlog(subjects, mine.map { it[:ordinal] }),
79
+ models: @adapter.models.map(&:wire),
80
+ autocompact: Adapters::AUTOCOMPACT / 100.0,
81
+ log: @log }
82
+ end
83
+
84
+ private
85
+
86
+ # ---- run --------------------------------------------------------------
87
+
88
+ # @param plans [Array<Hash>]
89
+ # @return [Hash]
90
+ def run(plans)
91
+ { status: run_status,
92
+ started_at: @started_at && iso(@started_at),
93
+ up: @board.up.to_i,
94
+ down: @board.down.to_i,
95
+ cost: plans.sum { it[:cost] }.round(4),
96
+ agents_working: @board.running.to_i }
97
+ end
98
+
99
+ # @return [String]
100
+ def run_status
101
+ return "idle" if @board.running.to_i.zero? && @board.rows.empty? && @board.status == :running
102
+
103
+ @board.status.to_s
104
+ end
105
+
106
+ # ---- plans ------------------------------------------------------------
107
+
108
+ # The plans this node is working on: the board's, and any it has
109
+ # claimed but not yet started.
110
+ #
111
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
112
+ # @return [Array<Hash>]
113
+ def ours(subjects)
114
+ ordinals = (@board.plans + Array(@claims&.held)).uniq
115
+ subjects.select { |s| ordinals.include?(s.feature.ordinal.to_s) }.map { |s| plan(s) }
116
+ end
117
+
118
+ # @param subject [OpenLanes::Plans::Subject]
119
+ # @return [Hash] `Plan`
120
+ def plan(subject)
121
+ ordinal = subject.feature.ordinal.to_s
122
+ state = Plans::PlanState.for(subject.feature.path)
123
+ stages = state.stages
124
+ rows = @board.rows.select { it.ordinal == ordinal }
125
+ spent = @meter.for(ordinal, method(:price))
126
+ started = @meter.started_at(ordinal)
127
+ { ordinal:,
128
+ title: subject.feature.title,
129
+ slug: subject.feature.slug,
130
+ status: status(subject.status),
131
+ lane: Plans::Spec.for(subject).lane.to_s,
132
+ station: Stations.key_for(subject.status.key, before: stages.last&.fetch("from", nil)&.to_sym).to_s,
133
+ up: spent[:up],
134
+ down: spent[:down],
135
+ cost: spent[:cost],
136
+ started_at: started && iso(started),
137
+ elapsed: started ? (@at - started).round : 0,
138
+ cars: rows.map { car(it, subject, stages) },
139
+ pr: pull_request(subject),
140
+ questions: questions(subject, state),
141
+ interrupted: interrupted(stages, rows),
142
+ claimed_by: claimed_by(ordinal) }
143
+ end
144
+
145
+ # @param status [OpenLanes::Plans::Status]
146
+ # @return [Hash] `Status`
147
+ def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label }
148
+
149
+ # @param subjects [Array<OpenLanes::Plans::Subject>]
150
+ # @param taken [Array<String>] ordinals already on the board
151
+ # @return [Array<Hash>] plans a person could add to this run
152
+ def backlog(subjects, taken)
153
+ subjects.reject { |s| taken.include?(s.feature.ordinal.to_s) || FINISHED.include?(s.status.key) }
154
+ .reject { |s| foreign?(s.feature.ordinal.to_s) }
155
+ .map do |s|
156
+ { ordinal: s.feature.ordinal.to_s, title: s.feature.title, status: status(s.status),
157
+ lane: Plans::Spec.for(s).lane.to_s }
158
+ end
159
+ end
160
+
161
+ # ---- cars -------------------------------------------------------------
162
+
163
+ # @param row [OpenLanes::Engine::Board::Row]
164
+ # @param subject [OpenLanes::Plans::Subject]
165
+ # @param stages [Array<Hash>]
166
+ # @return [Hash] `Car`
167
+ def car(row, subject, stages)
168
+ { key: row.key,
169
+ agent: row.agent,
170
+ role: row.role.to_s,
171
+ round: row.round,
172
+ rounds: row.rounds,
173
+ model: model_id(row.model),
174
+ effort: stages.rfind { it["agent"] == row.agent }&.fetch("effort", nil),
175
+ state: car_state(row, subject),
176
+ phase: row.phase&.to_s,
177
+ message: row.message.to_s,
178
+ history: Array(row.history).first(Engine::Board::Row::HISTORY),
179
+ up: row.up.to_i,
180
+ down: row.down.to_i,
181
+ context: row.context,
182
+ context_limit: price(row.model)&.context,
183
+ elapsed: row.elapsed.to_i,
184
+ remaining: row.remaining,
185
+ held: @run_state.car_held?(row.key),
186
+ next_model: next_model(row) }
187
+ end
188
+
189
+ # The model a person queued for the agent's next round, until a round
190
+ # runs on it: from then on it is the model, not the next one.
191
+ #
192
+ # @param row [OpenLanes::Engine::Board::Row]
193
+ # @return [String, nil]
194
+ def next_model(row)
195
+ queued = @run_state.model_for(row.key)
196
+ queued unless queued == model_id(row.model)
197
+ end
198
+
199
+ # A running agent on a plan that has stopped for a question is stuck,
200
+ # not working: the page says so rather than spinning.
201
+ #
202
+ # @return [String]
203
+ def car_state(row, subject)
204
+ return "stuck" if row.running? && %i[blocked product_blocked].include?(subject.status.key)
205
+
206
+ row.state.to_s
207
+ end
208
+
209
+ # @param label [String, nil] a {Agents::Profile#label}: `opus`, or `codex:gpt-5`
210
+ # @return [String] the full model id when the adapter knows it
211
+ def model_id(label)
212
+ name = label.to_s.split(":", 2).last
213
+ adapter_for(label)&.model_entry(name)&.id || name
214
+ end
215
+
216
+ # The catalogue's entry for a model: its prices, and its context window.
217
+ #
218
+ # @param label [String, nil]
219
+ # @return [OpenLanes::Adapters::Model, nil]
220
+ def price(label) = adapter_for(label)&.model_entry(label.to_s.split(":", 2).last)
221
+
222
+ # @param label [String, nil]
223
+ # @return [OpenLanes::Adapters::Base, nil]
224
+ def adapter_for(label)
225
+ prefix = label.to_s.include?(":") ? label.to_s.split(":", 2).first : Adapters::DEFAULT
226
+ Adapters.known?(prefix) ? Adapters.for(prefix) : nil
227
+ end
228
+
229
+ # ---- pull request, interruption, claim --------------------------------
230
+
231
+ # @param subject [OpenLanes::Plans::Subject]
232
+ # @return [Hash, nil] `PullRequest`
233
+ def pull_request(subject)
234
+ pr = subject.pull_requests.find(&:open?) || subject.pull_requests.last or return nil
235
+ { number: pr.number.to_s[/\d+/].to_i, url: pr.url, state: pr_state(pr, subject), ci: nil, merged_at: nil }
236
+ end
237
+
238
+ # @return [String]
239
+ def pr_state(pr, subject)
240
+ return "merged" if pr.merged?
241
+ return "closed" unless pr.open?
242
+
243
+ { rejected: "rejected", approved: "approved" }.fetch(subject.status.key, "open")
244
+ end
245
+
246
+ # The latest stage, when it is an interruption nobody has resumed.
247
+ #
248
+ # @param stages [Array<Hash>]
249
+ # @param rows [Array<OpenLanes::Engine::Board::Row>]
250
+ # @return [Hash, nil]
251
+ def interrupted(stages, rows)
252
+ last = stages.last
253
+ return nil unless last && last["status"] == "Interrupted" && rows.none? { it.agent == last["agent"] && it.running? }
254
+
255
+ { agent: last["agent"], at: last["finished_at"] || last["started_at"], note: last["note"].to_s }
256
+ end
257
+
258
+ # @param ordinal [String]
259
+ # @return [String, nil] the node id, without its checkout
260
+ def claimed_by(ordinal) = @claims&.held_by(ordinal)&.then { it.split(":").first }
261
+
262
+ # @param ordinal [String]
263
+ # @return [Boolean]
264
+ def foreign?(ordinal) = !@claims.nil? && @claims.foreign?(ordinal)
265
+
266
+ # ---- questions --------------------------------------------------------
267
+
268
+ # The questions `blocked.md` still asks, each with its body, whether it
269
+ # is a yes/no question, and who asked.
270
+ #
271
+ # @param subject [OpenLanes::Plans::Subject]
272
+ # @param state [OpenLanes::Plans::PlanState]
273
+ # @return [Array<Hash>] `Question`
274
+ def questions(subject, state)
275
+ open = Lifecycle::Unblocker.questions(subject).reject(&:answered)
276
+ return [] if open.empty?
277
+
278
+ bodies = bodies(subject.read("blocked.md").to_s)
279
+ asker = state.signatures.rfind { it["status"] == "Blocked" }
280
+ asked_at = asker&.fetch("at", nil) || iso(File.mtime(File.join(subject.feature.path, "blocked.md")))
281
+ open.map { |q| question(subject, q, bodies.fetch(q.number, ""), asker, asked_at) }
282
+ end
283
+
284
+ # @return [Hash]
285
+ def question(subject, open, body, asker, asked_at)
286
+ id = "#{subject.feature.ordinal}/B#{open.number}"
287
+ yes_no = open.heading.match?(YES_NO) || body.match?(YES_NO)
288
+ { id:,
289
+ number: open.number,
290
+ heading: open.heading.sub(YES_NO, "").strip,
291
+ body: body.lines.grep_v(/\A\s*#{YES_NO.source}\s*\z/i).join.strip,
292
+ agent: asker&.fetch("agent", nil),
293
+ owner: subject.status.key == :product_blocked ? "product" : "technical",
294
+ answers: yes_no ? %w[yes no] : nil,
295
+ held: @run_state.question_held?(id),
296
+ asked_at: }
297
+ end
298
+
299
+ # @param text [String] the whole of blocked.md
300
+ # @return [Hash{Integer => String}] each open question's number to the
301
+ # lines under its heading, up to the next heading
302
+ def bodies(text)
303
+ found = {}
304
+ current = nil
305
+ text.each_line do |line|
306
+ if (number = line[Plans::OPEN_BLOCK, 1])
307
+ current = found[number.to_i] = +""
308
+ elsif line.match?(HEADING) || line.match?(Plans::ANSWER_BLOCK)
309
+ current = nil
310
+ elsif current
311
+ current << line
312
+ end
313
+ end
314
+ found
315
+ end
316
+
317
+ # @param time [Time]
318
+ # @return [String]
319
+ def iso(time) = time.utc.iso8601
320
+ end
321
+ end
322
+ end