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
data/lib/openlanes/adapters.rb
CHANGED
|
@@ -44,6 +44,18 @@ module OpenLanes
|
|
|
44
44
|
# @!attribute [r] slash_commands
|
|
45
45
|
# @return [Boolean] keep the CLI's slash commands even when lean, which
|
|
46
46
|
# only the `/compact` sent into a resumed session needs
|
|
47
|
+
# @!attribute [r] dirs
|
|
48
|
+
# @return [Array<String>] directories beyond {#root} the agent may read,
|
|
49
|
+
# such as the trace directory the copilot reads transcripts from
|
|
50
|
+
# @!attribute [r] brief
|
|
51
|
+
# @return [Boolean] whether the agent reports through the CLI's
|
|
52
|
+
# one-line status tool, which only a car on the board has a use for
|
|
53
|
+
# @!attribute [r] tools
|
|
54
|
+
# @return [Array<String>, nil] the only built-in tools the agent has, with
|
|
55
|
+
# no settings file able to add one; nil leaves the CLI's own set
|
|
56
|
+
# @!attribute [r] system
|
|
57
|
+
# @return [String, nil] standing instructions, sent as system prompt on
|
|
58
|
+
# every invocation so a compacted conversation does not lose them
|
|
47
59
|
Invocation = Data.define(:prompt,
|
|
48
60
|
:root,
|
|
49
61
|
:model,
|
|
@@ -54,9 +66,14 @@ module OpenLanes
|
|
|
54
66
|
:network,
|
|
55
67
|
:lean,
|
|
56
68
|
:resume,
|
|
57
|
-
:slash_commands
|
|
69
|
+
:slash_commands,
|
|
70
|
+
:dirs,
|
|
71
|
+
:brief,
|
|
72
|
+
:tools,
|
|
73
|
+
:system) do
|
|
58
74
|
def initialize(allowed_tools: [], denied_tools: [], denied_commands: [], network: false, lean: true,
|
|
59
|
-
model: nil, effort: nil, resume: nil, slash_commands: false,
|
|
75
|
+
model: nil, effort: nil, resume: nil, slash_commands: false, dirs: [], brief: true, tools: nil, system: nil,
|
|
76
|
+
**rest)
|
|
60
77
|
super
|
|
61
78
|
end
|
|
62
79
|
end
|
|
@@ -81,6 +98,13 @@ module OpenLanes
|
|
|
81
98
|
klass.new
|
|
82
99
|
end
|
|
83
100
|
|
|
101
|
+
# The lean-start flags of one CLI, so a caller building its own argv
|
|
102
|
+
# never names an adapter class.
|
|
103
|
+
#
|
|
104
|
+
# @param name [String, Symbol, nil]
|
|
105
|
+
# @return [Array<String>]
|
|
106
|
+
def lean_flags(name = DEFAULT) = self.for(name).lean_flags
|
|
107
|
+
|
|
84
108
|
# @param name [String, Symbol, nil]
|
|
85
109
|
# @return [Boolean]
|
|
86
110
|
def known?(name) = registry.key?((name || DEFAULT).to_s)
|
|
@@ -89,11 +89,12 @@ module OpenLanes
|
|
|
89
89
|
# @return [Boolean]
|
|
90
90
|
def serves?(lane) = lanes.empty? || lanes.include?(lane.to_sym)
|
|
91
91
|
|
|
92
|
-
# The word after the hyphen: `researcher`, `backend
|
|
93
|
-
#
|
|
92
|
+
# The word after the last hyphen: `researcher`, `backend`, and `planner`
|
|
93
|
+
# for `luthen-rael-planner`, whose name has two words before its role.
|
|
94
|
+
# What the screen's agent column shows, the name being too long for it.
|
|
94
95
|
#
|
|
95
96
|
# @return [String]
|
|
96
|
-
def role = name.split("-"
|
|
97
|
+
def role = name.split("-").last.to_s
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
# More rounds than this costs tokens and buys nothing: an agent that has
|
|
@@ -27,6 +27,13 @@ module OpenLanes
|
|
|
27
27
|
# folder by what its files justify.
|
|
28
28
|
BY_CONTENTS = "by contents"
|
|
29
29
|
|
|
30
|
+
# What bb8-resolver is offered: no plan state, but the stopped rebases
|
|
31
|
+
# the harness hands it (D10). An empty cell read as "does nothing".
|
|
32
|
+
REBASES = "stopped rebases, from the harness"
|
|
33
|
+
|
|
34
|
+
# It writes, so "read-only" would be false; it moves no plan either.
|
|
35
|
+
STAGES_ONLY = "stages, never moves a plan"
|
|
36
|
+
|
|
30
37
|
# @param agents [OpenLanes::Agents::Registry]
|
|
31
38
|
def initialize(agents: Registry.new)
|
|
32
39
|
@agents = agents
|
|
@@ -50,7 +57,7 @@ module OpenLanes
|
|
|
50
57
|
# A state as its words alone, deliberately without the emoji that every
|
|
51
58
|
# other rendering of a state carries. The table widget pads a cell to a
|
|
52
59
|
# width it measures in characters, while the terminal draws a status emoji
|
|
53
|
-
# in two cells; the variation-selector ones (
|
|
60
|
+
# in two cells; the variation-selector ones (⭕ 🛑 ⚪ ⭐ ⏪) are two
|
|
54
61
|
# characters drawn in two cells, so no single count is right for all of
|
|
55
62
|
# them. A row carrying one lands short or long and the frame stops lining
|
|
56
63
|
# up. The label says the same thing unambiguously, and is worth more than
|
|
@@ -66,12 +73,17 @@ module OpenLanes
|
|
|
66
73
|
|
|
67
74
|
# @param agent [OpenLanes::Agents::Agent]
|
|
68
75
|
# @return [String]
|
|
69
|
-
def handles(agent)
|
|
76
|
+
def handles(agent)
|
|
77
|
+
return REBASES if agent.name == Engine::Rebase::RESOLVER
|
|
78
|
+
|
|
79
|
+
agent.handles.map { |key| state(key) }.join(", ")
|
|
80
|
+
end
|
|
70
81
|
|
|
71
82
|
# @param agent [OpenLanes::Agents::Agent]
|
|
72
83
|
# @return [String]
|
|
73
84
|
def advances(agent)
|
|
74
85
|
return state(agent.advances_to) if agent.advances_to
|
|
86
|
+
return STAGES_ONLY if agent.name == Engine::Rebase::RESOLVER
|
|
75
87
|
return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| Plans::StateMachine::SETTLED.include?(key) }
|
|
76
88
|
|
|
77
89
|
READ_ONLY
|
|
@@ -16,6 +16,13 @@ module OpenLanes
|
|
|
16
16
|
# and "no front-end units" starts nobody.
|
|
17
17
|
UNIT_HEADING = /^[ \t]{0,3}\#{2,3}[ \t]+\S/
|
|
18
18
|
|
|
19
|
+
# A work unit inside a section of `plan.md`: the section is the `##`,
|
|
20
|
+
# so only a deeper heading is a unit.
|
|
21
|
+
SECTION_UNIT_HEADING = /^[ \t]{0,3}\#{3,4}[ \t]+\S/
|
|
22
|
+
|
|
23
|
+
# Any second-level heading, which ends the section before it.
|
|
24
|
+
SECTION_HEADING = /^[ \t]{0,3}\#\#[ \t]+(.+?)[ \t#]*$/
|
|
25
|
+
|
|
19
26
|
module_function
|
|
20
27
|
|
|
21
28
|
# @param agent [OpenLanes::Agents::Agent]
|
|
@@ -36,7 +43,7 @@ module OpenLanes
|
|
|
36
43
|
#
|
|
37
44
|
# {Agent#needs} only ever chooses between agents. When lanes and
|
|
38
45
|
# switches leave one candidate, that one runs: a plan parked at 🎨 is
|
|
39
|
-
# rey-frontend's to finish whatever `plan
|
|
46
|
+
# rey-frontend's to finish whatever `plan.md`'s `## Frontend` says, and an
|
|
40
47
|
# operator who typed `--agent rey-frontend` meant it.
|
|
41
48
|
#
|
|
42
49
|
# @param agents [Array<OpenLanes::Agents::Agent>]
|
|
@@ -79,10 +86,43 @@ module OpenLanes
|
|
|
79
86
|
# @return [Boolean]
|
|
80
87
|
def reviewable?(agent, subject) = agent.phase.to_s != "review" || subject.pull_requests.any?(&:open?)
|
|
81
88
|
|
|
89
|
+
# Whether +need+ holds a work unit. A need is a file (`plan-frontend.md`)
|
|
90
|
+
# or a section of one (`plan.md#frontend`: the `## Frontend` section,
|
|
91
|
+
# matched without regard to case). Units moved into `plan.md` in plan
|
|
92
|
+
# 003; a folder planned before that still holds `plan-<section>.md`, and
|
|
93
|
+
# is read from there when `plan.md` has no such section, so no folder
|
|
94
|
+
# needs migrating.
|
|
95
|
+
#
|
|
82
96
|
# @param subject [OpenLanes::Plans::Subject]
|
|
83
|
-
# @param
|
|
97
|
+
# @param need [String]
|
|
84
98
|
# @return [Boolean]
|
|
85
|
-
def units?(subject,
|
|
99
|
+
def units?(subject, need)
|
|
100
|
+
file, section = need.split("#", 2)
|
|
101
|
+
return Plans::Ledger.stripped(subject.read(file)).match?(UNIT_HEADING) unless section
|
|
102
|
+
|
|
103
|
+
body = section_of(Plans::Ledger.stripped(subject.read(file)), section)
|
|
104
|
+
return body.match?(SECTION_UNIT_HEADING) if body
|
|
105
|
+
|
|
106
|
+
units?(subject, "plan-#{section.downcase}.md")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param text [String]
|
|
110
|
+
# @param name [String]
|
|
111
|
+
# @return [String, nil] the lines under `## name`, up to the next `##`;
|
|
112
|
+
# nil when there is no such heading
|
|
113
|
+
def section_of(text, name)
|
|
114
|
+
body = nil
|
|
115
|
+
text.each_line do |line|
|
|
116
|
+
if (heading = SECTION_HEADING.match(line))
|
|
117
|
+
break if body
|
|
118
|
+
|
|
119
|
+
body = +"" if heading[1].strip.casecmp?(name)
|
|
120
|
+
elsif body
|
|
121
|
+
body << line
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
body
|
|
125
|
+
end
|
|
86
126
|
end
|
|
87
127
|
end
|
|
88
128
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# The `app:` block of `.openlanes.yml`: what kind of app a project builds
|
|
6
|
+
# and the processes that run it, in the order they start.
|
|
7
|
+
#
|
|
8
|
+
# Written by luke on a plan's first build (M12) and only ever read here.
|
|
9
|
+
# It is new surface that every project will copy, so anything it does not
|
|
10
|
+
# know is refused with the file named, exactly as {Config} refuses an
|
|
11
|
+
# unknown top-level key: a `restart-on:` typo silently ignored is a
|
|
12
|
+
# watcher that never fires and nobody can say why.
|
|
13
|
+
#
|
|
14
|
+
# @!attribute [r] kind
|
|
15
|
+
# @return [String, nil] free text, `web` or `desktop` (M11); recorded,
|
|
16
|
+
# not interpreted here
|
|
17
|
+
# @!attribute [r] processes
|
|
18
|
+
# @return [Array<OpenLanes::Apps::ProcessSpec>] in start order
|
|
19
|
+
Definition = Data.define(:kind, :processes) do
|
|
20
|
+
# @return [OpenLanes::Apps::ProcessSpec, nil] the process marked
|
|
21
|
+
# `preview: true`; nil when none is, which means no Preview tab
|
|
22
|
+
def preview = processes.find(&:preview?)
|
|
23
|
+
|
|
24
|
+
# @param name [String]
|
|
25
|
+
# @return [OpenLanes::Apps::ProcessSpec, nil]
|
|
26
|
+
def process(name) = processes.find { it.name == name.to_s }
|
|
27
|
+
|
|
28
|
+
# Every process the Preview tab's Restart backend button restarts:
|
|
29
|
+
# all but the one it shows, in declared order.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<OpenLanes::Apps::ProcessSpec>]
|
|
32
|
+
def backend = processes.reject(&:preview?)
|
|
33
|
+
|
|
34
|
+
# @return [Hash] as `.openlanes.yml` spells it
|
|
35
|
+
def to_h = { "kind" => kind, "processes" => processes.map(&:to_h) }.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Definition
|
|
39
|
+
# Keys the `app:` mapping may hold.
|
|
40
|
+
KEYS = %w[kind processes].freeze
|
|
41
|
+
|
|
42
|
+
# Keys each process may hold.
|
|
43
|
+
PROCESS_KEYS = %w[name start ready restart_on preview].freeze
|
|
44
|
+
|
|
45
|
+
# A process name: what `PORT_<NAME>`, the Preview tab and `target:`
|
|
46
|
+
# all carry, so nothing a shell or a URL would trip over.
|
|
47
|
+
NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# @param value [Object] the parsed `app:` value
|
|
51
|
+
# @param path [String] the file, for every error
|
|
52
|
+
# @return [OpenLanes::Apps::Definition]
|
|
53
|
+
# @raise [OpenLanes::Error] naming the file and what is wrong
|
|
54
|
+
def parse(value, path:)
|
|
55
|
+
fail_with(path, "app: must be a mapping with processes:") unless value.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
data = value.transform_keys(&:to_s)
|
|
58
|
+
unknown!(path, "app", data.keys - KEYS, KEYS)
|
|
59
|
+
kind = data["kind"]
|
|
60
|
+
fail_with(path, "app.kind: #{kind.inspect} is not a word like web") unless kind.nil? || kind.is_a?(String)
|
|
61
|
+
|
|
62
|
+
list = data["processes"]
|
|
63
|
+
fail_with(path, "app.processes: must be a list of at least one process") unless list.is_a?(Array) && !list.empty?
|
|
64
|
+
|
|
65
|
+
processes = list.each_with_index.map { |entry, index| process(entry, index, path) }
|
|
66
|
+
checked(new(kind: kind&.strip, processes:), path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @return [OpenLanes::Apps::ProcessSpec]
|
|
72
|
+
def process(entry, index, path)
|
|
73
|
+
where = "app.processes[#{index}]"
|
|
74
|
+
fail_with(path, "#{where}: must be a mapping with name: and start:") unless entry.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
data = entry.transform_keys(&:to_s)
|
|
77
|
+
unknown!(path, where, data.keys - PROCESS_KEYS, PROCESS_KEYS)
|
|
78
|
+
name = data["name"]
|
|
79
|
+
fail_with(path, "#{where}: needs name:, letters, digits, - or _") unless name.is_a?(String) && name.match?(NAME)
|
|
80
|
+
|
|
81
|
+
ProcessSpec.new(name:,
|
|
82
|
+
start: start(data["start"], "app.processes.#{name}", path),
|
|
83
|
+
ready: ready(data["ready"], "app.processes.#{name}", path),
|
|
84
|
+
restart_on: restart_on(data["restart_on"], "app.processes.#{name}", path),
|
|
85
|
+
preview: preview(data["preview"], "app.processes.#{name}", path))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [String]
|
|
89
|
+
def start(value, where, path)
|
|
90
|
+
return value.strip if value.is_a?(String) && !value.strip.empty?
|
|
91
|
+
|
|
92
|
+
fail_with(path, "#{where}: needs start:, the shell line that runs it")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def ready(value, where, path)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
return value if value.is_a?(String) && value.start_with?("/")
|
|
99
|
+
|
|
100
|
+
fail_with(path, "#{where}.ready: #{value.inspect} is not an HTTP path like /up")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Array<String>]
|
|
104
|
+
def restart_on(value, where, path)
|
|
105
|
+
globs = value.is_a?(String) ? [value] : value
|
|
106
|
+
return [] if globs.nil?
|
|
107
|
+
return globs if globs.is_a?(Array) && globs.all? { it.is_a?(String) && !it.strip.empty? }
|
|
108
|
+
|
|
109
|
+
fail_with(path, "#{where}.restart_on: must be a glob or a list of globs")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def preview(value, where, path)
|
|
114
|
+
return value == true if [true, false, nil].include?(value)
|
|
115
|
+
|
|
116
|
+
fail_with(path, "#{where}.preview: #{value.inspect} is not true or false")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Two processes that one name, one `PORT_` variable or two preview
|
|
120
|
+
# flags would confuse.
|
|
121
|
+
#
|
|
122
|
+
# @return [OpenLanes::Apps::Definition]
|
|
123
|
+
def checked(definition, path)
|
|
124
|
+
names = definition.processes.map(&:name)
|
|
125
|
+
twice = names.select { names.count(it) > 1 }.uniq
|
|
126
|
+
fail_with(path, "app.processes: #{twice.join(", ")} named twice") unless twice.empty?
|
|
127
|
+
|
|
128
|
+
envs = definition.processes.group_by(&:port_env).select { _2.size > 1 }
|
|
129
|
+
envs.each do |env, specs|
|
|
130
|
+
fail_with(path, "app.processes: #{specs.map(&:name).join(" and ")} would both be #{env}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
previews = definition.processes.select(&:preview?)
|
|
134
|
+
if previews.size > 1
|
|
135
|
+
fail_with(path, "app.processes: only one process may be preview: true, not #{previews.map(&:name).join(" and ")}")
|
|
136
|
+
end
|
|
137
|
+
definition
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [void]
|
|
141
|
+
def unknown!(path, where, unknown, known)
|
|
142
|
+
return if unknown.empty?
|
|
143
|
+
|
|
144
|
+
fail_with(path, "#{where}: unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; known: #{known.join(", ")}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [void] never returns
|
|
148
|
+
def fail_with(path, message) = raise(Error, "#{path}: #{message}")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Apps
|
|
8
|
+
# Whether the Preview tab may show an app in an iframe, or must open it in
|
|
9
|
+
# a window of its own (M13, plan 012 item 11).
|
|
10
|
+
#
|
|
11
|
+
# The page cannot find out: a cross-origin frame's DOM is closed to it
|
|
12
|
+
# and a refused frame still fires `load`. So the runner asks the app
|
|
13
|
+
# itself, once its preview process is ready and after each restart, and
|
|
14
|
+
# reads the two headers that refuse framing:
|
|
15
|
+
#
|
|
16
|
+
# - `X-Frame-Options`: `DENY` refuses everyone, and `SAMEORIGIN` refuses
|
|
17
|
+
# the control room too, whose origin differs from the app's by port.
|
|
18
|
+
# `ALLOW-FROM` is obsolete and ignored by browsers, so it frames.
|
|
19
|
+
# - `Content-Security-Policy: frame-ancestors`: refuses unless one of its
|
|
20
|
+
# sources matches the control room's origin. `'self'` is the app's own
|
|
21
|
+
# origin, so it refuses too.
|
|
22
|
+
#
|
|
23
|
+
# Either one refusing sends the tab to a window. Browsers let
|
|
24
|
+
# `frame-ancestors` override `X-Frame-Options`; this stays on the
|
|
25
|
+
# cautious side, because a window always works and a refused frame is a
|
|
26
|
+
# blank tab nobody can explain.
|
|
27
|
+
module Framing
|
|
28
|
+
# The control room's origin unless told otherwise.
|
|
29
|
+
ORIGIN = "http://#{Status::HOST}:11000".freeze
|
|
30
|
+
|
|
31
|
+
# Default ports, for a source that names none.
|
|
32
|
+
DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# @param headers [Hash{String => String, Array<String>}] any case
|
|
37
|
+
# @param origin [String] the page that embeds it
|
|
38
|
+
# @return [Symbol] `:frame` or `:window`
|
|
39
|
+
def verdict(headers, origin: ORIGIN)
|
|
40
|
+
values = headers.to_h { |name, value| [name.to_s.downcase, Array(value).join(",")] }
|
|
41
|
+
refused = x_frame_refuses?(values["x-frame-options"]) || ancestors_refuse?(values["content-security-policy"], URI(origin))
|
|
42
|
+
refused ? :window : :frame
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One GET of the URL the tab would open, then {.verdict}. An app that
|
|
46
|
+
# cannot be asked opens in a window: it may work there, and an iframe
|
|
47
|
+
# would show nothing either way.
|
|
48
|
+
#
|
|
49
|
+
# @param url [String]
|
|
50
|
+
# @param origin [String]
|
|
51
|
+
# @return [Symbol]
|
|
52
|
+
def check(url, origin: ORIGIN)
|
|
53
|
+
uri = URI(url)
|
|
54
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
+
http.open_timeout = Probe::TIMEOUT
|
|
56
|
+
http.read_timeout = Probe::TIMEOUT
|
|
57
|
+
response = http.request_get(uri.request_uri)
|
|
58
|
+
verdict(response.to_hash, origin:)
|
|
59
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError, URI::Error
|
|
60
|
+
:window
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param value [String, nil]
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def x_frame_refuses?(value)
|
|
66
|
+
value.to_s.split(",").map { it.strip.upcase }.intersect?(%w[DENY SAMEORIGIN])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Every policy is enforced, so any one whose `frame-ancestors` leaves
|
|
70
|
+
# the origin out refuses it. A policy without the directive allows
|
|
71
|
+
# framing: it has no `default-src` fallback.
|
|
72
|
+
#
|
|
73
|
+
# @param value [String, nil] one or more policies, comma-joined
|
|
74
|
+
# @param origin [URI::Generic]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def ancestors_refuse?(value, origin)
|
|
77
|
+
value.to_s.split(",").any? do |policy|
|
|
78
|
+
directive = policy.split(";").map(&:strip).find { it.downcase.start_with?("frame-ancestors") }
|
|
79
|
+
directive && directive.split[1..].none? { allows?(it, origin) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param source [String] one source expression
|
|
84
|
+
# @param origin [URI::Generic]
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def allows?(source, origin)
|
|
87
|
+
text = source.strip.downcase
|
|
88
|
+
return true if text == "*"
|
|
89
|
+
return text.delete_suffix(":") == origin.scheme if text.match?(/\A[a-z][a-z0-9+.-]*:\z/)
|
|
90
|
+
return false if text.start_with?("'")
|
|
91
|
+
|
|
92
|
+
host_allows?(text, origin)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def host_allows?(text, origin)
|
|
97
|
+
match = text.match(%r{\A(?:(?<scheme>[a-z][a-z0-9+.-]*)://)?(?<host>[^:/]+)(?::(?<port>\d+|\*))?(?:/.*)?\z})
|
|
98
|
+
return false unless match
|
|
99
|
+
return false if match[:scheme] && match[:scheme] != origin.scheme
|
|
100
|
+
return false unless host_matches?(match[:host], origin.host)
|
|
101
|
+
|
|
102
|
+
port = match[:port]
|
|
103
|
+
port == "*" || (port || DEFAULT_PORTS[match[:scheme] || origin.scheme]).to_i == origin.port
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def host_matches?(pattern, host)
|
|
108
|
+
return host == pattern unless pattern.start_with?("*.")
|
|
109
|
+
|
|
110
|
+
host.end_with?(pattern.delete_prefix("*"))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Apps
|
|
7
|
+
# The apps running on this machine, as the control room reads them from
|
|
8
|
+
# Redis: one hash per machine (`Live::Keys#apps`), a field per plan, its
|
|
9
|
+
# value the plan's process statuses and a heartbeat.
|
|
10
|
+
#
|
|
11
|
+
# Per machine rather than per checkout, because the control room runs in
|
|
12
|
+
# the main checkout and an app runs in the plan's worktree; per machine
|
|
13
|
+
# rather than per repository, because `127.0.0.1:11101` means nothing on
|
|
14
|
+
# another one.
|
|
15
|
+
#
|
|
16
|
+
# A runner killed with KILL never removes its field, so a field whose
|
|
17
|
+
# heartbeat is older than {TTL} is dropped by whoever reads it, the way
|
|
18
|
+
# `Live.board` drops a node that stopped heartbeating.
|
|
19
|
+
module Listing
|
|
20
|
+
# Seconds a plan's entry outlives its last heartbeat.
|
|
21
|
+
TTL = 30
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
26
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
27
|
+
# @param plan [String]
|
|
28
|
+
# @param statuses [Array<OpenLanes::Apps::Status>]
|
|
29
|
+
# @param now [Time]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def write(store:, keys:, plan:, statuses:, now: Time.now)
|
|
32
|
+
store.hset(keys.apps, plan, JSON.generate("heartbeat" => now.to_f, "processes" => statuses.map(&:to_h)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [void]
|
|
36
|
+
def remove(store:, keys:, plan:) = store.hdel(keys.apps, plan)
|
|
37
|
+
|
|
38
|
+
# Every live process, oldest plan first, in each plan's start order.
|
|
39
|
+
#
|
|
40
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
41
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
42
|
+
# @param now [Time]
|
|
43
|
+
# @return [Array<Hash>] `<app>` hashes, as {Status#to_h} wrote them
|
|
44
|
+
def read(store:, keys:, now: Time.now)
|
|
45
|
+
entries = store.hgetall(keys.apps).transform_values { parse(it) }
|
|
46
|
+
stale = entries.select { |_, entry| entry.nil? || now.to_f - entry["heartbeat"].to_f > TTL }.keys
|
|
47
|
+
store.hdel(keys.apps, *stale) unless stale.empty?
|
|
48
|
+
entries.except(*stale).sort.flat_map { |_, entry| Array(entry["processes"]) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash, nil] nil for a field that is not ours to read
|
|
52
|
+
def parse(json)
|
|
53
|
+
data = JSON.parse(json.to_s)
|
|
54
|
+
data.is_a?(Hash) ? data : nil
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module OpenLanes
|
|
9
|
+
module Apps
|
|
10
|
+
# Every port an app holds on this machine, in one file (plan 012).
|
|
11
|
+
#
|
|
12
|
+
# One file per machine, not per checkout: a worktree isolates files, not
|
|
13
|
+
# ports, and two worktrees of one app asking for 11100 each would both
|
|
14
|
+
# be told yes by anything that only looked at its own checkout.
|
|
15
|
+
#
|
|
16
|
+
# A port is reserved under an exclusive `flock` and also probed, both by
|
|
17
|
+
# connecting to it and by binding it: something that never registered
|
|
18
|
+
# here (a dev server started by hand, the control room) still holds it.
|
|
19
|
+
# The probe and the spawn that follows it leave a gap no lock covers, so
|
|
20
|
+
# {Supervisor} retries a start that lost that race.
|
|
21
|
+
#
|
|
22
|
+
# An entry belongs to the pid that reserved it. Once that process is
|
|
23
|
+
# gone the entry counts as free, which is how a runner killed with KILL
|
|
24
|
+
# gives its ports back without anyone cleaning up after it.
|
|
25
|
+
class Ports
|
|
26
|
+
# The first port an app is given (M12).
|
|
27
|
+
FLOOR = 11_100
|
|
28
|
+
|
|
29
|
+
# The last. Not configurable: per-plan ranges are a non-goal.
|
|
30
|
+
CEILING = 11_999
|
|
31
|
+
|
|
32
|
+
# Overrides where the registry lives; the suite and the Cypress run
|
|
33
|
+
# point it at a temp file.
|
|
34
|
+
FILE_ENV = "OPENLANES_PORTS_FILE"
|
|
35
|
+
|
|
36
|
+
# Every port in the range is held.
|
|
37
|
+
class Exhausted < Error; end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# `$OPENLANES_PORTS_FILE`, else `${XDG_STATE_HOME:-~/.local/state}/openlanes/ports.json`.
|
|
41
|
+
#
|
|
42
|
+
# @param env [Hash]
|
|
43
|
+
# @return [String]
|
|
44
|
+
def default_path(env = ENV)
|
|
45
|
+
return env[FILE_ENV] unless env[FILE_ENV].to_s.strip.empty?
|
|
46
|
+
|
|
47
|
+
state = env["XDG_STATE_HOME"].to_s.strip
|
|
48
|
+
File.join(state.empty? ? File.expand_path("~/.local/state") : state, "openlanes", "ports.json")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Nothing answers on the port and it can be bound on 127.0.0.1. The
|
|
52
|
+
# connect catches a listener on another address, which macOS lets a
|
|
53
|
+
# SO_REUSEADDR bind on 127.0.0.1 sit beside.
|
|
54
|
+
#
|
|
55
|
+
# @param port [Integer]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def free?(port)
|
|
58
|
+
return false if answering?(port)
|
|
59
|
+
|
|
60
|
+
TCPServer.new(Status::HOST, port).close
|
|
61
|
+
true
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param port [Integer]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def answering?(port) = Probe.listening?(port, timeout: 0.2)
|
|
69
|
+
|
|
70
|
+
# @param pid [Integer]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def alive?(pid)
|
|
73
|
+
Process.kill(0, pid.to_i)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::ESRCH, RangeError
|
|
76
|
+
false
|
|
77
|
+
rescue Errno::EPERM
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param path [String] the registry file
|
|
83
|
+
# @param owner [Integer] whose reservations these are
|
|
84
|
+
# @param floor [Integer]
|
|
85
|
+
# @param ceiling [Integer]
|
|
86
|
+
# @param free [#call] a port to whether it can be taken
|
|
87
|
+
# @param alive [#call] a pid to whether it still runs
|
|
88
|
+
def initialize(path: self.class.default_path, owner: Process.pid, floor: FLOOR, ceiling: CEILING,
|
|
89
|
+
free: self.class.method(:free?), alive: self.class.method(:alive?))
|
|
90
|
+
@path = path
|
|
91
|
+
@owner = owner
|
|
92
|
+
@floor = floor
|
|
93
|
+
@ceiling = ceiling
|
|
94
|
+
@free = free
|
|
95
|
+
@alive = alive
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [String]
|
|
99
|
+
attr_reader :path
|
|
100
|
+
|
|
101
|
+
# The lowest port at or above the floor that nobody holds.
|
|
102
|
+
#
|
|
103
|
+
# @param root [String] the checkout the app runs in
|
|
104
|
+
# @param plan [String]
|
|
105
|
+
# @param process [String]
|
|
106
|
+
# @return [Integer]
|
|
107
|
+
# @raise [OpenLanes::Apps::Ports::Exhausted]
|
|
108
|
+
def reserve(root:, plan:, process:)
|
|
109
|
+
locked do |entries|
|
|
110
|
+
port = (@floor..@ceiling).find { !entries.key?(it) && @free.call(it) }
|
|
111
|
+
raise Exhausted, "every port from #{@floor} to #{@ceiling} is taken" unless port
|
|
112
|
+
|
|
113
|
+
entries[port] = { "owner" => @owner, "root" => root, "plan" => plan, "process" => process, "at" => Time.now.utc.iso8601 }
|
|
114
|
+
port
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param port [Integer]
|
|
119
|
+
# @return [void]
|
|
120
|
+
def release(port) = locked { it.delete(port.to_i) }
|
|
121
|
+
|
|
122
|
+
# Everything one process holds.
|
|
123
|
+
#
|
|
124
|
+
# @param pid [Integer]
|
|
125
|
+
# @return [Array<Integer>] the ports released
|
|
126
|
+
def release_owner(pid = @owner)
|
|
127
|
+
locked do |entries|
|
|
128
|
+
entries.select { |_, entry| entry["owner"] == pid }.keys.tap { |ports| ports.each { entries.delete(it) } }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Every live reservation, by port.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash{Integer => Hash}] `owner`, `root`, `plan`, `process`, `at`
|
|
135
|
+
def entries = locked(&:dup)
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Read, hand the live entries to the block, write back what it left.
|
|
140
|
+
# Dead owners' entries are dropped on the way.
|
|
141
|
+
#
|
|
142
|
+
# @yieldparam entries [Hash{Integer => Hash}]
|
|
143
|
+
# @return [Object] the block's value
|
|
144
|
+
def locked
|
|
145
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
146
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
147
|
+
file.flock(File::LOCK_EX)
|
|
148
|
+
entries = parse(file.read).select { |_, entry| @alive.call(entry["owner"]) }
|
|
149
|
+
yield(entries).tap do
|
|
150
|
+
file.rewind
|
|
151
|
+
file.truncate(0)
|
|
152
|
+
file.write(JSON.pretty_generate(entries.sort.to_h { [it.first.to_s, it.last] }))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Hash{Integer => Hash}]
|
|
158
|
+
def parse(text)
|
|
159
|
+
data = text.strip.empty? ? {} : JSON.parse(text)
|
|
160
|
+
data.is_a?(Hash) ? data.to_h { |port, entry| [port.to_i, entry] }.select { |_, entry| entry.is_a?(Hash) } : {}
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
# A file somebody broke by hand is a registry with nothing in it; the
|
|
163
|
+
# probes still keep a taken port from being handed out twice.
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|