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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module CLI
|
|
5
|
+
# `openlanes setup` — get this machine ready to run the factory: the
|
|
6
|
+
# gem's `Brewfile`, then the claude CLI, `gh auth` and Redis.
|
|
7
|
+
#
|
|
8
|
+
# Standalone, since a machine is ready or not whichever directory it
|
|
9
|
+
# was asked from.
|
|
10
|
+
class Setup < Base
|
|
11
|
+
standalone!
|
|
12
|
+
desc "Install the tools openlanes needs (brew bundle) and check claude, gh auth and Redis"
|
|
13
|
+
|
|
14
|
+
dry_run_option "Install nothing; list what would be installed and run the checks"
|
|
15
|
+
option :redis_url, desc: "Redis to check (default: .openlanes.yml redis_url, else $REDIS_URL, else #{Live::DEFAULT_URL})"
|
|
16
|
+
|
|
17
|
+
example [
|
|
18
|
+
" # install what is missing, then check",
|
|
19
|
+
"-n # list what would be installed, change nothing"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# @param options [Hash]
|
|
23
|
+
# @return [void]
|
|
24
|
+
def call(**options)
|
|
25
|
+
quiet?(options)
|
|
26
|
+
machine = machine(options)
|
|
27
|
+
outcome = machine.call(dry_run: dry_run?(options))
|
|
28
|
+
preview(machine, outcome) if dry_run?(options)
|
|
29
|
+
$stdout.write(OpenLanes::Setup.render(outcome.checks))
|
|
30
|
+
refuse("This machine is not ready: run each fix above, then openlanes setup again.", 69) unless outcome.ok?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A named seam, so a spec can stand in for brew, gh and Redis.
|
|
36
|
+
#
|
|
37
|
+
# @param options [Hash]
|
|
38
|
+
# @return [OpenLanes::Setup::Machine]
|
|
39
|
+
def machine(options) = OpenLanes::Setup.machine(redis_url: options[:redis_url])
|
|
40
|
+
|
|
41
|
+
# @return [void]
|
|
42
|
+
def preview(machine, outcome)
|
|
43
|
+
install = outcome.missing.empty? ? "nothing" : outcome.missing.join(", ")
|
|
44
|
+
warn("Dry run — nothing was installed.\n" \
|
|
45
|
+
"Would install: #{install}\n" \
|
|
46
|
+
"#{"Would run: #{machine.bundle_command.join(" ")}\n" unless outcome.missing.empty?}" \
|
|
47
|
+
"Checks: #{outcome.checks.map(&:name).join(", ")}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -20,10 +20,10 @@ module OpenLanes
|
|
|
20
20
|
|
|
21
21
|
# Inherited from `run`, and meaningless here: a dry run publishes no
|
|
22
22
|
# board, and a run without its page cannot be answered.
|
|
23
|
-
@options = options.reject { %i[dry_run web until_settled].include?(it.name) }
|
|
23
|
+
@options = options.reject { %i[dry_run web stay until_settled].include?(it.name) }
|
|
24
24
|
|
|
25
25
|
option :rescan,
|
|
26
|
-
default: Engine::Dispatcher::
|
|
26
|
+
default: Engine::Dispatcher::RESCAN_SECONDS.to_s,
|
|
27
27
|
desc: "Seconds between rescans of .plans while idle; a command from the page rescans at once"
|
|
28
28
|
|
|
29
29
|
example [
|
|
@@ -33,78 +33,21 @@ module OpenLanes
|
|
|
33
33
|
"--plan 005 # only plan 005, until stopped"
|
|
34
34
|
]
|
|
35
35
|
|
|
36
|
-
# Exit status when Redis or the control room is not there to serve.
|
|
37
|
-
UNAVAILABLE = 69
|
|
38
|
-
|
|
39
|
-
# The progress log, echoed to the terminal: the terminal is where a
|
|
40
|
-
# person watches `start` from when the page is closed, and it takes
|
|
41
|
-
# no input.
|
|
42
|
-
#
|
|
43
36
|
# @param options [Hash]
|
|
44
37
|
# @return [void]
|
|
45
38
|
def call(**options)
|
|
46
|
-
@rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::
|
|
39
|
+
@rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN_SECONDS), exception: false)
|
|
47
40
|
refuse("--rescan takes a whole number of seconds, at least 1.", 64) unless @rescan&.positive?
|
|
48
41
|
|
|
49
|
-
tap = UI.tap_log(lambda { |message, fields|
|
|
50
|
-
next if UI.quiet
|
|
51
|
-
|
|
52
|
-
Kernel.warn(Engine::ProgressLog.render(message, **fields.slice(:plan, :status, :agent, :seconds, :round)))
|
|
53
|
-
})
|
|
54
42
|
super
|
|
55
|
-
ensure
|
|
56
|
-
UI.untap_log(tap) if tap
|
|
57
43
|
end
|
|
58
44
|
|
|
59
45
|
private
|
|
60
46
|
|
|
61
|
-
# @param
|
|
62
|
-
# @param
|
|
63
|
-
# @return [
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
# @return [void]
|
|
67
|
-
def without_live(message, _options)
|
|
68
|
-
refuse("#{message}\n\nopenlanes start needs Redis: its control room is the only way to answer the " \
|
|
69
|
-
"agents' questions. Start redis-server, or pass --redis-url. " \
|
|
70
|
-
"`openlanes run` goes on without it, headless.",
|
|
71
|
-
UNAVAILABLE)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [void]
|
|
75
|
-
def without_web(web, live, _options)
|
|
76
|
-
live.close
|
|
77
|
-
refuse("The control room did not answer on #{web.url}, and openlanes start is no use without it. " \
|
|
78
|
-
"Its log: #{web.log}",
|
|
79
|
-
UNAVAILABLE)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# A project with nothing left to do opens on the idea dialog (W19).
|
|
83
|
-
#
|
|
84
|
-
# @return [String]
|
|
85
|
-
def browser_url(web, tree)
|
|
86
|
-
open = tree.subjects.reject { Engine::Dispatcher::FINISHED.include?(it.status.key) }
|
|
87
|
-
open.empty? ? "#{web.url}#start" : web.url
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# What the service did, and exit 0: stopping it on purpose is not a
|
|
91
|
-
# failure, and the failed attempts it lived through are listed above
|
|
92
|
-
# the box.
|
|
93
|
-
#
|
|
94
|
-
# @return [void]
|
|
95
|
-
def finish(runner, attempts, options, seconds:)
|
|
96
|
-
puts ""
|
|
97
|
-
puts attempt_lines(attempts)
|
|
98
|
-
puts("", Engine.tally(attempts:, seconds:).render)
|
|
99
|
-
unless quiet?(options)
|
|
100
|
-
blocked = runner.blocked.map { it.feature.ordinal }
|
|
101
|
-
lines = ["Stopped. Unfinished plans keep their state; openlanes start picks them up again."]
|
|
102
|
-
lines << "Still waiting for answers: #{blocked.join(", ")}" unless blocked.empty?
|
|
103
|
-
success("#{attempts.size} invocation#{"s" unless attempts.size == 1}, " \
|
|
104
|
-
"#{attempts.count(&:advanced?)} advanced\n\n#{lines.join("\n")}")
|
|
105
|
-
end
|
|
106
|
-
exit(0)
|
|
107
|
-
end
|
|
47
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
48
|
+
# @param options [Hash]
|
|
49
|
+
# @return [OpenLanes::Engine::StartWorkflow]
|
|
50
|
+
def workflow(tree, options) = Engine.start(tree:, origin:, **options.except(:rescan), rescan: @rescan)
|
|
108
51
|
end
|
|
109
52
|
end
|
|
110
53
|
end
|
|
@@ -13,7 +13,7 @@ module OpenLanes
|
|
|
13
13
|
# @param options [Hash]
|
|
14
14
|
# @return [OpenLanes::Plans::PlanState]
|
|
15
15
|
def plan_state_for(options)
|
|
16
|
-
tree = tree_for(options)
|
|
16
|
+
tree = tree_for(options).everything
|
|
17
17
|
ordinal = Plans::Ordinal.parse(options[:plan].to_s)
|
|
18
18
|
subject = ordinal && tree.find(ordinal)
|
|
19
19
|
refuse("No plan #{options[:plan]} in #{tree.dir}.\n\nKnown: #{tree.ordinals.join(", ")}", 66) unless subject
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module CLI
|
|
5
|
+
module State
|
|
6
|
+
# `openlanes state resume` — what an interrupted agent leaves for
|
|
7
|
+
# whoever runs next, most likely itself.
|
|
8
|
+
#
|
|
9
|
+
# It used to be a `RESUME: ...` mail, which the next round had to read
|
|
10
|
+
# and believe. This is a record with fixed fields, kept in `state.json`
|
|
11
|
+
# under `last-known-state`, one per agent.
|
|
12
|
+
class Resume < Base
|
|
13
|
+
include Locating
|
|
14
|
+
|
|
15
|
+
desc "Record where an interrupted agent stood, for the next round to resume from"
|
|
16
|
+
|
|
17
|
+
option :plan, required: true, desc: "Which plan: NNN or NNN.MM"
|
|
18
|
+
option :agent, required: true, desc: "The agent recording"
|
|
19
|
+
option :round, required: true, desc: "Which round this is, as the prompt states it"
|
|
20
|
+
option :status, default: "Interrupted", values: Plans::Ledger::STATUSES, desc: "Where the agent stands"
|
|
21
|
+
option :done, desc: "What is finished, comma-separated"
|
|
22
|
+
option :remaining, desc: "What is not, comma-separated"
|
|
23
|
+
option :next_step, desc: "The exact first thing to do on resuming"
|
|
24
|
+
option :files, desc: "Files touched, comma-separated"
|
|
25
|
+
|
|
26
|
+
example [
|
|
27
|
+
%(--plan 003 --agent luke-backend --round 1 --done "B1,B2" --remaining "B3" --next-step "run rspec spec/openlanes/live" --files lib/openlanes/live/mail.rb)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# @param options [Hash]
|
|
31
|
+
# @return [void]
|
|
32
|
+
def call(**options)
|
|
33
|
+
state = plan_state_for(options)
|
|
34
|
+
entry = begin
|
|
35
|
+
state.record_last_known(options[:agent],
|
|
36
|
+
Plans::LastKnownState.new(source: "agent",
|
|
37
|
+
round: Integer(options[:round].to_s, exception: false) || refuse("--round must be a number", 64),
|
|
38
|
+
status: options[:status],
|
|
39
|
+
done: list(options[:done]),
|
|
40
|
+
remaining: list(options[:remaining]),
|
|
41
|
+
next_step: options[:next_step],
|
|
42
|
+
files_touched: list(options[:files])))
|
|
43
|
+
rescue OpenLanes::Error => e
|
|
44
|
+
refuse(e.message, 64)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return if quiet?(options)
|
|
48
|
+
|
|
49
|
+
UI.line("#{options[:agent]}: #{entry.status}, round #{entry.round}, resume record " \
|
|
50
|
+
"in #{File.basename(state.dir)}/#{Plans::PlanState::FILENAME}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @param text [String, nil]
|
|
56
|
+
# @return [Array<String>]
|
|
57
|
+
def list(text) = text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -20,7 +20,7 @@ module OpenLanes
|
|
|
20
20
|
example [
|
|
21
21
|
"--plan 003 --agent luke-backend --round 1 --status Started",
|
|
22
22
|
"--plan 003 --agent hansolo-reviewer --round 1 --status Completed --note approved",
|
|
23
|
-
"--plan 003 --agent yoda-writer --round 1 --status Completed --next
|
|
23
|
+
"--plan 003 --agent yoda-writer --round 1 --status Completed --next luthen-rael-planner"
|
|
24
24
|
]
|
|
25
25
|
|
|
26
26
|
# @param options [Hash]
|
|
@@ -3,24 +3,9 @@
|
|
|
3
3
|
module OpenLanes
|
|
4
4
|
module CLI
|
|
5
5
|
# `openlanes unblock NNN…` — hand a stopped plan to the agent that
|
|
6
|
-
# drains its `blocked.md`.
|
|
7
|
-
#
|
|
8
|
-
# Deliberately not part of `run`. ⭕️ and 🅱️ are {StateMachine::SETTLED}, so
|
|
9
|
-
# the loop never offers a blocked plan to anybody, and that is the property
|
|
10
|
-
# that makes the state mean anything: the plan waits for a human, and no
|
|
11
|
-
# agent quietly decides otherwise. Answers arriving is not a fact the tool
|
|
12
|
-
# can observe, so a human typing this command *is* the signal, and there is
|
|
13
|
-
# nothing else that could produce it.
|
|
6
|
+
# drains its `blocked.md`. Why this is a command of its own rather than
|
|
7
|
+
# part of `run` is told in {Lifecycle::UnblockWorkflow}.
|
|
14
8
|
class Unblock < Base
|
|
15
|
-
# The agent that knows the shape of `blocked.md`.
|
|
16
|
-
DEFAULT_AGENT = "lando-broker"
|
|
17
|
-
|
|
18
|
-
# What each token turned out to be, for whoever has to fix it.
|
|
19
|
-
PROBLEMS = {
|
|
20
|
-
missing: "no plan of that number",
|
|
21
|
-
not_blocked: "no blocked.md, so there is nothing to drain"
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
9
|
desc "Fold answered blocks into a plan's documents and retire blocked.md"
|
|
25
10
|
|
|
26
11
|
argument :plans,
|
|
@@ -29,7 +14,9 @@ module OpenLanes
|
|
|
29
14
|
desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
|
|
30
15
|
|
|
31
16
|
dry_run_option "Invoke no agent; print the questions still open"
|
|
32
|
-
option :agent,
|
|
17
|
+
option :agent,
|
|
18
|
+
default: OpenLanes::Lifecycle::UnblockWorkflow::DEFAULT_AGENT,
|
|
19
|
+
desc: "Hand the folder to a different agent"
|
|
33
20
|
option :root, desc: "Repository root the agent works in (default: the .plans parent)"
|
|
34
21
|
|
|
35
22
|
example [
|
|
@@ -42,193 +29,12 @@ module OpenLanes
|
|
|
42
29
|
# @param options [Hash]
|
|
43
30
|
# @return [void]
|
|
44
31
|
def call(plans:, **options)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
executor: Execution.executor(root: options[:root] || File.dirname(tree.dir),
|
|
52
|
-
dry_run: dry_run?(options)))
|
|
53
|
-
|
|
54
|
-
targets = unblocker.resolve(plans)
|
|
55
|
-
refused(targets, tree, options)
|
|
56
|
-
subjects = targets.select(&:drainable?).map(&:subject)
|
|
57
|
-
exit(worst(targets, [])) if subjects.empty?
|
|
58
|
-
|
|
59
|
-
credentials_warning if !dry_run?(options) && !quiet?(options)
|
|
60
|
-
preflight(subjects, unblocker, options)
|
|
61
|
-
|
|
62
|
-
outcomes = Execution.on_interrupt { unblocker.call(subjects) }
|
|
63
|
-
outcomes.each { |outcome| report(outcome, options) }
|
|
64
|
-
footer(outcomes, options) unless quiet?(options)
|
|
65
|
-
exit(worst(targets, outcomes))
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
|
|
70
|
-
# @param options [Hash]
|
|
71
|
-
# @return [OpenLanes::Agents::Agent]
|
|
72
|
-
def agent_for(options)
|
|
73
|
-
name = options.fetch(:agent, DEFAULT_AGENT)
|
|
74
|
-
agents = OpenLanes::Agents.registry
|
|
75
|
-
agents.find(name) or
|
|
76
|
-
refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# A number that names no folder, or a folder that was never stopped, is
|
|
80
|
-
# refused rather than skipped. Whoever typed this believes an answer has
|
|
81
|
-
# arrived; running against a folder nobody blocked and saying nothing is
|
|
82
|
-
# how you come back later to a plan nobody touched and no record of why.
|
|
83
|
-
#
|
|
84
|
-
# Every bad token is reported, not just the first, because stopping at
|
|
85
|
-
# the first one hides the rest of the answer.
|
|
86
|
-
#
|
|
87
|
-
# @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
|
|
88
|
-
# @param tree [OpenLanes::Plans::Tree]
|
|
89
|
-
# @param options [Hash]
|
|
90
|
-
# @return [void]
|
|
91
|
-
def refused(targets, tree, options)
|
|
92
|
-
problems = targets.reject(&:drainable?)
|
|
93
|
-
return if problems.empty?
|
|
94
|
-
|
|
95
|
-
problems.each do |target|
|
|
96
|
-
state = target.subject ? target.subject.status.key : "unknown"
|
|
97
|
-
puts "#{target.token}\t#{state}\t0 open\t#{PROBLEMS.fetch(target.problem)}"
|
|
98
|
-
end
|
|
99
|
-
return if quiet?(options)
|
|
100
|
-
|
|
101
|
-
error("#{problems.size} of the plans named cannot be drained:\n\n" +
|
|
102
|
-
problems.map { |t| " #{t.token} — #{PROBLEMS.fetch(t.problem)}" }.join("\n") +
|
|
103
|
-
"\n\nThe tree holds: #{tree.ordinals.join(", ")}")
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# What each folder is waiting on, said BEFORE the agent is invoked.
|
|
107
|
-
#
|
|
108
|
-
# This is the half that was missing. A real run hands the folder to an
|
|
109
|
-
# agent that can take a quarter of an hour, and until it came back the
|
|
110
|
-
# terminal showed nothing at all — a run that had found nothing to do and
|
|
111
|
-
# a run still working looked exactly alike.
|
|
112
|
-
#
|
|
113
|
-
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
114
|
-
# @param unblocker [OpenLanes::Lifecycle::Unblocker]
|
|
115
|
-
# @param options [Hash]
|
|
116
|
-
# @return [void]
|
|
117
|
-
def preflight(subjects, _unblocker, options)
|
|
118
|
-
return if quiet?(options)
|
|
119
|
-
|
|
120
|
-
subjects.each do |subject|
|
|
121
|
-
questions = Lifecycle::Unblocker.questions(subject)
|
|
122
|
-
answered = questions.count(&:answered)
|
|
123
|
-
say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
|
|
124
|
-
"#{subject.feature.title} — #{summary(questions.size, answered)}")
|
|
125
|
-
detail(subject, questions)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# @param open [Integer]
|
|
130
|
-
# @param answered [Integer]
|
|
131
|
-
# @return [String]
|
|
132
|
-
def summary(open, answered)
|
|
133
|
-
return "nothing open" if open.zero?
|
|
134
|
-
|
|
135
|
-
"#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# @param subject [OpenLanes::Plans::Subject]
|
|
139
|
-
# @param questions [Array<OpenLanes::Lifecycle::Unblocker::Question>]
|
|
140
|
-
# @return [void]
|
|
141
|
-
def detail(subject, questions)
|
|
142
|
-
if subject.unreadable_block?
|
|
143
|
-
say(" #{paint("blocked.md names no `## B<n>` question, so nothing here can be drained", :red)}",
|
|
144
|
-
bullet: " ")
|
|
145
|
-
say(" #{paint("Number each open question `## B1`, `## B2`, and each answer `## A1`, `## A2`.", :yellow)}",
|
|
146
|
-
bullet: " ")
|
|
147
|
-
elsif questions.empty?
|
|
148
|
-
say(" #{paint("nothing left open", :green)}", bullet: " ")
|
|
149
|
-
else
|
|
150
|
-
questions.each { |question| say(" #{paint(question.to_s, :yellow)}", bullet: " ") }
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# One plan, after the fact: the deliverable line on STDOUT, and what
|
|
155
|
-
# changed on STDERR.
|
|
156
|
-
#
|
|
157
|
-
# @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
|
|
158
|
-
# @param options [Hash]
|
|
159
|
-
# @return [void]
|
|
160
|
-
def report(outcome, options)
|
|
161
|
-
moved = movement(outcome, !dry_run?(options))
|
|
162
|
-
puts "#{outcome.ordinal}\t#{outcome.ok ? outcome.subject.status.key : "failed"}\t" \
|
|
163
|
-
"#{outcome.after.size} open\t#{moved}\t#{outcome.note}"
|
|
164
|
-
# A dry run changed nothing, so the preflight above it is still the
|
|
165
|
-
# whole truth. Printing the same three lines again reads as a second
|
|
166
|
-
# pass that found the same thing, which is not what happened.
|
|
167
|
-
return if quiet?(options) || dry_run?(options)
|
|
168
|
-
|
|
169
|
-
say("#{paint(outcome.ordinal.to_s, :bright_black)} #{outcome.subject.status.emoji} " \
|
|
170
|
-
"#{outcome.subject.feature.title} — #{paint(moved, outcome.ok ? :green : :red)}")
|
|
171
|
-
detail(outcome.subject, outcome.after)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# What the file says happened, rather than what the agent claims. The
|
|
175
|
-
# questions are counted off disk on both sides of the run.
|
|
176
|
-
#
|
|
177
|
-
# @param outcome [OpenLanes::Lifecycle::Unblocker::Outcome]
|
|
178
|
-
# @param commit [Boolean]
|
|
179
|
-
# @return [String]
|
|
180
|
-
def movement(outcome, commit)
|
|
181
|
-
return "failed" unless outcome.ok
|
|
182
|
-
return "not attempted" unless commit
|
|
183
|
-
return "blocked.md retired" if outcome.cleared?
|
|
184
|
-
return "nothing folded" if outcome.folded.empty?
|
|
185
|
-
|
|
186
|
-
"folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
# @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
|
|
190
|
-
# @param options [Hash]
|
|
191
|
-
# @return [void]
|
|
192
|
-
def footer(outcomes, options)
|
|
193
|
-
failed = outcomes.reject(&:ok)
|
|
194
|
-
|
|
195
|
-
if dry_run?(options)
|
|
196
|
-
warn("Dry run: no agent was invoked, and #{outcomes.size} " \
|
|
197
|
-
"plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
|
|
198
|
-
"Re-run without --dry-run to fold in whatever has been answered.")
|
|
199
|
-
return
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
return error(failed.map { |o| "#{o.ordinal}: #{o.note}" }.join("\n")) unless failed.empty?
|
|
203
|
-
|
|
204
|
-
cleared = outcomes.count(&:cleared?)
|
|
205
|
-
folded = outcomes.sum { |o| o.folded.size }
|
|
206
|
-
waiting = outcomes.sum { |o| o.after.size }
|
|
207
|
-
success("#{folded} question#{"s" unless folded == 1} folded in · " \
|
|
208
|
-
"#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
|
|
209
|
-
"#{waiting} still waiting on a human." +
|
|
210
|
-
(if folded.zero?
|
|
211
|
-
"\n\nNothing moved. An answer has to be written into blocked.md as its own " \
|
|
212
|
-
"`## A<n>` section, answering the `## B<n>` of the same number, before " \
|
|
213
|
-
"there is anything to fold."
|
|
214
|
-
else
|
|
215
|
-
""
|
|
216
|
-
end))
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# The most serious thing that happened, as an exit status. A tree that
|
|
220
|
-
# was partly drained still exits non-zero when part of it could not be.
|
|
221
|
-
#
|
|
222
|
-
# @param targets [Array<OpenLanes::Lifecycle::Unblocker::Target>]
|
|
223
|
-
# @param outcomes [Array<OpenLanes::Lifecycle::Unblocker::Outcome>]
|
|
224
|
-
# @return [Integer]
|
|
225
|
-
def worst(targets, outcomes)
|
|
226
|
-
problems = targets.map(&:problem)
|
|
227
|
-
return 66 if problems.include?(:missing)
|
|
228
|
-
return 65 if problems.include?(:not_blocked)
|
|
229
|
-
return 1 unless outcomes.all?(&:ok)
|
|
230
|
-
|
|
231
|
-
0
|
|
32
|
+
workflow = OpenLanes::Lifecycle.unblock(tree: tree_for(options),
|
|
33
|
+
agent: options.fetch(:agent, OpenLanes::Lifecycle::UnblockWorkflow::DEFAULT_AGENT),
|
|
34
|
+
root: options[:root],
|
|
35
|
+
dry_run: dry_run?(options),
|
|
36
|
+
quiet: quiet?(options))
|
|
37
|
+
exit(unwrap(workflow.call(plans)))
|
|
232
38
|
end
|
|
233
39
|
end
|
|
234
40
|
end
|
|
@@ -6,11 +6,13 @@ module OpenLanes
|
|
|
6
6
|
# starts one beside the engine; this is that server, and with `--demo`
|
|
7
7
|
# the same server over a pretend engine, for Cypress and front-end work.
|
|
8
8
|
class Web < Base
|
|
9
|
-
desc "Serve the control room page and its API on 127.0.0.1"
|
|
9
|
+
desc "Serve the control room page and its API, on 127.0.0.1 unless told otherwise"
|
|
10
10
|
|
|
11
11
|
option :port,
|
|
12
|
+
aliases: ["-p"],
|
|
12
13
|
default: OpenLanes::Web::DEFAULT_PORT.to_s,
|
|
13
14
|
desc: "Port to listen on"
|
|
15
|
+
listen_options
|
|
14
16
|
option :redis_url,
|
|
15
17
|
desc: "Redis the engine publishes to (default: $OPENLANES_REDIS_URL, else .openlanes.yml redis_url, else $REDIS_URL, " \
|
|
16
18
|
"else #{Live::DEFAULT_URL})"
|
|
@@ -23,11 +25,19 @@ module OpenLanes
|
|
|
23
25
|
default: false,
|
|
24
26
|
desc: "Serve a sample run that moves and answers commands, with no agents. " \
|
|
25
27
|
"Uses Redis when it answers, an in-memory store when it does not"
|
|
28
|
+
option :identity,
|
|
29
|
+
desc: "With --demo: register as USER@HOST instead of this login and host, so a second demo on " \
|
|
30
|
+
"another port is another machine on the same Redis"
|
|
31
|
+
log_options
|
|
26
32
|
|
|
27
33
|
example [
|
|
28
34
|
" # serve the live run of this checkout",
|
|
29
35
|
"--demo --no-open # the sample run, for Cypress and npm run dev",
|
|
30
|
-
"--port 4848 # somewhere other than 11000"
|
|
36
|
+
"--port 4848 # somewhere other than 11000",
|
|
37
|
+
"-I --admin-credentials admin:s3cret # reachable from other machines, behind a login",
|
|
38
|
+
"-i 127.0.0.1,192.168.1.20 -p 4848 # loopback and one LAN address",
|
|
39
|
+
"--bind 0.0.0.0 --admin-credentials admin:s3cret # every interface, behind a login",
|
|
40
|
+
"--demo --port 11101 --identity demo@second-machine # a second machine for the chat"
|
|
31
41
|
]
|
|
32
42
|
|
|
33
43
|
# @param options [Hash]
|
|
@@ -35,21 +45,47 @@ module OpenLanes
|
|
|
35
45
|
def call(**options)
|
|
36
46
|
tree = tree_for(options)
|
|
37
47
|
root = File.dirname(tree.dir)
|
|
48
|
+
logging(options, root:)
|
|
49
|
+
listen = listen_for(options)
|
|
50
|
+
credentials = credentials_for(options, listen)
|
|
38
51
|
url = Live.redis_url(option: redis_url_option(options), config: Config.current)
|
|
52
|
+
node = node_for(root, options)
|
|
39
53
|
stores = stores_for(url, options)
|
|
40
|
-
node = Live::Node.current(root:)
|
|
41
54
|
keys = Live.keys(root:, node:)
|
|
42
55
|
keys = keys.with(repo: "demo/#{keys.repo}") if options[:demo]
|
|
43
|
-
demo = OpenLanes::Web.demo(store: stores.current, keys:).start if options[:demo]
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
demo = OpenLanes::Web.demo(store: stores.current, keys:, node: declared(node, options)).start if options[:demo]
|
|
57
|
+
copilot = demo ? OpenLanes::Web::Demo::COPILOT : copilot_for(root:, tree:, stores:, keys:)
|
|
58
|
+
context = OpenLanes::Web.context(stores:,
|
|
59
|
+
keys:,
|
|
60
|
+
plans_dir: tree.dir,
|
|
61
|
+
url: stores.current.url,
|
|
62
|
+
node:,
|
|
63
|
+
demo:,
|
|
64
|
+
copilot:,
|
|
65
|
+
listen:,
|
|
66
|
+
credentials:)
|
|
67
|
+
server = OpenLanes::Web.server(app: OpenLanes::Web.app(context), context:, port: options[:port], listen:)
|
|
68
|
+
serve(server, options, keys:, node:, url: stores.current.url, credentials:)
|
|
47
69
|
ensure
|
|
48
70
|
demo&.stop
|
|
71
|
+
copilot.stop if copilot.respond_to?(:stop)
|
|
49
72
|
end
|
|
50
73
|
|
|
51
74
|
private
|
|
52
75
|
|
|
76
|
+
# Logs as `web`, to the file the engine named in this process's
|
|
77
|
+
# environment when the engine spawned it, else to the same default the
|
|
78
|
+
# engine would pick, so a server started by hand still joins the run's log.
|
|
79
|
+
#
|
|
80
|
+
# @param options [Hash]
|
|
81
|
+
# @param root [String]
|
|
82
|
+
# @return [OpenLanes::Log::Settings]
|
|
83
|
+
def logging(options, root:)
|
|
84
|
+
Log.setup(Log::Settings.from(options, root:), name: "web")
|
|
85
|
+
rescue Log::Invalid => e
|
|
86
|
+
refuse(e.message, 64)
|
|
87
|
+
end
|
|
88
|
+
|
|
53
89
|
# `--redis-url`, else the URL `openlanes run` hands its server through the
|
|
54
90
|
# environment, so a password never shows in `ps`. Both are passed to
|
|
55
91
|
# {Live.redis_url} as the option, which keeps the rest of its order.
|
|
@@ -61,6 +97,27 @@ module OpenLanes
|
|
|
61
97
|
[options[:redis_url], env[OpenLanes::Web::Launcher::REDIS_URL_ENV]].find { !it.to_s.strip.empty? }
|
|
62
98
|
end
|
|
63
99
|
|
|
100
|
+
# This machine, or under `--demo` the one `--identity` declares. A live
|
|
101
|
+
# server is refused one: its node id is what claims and commands are
|
|
102
|
+
# keyed by, and a run under a borrowed name would answer for a machine
|
|
103
|
+
# it is not.
|
|
104
|
+
#
|
|
105
|
+
# @param root [String]
|
|
106
|
+
# @param options [Hash]
|
|
107
|
+
# @return [OpenLanes::Live::Node]
|
|
108
|
+
def node_for(root, options)
|
|
109
|
+
identity = options[:identity].to_s.strip
|
|
110
|
+
return Live::Node.current(root:) if identity.empty?
|
|
111
|
+
|
|
112
|
+
refuse("--identity is for the demo only; pass --demo with it.", 64) unless options[:demo]
|
|
113
|
+
Live::Node.declared(identity, root:)
|
|
114
|
+
rescue Live::Node::Invalid => e
|
|
115
|
+
refuse("--identity: #{e.message}", 64)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [OpenLanes::Live::Node, nil] the node the demo publishes as, when declared
|
|
119
|
+
def declared(node, options) = options[:identity].to_s.strip.empty? ? nil : node
|
|
120
|
+
|
|
64
121
|
# @return [OpenLanes::Web::Stores]
|
|
65
122
|
def stores_for(url, options)
|
|
66
123
|
store = Live.connect(url)
|
|
@@ -76,25 +133,54 @@ module OpenLanes
|
|
|
76
133
|
OpenLanes::Web::Stores.shared(Live.memory_store)
|
|
77
134
|
end
|
|
78
135
|
|
|
136
|
+
# c3po-operator, answering in this server (W17). It runs beside the
|
|
137
|
+
# page rather than in the engine so it answers with no run going too.
|
|
138
|
+
#
|
|
139
|
+
# @return [OpenLanes::Live::Copilot::Operator]
|
|
140
|
+
def copilot_for(root:, tree:, stores:, keys:)
|
|
141
|
+
Live::Copilot::Operator.new(root:, plans_dir: tree.dir, stores:, keys:)
|
|
142
|
+
end
|
|
143
|
+
|
|
79
144
|
# Listen until INT or TERM.
|
|
80
145
|
#
|
|
81
146
|
# @return [void]
|
|
82
|
-
def serve(server, options, keys:, node:, url:)
|
|
147
|
+
def serve(server, options, keys:, node:, url:, credentials:)
|
|
83
148
|
begin
|
|
84
149
|
server.start
|
|
85
150
|
rescue Errno::EADDRINUSE
|
|
86
151
|
refuse("Port #{options[:port]} is taken: another `openlanes web` may already be serving. " \
|
|
87
152
|
"Pass --port to use another.",
|
|
88
153
|
69)
|
|
154
|
+
rescue Errno::EADDRNOTAVAIL
|
|
155
|
+
refuse("--ips #{server.listen.ips.join(",")}: an address is not one of this machine's.", 69)
|
|
89
156
|
end
|
|
90
157
|
%w[INT TERM].each { |name| Signal.trap(name) { Thread.new { server.stop } } }
|
|
91
158
|
unless quiet?(options)
|
|
92
159
|
info("Control room: #{server.url}#{" (demo)" if options[:demo]}\n" \
|
|
160
|
+
"#{"Also on: #{(server.urls - [server.url]).join(", ")}\n" if server.listen.public?}" \
|
|
93
161
|
"Redis: #{Live.mask(url)} as #{node.label} (#{keys.node}), repository #{keys.repo}")
|
|
94
162
|
end
|
|
163
|
+
exposed(server, credentials)
|
|
164
|
+
Log.logger.info("listening", url: server.url, urls: server.urls, demo: options[:demo], repo: keys.repo)
|
|
95
165
|
OpenLanes::Web.open(server.url) if options[:open]
|
|
96
166
|
server.wait
|
|
97
167
|
end
|
|
168
|
+
|
|
169
|
+
# Said even under --quiet, and logged: listening beyond this machine
|
|
170
|
+
# hands anyone with the login the controls, and that is never silent.
|
|
171
|
+
# {#credentials_for} has already refused a public server without one.
|
|
172
|
+
#
|
|
173
|
+
# @param server [OpenLanes::Web::Server]
|
|
174
|
+
# @param credentials [OpenLanes::Web::AdminAuth::Credentials]
|
|
175
|
+
# @return [void]
|
|
176
|
+
def exposed(server, credentials)
|
|
177
|
+
return unless server.listen.public?
|
|
178
|
+
|
|
179
|
+
ips = server.listen.public_ips.join(", ")
|
|
180
|
+
warn("Listening on #{ips}, behind a login as #{credentials.user}: anyone with it who can reach this " \
|
|
181
|
+
"machine on port #{server.port} can stop agents, answer their questions and message them.")
|
|
182
|
+
Log.logger.warn("listening beyond loopback", ips: server.listen.public_ips, port: server.port, user: credentials.user)
|
|
183
|
+
end
|
|
98
184
|
end
|
|
99
185
|
end
|
|
100
186
|
end
|
|
@@ -18,7 +18,7 @@ module OpenLanes
|
|
|
18
18
|
example [
|
|
19
19
|
"002 # create/seed the worktree for plan 002.00",
|
|
20
20
|
"002.01 # create/seed the worktree for plan 002.01",
|
|
21
|
-
"002.00
|
|
21
|
+
"002.00━━⚪━━❯update-dsl # find the plan by its folder name",
|
|
22
22
|
"--skip-seed 003 # create the worktree but don't seed it"
|
|
23
23
|
]
|
|
24
24
|
|