openlanes 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -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 +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- 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 +7 -0
- 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 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- 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 +84 -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 +1175 -117
- 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 +6 -6
- 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 +12 -120
- 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 +223 -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 +17 -13
- 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 +60 -11
- 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 +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- 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 +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- 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 +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -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 +64 -5
- 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 +39 -24
- 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 +226 -8
- 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 +167 -6
- 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 +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- 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 +29 -10
- data/lib/openlanes/web.rb +6 -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/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- 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/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Engine
|
|
7
|
+
module Verification
|
|
8
|
+
# One verification of one plan: `<plan folder>/verification/<number>/`
|
|
9
|
+
# (M14), where chewie leaves its screenshots and `report.md`.
|
|
10
|
+
#
|
|
11
|
+
# The number is read from disk, never kept anywhere else: a round is
|
|
12
|
+
# finished once its `report.md` exists, so the next run of chewie gets
|
|
13
|
+
# the next number, while a run that died before reporting hands its
|
|
14
|
+
# folder to the one that follows it rather than leaving a gap.
|
|
15
|
+
#
|
|
16
|
+
# @!attribute [r] plan_dir
|
|
17
|
+
# @return [String] the plan folder, in the main checkout
|
|
18
|
+
# @!attribute [r] number
|
|
19
|
+
# @return [Integer] from 1
|
|
20
|
+
Round = Data.define(:plan_dir, :number) do
|
|
21
|
+
# @return [String] the round's own folder
|
|
22
|
+
def dir = File.join(plan_dir, Round::FOLDER, number.to_s)
|
|
23
|
+
|
|
24
|
+
# @return [String] what chewie writes when it is done
|
|
25
|
+
def report_path = File.join(dir, Round::REPORT)
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] whether chewie, or the engine for it, reported
|
|
28
|
+
def reported? = File.file?(report_path)
|
|
29
|
+
|
|
30
|
+
# @return [Report, nil]
|
|
31
|
+
def report = reported? ? Report.read(report_path) : nil
|
|
32
|
+
|
|
33
|
+
# @return [String] the folder, relative to the plan folder
|
|
34
|
+
def relative = File.join(Round::FOLDER, number.to_s)
|
|
35
|
+
|
|
36
|
+
# @return [String] where the engine records the verification commit
|
|
37
|
+
def published_path = File.join(dir, Round::PUBLISHED)
|
|
38
|
+
|
|
39
|
+
# Whether the engine pushed this round's evidence. chewie's
|
|
40
|
+
# `Verdict: verified` is only chewie's word: a run that died after
|
|
41
|
+
# writing it, before its signature or the push, would otherwise be
|
|
42
|
+
# taken for a round waiting on CI, and the old head labelled ready.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def published? = File.file?(published_path)
|
|
46
|
+
|
|
47
|
+
# @param sha [String, nil] the commit that carried the round
|
|
48
|
+
# @return [void]
|
|
49
|
+
def publish!(sha) = File.write(published_path, "#{sha}\n")
|
|
50
|
+
|
|
51
|
+
# @return [self] with its folder made
|
|
52
|
+
def make!
|
|
53
|
+
FileUtils.mkdir_p(dir)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Round
|
|
59
|
+
# Under the plan folder.
|
|
60
|
+
FOLDER = "verification"
|
|
61
|
+
|
|
62
|
+
# chewie's own account of the round.
|
|
63
|
+
REPORT = "report.md"
|
|
64
|
+
|
|
65
|
+
# The engine's note to chewie about the app it started.
|
|
66
|
+
APP_NOTE = "app.md"
|
|
67
|
+
|
|
68
|
+
# The engine's record that the round's evidence was pushed: the SHA.
|
|
69
|
+
PUBLISHED = "published.txt"
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# @param plan_dir [String]
|
|
73
|
+
# @return [Array<Round>] every round on disk, oldest first
|
|
74
|
+
def all(plan_dir)
|
|
75
|
+
base = File.join(plan_dir, FOLDER)
|
|
76
|
+
return [] unless File.directory?(base)
|
|
77
|
+
|
|
78
|
+
Dir.children(base).grep(/\A[1-9]\d*\z/).map(&:to_i).sort.map { new(plan_dir:, number: it) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param plan_dir [String]
|
|
82
|
+
# @return [Round, nil] the newest round, reported or not
|
|
83
|
+
def latest(plan_dir) = all(plan_dir).last
|
|
84
|
+
|
|
85
|
+
# The round a new run of chewie works in: the newest one when it
|
|
86
|
+
# has no report yet, otherwise the one after it.
|
|
87
|
+
#
|
|
88
|
+
# @param plan_dir [String]
|
|
89
|
+
# @return [Round]
|
|
90
|
+
def next(plan_dir)
|
|
91
|
+
last = latest(plan_dir)
|
|
92
|
+
return last if last && !last.reported?
|
|
93
|
+
|
|
94
|
+
new(plan_dir:, number: (last&.number || 0) + 1)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Engine
|
|
8
|
+
module Verification
|
|
9
|
+
# The images one round leaves, made ready to commit (M14): PNG only,
|
|
10
|
+
# only the failing step and the final state, each optimised.
|
|
11
|
+
#
|
|
12
|
+
# PNG because GitHub renders neither AVIF nor WebP inline in a pull
|
|
13
|
+
# request comment, and a screenshot nobody sees proves nothing. Two
|
|
14
|
+
# images because every one lands in the branch, then on `main`, for
|
|
15
|
+
# good: a plan rejected three times already adds six.
|
|
16
|
+
class Screenshots
|
|
17
|
+
# Anything an image could be. Only `.png` survives {#prepare}.
|
|
18
|
+
IMAGE = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|bmp|tiff?)\z/i
|
|
19
|
+
|
|
20
|
+
# The last screenshot of every round, pass or fail.
|
|
21
|
+
FINAL = "final.png"
|
|
22
|
+
|
|
23
|
+
# Optimisers, in the order tried (M14 names both). Each writes a
|
|
24
|
+
# recompressed copy; the smallest file, the original included, is
|
|
25
|
+
# the one kept, so an optimiser that grows an already tight PNG (vips
|
|
26
|
+
# does, on small flat images) costs nothing.
|
|
27
|
+
TOOLS = {
|
|
28
|
+
"vips" => ->(from, to) { ["vips", "pngsave", from, to, "--compression", "9", "--keep", "none"] },
|
|
29
|
+
"magick" => ->(from, to) { ["magick", from, "-strip", "-define", "png:compression-level=9", to] }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# A round holding an image other than PNG.
|
|
33
|
+
class Refused < Error; end
|
|
34
|
+
|
|
35
|
+
# @param dir [String] the round's folder
|
|
36
|
+
# @param tools [Hash{String => Proc}] name to argv builder, see {TOOLS}
|
|
37
|
+
# @param which [#call] a tool name to whether it is on `PATH`
|
|
38
|
+
# @param runner [#call] argv to whether it succeeded
|
|
39
|
+
def initialize(dir:, tools: TOOLS, which: method(:on_path?), runner: method(:run))
|
|
40
|
+
@dir = dir
|
|
41
|
+
@tools = tools
|
|
42
|
+
@which = which
|
|
43
|
+
@runner = runner
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Refuse what is not PNG, drop every image but the failing step's and
|
|
47
|
+
# {FINAL}, and optimise those.
|
|
48
|
+
#
|
|
49
|
+
# @param failing_step [Integer, nil] the step whose screenshot to keep
|
|
50
|
+
# @return [Array<String>] the kept paths, failing step first
|
|
51
|
+
# @raise [Refused] naming every image that is not PNG; nothing is deleted then
|
|
52
|
+
def prepare(failing_step: nil)
|
|
53
|
+
images = Dir.children(@dir).select { it.match?(IMAGE) && File.file?(File.join(@dir, it)) }.sort
|
|
54
|
+
foreign = images.grep_v(/\.png\z/i)
|
|
55
|
+
raise Refused, "#{@dir}: not PNG, which GitHub does not show inline: #{foreign.join(", ")}" unless foreign.empty?
|
|
56
|
+
|
|
57
|
+
keep = [(format(Report::STEP_PNG, failing_step) if failing_step), FINAL].compact
|
|
58
|
+
(images - keep).each { FileUtils.rm_f(File.join(@dir, it)) }
|
|
59
|
+
kept = keep.map { File.join(@dir, it) }.select { File.file?(it) }
|
|
60
|
+
optimise(kept)
|
|
61
|
+
kept
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Delete every image that is not PNG. {#prepare} deletes nothing when
|
|
65
|
+
# it refuses, so that a person reading the round can see what was
|
|
66
|
+
# there; but the round's folder is committed whole, so whatever is
|
|
67
|
+
# left in it is published. A caller about to commit calls this
|
|
68
|
+
# first, then {#prepare} again.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<String>] the names removed
|
|
71
|
+
def discard_foreign!
|
|
72
|
+
foreign = Dir.children(@dir).select { it.match?(IMAGE) && !it.match?(/\.png\z/i) && File.file?(File.join(@dir, it)) }.sort
|
|
73
|
+
foreign.each { FileUtils.rm_f(File.join(@dir, it)) }
|
|
74
|
+
foreign
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# @param paths [Array<String>]
|
|
80
|
+
# @return [void]
|
|
81
|
+
def optimise(paths)
|
|
82
|
+
return if paths.empty?
|
|
83
|
+
|
|
84
|
+
available = @tools.select { |name, _| @which.call(name) }
|
|
85
|
+
if available.empty?
|
|
86
|
+
UI.log("screenshots not optimised: neither #{@tools.keys.join(" nor ")} is installed", level: :warn)
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
paths.each { |path| shrink(path, available.values) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [void]
|
|
94
|
+
def shrink(path, builders)
|
|
95
|
+
candidates = builders.each_with_index.filter_map do |build, i|
|
|
96
|
+
out = "#{path}.#{i}.tmp.png"
|
|
97
|
+
@runner.call(build.call(path, out)) && File.file?(out) ? out : nil
|
|
98
|
+
end
|
|
99
|
+
best = candidates.min_by { File.size(it) }
|
|
100
|
+
FileUtils.mv(best, path) if best && File.size(best) < File.size(path)
|
|
101
|
+
ensure
|
|
102
|
+
candidates&.each { FileUtils.rm_f(it) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param name [String]
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def on_path?(name) = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, name)) }
|
|
108
|
+
|
|
109
|
+
# @param argv [Array<String>]
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def run(argv)
|
|
112
|
+
_, status = Open3.capture2e(*argv)
|
|
113
|
+
status.success?
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# Running a reviewed pull request before it may merge (plan 014, M1).
|
|
6
|
+
#
|
|
7
|
+
# hansolo's approval moves the plan to 🧪 Verifying. The engine starts the
|
|
8
|
+
# plan's app ({AppSession}), `chewie-verifier` follows the pull request's
|
|
9
|
+
# `## How to test` against it and leaves screenshots and a report in a
|
|
10
|
+
# {Round}'s folder, and the engine acts on the verdict ({Outcome}):
|
|
11
|
+
# commit, comment, label, and the plan to 🔴, or green CI, the coverage
|
|
12
|
+
# gate and `ready-to-merge`.
|
|
13
|
+
module Verification
|
|
14
|
+
# The phase an agent definition declares to be the verifier. Its
|
|
15
|
+
# Completed notes are verdicts, as the review phase's are.
|
|
16
|
+
PHASE = "verification"
|
|
17
|
+
|
|
18
|
+
# The verdicts that go on to verification at every quality.
|
|
19
|
+
FROM_REVIEW = [Plans::ReviewLabel::APPROVED].freeze
|
|
20
|
+
|
|
21
|
+
# The verdict whose fate `desired_codebase_quality` decides (R4): on
|
|
22
|
+
# to verification under `mvp`, back to luke and rey under `high`.
|
|
23
|
+
ON_QUALITY = Plans::ReviewLabel::OK_NOT_PERFECT
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param plan_dir [String]
|
|
28
|
+
# @return [Round] the round a new run of chewie works in
|
|
29
|
+
def round_for(plan_dir) = Round.next(plan_dir)
|
|
30
|
+
|
|
31
|
+
# @param plan_dir [String]
|
|
32
|
+
# @return [Round, nil] the newest round
|
|
33
|
+
def latest(plan_dir) = Round.latest(plan_dir)
|
|
34
|
+
|
|
35
|
+
# @return [AppSession]
|
|
36
|
+
def session(**) = AppSession.new(**)
|
|
37
|
+
|
|
38
|
+
# @return [Outcome]
|
|
39
|
+
def outcome(**) = Outcome.new(**)
|
|
40
|
+
|
|
41
|
+
# @param labels [Array<String>] a pull request's labels
|
|
42
|
+
# @param quality [Symbol, nil] {Config#desired_codebase_quality}; nil
|
|
43
|
+
# while the project is still to be asked, which lets nothing but
|
|
44
|
+
# {FROM_REVIEW} through
|
|
45
|
+
# @return [Boolean] whether its review verdict goes on to verification
|
|
46
|
+
def from_review?(labels, quality: nil)
|
|
47
|
+
return true if FROM_REVIEW.any? { labels.include?(it.name) }
|
|
48
|
+
|
|
49
|
+
quality == :mvp && labels.include?(ON_QUALITY.name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param labels [Array<String>] a pull request's labels
|
|
53
|
+
# @return [Boolean] whether the verdict is the one the quality decides
|
|
54
|
+
def ok_not_perfect?(labels) = labels.include?(ON_QUALITY.name) && FROM_REVIEW.none? { labels.include?(it.name) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/openlanes/engine.rb
CHANGED
|
@@ -6,14 +6,43 @@ module OpenLanes
|
|
|
6
6
|
module Engine
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
+
# @param options [Hash] passed to {RunWorkflow#initialize}
|
|
10
|
+
# @return [OpenLanes::Engine::RunWorkflow] `openlanes run`, without the command
|
|
11
|
+
def run(**) = RunWorkflow.new(**)
|
|
12
|
+
|
|
13
|
+
# @param options [Hash] passed to {StartWorkflow#initialize}
|
|
14
|
+
# @return [OpenLanes::Engine::StartWorkflow] `openlanes start`, without the command
|
|
15
|
+
def start(**) = StartWorkflow.new(**)
|
|
16
|
+
|
|
9
17
|
# @return [OpenLanes::Engine::Runner]
|
|
10
18
|
def runner(**) = Runner.new(**)
|
|
11
19
|
|
|
12
20
|
# @param tree [OpenLanes::Plans::Tree]
|
|
13
21
|
# @return [OpenLanes::Engine::StateFile]
|
|
14
|
-
def state_file(tree) = StateFile.new(path: StateFile.for(tree))
|
|
22
|
+
def state_file(tree) = StateFile.new(path: StateFile.for(tree), legacy: StateFile.legacy_for(tree))
|
|
15
23
|
|
|
16
24
|
# @return [OpenLanes::Engine::Tally]
|
|
17
25
|
def tally(**) = Tally.new(**)
|
|
26
|
+
|
|
27
|
+
# The coverage check `ready-to-merge` waits on (M15).
|
|
28
|
+
#
|
|
29
|
+
# @return [OpenLanes::Engine::CoverageGate]
|
|
30
|
+
def coverage_gate(**) = CoverageGate.new(**)
|
|
31
|
+
|
|
32
|
+
@agent_locks = ->(dir, own: []) { AgentLocks.new(root: dir, own:) }
|
|
33
|
+
|
|
34
|
+
# The plans' alock register (W31), for the repository holding +dir+; nil
|
|
35
|
+
# when the factory is, which is how the suite keeps `alock` unrun.
|
|
36
|
+
#
|
|
37
|
+
# @param dir [String] any directory in the repository, the plans' one
|
|
38
|
+
# @param own [Array<String>] the engine's agents, whose locks are not outside ones
|
|
39
|
+
# @return [OpenLanes::Engine::AgentLocks, nil]
|
|
40
|
+
def agent_locks(dir, own: []) = @agent_locks&.call(dir, own:)
|
|
41
|
+
|
|
42
|
+
# @param factory [#call, nil] answers `call(dir, own:)` with an {AgentLocks}
|
|
43
|
+
# @return [void]
|
|
44
|
+
def agent_locks=(factory)
|
|
45
|
+
@agent_locks = factory
|
|
46
|
+
end
|
|
18
47
|
end
|
|
19
48
|
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Execution
|
|
7
|
+
# The AFTER half of the autonomy boundary: what the checkout looked like
|
|
8
|
+
# before an agent ran, and what crossing the line looks like afterwards.
|
|
9
|
+
#
|
|
10
|
+
# Two things are checked. HEAD must not move, because the boundary is docs
|
|
11
|
+
# plus code and no commits. And no remote-tracking ref may have been moved
|
|
12
|
+
# by a push, because a push is the one thing that leaves the machine.
|
|
13
|
+
#
|
|
14
|
+
# The push check reads only local state: `git push` updates the
|
|
15
|
+
# remote-tracking ref of every branch it pushes (`refs/remotes/origin/x`)
|
|
16
|
+
# and logs that update as "update by push". `git ls-remote` would also see
|
|
17
|
+
# pushes made some other way (a URL instead of a remote name, the GitHub
|
|
18
|
+
# API), but it costs a network round trip on every invocation, and it
|
|
19
|
+
# cannot tell an agent's push from a teammate's, from CI, or from this
|
|
20
|
+
# harness publishing another plan a second earlier, so it would fail
|
|
21
|
+
# honest runs for other people's work. A check that cries wolf gets
|
|
22
|
+
# turned off.
|
|
23
|
+
#
|
|
24
|
+
# Comparing tracking refs alone is not enough either: an agent that runs
|
|
25
|
+
# `git fetch` moves them to whatever the remote already holds, and nothing
|
|
26
|
+
# left the machine. So a ref that moved counts only when its reflog says a
|
|
27
|
+
# push moved it during the run. A ref that moved with no reflog entry at all
|
|
28
|
+
# (reflogs switched off) cannot be told apart from a push, and is reported.
|
|
29
|
+
#
|
|
30
|
+
# Worktrees share `refs/remotes`, so a push from any other checkout of
|
|
31
|
+
# the repository (a person, another session, a sibling agent's harness)
|
|
32
|
+
# moves the same refs during this run. A pushed commit reachable from
|
|
33
|
+
# another worktree's HEAD, and not from this one's, was pushed from there
|
|
34
|
+
# and is not this agent's. The main checkout counts as another worktree,
|
|
35
|
+
# so a push of whatever its HEAD names is exempt too; that is accepted,
|
|
36
|
+
# because it is a push from that checkout, not from this agent. A sibling
|
|
37
|
+
# that sits on this checkout's own commit is not told apart from it, so a
|
|
38
|
+
# push of that shared commit is still reported.
|
|
39
|
+
#
|
|
40
|
+
# Blind spots, by construction: a push to a bare URL updates no tracking
|
|
41
|
+
# ref, and a deleted remote branch takes its tracking ref and that ref's
|
|
42
|
+
# reflog with it, which locally looks the same as `git fetch --prune`.
|
|
43
|
+
module Boundary
|
|
44
|
+
# How `git push` signs the tracking refs it updates.
|
|
45
|
+
PUSHED = "update by push"
|
|
46
|
+
|
|
47
|
+
# What one checkout looked like before an agent ran in it.
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [r] head
|
|
50
|
+
# @return [String, nil] nil outside a repository, which turns the check off
|
|
51
|
+
# @!attribute [r] remotes
|
|
52
|
+
# @return [Hash{String => String}] remote-tracking ref to the commit it named
|
|
53
|
+
# @!attribute [r] reflogs
|
|
54
|
+
# @return [Hash{String => Integer}] remote-tracking ref to how many
|
|
55
|
+
# reflog entries it had, so the entries the run added can be told
|
|
56
|
+
# from the ones before it. Counting, rather than comparing reflog
|
|
57
|
+
# timestamps, because those have one-second resolution and a
|
|
58
|
+
# publish in the same second as the snapshot would read as new.
|
|
59
|
+
Snapshot = Data.define(:head, :remotes, :reflogs) do
|
|
60
|
+
# @param root [String] the checkout the agent worked in
|
|
61
|
+
# @return [String, nil] what boundary was crossed, or nil
|
|
62
|
+
def violation(root)
|
|
63
|
+
return nil if head.nil?
|
|
64
|
+
|
|
65
|
+
after = Boundary.head(root)
|
|
66
|
+
return "agent committed (HEAD moved #{head[0, 7]} → #{after.to_s[0, 7]}) — the boundary is docs plus code, no commits" if after != head
|
|
67
|
+
|
|
68
|
+
pushed = Boundary.pushed(self, root)
|
|
69
|
+
return nil if pushed.empty?
|
|
70
|
+
|
|
71
|
+
"agent pushed (#{pushed.map { |ref| ref.delete_prefix("refs/remotes/") }.join(", ")}) — the boundary is docs plus code, nothing leaves the machine"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@sanctioned = Hash.new { |hash, ref| hash[ref] = [] }
|
|
76
|
+
@lock = Mutex.new
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
# @param root [String]
|
|
80
|
+
# @return [OpenLanes::Execution::Boundary::Snapshot]
|
|
81
|
+
def snapshot(root)
|
|
82
|
+
Snapshot.new(head: head(root), remotes: remotes(root), reflogs: reflogs(root).transform_values(&:size))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Records a push the harness itself is about to make, so an agent
|
|
86
|
+
# running in another worktree of the same repository is not blamed
|
|
87
|
+
# for it. Worktrees share `refs/remotes`, and the publisher pushes one
|
|
88
|
+
# plan's branch while other agents are still working theirs.
|
|
89
|
+
#
|
|
90
|
+
# @param path [String] the checkout being pushed
|
|
91
|
+
# @param remote [String]
|
|
92
|
+
# @param branch [String]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def sanction(path, remote, branch)
|
|
95
|
+
sha = rev_parse(path, "refs/heads/#{branch}")
|
|
96
|
+
return if sha.nil?
|
|
97
|
+
|
|
98
|
+
@lock.synchronize { @sanctioned["refs/remotes/#{remote}/#{branch}"] << sha }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param ref [String]
|
|
102
|
+
# @param sha [String]
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def sanctioned?(ref, sha) = @lock.synchronize { @sanctioned.fetch(ref, []).include?(sha) }
|
|
105
|
+
|
|
106
|
+
# For specs: forget every sanctioned push.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def reset! = @lock.synchronize { @sanctioned.clear }
|
|
110
|
+
|
|
111
|
+
# @param root [String]
|
|
112
|
+
# @return [String, nil] current commit, or nil outside a repository
|
|
113
|
+
def head(root) = rev_parse(root, "HEAD")
|
|
114
|
+
|
|
115
|
+
# The symbolic `refs/remotes/<remote>/HEAD` is left out: it moves with
|
|
116
|
+
# `git remote set-head`, which pushes nothing.
|
|
117
|
+
#
|
|
118
|
+
# @param root [String]
|
|
119
|
+
# @return [Hash{String => String}]
|
|
120
|
+
def remotes(root)
|
|
121
|
+
out = `git -C #{root.shellescape} for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null`
|
|
122
|
+
out.lines.map(&:split).select { |pair| pair.size == 2 }.reject { |ref, _| ref.end_with?("/HEAD") }.to_h
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param before [OpenLanes::Execution::Boundary::Snapshot]
|
|
126
|
+
# @param root [String]
|
|
127
|
+
# @return [Array<String>] tracking refs a push moved since the snapshot
|
|
128
|
+
def pushed(before, root)
|
|
129
|
+
logs = elsewhere = nil
|
|
130
|
+
remotes(root).filter_map do |ref, sha|
|
|
131
|
+
next if before.remotes[ref] == sha || sanctioned?(ref, sha)
|
|
132
|
+
next if pushed_elsewhere?(root, sha, elsewhere ||= other_heads(root))
|
|
133
|
+
|
|
134
|
+
logs ||= reflogs(root)
|
|
135
|
+
entries = logs.fetch(ref, [])
|
|
136
|
+
added = entries.first([entries.size - before.reflogs.fetch(ref, 0), 0].max)
|
|
137
|
+
ref if added.empty? || added.any? { |reason| reason.start_with?(PUSHED) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether a pushed commit came from another worktree: it is reachable
|
|
142
|
+
# from that worktree's HEAD (not merely equal to it, so a sibling that
|
|
143
|
+
# pushed and then committed again before this check is still
|
|
144
|
+
# recognised) and not reachable from this checkout's own HEAD. The
|
|
145
|
+
# second half matters because an agent's HEAD is unmoved, so
|
|
146
|
+
# everything it could push is an ancestor of it; without the
|
|
147
|
+
# exclusion a sibling branched from the same commit would exempt the
|
|
148
|
+
# agent's own base commit.
|
|
149
|
+
#
|
|
150
|
+
# @param root [String]
|
|
151
|
+
# @param sha [String]
|
|
152
|
+
# @param heads [Set<String>] from {#other_heads}
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def pushed_elsewhere?(root, sha, heads)
|
|
155
|
+
return false if ancestor?(root, sha, "HEAD")
|
|
156
|
+
|
|
157
|
+
heads.any? { |head| ancestor?(root, sha, head) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The commit checked out in every worktree of the repository but
|
|
161
|
+
# +root+'s own: what a push from one of them would carry.
|
|
162
|
+
#
|
|
163
|
+
# @param root [String]
|
|
164
|
+
# @return [Set<String>]
|
|
165
|
+
def other_heads(root)
|
|
166
|
+
own = real(rev_parse_path(root))
|
|
167
|
+
out = `git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
|
|
168
|
+
out.split("\n\n").each_with_object(Set.new) do |block, heads|
|
|
169
|
+
fields = block.lines.to_h { |line| line.chomp.split(" ", 2) }
|
|
170
|
+
heads << fields["HEAD"] if fields["HEAD"] && real(fields["worktree"]) != own
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# @return [Boolean] whether +sha+ is reachable from +rev+ (or is it)
|
|
177
|
+
def ancestor?(root, sha, rev) = system("git", "-C", root, "merge-base", "--is-ancestor", sha, rev, err: File::NULL)
|
|
178
|
+
|
|
179
|
+
# @return [String, nil] the checkout's top level
|
|
180
|
+
def rev_parse_path(root)
|
|
181
|
+
out = `git -C #{root.shellescape} rev-parse --show-toplevel 2>/dev/null`.strip
|
|
182
|
+
out.empty? ? root : out
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [String, nil] the path with symlinks resolved, as git prints it
|
|
186
|
+
def real(path)
|
|
187
|
+
File.realpath(path.to_s)
|
|
188
|
+
rescue SystemCallError
|
|
189
|
+
path
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [String, nil]
|
|
193
|
+
def rev_parse(root, rev)
|
|
194
|
+
out = `git -C #{root.shellescape} rev-parse --verify --quiet #{rev.shellescape} 2>/dev/null`.strip
|
|
195
|
+
out.empty? ? nil : out
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Every remote-tracking ref's reflog, in one call.
|
|
199
|
+
#
|
|
200
|
+
# @return [Hash{String => Array<String>}] ref to why it moved, newest first
|
|
201
|
+
def reflogs(root)
|
|
202
|
+
out = `git -C #{root.shellescape} log --walk-reflogs --glob=refs/remotes --format=%gD%x09%gs 2>/dev/null`
|
|
203
|
+
out.lines.each_with_object(Hash.new { |hash, ref| hash[ref] = [] }) do |line, logs|
|
|
204
|
+
selector, reason = line.chomp.split("\t", 2)
|
|
205
|
+
ref, index = selector.to_s.match(/\A(.+)@\{(\d+)\}\z/)&.captures
|
|
206
|
+
logs[ref][index.to_i] = reason.to_s if ref
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -44,12 +44,14 @@ module OpenLanes
|
|
|
44
44
|
# @param chdir [String, nil]
|
|
45
45
|
# @param env [Hash{String => String}] what the adapter adds on top of
|
|
46
46
|
# {.environment}, e.g. claude's autocompact threshold
|
|
47
|
+
# @param spawner [#spawn] {OpenLanes::Spawner::System} unless a spec
|
|
48
|
+
# starts nothing
|
|
47
49
|
# @return [OpenLanes::Execution::Child]
|
|
48
|
-
def spawn(argv, chdir: nil, env: {})
|
|
50
|
+
def spawn(argv, chdir: nil, env: {}, spawner: Spawner::System)
|
|
49
51
|
reader, writer = IO.pipe
|
|
50
52
|
options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true, pgroup: true }
|
|
51
53
|
options[:chdir] = chdir if chdir
|
|
52
|
-
pid =
|
|
54
|
+
pid = spawner.spawn(environment.merge(env), *argv, **options)
|
|
53
55
|
writer.close
|
|
54
56
|
track(new(pid:, reader:))
|
|
55
57
|
end
|
|
@@ -47,6 +47,8 @@ module OpenLanes
|
|
|
47
47
|
# @return [void]
|
|
48
48
|
def tick
|
|
49
49
|
@mutex.synchronize do
|
|
50
|
+
next if paused
|
|
51
|
+
|
|
50
52
|
left = @deadline - @now.call
|
|
51
53
|
WARNINGS.each do |before, text|
|
|
52
54
|
next if @fired.include?(before) || left > before
|
|
@@ -89,26 +91,54 @@ module OpenLanes
|
|
|
89
91
|
#
|
|
90
92
|
# @return [void]
|
|
91
93
|
def stop
|
|
92
|
-
thread = @mutex.synchronize
|
|
94
|
+
thread = @mutex.synchronize do
|
|
95
|
+
@closed = true
|
|
96
|
+
@thread.tap { @thread = nil }
|
|
97
|
+
end
|
|
93
98
|
thread&.kill
|
|
94
99
|
end
|
|
95
100
|
|
|
96
|
-
# Ten more minutes, or however many. The file
|
|
97
|
-
#
|
|
101
|
+
# Ten more minutes, or however many. The file tells the agent how long
|
|
102
|
+
# it now has, so one told "5 minutes left" does not wrap up on the old
|
|
103
|
+
# deadline, and every moment is re-armed against the new one.
|
|
104
|
+
#
|
|
105
|
+
# Refused once STOP is out or the clock is stopped: that agent is
|
|
106
|
+
# exiting, and time given to it would reach nobody.
|
|
98
107
|
#
|
|
99
108
|
# @param seconds [Integer]
|
|
100
|
-
# @return [
|
|
109
|
+
# @return [Boolean] whether the time reached a clock still running
|
|
101
110
|
def extend!(seconds)
|
|
102
111
|
@mutex.synchronize do
|
|
112
|
+
next false if @closed || %i[stopped expired].include?(@phase)
|
|
113
|
+
|
|
103
114
|
@deadline += seconds
|
|
104
|
-
@fired =
|
|
115
|
+
@fired = WARNINGS.map(&:first).select { |before| before >= @deadline - @now.call }
|
|
105
116
|
@phase = :calm
|
|
106
|
-
Control.write(@control, "")
|
|
117
|
+
Control.write(@control, "#{Control::WARN}: #{((@deadline - @now.call) / 60.0).round} minutes left, time was extended")
|
|
118
|
+
true
|
|
107
119
|
end
|
|
108
120
|
end
|
|
109
121
|
|
|
110
122
|
private
|
|
111
123
|
|
|
124
|
+
# While agents are paused (W32) the clock stands still: no warning
|
|
125
|
+
# overwrites the PAUSE in the agent's file, and on resume the deadline
|
|
126
|
+
# moves on by as long as the pause lasted, so a paused agent is never
|
|
127
|
+
# stopped for time it spent waiting.
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean] whether this tick is skipped
|
|
130
|
+
def paused
|
|
131
|
+
if Control.paused?
|
|
132
|
+
@paused_at ||= @now.call
|
|
133
|
+
return true
|
|
134
|
+
end
|
|
135
|
+
if @paused_at
|
|
136
|
+
@deadline += @now.call - @paused_at
|
|
137
|
+
@paused_at = nil
|
|
138
|
+
end
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
112
142
|
# @param seconds [Integer]
|
|
113
143
|
# @return [void]
|
|
114
144
|
def arm(seconds)
|