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,505 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # The engine's side of the command stream (W13). Called at the top of
9
+ # every tick, on the loop thread, it reads what the page sent through the
10
+ # `engine` consumer group, applies each command to the dispatcher, and
11
+ # answers it.
12
+ #
13
+ # Delivery is at least once: a command is acknowledged only after it has
14
+ # been applied, in the same `MULTI` that records its reply in `done` and
15
+ # appends it to `replies`. A command whose id is already in `done` was
16
+ # applied before the engine died, so it is acknowledged and its first
17
+ # reply sent again, and nothing is applied twice.
18
+ #
19
+ # Each kind is one method, named in {HANDLERS}. A handler raises
20
+ # {Refusal} with the reason a person will read; anything it returns is
21
+ # a reason to show beside "done".
22
+ class Commands
23
+ # A command the engine will not apply, with the reason why.
24
+ class Refusal < StandardError; end
25
+
26
+ # Commands read per tick, so a flood cannot hold the loop.
27
+ BATCH = 50
28
+
29
+ # Seconds between sweeps of `done` for entries older than a day.
30
+ SWEEP_EVERY = 600
31
+
32
+ # Command kind to the method that applies it.
33
+ HANDLERS = {
34
+ "answer" => :answer,
35
+ "hold_blocked" => :hold_blocked,
36
+ "release_hold" => :release_hold,
37
+ "resume" => :resume,
38
+ "discard" => :discard,
39
+ "message" => :message,
40
+ "hold" => :signal,
41
+ "wrap_up" => :signal,
42
+ "compact" => :compact,
43
+ "stop" => :signal,
44
+ "stop_all" => :stop_all,
45
+ "set_model" => :set_model,
46
+ "switch_model" => :switch_model,
47
+ "add_plan" => :add_plan,
48
+ "create_plans" => :create_plans
49
+ }.freeze
50
+
51
+ # Plans a person may not add to a run: there is nothing left to do.
52
+ FINISHED = Snapshot::FINISHED
53
+
54
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
55
+ # @param keys [OpenLanes::Live::Keys]
56
+ # @param node [OpenLanes::Live::Node]
57
+ # @param run_state [OpenLanes::Live::RunState]
58
+ # @param record [OpenLanes::Live::RunRecord, nil] writes the run state
59
+ # in the same `MULTI` as each reply
60
+ # @param claims [OpenLanes::Live::Claims, nil]
61
+ # @param clock [Proc] wall time
62
+ def initialize(store:, keys:, node:, run_state: RunState.new, record: nil, claims: nil, clock: -> { Time.now })
63
+ @store = store
64
+ @keys = keys
65
+ @node = node
66
+ @run_state = run_state
67
+ @record = record
68
+ @claims = claims
69
+ @clock = clock
70
+ @ready = false
71
+ @swept_at = nil
72
+ @pending = {}
73
+ end
74
+
75
+ # Apply everything waiting: this consumer's unacknowledged commands
76
+ # first, which a previous engine read and died on, then new ones.
77
+ #
78
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
79
+ # @return [Array<OpenLanes::Live::Reply>] what was answered
80
+ def call(dispatcher)
81
+ prepare
82
+ replies = collect
83
+ entries = read("0").reject { @pending.key?(it.id) }
84
+ entries += read(">") if entries.size < BATCH
85
+ replies += entries.filter_map { |entry| handle(entry, dispatcher) }
86
+ # Whatever a command changed is acted on this tick, not at the end
87
+ # of an idle run's rescan interval.
88
+ dispatcher.wake! unless entries.empty? && replies.empty?
89
+ sweep
90
+ replies
91
+ rescue Unavailable => e
92
+ UI.log("commands not read: #{e.message}")
93
+ []
94
+ end
95
+
96
+ # @return [Proc] for {Engine::Dispatcher#before_tick}
97
+ def to_proc = method(:call).to_proc
98
+
99
+ private
100
+
101
+ # @return [void]
102
+ def prepare
103
+ return if @ready
104
+
105
+ @store.xgroup_create(@keys.commands, Keys::GROUP, from: "0")
106
+ @ready = true
107
+ end
108
+
109
+ # @param id [String] "0" for this consumer's pending, ">" for new
110
+ # @return [Array<OpenLanes::Live::Store::Entry>]
111
+ def read(id) = @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
112
+
113
+ # Answer the commands whose work finished since the last tick. A
114
+ # command still at work is neither answered nor acknowledged, so an
115
+ # engine that dies meanwhile gets it again.
116
+ #
117
+ # @return [Array<OpenLanes::Live::Reply>]
118
+ def collect
119
+ @pending.select { |_, (_, _, work)| work.finished? }.map do |entry_id, (entry, id, work)|
120
+ @pending.delete(entry_id)
121
+ reply(entry, id, work.status, work.reason)
122
+ end
123
+ end
124
+
125
+ # @param entry [OpenLanes::Live::Store::Entry]
126
+ # @param dispatcher [OpenLanes::Engine::Dispatcher]
127
+ # @return [OpenLanes::Live::Reply, nil] nil while the command is still at work
128
+ def handle(entry, dispatcher)
129
+ command = Command.parse(entry.fields["json"].to_s)
130
+ if (first = @store.hget(@keys.done, command.id))
131
+ return answer_again(entry, first)
132
+ end
133
+
134
+ status, reason = apply(command, dispatcher)
135
+ return reply(entry, command.id, status, reason) unless status == :pending
136
+
137
+ @pending[entry.id] = [entry, command.id, reason]
138
+ nil
139
+ rescue Command::Invalid => e
140
+ reply(entry, invalid_id(entry), "refused", e.message)
141
+ end
142
+
143
+ # @return [Array(String, String)] status and reason, or `:pending` and
144
+ # the work still under way
145
+ def apply(command, dispatcher)
146
+ reason = send(HANDLERS.fetch(command.kind), command, dispatcher)
147
+ return [:pending, reason] if reason.respond_to?(:finished?)
148
+
149
+ ["done", reason.is_a?(String) ? reason : nil]
150
+ rescue Refusal, OpenLanes::Error => e
151
+ ["refused", e.message]
152
+ rescue StandardError => e
153
+ UI.log("#{command.kind} failed: #{e.class}: #{e.message}", plan: command.plan)
154
+ ["refused", "#{command.kind} failed: #{e.message}"]
155
+ end
156
+
157
+ # @return [OpenLanes::Live::Reply]
158
+ def reply(entry, id, status, reason)
159
+ reply = Reply.new(id:, status:, reason:, at: @clock.call)
160
+ json = reply.to_json
161
+ @store.multi do |tx|
162
+ tx.hset(@keys.done, id, json)
163
+ tx.xadd(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
164
+ tx.xack(@keys.commands, Keys::GROUP, entry.id)
165
+ @record&.write(tx)
166
+ end
167
+ reply
168
+ end
169
+
170
+ # @return [OpenLanes::Live::Reply] the first reply, sent again
171
+ def answer_again(entry, first)
172
+ @store.multi do |tx|
173
+ tx.xadd(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
174
+ tx.xack(@keys.commands, Keys::GROUP, entry.id)
175
+ end
176
+ data = JSON.parse(first)
177
+ Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
178
+ end
179
+
180
+ # A command too malformed to carry an id is answered under its stream
181
+ # entry's id, so the reply still lands somewhere a person can see.
182
+ #
183
+ # @return [String]
184
+ def invalid_id(entry)
185
+ id = JSON.parse(entry.fields["json"].to_s)["id"].to_s
186
+ id.match?(Command::ID) ? id : entry.id
187
+ rescue JSON::ParserError, TypeError, NoMethodError
188
+ entry.id
189
+ end
190
+
191
+ # Forget replies older than {Keys::DONE_TTL}; a command redelivered a
192
+ # day late has bigger problems than being applied twice.
193
+ #
194
+ # @return [void]
195
+ def sweep
196
+ now = @clock.call
197
+ return if @swept_at && now - @swept_at < SWEEP_EVERY
198
+
199
+ @swept_at = now
200
+ stale = @store.hgetall(@keys.done).reject { |_, json| (at = reply_time(json)) && now - at <= Keys::DONE_TTL }
201
+ @store.hdel(@keys.done, *stale.keys)
202
+ end
203
+
204
+ # @param json [String] a reply in `done`
205
+ # @return [Time, nil] when it was answered; nil for an entry nothing can
206
+ # read, which the sweep removes rather than letting it stop the sweep
207
+ def reply_time(json)
208
+ Time.iso8601(JSON.parse(json).fetch("at"))
209
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError, NoMethodError
210
+ nil
211
+ end
212
+
213
+ # ---- questions --------------------------------------------------------
214
+
215
+ # `## A<n>` under the question, signed, for `lando-broker` to fold in
216
+ # (W10). The dispatcher hands the plan to the broker once every
217
+ # question has an answer, and the plan leaves ⭕️ when the file is
218
+ # drained. The answer carries the command's id in a comment, so a
219
+ # command applied again after a crash finds it and writes nothing.
220
+ #
221
+ # @return [String, nil]
222
+ def answer(command, dispatcher)
223
+ subject, question = question!(command, dispatcher)
224
+ path = File.join(subject.feature.path, "blocked.md")
225
+ return nil if File.read(path).include?(marker(command))
226
+ return "B#{question.number} already has an answer waiting" if question.answered
227
+
228
+ File.open(path, "a") do |file|
229
+ file.write("\n## A#{question.number}\n\n#{command["text"]}\n\n" \
230
+ "Answered by #{@node.user} on #{@clock.call.strftime("%Y-%m-%d")}\n#{marker(command)}\n")
231
+ end
232
+ @run_state.hold_question(question_id(subject, question), held: false)
233
+ dispatcher.wake!
234
+ nil
235
+ end
236
+
237
+ # @return [nil]
238
+ def hold_blocked(command, dispatcher) = hold_question(command, dispatcher, held: true)
239
+
240
+ # @return [nil]
241
+ def release_hold(command, dispatcher) = hold_question(command, dispatcher, held: false)
242
+
243
+ # @return [nil]
244
+ def hold_question(command, dispatcher, held:)
245
+ subject, question = question!(command, dispatcher)
246
+ @run_state.hold_question(question_id(subject, question), held:)
247
+ nil
248
+ end
249
+
250
+ # @return [Array(OpenLanes::Plans::Subject, OpenLanes::Lifecycle::Unblocker::Question)]
251
+ def question!(command, dispatcher)
252
+ subject = subject!(command, dispatcher)
253
+ number = command["question"][/\AB?(\d+)\z/i, 1] or raise Refusal, "#{command["question"]} is not a question number like B2"
254
+ question = Lifecycle::Unblocker.questions(subject).find { it.number == number.to_i } or
255
+ raise Refusal, "#{command.plan} has no open question B#{number}"
256
+ [subject, question]
257
+ end
258
+
259
+ # @return [String]
260
+ def question_id(subject, question) = "#{subject.feature.ordinal}/B#{question.number}"
261
+
262
+ # ---- agents -----------------------------------------------------------
263
+
264
+ # The agent the plan's history says was interrupted is started again,
265
+ # with the person's note waiting in its mailbox.
266
+ #
267
+ # @return [String]
268
+ def resume(command, dispatcher)
269
+ subject = subject!(command, dispatcher)
270
+ last = Plans::PlanState.for(subject.feature.path).stages.rfind { it["status"] == "Interrupted" }
271
+ agent = command.agent.to_s.empty? ? last&.fetch("agent", nil) : command.agent
272
+ raise Refusal, "#{command.plan} has no interrupted agent to resume" unless agent
273
+
274
+ note = command["note"].empty? ? "carry on from your last resume note" : command["note"]
275
+ Plans::Mailbox.new(dir: subject.feature.path).append(from: "human",
276
+ to: agent,
277
+ body: "RESUME: #{note}",
278
+ command: command.id)
279
+ dispatcher.resume(subject.feature.ordinal, agent)
280
+ dispatcher.runner.add_plan(subject.feature.ordinal)
281
+ dispatcher.wake!
282
+ "#{agent} resumes on #{command.plan} at its next turn"
283
+ end
284
+
285
+ # Plan-wide (W9): every agent on the plan stops, its worktree is reset,
286
+ # and the folder goes back one state. The reply waits for all three.
287
+ #
288
+ # @return [OpenLanes::Engine::Dispatcher::Discard] still at work
289
+ def discard(command, dispatcher)
290
+ subject = subject!(command, dispatcher)
291
+ dispatcher.discard(subject.feature.ordinal)
292
+ end
293
+
294
+ # A person's message, into the plan's mailbox, from `human` (W10). To
295
+ # the agent named, or every agent working the plan when none is. Each
296
+ # entry carries the command's id, so a command redelivered after the
297
+ # engine died between the append and its reply appends nothing (W13).
298
+ #
299
+ # @return [nil]
300
+ def message(command, dispatcher)
301
+ subject = subject!(command, dispatcher)
302
+ to = command.agent.to_s.empty? ? running_on(dispatcher, command.plan) : [command.agent]
303
+ raise Refusal, "no agent named, and none is working #{command.plan}" if to.empty?
304
+
305
+ mailbox = Plans::Mailbox.new(dir: subject.feature.path)
306
+ to.each do |agent|
307
+ mailbox.append(from: "human", to: agent, body: command["text"], command: command.id)
308
+ @run_state.hold_car("#{command.plan}/#{agent}", held: false)
309
+ end
310
+ nil
311
+ end
312
+
313
+ # HOLD, WRAP_UP or STOP into one agent's control file.
314
+ #
315
+ # @return [nil]
316
+ def signal(command, dispatcher)
317
+ dispatcher.signal(command.car, Execution::Control::WORDS.fetch(command.kind)) or
318
+ raise Refusal, "#{command.car} is not running"
319
+ @run_state.hold_car(command.car, held: command.kind == "hold")
320
+ nil
321
+ end
322
+
323
+ # Stop All Local: every agent on this machine is told to stop, and once
324
+ # they have gone the engine ends, and the control room with it. That is
325
+ # the one way a run with its control room ends from the page; it never
326
+ # ends on its own, since the page can always create more work.
327
+ # Other machines keep running.
328
+ #
329
+ # @return [String]
330
+ def stop_all(_command, dispatcher)
331
+ Execution::Control.quit!
332
+ count = dispatcher.running.size
333
+ "stopping #{count} agent#{"s" unless count == 1} on #{@node.label}; " \
334
+ "the engine and the control room shut down once they have gone"
335
+ end
336
+
337
+ # The model for the agent's next round on this plan (W24).
338
+ #
339
+ # @return [String]
340
+ def set_model(command, dispatcher)
341
+ id = model!(command, dispatcher)
342
+ dispatcher.prefer_model(command.plan, command.agent, id) or
343
+ raise Refusal, "this run's executor cannot change an agent's model"
344
+ @run_state.choose_model(command.car, id)
345
+ "#{command.agent} runs on #{id} from its next round"
346
+ end
347
+
348
+ # Compact the agent's session now (W28): it stops at its next step,
349
+ # leaving a resume note, its session is compacted with `--resume`, and
350
+ # it carries on in that session on the same model. Answered once the
351
+ # continuation has started.
352
+ #
353
+ # @return [OpenLanes::Execution::Executor::Compaction] still at work
354
+ # @raise [Refusal] when it cannot be compacted
355
+ def compact(command, dispatcher)
356
+ job = dispatcher.job(command.car) or raise Refusal, "#{command.car} is not running"
357
+ adapter = dispatcher.adapter_for(job)
358
+ raise Refusal, "#{adapter.name} cannot resume a session, so #{command.agent} cannot be compacted" unless adapter.compacts?
359
+ return dispatcher.compact(command.car) if job.handle.session
360
+
361
+ # Nothing is compacted, so the reply is a refusal, though the agent
362
+ # still restarts in a fresh session, which is as compact as it gets.
363
+ dispatcher.requeue(command.car)
364
+ raise Refusal,
365
+ "#{command.agent} has no session id yet, so it cannot be compacted: it stops at its next step " \
366
+ "and restarts from its resume note in a fresh session"
367
+ end
368
+
369
+ # The model now (W27): the agent is stopped with a resume note, its
370
+ # session compacted, and carried on in it on the new model (W28). An
371
+ # agent whose session cannot be resumed is started again on the model
372
+ # from its resume note instead, in a fresh session.
373
+ #
374
+ # @return [OpenLanes::Execution::Executor::Compaction, String]
375
+ def switch_model(command, dispatcher)
376
+ job = dispatcher.job(command.car) or
377
+ raise Refusal, "#{command.car} is not running; use set_model for its next round"
378
+
379
+ set_model(command, dispatcher)
380
+ model = @run_state.model_for(command.car)
381
+ adapter = dispatcher.adapter_for(job)
382
+ return dispatcher.compact(command.car, model:) if adapter.compacts? && job.handle.session
383
+
384
+ dispatcher.requeue(command.car)
385
+ why = adapter.compacts? ? "it has no session id yet" : "#{adapter.name} cannot resume a session"
386
+ "#{command.agent} stops at its next step and restarts on #{model} from its resume note " \
387
+ "in a fresh session: #{why}"
388
+ end
389
+
390
+ # @return [String] the full model id
391
+ def model!(command, dispatcher)
392
+ subject!(command, dispatcher)
393
+ agent = dispatcher.runner.agents.find(command.agent) or raise Refusal, "no agent called #{command.agent}"
394
+ adapter = Adapters.for(agent.adapter)
395
+ return command["model"] if adapter.models.empty?
396
+
397
+ entry = adapter.model_entry(command["model"]) or raise Refusal, "#{adapter.name} offers no model #{command["model"]}"
398
+ # What the CLI will actually be asked for, after any ceiling, so the
399
+ # reply and the run state never name a model that will not run.
400
+ adapter.model(entry.id)
401
+ end
402
+
403
+ # ---- plans ------------------------------------------------------------
404
+
405
+ # @return [String, nil]
406
+ def add_plan(command, dispatcher)
407
+ ordinal = Plans::Ordinal.parse(command["ordinal"]) or raise Refusal, "#{command["ordinal"]} is not a plan number"
408
+ subject = dispatcher.runner.tree.reload.find(ordinal) or raise Refusal, "there is no plan #{ordinal}"
409
+ raise Refusal, "#{ordinal} is #{subject.status}, with nothing left to do" if FINISHED.include?(subject.status.key)
410
+
411
+ claim!(ordinal)
412
+ dispatcher.runner.add_plan(ordinal)
413
+ dispatcher.wake!
414
+ nil
415
+ end
416
+
417
+ # The idea becomes a ⚪️ plan whose spec.md opens with it, then joins the run.
418
+ #
419
+ # @return [String]
420
+ def create_plans(command, dispatcher)
421
+ tree = dispatcher.runner.tree
422
+ idea = command["idea"]
423
+ if (made = tree.reload.subjects.find { it.read("spec.md").to_s.include?(marker(command)) })
424
+ return created(made.feature, dispatcher)
425
+ end
426
+
427
+ path = Lifecycle.create(dir: tree.dir, words: idea.split.first(8)).either(
428
+ ->(created) { created },
429
+ ->(problem) { raise Refusal, "could not create the plan: #{problem}" }
430
+ )
431
+ feature = Plans::Feature.parse(path)
432
+ spec = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed: "#{idea}\n\n#{marker(command)}")
433
+ .write_scaffold!
434
+ settings = idea_frontmatter(command.payload["frontmatter"])
435
+ File.write(spec, "#{YAML.dump(settings)}---\n\n#{File.read(spec)}") unless settings.empty?
436
+ created(feature, dispatcher)
437
+ end
438
+
439
+ # The idea dialog's form (W19) as spec.md frontmatter, in
440
+ # {Plans::Spec}'s vocabulary. It used to be sent and dropped, so a plan
441
+ # the page asked for in the quick lane at depth fast ran the full lane
442
+ # on every agent's default model. `auto` means "let the plan decide",
443
+ # which is what leaving the key out already says, and `research` has
444
+ # no key in spec.md to land in, so neither is written. Anything else
445
+ # is passed through as typed: {Plans::Spec#problems} names a bad
446
+ # value, the way it does for one typed by hand.
447
+ #
448
+ # @param form [Hash, nil] `payload.frontmatter`, as the page sends it
449
+ # @return [Hash{String => Object}] empty when there is nothing to say
450
+ def idea_frontmatter(form)
451
+ return {} unless form.is_a?(Hash)
452
+
453
+ form = form.transform_keys(&:to_s)
454
+ form.slice("lane", "frontend", "depth").each_with_object({}) do |(key, value), out|
455
+ text = value.to_s.strip
456
+ next if text.empty? || text == "auto"
457
+
458
+ out[key] = key == "frontend" && %w[true false].include?(text) ? text == "true" : text
459
+ end
460
+ end
461
+
462
+ # @return [String]
463
+ def created(feature, dispatcher)
464
+ claim!(feature.ordinal)
465
+ dispatcher.runner.add_plan(feature.ordinal)
466
+ dispatcher.wake!
467
+ "created #{feature.ordinal} #{feature.slug}"
468
+ end
469
+
470
+ # What a file written by a command carries, so the command applied a
471
+ # second time finds its own work and does not repeat it (W13).
472
+ #
473
+ # @param command [OpenLanes::Live::Command]
474
+ # @return [String]
475
+ def marker(command) = "<!-- cmd:#{command.id} -->"
476
+
477
+ # @return [void]
478
+ def claim!(ordinal)
479
+ return if @claims.nil? || @claims.claim(ordinal)
480
+
481
+ raise Refusal, "#{ordinal} is being worked on #{holder_label(@claims.held_by(ordinal))}"
482
+ end
483
+
484
+ # @param holder [String, nil] a node name
485
+ # @return [String] `user@host` when the holder's heartbeat says
486
+ def holder_label(holder)
487
+ json = holder && @store.hget(@keys.nodes, holder)
488
+ json ? JSON.parse(json).values_at("user", "host").join("@") : "another machine"
489
+ end
490
+
491
+ # ---- lookups ----------------------------------------------------------
492
+
493
+ # @return [OpenLanes::Plans::Subject]
494
+ def subject!(command, dispatcher)
495
+ ordinal = Plans::Ordinal.parse(command.plan.to_s) or raise Refusal, "#{command.plan} is not a plan number"
496
+ dispatcher.runner.tree.reload.find(ordinal) or raise Refusal, "there is no plan #{command.plan}"
497
+ end
498
+
499
+ # @return [Array<String>] agents working the plan now
500
+ def running_on(dispatcher, plan)
501
+ dispatcher.running.filter_map { |job| job.task.agent.name if job.key.start_with?("#{plan}/") }
502
+ end
503
+ end
504
+ end
505
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # Every Redis key openlanes writes, built here and nowhere else, as
6
+ # `.plans/004.00/contract.md` lays them out. `P` is
7
+ # `openlanes:<repo>`; a node is `<node id>:<checkout id>`.
8
+ #
9
+ # Methods that name one node's key take that node, defaulting to this
10
+ # one, so the API server reads another machine's snapshot through the
11
+ # same method the engine writes its own with.
12
+ #
13
+ # @!attribute [r] repo
14
+ # @return [String] `github.com/owner/name`
15
+ # @!attribute [r] node
16
+ # @return [String] this node's `<node id>:<checkout id>`
17
+ Keys = Data.define(:repo, :node) do
18
+ # @return [String] `openlanes:<repo>`
19
+ def base = "#{Keys::NAMESPACE}:#{repo}"
20
+
21
+ # Hash: node name to its `Node` JSON, refreshed every heartbeat.
22
+ #
23
+ # @return [String]
24
+ def nodes = "#{base}:nodes"
25
+
26
+ # String: the latest `Snapshot` JSON, `EX 30`. A node is alive while it exists.
27
+ #
28
+ # @param of [String] a node name
29
+ # @return [String]
30
+ def snapshot(of = node) = "#{base}:#{of}:snapshot"
31
+
32
+ # Stream: `{seq, json}` per published snapshot.
33
+ #
34
+ # @param of [String]
35
+ # @return [String]
36
+ def board(of = node) = "#{base}:#{of}:board"
37
+
38
+ # Stream: `{json}`, one `Command`, read by the engine through {Keys::GROUP}.
39
+ #
40
+ # @param of [String]
41
+ # @return [String]
42
+ def commands(of = node) = "#{base}:#{of}:commands"
43
+
44
+ # Stream: `{json}`, one `Reply`.
45
+ #
46
+ # @param of [String]
47
+ # @return [String]
48
+ def replies(of = node) = "#{base}:#{of}:replies"
49
+
50
+ # Hash: command id to its first `Reply` JSON, for redelivery.
51
+ #
52
+ # @param of [String]
53
+ # @return [String]
54
+ def done(of = node) = "#{base}:#{of}:done"
55
+
56
+ # Hash: `seq` and `json`, the run state a person set from the page
57
+ # (held questions and cars, queued models), so a restarted engine
58
+ # gets it back (W6). {RunRecord} writes it; the engine flushes it to
59
+ # `.plans/openlanes-state.json` as well.
60
+ #
61
+ # @param of [String]
62
+ # @return [String]
63
+ def run(of = node) = "#{base}:#{of}:run"
64
+
65
+ # String: the node name holding a plan, `SET NX EX`.
66
+ #
67
+ # @param ordinal [#to_s] e.g. "004.00"
68
+ # @return [String]
69
+ def claim(ordinal) = "#{base}:claim:#{ordinal}"
70
+ end
71
+
72
+ class Keys
73
+ # What every key starts with, so `SCAN openlanes:*` finds all of it.
74
+ NAMESPACE = "openlanes"
75
+
76
+ # The consumer group the engine reads `commands` through.
77
+ GROUP = "engine"
78
+
79
+ # Lengths of {#board} and {#replies}, kept approximately.
80
+ BOARD_LENGTH = 100
81
+ REPLIES_LENGTH = 1000
82
+
83
+ # Seconds a snapshot outlives its last refresh.
84
+ SNAPSHOT_TTL = 30
85
+
86
+ # Seconds a claim outlives its last renewal.
87
+ CLAIM_TTL = 90
88
+
89
+ # Seconds a `done` entry is kept before it is trimmed.
90
+ DONE_TTL = 86_400
91
+ end
92
+ end
93
+ end