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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # {Store}'s methods, kept in this process. The suite's Redis.
8
+ #
9
+ # It emulates exactly as much of Redis as the contract uses, and the
10
+ # shared examples in `spec/openlanes/live/store_examples.rb` hold it to
11
+ # the real thing: an expiry read off a clock the example controls,
12
+ # compare-and-delete, a consumer group's pending list, and approximate
13
+ # trimming done exactly (which `MAXLEN ~` permits: it promises at least
14
+ # that many, never exactly that many).
15
+ class MemoryStore
16
+ # @param clock [Proc] seconds, monotonic; an example moves it to expire keys
17
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
18
+ @clock = clock
19
+ @mutex = Monitor.new
20
+ @data = {}
21
+ @expiry = {}
22
+ @groups = Hash.new { |h, k| h[k] = {} }
23
+ @last_ms = 0
24
+ @last_seq = 0
25
+ end
26
+
27
+ # @return [String] what {Live.mask} shows for it
28
+ def url = "memory://"
29
+
30
+ # @return [Boolean]
31
+ def ping = true
32
+
33
+ # @return [void]
34
+ def close = nil
35
+
36
+ # ---- strings ----------------------------------------------------------
37
+
38
+ # @return [String, nil]
39
+ def get(key) = locked { live(key) }
40
+
41
+ # @return [Boolean] whether the value was written
42
+ def set(key, value, ex: nil, nx: false)
43
+ locked do
44
+ next false if nx && !live(key).nil?
45
+
46
+ @data[key] = value.to_s
47
+ ex ? @expiry[key] = now + ex : @expiry.delete(key)
48
+ true
49
+ end
50
+ end
51
+
52
+ # @return [Integer] how many were removed
53
+ def del(*keys) = locked { keys.count { |key| forget(key) } }
54
+
55
+ # @return [Boolean] whether the key exists to expire
56
+ def expire(key, seconds)
57
+ locked do
58
+ next false if live(key).nil?
59
+
60
+ @expiry[key] = now + seconds
61
+ true
62
+ end
63
+ end
64
+
65
+ # @return [Integer] seconds left, -1 without an expiry, -2 when absent
66
+ def ttl(key)
67
+ locked do
68
+ next -2 if live(key).nil?
69
+
70
+ @expiry.key?(key) ? (@expiry[key] - now).ceil : -1
71
+ end
72
+ end
73
+
74
+ # @return [Boolean]
75
+ def compare_and_delete(key, expected)
76
+ locked { live(key) == expected ? forget(key) : false }
77
+ end
78
+
79
+ # @return [Boolean]
80
+ def compare_and_expire(key, expected, seconds)
81
+ locked { live(key) == expected ? expire(key, seconds) : false }
82
+ end
83
+
84
+ # ---- hashes -----------------------------------------------------------
85
+
86
+ # @return [Boolean] whether the field is new
87
+ def hset(key, field, value)
88
+ locked do
89
+ hash = live(key) || (@data[key] = {})
90
+ fresh = !hash.key?(field.to_s)
91
+ hash[field.to_s] = value.to_s
92
+ fresh
93
+ end
94
+ end
95
+
96
+ # @return [String, nil]
97
+ def hget(key, field) = locked { live(key)&.fetch(field.to_s, nil) }
98
+
99
+ # @return [Hash{String => String}]
100
+ def hgetall(key) = locked { (live(key) || {}).dup }
101
+
102
+ # @return [Integer]
103
+ def hdel(key, *fields)
104
+ locked do
105
+ hash = live(key) or next 0
106
+ removed = fields.count { |f| hash.delete(f.to_s) }
107
+ forget(key) if hash.empty?
108
+ removed
109
+ end
110
+ end
111
+
112
+ # ---- streams ----------------------------------------------------------
113
+
114
+ # @return [String] the new entry's id
115
+ def xadd(key, fields, maxlen: nil)
116
+ locked do
117
+ stream = live(key) || (@data[key] = [])
118
+ id = next_id
119
+ stream << Store::Entry.new(id:, fields: fields.to_h { |k, v| [k.to_s, v.to_s] })
120
+ stream.shift(stream.size - maxlen) if maxlen && stream.size > maxlen
121
+ id
122
+ end
123
+ end
124
+
125
+ # @return [Integer]
126
+ def xlen(key) = locked { (live(key) || []).size }
127
+
128
+ # @return [Boolean] whether the group is new
129
+ def xgroup_create(key, group, from: "0")
130
+ locked do
131
+ stream = live(key) || (@data[key] = [])
132
+ next false if @groups[key].key?(group)
133
+
134
+ last = from == "$" ? stream.last&.id || "0-0" : from
135
+ @groups[key][group] = { last: id_of(last.include?("-") ? last : "#{last}-0"), pending: {} }
136
+ true
137
+ end
138
+ end
139
+
140
+ # @return [Array<Store::Entry>]
141
+ def xreadgroup(key, group, consumer, id: ">", count: 100)
142
+ locked do
143
+ state = @groups[key][group] or raise Unavailable, "NOGROUP #{group} on #{key}"
144
+ stream = live(key) || []
145
+ if id == ">"
146
+ fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
147
+ fresh.each { |e| state[:pending][e.id] = consumer }
148
+ state[:last] = id_of(fresh.last.id) unless fresh.empty?
149
+ fresh
150
+ else
151
+ floor = id_of(id.include?("-") ? id : "#{id}-0")
152
+ state[:pending].select { |pid, who| who == consumer && (id_of(pid) <=> floor).positive? }.keys
153
+ .sort_by { id_of(it) }.first(count)
154
+ .map { |pid| stream.find { |e| e.id == pid } || Store::Entry.new(id: pid, fields: {}) }
155
+ end
156
+ end
157
+ end
158
+
159
+ # @return [Integer]
160
+ def xack(key, group, *ids)
161
+ locked do
162
+ pending = @groups.dig(key, group, :pending) or next 0
163
+ ids.count { |id| pending.delete(id) }
164
+ end
165
+ end
166
+
167
+ # Never waits: nothing else can add to this process's streams while
168
+ # the caller is blocked, so +block+ is accepted and ignored.
169
+ #
170
+ # @return [Array<Store::Entry>]
171
+ def xread(key, id: "0", count: 100, block: nil) # rubocop:disable Lint/UnusedMethodArgument
172
+ locked do
173
+ stream = live(key) || []
174
+ next [] if id == "$"
175
+
176
+ floor = id_of(id.include?("-") ? id : "#{id}-0")
177
+ stream.select { |e| (id_of(e.id) <=> floor).positive? }.first(count)
178
+ end
179
+ end
180
+
181
+ # @return [Array<Store::Entry>] oldest first
182
+ def xrange(key, start: "-", stop: "+", count: 100)
183
+ locked { within(live(key) || [], start, stop).first(count) }
184
+ end
185
+
186
+ # @return [Array<Store::Entry>] newest first
187
+ def xrevrange(key, stop: "+", start: "-", count: 100)
188
+ locked { within(live(key) || [], start, stop).reverse.first(count) }
189
+ end
190
+
191
+ # ---- keys -------------------------------------------------------------
192
+
193
+ # @return [Array<String>]
194
+ def scan(prefix) = locked { @data.keys.select { |k| k.start_with?(prefix) && !live(k).nil? }.sort }
195
+
196
+ # ---- transactions -----------------------------------------------------
197
+
198
+ # @yieldparam tx [Store::Transaction]
199
+ # @return [void]
200
+ def multi
201
+ tx = Store::Transaction.new
202
+ yield tx
203
+ locked { tx.calls.each { |name, args, kwargs| public_send(name, *args, **kwargs) } }
204
+ nil
205
+ end
206
+
207
+ private
208
+
209
+ # @return [Object] the block's value, under the lock
210
+ def locked(&) = @mutex.synchronize(&)
211
+
212
+ # @return [Float]
213
+ def now = @clock.call
214
+
215
+ # @return [Object, nil] the value, unless it has expired
216
+ def live(key)
217
+ if @expiry.key?(key) && @expiry[key] <= now
218
+ forget(key)
219
+ return nil
220
+ end
221
+ @data[key]
222
+ end
223
+
224
+ # @return [Boolean] whether there was anything to forget
225
+ def forget(key)
226
+ @expiry.delete(key)
227
+ @groups.delete(key)
228
+ !@data.delete(key).nil?
229
+ end
230
+
231
+ # Ids as Redis makes them: milliseconds, then a sequence within the
232
+ # millisecond, always increasing even if the clock does not.
233
+ #
234
+ # @return [String]
235
+ def next_id
236
+ ms = (Time.now.to_f * 1000).to_i
237
+ if ms > @last_ms
238
+ @last_ms = ms
239
+ @last_seq = 0
240
+ else
241
+ @last_seq += 1
242
+ end
243
+ "#{@last_ms}-#{@last_seq}"
244
+ end
245
+
246
+ # @return [Array(Integer, Integer)]
247
+ def id_of(id) = id.split("-", 2).map(&:to_i)
248
+
249
+ # @return [Array<Store::Entry>]
250
+ def within(stream, start, stop)
251
+ low = if start == "-"
252
+ [0, 0]
253
+ else
254
+ id_of(start.include?("-") ? start : "#{start}-0")
255
+ end
256
+ high = if stop == "+"
257
+ [Float::INFINITY, Float::INFINITY]
258
+ else
259
+ id_of(stop.include?("-") ? stop : "#{stop}-#{2**63}")
260
+ end
261
+ stream.select { (low..high).cover?(id_of(it.id)) }
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # What each plan has spent this run, kept across the board's rows.
6
+ #
7
+ # A row leaves the board ten seconds after its agent finishes, and what it
8
+ # spent must not leave the plan's total with it. So every row seen is
9
+ # remembered by plan, agent, round and invocation, at the highest figures
10
+ # it showed. The invocation matters because switch_model starts an agent
11
+ # again in the same round, on another model: keyed by round alone, the
12
+ # restart's smaller figures would hide the first run's spend, and the
13
+ # whole round would be priced at the second model.
14
+ class Meter
15
+ # One invocation of an agent's round on one plan, at its latest reading.
16
+ Spend = Data.define(:up, :down, :model)
17
+
18
+ def initialize
19
+ @spends = Hash.new { |h, k| h[k] = {} }
20
+ @first_seen = {}
21
+ end
22
+
23
+ # @param rows [Array<OpenLanes::Engine::Board::Row>]
24
+ # @param now [Time]
25
+ # @return [void]
26
+ def record(rows, now: Time.now)
27
+ rows.each do |row|
28
+ @first_seen[row.ordinal] ||= now
29
+ key = [row.agent, row.round, row.since]
30
+ known = @spends[row.ordinal][key]
31
+ @spends[row.ordinal][key] =
32
+ Spend.new(up: [row.up.to_i, known&.up.to_i].max, down: [row.down.to_i, known&.down.to_i].max, model: row.model)
33
+ end
34
+ end
35
+
36
+ # @param ordinal [String]
37
+ # @return [Time, nil] when this run first saw an agent on the plan
38
+ def started_at(ordinal) = @first_seen[ordinal]
39
+
40
+ # @param ordinal [String]
41
+ # @param price [#call] model name to an {Adapters::Model}, or nil
42
+ # @return [Hash{Symbol => Numeric}] `up`, `down` and `cost`
43
+ def for(ordinal, price)
44
+ spends = @spends.fetch(ordinal, {}).values
45
+ { up: spends.sum(&:up),
46
+ down: spends.sum(&:down),
47
+ cost: spends.sum { |s| price.call(s.model)&.cost(s.up, s.down).to_f }.round(4) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "etc"
5
+ require "socket"
6
+
7
+ module OpenLanes
8
+ module Live
9
+ # This machine and user, as other machines sharing the Redis see it (W26).
10
+ #
11
+ # The id is a digest of `user@host` so keys are short and uniform, not to
12
+ # hide anyone: the plain names travel beside it. The checkout id keeps
13
+ # two clones on one machine apart.
14
+ #
15
+ # @!attribute [r] id
16
+ # @return [String] 24 hex digits of SHA-256 of `user@host`
17
+ # @!attribute [r] user
18
+ # @return [String]
19
+ # @!attribute [r] host
20
+ # @return [String]
21
+ # @!attribute [r] ip
22
+ # @return [String, nil] shown, never used to identify the node
23
+ # @!attribute [r] checkout
24
+ # @return [String] the checkout's path, `$HOME` written as `~`
25
+ # @!attribute [r] checkout_id
26
+ # @return [String] 12 hex digits of SHA-256 of the checkout's realpath
27
+ Node = Data.define(:id, :user, :host, :ip, :checkout, :checkout_id) do
28
+ # @return [String] `<id>:<checkout_id>`, what keys and claims carry
29
+ def name = "#{id}:#{checkout_id}"
30
+
31
+ # @return [String] `user@host`, what the page shows
32
+ def label = "#{user}@#{host}"
33
+
34
+ # The `Node` of `web/src/api/types.ts`.
35
+ #
36
+ # @param heartbeat_at [Time]
37
+ # @param me [Boolean]
38
+ # @return [Hash]
39
+ def wire(heartbeat_at:, me: true)
40
+ { id:, user:, host:, ip:, checkout:, heartbeat_at: heartbeat_at.utc.iso8601, me: }
41
+ end
42
+ end
43
+
44
+ class Node
45
+ # Hex digits kept of each digest.
46
+ ID_LENGTH = 24
47
+ CHECKOUT_LENGTH = 12
48
+
49
+ class << self
50
+ # @param root [String] the checkout
51
+ # @param user [String]
52
+ # @param host [String]
53
+ # @param home [String]
54
+ # @return [OpenLanes::Live::Node]
55
+ def current(root:, user: login, host: Socket.gethostname, home: Dir.home, ip: address)
56
+ real = File.realpath(root)
57
+ new(id: digest("#{user}@#{host}", ID_LENGTH),
58
+ user:,
59
+ host:,
60
+ ip:,
61
+ checkout: tilde(real, home),
62
+ checkout_id: digest(real, CHECKOUT_LENGTH))
63
+ end
64
+
65
+ # @param text [String]
66
+ # @param length [Integer]
67
+ # @return [String]
68
+ def digest(text, length) = Digest::SHA256.hexdigest(text)[0, length]
69
+
70
+ # @return [String]
71
+ def login = Etc.getlogin || ENV.fetch("USER", "unknown")
72
+
73
+ # The first address that is neither loopback nor link-local: the one
74
+ # other machines on the network most likely reach this one by.
75
+ #
76
+ # @return [String, nil]
77
+ def address
78
+ Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? && !a.ipv4_multicast? }&.ip_address
79
+ end
80
+
81
+ # @param path [String]
82
+ # @param home [String]
83
+ # @return [String]
84
+ def tilde(path, home) = path == home || path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # The engine's side of the board (W13): handed each tick's
8
+ # {Engine::Board} as the runner's `on_board`, it writes the snapshot,
9
+ # appends it to the board stream, refreshes this node's heartbeat and
10
+ # renews its claims.
11
+ #
12
+ # It also keeps the latest log lines for the page, by tapping {UI.log}
13
+ # rather than asking callers to log twice.
14
+ #
15
+ # A Redis that goes away mid-run is reported once and otherwise
16
+ # ignored: the run is the agents' work, and the page is a view of it.
17
+ class Publisher
18
+ # Log lines a snapshot carries.
19
+ LOG_LINES = 200
20
+
21
+ # Words in a log line that mark it as something gone wrong.
22
+ PROBLEMS = /\b(?:fail(?:ed|ure)?|error|refused|unavailable|died)\b/i
23
+
24
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
25
+ # @param keys [OpenLanes::Live::Keys]
26
+ # @param node [OpenLanes::Live::Node]
27
+ # @param tree [OpenLanes::Plans::Tree]
28
+ # @param agents [OpenLanes::Agents::Registry]
29
+ # @param project [String]
30
+ # @param claims [OpenLanes::Live::Claims, nil]
31
+ # @param run_state [OpenLanes::Live::RunState]
32
+ # @param description [String, nil]
33
+ # @param clock [Proc] wall time
34
+ def initialize(store:, keys:, node:, tree:, agents:, project:, claims: nil, run_state: RunState.new,
35
+ description: nil, clock: -> { Time.now })
36
+ @store = store
37
+ @keys = keys
38
+ @node = node
39
+ @tree = tree
40
+ @agents = agents
41
+ @project = project
42
+ @claims = claims
43
+ @run_state = run_state
44
+ @description = description
45
+ @clock = clock
46
+ @meter = Meter.new
47
+ @log = []
48
+ @log_mutex = Mutex.new
49
+ @seq = nil
50
+ @warned = false
51
+ @on_lost = nil
52
+ end
53
+
54
+ # What to do with plans whose claim lapsed and went to another node:
55
+ # the session stops their agents, so two machines never work one plan.
56
+ #
57
+ # @yieldparam ordinals [Array<String>]
58
+ # @return [void]
59
+ def on_lost(&block)
60
+ @on_lost = block
61
+ end
62
+
63
+ # @return [OpenLanes::Live::Meter]
64
+ attr_reader :meter
65
+
66
+ # @return [Integer, nil] the last sequence number published
67
+ attr_reader :seq
68
+
69
+ # @return [Proc] for {Engine::Runner}'s `on_board:`
70
+ def to_proc = method(:call).to_proc
71
+
72
+ # @param board [OpenLanes::Engine::Board]
73
+ # @return [Hash, nil] the snapshot published, nil when Redis was not there
74
+ def call(board)
75
+ now = @clock.call
76
+ @started_at ||= now - (UI.monotonic - board.started_at)
77
+ @meter.record(board.rows, now:)
78
+ snapshot = build(board, now)
79
+ json = JSON.generate(snapshot)
80
+ @store.multi do |tx|
81
+ tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
82
+ tx.xadd(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
83
+ tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
84
+ end
85
+ lost = @claims&.renew || []
86
+ unless lost.empty?
87
+ UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
88
+ @on_lost&.call(lost)
89
+ end
90
+ @warned = false
91
+ snapshot
92
+ rescue Unavailable => e
93
+ unavailable(e)
94
+ nil
95
+ end
96
+
97
+ # Start keeping {UI.log}'s lines.
98
+ #
99
+ # @return [void]
100
+ def listen
101
+ return if @tap
102
+
103
+ @tap = UI.tap_log(method(:hear))
104
+ end
105
+
106
+ # Stop keeping them.
107
+ #
108
+ # @return [void]
109
+ def close
110
+ UI.untap_log(@tap) if @tap
111
+ @tap = nil
112
+ end
113
+
114
+ # One log line, as the page shows it. Public so a component that does
115
+ # not go through {UI.log} can still be heard.
116
+ #
117
+ # @param message [String]
118
+ # @param fields [Hash] `plan:`, `agent:` and the rest of {UI.log}'s columns
119
+ # @param source [String, nil]
120
+ # @param level [String, nil]
121
+ # @return [void]
122
+ def hear(message, fields = {}, source: nil, level: nil)
123
+ line = { at: @clock.call.utc.iso8601,
124
+ source: source || fields[:agent]&.to_s || "engine",
125
+ level: level || (message.to_s.match?(PROBLEMS) ? "warn" : "info"),
126
+ text: message.to_s,
127
+ plan: fields[:plan]&.to_s }
128
+ @log_mutex.synchronize do
129
+ @log << line
130
+ @log.shift(@log.size - LOG_LINES) if @log.size > LOG_LINES
131
+ end
132
+ end
133
+
134
+ # @return [Array<Hash>] the lines kept, newest last
135
+ def log = @log_mutex.synchronize { @log.dup }
136
+
137
+ private
138
+
139
+ # @param board [OpenLanes::Engine::Board]
140
+ # @param now [Time]
141
+ # @return [Hash]
142
+ def build(board, now)
143
+ Snapshot.new(board:,
144
+ tree: @tree,
145
+ agents: @agents,
146
+ node: @node,
147
+ repo: @keys.repo,
148
+ project: @project,
149
+ description: @description,
150
+ seq: next_seq,
151
+ at: now,
152
+ started_at: @started_at,
153
+ claims: @claims,
154
+ run_state: @run_state,
155
+ meter: @meter,
156
+ log:).to_h
157
+ end
158
+
159
+ # One more than the last published, even across an engine restart:
160
+ # the API server reads a change of `seq` as news, and a restart that
161
+ # counted from one again would look like the past.
162
+ #
163
+ # @return [Integer]
164
+ def next_seq
165
+ @seq ||= @store.xrevrange(@keys.board, count: 1).first&.fields&.fetch("seq", nil).to_i
166
+ @seq += 1
167
+ end
168
+
169
+ # @param error [OpenLanes::Live::Unavailable]
170
+ # @return [void]
171
+ def unavailable(error)
172
+ return if @warned
173
+
174
+ @warned = true
175
+ UI.log("board not published: #{error.message}")
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "open3"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # The repository a run belongs to, as the part of every key that machines
9
+ # sharing one Redis have in common (W26, W29): `github.com/<owner>/<name>`.
10
+ #
11
+ # Readable rather than hashed, so `redis-cli --scan` shows whose
12
+ # repository a key belongs to. The clone decides which factory a machine
13
+ # joins, and nothing else does, which is why there is no override.
14
+ module Repo
15
+ module_function
16
+
17
+ # `git@github.com:Owner/Name.git`, `https://github.com/owner/name`,
18
+ # `ssh://git@github.com/owner/name.git` and their trailing slashes and
19
+ # letter case all fold to one spelling.
20
+ #
21
+ # @param url [String]
22
+ # @return [String, nil] `host/owner/name`, nil for a string that is not
23
+ # a remote URL
24
+ def normalise(url)
25
+ text = url.to_s.strip.downcase.delete_suffix("/").delete_suffix(".git")
26
+ path = case text
27
+ when %r{\A[a-z][a-z0-9+.-]*://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+)\z},
28
+ %r{\A(?:[^@/]+@)?([^/:]+):(.+)\z} then "#{::Regexp.last_match(1)}/#{::Regexp.last_match(2)}"
29
+ end
30
+ path&.squeeze("/")
31
+ end
32
+
33
+ # The repository of the checkout at +root+: its `origin`, normalised,
34
+ # or, with no remote to share, `local/<name>-<12 hex>`, the directory's
35
+ # name and a digest of its real path. The name alone collided: two
36
+ # unrelated clones both called `app` on one Redis would have read each
37
+ # other's boards and taken each other's claims.
38
+ #
39
+ # @param root [String]
40
+ # @return [String]
41
+ def for(root)
42
+ out, status = Open3.capture2("git", "-C", root, "remote", "get-url", "origin", err: File::NULL)
43
+ (status.success? && normalise(out)) || local(root)
44
+ rescue SystemCallError
45
+ local(root)
46
+ end
47
+
48
+ # @param root [String]
49
+ # @return [String] `local/<basename>-<12 hex of SHA-256 of the real path>`
50
+ def local(root)
51
+ path = begin
52
+ File.realpath(root)
53
+ rescue SystemCallError
54
+ File.expand_path(root)
55
+ end
56
+ "local/#{File.basename(path)}-#{Digest::SHA256.hexdigest(path)[0, 12]}"
57
+ end
58
+ end
59
+ end
60
+ end