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,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "tty/command"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Engine
|
|
9
|
+
# The plans an agent outside openlanes is working on, as `alock` (the
|
|
10
|
+
# agent-lock gem) records them, and the engine's own claim in the same
|
|
11
|
+
# register (decision W31).
|
|
12
|
+
#
|
|
13
|
+
# The Redis claim (W26) keeps engines apart and the lock file keeps two
|
|
14
|
+
# runs on one machine apart, but an agent a person started by hand reads
|
|
15
|
+
# neither. It does read alock: every agent here claims what it is about
|
|
16
|
+
# to write. So the engine stays out of a plan whose folder, or whose
|
|
17
|
+
# worktree, somebody else holds, and holds the folder itself while its
|
|
18
|
+
# agents are in it, so that agent sees the engine too. With alock on
|
|
19
|
+
# Redis this reaches across machines as well.
|
|
20
|
+
#
|
|
21
|
+
# `alock acquire` is the atomic step: it refuses (exit 1) when anybody
|
|
22
|
+
# holds an overlapping scope, so two contenders cannot both win. The
|
|
23
|
+
# listing only keeps the engine from asking, tick after tick, for plans
|
|
24
|
+
# it would be refused.
|
|
25
|
+
#
|
|
26
|
+
# alock is a command, not a library: its holder is the process that runs
|
|
27
|
+
# it, skipping shells, which is the engine itself, so a lock ends with
|
|
28
|
+
# the engine that took it. A machine without alock has no agent using it
|
|
29
|
+
# either, and the engine carries on without it, said once.
|
|
30
|
+
class AgentLocks
|
|
31
|
+
# How long one `alock list` answers for. The dispatcher asks on every
|
|
32
|
+
# one-second tick, and each answer starts a Ruby process.
|
|
33
|
+
FRESH = 5
|
|
34
|
+
|
|
35
|
+
# One lock, as `alock list --json` reports it.
|
|
36
|
+
#
|
|
37
|
+
# @!attribute [r] agent
|
|
38
|
+
# @return [String] the holder's agent id
|
|
39
|
+
# @!attribute [r] scope
|
|
40
|
+
# @return [String] relative to {#tree}, e.g. `.plans/003.00━━🟡━━❯x/**`
|
|
41
|
+
# @!attribute [r] tree
|
|
42
|
+
# @return [String] the checkout the lock was taken in
|
|
43
|
+
# @!attribute [r] intent
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
Held = Data.define(:agent, :scope, :tree, :intent) do
|
|
46
|
+
# @return [String] the scope's literal part: `**` is "", `a/b/**` is "a/b"
|
|
47
|
+
def fixed = scope.split("/").take_while { !it.match?(/[*?\[{]/) }.join("/")
|
|
48
|
+
|
|
49
|
+
# @return [String] who holds it and why, for the log
|
|
50
|
+
def to_s = "#{agent} (#{intent || "no intent given"})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param root [String] the repository whose plans are locked
|
|
54
|
+
# @param id [String] the agent id the engine's own locks carry
|
|
55
|
+
# @param own [Array<String>] the engine's agents, who claim with
|
|
56
|
+
# `AGENT_ID=<name>` or `<name>-<suffix>` as their prompt tells them to
|
|
57
|
+
# @param command [TTY::Command]
|
|
58
|
+
# @param clock [#call] monotonic seconds
|
|
59
|
+
def initialize(root:, id: "openlanes@#{Socket.gethostname}:#{Process.pid}", own: [],
|
|
60
|
+
command: TTY::Command.new(printer: :null), clock: -> { UI.monotonic })
|
|
61
|
+
@root = File.realpath(root)
|
|
62
|
+
@id = id
|
|
63
|
+
@own = own
|
|
64
|
+
@failing = nil
|
|
65
|
+
@command = command
|
|
66
|
+
@clock = clock
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
@snapshot = nil
|
|
69
|
+
@taken_at = nil
|
|
70
|
+
@off = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [String]
|
|
74
|
+
attr_reader :id
|
|
75
|
+
|
|
76
|
+
# Somebody else's live lock that overlaps the plan's folder, or any
|
|
77
|
+
# lock taken inside its worktree. The engine's own agents are not
|
|
78
|
+
# somebody else: they claim each file before writing it, so counting
|
|
79
|
+
# them would keep a pair's partner out of the plan while the first one
|
|
80
|
+
# works in it.
|
|
81
|
+
#
|
|
82
|
+
# @param dir [String] the plan folder
|
|
83
|
+
# @param checkout [String, nil] the plan's worktree, when the run isolates
|
|
84
|
+
# @return [Held, nil]
|
|
85
|
+
def held_elsewhere(dir, checkout: nil)
|
|
86
|
+
folder = real(dir)
|
|
87
|
+
work = checkout && real(checkout)
|
|
88
|
+
held.find do |lock|
|
|
89
|
+
next false if ours?(lock.agent)
|
|
90
|
+
|
|
91
|
+
tree = real(lock.tree)
|
|
92
|
+
(work && tree == work) || (inside?(folder, tree) && overlap?(relative(folder, tree), lock.fixed))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Anybody's live lock taken inside one checkout, the engine's own
|
|
97
|
+
# included: a worktree with a lock in it is in use, whoever holds it.
|
|
98
|
+
#
|
|
99
|
+
# @param checkout [String]
|
|
100
|
+
# @return [Held, nil]
|
|
101
|
+
def held_in(checkout)
|
|
102
|
+
work = real(checkout)
|
|
103
|
+
held.find { real(it.tree) == work }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Claim the plan's folder. False when somebody holds it, in which case
|
|
107
|
+
# the plan is left alone; true when alock is not installed.
|
|
108
|
+
#
|
|
109
|
+
# @param dir [String] the plan folder
|
|
110
|
+
# @param intent [String] what the engine is about to do there
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def acquire(dir, intent)
|
|
113
|
+
run("acquire", scope(dir), intent, dir: File.dirname(dir))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param dir [String] the plan folder, as it was named when acquired
|
|
117
|
+
# @return [void]
|
|
118
|
+
def release(dir)
|
|
119
|
+
run("release", scope(dir), dir: File.dirname(dir))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# @param agent [String] a lock's holder
|
|
125
|
+
# @return [Boolean] the engine itself, one of its agents, or a sub-agent of one
|
|
126
|
+
def ours?(agent) = agent == @id || @own.any? { agent == it || agent.start_with?("#{it}-") }
|
|
127
|
+
|
|
128
|
+
# @return [Array<Held>] every active lock, at most {FRESH} seconds old
|
|
129
|
+
def held
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
return [] if @off
|
|
132
|
+
return @snapshot if @snapshot && @clock.call - @taken_at < FRESH
|
|
133
|
+
|
|
134
|
+
@snapshot = list
|
|
135
|
+
@taken_at = @clock.call
|
|
136
|
+
@snapshot
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Array<Held>]
|
|
141
|
+
def list
|
|
142
|
+
out = @command.run("alock", "list", "--json", "--dir", @root).out
|
|
143
|
+
listed = JSON.parse(out).filter_map do |lock|
|
|
144
|
+
next unless lock["status"] == "active"
|
|
145
|
+
|
|
146
|
+
Held.new(agent: lock["agent_id"].to_s, scope: lock["scope"].to_s, tree: lock["tree"].to_s, intent: lock["intent"])
|
|
147
|
+
end
|
|
148
|
+
@failing = nil
|
|
149
|
+
listed
|
|
150
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
151
|
+
failing(e)
|
|
152
|
+
[]
|
|
153
|
+
rescue SystemCallError
|
|
154
|
+
missing
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Boolean] exit 0; false on exit 1, which is a refusal
|
|
159
|
+
def run(*args, dir:)
|
|
160
|
+
return true if @off
|
|
161
|
+
|
|
162
|
+
result = @command.run!("alock", *args, "--dir", dir, env: { "AGENT_ID" => @id })
|
|
163
|
+
@mutex.synchronize { @snapshot = nil }
|
|
164
|
+
# 1 is a refusal; anything above is alock failing, which is said, and
|
|
165
|
+
# counts as a refusal too: a plan nobody could check is left alone.
|
|
166
|
+
if result.exit_status > 1
|
|
167
|
+
left = args.first == "release" ? "; #{args[1]} stays held until the engine exits" : ""
|
|
168
|
+
UI.log("alock #{args.first} #{args[1]} failed: #{(result.err.to_s + result.out.to_s).lines.first.to_s.strip}#{left}",
|
|
169
|
+
level: :warn)
|
|
170
|
+
end
|
|
171
|
+
result.success?
|
|
172
|
+
rescue SystemCallError
|
|
173
|
+
missing
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Said once per failure, not on every tick it lasts: the listing is
|
|
178
|
+
# asked for every few seconds, and the same line each time buried the
|
|
179
|
+
# log. A listing that works again clears it.
|
|
180
|
+
#
|
|
181
|
+
# @param error [Exception]
|
|
182
|
+
# @return [void]
|
|
183
|
+
def failing(error)
|
|
184
|
+
# TTY::Command's message is the command line first; the reason is in
|
|
185
|
+
# the stderr lines after it.
|
|
186
|
+
reason = error.message.lines.map(&:strip).reject(&:empty?).find { it.start_with?("stderr:") } ||
|
|
187
|
+
error.message.lines.first.to_s.strip
|
|
188
|
+
return if @failing == reason
|
|
189
|
+
|
|
190
|
+
@failing = reason
|
|
191
|
+
UI.log("alock list failed, plans unchecked until it works: #{reason}", level: :warn)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [void]
|
|
195
|
+
def missing
|
|
196
|
+
return if @off
|
|
197
|
+
|
|
198
|
+
@off = true
|
|
199
|
+
UI.log("alock is not installed: plans are not checked against agents outside openlanes", level: :warn)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The folder and everything in it: a directory scope covers its
|
|
203
|
+
# contents, and the folder's own files are what the agents write.
|
|
204
|
+
#
|
|
205
|
+
# @param dir [String]
|
|
206
|
+
# @return [String] relative to the folder's parent, where alock runs
|
|
207
|
+
def scope(dir) = "#{File.basename(dir)}/**"
|
|
208
|
+
|
|
209
|
+
# @param path [String]
|
|
210
|
+
# @return [String]
|
|
211
|
+
def real(path) = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
|
|
212
|
+
|
|
213
|
+
# @return [Boolean]
|
|
214
|
+
def inside?(path, tree) = path == tree || path.start_with?("#{tree}/")
|
|
215
|
+
|
|
216
|
+
# @return [String]
|
|
217
|
+
def relative(path, tree) = path == tree ? "" : path.delete_prefix("#{tree}/")
|
|
218
|
+
|
|
219
|
+
# alock's own rule: two scopes conflict when either one's literal part
|
|
220
|
+
# contains the other's, and "" (the whole tree) contains everything.
|
|
221
|
+
#
|
|
222
|
+
# @param path [String]
|
|
223
|
+
# @param fixed [String]
|
|
224
|
+
# @return [Boolean]
|
|
225
|
+
def overlap?(path, fixed)
|
|
226
|
+
fixed.empty? || path.empty? || path == fixed ||
|
|
227
|
+
path.start_with?("#{fixed}/") || fixed.start_with?("#{path}/")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Engine
|
|
5
|
+
# Where a plan goes once its rejections reach `merge.max_rejections`
|
|
6
|
+
# (M6): to the arbitrator, `obi-wan-kenobi-arbitrator` (M7, M8), which
|
|
7
|
+
# plan 015 builds.
|
|
8
|
+
#
|
|
9
|
+
# Until then this is the seam it plugs into, and its default only says
|
|
10
|
+
# so. The plan is handled as any other rejection, so nothing waits on a
|
|
11
|
+
# ruling nobody can give yet; holding it under Needs you at the cap is
|
|
12
|
+
# plan 014.02's.
|
|
13
|
+
class Arbitration
|
|
14
|
+
# @param plan [OpenLanes::Plans::Subject]
|
|
15
|
+
# @param count [Integer] rejections so far, hansolo's and chewie's
|
|
16
|
+
# @param max [Integer] `merge.max_rejections`
|
|
17
|
+
# @return [void]
|
|
18
|
+
def call(plan:, count:, max:)
|
|
19
|
+
UI.log("rejected #{count} of #{max}: at the cap, and no arbitrator is built yet (plan 015)",
|
|
20
|
+
level: :warn,
|
|
21
|
+
plan: plan.feature.ordinal.to_s)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
module OpenLanes
|
|
4
4
|
module Engine
|
|
5
5
|
# What the screen draws: everything about the run, as values, refreshed
|
|
6
|
-
#
|
|
6
|
+
# every tick by the dispatcher. The screen paints it and knows nothing
|
|
7
7
|
# about threads; the dispatcher builds it and knows nothing about cells.
|
|
8
8
|
#
|
|
9
9
|
# @!attribute [r] rows
|
|
10
10
|
# @return [Array<OpenLanes::Engine::Board::Row>] one per agent invocation still
|
|
11
11
|
# worth showing: running, or finished within the last few seconds
|
|
12
|
+
# @!attribute [r] waiting_on
|
|
13
|
+
# @return [Hash{String => Array<Integer>}] plan to the pull requests open
|
|
14
|
+
# and waiting on a person, as the human-mode watch last saw them (M4)
|
|
15
|
+
# @!attribute [r] jobs
|
|
16
|
+
# @return [Integer] how many agents may run at once now, after any
|
|
17
|
+
# `/concurrency` from the copilot (plan 020.00)
|
|
12
18
|
Board = Data.define(:started_at,
|
|
13
19
|
:status,
|
|
14
20
|
:plans,
|
|
@@ -23,8 +29,10 @@ module OpenLanes
|
|
|
23
29
|
:dialog,
|
|
24
30
|
:help,
|
|
25
31
|
:about,
|
|
26
|
-
:hidden
|
|
27
|
-
|
|
32
|
+
:hidden,
|
|
33
|
+
:waiting_on,
|
|
34
|
+
:jobs) do
|
|
35
|
+
def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, waiting_on: {}, jobs: 1, **rest) = super
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
# @!attribute [r] key
|
|
@@ -52,6 +60,13 @@ module OpenLanes
|
|
|
52
60
|
# @!attribute [r] session
|
|
53
61
|
# @return [String, nil] the CLI's session id, once its stream named it;
|
|
54
62
|
# what a compaction resumes (W28)
|
|
63
|
+
# @!attribute [r] unacked
|
|
64
|
+
# @return [Integer] messages in the plan's mailbox nobody has acked yet.
|
|
65
|
+
# Data for the screen only (D2): no round, route or rename reads it,
|
|
66
|
+
# because an unanswered message is a warning, never an input.
|
|
67
|
+
# @!attribute [r] running
|
|
68
|
+
# @return [Array<String>] its sub-agents still at work, by name. Empty
|
|
69
|
+
# once the row is done, and for an adapter whose stream never says
|
|
55
70
|
Board::Row = Data.define(:key,
|
|
56
71
|
:at,
|
|
57
72
|
:ordinal,
|
|
@@ -75,12 +90,28 @@ module OpenLanes
|
|
|
75
90
|
:history,
|
|
76
91
|
:context,
|
|
77
92
|
:since,
|
|
78
|
-
:session
|
|
93
|
+
:session,
|
|
94
|
+
:unacked,
|
|
95
|
+
:running) do
|
|
79
96
|
def initialize(pr: nil, phase: nil, remaining: nil, frame: 0, bold: false, message: nil,
|
|
80
|
-
elapsed: 0, subagents: 0, history: [], context: nil, since: nil, session: nil, **rest)
|
|
97
|
+
elapsed: 0, subagents: 0, history: [], context: nil, since: nil, session: nil, unacked: 0, running: [], **rest)
|
|
81
98
|
super
|
|
82
99
|
end
|
|
83
100
|
|
|
101
|
+
# The count a row carries, read from the plan folder's mailbox, so the
|
|
102
|
+
# dispatcher fills the field in one call and the snapshot and the board
|
|
103
|
+
# cannot count differently (both ask Mailbox#unacked).
|
|
104
|
+
#
|
|
105
|
+
# @param dir [String] the plan folder
|
|
106
|
+
# @return [Integer] 0 for a plan nobody has written to, and for one
|
|
107
|
+
# whose `state.json` cannot be read: a count for the screen must not
|
|
108
|
+
# stop the tick that draws it
|
|
109
|
+
def self.unacked_in(dir)
|
|
110
|
+
Plans.mailbox(dir).unacked
|
|
111
|
+
rescue OpenLanes::Error, SystemCallError
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
84
115
|
# @param history [Array<String>] newest first
|
|
85
116
|
# @param text [String, nil]
|
|
86
117
|
# @return [Array<String>] +text+ on top, unless it is empty or repeats
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Engine
|
|
8
|
+
# The dispatcher's half of D9: an open question in a blocked plan's
|
|
9
|
+
# `blocked.md` that offers no choices is sent to the {Live::ChoiceConverter}
|
|
10
|
+
# and, once the model has answered, rewritten in place into D3's shape, so
|
|
11
|
+
# the page, lando-broker and everything else read the same options.
|
|
12
|
+
#
|
|
13
|
+
# The call runs on a thread of its own, like a drain, and its result is
|
|
14
|
+
# applied on a later tick, where the folder is looked up afresh. Nothing is
|
|
15
|
+
# written when the question changed meanwhile, was answered, or the plan
|
|
16
|
+
# was taken by another machine or another job.
|
|
17
|
+
#
|
|
18
|
+
# Each text is sent once. A rewrite, or an answer the model gave that is
|
|
19
|
+
# not a D3 question, is recorded in the plan's `state.json` under the
|
|
20
|
+
# SHA256 of the text, so a restarted run does not pay for it again. A call
|
|
21
|
+
# that failed (no network, a 4xx, a timeout) is remembered only for this
|
|
22
|
+
# process: the next run may well get through.
|
|
23
|
+
class Conversions
|
|
24
|
+
# One question sent to the model.
|
|
25
|
+
Job = Struct.new(:ordinal, :number, :digest, :original, :thread)
|
|
26
|
+
|
|
27
|
+
# @param converter [#call, nil] a {Live::ChoiceConverter}; nil converts nothing
|
|
28
|
+
# @param runner [OpenLanes::Engine::Runner]
|
|
29
|
+
# @param find [#call] an ordinal to the plan's {Plans::Subject} as the
|
|
30
|
+
# folder is now, nil when it is gone
|
|
31
|
+
def initialize(converter:, runner:, find:)
|
|
32
|
+
@converter = converter
|
|
33
|
+
@runner = runner
|
|
34
|
+
@find = find
|
|
35
|
+
@jobs = []
|
|
36
|
+
@sent = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<String>] plans with a question at the model right now
|
|
40
|
+
def ordinals = @jobs.map(&:ordinal)
|
|
41
|
+
|
|
42
|
+
# Apply what came back, then send what has not been sent.
|
|
43
|
+
#
|
|
44
|
+
# @param busy [Array<String>] plans nobody but their holder may touch
|
|
45
|
+
# @param start [Boolean] false applies answers and sends nothing new
|
|
46
|
+
# @return [void]
|
|
47
|
+
def call(busy:, start: true)
|
|
48
|
+
return if @converter.nil?
|
|
49
|
+
|
|
50
|
+
@jobs.reject { it.thread.alive? }.each { settle(it, busy) }
|
|
51
|
+
return unless start
|
|
52
|
+
|
|
53
|
+
@runner.blocked.each do |subject|
|
|
54
|
+
ordinal = subject.feature.ordinal.to_s
|
|
55
|
+
next if busy.include?(ordinal)
|
|
56
|
+
|
|
57
|
+
prose(subject).each do |question, body|
|
|
58
|
+
next unless @runner.claim(subject)
|
|
59
|
+
|
|
60
|
+
send_off(ordinal, question, body)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Open questions with a body and no choices in it, not yet sent.
|
|
68
|
+
#
|
|
69
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
70
|
+
# @return [Array<Array(Lifecycle::Unblocker::Question, String)>]
|
|
71
|
+
def prose(subject)
|
|
72
|
+
bodies = Plans::BlockedFile.bodies(subject.read("blocked.md").to_s)
|
|
73
|
+
state = Plans::PlanState.for(subject.feature.path)
|
|
74
|
+
Lifecycle::Unblocker.questions(subject).reject(&:answered).filter_map do |question|
|
|
75
|
+
body = bodies.fetch(question.number, "").strip
|
|
76
|
+
next if body.empty? || question.heading.match?(Plans::Choices::YES_NO) || Plans::Choices.parse(body)
|
|
77
|
+
|
|
78
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
79
|
+
next if @sent.include?(digest) || state.converted?(digest)
|
|
80
|
+
|
|
81
|
+
[question, body]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [void]
|
|
86
|
+
def send_off(ordinal, question, body)
|
|
87
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
88
|
+
@sent << digest
|
|
89
|
+
job = Job.new(ordinal:, number: question.number, digest:, original: body)
|
|
90
|
+
heading = question.heading
|
|
91
|
+
job.thread = Thread.new do
|
|
92
|
+
Thread.current.report_on_exception = false
|
|
93
|
+
@converter.call(heading:, body:)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
e
|
|
96
|
+
end
|
|
97
|
+
@jobs << job
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param job [Job]
|
|
101
|
+
# @param busy [Array<String>]
|
|
102
|
+
# @return [void]
|
|
103
|
+
def settle(job, busy)
|
|
104
|
+
@jobs.delete(job)
|
|
105
|
+
outcome = job.thread.value
|
|
106
|
+
subject = @find.call(Plans::Ordinal.parse(job.ordinal))
|
|
107
|
+
return couldnt(job, "the plan's folder is gone") if subject.nil?
|
|
108
|
+
return couldnt(job, "the plan was taken meanwhile") if busy.include?(job.ordinal)
|
|
109
|
+
|
|
110
|
+
case outcome
|
|
111
|
+
when Live::ChoiceConverter::Result then apply(job, subject, outcome)
|
|
112
|
+
when Live::ChoiceConverter::Invalid then reject(job, subject, outcome)
|
|
113
|
+
else couldnt(job, outcome.message.lines.first.to_s.strip)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [void]
|
|
118
|
+
def apply(job, subject, result)
|
|
119
|
+
path = File.join(subject.feature.path, "blocked.md")
|
|
120
|
+
text = subject.read("blocked.md").to_s
|
|
121
|
+
open = Lifecycle::Unblocker.questions(subject).find { it.number == job.number && !it.answered }
|
|
122
|
+
current = Plans::BlockedFile.bodies(text).fetch(job.number, "").strip
|
|
123
|
+
return couldnt(job, "it changed or was answered while the model was writing") if !open || current != job.original
|
|
124
|
+
|
|
125
|
+
write(path, Plans::BlockedFile.replace(text, job.number, result.body))
|
|
126
|
+
Plans::PlanState.for(subject.feature.path).conversion!(question: job.number,
|
|
127
|
+
digest: job.digest,
|
|
128
|
+
original: job.original,
|
|
129
|
+
model: result.model,
|
|
130
|
+
outcome: "converted")
|
|
131
|
+
UI.log("questions: converted B#{job.number} to #{result.count} choices", plan: job.ordinal)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [void]
|
|
135
|
+
def reject(job, subject, error)
|
|
136
|
+
Plans::PlanState.for(subject.feature.path).conversion!(question: job.number,
|
|
137
|
+
digest: job.digest,
|
|
138
|
+
original: job.original,
|
|
139
|
+
model: @converter.model,
|
|
140
|
+
outcome: "rejected",
|
|
141
|
+
note: error.message)
|
|
142
|
+
couldnt(job, error.message)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [void]
|
|
146
|
+
def couldnt(job, why) = UI.log("questions: could not convert B#{job.number}: #{why}", level: :warn, plan: job.ordinal)
|
|
147
|
+
|
|
148
|
+
# A reader never sees half a file.
|
|
149
|
+
#
|
|
150
|
+
# @return [void]
|
|
151
|
+
def write(path, text)
|
|
152
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
153
|
+
File.write(tmp, text)
|
|
154
|
+
File.rename(tmp, path)
|
|
155
|
+
ensure
|
|
156
|
+
FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|