openlanes 3.0.0 → 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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# `openlanes run` without the command: narrow the roster and the plans,
|
|
6
|
+
# check the pairing makes sense, set the run up (isolation, log,
|
|
7
|
+
# state file, live board, control room), build the {Runner}, run it to a
|
|
8
|
+
# fixed point, and report what it did.
|
|
9
|
+
#
|
|
10
|
+
# Progress, the startup box and the closing report draw on STDERR and the
|
|
11
|
+
# attempts list prints on STDOUT, as the run happens. The answer is the
|
|
12
|
+
# exit status the command ends with.
|
|
13
|
+
class RunWorkflow
|
|
14
|
+
include UI
|
|
15
|
+
include Dry::Monads[:result]
|
|
16
|
+
include Dry::Monads::Do.for(:call)
|
|
17
|
+
|
|
18
|
+
# `--prompt` shorter than this that names an existing file is read.
|
|
19
|
+
PROMPT_PATH_LIMIT = 80
|
|
20
|
+
|
|
21
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
22
|
+
# @param origin [String] the directory the command was typed in, which
|
|
23
|
+
# a path typed into `--prompt` is relative to
|
|
24
|
+
# @param options [Hash] the `run` flags as dry-cli parsed them, over
|
|
25
|
+
# `.openlanes.yml`
|
|
26
|
+
def initialize(tree:, origin: Dir.pwd, **options)
|
|
27
|
+
@tree = tree
|
|
28
|
+
@origin = origin
|
|
29
|
+
@options = options
|
|
30
|
+
@root = options[:root] || File.dirname(tree.dir)
|
|
31
|
+
@dry_run = options.fetch(:dry_run, false)
|
|
32
|
+
@quiet = options.fetch(:quiet, false)
|
|
33
|
+
@startup = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Option precedence, quietest to loudest: the built-in default, then
|
|
37
|
+
# `.openlanes.yml` (`jobs`, and the `commands.run` section), then the
|
|
38
|
+
# flag actually typed. {CLI::Base.default_params} folded the file in.
|
|
39
|
+
#
|
|
40
|
+
# @return [Dry::Monads::Result] Success(Integer) the exit status, or
|
|
41
|
+
# Failure({Refusal}) for flags that contradict each other or the tree
|
|
42
|
+
def call
|
|
43
|
+
yield prompt_needs_agent
|
|
44
|
+
agents = yield roster
|
|
45
|
+
plans = yield scope
|
|
46
|
+
yield assignable(agents, plans) if @options[:agent]
|
|
47
|
+
yield isolatable
|
|
48
|
+
yield logging
|
|
49
|
+
migrated = yield labels_migrated
|
|
50
|
+
|
|
51
|
+
prepare(plans, migrated)
|
|
52
|
+
live = yield live_for(agents)
|
|
53
|
+
web = yield web_for(live)
|
|
54
|
+
runner = runner_for(agents, plans, live, web)
|
|
55
|
+
startup_box
|
|
56
|
+
|
|
57
|
+
started = UI.monotonic
|
|
58
|
+
attempts = run(runner, live, web)
|
|
59
|
+
Success(finish(runner, attempts, seconds: UI.monotonic - started))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# @param message [String]
|
|
65
|
+
# @param status [Integer]
|
|
66
|
+
# @return [Dry::Monads::Result::Failure]
|
|
67
|
+
def refusal(message, status) = Failure(Refusal.new(message:, status:))
|
|
68
|
+
|
|
69
|
+
# @return [Dry::Monads::Result]
|
|
70
|
+
def prompt_needs_agent
|
|
71
|
+
return Success(nil) unless @options[:prompt] && !@options[:agent]
|
|
72
|
+
|
|
73
|
+
refusal("--prompt only works with --agent: it speaks to one agent, and without " \
|
|
74
|
+
"that restriction every agent in the round would hear it.",
|
|
75
|
+
64)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `--agent` restricts the roster for assignments, not just chaining, or a
|
|
79
|
+
# flag like `--prompt` aimed at one agent leaks to the whole round.
|
|
80
|
+
# Each `--skip` name is checked against the full roster, so a misspelled
|
|
81
|
+
# one is a refusal rather than a silent no-op — the same reasoning
|
|
82
|
+
# `--plan` applies to numbers.
|
|
83
|
+
#
|
|
84
|
+
# @return [Dry::Monads::Result] Success(OpenLanes::Agents::Registry)
|
|
85
|
+
def roster
|
|
86
|
+
all = OpenLanes::Agents.registry
|
|
87
|
+
only = @options[:agent]
|
|
88
|
+
skips = @options[:skip].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
89
|
+
|
|
90
|
+
([only].compact + skips).each { |name| all.find(name) or return refusal(unknown_agent(all, name), 65) }
|
|
91
|
+
if only && skips.include?(only)
|
|
92
|
+
return refusal("--agent #{only} and --skip #{only} contradict each other: " \
|
|
93
|
+
"one asks for only this agent, the other for anything but.",
|
|
94
|
+
64)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
agents = only ? all.only(only) : all
|
|
98
|
+
Success(skips.empty? ? agents : agents.without(*skips))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param all [OpenLanes::Agents::Registry]
|
|
102
|
+
# @param name [String]
|
|
103
|
+
# @return [String]
|
|
104
|
+
def unknown_agent(all, name) = "No agent called #{name}.\n\nKnown: #{all.all.map(&:name).join(", ")}"
|
|
105
|
+
|
|
106
|
+
# `--plan` names the whole point of scoping: a skill that just minted
|
|
107
|
+
# N folders hands off exactly those, not the tree. A typo'd or already-
|
|
108
|
+
# settled number silently running the whole tree instead is the failure
|
|
109
|
+
# this exists to prevent, so an unknown one is refused rather than
|
|
110
|
+
# dropped.
|
|
111
|
+
#
|
|
112
|
+
# @return [Dry::Monads::Result] Success(Array<OpenLanes::Plans::Ordinal>), or Success(nil) for all
|
|
113
|
+
def scope
|
|
114
|
+
return Success(nil) unless @options[:plan]
|
|
115
|
+
|
|
116
|
+
known = @tree.subjects.map { |s| s.feature.ordinal }
|
|
117
|
+
ordinals = @options[:plan].split(",").map(&:strip).reject(&:empty?).map do |token|
|
|
118
|
+
ordinal = Plans::Ordinal.parse(token)
|
|
119
|
+
unless ordinal && known.include?(ordinal)
|
|
120
|
+
return refusal("No plan #{token} in #{@tree.dir}.\n\nKnown: #{known.join(", ")}", 66)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
ordinal
|
|
124
|
+
end
|
|
125
|
+
Success(ordinals)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `--agent` narrowing the round to nobody used to exit 0 in silence —
|
|
129
|
+
# "0 plans addressed", green box, no hint that the agent named simply
|
|
130
|
+
# does not handle any in-scope plan's state. Someone who typed both an
|
|
131
|
+
# agent and a plan meant them to meet, so a pairing that cannot happen
|
|
132
|
+
# is refused up front with the agent that would actually take each plan.
|
|
133
|
+
#
|
|
134
|
+
# @param agents [OpenLanes::Agents::Registry] already narrowed to the one agent
|
|
135
|
+
# @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
|
|
136
|
+
# @return [Dry::Monads::Result]
|
|
137
|
+
def assignable(agents, plans)
|
|
138
|
+
name = @options[:agent]
|
|
139
|
+
agent = agents.find(name)
|
|
140
|
+
active = @tree.subjects.select do |s|
|
|
141
|
+
(plans.nil? || plans.include?(s.feature.ordinal)) && !Plans::StateMachine::SETTLED.include?(s.status.key)
|
|
142
|
+
end
|
|
143
|
+
return Success(nil) if active.empty? || active.any? { |s| agent.handles?(s.status) }
|
|
144
|
+
|
|
145
|
+
refusal("#{name} handles #{handled(agent).join(", ")}, and no plan in scope is there:\n\n" \
|
|
146
|
+
"#{takers(active).join("\n")}\n\nName the agent that takes these states, or drop --agent.",
|
|
147
|
+
65)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param agent [OpenLanes::Agents::Agent]
|
|
151
|
+
# @return [Array<String>] the states it handles, as a person reads them
|
|
152
|
+
def handled(agent)
|
|
153
|
+
agent.handles.map { |k| OpenLanes::Plans::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param active [Array<OpenLanes::Plans::Subject>]
|
|
157
|
+
# @return [Array<String>] each plan's state, and who would take it
|
|
158
|
+
def takers(active)
|
|
159
|
+
roster = OpenLanes::Agents.registry
|
|
160
|
+
active.map do |s|
|
|
161
|
+
names = OpenLanes::Agents::Routing.filter(roster.for_status(s.status), s).map(&:name)
|
|
162
|
+
verb = names.size == 1 ? "takes" : "take"
|
|
163
|
+
" #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
|
|
164
|
+
"#{" - #{names.join(" and ")} #{verb} it" unless names.empty?}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Symbol] :worktree or :shared
|
|
169
|
+
def isolation = @options.fetch(:isolation, "worktree").to_sym
|
|
170
|
+
|
|
171
|
+
# @return [Dry::Monads::Result]
|
|
172
|
+
def isolatable
|
|
173
|
+
return Success(nil) unless isolation == :worktree && !OpenLanes::Vcs.worktree(root: @root).repository?
|
|
174
|
+
|
|
175
|
+
refusal("#{@root} is not a git repository, so plans cannot be isolated.\n\n" \
|
|
176
|
+
"Run with --isolation shared to work in one tree, serially.",
|
|
177
|
+
66)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Opens the log every event of the run is written to, the one the
|
|
181
|
+
# control room's server will write to as well (it is handed the same
|
|
182
|
+
# settings through {Web::Launcher#env}). Logged as `engine`, so its
|
|
183
|
+
# lines and the server's tell themselves apart.
|
|
184
|
+
#
|
|
185
|
+
# The project root rather than `--root`: `--root` is where agents
|
|
186
|
+
# work, and the log belongs with the `.plans` it reports on.
|
|
187
|
+
#
|
|
188
|
+
# @return [Dry::Monads::Result]
|
|
189
|
+
def logging
|
|
190
|
+
settings = Log.setup(Log::Settings.from(@options, root: File.dirname(@tree.dir)), name: "engine")
|
|
191
|
+
Log.logger.info("run starting", root: @root, dry_run: @dry_run)
|
|
192
|
+
Success(settings)
|
|
193
|
+
rescue Log::Invalid => e
|
|
194
|
+
refusal(e.message, 64)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The warnings that must land before the first agent,
|
|
198
|
+
# the state file, and a clean control file.
|
|
199
|
+
#
|
|
200
|
+
# @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
|
|
201
|
+
# @param migrated [OpenLanes::Vcs::LabelMigration::Outcome, nil] what
|
|
202
|
+
# {#labels_migrated} did, reported beside the sync
|
|
203
|
+
# @return [void]
|
|
204
|
+
def prepare(plans, migrated = nil)
|
|
205
|
+
startup("Log", UI.log_path)
|
|
206
|
+
credentials unless @dry_run || @quiet
|
|
207
|
+
spec_warning(plans) unless @quiet
|
|
208
|
+
sync_labels(migrated) if publishing?
|
|
209
|
+
|
|
210
|
+
# The state file and the locks live in the runtime state directory,
|
|
211
|
+
# outside the repository; what is left to ignore is the temp siblings
|
|
212
|
+
# plan documents are written to and renamed over. Editing somebody's
|
|
213
|
+
# .gitignore is announced, the once it happens.
|
|
214
|
+
if !@dry_run && StateFile.ensure_ignored!(@root) && !@quiet
|
|
215
|
+
info("Added #{StateFile::IGNORE.join(" and ")} to .gitignore: plan documents are written through these.")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Control files from a previous run would otherwise be read as orders
|
|
219
|
+
# to this one.
|
|
220
|
+
Execution::Control.reset!
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# M3's review labels moved onto R3's (R3), before anything else of the
|
|
224
|
+
# run: chewie's verification reads `ready-to-merge` as verified and
|
|
225
|
+
# skips CI and coverage, so a run that started with the migration still
|
|
226
|
+
# owed would trust an M3 approval as a verification, and could put a
|
|
227
|
+
# real `ready-to-merge` on that the next start's migration takes back
|
|
228
|
+
# off. So a publishing run that cannot migrate does not start. A dry
|
|
229
|
+
# run labels nothing and only warns.
|
|
230
|
+
#
|
|
231
|
+
# @return [Dry::Monads::Result] Success(Outcome or nil), or a refusal
|
|
232
|
+
def labels_migrated
|
|
233
|
+
return Success(nil) unless publishing?
|
|
234
|
+
|
|
235
|
+
Success(Vcs.label_migration(root: @root).call(dry_run: @dry_run))
|
|
236
|
+
rescue OpenLanes::Error => e
|
|
237
|
+
if @dry_run
|
|
238
|
+
warn("Review labels not migrated: #{e.message}") unless @quiet
|
|
239
|
+
return Success(nil)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
refusal("Review labels not migrated: #{e.message}\n\n" \
|
|
243
|
+
"Until they are, an M3 ready-to-merge on an open pull request would read as chewie's " \
|
|
244
|
+
"verification. Start again once gh can reach the repository, or run with --no-git-push.",
|
|
245
|
+
69)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Boolean] whether this run opens and labels pull requests;
|
|
249
|
+
# the same test {#publisher_for} applies, without its warning
|
|
250
|
+
def publishing? = isolation == :worktree && @options[:git_push] != false
|
|
251
|
+
|
|
252
|
+
# Every review label on the repository, in R3's colours, before any
|
|
253
|
+
# pull request is labelled (R3). Once per run. A `gh` that cannot be
|
|
254
|
+
# reached is warned about rather than refused: labelling creates a
|
|
255
|
+
# missing label on first use anyway, so the run loses colours, not work.
|
|
256
|
+
# Against `--root`'s repository, the one the publisher opens pull
|
|
257
|
+
# requests on, not whichever checkout the engine was started from.
|
|
258
|
+
#
|
|
259
|
+
# @param migrated [OpenLanes::Vcs::LabelMigration::Outcome, nil]
|
|
260
|
+
# @return [void]
|
|
261
|
+
def sync_labels(migrated = nil)
|
|
262
|
+
changes = Vcs.label_sync(root: @root).call(dry_run: @dry_run).select(&:write?)
|
|
263
|
+
lines = [migrated&.to_s, *changes].compact.map { " #{it}" }
|
|
264
|
+
return if lines.empty? || @quiet
|
|
265
|
+
|
|
266
|
+
lead = @dry_run ? "Dry run: would sync review labels" : "Synced review labels"
|
|
267
|
+
info("#{lead}:\n#{lines.join("\n")}")
|
|
268
|
+
rescue OpenLanes::Error => e
|
|
269
|
+
warn("Review labels not synced: #{e.message}") unless @quiet
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @return [void]
|
|
273
|
+
def credentials
|
|
274
|
+
notice = Execution.credentials_notice
|
|
275
|
+
warn(notice) if notice
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A spec.md whose frontmatter does not parse routes as if it had none:
|
|
279
|
+
# lane full, every override dropped. An eval agent's unquoted backtick
|
|
280
|
+
# sent a quick-lane rename to research that way, and said nothing.
|
|
281
|
+
# {Plans::Spec#problems} already knew; say it before agents are assigned.
|
|
282
|
+
#
|
|
283
|
+
# @param plans [Array<OpenLanes::Plans::Ordinal>, nil] the --plan ordinals, nil for all
|
|
284
|
+
# @return [void]
|
|
285
|
+
def spec_warning(plans)
|
|
286
|
+
lines = @tree.subjects.filter_map do |subject|
|
|
287
|
+
ordinal = subject.feature.ordinal
|
|
288
|
+
next if plans && !plans.include?(ordinal)
|
|
289
|
+
|
|
290
|
+
problems = Plans::Spec.for(subject).problems
|
|
291
|
+
"#{ordinal}: #{problems.join("; ")}" unless problems.empty?
|
|
292
|
+
end
|
|
293
|
+
return if lines.empty?
|
|
294
|
+
|
|
295
|
+
warn("spec.md frontmatter ignored in part, so these plans route on defaults:\n#{lines.join("\n")}")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The live board (W6, W13, W26), when this run has one: not on a dry
|
|
299
|
+
# run, which must leave nothing behind, and not without Redis, where
|
|
300
|
+
# the run goes on exactly as it did before there was a page. Either
|
|
301
|
+
# way it is said once, so a page that never updates is not a mystery.
|
|
302
|
+
#
|
|
303
|
+
# @param agents [OpenLanes::Agents::Registry]
|
|
304
|
+
# @return [Dry::Monads::Result] Success(OpenLanes::Live::Session or nil)
|
|
305
|
+
def live_for(agents)
|
|
306
|
+
if @dry_run
|
|
307
|
+
warn("Dry run: the live board is off, so nothing is published to Redis and no plan is claimed.") unless @quiet
|
|
308
|
+
return Success(nil)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
config = Config.current
|
|
312
|
+
store = Live.connect(Live.redis_url(option: @options[:redis_url], config:))
|
|
313
|
+
Success(Live.session(store:, root: @root, tree: @tree, agents:, project: config.project).tap do |session|
|
|
314
|
+
startup("Live board", session.described)
|
|
315
|
+
end)
|
|
316
|
+
rescue Live::Unavailable => e
|
|
317
|
+
without_live(e.message)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A live board this run cannot do without: `run` goes on headless.
|
|
321
|
+
#
|
|
322
|
+
# @param message [String] why there is no board
|
|
323
|
+
# @return [Dry::Monads::Result] Success(nil)
|
|
324
|
+
def without_live(message)
|
|
325
|
+
unless @quiet
|
|
326
|
+
warn("#{message}\n\nThe run goes on without the live board: no page, no commands from it, and no " \
|
|
327
|
+
"plan claims shared with other machines. Start redis-server, or pass --redis-url.")
|
|
328
|
+
end
|
|
329
|
+
Success(nil)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The control room (W1, W7): `openlanes web` as its own process beside
|
|
333
|
+
# the engine, reading the board this run publishes. Only with a live
|
|
334
|
+
# board to read, so a dry run, a run without Redis and `--no-web` all
|
|
335
|
+
# stay headless. A server that does not come up is reported and left
|
|
336
|
+
# out; the run is the agents' work, and the page is a view of it.
|
|
337
|
+
#
|
|
338
|
+
# @param live [OpenLanes::Live::Session, nil]
|
|
339
|
+
# @return [Dry::Monads::Result] Success(OpenLanes::Web::Launcher or nil)
|
|
340
|
+
def web_for(live)
|
|
341
|
+
return Success(nil) if live.nil? || @options[:web] == false
|
|
342
|
+
|
|
343
|
+
port = Integer(@options[:port] || OpenLanes::Web::DEFAULT_PORT)
|
|
344
|
+
listen = @options[:listen] || OpenLanes::Web::Listen.parse(nil)
|
|
345
|
+
web = OpenLanes::Web.launcher(root: @root,
|
|
346
|
+
plans_dir: @tree.dir,
|
|
347
|
+
port:,
|
|
348
|
+
redis_url: live.store.url,
|
|
349
|
+
listen:,
|
|
350
|
+
credentials: @options[:credentials]).start
|
|
351
|
+
unless web.ready?
|
|
352
|
+
web.stop
|
|
353
|
+
return without_web(web, live)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
startup("Control room", web.port == port ? web.url : "#{web.url} (#{port} was taken)")
|
|
357
|
+
startup("Also on", "#{(listen.urls(web.port) - [web.url]).join(", ")}, behind a login") if listen.public?
|
|
358
|
+
OpenLanes::Web.open(browser_url(web)) unless @options[:open] == false
|
|
359
|
+
Success(web)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# @param web [OpenLanes::Web::Launcher] one that did not answer
|
|
363
|
+
# @param _live [OpenLanes::Live::Session]
|
|
364
|
+
# @return [Dry::Monads::Result] Success(nil)
|
|
365
|
+
def without_web(web, _live)
|
|
366
|
+
warn("The control room did not answer on #{web.url}, so the run goes on without it. Its log: #{web.log}") unless @quiet
|
|
367
|
+
Success(nil)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Where the browser is sent once the control room answers.
|
|
371
|
+
#
|
|
372
|
+
# @param web [OpenLanes::Web::Launcher]
|
|
373
|
+
# @return [String]
|
|
374
|
+
def browser_url(web) = web.url
|
|
375
|
+
|
|
376
|
+
# @param agents [OpenLanes::Agents::Registry]
|
|
377
|
+
# @param plans [Array<OpenLanes::Plans::Ordinal>, nil]
|
|
378
|
+
# @param live [OpenLanes::Live::Session, nil]
|
|
379
|
+
# @param web [OpenLanes::Web::Launcher, nil]
|
|
380
|
+
# @return [OpenLanes::Engine::Runner]
|
|
381
|
+
def runner_for(agents, plans, live, web)
|
|
382
|
+
worktree = (OpenLanes::Vcs.worktree(root: @root) if isolation == :worktree)
|
|
383
|
+
publisher = publisher_for(@root, isolation)
|
|
384
|
+
Engine.runner(
|
|
385
|
+
tree: @tree,
|
|
386
|
+
agents:,
|
|
387
|
+
isolation:,
|
|
388
|
+
jobs: (@options[:jobs] || UI.default_jobs).to_i,
|
|
389
|
+
plans:,
|
|
390
|
+
state: Engine.state_file(@tree),
|
|
391
|
+
worktree:,
|
|
392
|
+
rounds: @options[:rounds]&.to_i,
|
|
393
|
+
executor: executor,
|
|
394
|
+
dry_run: @dry_run,
|
|
395
|
+
publisher:,
|
|
396
|
+
verification: verification_for(publisher, worktree),
|
|
397
|
+
merging: merging_for(publisher, worktree),
|
|
398
|
+
on_board: live&.on_board,
|
|
399
|
+
claims: live&.claims,
|
|
400
|
+
ignore_bombs: @options.fetch(:ignore_bombs, false),
|
|
401
|
+
aging_since: Config.current.aging_since,
|
|
402
|
+
journal: (journal_for(live) unless @dry_run),
|
|
403
|
+
**staying(web, live)
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# The run's progress journal (S9), under the runtime directory and
|
|
408
|
+
# never under `.plans/`, mirrored to the live bus when there is one.
|
|
409
|
+
#
|
|
410
|
+
# @param live [OpenLanes::Live::Session, nil]
|
|
411
|
+
# @return [OpenLanes::Engine::EventJournal]
|
|
412
|
+
def journal_for(live)
|
|
413
|
+
return EventJournal.new(root: @root) unless live
|
|
414
|
+
|
|
415
|
+
EventJournal.new(root: @root, node: live.keys.node, bus: Live::EventBus.for(live.store), channel: live.keys.journal)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# @return [OpenLanes::Execution::Executor]
|
|
419
|
+
def executor
|
|
420
|
+
Execution.executor(root: @root,
|
|
421
|
+
timeout: @options[:timeout]&.to_i,
|
|
422
|
+
dry_run: @dry_run,
|
|
423
|
+
instructions: instructions_from(@options[:prompt]),
|
|
424
|
+
model: @options[:model],
|
|
425
|
+
max_tokens: @options[:max_tokens]&.to_i,
|
|
426
|
+
lean: !@options[:user_config])
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# `--prompt` takes the instructions themselves or a file holding them.
|
|
430
|
+
# A short argument naming an existing file is read; anything else is
|
|
431
|
+
# the text, so a sentence that happens to match a filename in the
|
|
432
|
+
# project is not silently swapped for that file once it is long enough
|
|
433
|
+
# to be a sentence.
|
|
434
|
+
#
|
|
435
|
+
# @param prompt [String, nil]
|
|
436
|
+
# @return [String, nil]
|
|
437
|
+
def instructions_from(prompt)
|
|
438
|
+
path = prompt && File.expand_path(prompt, @origin)
|
|
439
|
+
return prompt unless prompt && prompt.length < PROMPT_PATH_LIMIT && File.file?(path)
|
|
440
|
+
|
|
441
|
+
File.read(path)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# The one thing this harness does that leaves the machine — pushing a
|
|
445
|
+
# branch and opening its pull request — so it takes one more thing to
|
|
446
|
+
# turn on than everything else here: not being a dry run is not enough,
|
|
447
|
+
# isolation has to actually give each plan a branch of its own too.
|
|
448
|
+
#
|
|
449
|
+
# `nil` is how {Runner} is told to leave a finished worktree alone; it
|
|
450
|
+
# is what `--no-git-push` asks for, and what a shared tree gets
|
|
451
|
+
# by construction, since there is no separate branch there to push.
|
|
452
|
+
#
|
|
453
|
+
# @param root [String]
|
|
454
|
+
# @param isolation [Symbol]
|
|
455
|
+
# @return [OpenLanes::Vcs::Publisher, nil]
|
|
456
|
+
def publisher_for(root, isolation)
|
|
457
|
+
if isolation != :worktree || @options[:git_push] == false
|
|
458
|
+
unless @quiet
|
|
459
|
+
warn("No pull request will be opened (#{isolation == :worktree ? "--no-git-push" : "--isolation shared"}), " \
|
|
460
|
+
"so a finished build stays at 🟡 Building:\n🟢 Ready for Review needs an open pull request, " \
|
|
461
|
+
"and hansolo-reviewer reviews one.")
|
|
462
|
+
end
|
|
463
|
+
return nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
Vcs.publisher(root:, dry_run: @dry_run)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Verification (plan 014) commits, comments and labels, so it runs
|
|
470
|
+
# only where the build was published: a plan never pushed has no pull
|
|
471
|
+
# request to verify, and a dry run changes nothing.
|
|
472
|
+
#
|
|
473
|
+
# @param publisher [OpenLanes::Vcs::Publisher, nil]
|
|
474
|
+
# @param worktree [OpenLanes::Vcs::Worktree, nil]
|
|
475
|
+
# @return [OpenLanes::Engine::Verification::Driver, nil]
|
|
476
|
+
def verification_for(publisher, worktree)
|
|
477
|
+
return nil if publisher.nil? || worktree.nil? || @dry_run
|
|
478
|
+
|
|
479
|
+
github = Vcs.github
|
|
480
|
+
config = Config.current
|
|
481
|
+
outcome = Verification.outcome(root: @root,
|
|
482
|
+
publisher:,
|
|
483
|
+
github:,
|
|
484
|
+
outcomes: PrOutcomes.new(tree_dir: @tree.dir, agents: Agents::Registry.new),
|
|
485
|
+
agents: Agents::Registry.new,
|
|
486
|
+
# Read live, like `quality:` below: U3 asks the quality mid-run, and
|
|
487
|
+
# an answer of mvp lowers the cap to 1 as soon as it is given.
|
|
488
|
+
max_rejections: -> { Config.current.max_rejections })
|
|
489
|
+
Verification::Driver.new(outcome:, worktree:, app: Apps.definition(config), quality: -> { Config.current.desired_codebase_quality })
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Merging (P1 to P5) labels, merges and rebases on GitHub, so it runs
|
|
493
|
+
# only where verification does: a plan never published has no pull
|
|
494
|
+
# request to merge, and a dry run changes nothing.
|
|
495
|
+
#
|
|
496
|
+
# @param publisher [OpenLanes::Vcs::Publisher, nil]
|
|
497
|
+
# @param worktree [OpenLanes::Vcs::Worktree, nil]
|
|
498
|
+
# @return [OpenLanes::Engine::Merging, nil]
|
|
499
|
+
def merging_for(publisher, worktree)
|
|
500
|
+
return nil if publisher.nil? || worktree.nil? || @dry_run
|
|
501
|
+
|
|
502
|
+
Merging.new(github: Vcs.github,
|
|
503
|
+
outcomes: PrOutcomes.new(tree_dir: @tree.dir, agents: Agents::Registry.new),
|
|
504
|
+
# Read live, as the quality is: a person may switch `merge.by`
|
|
505
|
+
# while a plan waits at 🏁.
|
|
506
|
+
merge_by: -> { Config.current.merger },
|
|
507
|
+
worktree:,
|
|
508
|
+
publisher:,
|
|
509
|
+
# A merged plan's checkout with somebody's lock in it stays.
|
|
510
|
+
held: ->(path) { Engine.agent_locks(@tree.dir)&.held_in(path)&.to_s })
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# What the runner is told about idling past its fixed points. With its
|
|
514
|
+
# control room up the engine stays, since the page can answer a blocked
|
|
515
|
+
# plan or create the next one at any moment, and a run that had ended
|
|
516
|
+
# would leave the page talking to nobody. `--stay` asks the same of a
|
|
517
|
+
# headless run whose page `openlanes web` serves apart, as mprocs does:
|
|
518
|
+
# the commands travel through the live board, not the server. Otherwise
|
|
519
|
+
# headless, or with `--until-settled`, it ends at the first fixed point.
|
|
520
|
+
#
|
|
521
|
+
# A staying run is also the one that watches the pull requests a person
|
|
522
|
+
# merges (M4): it is still up when they do. A run that ends cannot wait
|
|
523
|
+
# for a merge, so it names the pull requests it leaves unwatched instead.
|
|
524
|
+
#
|
|
525
|
+
# @param web [OpenLanes::Web::Launcher, nil] the control room, when it came up
|
|
526
|
+
# @param live [OpenLanes::Live::Session, nil] the board a page apart would read
|
|
527
|
+
# @return [Hash] `stay:`, `rescan:` and `watch:` for {Runner}, or nothing
|
|
528
|
+
def staying(web, live = nil)
|
|
529
|
+
stay_unheeded(live)
|
|
530
|
+
return {} if @options[:until_settled]
|
|
531
|
+
return {} unless web || (live && @options[:stay])
|
|
532
|
+
|
|
533
|
+
@stays = if web
|
|
534
|
+
"The engine stays up with its control room: Stop All Local or Ctrl-C ends it."
|
|
535
|
+
else
|
|
536
|
+
"The engine stays up for the control room `openlanes web` serves: Stop All Local or Ctrl-C ends it."
|
|
537
|
+
end
|
|
538
|
+
{ stay: true, rescan: Dispatcher::RESCAN, watch: watch }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# A `--stay` this run cannot keep, said once on STDERR. Dropped in
|
|
542
|
+
# silence, mprocs showed an engine that had ended at its first fixed
|
|
543
|
+
# point and nothing said why. A run without Redis goes on headless
|
|
544
|
+
# rather than refusing (see {#without_live}), so this warns the same
|
|
545
|
+
# way instead of exiting 69 as `start` does.
|
|
546
|
+
#
|
|
547
|
+
# @param live [OpenLanes::Live::Session, nil]
|
|
548
|
+
# @return [void]
|
|
549
|
+
def stay_unheeded(live)
|
|
550
|
+
return if @quiet || !@options[:stay]
|
|
551
|
+
|
|
552
|
+
if @options[:until_settled]
|
|
553
|
+
warn("--stay is ignored with --until-settled: the run ends at the first fixed point.")
|
|
554
|
+
elsif live.nil?
|
|
555
|
+
warn("--stay needs the live board (Redis) a separate page reads, and this run has none, " \
|
|
556
|
+
"so it ends at the first fixed point.")
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# The pull request watch a staying run carries, in human mode only: when
|
|
561
|
+
# agents merge, nobody else's merge is worth waiting for. Kept apart from
|
|
562
|
+
# {#staying} so {StartWorkflow}, which overrides that, cannot leave it behind.
|
|
563
|
+
#
|
|
564
|
+
# @return [OpenLanes::Engine::PrWatch, nil]
|
|
565
|
+
def watch
|
|
566
|
+
PrWatch.new(github: Vcs.github) if Config.current.human_merges? && !@dry_run
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The open pull requests a run that does not stay leaves for a person,
|
|
570
|
+
# logged so the next `openlanes start` is known to be the one to watch them.
|
|
571
|
+
#
|
|
572
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
573
|
+
# @return [void]
|
|
574
|
+
def unwatched(runner)
|
|
575
|
+
return if @stays || !Config.current.human_merges?
|
|
576
|
+
|
|
577
|
+
open = runner.in_scope.flat_map { |s| s.pull_requests.select(&:open?).map { "#{s.feature.ordinal} ##{it.number}" } }
|
|
578
|
+
return if open.empty?
|
|
579
|
+
|
|
580
|
+
UI.log("not watching #{open.join(", ")}: openlanes start watches pull requests until they merge")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# One line of the startup box, kept until {#startup_box} draws it.
|
|
584
|
+
#
|
|
585
|
+
# @param label [String]
|
|
586
|
+
# @param value [String, Array<String>] parts a narrow box may break between
|
|
587
|
+
# @return [void]
|
|
588
|
+
def startup(label, value) = @startup << [label, value]
|
|
589
|
+
|
|
590
|
+
# Where the run writes, what it publishes to, and where its page is:
|
|
591
|
+
# one Info box, drawn once all three are known. A line that does not
|
|
592
|
+
# apply (no Redis, no control room) is left out, not drawn empty.
|
|
593
|
+
#
|
|
594
|
+
# @return [void]
|
|
595
|
+
def startup_box
|
|
596
|
+
return if @quiet || @startup.empty?
|
|
597
|
+
|
|
598
|
+
info([UI.labelled(@startup), @stays].compact.join("\n\n"))
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The terminal hears the run as it happens, one appended line per
|
|
602
|
+
# event, whether or not a control room is up: the page is the board,
|
|
603
|
+
# and the terminal is its log. Nothing here redraws, so nothing can
|
|
604
|
+
# draw over a box or over another line.
|
|
605
|
+
#
|
|
606
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
607
|
+
# @param live [OpenLanes::Live::Session, nil]
|
|
608
|
+
# @param web [OpenLanes::Web::Launcher, nil]
|
|
609
|
+
# @return [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
610
|
+
def run(runner, live, web)
|
|
611
|
+
narrator = UI.tap_log(Narrator.new) unless @quiet
|
|
612
|
+
Execution.on_interrupt(signals:) { runner.call { |dispatcher| live&.attach(dispatcher) } }
|
|
613
|
+
ensure
|
|
614
|
+
UI.untap_log(narrator) if narrator
|
|
615
|
+
live&.close
|
|
616
|
+
web&.stop
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Signals that stop the run gracefully, the way the first Ctrl-C does.
|
|
620
|
+
# TERM too: a run with its control room stays up like a service, and a
|
|
621
|
+
# service manager stops a service that way.
|
|
622
|
+
#
|
|
623
|
+
# @return [Array<String>]
|
|
624
|
+
def signals = %w[INT TERM]
|
|
625
|
+
|
|
626
|
+
# The closing report. A halted run that exited 0 would read to a
|
|
627
|
+
# script as a tree with nothing left to do, so a 💣 makes it 1 (N9).
|
|
628
|
+
#
|
|
629
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
630
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
631
|
+
# @param seconds [Float] wall clock for the whole loop
|
|
632
|
+
# @return [Integer] the exit status: 1 when any attempt failed, or
|
|
633
|
+
# when a 💣 halted the run
|
|
634
|
+
def finish(runner, attempts, seconds:)
|
|
635
|
+
unwatched(runner)
|
|
636
|
+
report(runner, attempts, seconds:)
|
|
637
|
+
bombs = runner.halted_by
|
|
638
|
+
bomb_report(bombs)
|
|
639
|
+
status = failures(attempts).empty? && bombs.empty? ? 0 : 1
|
|
640
|
+
Log.logger.public_send(status.zero? ? :info : :warn,
|
|
641
|
+
"run finished",
|
|
642
|
+
attempts: attempts.size,
|
|
643
|
+
failed: failures(attempts).size,
|
|
644
|
+
seconds: seconds.round(1),
|
|
645
|
+
exit: status)
|
|
646
|
+
status
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Named on STDERR even under --quiet: it is why the run exits 1.
|
|
650
|
+
#
|
|
651
|
+
# @param bombs [Array<OpenLanes::Plans::Subject>]
|
|
652
|
+
# @return [void]
|
|
653
|
+
def bomb_report(bombs)
|
|
654
|
+
return if bombs.empty?
|
|
655
|
+
|
|
656
|
+
names = bombs.map { |b| " #{b.feature.ordinal} #{b.feature.title} — see blocked.md" }.join("\n")
|
|
657
|
+
error("Halted: #{bombs.size} 💣 plan#{"s" unless bombs.size == 1} other plans wait on, " \
|
|
658
|
+
"blocked too long:\n#{names}\n\n" \
|
|
659
|
+
"Answer blocked.md, resume anyway from the control room, or re-run with --ignore-bombs.")
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
663
|
+
# @return [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
664
|
+
def failures(attempts) = attempts.reject(&:ok)
|
|
665
|
+
|
|
666
|
+
# One line per attempt: the plan, who ran, which of that agent's rounds
|
|
667
|
+
# it was, what moved, and the note. There is no round header because
|
|
668
|
+
# the loop has no rounds of its own: each agent counts its own per
|
|
669
|
+
# plan, so two agents on one plan can be on different numbers.
|
|
670
|
+
#
|
|
671
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
672
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
673
|
+
# @param seconds [Float]
|
|
674
|
+
# @return [void]
|
|
675
|
+
def report(runner, attempts, seconds:)
|
|
676
|
+
# What the run cost, on STDOUT with the attempts it belongs to, so a run
|
|
677
|
+
# redirected to a file keeps its bill. A dry run spent nothing and gets
|
|
678
|
+
# none of this.
|
|
679
|
+
puts_report(attempts, (Engine.tally(attempts:, seconds:).render unless @dry_run))
|
|
680
|
+
return if @quiet
|
|
681
|
+
|
|
682
|
+
if Execution::Control.quit?
|
|
683
|
+
warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
|
|
684
|
+
"re-run to resume where they stand.")
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
summarize(runner, attempts)
|
|
688
|
+
held_report(runner.held)
|
|
689
|
+
blocked_report(runner.blocked)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
693
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
694
|
+
# @return [void]
|
|
695
|
+
def summarize(runner, attempts)
|
|
696
|
+
failed = failures(attempts)
|
|
697
|
+
summary = ["#{attempts.size} invocation#{"s" unless attempts.size == 1}", "#{attempts.count(&:advanced?)} advanced"]
|
|
698
|
+
summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
|
|
699
|
+
summary << "#{runner.blocked.size} blocked" unless runner.blocked.empty?
|
|
700
|
+
summary << "#{failed.size} failed" unless failed.empty?
|
|
701
|
+
|
|
702
|
+
if @dry_run
|
|
703
|
+
warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run without --dry-run.")
|
|
704
|
+
elsif failed.empty?
|
|
705
|
+
success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
|
|
706
|
+
else
|
|
707
|
+
error("#{summary.join(" · ")}\n\n#{failed.map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Plans the dependency gate held back, and why, so a run that stops at a
|
|
712
|
+
# fixed point with work left says what it is waiting for.
|
|
713
|
+
#
|
|
714
|
+
# @param held [Hash{String => String}] ordinal => reason
|
|
715
|
+
# @return [void]
|
|
716
|
+
def held_report(held)
|
|
717
|
+
return if held.empty?
|
|
718
|
+
|
|
719
|
+
warn("#{held.size} plan#{"s" unless held.size == 1} held by dependencies:\n" +
|
|
720
|
+
held.map { |ordinal, why| " #{ordinal} #{why}" }.join("\n"))
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# @param blocked [Array<OpenLanes::Plans::Subject>]
|
|
724
|
+
# @return [void]
|
|
725
|
+
def blocked_report(blocked)
|
|
726
|
+
return if blocked.empty?
|
|
727
|
+
|
|
728
|
+
warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
|
|
729
|
+
blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
|
|
730
|
+
"\n\nWrite the answers into blocked.md, then: openlanes unblock " \
|
|
731
|
+
"#{blocked.map { |b| b.feature.ordinal }.join(",")}")
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# The attempts and the bill, one blank line before each part that has
|
|
735
|
+
# anything in it. `puts []` prints an empty line, so a run with no
|
|
736
|
+
# attempts used to open its report with two blank lines and a third
|
|
737
|
+
# before the tally.
|
|
738
|
+
#
|
|
739
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
740
|
+
# @param tally [String, nil] nil prints none
|
|
741
|
+
# @return [void]
|
|
742
|
+
def puts_report(attempts, tally)
|
|
743
|
+
lines = attempt_lines(attempts)
|
|
744
|
+
puts("", *lines) unless lines.empty?
|
|
745
|
+
puts("", tally) if tally
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# The attempts list, padded into columns. Widths come from the rows
|
|
749
|
+
# themselves, so the columns are as narrow as the run allows and still
|
|
750
|
+
# square; tabs put every column on the next multiple of eight, so one
|
|
751
|
+
# agent's name a character longer pushed a whole column a stop right.
|
|
752
|
+
#
|
|
753
|
+
# @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
|
|
754
|
+
# @return [Array<String>] one line per attempt, indented by two
|
|
755
|
+
def attempt_lines(attempts)
|
|
756
|
+
rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
|
|
757
|
+
widths = rows.transpose.map { |column| column.map { |cell| UI.display_width(cell) }.max.to_i }
|
|
758
|
+
rows.map do |cells|
|
|
759
|
+
# The note is last and is left as it is: padding the final column
|
|
760
|
+
# only adds trailing whitespace a copy-paste then carries.
|
|
761
|
+
last = cells.size - 1
|
|
762
|
+
" #{cells.each_with_index.map { |cell, i| i == last ? cell : UI.fit(cell, widths[i] + 2) }.join}"
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# @param attempt [OpenLanes::Engine::Runner::Attempt]
|
|
767
|
+
# @return [String] what the attempt did to its plan's state
|
|
768
|
+
def mark(attempt)
|
|
769
|
+
return "FAIL" unless attempt.ok
|
|
770
|
+
return "#{attempt.from} -> #{attempt.to}" if attempt.advanced?
|
|
771
|
+
|
|
772
|
+
"no change"
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|