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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# What the engine says once Redis is back: how long it was gone, how
|
|
9
|
+
# that was measured, and which copy of the run state won.
|
|
10
|
+
#
|
|
11
|
+
# The duration prefers this node's last heartbeat in Redis, because it
|
|
12
|
+
# is the last moment Redis provably had the engine's word. A heartbeat
|
|
13
|
+
# far older than the first failure is not that: it is what an RDB
|
|
14
|
+
# rollback restores, and measuring from it would overstate the outage by
|
|
15
|
+
# however long the snapshot was stale. Then the engine's own monotonic
|
|
16
|
+
# window is the answer, and `measured_by` says so.
|
|
17
|
+
class OutageReport
|
|
18
|
+
# Seconds a heartbeat may precede the first failure and still count.
|
|
19
|
+
# The publisher beats every tick, so a live one is a few seconds old.
|
|
20
|
+
FRESH = 15
|
|
21
|
+
|
|
22
|
+
# @param store [#hget]
|
|
23
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
24
|
+
# @param node [OpenLanes::Live::Node]
|
|
25
|
+
# @param window [OpenLanes::Live::Outage::Window]
|
|
26
|
+
# @param reconciled_from [Symbol, nil] `:redis`, `:file` or nil, from
|
|
27
|
+
# {RunRecord#reconcile}
|
|
28
|
+
def initialize(store:, keys:, node:, window:, reconciled_from:)
|
|
29
|
+
@store = store
|
|
30
|
+
@keys = keys
|
|
31
|
+
@node = node
|
|
32
|
+
@window = window
|
|
33
|
+
@reconciled_from = reconciled_from
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The `run.outage` wire shape (plan 022, shared with the page).
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def to_h
|
|
40
|
+
{ started_at: iso(started_at),
|
|
41
|
+
ended_at: iso(@window.ended_at),
|
|
42
|
+
seconds: seconds,
|
|
43
|
+
measured_by: measured_by,
|
|
44
|
+
reconciled_from: @reconciled_from&.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] one log line
|
|
48
|
+
def line
|
|
49
|
+
from = @reconciled_from ? "run state from #{@reconciled_from}" : "no run state to reconcile"
|
|
50
|
+
"Redis is back after #{seconds}s (by #{measured_by}), #{from}; resuming"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Integer]
|
|
54
|
+
def seconds
|
|
55
|
+
(heartbeat ? @window.ended_at - heartbeat : @window.seconds).round
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] `"heartbeat"` or `"clock"`
|
|
59
|
+
def measured_by = heartbeat ? "heartbeat" : "clock"
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @return [Time]
|
|
64
|
+
def started_at = heartbeat || @window.started_at
|
|
65
|
+
|
|
66
|
+
# This node's heartbeat, when it is from just before the first failure.
|
|
67
|
+
#
|
|
68
|
+
# @return [Time, nil]
|
|
69
|
+
def heartbeat
|
|
70
|
+
return @heartbeat if defined?(@heartbeat)
|
|
71
|
+
|
|
72
|
+
@heartbeat = read_heartbeat
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Time, nil]
|
|
76
|
+
def read_heartbeat
|
|
77
|
+
json = @store.hget(@keys.nodes, @node.name) or return nil
|
|
78
|
+
at = Time.iso8601(JSON.parse(json)["heartbeat_at"].to_s)
|
|
79
|
+
at if at <= @window.started_at && @window.started_at - at <= FRESH
|
|
80
|
+
rescue JSON::ParserError, ArgumentError, Unavailable
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param time [Time]
|
|
85
|
+
# @return [String]
|
|
86
|
+
def iso(time) = time.utc.iso8601
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -60,6 +60,12 @@ module OpenLanes
|
|
|
60
60
|
@on_lost = block
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# {OutageReport#to_h} of the outage this run last recovered from,
|
|
64
|
+
# published as `run.outage` from the next snapshot on.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash, nil]
|
|
67
|
+
attr_accessor :outage
|
|
68
|
+
|
|
63
69
|
# @return [OpenLanes::Live::Meter]
|
|
64
70
|
attr_reader :meter
|
|
65
71
|
|
|
@@ -77,11 +83,12 @@ module OpenLanes
|
|
|
77
83
|
@meter.record(board.rows, now:)
|
|
78
84
|
snapshot = build(board, now)
|
|
79
85
|
json = JSON.generate(snapshot)
|
|
80
|
-
@store.
|
|
86
|
+
EventBus.for(@store).transaction do |tx|
|
|
81
87
|
tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
|
|
82
|
-
tx.
|
|
88
|
+
tx.append(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
|
|
83
89
|
tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
|
|
84
90
|
end
|
|
91
|
+
nudge(snapshot)
|
|
85
92
|
lost = @claims&.renew || []
|
|
86
93
|
unless lost.empty?
|
|
87
94
|
UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
|
|
@@ -153,9 +160,42 @@ module OpenLanes
|
|
|
153
160
|
claims: @claims,
|
|
154
161
|
run_state: @run_state,
|
|
155
162
|
meter: @meter,
|
|
163
|
+
# The URL the engine publishes through, not one read back from the
|
|
164
|
+
# environment: the page's status bar says which Redis the claims and
|
|
165
|
+
# run state really live in, and only the store that wrote them knows.
|
|
166
|
+
redis_url: @store.url,
|
|
167
|
+
outage: @outage,
|
|
156
168
|
log:).to_h
|
|
157
169
|
end
|
|
158
170
|
|
|
171
|
+
# Fields that move with the clock alone: the sequence number, the
|
|
172
|
+
# snapshot's time, this node's heartbeat, and how long each plan has
|
|
173
|
+
# been worked on. A car's `elapsed` is left in on purpose: while an
|
|
174
|
+
# agent runs, its seconds counter is what the page is there to show.
|
|
175
|
+
#
|
|
176
|
+
# @param snapshot [Hash]
|
|
177
|
+
# @return [Hash] what the page would see change
|
|
178
|
+
def content(snapshot)
|
|
179
|
+
snapshot.except(:seq, :at)
|
|
180
|
+
.merge(node: snapshot[:node].except(:heartbeat_at),
|
|
181
|
+
plans: Array(snapshot[:plans]).map { it.except(:elapsed) })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Nudges the open pages ({News}) when this tick's snapshot says
|
|
185
|
+
# something the last one did not. The snapshot is still written every
|
|
186
|
+
# tick, because its expiry is how a node is known to be alive; only
|
|
187
|
+
# the nudge waits for news, so an engine with nothing to do is quiet.
|
|
188
|
+
#
|
|
189
|
+
# @param snapshot [Hash]
|
|
190
|
+
# @return [void]
|
|
191
|
+
def nudge(snapshot)
|
|
192
|
+
now = content(snapshot)
|
|
193
|
+
return if now == @published
|
|
194
|
+
|
|
195
|
+
News.publish(store: @store, keys: @keys, kind: "board")
|
|
196
|
+
@published = now
|
|
197
|
+
end
|
|
198
|
+
|
|
159
199
|
# One more than the last published, even across an engine restart:
|
|
160
200
|
# the API server reads a change of `seq` as news, and a restart that
|
|
161
201
|
# counted from one again would look like the past.
|
|
@@ -50,7 +50,7 @@ module OpenLanes
|
|
|
50
50
|
@state_file = dispatcher.state_file
|
|
51
51
|
redis = read_redis
|
|
52
52
|
file = @state_file&.live
|
|
53
|
-
newer =
|
|
53
|
+
newer = newest(redis, file)
|
|
54
54
|
# A copy that is missing is behind, unless both are: then there is
|
|
55
55
|
# nothing to keep until a person changes something.
|
|
56
56
|
@in_redis = redis ? redis["seq"].to_i : (newer && -1) || 0
|
|
@@ -62,6 +62,37 @@ module OpenLanes
|
|
|
62
62
|
ordinal, agent = key.split("/", 2)
|
|
63
63
|
dispatcher.prefer_model(ordinal, agent, model)
|
|
64
64
|
end
|
|
65
|
+
restore_concurrency(dispatcher)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A `/concurrency` set before the restart holds after it (plan 020.00).
|
|
69
|
+
# One this run cannot take (a shared checkout runs one agent) is said
|
|
70
|
+
# once and dropped, rather than stopping the engine from starting.
|
|
71
|
+
#
|
|
72
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def restore_concurrency(dispatcher)
|
|
75
|
+
limit = @run_state.concurrency or return
|
|
76
|
+
dispatcher.concurrency = limit
|
|
77
|
+
rescue OpenLanes::Error => e
|
|
78
|
+
UI.log("concurrency #{limit} from before the restart not kept: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# After Redis comes back: which copy is the newer, by the same `seq`
|
|
82
|
+
# rule as {#restore}. An RDB rollback leaves Redis behind or empty and
|
|
83
|
+
# the file wins. The engine's own state kept going through the outage,
|
|
84
|
+
# so nothing is restored from either; both marks are reset to what is
|
|
85
|
+
# really there, so the next {#flush} rewrites whichever copy is behind.
|
|
86
|
+
#
|
|
87
|
+
# @return [Symbol, nil] `:redis` or `:file`, or nil with neither copy
|
|
88
|
+
def reconcile
|
|
89
|
+
@warned = false
|
|
90
|
+
redis = read_redis
|
|
91
|
+
file = @state_file&.live
|
|
92
|
+
@in_redis = redis ? redis["seq"].to_i : -1
|
|
93
|
+
@in_file = file ? file["seq"].to_i : -1
|
|
94
|
+
newer = newest(redis, file) or return nil
|
|
95
|
+
newer.equal?(redis) ? :redis : :file
|
|
65
96
|
end
|
|
66
97
|
|
|
67
98
|
# Bring both copies up to the state's `seq`. The file is written by the
|
|
@@ -98,6 +129,13 @@ module OpenLanes
|
|
|
98
129
|
|
|
99
130
|
private
|
|
100
131
|
|
|
132
|
+
# The copy with the higher `seq`; Redis on a tie, being listed first.
|
|
133
|
+
#
|
|
134
|
+
# @param redis [Hash, nil]
|
|
135
|
+
# @param file [Hash, nil]
|
|
136
|
+
# @return [Hash, nil]
|
|
137
|
+
def newest(redis, file) = [redis, file].compact.max_by { it["seq"].to_i }
|
|
138
|
+
|
|
101
139
|
# @return [void]
|
|
102
140
|
def mirror
|
|
103
141
|
return unless @state_file && @in_file != @run_state.seq
|
|
@@ -9,7 +9,8 @@ module OpenLanes
|
|
|
9
9
|
# Held in the engine, and kept by {RunRecord} in Redis and in
|
|
10
10
|
# `.plans/openlanes-state.json` (W6), so an engine that restarts still
|
|
11
11
|
# knows them. Every change moves {#seq} on by one, which is how the
|
|
12
|
-
# record tells which of its two copies is newer.
|
|
12
|
+
# record tells which of its two copies is newer. So does the concurrency
|
|
13
|
+
# a person set with `/concurrency` (plan 020.00). A model choice also
|
|
13
14
|
# lives in the executor, which is what applies it; this copy is what the
|
|
14
15
|
# page is shown and what a restart hands back to the executor.
|
|
15
16
|
class RunState
|
|
@@ -18,9 +19,24 @@ module OpenLanes
|
|
|
18
19
|
@held_questions = []
|
|
19
20
|
@held_cars = []
|
|
20
21
|
@models = {}
|
|
22
|
+
@concurrency = nil
|
|
21
23
|
@seq = 0
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# @return [Integer, nil] the limit `/concurrency` set; nil when nobody did
|
|
27
|
+
def concurrency = @mutex.synchronize { @concurrency }
|
|
28
|
+
|
|
29
|
+
# @param limit [Integer]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def concurrency=(limit)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
next if @concurrency == limit
|
|
34
|
+
|
|
35
|
+
@concurrency = limit
|
|
36
|
+
@seq += 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
# @param id [String] `"<ordinal>/B<n>"`
|
|
25
41
|
# @param held [Boolean]
|
|
26
42
|
# @return [void]
|
|
@@ -67,7 +83,7 @@ module OpenLanes
|
|
|
67
83
|
def to_h
|
|
68
84
|
@mutex.synchronize do
|
|
69
85
|
{ "seq" => @seq, "held_questions" => @held_questions.dup, "held_cars" => @held_cars.dup,
|
|
70
|
-
"models" => @models.dup }
|
|
86
|
+
"models" => @models.dup }.merge(@concurrency ? { "concurrency" => @concurrency } : {})
|
|
71
87
|
end
|
|
72
88
|
end
|
|
73
89
|
|
|
@@ -80,6 +96,7 @@ module OpenLanes
|
|
|
80
96
|
@held_questions = Array(data["held_questions"]).map(&:to_s)
|
|
81
97
|
@held_cars = Array(data["held_cars"]).map(&:to_s)
|
|
82
98
|
@models = (data["models"].is_a?(Hash) ? data["models"] : {}).to_h { |k, v| [k.to_s, v.to_s] }
|
|
99
|
+
@concurrency = data["concurrency"].is_a?(Integer) && data["concurrency"].positive? ? data["concurrency"] : nil
|
|
83
100
|
@seq = data["seq"].to_i
|
|
84
101
|
end
|
|
85
102
|
self
|
|
@@ -19,13 +19,16 @@ module OpenLanes
|
|
|
19
19
|
# @param node [OpenLanes::Live::Node]
|
|
20
20
|
# @param repo [String]
|
|
21
21
|
# @param clock [Proc] wall time
|
|
22
|
+
# @param outage [OpenLanes::Live::Outage] what pauses the run while
|
|
23
|
+
# Redis is gone; a spec hands in one on its own clock
|
|
22
24
|
def initialize(store:, root:, tree:, agents:, project: nil, node: Node.current(root:), repo: Repo.for(root),
|
|
23
|
-
clock: -> { Time.now })
|
|
25
|
+
clock: -> { Time.now }, outage: Outage.new(store:, limit: Config.current.outage_limit, **Config.current.redis_backoff))
|
|
24
26
|
@store = store
|
|
25
27
|
@node = node
|
|
26
28
|
@keys = Keys.new(repo:, node: node.name)
|
|
27
29
|
@run_state = RunState.new
|
|
28
30
|
@record = RunRecord.new(store:, keys: @keys, run_state: @run_state)
|
|
31
|
+
@outage = outage
|
|
29
32
|
@claims = Claims.new(store:, keys: @keys)
|
|
30
33
|
@publisher = Publisher.new(store:,
|
|
31
34
|
keys: @keys,
|
|
@@ -69,20 +72,45 @@ module OpenLanes
|
|
|
69
72
|
# @return [OpenLanes::Live::RunRecord]
|
|
70
73
|
attr_reader :record
|
|
71
74
|
|
|
75
|
+
# @return [OpenLanes::Live::Outage]
|
|
76
|
+
attr_reader :outage
|
|
77
|
+
|
|
72
78
|
# @return [Proc] the runner's `on_board:`
|
|
73
79
|
def on_board = @publisher.to_proc
|
|
74
80
|
|
|
75
81
|
# Wire the run in: the run state a previous engine left is taken back
|
|
76
|
-
# on the first tick, commands are applied at the top of each tick,
|
|
77
|
-
#
|
|
82
|
+
# on the first tick, commands are applied at the top of each tick, mail
|
|
83
|
+
# published to Redis is drained into the plans' `state.json`, and the
|
|
84
|
+
# log is heard from now on.
|
|
78
85
|
#
|
|
79
86
|
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
80
87
|
# @return [void]
|
|
81
88
|
def attach(dispatcher)
|
|
82
89
|
@publisher.listen
|
|
83
90
|
@publisher.on_lost { |ordinals| dispatcher.fence(ordinals) }
|
|
91
|
+
# First, so a tick that finds Redis gone pauses before anything else
|
|
92
|
+
# on it asks Redis for something (W32).
|
|
93
|
+
dispatcher.before_tick(&@outage)
|
|
84
94
|
dispatcher.before_tick(&@record)
|
|
85
95
|
dispatcher.before_tick(&@commands)
|
|
96
|
+
dispatcher.mail = Mail.new(store: @store, keys: @keys)
|
|
97
|
+
@outage.on_resume { recover(it) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Redis answers again: say how long it was gone and which copy of the
|
|
101
|
+
# run state won, and publish that as `run.outage`.
|
|
102
|
+
#
|
|
103
|
+
# @param window [OpenLanes::Live::Outage::Window]
|
|
104
|
+
# @return [OpenLanes::Live::OutageReport]
|
|
105
|
+
def recover(window)
|
|
106
|
+
report = OutageReport.new(store: @store,
|
|
107
|
+
keys: @keys,
|
|
108
|
+
node: @node,
|
|
109
|
+
window:,
|
|
110
|
+
reconciled_from: @record.reconcile)
|
|
111
|
+
UI.log(report.line)
|
|
112
|
+
@publisher.outage = report.to_h
|
|
113
|
+
report
|
|
86
114
|
end
|
|
87
115
|
|
|
88
116
|
# The run is over: the run state is written down, claims go back, and
|
|
@@ -101,7 +129,13 @@ module OpenLanes
|
|
|
101
129
|
end
|
|
102
130
|
|
|
103
131
|
# @return [String] where, with the password masked, and as whom
|
|
104
|
-
def describe =
|
|
132
|
+
def describe = described.join(" ")
|
|
133
|
+
|
|
134
|
+
# The same, in the two parts a narrow box may break it between: the
|
|
135
|
+
# node id is long, and wraps mid-word when left to the box.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array(String, String)] `"redis://… as kig@host"` and `"(<node id>)"`
|
|
138
|
+
def described = ["#{Live.mask(@store.url)} as #{@node.label}", "(#{@node.name})"]
|
|
105
139
|
end
|
|
106
140
|
end
|
|
107
141
|
end
|
|
@@ -13,17 +13,6 @@ module OpenLanes
|
|
|
13
13
|
# to the page's fixture, level by level, so a field renamed on one side
|
|
14
14
|
# fails the other's suite.
|
|
15
15
|
class Snapshot
|
|
16
|
-
# How an agent marks a question as answerable with Yes or No (W30), on
|
|
17
|
-
# its own line under the question's heading. The agents' prompt shows
|
|
18
|
-
# it, and {#questions} looks for it; neither spells it twice.
|
|
19
|
-
YES_NO_MARK = "answers: yes, no"
|
|
20
|
-
|
|
21
|
-
# {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
|
|
22
|
-
YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
|
|
23
|
-
|
|
24
|
-
# Any Markdown heading, which ends the body of the question above it.
|
|
25
|
-
HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
|
|
26
|
-
|
|
27
16
|
# Plan states finished with, kept off "Choose an existing plan".
|
|
28
17
|
FINISHED = %i[approved deployed discarded].freeze
|
|
29
18
|
|
|
@@ -42,9 +31,16 @@ module OpenLanes
|
|
|
42
31
|
# @param meter [OpenLanes::Live::Meter]
|
|
43
32
|
# @param log [Array<Hash>] `LogLine`s, newest last
|
|
44
33
|
# @param adapter [OpenLanes::Adapters::Base] whose models the page offers
|
|
34
|
+
# @param judge [#call, nil] a second opinion on whether a question asks
|
|
35
|
+
# to choose ({ChoiceJudge}); nil leaves the parser's verdict alone
|
|
36
|
+
# @param redis_url [String, nil] the store the engine itself is using,
|
|
37
|
+
# unredacted: the snapshot redacts it ({Live.redact})
|
|
38
|
+
# @param outage [Hash, nil] {OutageReport#to_h} of the outage this run
|
|
39
|
+
# last recovered from; nil when there was none
|
|
45
40
|
def initialize(board:, tree:, agents:, node:, repo:, project:, seq:, description: nil, at: Time.now,
|
|
46
41
|
started_at: nil, claims: nil, run_state: RunState.new, meter: Meter.new, log: [],
|
|
47
|
-
adapter: Adapters.for(Adapters::DEFAULT)
|
|
42
|
+
adapter: Adapters.for(Adapters::DEFAULT), judge: Live.choice_judge, redis_url: nil,
|
|
43
|
+
outage: nil)
|
|
48
44
|
@board = board
|
|
49
45
|
@tree = tree
|
|
50
46
|
@agents = agents
|
|
@@ -60,6 +56,9 @@ module OpenLanes
|
|
|
60
56
|
@meter = meter
|
|
61
57
|
@log = log
|
|
62
58
|
@adapter = adapter
|
|
59
|
+
@judge = judge
|
|
60
|
+
@redis_url = redis_url
|
|
61
|
+
@outage = outage
|
|
63
62
|
end
|
|
64
63
|
|
|
65
64
|
# @return [Hash]
|
|
@@ -76,8 +75,10 @@ module OpenLanes
|
|
|
76
75
|
stations: Stations.all(@agents).map(&:wire),
|
|
77
76
|
plans: mine,
|
|
78
77
|
backlog: backlog(subjects, mine.map { it[:ordinal] }),
|
|
78
|
+
bombs: bombs(subjects),
|
|
79
79
|
models: @adapter.models.map(&:wire),
|
|
80
80
|
autocompact: Adapters::AUTOCOMPACT / 100.0,
|
|
81
|
+
redis_url: @redis_url && Live.redact(@redis_url),
|
|
81
82
|
log: @log }
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -93,7 +94,9 @@ module OpenLanes
|
|
|
93
94
|
up: @board.up.to_i,
|
|
94
95
|
down: @board.down.to_i,
|
|
95
96
|
cost: plans.sum { it[:cost] }.round(4),
|
|
96
|
-
agents_working: @board.running.to_i
|
|
97
|
+
agents_working: @board.running.to_i,
|
|
98
|
+
jobs: @board.jobs.to_i,
|
|
99
|
+
outage: @outage }
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
# @return [String]
|
|
@@ -121,12 +124,15 @@ module OpenLanes
|
|
|
121
124
|
ordinal = subject.feature.ordinal.to_s
|
|
122
125
|
state = Plans::PlanState.for(subject.feature.path)
|
|
123
126
|
stages = state.stages
|
|
127
|
+
finished = state.finished
|
|
124
128
|
rows = @board.rows.select { it.ordinal == ordinal }
|
|
125
129
|
spent = @meter.for(ordinal, method(:price))
|
|
126
130
|
started = @meter.started_at(ordinal)
|
|
131
|
+
mailbox = Plans.mailbox(subject.feature.path)
|
|
127
132
|
{ ordinal:,
|
|
128
133
|
title: subject.feature.title,
|
|
129
134
|
slug: subject.feature.slug,
|
|
135
|
+
summary: Plans::Spec.summary(subject.read(Plans::Spec::FILENAME).to_s),
|
|
130
136
|
status: status(subject.status),
|
|
131
137
|
lane: Plans::Spec.for(subject).lane.to_s,
|
|
132
138
|
station: Stations.key_for(subject.status.key, before: stages.last&.fetch("from", nil)&.to_sym).to_s,
|
|
@@ -136,15 +142,20 @@ module OpenLanes
|
|
|
136
142
|
started_at: started && iso(started),
|
|
137
143
|
elapsed: started ? (@at - started).round : 0,
|
|
138
144
|
cars: rows.map { car(it, subject, stages) },
|
|
139
|
-
pr: pull_request(subject),
|
|
145
|
+
pr: pull_request(subject, finished),
|
|
140
146
|
questions: questions(subject, state),
|
|
141
147
|
interrupted: interrupted(stages, rows),
|
|
142
|
-
claimed_by: claimed_by(ordinal)
|
|
148
|
+
claimed_by: claimed_by(ordinal),
|
|
149
|
+
unacked: mailbox.unacked,
|
|
150
|
+
mail: mailbox.unacked_pairs,
|
|
151
|
+
last_known: last_known(state),
|
|
152
|
+
locked: Plans::Lock.read(subject.feature.path)&.to_json_hash,
|
|
153
|
+
finished: finished && { kind: finished.kind, at: iso(finished.at) } }
|
|
143
154
|
end
|
|
144
155
|
|
|
145
156
|
# @param status [OpenLanes::Plans::Status]
|
|
146
157
|
# @return [Hash] `Status`
|
|
147
|
-
def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label }
|
|
158
|
+
def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label, note: status.note.to_s.delete("`") }
|
|
148
159
|
|
|
149
160
|
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
150
161
|
# @param taken [Array<String>] ordinals already on the board
|
|
@@ -158,6 +169,24 @@ module OpenLanes
|
|
|
158
169
|
end
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
# Every 💣 in the tree that is not another machine's, on the board or
|
|
173
|
+
# not: a bomb halts the whole run, so the page shows it whatever this
|
|
174
|
+
# node is working on (plan 009).
|
|
175
|
+
#
|
|
176
|
+
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
177
|
+
# @return [Array<Hash>] `Bomb`
|
|
178
|
+
def bombs(subjects)
|
|
179
|
+
aging = Lifecycle::Aging.new(tree: @tree, now: @at)
|
|
180
|
+
subjects.select { it.status.key == :bomb && !foreign?(it.feature.ordinal.to_s) }.map do |s|
|
|
181
|
+
resume = Plans::PlanState.for(s.feature.path).resume_until
|
|
182
|
+
{ plan: s.feature.ordinal.to_s,
|
|
183
|
+
slug: s.feature.slug,
|
|
184
|
+
blocked_md: s.read("blocked.md").to_s,
|
|
185
|
+
blocked_since: iso(aging.started_at(s)),
|
|
186
|
+
resume_until: resume && iso(resume) }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
161
190
|
# ---- cars -------------------------------------------------------------
|
|
162
191
|
|
|
163
192
|
# @param row [OpenLanes::Engine::Board::Row]
|
|
@@ -183,6 +212,7 @@ module OpenLanes
|
|
|
183
212
|
elapsed: row.elapsed.to_i,
|
|
184
213
|
remaining: row.remaining,
|
|
185
214
|
held: @run_state.car_held?(row.key),
|
|
215
|
+
subagents: Array(row.running),
|
|
186
216
|
next_model: next_model(row) }
|
|
187
217
|
end
|
|
188
218
|
|
|
@@ -229,16 +259,24 @@ module OpenLanes
|
|
|
229
259
|
# ---- pull request, interruption, claim --------------------------------
|
|
230
260
|
|
|
231
261
|
# @param subject [OpenLanes::Plans::Subject]
|
|
262
|
+
# @param finished [OpenLanes::Plans::PlanState::Finished, nil]
|
|
232
263
|
# @return [Hash, nil] `PullRequest`
|
|
233
|
-
def pull_request(subject)
|
|
264
|
+
def pull_request(subject, finished)
|
|
234
265
|
pr = subject.pull_requests.find(&:open?) || subject.pull_requests.last or return nil
|
|
235
|
-
|
|
266
|
+
merged_at = finished.at if pr.merged? && finished&.merged?
|
|
267
|
+
{ number: pr.number.to_s[/\d+/].to_i, url: pr.url, state: pr_state(pr, subject, finished), ci: nil,
|
|
268
|
+
merged_at: merged_at && iso(merged_at) }
|
|
236
269
|
end
|
|
237
270
|
|
|
271
|
+
# hansolo's approval leaves the plan at 👀 with its pull request open,
|
|
272
|
+
# which the status key alone reads as "open": `finished` is what says
|
|
273
|
+
# it was approved, so the page's pill turns green.
|
|
274
|
+
#
|
|
238
275
|
# @return [String]
|
|
239
|
-
def pr_state(pr, subject)
|
|
276
|
+
def pr_state(pr, subject, finished)
|
|
240
277
|
return "merged" if pr.merged?
|
|
241
278
|
return "closed" unless pr.open?
|
|
279
|
+
return "approved" if finished&.kind == "approved"
|
|
242
280
|
|
|
243
281
|
{ rejected: "rejected", approved: "approved" }.fetch(subject.status.key, "open")
|
|
244
282
|
end
|
|
@@ -255,6 +293,18 @@ module OpenLanes
|
|
|
255
293
|
{ agent: last["agent"], at: last["finished_at"] || last["started_at"], note: last["note"].to_s }
|
|
256
294
|
end
|
|
257
295
|
|
|
296
|
+
# Where each interrupted agent stood, as the next round will read it.
|
|
297
|
+
# Read-only: an unacked count or a resume record never changes a round.
|
|
298
|
+
#
|
|
299
|
+
# @param state [OpenLanes::Plans::PlanState]
|
|
300
|
+
# @return [Array<Hash>] `LastKnown`, one per agent
|
|
301
|
+
def last_known(state)
|
|
302
|
+
state.last_known_states.map do |agent, entry|
|
|
303
|
+
{ agent:, source: entry.source, at: entry.at, round: entry.round, status: entry.status,
|
|
304
|
+
next_step: entry.next_step, note: entry.note }
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
258
308
|
# @param ordinal [String]
|
|
259
309
|
# @return [String, nil] the node id, without its checkout
|
|
260
310
|
def claimed_by(ordinal) = @claims&.held_by(ordinal)&.then { it.split(":").first }
|
|
@@ -265,17 +315,25 @@ module OpenLanes
|
|
|
265
315
|
|
|
266
316
|
# ---- questions --------------------------------------------------------
|
|
267
317
|
|
|
268
|
-
# The questions `blocked.md` still asks, each with its
|
|
269
|
-
#
|
|
318
|
+
# The questions `blocked.md` still asks, each with its context, the
|
|
319
|
+
# options it offers (D3), and who asked; then the harness's own
|
|
320
|
+
# requests for more time or rounds (E1), and R4's quality question on
|
|
321
|
+
# the plan that asked it.
|
|
270
322
|
#
|
|
271
323
|
# @param subject [OpenLanes::Plans::Subject]
|
|
272
324
|
# @param state [OpenLanes::Plans::PlanState]
|
|
273
325
|
# @return [Array<Hash>] `Question`
|
|
274
326
|
def questions(subject, state)
|
|
327
|
+
blocked(subject, state) + Engine::Extensions.questions(subject, state) +
|
|
328
|
+
Engine::QualityQuestion.questions(subject, state)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# @return [Array<Hash>] `Question`, from `blocked.md`
|
|
332
|
+
def blocked(subject, state)
|
|
275
333
|
open = Lifecycle::Unblocker.questions(subject).reject(&:answered)
|
|
276
334
|
return [] if open.empty?
|
|
277
335
|
|
|
278
|
-
bodies = bodies(subject.read("blocked.md").to_s)
|
|
336
|
+
bodies = Plans::BlockedFile.bodies(subject.read("blocked.md").to_s)
|
|
279
337
|
asker = state.signatures.rfind { it["status"] == "Blocked" }
|
|
280
338
|
asked_at = asker&.fetch("at", nil) || iso(File.mtime(File.join(subject.feature.path, "blocked.md")))
|
|
281
339
|
open.map { |q| question(subject, q, bodies.fetch(q.number, ""), asker, asked_at) }
|
|
@@ -284,34 +342,37 @@ module OpenLanes
|
|
|
284
342
|
# @return [Hash]
|
|
285
343
|
def question(subject, open, body, asker, asked_at)
|
|
286
344
|
id = "#{subject.feature.ordinal}/B#{open.number}"
|
|
287
|
-
|
|
345
|
+
choices = choices(open.heading, body)
|
|
288
346
|
{ id:,
|
|
289
347
|
number: open.number,
|
|
290
|
-
|
|
291
|
-
|
|
348
|
+
kind: "block",
|
|
349
|
+
heading: open.heading.sub(Plans::Choices::YES_NO, "").strip,
|
|
350
|
+
body: choices ? choices.context : body.strip,
|
|
292
351
|
agent: asker&.fetch("agent", nil),
|
|
293
352
|
owner: subject.status.key == :product_blocked ? "product" : "technical",
|
|
294
|
-
|
|
353
|
+
options: choices&.to_a,
|
|
295
354
|
held: @run_state.question_held?(id),
|
|
296
355
|
asked_at: }
|
|
297
356
|
end
|
|
298
357
|
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
found
|
|
358
|
+
# The options a question offers, or nil for one that wants words.
|
|
359
|
+
#
|
|
360
|
+
# A question its agent marked yes/no (W30) is taken at its word. Any
|
|
361
|
+
# other the parser found options in goes past the judge, which may say
|
|
362
|
+
# the list was not a choice after all; it is never asked about a
|
|
363
|
+
# question the parser found nothing in, since there would be no buttons
|
|
364
|
+
# to show.
|
|
365
|
+
#
|
|
366
|
+
# @param heading [String]
|
|
367
|
+
# @param body [String]
|
|
368
|
+
# @return [OpenLanes::Plans::Choices, nil]
|
|
369
|
+
def choices(heading, body)
|
|
370
|
+
return Plans::Choices.yes_no(body) if heading.match?(Plans::Choices::YES_NO)
|
|
371
|
+
|
|
372
|
+
found = Plans::Choices.parse(body) or return nil
|
|
373
|
+
return found if body.match?(Plans::Choices::YES_NO)
|
|
374
|
+
|
|
375
|
+
@judge&.call(body) == false ? nil : found
|
|
315
376
|
end
|
|
316
377
|
|
|
317
378
|
# @param time [Time]
|
|
@@ -25,17 +25,20 @@ module OpenLanes
|
|
|
25
25
|
def wire = { key: key.to_s, label:, agents: }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Key, label and the states folded into it, in spine order.
|
|
28
|
+
# Key, label and the states folded into it, in spine order. ⭐ Planned
|
|
29
29
|
# sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
|
|
30
30
|
# because on the board each pair is one stop with the same agents.
|
|
31
|
+
# 🔴 Rejected sits at Building too: luke and rey rework it there, and
|
|
32
|
+
# placed by the spine alone it showed them at Building UI, a stop whose
|
|
33
|
+
# agent list leaves luke out.
|
|
31
34
|
GROUPS = [
|
|
32
35
|
[:new, "New", %i[new]],
|
|
33
36
|
[:researched, "Researched", %i[researched retroactive]],
|
|
34
37
|
[:ready_for_planning, "Planning", %i[ready_for_planning]],
|
|
35
|
-
[:building, "Building", %i[planned building]],
|
|
38
|
+
[:building, "Building", %i[planned building rejected]],
|
|
36
39
|
[:building_ui, "Building UI", %i[building_ui]],
|
|
37
|
-
[:in_review, "Review", %i[ready_for_review in_review]],
|
|
38
|
-
[:pr, "Pull request", %i[approved deployed]]
|
|
40
|
+
[:in_review, "Review", %i[ready_for_review in_review verifying]],
|
|
41
|
+
[:pr, "Pull request", %i[completed approved deployed]]
|
|
39
42
|
].freeze
|
|
40
43
|
|
|
41
44
|
module_function
|
|
@@ -48,10 +51,11 @@ module OpenLanes
|
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
# The stop a plan in +state+ is shown at. A state
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
54
|
+
# The stop a plan in +state+ is shown at. A state a {GROUPS} entry
|
|
55
|
+
# names is shown at that stop, which is how 🔴 Rejected lands at
|
|
56
|
+
# Building. Any other state off the spine (💩, 😱) is shown where the
|
|
57
|
+
# spine takes it next; one with no way forward (⭕, 🛑) where the
|
|
58
|
+
# plan was when it stopped, which only its history knows.
|
|
55
59
|
#
|
|
56
60
|
# @param state [Symbol]
|
|
57
61
|
# @param before [Symbol, nil] the state the plan was worked in last
|