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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Engine
|
|
7
|
+
class EventJournal
|
|
8
|
+
# Rebuilds the in-flight picture from a journal file: the run, each
|
|
9
|
+
# plan's last known state, and every agent that started and has not
|
|
10
|
+
# ended, with its pid, session and last offset.
|
|
11
|
+
#
|
|
12
|
+
# A kill can tear the last line, so a line that does not parse is
|
|
13
|
+
# counted in `torn` and skipped. A line whose `v` is newer than
|
|
14
|
+
# {EventJournal::VERSION} is counted in `newer` rather than read, so an
|
|
15
|
+
# old reader never invents a picture from fields it does not know.
|
|
16
|
+
module Replay
|
|
17
|
+
# One agent the journal says is running.
|
|
18
|
+
Agent = Data.define(:plan, :agent, :round, :pid, :started_at, :session_id, :output_file, :offset)
|
|
19
|
+
|
|
20
|
+
# What the journal says was going on when it stopped.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] run
|
|
23
|
+
# @return [Hash, nil] the `run_started` event, and `ended` when a `run_ended` followed
|
|
24
|
+
# @!attribute [r] plans
|
|
25
|
+
# @return [Hash{String => String}] plan number to the last state a transition named
|
|
26
|
+
# @!attribute [r] agents
|
|
27
|
+
# @return [Array<Agent>] agents started and not ended, in start order
|
|
28
|
+
# @!attribute [r] torn
|
|
29
|
+
# @return [Integer] lines that did not parse
|
|
30
|
+
# @!attribute [r] newer
|
|
31
|
+
# @return [Integer] lines from a newer schema version, skipped
|
|
32
|
+
# @!attribute [r] seq
|
|
33
|
+
# @return [Integer] the last `seq` read
|
|
34
|
+
InFlight = Data.define(:run, :plans, :agents, :torn, :newer, :seq) do
|
|
35
|
+
# @return [Boolean] the run started and no `run_ended` followed
|
|
36
|
+
def running? = !run.nil? && !run.key?("ended")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param path [String] a journal file
|
|
42
|
+
# @return [InFlight]
|
|
43
|
+
def call(path)
|
|
44
|
+
state = { run: nil, plans: {}, agents: {}, torn: 0, newer: 0, seq: 0 }
|
|
45
|
+
File.foreach(path) { read(state, it) }
|
|
46
|
+
InFlight.new(run: state[:run],
|
|
47
|
+
plans: state[:plans],
|
|
48
|
+
agents: state[:agents].values,
|
|
49
|
+
torn: state[:torn],
|
|
50
|
+
newer: state[:newer],
|
|
51
|
+
seq: state[:seq])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
def read(state, line)
|
|
56
|
+
return if line.strip.empty?
|
|
57
|
+
|
|
58
|
+
event = JSON.parse(line)
|
|
59
|
+
return state[:newer] += 1 if event["v"].to_i > VERSION
|
|
60
|
+
|
|
61
|
+
state[:seq] = event["seq"].to_i
|
|
62
|
+
apply(state, event)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
state[:torn] += 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
def apply(state, event)
|
|
69
|
+
key = [event["plan"], event["agent"]]
|
|
70
|
+
agents = state[:agents]
|
|
71
|
+
case event["kind"]
|
|
72
|
+
when "run_started" then state[:run] = event
|
|
73
|
+
when "run_ended" then state[:run] = (state[:run] || {}).merge("ended" => event)
|
|
74
|
+
when "transition" then state[:plans][event["plan"]] = event["to"]
|
|
75
|
+
when "agent_started" then agents[key] = start(event)
|
|
76
|
+
when "agent_session" then agents[key] &&= agents[key].with(session_id: event["session_id"])
|
|
77
|
+
when "agent_offset" then agents[key] &&= agents[key].with(offset: event["offset"])
|
|
78
|
+
when "agent_ended" then agents.delete(key)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @api private
|
|
83
|
+
def start(event)
|
|
84
|
+
Agent.new(plan: event["plan"],
|
|
85
|
+
agent: event["agent"],
|
|
86
|
+
round: event["round"],
|
|
87
|
+
pid: event["pid"],
|
|
88
|
+
started_at: event["started_at"],
|
|
89
|
+
session_id: event["session_id"],
|
|
90
|
+
output_file: event["output_file"],
|
|
91
|
+
offset: 0)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module OpenLanes
|
|
10
|
+
module Engine
|
|
11
|
+
# The node's progress journal: one append-only NDJSON file per run under
|
|
12
|
+
# {Runtime.journal_dir}, one schema-versioned line per engine event,
|
|
13
|
+
# flushed and fsync'd before {#append} returns. Each line is then mirrored
|
|
14
|
+
# to the {Live::EventBus} on {Live::Keys#journal}.
|
|
15
|
+
#
|
|
16
|
+
# The file is the record, not Redis. {StateFile} is a snapshot rewritten
|
|
17
|
+
# every tick, and the Redis board keeps a hundred entries that vanish with
|
|
18
|
+
# the server, so neither could say what a killed engine had been doing.
|
|
19
|
+
# The bus is best-effort: a publish that raises is reported once and the
|
|
20
|
+
# line stays on disk.
|
|
21
|
+
#
|
|
22
|
+
# Named `EventJournal`, never `Journal`: {Plans::Journal} is the agents'
|
|
23
|
+
# own memory in `state.json`, a different thing that shares the word.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# journal = EventJournal.new(root: Dir.pwd, run_id: "r1", node: "mbp:abc123")
|
|
27
|
+
# journal.append(:transition, plan: "003.00", from: "planned", to: "building", event: "build")
|
|
28
|
+
# EventJournal::Replay.call(journal.path).agents
|
|
29
|
+
class EventJournal
|
|
30
|
+
# The schema every line validates against.
|
|
31
|
+
SCHEMA_PATH = File.expand_path("../../../schemas/journal-event.schema.json", __dir__)
|
|
32
|
+
|
|
33
|
+
# The `v` of every line this writer appends. A reader that meets a
|
|
34
|
+
# newer one reports it rather than guessing at the fields.
|
|
35
|
+
VERSION = 1
|
|
36
|
+
|
|
37
|
+
# Every `kind` the schema accepts, in the order a run produces them.
|
|
38
|
+
KINDS = %w[
|
|
39
|
+
run_started run_ended plan_assigned agent_started agent_session
|
|
40
|
+
agent_offset agent_ended transition extension question
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# @return [String] the NDJSON file this journal appends to
|
|
44
|
+
attr_reader :path
|
|
45
|
+
|
|
46
|
+
# @return [String]
|
|
47
|
+
attr_reader :run_id
|
|
48
|
+
|
|
49
|
+
# @param root [String] the project root, keying {Runtime.journal_dir}
|
|
50
|
+
# @param run_id [String] names the file; one file per run, never truncated
|
|
51
|
+
# @param node [String] this node's `<node id>:<checkout id>`
|
|
52
|
+
# @param bus [Live::EventBus::Base, nil] where each line is mirrored
|
|
53
|
+
# @param channel [String, nil] the bus channel, {Live::Keys#journal}
|
|
54
|
+
# @param clock [#now]
|
|
55
|
+
def initialize(root:, run_id: self.class.run_id, node: self.class.node, bus: nil, channel: nil, clock: Time)
|
|
56
|
+
@run_id = run_id
|
|
57
|
+
@node = node
|
|
58
|
+
@bus = bus
|
|
59
|
+
@channel = channel
|
|
60
|
+
@clock = clock
|
|
61
|
+
@path = File.join(Runtime.journal_dir(root:), "#{run_id}.ndjson")
|
|
62
|
+
@lock = Mutex.new
|
|
63
|
+
@seq = last_seq
|
|
64
|
+
@warned = false
|
|
65
|
+
# O_APPEND, so each line lands whole at the end even beside another
|
|
66
|
+
# writer, and one `write` per line, so a kill tears at most the last.
|
|
67
|
+
@file = File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
|
|
68
|
+
seal_torn_tail
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Append one event, durable on return.
|
|
72
|
+
#
|
|
73
|
+
# @param kind [String, Symbol] one of {KINDS}
|
|
74
|
+
# @param fields [Hash] the kind's own fields; `nil` values are kept,
|
|
75
|
+
# because the schema tells "unknown" from "absent"
|
|
76
|
+
# @return [Hash] the event as written
|
|
77
|
+
# @raise [ArgumentError] on a kind outside {KINDS}
|
|
78
|
+
def append(kind, **fields)
|
|
79
|
+
kind = kind.to_s
|
|
80
|
+
raise ArgumentError, "unknown journal event kind: #{kind}" unless KINDS.include?(kind)
|
|
81
|
+
|
|
82
|
+
event = @lock.synchronize do
|
|
83
|
+
@seq += 1
|
|
84
|
+
line = { "v" => VERSION, "seq" => @seq, "at" => @clock.now.utc.iso8601(6), "node" => @node,
|
|
85
|
+
"run_id" => @run_id, "kind" => kind }.merge(fields.transform_keys(&:to_s))
|
|
86
|
+
@file.write("#{JSON.generate(line)}\n")
|
|
87
|
+
@file.flush
|
|
88
|
+
@file.fsync
|
|
89
|
+
line
|
|
90
|
+
end
|
|
91
|
+
mirror(event)
|
|
92
|
+
event
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [void]
|
|
96
|
+
def close = @lock.synchronize { @file.close unless @file.closed? }
|
|
97
|
+
|
|
98
|
+
# @param root [String]
|
|
99
|
+
# @return [String, nil] the most recently written journal under +root+'s runtime directory
|
|
100
|
+
def self.latest(root:)
|
|
101
|
+
Dir.glob(File.join(Runtime.journal_dir(root:), "*.ndjson")).max_by { File.mtime(it) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param clock [#now]
|
|
105
|
+
# @return [String] `YYYYMMDD-HHMMSS-<6 hex>`, sortable and unique per run
|
|
106
|
+
def self.run_id(clock: Time) = "#{clock.now.utc.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.hex(3)}"
|
|
107
|
+
|
|
108
|
+
# @return [String] the host and this process, when no live node id is known
|
|
109
|
+
def self.node = "#{Socket.gethostname}:#{Process.pid}"
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# A file reopened for the same run carries on numbering where it stopped.
|
|
114
|
+
def last_seq
|
|
115
|
+
return 0 unless File.exist?(@path)
|
|
116
|
+
|
|
117
|
+
File.foreach(@path).reduce(0) do |seq, line|
|
|
118
|
+
JSON.parse(line)["seq"].to_i
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
seq
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A run killed mid-write leaves a fragment with no newline. The next
|
|
125
|
+
# line appended straight after it fused with it, and the replay then
|
|
126
|
+
# dropped both as one torn line, losing an event it had acknowledged.
|
|
127
|
+
def seal_torn_tail
|
|
128
|
+
return if File.empty?(@path)
|
|
129
|
+
return if File.open(@path, "rb") { it.seek(-1, IO::SEEK_END) && it.read(1) } == "\n"
|
|
130
|
+
|
|
131
|
+
@file.write("\n")
|
|
132
|
+
@file.flush
|
|
133
|
+
@file.fsync
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def mirror(event)
|
|
137
|
+
return unless @bus && @channel
|
|
138
|
+
|
|
139
|
+
@bus.publish(@channel, JSON.generate(event))
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
return if @warned
|
|
142
|
+
|
|
143
|
+
@warned = true
|
|
144
|
+
UI.log("journal mirror to the event bus failed (#{e.class}: #{e.message}); the file still has every event")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Engine
|
|
7
|
+
# An agent that runs out of time or rounds asks for more instead of
|
|
8
|
+
# simply ending (E1). The harness, not the agent, asks: a question on the
|
|
9
|
+
# plan, `X<n>`, with three fixed choices, kept in the plan's `state.json`
|
|
10
|
+
# so it survives a restart and can be answered from any control room.
|
|
11
|
+
#
|
|
12
|
+
# - Time: asked while the agent still runs, {ASK_AT} seconds before its
|
|
13
|
+
# clock's zero. A time choice answered then moves the live deadline, and
|
|
14
|
+
# the agent carries on in the same session.
|
|
15
|
+
# - Rounds: asked once the agent has used every round it had on the plan's
|
|
16
|
+
# state without finishing. Every choice gives it one more round; the
|
|
17
|
+
# time choices also lengthen that round's clock.
|
|
18
|
+
#
|
|
19
|
+
# Nobody may be watching, so each kind is granted once without an answer:
|
|
20
|
+
# ten more minutes when the clock reaches {AUTO_AT}, and one more round at
|
|
21
|
+
# once on a run with no control room, or {AUTO_AFTER} seconds after asking
|
|
22
|
+
# on one that has one. After that the request waits for a person, and the
|
|
23
|
+
# limit applies as it always did: STOP at zero, no round past the last.
|
|
24
|
+
#
|
|
25
|
+
# Nothing here starts, stops or renames anything; the dispatcher asks and
|
|
26
|
+
# acts on the answers.
|
|
27
|
+
class Extensions
|
|
28
|
+
# Seconds left on a clock when the time request is asked: the same
|
|
29
|
+
# moment the agent itself is told "5 minutes left".
|
|
30
|
+
ASK_AT = 300
|
|
31
|
+
|
|
32
|
+
# Seconds left when an unanswered time request is granted on its own:
|
|
33
|
+
# before the clock's WRAP_UP at 60, so the agent is not already
|
|
34
|
+
# wrapping up when the time arrives.
|
|
35
|
+
AUTO_AT = 120
|
|
36
|
+
|
|
37
|
+
# Seconds a rounds request waits for a person on a staying run before
|
|
38
|
+
# it is granted on its own.
|
|
39
|
+
AUTO_AFTER = 300
|
|
40
|
+
|
|
41
|
+
# Who answered a request nobody else did.
|
|
42
|
+
AUTO = "auto"
|
|
43
|
+
|
|
44
|
+
# One of the three choices every request offers.
|
|
45
|
+
#
|
|
46
|
+
# @!attribute [r] letter
|
|
47
|
+
# @return [String]
|
|
48
|
+
# @!attribute [r] label
|
|
49
|
+
# @return [String]
|
|
50
|
+
# @!attribute [r] seconds
|
|
51
|
+
# @return [Integer] extra seconds on the clock, 0 for a round alone
|
|
52
|
+
Choice = Data.define(:letter, :label, :seconds)
|
|
53
|
+
|
|
54
|
+
# @return [Array<Choice>]
|
|
55
|
+
CHOICES = [
|
|
56
|
+
Choice.new(letter: "a", label: "Extend Rounds by 1", seconds: 0),
|
|
57
|
+
Choice.new(letter: "b", label: "Extend Time by 10 minutes", seconds: 600),
|
|
58
|
+
Choice.new(letter: "c", label: "Extend Time by 20 minutes", seconds: 1200)
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# The choice granted without an answer, by what ran out.
|
|
62
|
+
DEFAULTS = { "time" => "b", "rounds" => "a" }.freeze
|
|
63
|
+
|
|
64
|
+
# @param stay [Boolean] whether a control room can answer at all
|
|
65
|
+
# @param auto [Boolean] false to ask and never grant on its own
|
|
66
|
+
# @param now [Proc] the wall clock
|
|
67
|
+
def initialize(stay:, auto: true, now: -> { Clock::System.now })
|
|
68
|
+
@stay = stay
|
|
69
|
+
@auto = auto
|
|
70
|
+
@now = now
|
|
71
|
+
# Rounds used are counted in memory, from this run's start; rounds
|
|
72
|
+
# granted are counted from the same moment, or a restart would hand
|
|
73
|
+
# back every round ever granted on top of a fresh allowance.
|
|
74
|
+
@since = now.call
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A running agent whose clock is nearly out: ask once per round, and
|
|
78
|
+
# grant ten minutes on its own once per agent and state.
|
|
79
|
+
#
|
|
80
|
+
# @param job [#task, #state, #handle] the dispatcher's job
|
|
81
|
+
# @param path [String] the plan folder as it is named now
|
|
82
|
+
# @return [Hash, nil] the request, when there is one
|
|
83
|
+
def watch(job, path)
|
|
84
|
+
left = job.handle.remaining or return nil
|
|
85
|
+
return nil if left > ASK_AT
|
|
86
|
+
|
|
87
|
+
plan = Plans::PlanState.for(path)
|
|
88
|
+
agent = job.task.agent.name
|
|
89
|
+
state = job.state.to_s
|
|
90
|
+
request = pending(plan.extensions, agent, state) ||
|
|
91
|
+
unless asked?(plan.extensions, agent, state, job.task.round, "time")
|
|
92
|
+
plan.ask_extension!(agent:, state:, round: job.task.round, limit: "time", at: @now.call)
|
|
93
|
+
end
|
|
94
|
+
return request unless @auto && request && left <= AUTO_AT && !auto?(plan.extensions, agent, state, "live")
|
|
95
|
+
|
|
96
|
+
answer!(path, request["number"], DEFAULTS.fetch("time"), by: AUTO, live: job.handle)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# An agent with no rounds left on the plan's state: ask once per
|
|
100
|
+
# exhaustion, and grant a round on its own once per agent and state.
|
|
101
|
+
#
|
|
102
|
+
# @param path [String]
|
|
103
|
+
# @param agent [String]
|
|
104
|
+
# @param state [#to_s] the status key
|
|
105
|
+
# @param round [Integer] the last round it ran
|
|
106
|
+
# @return [Hash, nil] the request, when there is one
|
|
107
|
+
def exhausted(path, agent, state, round)
|
|
108
|
+
plan = Plans::PlanState.for(path)
|
|
109
|
+
state = state.to_s
|
|
110
|
+
all = plan.extensions
|
|
111
|
+
request = pending(all, agent, state) ||
|
|
112
|
+
unless asked?(all, agent, state, round, "rounds")
|
|
113
|
+
plan.ask_extension!(agent:, state:, round:, limit: "rounds", at: @now.call)
|
|
114
|
+
end
|
|
115
|
+
return request unless @auto && request && !auto?(all, agent, state, "round") && waited?(request)
|
|
116
|
+
|
|
117
|
+
answer!(path, request["number"], DEFAULTS.fetch("rounds"), by: AUTO)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Settle a request with a choice. A time choice reaches a clock still
|
|
121
|
+
# running when +live+ is given; anything else is one more round, with
|
|
122
|
+
# the choice's seconds added to that round's clock.
|
|
123
|
+
#
|
|
124
|
+
# @param path [String]
|
|
125
|
+
# @param number [Integer]
|
|
126
|
+
# @param letter [String] "a", "b" or "c"
|
|
127
|
+
# @param by [String]
|
|
128
|
+
# @param live [OpenLanes::Execution::Executor::Handle, nil] the asking agent's, while it runs
|
|
129
|
+
# @return [Hash, nil] the request as settled; nil when it was settled already
|
|
130
|
+
# @raise [ArgumentError] on a letter that is not one of {CHOICES}
|
|
131
|
+
def answer!(path, number, letter, by:, live: nil)
|
|
132
|
+
choice = CHOICES.find { it.letter == letter.to_s.downcase } or
|
|
133
|
+
raise ArgumentError, "#{letter} is not one of #{CHOICES.map { "#{it.letter})" }.join(", ")}"
|
|
134
|
+
plan = Plans::PlanState.for(path)
|
|
135
|
+
request = plan.extensions.find { it["number"] == Integer(number) }
|
|
136
|
+
return nil if request.nil? || request.key?("settled_at")
|
|
137
|
+
|
|
138
|
+
# Only a time request moves a running clock: every choice on a
|
|
139
|
+
# rounds request is a round, even while the agent was resumed.
|
|
140
|
+
grant = live && request["limit"] == "time" && choice.seconds.positive? ? "live" : "round"
|
|
141
|
+
settled = plan.settle_extension!(number, answer: choice.letter, by:, grant:, seconds: choice.seconds, at: @now.call)
|
|
142
|
+
# Someone else settled it between the read above and this write.
|
|
143
|
+
return nil unless settled
|
|
144
|
+
|
|
145
|
+
# The clock told the agent STOP, or stopped, before the time got
|
|
146
|
+
# there: the time comes with a round instead of going nowhere.
|
|
147
|
+
settled = plan.amend_extension!(number, grant: "round") if grant == "live" && !live.extend!(choice.seconds)
|
|
148
|
+
UI.log("#{by == AUTO ? "auto-extended" : "extended by #{by}"}: #{request["agent"]} #{choice.label.downcase}",
|
|
149
|
+
plan: File.basename(path)[/\A\d{3}\.\d{2}/])
|
|
150
|
+
settled
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The rounds a person or this class has added to an agent's own.
|
|
154
|
+
#
|
|
155
|
+
# @param path [String]
|
|
156
|
+
# @param agent [String]
|
|
157
|
+
# @param state [#to_s]
|
|
158
|
+
# @return [Integer]
|
|
159
|
+
def rounds(path, agent, state)
|
|
160
|
+
granted(Plans::PlanState.for(path).extensions, agent, state.to_s).count { Time.iso8601(it["settled_at"]) >= @since.floor }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The extra seconds a granted round runs on, handed out once.
|
|
164
|
+
#
|
|
165
|
+
# @param path [String]
|
|
166
|
+
# @param agent [String]
|
|
167
|
+
# @param state [#to_s]
|
|
168
|
+
# @return [Integer] 0 when the round has none
|
|
169
|
+
def take_seconds(path, agent, state)
|
|
170
|
+
plan = Plans::PlanState.for(path)
|
|
171
|
+
grant = granted(plan.extensions, agent, state.to_s).find { it["seconds"].to_i.positive? && !it["spent"] }
|
|
172
|
+
return 0 unless grant
|
|
173
|
+
|
|
174
|
+
plan.spend_extension!(grant["number"])
|
|
175
|
+
grant["seconds"]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A request nobody needs any more: the agent's round ended, however it
|
|
179
|
+
# ended. A time card left open would say "running out of time" about an
|
|
180
|
+
# agent no longer running, and stand in for the next round's own ask.
|
|
181
|
+
#
|
|
182
|
+
# @param path [String]
|
|
183
|
+
# @param agent [String]
|
|
184
|
+
# @param state [#to_s]
|
|
185
|
+
# @return [void]
|
|
186
|
+
def close!(path, agent, state, why: "finished")
|
|
187
|
+
plan = Plans::PlanState.for(path)
|
|
188
|
+
request = pending(plan.extensions, agent, state.to_s) or return
|
|
189
|
+
plan.settle_extension!(request["number"], closed: why, at: @now.call)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class << self
|
|
193
|
+
# The requests a plan still has open for its state, as the page's
|
|
194
|
+
# `Question`s, beside the ones `blocked.md` asks.
|
|
195
|
+
#
|
|
196
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
197
|
+
# @param plan [OpenLanes::Plans::PlanState]
|
|
198
|
+
# @return [Array<Hash>]
|
|
199
|
+
def questions(subject, plan)
|
|
200
|
+
state = subject.status.key.to_s
|
|
201
|
+
plan.extensions.reject { it.key?("settled_at") || it["state"] != state }.map { question(subject, it) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
# @return [Hash]
|
|
207
|
+
def question(subject, request)
|
|
208
|
+
time = request["limit"] == "time"
|
|
209
|
+
recommended = DEFAULTS.fetch(request["limit"])
|
|
210
|
+
{ id: "#{subject.feature.ordinal}/X#{request["number"]}",
|
|
211
|
+
number: request["number"],
|
|
212
|
+
kind: "extension",
|
|
213
|
+
heading: time ? "#{request["agent"]} is running out of time" : "#{request["agent"]} is out of rounds",
|
|
214
|
+
body: body(subject, request, time),
|
|
215
|
+
agent: request["agent"],
|
|
216
|
+
owner: "technical",
|
|
217
|
+
options: CHOICES.map do |choice|
|
|
218
|
+
{ letter: choice.letter,
|
|
219
|
+
label: choice.label,
|
|
220
|
+
recommended: choice.letter == recommended,
|
|
221
|
+
answer: "#{choice.letter}) #{choice.label}" }
|
|
222
|
+
end,
|
|
223
|
+
held: false,
|
|
224
|
+
asked_at: request["asked_at"] }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @return [String]
|
|
228
|
+
def body(subject, request, time)
|
|
229
|
+
if time
|
|
230
|
+
"Round #{request["round"]} on #{subject.status} has under #{ASK_AT / 60} minutes left on its clock. " \
|
|
231
|
+
"More time lets it carry on where it is; another round starts it again from its resume note."
|
|
232
|
+
else
|
|
233
|
+
"It has used every round it had on #{subject.status} without finishing. " \
|
|
234
|
+
"Each choice gives it one more round; the time choices also lengthen that round's clock."
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
# @return [Hash, nil] the agent's unsettled request on this state
|
|
242
|
+
def pending(all, agent, state) = all.find { it["agent"] == agent && it["state"] == state && !it.key?("settled_at") }
|
|
243
|
+
|
|
244
|
+
# @return [Array<Hash>] the requests that gave the agent a round on this state
|
|
245
|
+
def granted(all, agent, state) = all.select { it["grant"] == "round" && it["agent"] == agent && it["state"] == state }
|
|
246
|
+
|
|
247
|
+
# A time request answered with time on the running clock does not
|
|
248
|
+
# count: when that time runs low too, the round asks again, so a
|
|
249
|
+
# person can add to an automatic grant.
|
|
250
|
+
#
|
|
251
|
+
# @return [Boolean] whether this round already asked about this limit
|
|
252
|
+
def asked?(all, agent, state, round, limit)
|
|
253
|
+
all.any? do |r|
|
|
254
|
+
r["agent"] == agent && r["state"] == state && r["round"] == round && r["limit"] == limit && r["grant"] != "live"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @return [Boolean] whether the one grant of this kind nobody answered is spent
|
|
259
|
+
def auto?(all, agent, state, grant)
|
|
260
|
+
all.any? { it["agent"] == agent && it["state"] == state && it["by"] == AUTO && it["grant"] == grant }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @return [Boolean] whether a rounds request has waited long enough for a person
|
|
264
|
+
def waited?(request)
|
|
265
|
+
return true unless @stay
|
|
266
|
+
|
|
267
|
+
@now.call - Time.iso8601(request["asked_at"]) >= AUTO_AFTER
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|