openlanes 3.0.1 → 3.1.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 (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # The `app:` block of `.openlanes.yml`: what kind of app a project builds
6
+ # and the processes that run it, in the order they start.
7
+ #
8
+ # Written by luke on a plan's first build (M12) and only ever read here.
9
+ # It is new surface that every project will copy, so anything it does not
10
+ # know is refused with the file named, exactly as {Config} refuses an
11
+ # unknown top-level key: a `restart-on:` typo silently ignored is a
12
+ # watcher that never fires and nobody can say why.
13
+ #
14
+ # @!attribute [r] kind
15
+ # @return [String, nil] free text, `web` or `desktop` (M11); recorded,
16
+ # not interpreted here
17
+ # @!attribute [r] processes
18
+ # @return [Array<OpenLanes::Apps::ProcessSpec>] in start order
19
+ Definition = Data.define(:kind, :processes) do
20
+ # @return [OpenLanes::Apps::ProcessSpec, nil] the process marked
21
+ # `preview: true`; nil when none is, which means no Preview tab
22
+ def preview = processes.find(&:preview?)
23
+
24
+ # @param name [String]
25
+ # @return [OpenLanes::Apps::ProcessSpec, nil]
26
+ def process(name) = processes.find { it.name == name.to_s }
27
+
28
+ # Every process the Preview tab's Restart backend button restarts:
29
+ # all but the one it shows, in declared order.
30
+ #
31
+ # @return [Array<OpenLanes::Apps::ProcessSpec>]
32
+ def backend = processes.reject(&:preview?)
33
+
34
+ # @return [Hash] as `.openlanes.yml` spells it
35
+ def to_h = { "kind" => kind, "processes" => processes.map(&:to_h) }.compact
36
+ end
37
+
38
+ class Definition
39
+ # Keys the `app:` mapping may hold.
40
+ KEYS = %w[kind processes].freeze
41
+
42
+ # Keys each process may hold.
43
+ PROCESS_KEYS = %w[name start ready restart_on preview].freeze
44
+
45
+ # A process name: what `PORT_<NAME>`, the Preview tab and `target:`
46
+ # all carry, so nothing a shell or a URL would trip over.
47
+ NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
48
+
49
+ class << self
50
+ # @param value [Object] the parsed `app:` value
51
+ # @param path [String] the file, for every error
52
+ # @return [OpenLanes::Apps::Definition]
53
+ # @raise [OpenLanes::Error] naming the file and what is wrong
54
+ def parse(value, path:)
55
+ fail_with(path, "app: must be a mapping with processes:") unless value.is_a?(Hash)
56
+
57
+ data = value.transform_keys(&:to_s)
58
+ unknown!(path, "app", data.keys - KEYS, KEYS)
59
+ kind = data["kind"]
60
+ fail_with(path, "app.kind: #{kind.inspect} is not a word like web") unless kind.nil? || kind.is_a?(String)
61
+
62
+ list = data["processes"]
63
+ fail_with(path, "app.processes: must be a list of at least one process") unless list.is_a?(Array) && !list.empty?
64
+
65
+ processes = list.each_with_index.map { |entry, index| process(entry, index, path) }
66
+ checked(new(kind: kind&.strip, processes:), path)
67
+ end
68
+
69
+ private
70
+
71
+ # @return [OpenLanes::Apps::ProcessSpec]
72
+ def process(entry, index, path)
73
+ where = "app.processes[#{index}]"
74
+ fail_with(path, "#{where}: must be a mapping with name: and start:") unless entry.is_a?(Hash)
75
+
76
+ data = entry.transform_keys(&:to_s)
77
+ unknown!(path, where, data.keys - PROCESS_KEYS, PROCESS_KEYS)
78
+ name = data["name"]
79
+ fail_with(path, "#{where}: needs name:, letters, digits, - or _") unless name.is_a?(String) && name.match?(NAME)
80
+
81
+ ProcessSpec.new(name:,
82
+ start: start(data["start"], "app.processes.#{name}", path),
83
+ ready: ready(data["ready"], "app.processes.#{name}", path),
84
+ restart_on: restart_on(data["restart_on"], "app.processes.#{name}", path),
85
+ preview: preview(data["preview"], "app.processes.#{name}", path))
86
+ end
87
+
88
+ # @return [String]
89
+ def start(value, where, path)
90
+ return value.strip if value.is_a?(String) && !value.strip.empty?
91
+
92
+ fail_with(path, "#{where}: needs start:, the shell line that runs it")
93
+ end
94
+
95
+ # @return [String, nil]
96
+ def ready(value, where, path)
97
+ return nil if value.nil?
98
+ return value if value.is_a?(String) && value.start_with?("/")
99
+
100
+ fail_with(path, "#{where}.ready: #{value.inspect} is not an HTTP path like /up")
101
+ end
102
+
103
+ # @return [Array<String>]
104
+ def restart_on(value, where, path)
105
+ globs = value.is_a?(String) ? [value] : value
106
+ return [] if globs.nil?
107
+ return globs if globs.is_a?(Array) && globs.all? { it.is_a?(String) && !it.strip.empty? }
108
+
109
+ fail_with(path, "#{where}.restart_on: must be a glob or a list of globs")
110
+ end
111
+
112
+ # @return [Boolean]
113
+ def preview(value, where, path)
114
+ return value == true if [true, false, nil].include?(value)
115
+
116
+ fail_with(path, "#{where}.preview: #{value.inspect} is not true or false")
117
+ end
118
+
119
+ # Two processes that one name, one `PORT_` variable or two preview
120
+ # flags would confuse.
121
+ #
122
+ # @return [OpenLanes::Apps::Definition]
123
+ def checked(definition, path)
124
+ names = definition.processes.map(&:name)
125
+ twice = names.select { names.count(it) > 1 }.uniq
126
+ fail_with(path, "app.processes: #{twice.join(", ")} named twice") unless twice.empty?
127
+
128
+ envs = definition.processes.group_by(&:port_env).select { _2.size > 1 }
129
+ envs.each do |env, specs|
130
+ fail_with(path, "app.processes: #{specs.map(&:name).join(" and ")} would both be #{env}")
131
+ end
132
+
133
+ previews = definition.processes.select(&:preview?)
134
+ if previews.size > 1
135
+ fail_with(path, "app.processes: only one process may be preview: true, not #{previews.map(&:name).join(" and ")}")
136
+ end
137
+ definition
138
+ end
139
+
140
+ # @return [void]
141
+ def unknown!(path, where, unknown, known)
142
+ return if unknown.empty?
143
+
144
+ fail_with(path, "#{where}: unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; known: #{known.join(", ")}")
145
+ end
146
+
147
+ # @return [void] never returns
148
+ def fail_with(path, message) = raise(Error, "#{path}: #{message}")
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module OpenLanes
7
+ module Apps
8
+ # Whether the Preview tab may show an app in an iframe, or must open it in
9
+ # a window of its own (M13, plan 012 item 11).
10
+ #
11
+ # The page cannot find out: a cross-origin frame's DOM is closed to it
12
+ # and a refused frame still fires `load`. So the runner asks the app
13
+ # itself, once its preview process is ready and after each restart, and
14
+ # reads the two headers that refuse framing:
15
+ #
16
+ # - `X-Frame-Options`: `DENY` refuses everyone, and `SAMEORIGIN` refuses
17
+ # the control room too, whose origin differs from the app's by port.
18
+ # `ALLOW-FROM` is obsolete and ignored by browsers, so it frames.
19
+ # - `Content-Security-Policy: frame-ancestors`: refuses unless one of its
20
+ # sources matches the control room's origin. `'self'` is the app's own
21
+ # origin, so it refuses too.
22
+ #
23
+ # Either one refusing sends the tab to a window. Browsers let
24
+ # `frame-ancestors` override `X-Frame-Options`; this stays on the
25
+ # cautious side, because a window always works and a refused frame is a
26
+ # blank tab nobody can explain.
27
+ module Framing
28
+ # The control room's origin unless told otherwise.
29
+ ORIGIN = "http://#{Status::HOST}:11000".freeze
30
+
31
+ # Default ports, for a source that names none.
32
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
33
+
34
+ module_function
35
+
36
+ # @param headers [Hash{String => String, Array<String>}] any case
37
+ # @param origin [String] the page that embeds it
38
+ # @return [Symbol] `:frame` or `:window`
39
+ def verdict(headers, origin: ORIGIN)
40
+ values = headers.to_h { |name, value| [name.to_s.downcase, Array(value).join(",")] }
41
+ refused = x_frame_refuses?(values["x-frame-options"]) || ancestors_refuse?(values["content-security-policy"], URI(origin))
42
+ refused ? :window : :frame
43
+ end
44
+
45
+ # One GET of the URL the tab would open, then {.verdict}. An app that
46
+ # cannot be asked opens in a window: it may work there, and an iframe
47
+ # would show nothing either way.
48
+ #
49
+ # @param url [String]
50
+ # @param origin [String]
51
+ # @return [Symbol]
52
+ def check(url, origin: ORIGIN)
53
+ uri = URI(url)
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.open_timeout = Probe::TIMEOUT
56
+ http.read_timeout = Probe::TIMEOUT
57
+ response = http.request_get(uri.request_uri)
58
+ verdict(response.to_hash, origin:)
59
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError, URI::Error
60
+ :window
61
+ end
62
+
63
+ # @param value [String, nil]
64
+ # @return [Boolean]
65
+ def x_frame_refuses?(value)
66
+ value.to_s.split(",").map { it.strip.upcase }.intersect?(%w[DENY SAMEORIGIN])
67
+ end
68
+
69
+ # Every policy is enforced, so any one whose `frame-ancestors` leaves
70
+ # the origin out refuses it. A policy without the directive allows
71
+ # framing: it has no `default-src` fallback.
72
+ #
73
+ # @param value [String, nil] one or more policies, comma-joined
74
+ # @param origin [URI::Generic]
75
+ # @return [Boolean]
76
+ def ancestors_refuse?(value, origin)
77
+ value.to_s.split(",").any? do |policy|
78
+ directive = policy.split(";").map(&:strip).find { it.downcase.start_with?("frame-ancestors") }
79
+ directive && directive.split[1..].none? { allows?(it, origin) }
80
+ end
81
+ end
82
+
83
+ # @param source [String] one source expression
84
+ # @param origin [URI::Generic]
85
+ # @return [Boolean]
86
+ def allows?(source, origin)
87
+ text = source.strip.downcase
88
+ return true if text == "*"
89
+ return text.delete_suffix(":") == origin.scheme if text.match?(/\A[a-z][a-z0-9+.-]*:\z/)
90
+ return false if text.start_with?("'")
91
+
92
+ host_allows?(text, origin)
93
+ end
94
+
95
+ # @return [Boolean]
96
+ def host_allows?(text, origin)
97
+ match = text.match(%r{\A(?:(?<scheme>[a-z][a-z0-9+.-]*)://)?(?<host>[^:/]+)(?::(?<port>\d+|\*))?(?:/.*)?\z})
98
+ return false unless match
99
+ return false if match[:scheme] && match[:scheme] != origin.scheme
100
+ return false unless host_matches?(match[:host], origin.host)
101
+
102
+ port = match[:port]
103
+ port == "*" || (port || DEFAULT_PORTS[match[:scheme] || origin.scheme]).to_i == origin.port
104
+ end
105
+
106
+ # @return [Boolean]
107
+ def host_matches?(pattern, host)
108
+ return host == pattern unless pattern.start_with?("*.")
109
+
110
+ host.end_with?(pattern.delete_prefix("*"))
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Apps
7
+ # The apps running on this machine, as the control room reads them from
8
+ # Redis: one hash per machine (`Live::Keys#apps`), a field per plan, its
9
+ # value the plan's process statuses and a heartbeat.
10
+ #
11
+ # Per machine rather than per checkout, because the control room runs in
12
+ # the main checkout and an app runs in the plan's worktree; per machine
13
+ # rather than per repository, because `127.0.0.1:11101` means nothing on
14
+ # another one.
15
+ #
16
+ # A runner killed with KILL never removes its field, so a field whose
17
+ # heartbeat is older than {TTL} is dropped by whoever reads it, the way
18
+ # `Live.board` drops a node that stopped heartbeating.
19
+ module Listing
20
+ # Seconds a plan's entry outlives its last heartbeat.
21
+ TTL = 30
22
+
23
+ module_function
24
+
25
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
26
+ # @param keys [OpenLanes::Live::Keys]
27
+ # @param plan [String]
28
+ # @param statuses [Array<OpenLanes::Apps::Status>]
29
+ # @param now [Time]
30
+ # @return [void]
31
+ def write(store:, keys:, plan:, statuses:, now: Time.now)
32
+ store.hset(keys.apps, plan, JSON.generate("heartbeat" => now.to_f, "processes" => statuses.map(&:to_h)))
33
+ end
34
+
35
+ # @return [void]
36
+ def remove(store:, keys:, plan:) = store.hdel(keys.apps, plan)
37
+
38
+ # Every live process, oldest plan first, in each plan's start order.
39
+ #
40
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
41
+ # @param keys [OpenLanes::Live::Keys]
42
+ # @param now [Time]
43
+ # @return [Array<Hash>] `<app>` hashes, as {Status#to_h} wrote them
44
+ def read(store:, keys:, now: Time.now)
45
+ entries = store.hgetall(keys.apps).transform_values { parse(it) }
46
+ stale = entries.select { |_, entry| entry.nil? || now.to_f - entry["heartbeat"].to_f > TTL }.keys
47
+ store.hdel(keys.apps, *stale) unless stale.empty?
48
+ entries.except(*stale).sort.flat_map { |_, entry| Array(entry["processes"]) }
49
+ end
50
+
51
+ # @return [Hash, nil] nil for a field that is not ours to read
52
+ def parse(json)
53
+ data = JSON.parse(json.to_s)
54
+ data.is_a?(Hash) ? data : nil
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "socket"
6
+ require "time"
7
+
8
+ module OpenLanes
9
+ module Apps
10
+ # Every port an app holds on this machine, in one file (plan 012).
11
+ #
12
+ # One file per machine, not per checkout: a worktree isolates files, not
13
+ # ports, and two worktrees of one app asking for 11100 each would both
14
+ # be told yes by anything that only looked at its own checkout.
15
+ #
16
+ # A port is reserved under an exclusive `flock` and also probed, both by
17
+ # connecting to it and by binding it: something that never registered
18
+ # here (a dev server started by hand, the control room) still holds it.
19
+ # The probe and the spawn that follows it leave a gap no lock covers, so
20
+ # {Supervisor} retries a start that lost that race.
21
+ #
22
+ # An entry belongs to the pid that reserved it. Once that process is
23
+ # gone the entry counts as free, which is how a runner killed with KILL
24
+ # gives its ports back without anyone cleaning up after it.
25
+ class Ports
26
+ # The first port an app is given (M12).
27
+ FLOOR = 11_100
28
+
29
+ # The last. Not configurable: per-plan ranges are a non-goal.
30
+ CEILING = 11_999
31
+
32
+ # Overrides where the registry lives; the suite and the Cypress run
33
+ # point it at a temp file.
34
+ FILE_ENV = "OPENLANES_PORTS_FILE"
35
+
36
+ # Every port in the range is held.
37
+ class Exhausted < Error; end
38
+
39
+ class << self
40
+ # `$OPENLANES_PORTS_FILE`, else `${XDG_STATE_HOME:-~/.local/state}/openlanes/ports.json`.
41
+ #
42
+ # @param env [Hash]
43
+ # @return [String]
44
+ def default_path(env = ENV)
45
+ return env[FILE_ENV] unless env[FILE_ENV].to_s.strip.empty?
46
+
47
+ state = env["XDG_STATE_HOME"].to_s.strip
48
+ File.join(state.empty? ? File.expand_path("~/.local/state") : state, "openlanes", "ports.json")
49
+ end
50
+
51
+ # Nothing answers on the port and it can be bound on 127.0.0.1. The
52
+ # connect catches a listener on another address, which macOS lets a
53
+ # SO_REUSEADDR bind on 127.0.0.1 sit beside.
54
+ #
55
+ # @param port [Integer]
56
+ # @return [Boolean]
57
+ def free?(port)
58
+ return false if answering?(port)
59
+
60
+ TCPServer.new(Status::HOST, port).close
61
+ true
62
+ rescue SystemCallError
63
+ false
64
+ end
65
+
66
+ # @param port [Integer]
67
+ # @return [Boolean]
68
+ def answering?(port) = Probe.listening?(port, timeout: 0.2)
69
+
70
+ # @param pid [Integer]
71
+ # @return [Boolean]
72
+ def alive?(pid)
73
+ Process.kill(0, pid.to_i)
74
+ true
75
+ rescue Errno::ESRCH, RangeError
76
+ false
77
+ rescue Errno::EPERM
78
+ true
79
+ end
80
+ end
81
+
82
+ # @param path [String] the registry file
83
+ # @param owner [Integer] whose reservations these are
84
+ # @param floor [Integer]
85
+ # @param ceiling [Integer]
86
+ # @param free [#call] a port to whether it can be taken
87
+ # @param alive [#call] a pid to whether it still runs
88
+ def initialize(path: self.class.default_path, owner: Process.pid, floor: FLOOR, ceiling: CEILING,
89
+ free: self.class.method(:free?), alive: self.class.method(:alive?))
90
+ @path = path
91
+ @owner = owner
92
+ @floor = floor
93
+ @ceiling = ceiling
94
+ @free = free
95
+ @alive = alive
96
+ end
97
+
98
+ # @return [String]
99
+ attr_reader :path
100
+
101
+ # The lowest port at or above the floor that nobody holds.
102
+ #
103
+ # @param root [String] the checkout the app runs in
104
+ # @param plan [String]
105
+ # @param process [String]
106
+ # @return [Integer]
107
+ # @raise [OpenLanes::Apps::Ports::Exhausted]
108
+ def reserve(root:, plan:, process:)
109
+ locked do |entries|
110
+ port = (@floor..@ceiling).find { !entries.key?(it) && @free.call(it) }
111
+ raise Exhausted, "every port from #{@floor} to #{@ceiling} is taken" unless port
112
+
113
+ entries[port] = { "owner" => @owner, "root" => root, "plan" => plan, "process" => process, "at" => Time.now.utc.iso8601 }
114
+ port
115
+ end
116
+ end
117
+
118
+ # @param port [Integer]
119
+ # @return [void]
120
+ def release(port) = locked { it.delete(port.to_i) }
121
+
122
+ # Everything one process holds.
123
+ #
124
+ # @param pid [Integer]
125
+ # @return [Array<Integer>] the ports released
126
+ def release_owner(pid = @owner)
127
+ locked do |entries|
128
+ entries.select { |_, entry| entry["owner"] == pid }.keys.tap { |ports| ports.each { entries.delete(it) } }
129
+ end
130
+ end
131
+
132
+ # Every live reservation, by port.
133
+ #
134
+ # @return [Hash{Integer => Hash}] `owner`, `root`, `plan`, `process`, `at`
135
+ def entries = locked(&:dup)
136
+
137
+ private
138
+
139
+ # Read, hand the live entries to the block, write back what it left.
140
+ # Dead owners' entries are dropped on the way.
141
+ #
142
+ # @yieldparam entries [Hash{Integer => Hash}]
143
+ # @return [Object] the block's value
144
+ def locked
145
+ FileUtils.mkdir_p(File.dirname(path))
146
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
147
+ file.flock(File::LOCK_EX)
148
+ entries = parse(file.read).select { |_, entry| @alive.call(entry["owner"]) }
149
+ yield(entries).tap do
150
+ file.rewind
151
+ file.truncate(0)
152
+ file.write(JSON.pretty_generate(entries.sort.to_h { [it.first.to_s, it.last] }))
153
+ end
154
+ end
155
+ end
156
+
157
+ # @return [Hash{Integer => Hash}]
158
+ def parse(text)
159
+ data = text.strip.empty? ? {} : JSON.parse(text)
160
+ data.is_a?(Hash) ? data.to_h { |port, entry| [port.to_i, entry] }.select { |_, entry| entry.is_a?(Hash) } : {}
161
+ rescue JSON::ParserError
162
+ # A file somebody broke by hand is a registry with nothing in it; the
163
+ # probes still keep a taken port from being handed out twice.
164
+ {}
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+
6
+ module OpenLanes
7
+ module Apps
8
+ # Whether a process is ready (plan 012, decision 1): something accepts a
9
+ # TCP connection on its port, and, when its `ready:` names a path, a GET
10
+ # of that path answers 2xx or 3xx.
11
+ #
12
+ # Only ever on the port the process was given. An app that moved to
13
+ # another port (Vite without `--strictPort` does) therefore never
14
+ # becomes ready, which is how a moved port fails the start rather than
15
+ # leaving the Preview tab pointing at nothing.
16
+ module Probe
17
+ # Seconds a connect, and a read, may take.
18
+ TIMEOUT = 1
19
+
20
+ module_function
21
+
22
+ # @param port [Integer]
23
+ # @param path [String, nil]
24
+ # @return [Boolean]
25
+ def ready?(port, path: nil)
26
+ return false unless listening?(port)
27
+ return true if path.nil?
28
+
29
+ status(port, path).between?(200, 399)
30
+ end
31
+
32
+ # Something accepted a connection on the port. Asking the socket for
33
+ # its peer is the part that matters: behind a network filter (Little
34
+ # Snitch on macOS) `Socket.tcp` with `connect_timeout` returns a socket
35
+ # for a refused connect, and only `getpeername` then says EINVAL. Without
36
+ # it every port on the machine reads as taken.
37
+ #
38
+ # @param port [Integer]
39
+ # @param timeout [Numeric] seconds the connect may take
40
+ # @return [Boolean]
41
+ def listening?(port, timeout: TIMEOUT)
42
+ Socket.tcp(Status::HOST, port, connect_timeout: timeout) do |socket|
43
+ socket.remote_address
44
+ true
45
+ end
46
+ rescue SystemCallError, IOError, SocketError
47
+ false
48
+ end
49
+
50
+ # @param port [Integer]
51
+ # @param path [String]
52
+ # @return [Integer] the status, 0 when nothing answered
53
+ def status(port, path)
54
+ http = Net::HTTP.new(Status::HOST, port)
55
+ http.open_timeout = TIMEOUT
56
+ http.read_timeout = TIMEOUT
57
+ http.request_get(path).code.to_i
58
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError
59
+ 0
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Apps
5
+ # One process of an app, as `.openlanes.yml` declares it under
6
+ # `app.processes` (M12).
7
+ #
8
+ # @!attribute [r] name
9
+ # @return [String] `api`, `web`; unique within the app
10
+ # @!attribute [r] start
11
+ # @return [String] a shell line, run by `sh -c` in the checkout, so
12
+ # `$PORT_WEB` expands there
13
+ # @!attribute [r] ready
14
+ # @return [String, nil] an HTTP path that must answer 2xx or 3xx; nil
15
+ # means a TCP connect is enough
16
+ # @!attribute [r] restart_on
17
+ # @return [Array<String>] `Dir.glob` patterns, relative to the checkout
18
+ # @!attribute [r] preview
19
+ # @return [Boolean] the one process the Preview tab shows
20
+ ProcessSpec = Data.define(:name, :start, :ready, :restart_on, :preview) do
21
+ def initialize(name:, start:, ready: nil, restart_on: [], preview: false) = super
22
+
23
+ # `PORT_<NAME>`: upcased, anything not a letter or digit as `_`, so
24
+ # `api-v2` is `PORT_API_V2` and a shell can expand it.
25
+ #
26
+ # @return [String]
27
+ def port_env = "PORT_#{name.upcase.gsub(/[^A-Z0-9]/, "_")}"
28
+
29
+ # @return [Boolean]
30
+ def preview? = preview == true
31
+
32
+ # @return [Hash] as `.openlanes.yml` spells it, defaults left out
33
+ def to_h
34
+ { "name" => name, "start" => start, "ready" => ready,
35
+ "restart_on" => (restart_on unless restart_on.empty?), "preview" => (true if preview?) }.compact
36
+ end
37
+ end
38
+ end
39
+ end