openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "semantic_logger"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
# The one place a process sets SemanticLogger up, so the engine and the
|
|
8
|
+
# control room's server write the same file the same way.
|
|
9
|
+
#
|
|
10
|
+
# `openlanes run` and `openlanes start` log as `engine`; the server they
|
|
11
|
+
# spawn, and `openlanes web` alone, log as `web`. Both append to
|
|
12
|
+
# `<project root>/log/development.log` unless told otherwise, and the
|
|
13
|
+
# engine hands its server the file, level and format through {Settings#env},
|
|
14
|
+
# so a `--log` typed on `run` moves both processes rather than one.
|
|
15
|
+
#
|
|
16
|
+
# Two processes share one file safely because each opens it in append mode
|
|
17
|
+
# and writes a whole line per call: the kernel puts every O_APPEND write at
|
|
18
|
+
# the end, so lines from the two interleave but never tear. Each line carries
|
|
19
|
+
# its process id, which is how a reader tells them apart.
|
|
20
|
+
#
|
|
21
|
+
# Logging is synchronous. SemanticLogger's default background thread keeps
|
|
22
|
+
# its queue in memory, and a CLI that exits, is killed by its own run, or is
|
|
23
|
+
# TERMed by {Web::Launcher#stop} would take the last lines with it.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# Log.setup(Log::Settings.from(options, root:), name: "engine")
|
|
27
|
+
# Log.logger.info("started", plan: "003.00")
|
|
28
|
+
module Log
|
|
29
|
+
# Where both processes log unless `--log` says otherwise, relative to the
|
|
30
|
+
# project root.
|
|
31
|
+
DEFAULT_FILE = File.join("log", "development.log")
|
|
32
|
+
|
|
33
|
+
# `--log -`: the log goes to STDOUT instead of a file.
|
|
34
|
+
STDOUT_FILE = "-"
|
|
35
|
+
|
|
36
|
+
# Lowest first. Anything below the chosen level is not written.
|
|
37
|
+
LEVELS = %w[debug info warn error fatal].freeze
|
|
38
|
+
|
|
39
|
+
# The level a run logs at when nothing says otherwise.
|
|
40
|
+
DEFAULT_LEVEL = "info"
|
|
41
|
+
|
|
42
|
+
# SemanticLogger's colour formatter kept to one line per entry, as
|
|
43
|
+
# `standard` is: the stock one writes an exception's backtrace over many
|
|
44
|
+
# lines, which `tail -F` in the mprocs `logs` pane would scatter between
|
|
45
|
+
# the other process's lines.
|
|
46
|
+
class OneLineColor < SemanticLogger::Formatters::Color
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def message = ("-- #{log.message.delete("\n")}" if log.message)
|
|
49
|
+
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
def exception
|
|
52
|
+
return unless log.exception
|
|
53
|
+
|
|
54
|
+
"-- Exception: #{color}#{log.exception.class}: #{log.exception.message.delete("\n")}#{color_map.clear}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `--log-format` to SemanticLogger's formatter. `standard` is the one-line
|
|
59
|
+
# formatter rather than the default one, which writes a stack trace over
|
|
60
|
+
# many lines and breaks "one line per entry"; `color` is the same line in
|
|
61
|
+
# colour, by level, for a person reading the file with `tail -F` or `less -R`.
|
|
62
|
+
FORMATS = { "color" => OneLineColor, "standard" => :one_line, "json" => :json }.freeze
|
|
63
|
+
|
|
64
|
+
# The format a run logs in when nothing says otherwise. Plain, so a log
|
|
65
|
+
# written in CI or by any project using the gem greps without first
|
|
66
|
+
# stripping escape codes; `just start` asks for `--log-format color`.
|
|
67
|
+
DEFAULT_FORMAT = "standard"
|
|
68
|
+
|
|
69
|
+
# How the engine tells the server it spawns where to log. The environment
|
|
70
|
+
# rather than argv, for the same reason as the Redis URL: the server's
|
|
71
|
+
# command line stays the same whatever the run was told.
|
|
72
|
+
ENV_FILE = "OPENLANES_LOG"
|
|
73
|
+
ENV_LEVEL = "OPENLANES_LOG_LEVEL"
|
|
74
|
+
ENV_FORMAT = "OPENLANES_LOG_FORMAT"
|
|
75
|
+
|
|
76
|
+
# The password in a URL's userinfo, wherever the URL sits in a line: a
|
|
77
|
+
# git or Redis error, or an agent's failure reason, can quote a remote
|
|
78
|
+
# with its credentials, and the log outlives the run. The same rule as
|
|
79
|
+
# {Live.mask}, which only looks at a string that is one URL.
|
|
80
|
+
CREDENTIALS = %r{([a-z][a-z0-9+.-]*://[^:@/\s]*):[^@/\s]+@}i
|
|
81
|
+
|
|
82
|
+
# The logger a process uses when {.setup} was never told its name.
|
|
83
|
+
DEFAULT_NAME = "openlanes"
|
|
84
|
+
|
|
85
|
+
# A level or format nobody knows. Raised before anything is opened, so a
|
|
86
|
+
# typo is a refusal rather than a run that silently logs at the default.
|
|
87
|
+
class Invalid < ArgumentError; end
|
|
88
|
+
|
|
89
|
+
# Where a process logs, at what level, in which format.
|
|
90
|
+
#
|
|
91
|
+
# @!attribute [r] file
|
|
92
|
+
# @return [String] an absolute path, or {STDOUT_FILE}
|
|
93
|
+
# @!attribute [r] level
|
|
94
|
+
# @return [String] one of {LEVELS}
|
|
95
|
+
# @!attribute [r] format
|
|
96
|
+
# @return [String] one of the keys of {FORMATS}
|
|
97
|
+
Settings = Data.define(:file, :level, :format) do
|
|
98
|
+
# Flags first, then the environment the engine handed its server, then
|
|
99
|
+
# the defaults. A relative file is taken from the project root, not the
|
|
100
|
+
# directory the command was typed in, so `log/development.log` is the
|
|
101
|
+
# same file for a run typed three directories down.
|
|
102
|
+
#
|
|
103
|
+
# @param options [Hash] the command's options: `:log`, `:log_level`, `:log_format`
|
|
104
|
+
# @param root [String] the project root
|
|
105
|
+
# @param env [Hash]
|
|
106
|
+
# @return [Settings]
|
|
107
|
+
# @raise [Invalid] for a level or format that is not one of the known ones
|
|
108
|
+
def self.from(options = {}, root:, env: ENV)
|
|
109
|
+
pick = ->(key, name) { [options[key], env[name]].map { it.to_s.strip }.find { !it.empty? } }
|
|
110
|
+
file = pick.call(:log, ENV_FILE) || DEFAULT_FILE
|
|
111
|
+
level = (pick.call(:log_level, ENV_LEVEL) || DEFAULT_LEVEL).downcase
|
|
112
|
+
format = (pick.call(:log_format, ENV_FORMAT) || DEFAULT_FORMAT).downcase
|
|
113
|
+
raise Invalid, "--log-level #{level}: use one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
|
|
114
|
+
raise Invalid, "--log-format #{format}: use one of #{FORMATS.keys.join(", ")}" unless FORMATS.key?(format)
|
|
115
|
+
|
|
116
|
+
file = File.expand_path(file, root) unless file == STDOUT_FILE
|
|
117
|
+
new(file:, level:, format:)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Boolean] whether the log goes to STDOUT rather than a file
|
|
121
|
+
def stdout? = file == STDOUT_FILE
|
|
122
|
+
|
|
123
|
+
# @return [Hash{String => String}] for a child process's environment
|
|
124
|
+
def env = { ENV_FILE => file, ENV_LEVEL => level, ENV_FORMAT => format }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class << self
|
|
128
|
+
# The settings {.setup} was last given, nil before then.
|
|
129
|
+
#
|
|
130
|
+
# @return [Settings, nil]
|
|
131
|
+
attr_reader :settings
|
|
132
|
+
|
|
133
|
+
# Point SemanticLogger at the one file (or STDOUT), replacing whatever
|
|
134
|
+
# it wrote to before, so calling this twice in one process (the suite
|
|
135
|
+
# does) never leaves two appenders writing every line twice.
|
|
136
|
+
#
|
|
137
|
+
# @param settings [Settings]
|
|
138
|
+
# @param name [String] what this process's lines are signed with,
|
|
139
|
+
# `engine` or `web`
|
|
140
|
+
# @return [Settings]
|
|
141
|
+
def setup(settings, name: DEFAULT_NAME)
|
|
142
|
+
SemanticLogger.sync!
|
|
143
|
+
SemanticLogger.clear_appenders!
|
|
144
|
+
SemanticLogger.application = "openlanes"
|
|
145
|
+
SemanticLogger.default_level = settings.level.to_sym
|
|
146
|
+
formatter = FORMATS.fetch(settings.format)
|
|
147
|
+
formatter = formatter.new if formatter.is_a?(Class)
|
|
148
|
+
if settings.stdout?
|
|
149
|
+
SemanticLogger.add_appender(io: $stdout, formatter:)
|
|
150
|
+
else
|
|
151
|
+
FileUtils.mkdir_p(File.dirname(settings.file))
|
|
152
|
+
SemanticLogger.add_appender(file_name: settings.file, formatter:)
|
|
153
|
+
end
|
|
154
|
+
@name = name
|
|
155
|
+
@settings = settings
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Boolean] whether {.setup} has run in this process
|
|
159
|
+
def configured? = !@settings.nil?
|
|
160
|
+
|
|
161
|
+
# The file lines go to, or nil when they go nowhere or to STDOUT.
|
|
162
|
+
#
|
|
163
|
+
# @return [String, nil]
|
|
164
|
+
def path = settings&.then { it.stdout? ? nil : it.file }
|
|
165
|
+
|
|
166
|
+
# @param name [String, nil] another name than this process's own
|
|
167
|
+
# @return [SemanticLogger::Logger]
|
|
168
|
+
def logger(name = nil) = SemanticLogger[name || @name || DEFAULT_NAME]
|
|
169
|
+
|
|
170
|
+
# One {UI.log} line, at +level+, with its fields as the payload. A
|
|
171
|
+
# no-op until {.setup} has run: commands that never log (`list-plans`,
|
|
172
|
+
# `docs`) still call code that reports through {UI.log}.
|
|
173
|
+
#
|
|
174
|
+
# @param message [String]
|
|
175
|
+
# @param level [Symbol] one of {LEVELS}
|
|
176
|
+
# @param fields [Hash] plan, status, agent, round, seconds and the like
|
|
177
|
+
# @return [void]
|
|
178
|
+
def event(message, level: :info, **fields)
|
|
179
|
+
return unless configured?
|
|
180
|
+
|
|
181
|
+
payload = fields.compact.transform_values do |value|
|
|
182
|
+
case value
|
|
183
|
+
when Float then value.round(1)
|
|
184
|
+
when String then redact(value)
|
|
185
|
+
else value
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
logger.public_send(level, redact(message.to_s), payload.empty? ? nil : payload)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param text [String]
|
|
192
|
+
# @return [String] +text+ with every URL password replaced by `***`
|
|
193
|
+
def redact(text) = text.gsub(CREDENTIALS, '\1:***@')
|
|
194
|
+
|
|
195
|
+
# Forget the setup and stop writing. For the suite, which sets logging
|
|
196
|
+
# up in a temp directory and must not leave an appender writing there.
|
|
197
|
+
#
|
|
198
|
+
# @return [void]
|
|
199
|
+
def reset!
|
|
200
|
+
SemanticLogger.clear_appenders!
|
|
201
|
+
@settings = nil
|
|
202
|
+
@name = nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# Why a workflow would not do what it was asked, and the exit status that
|
|
5
|
+
# says so. Every command workflow fails with one of these inside a
|
|
6
|
+
# `Dry::Monads::Failure`, so the command that called it refuses the same
|
|
7
|
+
# way whichever workflow said no. The statuses follow sysexits, as
|
|
8
|
+
# {CLI::Base#refuse} documents.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] message
|
|
11
|
+
# @return [String] what the person reads in the error box
|
|
12
|
+
# @!attribute [r] status
|
|
13
|
+
# @return [Integer] the exit status
|
|
14
|
+
Refusal = Data.define(:message, :status)
|
|
15
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
# Where a project's runtime files live: outside `.plans/`, under the XDG
|
|
8
|
+
# state directory. A lock or a run record inside `.plans/` sat beside
|
|
9
|
+
# committed documents, so every `git status` and every PR snapshot had to
|
|
10
|
+
# learn to ignore it, and a rename of a locked folder moved the lock with it.
|
|
11
|
+
#
|
|
12
|
+
# Keyed by the project root (the directory that holds `.plans/`), so two
|
|
13
|
+
# checkouts never share a state directory, and the harness and an agent
|
|
14
|
+
# handed the same `--dir` address the same one.
|
|
15
|
+
module Runtime
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param env [Hash] the environment to read
|
|
19
|
+
# @return [String] absolute `XDG_STATE_HOME`, else `~/.local/state`. An
|
|
20
|
+
# empty or relative value is ignored, as the XDG spec says.
|
|
21
|
+
def state_home(env = ENV)
|
|
22
|
+
value = env["XDG_STATE_HOME"].to_s
|
|
23
|
+
return value if !value.empty? && value.start_with?("/")
|
|
24
|
+
|
|
25
|
+
File.join(Dir.home, ".local", "state")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param root [String] the directory that holds `.plans/`
|
|
29
|
+
# @return [String] `<state_home>/openlanes/<basename>-<6 hex>`, created on demand
|
|
30
|
+
def dir(root:)
|
|
31
|
+
real = begin
|
|
32
|
+
File.realpath(root)
|
|
33
|
+
rescue Errno::ENOENT
|
|
34
|
+
File.expand_path(root)
|
|
35
|
+
end
|
|
36
|
+
path = File.join(state_home, "openlanes", "#{File.basename(real)}-#{Digest::SHA256.hexdigest(real)[0, 6]}")
|
|
37
|
+
FileUtils.mkdir_p(path)
|
|
38
|
+
path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param root [String]
|
|
42
|
+
# @return [String] the directory plan locks are written to
|
|
43
|
+
def locks_dir(root:)
|
|
44
|
+
path = File.join(dir(root:), "locks")
|
|
45
|
+
FileUtils.mkdir_p(path)
|
|
46
|
+
path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param root [String]
|
|
50
|
+
# @return [String] the directory each run's {Engine::EventJournal} is appended to
|
|
51
|
+
def journal_dir(root:)
|
|
52
|
+
path = File.join(dir(root:), "journal")
|
|
53
|
+
FileUtils.mkdir_p(path)
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param root [String]
|
|
58
|
+
# @return [String] the run's {Engine::StateFile}
|
|
59
|
+
def state_file(root:) = File.join(dir(root:), "openlanes-state.json")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# The one place a child process is started, so a spec can start none.
|
|
5
|
+
#
|
|
6
|
+
# Named `Spawner`, not `Process`: Zeitwerk would resolve an
|
|
7
|
+
# `OpenLanes::Process` ahead of `::Process` at every bare `Process.kill`
|
|
8
|
+
# and `Process.wait` under `lib/openlanes`.
|
|
9
|
+
module Spawner
|
|
10
|
+
# Starts real processes.
|
|
11
|
+
module System
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param env [Hash{String => String}]
|
|
15
|
+
# @param argv [Array<String>]
|
|
16
|
+
# @param options [Hash] as `Process.spawn` takes them
|
|
17
|
+
# @return [Integer] the pid
|
|
18
|
+
def spawn(env, *argv, **) = ::Process.spawn(env, *argv, **)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Records what would have been started and hands back scripted pids.
|
|
22
|
+
class Fake
|
|
23
|
+
# One recorded call.
|
|
24
|
+
Call = Data.define(:env, :argv, :options)
|
|
25
|
+
|
|
26
|
+
# @return [Array<Call>]
|
|
27
|
+
def calls = @mutex.synchronize { @calls.dup }
|
|
28
|
+
|
|
29
|
+
# @param pids [Array<Integer>] handed out in order; a counter from
|
|
30
|
+
# `first_pid` once they run out
|
|
31
|
+
# @param first_pid [Integer]
|
|
32
|
+
def initialize(pids: [], first_pid: 90_000)
|
|
33
|
+
@pids = pids.dup
|
|
34
|
+
@next = first_pid
|
|
35
|
+
@calls = []
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param env [Hash{String => String}]
|
|
40
|
+
# @param argv [Array<String>]
|
|
41
|
+
# @param options [Hash]
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def spawn(env, *argv, **options)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@calls << Call.new(env:, argv:, options:)
|
|
46
|
+
@pids.shift || (@next += 1)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/openlanes/support/ui.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "concurrent/hash"
|
|
4
4
|
require "etc"
|
|
5
|
-
require "fileutils"
|
|
6
5
|
require "dry/cli/ui"
|
|
7
6
|
require "pastel"
|
|
8
7
|
require "stringio"
|
|
@@ -97,8 +96,9 @@ module OpenLanes
|
|
|
97
96
|
def alive = "#{seconds.round}s"
|
|
98
97
|
|
|
99
98
|
# @param message [String]
|
|
99
|
+
# @param level [Symbol] the entry's level in the log
|
|
100
100
|
# @return [void]
|
|
101
|
-
def note(message) = UI.log(message, **@fields, seconds:)
|
|
101
|
+
def note(message, level: :info) = UI.log(message, level:, **@fields, seconds:)
|
|
102
102
|
|
|
103
103
|
# @return [void]
|
|
104
104
|
def start
|
|
@@ -182,7 +182,7 @@ module OpenLanes
|
|
|
182
182
|
def failed(reason)
|
|
183
183
|
stop_ticker
|
|
184
184
|
@handle&.fail(reason)
|
|
185
|
-
note("failed after #{alive}: #{reason}")
|
|
185
|
+
note("failed after #{alive}: #{reason}", level: :warn)
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
# @param update [OpenLanes::Execution::Transcript::Progress]
|
|
@@ -219,6 +219,10 @@ module OpenLanes
|
|
|
219
219
|
end
|
|
220
220
|
end
|
|
221
221
|
|
|
222
|
+
# Serialises {.report_line} across worker threads. Created here, once:
|
|
223
|
+
# a lazy `||=` let two threads that arrived together each make their own.
|
|
224
|
+
@print_mutex = Mutex.new
|
|
225
|
+
|
|
222
226
|
class << self
|
|
223
227
|
# Set by the CLI's --quiet. Silences spinners and bars along with
|
|
224
228
|
# everything else, so a quiet run really is quiet.
|
|
@@ -240,16 +244,16 @@ module OpenLanes
|
|
|
240
244
|
def reset!
|
|
241
245
|
remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel)
|
|
242
246
|
self.quiet = false
|
|
243
|
-
self.log_path = nil
|
|
244
247
|
end
|
|
245
248
|
|
|
246
|
-
# Where {.log}
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
249
|
+
# Where {.log} writes, if anywhere: the file {Log.setup} was given
|
|
250
|
+
# (`-` for STDOUT), nil before then. `openlanes run` sets logging up
|
|
251
|
+
# before the loop starts, so a round started under a tool that discards
|
|
252
|
+
# STDERR (an agent's own Bash call, for instance) still leaves
|
|
253
|
+
# something to `tail -f`.
|
|
250
254
|
#
|
|
251
255
|
# @return [String, nil]
|
|
252
|
-
|
|
256
|
+
def log_path = Log.settings&.file
|
|
253
257
|
|
|
254
258
|
# Something else that hears every {.log} line, as it is logged: the
|
|
255
259
|
# live board keeps the latest few for the page. Taps are called on the
|
|
@@ -271,20 +275,18 @@ module OpenLanes
|
|
|
271
275
|
# @return [Array<#call>]
|
|
272
276
|
def log_taps = @log_taps || []
|
|
273
277
|
|
|
274
|
-
#
|
|
275
|
-
#
|
|
278
|
+
# Tell every tap, then write one entry to the log {Log.setup} opened,
|
|
279
|
+
# with the fields as its payload. The file is in addition to what the
|
|
280
|
+
# taps draw, never instead of it. Safe to call from several threads at once.
|
|
276
281
|
#
|
|
277
282
|
# @param message [String]
|
|
283
|
+
# @param level [Symbol] the entry's level in the log; taps hear every
|
|
284
|
+
# level alike, since the terminal has no levels to filter by
|
|
285
|
+
# @param fields [Hash] plan, status, agent, round, seconds, to
|
|
278
286
|
# @return [void]
|
|
279
|
-
def log(message, **fields)
|
|
287
|
+
def log(message, level: :info, **fields)
|
|
280
288
|
log_taps.each { |tap| tap.call(message, fields) }
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
line = Engine::ProgressLog.render(message, **fields)
|
|
284
|
-
(@log_mutex ||= Mutex.new).synchronize do
|
|
285
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
286
|
-
File.open(path, "a") { |f| f.puts(line) }
|
|
287
|
-
end
|
|
289
|
+
Log.event(message, level:, **fields)
|
|
288
290
|
end
|
|
289
291
|
|
|
290
292
|
# Cells the meter takes on a spinner line, per direction.
|
|
@@ -549,7 +551,7 @@ module OpenLanes
|
|
|
549
551
|
def report_line(text, bullet: "·")
|
|
550
552
|
return if quiet
|
|
551
553
|
|
|
552
|
-
|
|
554
|
+
@print_mutex.synchronize { line(text, bullet:) }
|
|
553
555
|
end
|
|
554
556
|
|
|
555
557
|
# A sensible worker count: agents are mostly waiting on a model rather
|
|
@@ -573,9 +575,25 @@ module OpenLanes
|
|
|
573
575
|
# @return [String] decorated when colour is on, bare otherwise
|
|
574
576
|
def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s
|
|
575
577
|
|
|
578
|
+
# The emoji variation selector U+FE0F, or a zero-width joiner U+200D.
|
|
579
|
+
EMOJI_SEQUENCE = /[\u{FE0F}\u{200D}]/
|
|
580
|
+
|
|
581
|
+
# `unicode-display_width` counts 🅱️, ☢️ and 🕰️ as one cell, while
|
|
582
|
+
# terminals with Unicode 9 widths draw them two, and every padded column
|
|
583
|
+
# came out one cell short (decision N2). A character the text explicitly
|
|
584
|
+
# asks to see as an emoji is two cells here, whatever the gem says.
|
|
585
|
+
# Measured per grapheme, so a ZWJ sequence such as 🏳️🌈, which the gem
|
|
586
|
+
# counts as three and which carries its selector mid-sequence, is the
|
|
587
|
+
# two cells a terminal draws, not four.
|
|
588
|
+
#
|
|
576
589
|
# @param text [String]
|
|
577
590
|
# @return [Integer] how many terminal cells the text occupies
|
|
578
|
-
def display_width(text)
|
|
591
|
+
def display_width(text)
|
|
592
|
+
text = text.to_s
|
|
593
|
+
return ::Unicode::DisplayWidth.of(text) unless text.match?(EMOJI_SEQUENCE)
|
|
594
|
+
|
|
595
|
+
text.each_grapheme_cluster.sum { it.match?(EMOJI_SEQUENCE) ? 2 : ::Unicode::DisplayWidth.of(it) }
|
|
596
|
+
end
|
|
579
597
|
|
|
580
598
|
# Pad or truncate to an exact number of terminal *cells*.
|
|
581
599
|
#
|
|
@@ -612,6 +630,77 @@ module OpenLanes
|
|
|
612
630
|
# @return [void]
|
|
613
631
|
def box(kind, message) = console.public_send(kind, message.to_s)
|
|
614
632
|
|
|
633
|
+
# Labelled lines for a box, the labels padded to one width:
|
|
634
|
+
#
|
|
635
|
+
# Progress: /var/folders/…/openlanes-minesweeper.log
|
|
636
|
+
# Live board: redis://host:6379 as kig@host
|
|
637
|
+
# (c016…:734c…)
|
|
638
|
+
#
|
|
639
|
+
# A value given as several parts is joined with spaces while it fits
|
|
640
|
+
# the box, and each part that does not starts a continuation line
|
|
641
|
+
# under the values, so a long id breaks where it was meant to rather
|
|
642
|
+
# than wherever the box's own wrap runs out of room.
|
|
643
|
+
#
|
|
644
|
+
# @param rows [Array<Array(String, String | Array<String>)>] label and value
|
|
645
|
+
# @param room [Integer] text columns inside the box
|
|
646
|
+
# @return [String]
|
|
647
|
+
def labelled(rows, room: box_room)
|
|
648
|
+
pad = rows.map { |label, _| display_width("#{label}:") }.max.to_i + 1
|
|
649
|
+
free = [room - pad, 1].max
|
|
650
|
+
rows.flat_map { |label, parts|
|
|
651
|
+
lines = Array(parts).map(&:to_s).each_with_object([]) do |part, acc|
|
|
652
|
+
if acc.any? && display_width("#{acc.last} #{part}") <= free
|
|
653
|
+
acc[-1] = "#{acc.last} #{part}"
|
|
654
|
+
else
|
|
655
|
+
acc.concat(broken(part, free))
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
lines.each_with_index.map { |text, i| (i.zero? ? fit("#{label}:", pad) : " " * pad) + text }
|
|
659
|
+
}.join("\n")
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# A value wider than its column, cut into pieces that fit: after a
|
|
663
|
+
# `/` where it can be, so a path breaks between directories, and
|
|
664
|
+
# anywhere when one name is wider than the column on its own. Left
|
|
665
|
+
# to the box, a path wider than the room after its label wrapped
|
|
666
|
+
# onto the next line whole, under the label rather than beside it,
|
|
667
|
+
# and was then cut mid-name with no indent.
|
|
668
|
+
#
|
|
669
|
+
# @param text [String]
|
|
670
|
+
# @param free [Integer] columns
|
|
671
|
+
# @return [Array<String>] the pieces, each at most +free+ wide
|
|
672
|
+
def broken(text, free)
|
|
673
|
+
return [text] if display_width(text) <= free
|
|
674
|
+
|
|
675
|
+
text.split(%r{(?<=/)}).flat_map { cells(it, free) }.each_with_object([+""]) do |piece, acc|
|
|
676
|
+
acc.last.empty? || display_width(acc.last + piece) <= free ? acc.last << piece : acc << piece.dup
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# A piece cut by terminal cells rather than characters, one grapheme
|
|
681
|
+
# cluster at a time, so a wide name such as `界界界` is never cut into
|
|
682
|
+
# a chunk wider than +free+ (nor a combining mark off its letter).
|
|
683
|
+
#
|
|
684
|
+
# @param text [String]
|
|
685
|
+
# @param free [Integer] columns
|
|
686
|
+
# @return [Array<String>]
|
|
687
|
+
def cells(text, free)
|
|
688
|
+
text.each_grapheme_cluster.with_object([+""]) do |grapheme, acc|
|
|
689
|
+
acc << +"" unless acc.last.empty? || display_width(acc.last + grapheme) <= free
|
|
690
|
+
acc.last << grapheme
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Text columns inside a box, as dry-cli-ui works them out: the box is
|
|
695
|
+
# {.width} or the terminal less two, whichever is narrower, and a
|
|
696
|
+
# border and two columns of padding go on either side.
|
|
697
|
+
#
|
|
698
|
+
# @return [Integer]
|
|
699
|
+
def box_room
|
|
700
|
+
terminal = tty? ? TTY::Screen.width : Dry::CLI::UI::Terminal::DEFAULT_WIDTH
|
|
701
|
+
[width, terminal - 2].min - 6
|
|
702
|
+
end
|
|
703
|
+
|
|
615
704
|
# A framed panel centered on the screen, for the keyboard help. Unlike
|
|
616
705
|
# {.box} it positions itself absolutely, so it overlays whatever the
|
|
617
706
|
# spinners are drawing rather than scrolling in below them — the next
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Vcs
|
|
5
|
+
# One word for a pull request's CI, from `gh pr checks` buckets (M3).
|
|
6
|
+
module CiGate
|
|
7
|
+
# Buckets that say nothing about the code: a skipped job is not a verdict.
|
|
8
|
+
NEUTRAL = %w[pass skipping].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# An unknown bucket counts as red (open question 5): a state `gh` grows
|
|
13
|
+
# later must not wave a passing label through.
|
|
14
|
+
#
|
|
15
|
+
# @param checks [Array<Hash>] `{name:, bucket:}`, as {GitHub#checks} returns
|
|
16
|
+
# @return [Symbol] `:green`, `:red`, `:pending`, or `:none` when no
|
|
17
|
+
# check ran
|
|
18
|
+
def verdict(checks)
|
|
19
|
+
buckets = checks.map { |c| c[:bucket].to_s }
|
|
20
|
+
# Every job skipped means CI did not run (a spent Actions budget can
|
|
21
|
+
# report it this way), which is the same as no checks, not a pass.
|
|
22
|
+
return :none if buckets.all?("skipping")
|
|
23
|
+
|
|
24
|
+
return :red if buckets.any? { |b| b != "pending" && !NEUTRAL.include?(b) }
|
|
25
|
+
return :pending if buckets.include?("pending")
|
|
26
|
+
|
|
27
|
+
:green
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|