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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # One line of `run --log FILE`, laid out as fixed-width columns.
6
+ #
7
+ # The log file is the only record of a headless run, and it is shared: two
8
+ # `openlanes run` invocations pointed at the same `--log` both append to
9
+ # it, interleaved, and so does every round within one run. That is what the
10
+ # process id column is for. Without it a reader cannot tell one run's agents
11
+ # from another's, and "started 003.00" twice looks like a bug rather than two
12
+ # machines doing the same work.
13
+ #
14
+ # Fixed columns are what make the file readable by eye and by `awk` alike. A
15
+ # value wider than its column is cut rather than allowed to push the columns
16
+ # out, and a value that is missing still occupies its width, so a round header
17
+ # with no plan or agent of its own lines up with the lines underneath it.
18
+ #
19
+ # @example
20
+ # ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
21
+ # agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
22
+
23
+ # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
24
+ module ProgressLog
25
+ # Wall clock only. A log line answers "when", and the date is the file's.
26
+ TIME_FORMAT = "%H:%M:%S"
27
+
28
+ # Width of {TIME_FORMAT}'s output.
29
+ TIME_WIDTH = 8
30
+
31
+ # "003.00".
32
+ PLAN_WIDTH = 6
33
+
34
+ # Derived from the widest state as {Status#to_s} renders it, emoji
35
+ # included, so a state added to {STATUSES} with a longer label cannot
36
+ # silently knock the columns after it out of line — then trimmed by five
37
+ # cells: the emoji already names the state, so the longest labels can
38
+ # afford to lose their tails to keep the line short.
39
+ STATUS_WIDTH = Plans::STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
40
+
41
+ # The longest specialist name in `agents/` is `palpatine-planner` at 17,
42
+ # and the names are hyphenated words rather than a bounded vocabulary, so
43
+ # this leaves room for one more without a reflow.
44
+ AGENT_WIDTH = 20
45
+
46
+ # Enough for a 32-bit process id, right justified.
47
+ PID_WIDTH = 7
48
+
49
+ # "01". Rounds cap at two digits; a run that reaches a third has bigger
50
+ # problems than this column.
51
+ ROUND_WIDTH = 2
52
+
53
+ # " 907s". Anything longer than four digits of seconds is an agent nobody
54
+ # is still waiting on.
55
+ SECONDS_WIDTH = 6
56
+
57
+ # Between columns, inside the brackets.
58
+ SEPARATOR = " | "
59
+
60
+ # Cells from the opening bracket to the closing one, separators included.
61
+ # The message starts one space after this on every line, whatever fields
62
+ # that line happens to be missing, so a reader indenting a wrapped message
63
+ # has a number to indent by.
64
+ COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
65
+ SECONDS_WIDTH].freeze
66
+
67
+ # @see COLUMN_WIDTHS
68
+ LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
69
+
70
+ class << self
71
+ # Render one log line: bracketed fixed-width columns, then the message.
72
+ #
73
+ # No colour. The line goes to a file, where an escape sequence is neither
74
+ # readable nor the width it claims to be.
75
+ #
76
+ # @param message [String] the free-form tail, the only variable-width part
77
+ # @param plan [String, nil] the plan's ordinal, e.g. "003.00"
78
+ # @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
79
+ # @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
80
+ # @param seconds [Numeric, nil] how long this agent has been alive
81
+ # @param round [String, nil] which pass over the tree, e.g. "01"
82
+ # @param pid [Integer, nil] which run wrote the line
83
+ # @param at [Time] injectable, so a caller can render a fixed clock
84
+ # @return [String] one line, with no trailing newline
85
+ def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
86
+ round: nil, pid: Process.pid, at: Time.now)
87
+ columns = [
88
+ left(at.strftime(TIME_FORMAT), TIME_WIDTH),
89
+ left(plan, PLAN_WIDTH),
90
+ left(status, STATUS_WIDTH),
91
+ left(agent, AGENT_WIDTH),
92
+ right(round, ROUND_WIDTH),
93
+ right(pid, PID_WIDTH),
94
+ right(duration(seconds), SECONDS_WIDTH)
95
+ ]
96
+
97
+ text = message.to_s
98
+ prefix = "[#{columns.join(SEPARATOR)}]"
99
+ text.empty? ? prefix : "#{prefix} #{text}"
100
+ end
101
+
102
+ # @param seconds [Numeric, nil]
103
+ # @return [String] whole seconds with a trailing "s", or blank for nil
104
+ def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
105
+
106
+ # @param value [Object, nil]
107
+ # @param width [Integer] terminal cells
108
+ # @return [String] padded on the right to exactly +width+ cells
109
+ def left(value, width) = UI.fit(value, width)
110
+
111
+ # {UI.fit} pads on the right, which is the wrong end for a number. Fit
112
+ # first so an over-long value is still truncated to the column, then move
113
+ # the padding to the front.
114
+ #
115
+ # @param value [Object, nil]
116
+ # @param width [Integer] terminal cells
117
+ # @return [String] padded on the left to exactly +width+ cells
118
+ def right(value, width)
119
+ text = UI.fit(value, width).rstrip
120
+ UI.fit("", width - UI.display_width(text)) + text
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # Drives specialist agents over a `.plans` tree until nothing is left to
6
+ # dispatch.
7
+ #
8
+ # The loop itself is {Dispatcher}. This holds what the loop needs about
9
+ # the run: the tree and its scope, the roster, the executor, how many run
10
+ # at once, where each plan is checked out, and how a finished plan is
11
+ # published. Blocked plans (⭕️ 🅱️) are stepped around, never assigned.
12
+ class Runner
13
+ # What one agent did to one plan.
14
+ Attempt = Data.define(:ordinal,
15
+ :agent,
16
+ :from,
17
+ :to,
18
+ :ok,
19
+ :note,
20
+ :up,
21
+ :down,
22
+ :subagents,
23
+ :delegated,
24
+ :seconds,
25
+ :round,
26
+ :file,
27
+ :model,
28
+ :status) do
29
+ def initialize(round: 1, file: "", model: nil, status: nil, **rest) = super
30
+
31
+ # @return [Boolean] whether the plan actually moved
32
+ def advanced? = ok && from != to
33
+ end
34
+
35
+ # One unit of work: an agent, a plan, and the checkout it happens in.
36
+ Task = Data.define(:agent, :subject, :root, :checkout, :round) do
37
+ # @return [Hash] columns for the log
38
+ def log_fields
39
+ { plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
40
+ agent: agent.name, round: format("%02d", round) }
41
+ end
42
+ end
43
+
44
+ # @param rounds [Integer, nil] `--rounds`, capping each agent's own
45
+ # @param state [OpenLanes::Engine::StateFile, nil]
46
+ # @param sleeper [Proc]
47
+ # @param on_board [Proc, nil]
48
+ # @param claims [OpenLanes::Live::Claims, nil] plan claims shared with
49
+ # other machines; nil when the run is not live, and every plan is ours
50
+ # @param stay [Boolean] idle past fixed points until a person stops
51
+ # the run; see {Dispatcher#done?}
52
+ # @param rescan [Integer] seconds between rescans while idle
53
+ def initialize(tree:, executor:, agents: Agents::Registry.new, isolation: :shared, jobs: 1, worktree: nil,
54
+ plans: nil, publisher: nil, dry_run: false, rounds: nil, state: nil,
55
+ sleeper: ->(seconds) { sleep(seconds) }, on_board: nil, claims: nil, stay: false, rescan: Dispatcher::RESCAN)
56
+ @tree = tree
57
+ @executor = executor
58
+ @agents = agents
59
+ @isolation = isolation
60
+ @worktree = worktree
61
+ @plans = plans
62
+ @publisher = publisher
63
+ @dry_run = dry_run
64
+ @rounds = rounds
65
+ @state = dry_run ? nil : state
66
+ @sleeper = sleeper
67
+ @on_board = on_board
68
+ @claims = claims
69
+ @stay = stay
70
+ @rescan = rescan
71
+ @attempts = []
72
+ @dropped = []
73
+ @jobs = isolated? ? jobs : 1
74
+ end
75
+
76
+ attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
77
+
78
+ # @return [Boolean]
79
+ def isolated? = @isolation == :worktree
80
+
81
+ # @return [Boolean]
82
+ def dry_run? = @dry_run
83
+
84
+ # Whether a finished build is pushed and its pull request opened. Not
85
+ # with `--isolation shared` or `--no-git-push`, the local-only runs, where
86
+ # a plan can never hold the open pull request 🟢 requires.
87
+ #
88
+ # @return [Boolean]
89
+ def publishes? = !@publisher.nil?
90
+
91
+ # @return [String] the repository root
92
+ def root = isolated? ? worktree.root : shared_root
93
+
94
+ # @param subject [OpenLanes::Plans::Subject]
95
+ # @return [Boolean]
96
+ def in_scope?(subject)
97
+ ordinal = subject.feature.ordinal
98
+ !@dropped.include?(ordinal.to_s) && (@plans.nil? || @plans.include?(ordinal))
99
+ end
100
+
101
+ # Take a plan out of this run, as when another machine now holds it.
102
+ #
103
+ # @param ordinal [#to_s]
104
+ # @return [void]
105
+ def drop_plan(ordinal) = @dropped |= [ordinal.to_s]
106
+
107
+ # @return [Array<OpenLanes::Plans::Subject>]
108
+ def in_scope = tree.subjects.select { |s| in_scope?(s) }
109
+
110
+ # Widen a run scoped by `--plan` to one more plan, from the page's
111
+ # "Choose an existing plan". An unscoped run already has every plan.
112
+ #
113
+ # @param ordinal [OpenLanes::Plans::Ordinal]
114
+ # @return [Boolean] whether the scope grew
115
+ def add_plan(ordinal)
116
+ @dropped.delete(ordinal.to_s)
117
+ return false if @plans.nil? || @plans.include?(ordinal)
118
+
119
+ @plans << ordinal
120
+ true
121
+ end
122
+
123
+ # Whether another machine holds the plan (W26). Such a plan is stepped
124
+ # around, as a blocked one is.
125
+ #
126
+ # @param subject [OpenLanes::Plans::Subject]
127
+ # @return [Boolean]
128
+ def foreign?(subject)
129
+ !@claims.nil? && @claims.foreign?(subject.feature.ordinal)
130
+ rescue Live::Unavailable => e
131
+ claims_unavailable(e)
132
+ false
133
+ end
134
+
135
+ # Take the plan before an agent starts on it. With Redis gone the run
136
+ # carries on unclaimed rather than stalling: the claim is an agreement
137
+ # between machines, and a machine that cannot reach the others is
138
+ # working alone.
139
+ #
140
+ # @param subject [OpenLanes::Plans::Subject]
141
+ # @return [Boolean] whether this machine may work it
142
+ def claim(subject)
143
+ @claims.nil? || @claims.claim(subject.feature.ordinal)
144
+ rescue Live::Unavailable => e
145
+ claims_unavailable(e)
146
+ true
147
+ end
148
+
149
+ # Run until nothing is dispatchable, or, staying, until a person stops
150
+ # it.
151
+ #
152
+ # @yieldparam dispatcher [OpenLanes::Engine::Dispatcher] before it runs, so a
153
+ # console can attach itself and reach the running jobs
154
+ # @return [Array<OpenLanes::Engine::Runner::Attempt>]
155
+ def call
156
+ dispatcher = Dispatcher.new(runner: self,
157
+ state: @state,
158
+ rounds: @rounds,
159
+ sleeper: @sleeper,
160
+ on_board: @on_board,
161
+ stay: @stay,
162
+ rescan: @rescan)
163
+ yield dispatcher if block_given?
164
+ @attempts = dispatcher.run
165
+ end
166
+
167
+ # @return [Array<OpenLanes::Plans::Subject>]
168
+ def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
169
+
170
+ # @return [Boolean]
171
+ def settled? = in_scope.all? { |s| Plans::StateMachine::SETTLED.include?(s.status.key) }
172
+
173
+ # A dry run gets the shared root whatever the isolation says: nothing
174
+ # runs in the checkout, and `git worktree add` is a side effect a
175
+ # preview must not have. It also fails outright when the plan's branch
176
+ # is the one checked out where the run was typed, which is where a dry
177
+ # run of that plan is typed.
178
+ #
179
+ # @return [OpenLanes::Engine::Runner::Task]
180
+ def prepare(agent, subject, round)
181
+ return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
182
+
183
+ checkout = worktree.checkout_for(subject.feature)
184
+ Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
185
+ end
186
+
187
+ # Push a finished build and record the pull request it opened in the
188
+ # plan's `pull-requests.md`, the row 🟢 is judged by. A push to a pull
189
+ # request the plan already had records nothing: the row is there, and a
190
+ # second copy of it would read as a second pull request.
191
+ #
192
+ # @param task [OpenLanes::Engine::Runner::Task]
193
+ # @param subject [OpenLanes::Plans::Subject] the plan in the main tree
194
+ # @return [OpenLanes::Vcs::Publisher::Publication, nil] nil when the run
195
+ # publishes nothing or the checkout holds no change
196
+ def publish(task, subject)
197
+ return nil unless @publisher && task.checkout&.dirty?
198
+
199
+ publication = @publisher.publish(checkout: task.checkout, subject:)
200
+ record_pull_request(subject.feature.path, publication) if publication.published? && !publication.updated
201
+ publication
202
+ end
203
+
204
+ private
205
+
206
+ # @return [String]
207
+ def shared_root = File.dirname(tree.dir)
208
+
209
+ # @param error [OpenLanes::Live::Unavailable]
210
+ # @return [void]
211
+ def claims_unavailable(error) = UI.log("claims unchecked: #{error.message}")
212
+
213
+ # @return [void]
214
+ def record_pull_request(path, publication)
215
+ number = publication.url.to_s[%r{/pull/(\d+)}, 1]
216
+ rows = Plans::PullRequests.new(dir: path).all.map { |pr|
217
+ { number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: "" }
218
+ }
219
+ rows << { number:, title: publication.title, url: publication.url, state: "Open 🟡", body: "" }
220
+ Plans::PullRequests.upsert(File.join(path, Plans::PullRequests::FILENAME), rows)
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module OpenLanes
7
+ module Engine
8
+ # Where a run stands, written every second, so a harness that dies leaves
9
+ # the next one something to restart from.
10
+ #
11
+ # The ledger inside each document is the record humans read and the
12
+ # dispatcher acts on; this is the index of it, plus what the ledger cannot
13
+ # hold: which run wrote what, process ids, tokens, and whether the run
14
+ # that wrote a `Started` is still alive. It sits in `.plans/` rather than
15
+ # the system temp dir because a reboot must not lose it, and it is
16
+ # gitignored because it is about one machine's run, not the plan.
17
+ class StateFile
18
+ FILENAME = "openlanes-state.json"
19
+
20
+ # Where it used to live, one directory down. Read by {#load} when the
21
+ # current path holds nothing, so a run started under the old layout is
22
+ # picked up rather than silently restarted from zero. Nothing writes
23
+ # here any more and nothing deletes it: the file left behind is the
24
+ # only record a half-finished run has.
25
+ LEGACY_DIRNAME = "tmp"
26
+
27
+ # The lines {.ensure_ignored!} adds. The second covers the sibling
28
+ # {#save} writes and renames over the first — brief, but `.plans/` is a
29
+ # directory whose other contents are committed, so a `git status` run
30
+ # during that instant must not offer it.
31
+ IGNORE = ["#{OpenLanes::PLANS_DIR}/#{FILENAME}", "#{OpenLanes::PLANS_DIR}/#{FILENAME}.*.tmp"].freeze
32
+
33
+ # @param tree [OpenLanes::Plans::Tree]
34
+ # @return [String]
35
+ def self.for(tree) = File.join(tree.dir, FILENAME)
36
+
37
+ # @param tree [OpenLanes::Plans::Tree]
38
+ # @return [String] where {LEGACY_DIRNAME} put it
39
+ def self.legacy_for(tree) = File.join(tree.dir, LEGACY_DIRNAME, FILENAME)
40
+
41
+ # Add the ignore lines once. Editing somebody's `.gitignore` is a thing
42
+ # to announce, which is why this returns whether it did.
43
+ #
44
+ # Only the first line is asked about: git answers for a literal path,
45
+ # and the second is a glob no path equals. They are written together,
46
+ # so one being ignored means both are.
47
+ #
48
+ # @param root [String] repository root
49
+ # @return [Boolean] true when lines were added
50
+ def self.ensure_ignored!(root)
51
+ return false unless system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
52
+ return false if system("git", "-C", root, "check-ignore", "-q", IGNORE.first, out: File::NULL, err: File::NULL)
53
+
54
+ path = File.join(root, ".gitignore")
55
+ existing = File.file?(path) ? File.read(path) : ""
56
+ glue = existing.empty? || existing.end_with?("\n") ? "" : "\n"
57
+ File.write(path, "#{existing}#{glue}#{IGNORE.join("\n")}\n")
58
+ true
59
+ end
60
+
61
+ # @param path [String]
62
+ # @param pid [Integer] this run's process id
63
+ def initialize(path:, pid: Process.pid)
64
+ @path = path
65
+ @pid = pid
66
+ @data = { "run" => {}, "plans" => {} }
67
+ @mutex = Mutex.new
68
+ end
69
+
70
+ # @return [String]
71
+ attr_reader :path
72
+
73
+ # Where a run started before the file moved up out of {LEGACY_DIRNAME}
74
+ # would have written. {#load} falls back to it; {#save} never uses it.
75
+ #
76
+ # @return [String]
77
+ def legacy_path = File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))
78
+
79
+ # Reads the current path, or the legacy one when the current path holds
80
+ # nothing. A run interrupted under the old layout is otherwise invisible
81
+ # to the run that picks it up, and {#stranded} would report no stranded
82
+ # stages for a harness that died with several.
83
+ #
84
+ # @return [self]
85
+ def load
86
+ @mutex.synchronize do
87
+ source = [path, legacy_path].find { |candidate| File.file?(candidate) }
88
+ @data = JSON.parse(File.read(source)) if source
89
+ @data = { "run" => {}, "plans" => {} } unless @data.is_a?(Hash) && @data["plans"].is_a?(Hash)
90
+ end
91
+ self
92
+ rescue JSON::ParserError
93
+ self
94
+ end
95
+
96
+ # Written whole to a sibling and renamed over, so a reader never sees
97
+ # half a file.
98
+ #
99
+ # @return [void]
100
+ def save
101
+ @mutex.synchronize do
102
+ FileUtils.mkdir_p(File.dirname(path))
103
+ temp = "#{path}.#{@pid}.tmp"
104
+ File.write(temp, JSON.pretty_generate(@data))
105
+ File.rename(temp, path)
106
+ end
107
+ end
108
+
109
+ # @param root [String]
110
+ # @return [void]
111
+ def begin_run!(root:)
112
+ @mutex.synchronize do
113
+ @data["run"] = { "pid" => @pid, "root" => root, "started_at" => Time.now.iso8601,
114
+ "heartbeat_at" => Time.now.iso8601 }
115
+ end
116
+ end
117
+
118
+ # @return [void]
119
+ def heartbeat!
120
+ @mutex.synchronize { @data["run"]["heartbeat_at"] = Time.now.iso8601 }
121
+ end
122
+
123
+ # @param ordinal [String]
124
+ # @param agent [String]
125
+ # @param round [Integer]
126
+ # @param fields [Hash] anything else worth keeping: status, model, file,
127
+ # pid, up, down, state, next, exit
128
+ # @return [void]
129
+ def record(ordinal, agent:, round:, **fields)
130
+ @mutex.synchronize do
131
+ plan = (@data["plans"][ordinal.to_s] ||= { "stages" => [] })
132
+ stage = plan["stages"].find { |s| s["agent"] == agent && s["round"] == round }
133
+ unless stage
134
+ stage = { "agent" => agent, "round" => round, "run_pid" => @pid }
135
+ plan["stages"] << stage
136
+ end
137
+ fields.each { |key, value| stage[key.to_s] = value }
138
+ end
139
+ end
140
+
141
+ # The live run's record (W6): what a person said about the run that no
142
+ # plan folder holds, kept here as the copy of Redis that survives Redis
143
+ # losing it. {Live::RunRecord} writes it and reads it back on start;
144
+ # it carries its own `seq`, so the newer of the two copies wins.
145
+ #
146
+ # @return [Hash, nil]
147
+ def live = @mutex.synchronize { @data["live"]&.dup }
148
+
149
+ # @param data [Hash] written by the next {#save}
150
+ # @return [void]
151
+ def live=(data)
152
+ @mutex.synchronize { @data["live"] = data }
153
+ end
154
+
155
+ # @param ordinal [String]
156
+ # @return [Array<Hash>]
157
+ def stages(ordinal) = @mutex.synchronize { (@data.dig("plans", ordinal.to_s, "stages") || []).map(&:dup) }
158
+
159
+ # @return [Array<String>] every ordinal with a stage recorded
160
+ def plans = @mutex.synchronize { @data["plans"].keys }
161
+
162
+ # @return [Boolean] whether the run that last wrote this file is gone
163
+ def previous_run_dead?
164
+ previous = @data.dig("run", "pid")
165
+ return false if previous.nil? || previous == @pid
166
+
167
+ Process.kill(0, previous)
168
+ false
169
+ rescue Errno::ESRCH, Errno::EPERM
170
+ true
171
+ end
172
+
173
+ # Stages a dead run left `Started`. Each is an agent that was cut off
174
+ # mid-flight and has to be re-run, with the harness's own Interrupted
175
+ # line written so the document agrees with this file.
176
+ #
177
+ # @return [Array<Array(String, Hash)>] ordinal and stage
178
+ def stranded
179
+ return [] unless previous_run_dead?
180
+
181
+ @mutex.synchronize do
182
+ @data["plans"].flat_map { |ordinal, plan|
183
+ plan["stages"].select { |s| s["status"] == "Started" && s["run_pid"] != @pid }
184
+ .map { |s| [ordinal, s.dup] }
185
+ }
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end