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,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Web
7
+ # One page's event stream (contract.md, `GET /api/events`): a `board`
8
+ # event whenever any node's snapshot `seq` changes, a `reply` event for
9
+ # each reply the engine writes, and a `: ping` comment so proxies and the
10
+ # browser keep the connection open.
11
+ #
12
+ # Polled rather than blocked on: a blocking read would hold the thread's
13
+ # connection for the length of the wait, and the board has to be looked
14
+ # at every tick anyway.
15
+ #
16
+ # A reply carries its stream id as the event's `id:`, so a browser that
17
+ # reconnects sends `Last-Event-ID` and gets what it missed. The stream
18
+ # opens with a `cursor` event carrying the id it starts from, so a
19
+ # browser that drops before the first reply still has one to send;
20
+ # without it, replies written during that gap were skipped.
21
+ class Events
22
+ # @param context [OpenLanes::Web::Context]
23
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
24
+ # @param last_id [String, nil] the `Last-Event-ID` the browser sent
25
+ # @param clock [Proc] seconds, monotonic
26
+ # @param sleeper [Proc] waits the given seconds between ticks
27
+ def initialize(context:, store:, last_id: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
28
+ sleeper: ->(seconds) { sleep seconds })
29
+ @context = context
30
+ @store = store
31
+ @keys = context.keys
32
+ @last_id = last_id.to_s.match?(/\A\d+-\d+\z/) ? last_id : nil
33
+ @clock = clock
34
+ @sleeper = sleeper
35
+ end
36
+
37
+ # Write events to +out+ until the browser leaves, the server stops, or
38
+ # the context's lifetime runs out.
39
+ #
40
+ # @param out [#<<, #closed?]
41
+ # @return [void]
42
+ def run(out)
43
+ started = @clock.call
44
+ pinged = started
45
+ cursor = @last_id || newest_reply
46
+ out << self.class.frame("cursor", { id: cursor }, id: cursor)
47
+ seqs = nil
48
+ until over?(out, started)
49
+ seqs = board(out, seqs)
50
+ cursor = replies(out, cursor)
51
+ pinged = ping(out) if @clock.call - pinged >= @context.ping_every
52
+ @sleeper.call(@context.tick)
53
+ end
54
+ rescue Live::Unavailable => e
55
+ # Ending the stream is the right answer: the page reconnects with
56
+ # backoff and shows itself as reconnecting meanwhile.
57
+ out << ": #{e.message.tr("\n", " ")}\n\n"
58
+ end
59
+
60
+ # @param event [String]
61
+ # @param data [Object]
62
+ # @param id [String, nil]
63
+ # @return [String] one server-sent event
64
+ def self.frame(event, data, id: nil) = "#{"id: #{id}\n" if id}event: #{event}\ndata: #{JSON.generate(data)}\n\n"
65
+
66
+ private
67
+
68
+ # @return [Boolean]
69
+ def over?(out, started)
70
+ out.closed? || @context.stopping.true? || (@context.lifetime && @clock.call - started >= @context.lifetime)
71
+ end
72
+
73
+ # @return [Array] the seqs now, after sending the board if they changed
74
+ def board(out, seqs)
75
+ board = Live.board(store: @store, keys: @keys)
76
+ now = [board[:me], *board[:others]].compact.map { [it.dig("node", "id"), it.dig("node", "checkout"), it["seq"]] }
77
+ out << self.class.frame("board", board) unless now == seqs
78
+ now
79
+ end
80
+
81
+ # @return [String] the last reply id sent
82
+ def replies(out, cursor)
83
+ Live.replies(store: @store, keys: @keys, after: cursor).each do |id, reply|
84
+ out << self.class.frame("reply", reply, id:)
85
+ cursor = id
86
+ end
87
+ cursor
88
+ end
89
+
90
+ # @return [Float] when it pinged
91
+ def ping(out)
92
+ out << ": ping\n\n"
93
+ @clock.call
94
+ end
95
+
96
+ # Replies already written before this page connected are not news.
97
+ # An empty stream starts from `0-0` rather than `0`: the browser echoes
98
+ # it back as `Last-Event-ID`, and only a full stream id is accepted.
99
+ #
100
+ # @return [String]
101
+ def newest_reply = @store.xrevrange(@keys.replies, count: 1).first&.id || "0-0"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "tmpdir"
5
+
6
+ module OpenLanes
7
+ module Web
8
+ # The API server beside a `openlanes run` (W7): `openlanes web` as a process
9
+ # of its own, in its own process group, so a crash in one never takes the
10
+ # other with it and stopping the run stops the server and nothing else.
11
+ #
12
+ # web = Web.launcher(root:, plans_dir:, port: 11000, redis_url:).start
13
+ # web.ready? # => true once /healthz answers
14
+ # web.stop
15
+ class Launcher
16
+ # The executable, from this gem rather than whatever is on PATH.
17
+ EXE = File.join(GEM_ROOT, "exe", "openlanes")
18
+
19
+ # Where the server finds this run's Redis. The URL can carry a password,
20
+ # and argv is world-readable through `ps`; a child's environment is not.
21
+ REDIS_URL_ENV = "OPENLANES_REDIS_URL"
22
+
23
+ # Seconds {#start} waits for `/healthz`.
24
+ READY_WITHIN = 10.0
25
+
26
+ # Seconds {#stop} gives the server after TERM before KILL.
27
+ STOP_WITHIN = 5.0
28
+
29
+ # @param root [String] the checkout the run works in
30
+ # @param plans_dir [String]
31
+ # @param port [Integer]
32
+ # @param redis_url [String]
33
+ # @param log [String] where the server's output goes
34
+ # @param ready_within [Float]
35
+ # @param probe [Proc, nil] answers whether the server is up; `/healthz` by default
36
+ def initialize(root:, plans_dir:, port:, redis_url:, log: default_log(root), ready_within: READY_WITHIN, probe: nil)
37
+ @root = root
38
+ @plans_dir = plans_dir
39
+ @port = Integer(port)
40
+ @redis_url = redis_url
41
+ @log = log
42
+ @ready_within = ready_within
43
+ @probe = probe || method(:healthy?)
44
+ end
45
+
46
+ # @return [String]
47
+ attr_reader :log
48
+
49
+ # @return [Integer, nil]
50
+ attr_reader :pid
51
+
52
+ # @return [String]
53
+ def url = "http://#{HOST}:#{@port}"
54
+
55
+ # @return [Array<String>] what is spawned; the Redis URL is in {#env}, never here
56
+ def argv
57
+ [EXE, "web", "--port", @port.to_s, "--no-open", "--dir", @plans_dir]
58
+ end
59
+
60
+ # @return [Hash{String => String}] added to the server's environment
61
+ def env = { REDIS_URL_ENV => @redis_url }
62
+
63
+ # Spawn the server and wait for it to answer.
64
+ #
65
+ # @return [self]
66
+ def start
67
+ @pid = Process.spawn(env, *argv, chdir: @root, pgroup: true, in: File::NULL, %i[out err] => [@log, "a"])
68
+ @ready = wait_until_ready
69
+ self
70
+ end
71
+
72
+ # @return [Boolean] whether `/healthz` answered within the wait
73
+ def ready? = @ready == true
74
+
75
+ # TERM to the server's process group, then KILL if it lingers.
76
+ #
77
+ # @return [void]
78
+ def stop
79
+ return unless @pid
80
+
81
+ signal("TERM")
82
+ deadline = monotonic + STOP_WITHIN
83
+ sleep 0.05 while alive? && monotonic < deadline
84
+ signal("KILL") if alive?
85
+ reap
86
+ @pid = nil
87
+ end
88
+
89
+ private
90
+
91
+ # @return [Boolean]
92
+ def wait_until_ready
93
+ deadline = monotonic + @ready_within
94
+ until monotonic >= deadline
95
+ return true if @probe.call
96
+ return false unless alive?
97
+
98
+ sleep 0.1
99
+ end
100
+ false
101
+ end
102
+
103
+ # @return [Boolean]
104
+ def healthy?
105
+ Net::HTTP.start(HOST, @port, open_timeout: 0.5, read_timeout: 1) { it.get("/healthz").code == "200" }
106
+ rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout
107
+ false
108
+ end
109
+
110
+ # @return [Boolean]
111
+ def alive?
112
+ Process.waitpid(@pid, Process::WNOHANG).nil?
113
+ rescue Errno::ECHILD
114
+ false
115
+ end
116
+
117
+ # @return [void]
118
+ def signal(name)
119
+ Process.kill(name, -@pid)
120
+ rescue Errno::ESRCH, Errno::EPERM
121
+ nil
122
+ end
123
+
124
+ # Blocks: after KILL the child is gone or about to be, and a
125
+ # non-blocking wait that runs before the kernel has finished with it
126
+ # returns nil and leaves a zombie behind. ECHILD means {#alive?}
127
+ # already reaped it.
128
+ #
129
+ # @return [void]
130
+ def reap
131
+ Process.waitpid(@pid)
132
+ rescue Errno::ECHILD
133
+ nil
134
+ end
135
+
136
+ # @return [Float]
137
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
+
139
+ # @return [String]
140
+ def default_log(root) = File.join(Dir.tmpdir, "openlanes-web-#{File.basename(root)}.log")
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma"
4
+ require "puma/log_writer"
5
+ require "puma/server"
6
+
7
+ module OpenLanes
8
+ module Web
9
+ # The app on Puma, multi-threaded because every open page holds one
10
+ # thread for its event stream, and bound to {HOST} only.
11
+ #
12
+ # server = Web.server(app:, context:, port: 11000).start
13
+ # server.url # => "http://127.0.0.1:11000"
14
+ # server.wait # until #stop
15
+ class Server
16
+ # Threads: one per open event stream plus the requests beside them.
17
+ MIN_THREADS = 2
18
+ MAX_THREADS = 64
19
+
20
+ # Seconds Puma waits for open streams after {#stop}. They notice the
21
+ # context's `stopping` within a tick; this is the backstop.
22
+ SHUTDOWN_AFTER = 2
23
+
24
+ # @param app [#call]
25
+ # @param context [OpenLanes::Web::Context]
26
+ # @param port [Integer] 0 for any free port
27
+ # @param host [String]
28
+ # @param log [IO] where Puma writes its errors
29
+ def initialize(app:, context:, port: DEFAULT_PORT, host: HOST, log: $stderr)
30
+ @app = app
31
+ @context = context
32
+ @port = Integer(port)
33
+ @host = host
34
+ @log = log
35
+ end
36
+
37
+ # @return [String]
38
+ attr_reader :host
39
+
40
+ # Listen, and serve on Puma's own threads.
41
+ #
42
+ # @return [self]
43
+ # @raise [Errno::EADDRINUSE] when the port is taken
44
+ def start
45
+ @puma = Puma::Server.new(@app,
46
+ nil,
47
+ min_threads: MIN_THREADS,
48
+ max_threads: MAX_THREADS,
49
+ force_shutdown_after: SHUTDOWN_AFTER,
50
+ log_writer: Puma::LogWriter.new(File.open(File::NULL, "w"), @log))
51
+ @puma.add_tcp_listener(@host, @port)
52
+ @thread = @puma.run
53
+ self
54
+ end
55
+
56
+ # @return [Integer] the port listened on, the real one when 0 was asked
57
+ def port = @puma&.connected_ports&.first || @port
58
+
59
+ # @return [String]
60
+ def url = "http://#{@host}:#{port}"
61
+
62
+ # Block until the server stops.
63
+ #
64
+ # @return [void]
65
+ def wait = @thread&.join
66
+
67
+ # Stop listening, end every open stream, and wait for Puma.
68
+ #
69
+ # @return [void]
70
+ def stop
71
+ @context.stopping.make_true
72
+ @puma&.stop(true)
73
+ @context.stores.close
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Web
5
+ # One store per Puma thread. {Live::Store} holds a single Redis
6
+ # connection, which two threads must not share: an event stream polling
7
+ # on one thread and a command posted on another would interleave their
8
+ # replies on the wire.
9
+ #
10
+ # Puma's threads are pooled, so the map is bounded by the pool's size.
11
+ class Stores
12
+ # Every thread gets the same store: {Live::MemoryStore}, which lives in
13
+ # this process and is already synchronised.
14
+ #
15
+ # @param store [OpenLanes::Live::MemoryStore]
16
+ # @return [OpenLanes::Web::Stores]
17
+ def self.shared(store) = new { store }
18
+
19
+ # @yieldreturn [OpenLanes::Live::Store] a new store
20
+ def initialize(&factory)
21
+ @factory = factory
22
+ @mutex = Mutex.new
23
+ @by_thread = {}
24
+ end
25
+
26
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
27
+ def current = @mutex.synchronize { @by_thread[Thread.current] ||= @factory.call }
28
+
29
+ # @return [void]
30
+ def close
31
+ stores = @mutex.synchronize { @by_thread.values.uniq.tap { @by_thread.clear } }
32
+ stores.each(&:close)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "securerandom"
5
+
6
+ module OpenLanes
7
+ # The control room's API server (plan 004, W3, W7): Sinatra on a threaded
8
+ # Puma, bound to 127.0.0.1. It holds no run state. Everything it shows is
9
+ # read from Redis through {Live}, and everything it is told goes back
10
+ # there as a command for the engine, so a restarted server loses nothing.
11
+ #
12
+ # context = Web.context(stores:, keys:, plans_dir:, url:)
13
+ # server = Web.server(app: Web.app(context), context:, port: 11000).start
14
+ # server.wait
15
+ module Web
16
+ # Where the gem is, whether run from a checkout or installed.
17
+ GEM_ROOT = File.expand_path("../..", __dir__)
18
+
19
+ # The built page, which the gem ships (W3).
20
+ DIST = File.join(GEM_ROOT, "web", "dist")
21
+
22
+ # The sample board the page's own tests type-check, and {Demo} replays.
23
+ FIXTURE = File.join(GEM_ROOT, "web", "src", "api", "fixtures", "board.json")
24
+
25
+ # Where the server listens unless told otherwise (contract.md).
26
+ DEFAULT_PORT = 11_000
27
+
28
+ # Loopback only: the page can stop agents, and nothing off this machine
29
+ # may reach it (W3).
30
+ HOST = "127.0.0.1"
31
+
32
+ module_function
33
+
34
+ # @param stores [OpenLanes::Web::Stores]
35
+ # @param keys [OpenLanes::Live::Keys]
36
+ # @param plans_dir [String] absolute path of the `.plans` directory
37
+ # @param url [String] the Redis address, shown masked by `/healthz`
38
+ # @param rest [Hash] {Context}'s other fields
39
+ # @return [OpenLanes::Web::Context]
40
+ def context(stores:, keys:, plans_dir:, url:, **rest)
41
+ Context.new(stores:, keys:, plans_dir:, url:, csrf: SecureRandom.hex(32), **rest)
42
+ end
43
+
44
+ # @param context [OpenLanes::Web::Context]
45
+ # @return [Class] a Rack application
46
+ def app(context) = App.serving(context)
47
+
48
+ # @param app [#call]
49
+ # @param context [OpenLanes::Web::Context]
50
+ # @param port [Integer]
51
+ # @return [OpenLanes::Web::Server]
52
+ def server(app:, context:, port: DEFAULT_PORT, **) = Server.new(app:, context:, port:, **)
53
+
54
+ # @return [OpenLanes::Web::Demo]
55
+ def demo(**) = Demo.new(**)
56
+
57
+ # @return [OpenLanes::Web::Launcher]
58
+ def launcher(**) = Launcher.new(**)
59
+
60
+ # Whether a browser can be opened here (W1): not under CI, not over
61
+ # SSH, not without a terminal, and on Linux only with a display.
62
+ #
63
+ # @param env [Hash]
64
+ # @param tty [Boolean] whether a person is at the terminal
65
+ # @param os [String] `RbConfig::CONFIG["host_os"]`
66
+ # @return [Boolean]
67
+ def display?(env: ENV, tty: $stderr.tty?, os: RbConfig::CONFIG["host_os"])
68
+ return false if !tty || present?(env["CI"]) || present?(env["SSH_CONNECTION"]) || present?(env["SSH_TTY"])
69
+ return true if os.match?(/darwin|mswin|mingw/)
70
+
71
+ present?(env["DISPLAY"]) || present?(env["WAYLAND_DISPLAY"])
72
+ end
73
+
74
+ # Open +url+ in the browser, when there is one to open.
75
+ #
76
+ # @param url [String]
77
+ # @param display [Boolean]
78
+ # @return [Boolean] whether a browser was asked to open it
79
+ def open(url, display: display?)
80
+ return false unless display
81
+
82
+ require "launchy"
83
+ Launchy.open(url)
84
+ true
85
+ rescue Launchy::Error, SystemCallError
86
+ false
87
+ end
88
+
89
+ # @return [Boolean]
90
+ def present?(value) = !value.to_s.strip.empty?
91
+ end
92
+ end
data/lib/openlanes.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Help screens come from dry-cli-help, which prepends itself to Dry::CLI the
4
+ # moment it loads, so it has to load before the registry's `help` block runs.
5
+ require "dry/cli"
6
+ require "dry/cli/help"
7
+
8
+ require "dry/inflector"
9
+ require "fileutils"
10
+ require "shellwords"
11
+ require "zeitwerk"
12
+ loader = Zeitwerk::Loader.for_gem
13
+ loader.inflector.inflect(
14
+ "openlanes" => "OpenLanes",
15
+ "api" => "API",
16
+ "cli" => "CLI",
17
+ "github" => "GitHub",
18
+ "issue" => "Issues",
19
+ "list_plans" => "ListPlans",
20
+ "unit" => "Units",
21
+ "ui" => "UI"
22
+ )
23
+ loader.collapse("#{__dir__}/openlanes/support")
24
+ loader.collapse("#{__dir__}/openlanes/cli/create")
25
+ loader.collapse("#{__dir__}/openlanes/cli/docs")
26
+ loader.collapse("#{__dir__}/openlanes/cli/eval")
27
+ loader.collapse("#{__dir__}/openlanes/cli/index")
28
+ loader.collapse("#{__dir__}/openlanes/cli/init")
29
+ loader.collapse("#{__dir__}/openlanes/cli/list_plans")
30
+ loader.collapse("#{__dir__}/openlanes/cli/run")
31
+ loader.collapse("#{__dir__}/openlanes/cli/start")
32
+ loader.collapse("#{__dir__}/openlanes/cli/states")
33
+ loader.collapse("#{__dir__}/openlanes/cli/unblock")
34
+ loader.collapse("#{__dir__}/openlanes/cli/version")
35
+ loader.collapse("#{__dir__}/openlanes/cli/web")
36
+ loader.collapse("#{__dir__}/openlanes/cli/worktree")
37
+ loader.collapse("#{__dir__}/openlanes/cli/agents/subcommands")
38
+ loader.collapse("#{__dir__}/openlanes/cli/linear/subcommands")
39
+ loader.collapse("#{__dir__}/openlanes/cli/mail/subcommands")
40
+ loader.collapse("#{__dir__}/openlanes/cli/state/subcommands")
41
+ loader.collapse("#{__dir__}/openlanes/cli/resync/subcommands")
42
+ loader.collapse("#{__dir__}/openlanes/cli/skills/subcommands")
43
+ loader.ignore("#{__dir__}/openlanes/cli/linear/linear.rb")
44
+ loader.ignore("#{__dir__}/openlanes/cli/mail/mail.rb")
45
+ loader.ignore("#{__dir__}/openlanes/cli/state/state.rb")
46
+ loader.ignore("#{__dir__}/openlanes/linear/mapping.rb")
47
+ loader.setup
48
+
49
+ # Spec → Plan → Build.
50
+ #
51
+ # Three phases, each with a file that proves it happened, and a guarded state
52
+ # machine that refuses to let a plan folder claim a phase it has not reached.
53
+ #
54
+ # spec ⚪️ New spec.md
55
+ # plan ⭐️ Ready plan.md
56
+ # build 🟡 → ✅ pull-requests.md
57
+ #
58
+ # The conventions are not documented anywhere else by hand: the status
59
+ # vocabulary, the numbering rules and the transition table live here and are
60
+ # emitted by `openlanes docs`. Three hand-maintained copies of that table
61
+ # have already drifted apart, which is why there is now exactly one.
62
+
63
+ # © 2026 Konstantin Gredeskoul
64
+ module OpenLanes
65
+ # The folder every project keeps its plans in.
66
+ PLANS_DIR = ENV.fetch("PLANS_DIR", ".plans")
67
+
68
+ # Prefix for a pull request that deliberately implements no plan —
69
+ # dependency bumps, CI work, hotfixes, developer tooling.
70
+ #
71
+ # Lower case, and not a number, so it cannot be mistaken for one. It used to
72
+ # be `DEV.00`, which read as a plan number and sorted among them.
73
+ NO_PLAN_PREFIX = "dev"
74
+
75
+ # What it used to be. Titles still wearing it are rewritten rather than
76
+ # skipped as already-prefixed.
77
+ STALE_NO_PLAN_PREFIX = "DEV.00"
78
+
79
+ # Prefix for a pull request that implements *something* nothing could name.
80
+ # Unlike `dev` this asserts nothing; it marks a question left open.
81
+ NONE_PREFIX = "none"
82
+
83
+ # @return [Dry::Inflector] shared Inflector
84
+ def self.inflector = @inflector ||= ::Dry::Inflector.new
85
+
86
+ # Move a directory, preferring `git mv` so its history follows it.
87
+ #
88
+ # Both the state machine and `resync dirs` move plan folders, and a folder
89
+ # that loses its history because one of them used `FileUtils` is a folder
90
+ # nobody can `git log`.
91
+ #
92
+ # @param source [String] absolute
93
+ # @param target [String] absolute
94
+ # @return [Boolean] false when the target was already occupied
95
+ def self.move_directory(source, target)
96
+ return false if File.exist?(target)
97
+
98
+ parent = File.dirname(source)
99
+ tracked = system("git",
100
+ "-C",
101
+ parent,
102
+ "ls-files",
103
+ "--error-unmatch",
104
+ source,
105
+ out: File::NULL,
106
+ err: File::NULL)
107
+ moved = tracked && system("git",
108
+ "-C",
109
+ parent,
110
+ "mv",
111
+ source,
112
+ target,
113
+ out: File::NULL,
114
+ err: File::NULL)
115
+ FileUtils.mv(source, target) unless moved
116
+ true
117
+ end
118
+
119
+ # Plan numbers visible in `.plans` on a git ref, without checking it out.
120
+ #
121
+ # A pull request's branch is the only place that knows how far the sequence
122
+ # had got when the work started, and branches are not all rebased onto the
123
+ # same main. Reading the ref directly costs one `ls-tree` and no worktree.
124
+ #
125
+ # @param root [String] repository root
126
+ # @param ref [String] a branch name; `origin/` is tried first
127
+ # @return [Array<OpenLanes::Plans::Ordinal>] possibly empty
128
+ def self.plans_on_ref(root, ref)
129
+ ["origin/#{ref}", ref].each do |candidate|
130
+ out = `git -C #{root.shellescape} ls-tree -d --name-only #{candidate.shellescape} #{PLANS_DIR}/ 2>/dev/null`
131
+ next if out.to_s.strip.empty?
132
+
133
+ return out.lines.filter_map { |line| Plans::Ordinal.from_dirname(File.basename(line.strip)) }
134
+ end
135
+ []
136
+ end
137
+
138
+ # The agent that writes a specification for work that already shipped.
139
+ # Named here rather than in the CLI so the definition file stays the single
140
+ # source of truth about who does what.
141
+ RETROACTIVE_WRITER = "yoda-writer"
142
+
143
+ class Error < StandardError; end
144
+ end
145
+
146
+ # plans/status.rb and plans/feature.rb define methods on OpenLanes::Plans (`status`,
147
+ # `plan_dirname`, `titleize` and friends) beside the class each is named for.
148
+ # Zeitwerk loads a file when its constant is first named, and a caller of
149
+ # `Plans.status` names no constant, so without this those methods exist
150
+ # or not depending on what happened to load first.
151
+ %w[status feature].each { |name| loader.load_file("#{__dir__}/openlanes/plans/#{name}.rb") }
data/openlanes.gemspec ADDED
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/openlanes/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "openlanes"
7
+ spec.version = OpenLanes::VERSION
8
+ spec.authors = ["Konstantin Gredeskoul"]
9
+ spec.email = ["kigster@gmail.com"]
10
+
11
+ spec.summary = "Agentic specification-driven development: spec, plan, build, review"
12
+ spec.description = <<~TEXT
13
+ Keeps a project's specifications, plans and pull requests joined up, and
14
+ drives specialist agents over them in parallel. A feature's state is its
15
+ folder name under .plans, so a transition renames a directory rather than
16
+ updating a row, and nothing can claim a phase whose document is missing.
17
+ TEXT
18
+ spec.homepage = "https://github.com/kigster/openlanes"
19
+
20
+ spec.required_ruby_version = ">= 3" # rubocop:disable Gemspec/RequiredRubyVersion -- the floor users get, not the Ruby the cops lint for
21
+
22
+ spec.metadata = {
23
+ "homepage_uri" => spec.homepage,
24
+ "source_code_uri" => spec.homepage,
25
+ "rubygems_mfa_required" => "true"
26
+ }
27
+
28
+ # Globbed rather than taken from `git ls-files`, so building the gem does not
29
+ # require a git checkout — the same reason the executables resolve their own
30
+ # bundle rather than trusting the caller's working directory.
31
+ spec.files = Dir.chdir(__dir__) do
32
+ # web/dist is the built page the API server serves; board.json is what
33
+ # `openlanes web --demo` replays.
34
+ # Source maps stay out: they double the page's size for nobody who installs it.
35
+ globbed = Dir.glob(["*.gemspec", "lib/**/*.rb", "agents/*.md", "schemas/*.json", "evals/cases/**/*", "skills/**/*",
36
+ "exe/*", "bin/*", "web/dist/**/*", "web/src/api/fixtures/board.json"])
37
+ globbed.select { |f| File.file?(f) && !f.end_with?(".map") }
38
+ end
39
+
40
+ # A gem built without the page installs a control room that serves a 404.
41
+ # Only `gem build` is stopped (which `rake build` and `just publish` run):
42
+ # Bundler, rspec and rubocop load this file too, in checkouts that have
43
+ # never run npm. `abort` rather than `raise`, because Gem::Specification.load
44
+ # swallows a StandardError into a warning and a puzzling "not found".
45
+ building = File.basename($PROGRAM_NAME) == "gem" && ARGV.first == "build"
46
+ if building && !File.file?(File.join(__dir__, "web", "dist", "index.html"))
47
+ abort "openlanes.gemspec: web/dist/index.html is missing, so the gem would ship without its control room.\n" \
48
+ "Build the page first: just web-build (or: cd web && npm ci && npm run build)."
49
+ end
50
+
51
+ spec.bindir = "exe"
52
+ # `depot` and `lanes` are symlinks to `openlanes` in the checkout; named here
53
+ # so that installing the gem writes all three, rather than only the one git
54
+ # recorded.
55
+ spec.executables = %w[openlanes depot lanes]
56
+ spec.require_paths = ["lib"]
57
+
58
+ spec.add_dependency "aasm"
59
+ spec.add_dependency "concurrent-ruby"
60
+ spec.add_dependency "dry-cli"
61
+ spec.add_dependency "dry-cli-autocomplete", "~> 0.5"
62
+ # 0.5.1 is the first release whose help screen survives `example [""]`,
63
+ # which four commands here declare.
64
+ spec.add_dependency "dry-cli-help", ">= 0.5.1", "< 0.6"
65
+ spec.add_dependency "dry-cli-ui", "~> 0.5"
66
+ spec.add_dependency "dry-inflector"
67
+ spec.add_dependency "dry-monads"
68
+ spec.add_dependency "fuzzy-string-match"
69
+ # Opens the control room in the browser when `openlanes run` starts the API server.
70
+ spec.add_dependency "launchy", "~> 3.1"
71
+ spec.add_dependency "parallel"
72
+ spec.add_dependency "pastel"
73
+ # The API server (plan 004, W3, W7): Sinatra on a threaded Puma, so SSE streams hold a thread each.
74
+ spec.add_dependency "puma", ">= 6.4"
75
+ spec.add_dependency "rack-protection", "~> 4.2"
76
+ spec.add_dependency "rackup", "~> 2.1"
77
+ # The live run (plan 004) lives in Redis: board and command streams, plan claims.
78
+ spec.add_dependency "redis-client", "~> 0.31"
79
+ spec.add_dependency "sinatra", "~> 4.2"
80
+ spec.add_dependency "tty-box"
81
+ spec.add_dependency "tty-command"
82
+ spec.add_dependency "tty-cursor"
83
+ spec.add_dependency "tty-prompt"
84
+ spec.add_dependency "tty-screen"
85
+ spec.add_dependency "unicode-display_width"
86
+ spec.add_dependency "zeitwerk"
87
+ end