openlanes 3.0.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 +7 -0
- data/agents/hansolo-reviewer.md +59 -0
- data/agents/lando-broker.md +70 -0
- data/agents/leah-researcher.md +60 -0
- data/agents/luke-backend.md +73 -0
- data/agents/palpatine-planner.md +61 -0
- data/agents/r2d2-mechanic.md +57 -0
- data/agents/rey-frontend.md +68 -0
- data/agents/yoda-writer.md +67 -0
- data/bin/factory-e2e +157 -0
- data/bin/plan-number +164 -0
- data/bin/setup +26 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
- data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
- data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast.yml +44 -0
- data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
- data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
- data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep.yml +51 -0
- data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
- data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
- data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast.yml +55 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
- data/evals/cases/luke-backend/build-medium.yml +68 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium.yml +55 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
- data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
- data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium.yml +40 -0
- data/exe/depot +1 -0
- data/exe/lanes +1 -0
- data/exe/openlanes +32 -0
- data/lib/openlanes/adapters/base.rb +69 -0
- data/lib/openlanes/adapters/claude.rb +124 -0
- data/lib/openlanes/adapters/codex.rb +33 -0
- data/lib/openlanes/adapters/model.rb +37 -0
- data/lib/openlanes/adapters/pi.rb +22 -0
- data/lib/openlanes/adapters/stream.rb +196 -0
- data/lib/openlanes/adapters.rb +92 -0
- data/lib/openlanes/agents/agent.rb +103 -0
- data/lib/openlanes/agents/profile.rb +57 -0
- data/lib/openlanes/agents/registry.rb +151 -0
- data/lib/openlanes/agents/roster.rb +103 -0
- data/lib/openlanes/agents/routing.rb +88 -0
- data/lib/openlanes/agents.rb +20 -0
- data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
- data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
- data/lib/openlanes/cli/base.rb +263 -0
- data/lib/openlanes/cli/create/create.rb +354 -0
- data/lib/openlanes/cli/docs/docs.rb +40 -0
- data/lib/openlanes/cli/eval/eval.rb +110 -0
- data/lib/openlanes/cli/index/index.rb +40 -0
- data/lib/openlanes/cli/init/init.rb +145 -0
- data/lib/openlanes/cli/linear/linear.rb +36 -0
- data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
- data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
- data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
- data/lib/openlanes/cli/mail/mail.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
- data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
- data/lib/openlanes/cli/run/run.rb +544 -0
- data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
- data/lib/openlanes/cli/start/start.rb +110 -0
- data/lib/openlanes/cli/state/state.rb +26 -0
- data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
- data/lib/openlanes/cli/states/states.rb +15 -0
- data/lib/openlanes/cli/unblock/unblock.rb +235 -0
- data/lib/openlanes/cli/version/version.rb +13 -0
- data/lib/openlanes/cli/web/web.rb +100 -0
- data/lib/openlanes/cli/worktree/worktree.rb +85 -0
- data/lib/openlanes/cli.rb +162 -0
- data/lib/openlanes/engine/board.rb +104 -0
- data/lib/openlanes/engine/dispatcher.rb +1297 -0
- data/lib/openlanes/engine/progress_log.rb +125 -0
- data/lib/openlanes/engine/runner.rb +224 -0
- data/lib/openlanes/engine/state_file.rb +190 -0
- data/lib/openlanes/engine/tally.rb +177 -0
- data/lib/openlanes/engine.rb +19 -0
- data/lib/openlanes/evals/case.rb +191 -0
- data/lib/openlanes/evals/changes.rb +67 -0
- data/lib/openlanes/evals/check.rb +224 -0
- data/lib/openlanes/evals/evidence.rb +71 -0
- data/lib/openlanes/evals/live.rb +140 -0
- data/lib/openlanes/evals/recording.rb +38 -0
- data/lib/openlanes/evals/run.rb +34 -0
- data/lib/openlanes/evals/score.rb +24 -0
- data/lib/openlanes/evals/scorer.rb +42 -0
- data/lib/openlanes/evals/verdict.rb +18 -0
- data/lib/openlanes/evals.rb +56 -0
- data/lib/openlanes/execution/checks.rb +73 -0
- data/lib/openlanes/execution/child.rb +228 -0
- data/lib/openlanes/execution/clock.rb +121 -0
- data/lib/openlanes/execution/control.rb +194 -0
- data/lib/openlanes/execution/executor.rb +849 -0
- data/lib/openlanes/execution/prompt.rb +455 -0
- data/lib/openlanes/execution/transcript.rb +531 -0
- data/lib/openlanes/execution.rb +19 -0
- data/lib/openlanes/launcher.rb +69 -0
- data/lib/openlanes/lifecycle/adoption.rb +197 -0
- data/lib/openlanes/lifecycle/brief.rb +288 -0
- data/lib/openlanes/lifecycle/completion.rb +58 -0
- data/lib/openlanes/lifecycle/creator.rb +126 -0
- data/lib/openlanes/lifecycle/lanes.rb +74 -0
- data/lib/openlanes/lifecycle/resync.rb +428 -0
- data/lib/openlanes/lifecycle/unblocker.rb +236 -0
- data/lib/openlanes/lifecycle.rb +28 -0
- data/lib/openlanes/linear/api.rb +224 -0
- data/lib/openlanes/linear/attribution.rb +185 -0
- data/lib/openlanes/linear/fuzzy.rb +68 -0
- data/lib/openlanes/linear/import.rb +314 -0
- data/lib/openlanes/linear/issue.rb +187 -0
- data/lib/openlanes/linear/mapping.rb +116 -0
- data/lib/openlanes/linear/push.rb +197 -0
- data/lib/openlanes/linear/survey.rb +174 -0
- data/lib/openlanes/linear/unit.rb +274 -0
- data/lib/openlanes/linear.rb +41 -0
- data/lib/openlanes/live/claims.rb +71 -0
- data/lib/openlanes/live/command.rb +122 -0
- data/lib/openlanes/live/commands.rb +505 -0
- data/lib/openlanes/live/keys.rb +93 -0
- data/lib/openlanes/live/memory_store.rb +265 -0
- data/lib/openlanes/live/meter.rb +51 -0
- data/lib/openlanes/live/node.rb +88 -0
- data/lib/openlanes/live/publisher.rb +179 -0
- data/lib/openlanes/live/repo.rb +60 -0
- data/lib/openlanes/live/run_record.rb +133 -0
- data/lib/openlanes/live/run_state.rb +101 -0
- data/lib/openlanes/live/session.rb +107 -0
- data/lib/openlanes/live/snapshot.rb +322 -0
- data/lib/openlanes/live/stations.rb +67 -0
- data/lib/openlanes/live/store.rb +277 -0
- data/lib/openlanes/live.rb +109 -0
- data/lib/openlanes/plans/dev_work.rb +56 -0
- data/lib/openlanes/plans/feature.rb +131 -0
- data/lib/openlanes/plans/index.rb +208 -0
- data/lib/openlanes/plans/ledger.rb +255 -0
- data/lib/openlanes/plans/mailbox.rb +164 -0
- data/lib/openlanes/plans/ordinal.rb +92 -0
- data/lib/openlanes/plans/plan_state.rb +291 -0
- data/lib/openlanes/plans/pull_request.rb +29 -0
- data/lib/openlanes/plans/pull_requests.rb +229 -0
- data/lib/openlanes/plans/spec.rb +213 -0
- data/lib/openlanes/plans/state_machine.rb +433 -0
- data/lib/openlanes/plans/status.rb +365 -0
- data/lib/openlanes/plans/subject.rb +133 -0
- data/lib/openlanes/plans/tree.rb +79 -0
- data/lib/openlanes/plans.rb +39 -0
- data/lib/openlanes/presentation/diagram.rb +149 -0
- data/lib/openlanes/presentation/documentation.rb +438 -0
- data/lib/openlanes/presentation/reporter.rb +181 -0
- data/lib/openlanes/presentation/viewer.rb +62 -0
- data/lib/openlanes/presentation.rb +23 -0
- data/lib/openlanes/skills/installer.rb +68 -0
- data/lib/openlanes/skills/skill.rb +60 -0
- data/lib/openlanes/skills.rb +25 -0
- data/lib/openlanes/support/config.rb +217 -0
- data/lib/openlanes/support/frontmatter.rb +38 -0
- data/lib/openlanes/support/markdown.rb +56 -0
- data/lib/openlanes/support/project.rb +79 -0
- data/lib/openlanes/support/ui.rb +671 -0
- data/lib/openlanes/vcs/github.rb +171 -0
- data/lib/openlanes/vcs/publisher.rb +221 -0
- data/lib/openlanes/vcs/worktree.rb +248 -0
- data/lib/openlanes/vcs.rb +19 -0
- data/lib/openlanes/version.rb +10 -0
- data/lib/openlanes/web/app.rb +176 -0
- data/lib/openlanes/web/context.rb +66 -0
- data/lib/openlanes/web/demo.rb +169 -0
- data/lib/openlanes/web/demo_board.rb +286 -0
- data/lib/openlanes/web/events.rb +104 -0
- data/lib/openlanes/web/launcher.rb +143 -0
- data/lib/openlanes/web/server.rb +77 -0
- data/lib/openlanes/web/stores.rb +36 -0
- data/lib/openlanes/web.rb +92 -0
- data/lib/openlanes.rb +151 -0
- data/openlanes.gemspec +87 -0
- data/schemas/plan-state.schema.json +101 -0
- data/skills/openlanes/SKILL.md +48 -0
- data/skills/openlanes-documents/SKILL.md +61 -0
- data/skills/openlanes-documents/references/blocked-template.md +49 -0
- data/skills/openlanes-documents/references/closing-templates.md +59 -0
- data/skills/openlanes-documents/references/plan-template.md +61 -0
- data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
- data/skills/openlanes-documents/references/spec-template.md +45 -0
- data/skills/openlanes-run/SKILL.md +90 -0
- data/skills/openlanes-setup/SKILL.md +64 -0
- data/skills/openlanes-spec/SKILL.md +90 -0
- data/skills/openlanes-status/SKILL.md +32 -0
- data/skills/openlanes-sync/SKILL.md +22 -0
- data/web/dist/assets/apl-B4CMkyY2.js +2 -0
- data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
- data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
- data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
- data/web/dist/assets/clike-B9uivgTg.js +2 -0
- data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
- data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
- data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
- data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
- data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
- data/web/dist/assets/crystal-SjHAIU92.js +2 -0
- data/web/dist/assets/css-BnMrqG3P.js +2 -0
- data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
- data/web/dist/assets/d-pRatUO7H.js +2 -0
- data/web/dist/assets/diff-DbItnlRl.js +2 -0
- data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
- data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
- data/web/dist/assets/dylan-DwRh75JA.js +2 -0
- data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
- data/web/dist/assets/ecl-Cabwm37j.js +2 -0
- data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
- data/web/dist/assets/elm-vLlmbW-K.js +2 -0
- data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
- data/web/dist/assets/factor-kuTfRLto.js +2 -0
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
- data/web/dist/assets/forth-Ffai-XNe.js +2 -0
- data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
- data/web/dist/assets/gas-Bneqetm1.js +2 -0
- data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
- data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
- data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
- data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
- data/web/dist/assets/http-DBlCnlav.js +2 -0
- data/web/dist/assets/idl-BEugSyMb.js +2 -0
- data/web/dist/assets/index-2xPC-eHt.js +2 -0
- data/web/dist/assets/index-8dNhRmiO.js +8 -0
- data/web/dist/assets/index-B7-O9ujQ.js +2 -0
- data/web/dist/assets/index-BdR34cXC.js +2 -0
- data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
- data/web/dist/assets/index-Byyxbh_z.js +4 -0
- data/web/dist/assets/index-C0VmkrO-.js +2 -0
- data/web/dist/assets/index-CI74IkWP.js +198 -0
- data/web/dist/assets/index-CYxcbpRM.js +3 -0
- data/web/dist/assets/index-D0rmJN3h.js +2 -0
- data/web/dist/assets/index-DB4QwuYn.js +2 -0
- data/web/dist/assets/index-DDI7zoAS.js +2 -0
- data/web/dist/assets/index-DGLUuEXi.js +2 -0
- data/web/dist/assets/index-OR-odsl9.js +2 -0
- data/web/dist/assets/index-P3vSkiU-.js +2 -0
- data/web/dist/assets/index-a4EHR-u4.css +1 -0
- data/web/dist/assets/index-mRV4XhJZ.js +2 -0
- data/web/dist/assets/index-popk9wbW.js +2 -0
- data/web/dist/assets/index-uM88Njdj.js +2 -0
- data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
- data/web/dist/assets/julia-DuME0IfC.js +2 -0
- data/web/dist/assets/livescript-BwQOo05w.js +2 -0
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- data/web/dist/assets/lua-VAEuO923.js +2 -0
- data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
- data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
- data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
- data/web/dist/assets/mllike-CXdrOF99.js +2 -0
- data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
- data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
- data/web/dist/assets/mumps-BT43cFF4.js +2 -0
- data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
- data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
- data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
- data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
- data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
- data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
- data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
- data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
- data/web/dist/assets/oz-BzwKVEFT.js +2 -0
- data/web/dist/assets/pascal--L3eBynH.js +2 -0
- data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
- data/web/dist/assets/pig-CevX1Tat.js +2 -0
- data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
- data/web/dist/assets/properties-C78fOPTZ.js +2 -0
- data/web/dist/assets/protobuf-ChK-085T.js +2 -0
- data/web/dist/assets/pug-DeIclll2.js +2 -0
- data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
- data/web/dist/assets/python-BuPzkPfP.js +2 -0
- data/web/dist/assets/q-pXgVlZs6.js +2 -0
- data/web/dist/assets/r-B6wPVr8A.js +2 -0
- data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
- data/web/dist/assets/ruby-BK5aLJev.js +2 -0
- data/web/dist/assets/sas-B4kiWyti.js +2 -0
- data/web/dist/assets/scheme-C41bIUwD.js +2 -0
- data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
- data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
- data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
- data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
- data/web/dist/assets/solr-DehyRSwq.js +2 -0
- data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
- data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
- data/web/dist/assets/sql-D0XecflT.js +2 -0
- data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
- data/web/dist/assets/stylus-B533Al4x.js +2 -0
- data/web/dist/assets/swift-BzpIVaGY.js +2 -0
- data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
- data/web/dist/assets/textile-CnDTJFAw.js +2 -0
- data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
- data/web/dist/assets/tiki-DGYXhP31.js +2 -0
- data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
- data/web/dist/assets/troff-wAsdV37c.js +2 -0
- data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
- data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
- data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
- data/web/dist/assets/vb-CmGdzxic.js +2 -0
- data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
- data/web/dist/assets/velocity-D8B20fx6.js +2 -0
- data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
- data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
- data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
- data/web/dist/assets/xquery-DzFWVndE.js +2 -0
- data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
- data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
- data/web/dist/index.html +14 -0
- data/web/dist/vite.svg +1 -0
- data/web/src/api/fixtures/board.json +512 -0
- metadata +907 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# The engine's side of the command stream (W13). Called at the top of
|
|
9
|
+
# every tick, on the loop thread, it reads what the page sent through the
|
|
10
|
+
# `engine` consumer group, applies each command to the dispatcher, and
|
|
11
|
+
# answers it.
|
|
12
|
+
#
|
|
13
|
+
# Delivery is at least once: a command is acknowledged only after it has
|
|
14
|
+
# been applied, in the same `MULTI` that records its reply in `done` and
|
|
15
|
+
# appends it to `replies`. A command whose id is already in `done` was
|
|
16
|
+
# applied before the engine died, so it is acknowledged and its first
|
|
17
|
+
# reply sent again, and nothing is applied twice.
|
|
18
|
+
#
|
|
19
|
+
# Each kind is one method, named in {HANDLERS}. A handler raises
|
|
20
|
+
# {Refusal} with the reason a person will read; anything it returns is
|
|
21
|
+
# a reason to show beside "done".
|
|
22
|
+
class Commands
|
|
23
|
+
# A command the engine will not apply, with the reason why.
|
|
24
|
+
class Refusal < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Commands read per tick, so a flood cannot hold the loop.
|
|
27
|
+
BATCH = 50
|
|
28
|
+
|
|
29
|
+
# Seconds between sweeps of `done` for entries older than a day.
|
|
30
|
+
SWEEP_EVERY = 600
|
|
31
|
+
|
|
32
|
+
# Command kind to the method that applies it.
|
|
33
|
+
HANDLERS = {
|
|
34
|
+
"answer" => :answer,
|
|
35
|
+
"hold_blocked" => :hold_blocked,
|
|
36
|
+
"release_hold" => :release_hold,
|
|
37
|
+
"resume" => :resume,
|
|
38
|
+
"discard" => :discard,
|
|
39
|
+
"message" => :message,
|
|
40
|
+
"hold" => :signal,
|
|
41
|
+
"wrap_up" => :signal,
|
|
42
|
+
"compact" => :compact,
|
|
43
|
+
"stop" => :signal,
|
|
44
|
+
"stop_all" => :stop_all,
|
|
45
|
+
"set_model" => :set_model,
|
|
46
|
+
"switch_model" => :switch_model,
|
|
47
|
+
"add_plan" => :add_plan,
|
|
48
|
+
"create_plans" => :create_plans
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# Plans a person may not add to a run: there is nothing left to do.
|
|
52
|
+
FINISHED = Snapshot::FINISHED
|
|
53
|
+
|
|
54
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
55
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
56
|
+
# @param node [OpenLanes::Live::Node]
|
|
57
|
+
# @param run_state [OpenLanes::Live::RunState]
|
|
58
|
+
# @param record [OpenLanes::Live::RunRecord, nil] writes the run state
|
|
59
|
+
# in the same `MULTI` as each reply
|
|
60
|
+
# @param claims [OpenLanes::Live::Claims, nil]
|
|
61
|
+
# @param clock [Proc] wall time
|
|
62
|
+
def initialize(store:, keys:, node:, run_state: RunState.new, record: nil, claims: nil, clock: -> { Time.now })
|
|
63
|
+
@store = store
|
|
64
|
+
@keys = keys
|
|
65
|
+
@node = node
|
|
66
|
+
@run_state = run_state
|
|
67
|
+
@record = record
|
|
68
|
+
@claims = claims
|
|
69
|
+
@clock = clock
|
|
70
|
+
@ready = false
|
|
71
|
+
@swept_at = nil
|
|
72
|
+
@pending = {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Apply everything waiting: this consumer's unacknowledged commands
|
|
76
|
+
# first, which a previous engine read and died on, then new ones.
|
|
77
|
+
#
|
|
78
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
79
|
+
# @return [Array<OpenLanes::Live::Reply>] what was answered
|
|
80
|
+
def call(dispatcher)
|
|
81
|
+
prepare
|
|
82
|
+
replies = collect
|
|
83
|
+
entries = read("0").reject { @pending.key?(it.id) }
|
|
84
|
+
entries += read(">") if entries.size < BATCH
|
|
85
|
+
replies += entries.filter_map { |entry| handle(entry, dispatcher) }
|
|
86
|
+
# Whatever a command changed is acted on this tick, not at the end
|
|
87
|
+
# of an idle run's rescan interval.
|
|
88
|
+
dispatcher.wake! unless entries.empty? && replies.empty?
|
|
89
|
+
sweep
|
|
90
|
+
replies
|
|
91
|
+
rescue Unavailable => e
|
|
92
|
+
UI.log("commands not read: #{e.message}")
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Proc] for {Engine::Dispatcher#before_tick}
|
|
97
|
+
def to_proc = method(:call).to_proc
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# @return [void]
|
|
102
|
+
def prepare
|
|
103
|
+
return if @ready
|
|
104
|
+
|
|
105
|
+
@store.xgroup_create(@keys.commands, Keys::GROUP, from: "0")
|
|
106
|
+
@ready = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param id [String] "0" for this consumer's pending, ">" for new
|
|
110
|
+
# @return [Array<OpenLanes::Live::Store::Entry>]
|
|
111
|
+
def read(id) = @store.xreadgroup(@keys.commands, Keys::GROUP, @node.name, id:, count: BATCH)
|
|
112
|
+
|
|
113
|
+
# Answer the commands whose work finished since the last tick. A
|
|
114
|
+
# command still at work is neither answered nor acknowledged, so an
|
|
115
|
+
# engine that dies meanwhile gets it again.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<OpenLanes::Live::Reply>]
|
|
118
|
+
def collect
|
|
119
|
+
@pending.select { |_, (_, _, work)| work.finished? }.map do |entry_id, (entry, id, work)|
|
|
120
|
+
@pending.delete(entry_id)
|
|
121
|
+
reply(entry, id, work.status, work.reason)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
126
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher]
|
|
127
|
+
# @return [OpenLanes::Live::Reply, nil] nil while the command is still at work
|
|
128
|
+
def handle(entry, dispatcher)
|
|
129
|
+
command = Command.parse(entry.fields["json"].to_s)
|
|
130
|
+
if (first = @store.hget(@keys.done, command.id))
|
|
131
|
+
return answer_again(entry, first)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
status, reason = apply(command, dispatcher)
|
|
135
|
+
return reply(entry, command.id, status, reason) unless status == :pending
|
|
136
|
+
|
|
137
|
+
@pending[entry.id] = [entry, command.id, reason]
|
|
138
|
+
nil
|
|
139
|
+
rescue Command::Invalid => e
|
|
140
|
+
reply(entry, invalid_id(entry), "refused", e.message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Array(String, String)] status and reason, or `:pending` and
|
|
144
|
+
# the work still under way
|
|
145
|
+
def apply(command, dispatcher)
|
|
146
|
+
reason = send(HANDLERS.fetch(command.kind), command, dispatcher)
|
|
147
|
+
return [:pending, reason] if reason.respond_to?(:finished?)
|
|
148
|
+
|
|
149
|
+
["done", reason.is_a?(String) ? reason : nil]
|
|
150
|
+
rescue Refusal, OpenLanes::Error => e
|
|
151
|
+
["refused", e.message]
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
UI.log("#{command.kind} failed: #{e.class}: #{e.message}", plan: command.plan)
|
|
154
|
+
["refused", "#{command.kind} failed: #{e.message}"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [OpenLanes::Live::Reply]
|
|
158
|
+
def reply(entry, id, status, reason)
|
|
159
|
+
reply = Reply.new(id:, status:, reason:, at: @clock.call)
|
|
160
|
+
json = reply.to_json
|
|
161
|
+
@store.multi do |tx|
|
|
162
|
+
tx.hset(@keys.done, id, json)
|
|
163
|
+
tx.xadd(@keys.replies, { json: }, maxlen: Keys::REPLIES_LENGTH)
|
|
164
|
+
tx.xack(@keys.commands, Keys::GROUP, entry.id)
|
|
165
|
+
@record&.write(tx)
|
|
166
|
+
end
|
|
167
|
+
reply
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @return [OpenLanes::Live::Reply] the first reply, sent again
|
|
171
|
+
def answer_again(entry, first)
|
|
172
|
+
@store.multi do |tx|
|
|
173
|
+
tx.xadd(@keys.replies, { json: first }, maxlen: Keys::REPLIES_LENGTH)
|
|
174
|
+
tx.xack(@keys.commands, Keys::GROUP, entry.id)
|
|
175
|
+
end
|
|
176
|
+
data = JSON.parse(first)
|
|
177
|
+
Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A command too malformed to carry an id is answered under its stream
|
|
181
|
+
# entry's id, so the reply still lands somewhere a person can see.
|
|
182
|
+
#
|
|
183
|
+
# @return [String]
|
|
184
|
+
def invalid_id(entry)
|
|
185
|
+
id = JSON.parse(entry.fields["json"].to_s)["id"].to_s
|
|
186
|
+
id.match?(Command::ID) ? id : entry.id
|
|
187
|
+
rescue JSON::ParserError, TypeError, NoMethodError
|
|
188
|
+
entry.id
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Forget replies older than {Keys::DONE_TTL}; a command redelivered a
|
|
192
|
+
# day late has bigger problems than being applied twice.
|
|
193
|
+
#
|
|
194
|
+
# @return [void]
|
|
195
|
+
def sweep
|
|
196
|
+
now = @clock.call
|
|
197
|
+
return if @swept_at && now - @swept_at < SWEEP_EVERY
|
|
198
|
+
|
|
199
|
+
@swept_at = now
|
|
200
|
+
stale = @store.hgetall(@keys.done).reject { |_, json| (at = reply_time(json)) && now - at <= Keys::DONE_TTL }
|
|
201
|
+
@store.hdel(@keys.done, *stale.keys)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @param json [String] a reply in `done`
|
|
205
|
+
# @return [Time, nil] when it was answered; nil for an entry nothing can
|
|
206
|
+
# read, which the sweep removes rather than letting it stop the sweep
|
|
207
|
+
def reply_time(json)
|
|
208
|
+
Time.iso8601(JSON.parse(json).fetch("at"))
|
|
209
|
+
rescue JSON::ParserError, ArgumentError, TypeError, KeyError, NoMethodError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ---- questions --------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
# `## A<n>` under the question, signed, for `lando-broker` to fold in
|
|
216
|
+
# (W10). The dispatcher hands the plan to the broker once every
|
|
217
|
+
# question has an answer, and the plan leaves ⭕️ when the file is
|
|
218
|
+
# drained. The answer carries the command's id in a comment, so a
|
|
219
|
+
# command applied again after a crash finds it and writes nothing.
|
|
220
|
+
#
|
|
221
|
+
# @return [String, nil]
|
|
222
|
+
def answer(command, dispatcher)
|
|
223
|
+
subject, question = question!(command, dispatcher)
|
|
224
|
+
path = File.join(subject.feature.path, "blocked.md")
|
|
225
|
+
return nil if File.read(path).include?(marker(command))
|
|
226
|
+
return "B#{question.number} already has an answer waiting" if question.answered
|
|
227
|
+
|
|
228
|
+
File.open(path, "a") do |file|
|
|
229
|
+
file.write("\n## A#{question.number}\n\n#{command["text"]}\n\n" \
|
|
230
|
+
"Answered by #{@node.user} on #{@clock.call.strftime("%Y-%m-%d")}\n#{marker(command)}\n")
|
|
231
|
+
end
|
|
232
|
+
@run_state.hold_question(question_id(subject, question), held: false)
|
|
233
|
+
dispatcher.wake!
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @return [nil]
|
|
238
|
+
def hold_blocked(command, dispatcher) = hold_question(command, dispatcher, held: true)
|
|
239
|
+
|
|
240
|
+
# @return [nil]
|
|
241
|
+
def release_hold(command, dispatcher) = hold_question(command, dispatcher, held: false)
|
|
242
|
+
|
|
243
|
+
# @return [nil]
|
|
244
|
+
def hold_question(command, dispatcher, held:)
|
|
245
|
+
subject, question = question!(command, dispatcher)
|
|
246
|
+
@run_state.hold_question(question_id(subject, question), held:)
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @return [Array(OpenLanes::Plans::Subject, OpenLanes::Lifecycle::Unblocker::Question)]
|
|
251
|
+
def question!(command, dispatcher)
|
|
252
|
+
subject = subject!(command, dispatcher)
|
|
253
|
+
number = command["question"][/\AB?(\d+)\z/i, 1] or raise Refusal, "#{command["question"]} is not a question number like B2"
|
|
254
|
+
question = Lifecycle::Unblocker.questions(subject).find { it.number == number.to_i } or
|
|
255
|
+
raise Refusal, "#{command.plan} has no open question B#{number}"
|
|
256
|
+
[subject, question]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# @return [String]
|
|
260
|
+
def question_id(subject, question) = "#{subject.feature.ordinal}/B#{question.number}"
|
|
261
|
+
|
|
262
|
+
# ---- agents -----------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
# The agent the plan's history says was interrupted is started again,
|
|
265
|
+
# with the person's note waiting in its mailbox.
|
|
266
|
+
#
|
|
267
|
+
# @return [String]
|
|
268
|
+
def resume(command, dispatcher)
|
|
269
|
+
subject = subject!(command, dispatcher)
|
|
270
|
+
last = Plans::PlanState.for(subject.feature.path).stages.rfind { it["status"] == "Interrupted" }
|
|
271
|
+
agent = command.agent.to_s.empty? ? last&.fetch("agent", nil) : command.agent
|
|
272
|
+
raise Refusal, "#{command.plan} has no interrupted agent to resume" unless agent
|
|
273
|
+
|
|
274
|
+
note = command["note"].empty? ? "carry on from your last resume note" : command["note"]
|
|
275
|
+
Plans::Mailbox.new(dir: subject.feature.path).append(from: "human",
|
|
276
|
+
to: agent,
|
|
277
|
+
body: "RESUME: #{note}",
|
|
278
|
+
command: command.id)
|
|
279
|
+
dispatcher.resume(subject.feature.ordinal, agent)
|
|
280
|
+
dispatcher.runner.add_plan(subject.feature.ordinal)
|
|
281
|
+
dispatcher.wake!
|
|
282
|
+
"#{agent} resumes on #{command.plan} at its next turn"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Plan-wide (W9): every agent on the plan stops, its worktree is reset,
|
|
286
|
+
# and the folder goes back one state. The reply waits for all three.
|
|
287
|
+
#
|
|
288
|
+
# @return [OpenLanes::Engine::Dispatcher::Discard] still at work
|
|
289
|
+
def discard(command, dispatcher)
|
|
290
|
+
subject = subject!(command, dispatcher)
|
|
291
|
+
dispatcher.discard(subject.feature.ordinal)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A person's message, into the plan's mailbox, from `human` (W10). To
|
|
295
|
+
# the agent named, or every agent working the plan when none is. Each
|
|
296
|
+
# entry carries the command's id, so a command redelivered after the
|
|
297
|
+
# engine died between the append and its reply appends nothing (W13).
|
|
298
|
+
#
|
|
299
|
+
# @return [nil]
|
|
300
|
+
def message(command, dispatcher)
|
|
301
|
+
subject = subject!(command, dispatcher)
|
|
302
|
+
to = command.agent.to_s.empty? ? running_on(dispatcher, command.plan) : [command.agent]
|
|
303
|
+
raise Refusal, "no agent named, and none is working #{command.plan}" if to.empty?
|
|
304
|
+
|
|
305
|
+
mailbox = Plans::Mailbox.new(dir: subject.feature.path)
|
|
306
|
+
to.each do |agent|
|
|
307
|
+
mailbox.append(from: "human", to: agent, body: command["text"], command: command.id)
|
|
308
|
+
@run_state.hold_car("#{command.plan}/#{agent}", held: false)
|
|
309
|
+
end
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# HOLD, WRAP_UP or STOP into one agent's control file.
|
|
314
|
+
#
|
|
315
|
+
# @return [nil]
|
|
316
|
+
def signal(command, dispatcher)
|
|
317
|
+
dispatcher.signal(command.car, Execution::Control::WORDS.fetch(command.kind)) or
|
|
318
|
+
raise Refusal, "#{command.car} is not running"
|
|
319
|
+
@run_state.hold_car(command.car, held: command.kind == "hold")
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Stop All Local: every agent on this machine is told to stop, and once
|
|
324
|
+
# they have gone the engine ends, and the control room with it. That is
|
|
325
|
+
# the one way a run with its control room ends from the page; it never
|
|
326
|
+
# ends on its own, since the page can always create more work.
|
|
327
|
+
# Other machines keep running.
|
|
328
|
+
#
|
|
329
|
+
# @return [String]
|
|
330
|
+
def stop_all(_command, dispatcher)
|
|
331
|
+
Execution::Control.quit!
|
|
332
|
+
count = dispatcher.running.size
|
|
333
|
+
"stopping #{count} agent#{"s" unless count == 1} on #{@node.label}; " \
|
|
334
|
+
"the engine and the control room shut down once they have gone"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The model for the agent's next round on this plan (W24).
|
|
338
|
+
#
|
|
339
|
+
# @return [String]
|
|
340
|
+
def set_model(command, dispatcher)
|
|
341
|
+
id = model!(command, dispatcher)
|
|
342
|
+
dispatcher.prefer_model(command.plan, command.agent, id) or
|
|
343
|
+
raise Refusal, "this run's executor cannot change an agent's model"
|
|
344
|
+
@run_state.choose_model(command.car, id)
|
|
345
|
+
"#{command.agent} runs on #{id} from its next round"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Compact the agent's session now (W28): it stops at its next step,
|
|
349
|
+
# leaving a resume note, its session is compacted with `--resume`, and
|
|
350
|
+
# it carries on in that session on the same model. Answered once the
|
|
351
|
+
# continuation has started.
|
|
352
|
+
#
|
|
353
|
+
# @return [OpenLanes::Execution::Executor::Compaction] still at work
|
|
354
|
+
# @raise [Refusal] when it cannot be compacted
|
|
355
|
+
def compact(command, dispatcher)
|
|
356
|
+
job = dispatcher.job(command.car) or raise Refusal, "#{command.car} is not running"
|
|
357
|
+
adapter = dispatcher.adapter_for(job)
|
|
358
|
+
raise Refusal, "#{adapter.name} cannot resume a session, so #{command.agent} cannot be compacted" unless adapter.compacts?
|
|
359
|
+
return dispatcher.compact(command.car) if job.handle.session
|
|
360
|
+
|
|
361
|
+
# Nothing is compacted, so the reply is a refusal, though the agent
|
|
362
|
+
# still restarts in a fresh session, which is as compact as it gets.
|
|
363
|
+
dispatcher.requeue(command.car)
|
|
364
|
+
raise Refusal,
|
|
365
|
+
"#{command.agent} has no session id yet, so it cannot be compacted: it stops at its next step " \
|
|
366
|
+
"and restarts from its resume note in a fresh session"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The model now (W27): the agent is stopped with a resume note, its
|
|
370
|
+
# session compacted, and carried on in it on the new model (W28). An
|
|
371
|
+
# agent whose session cannot be resumed is started again on the model
|
|
372
|
+
# from its resume note instead, in a fresh session.
|
|
373
|
+
#
|
|
374
|
+
# @return [OpenLanes::Execution::Executor::Compaction, String]
|
|
375
|
+
def switch_model(command, dispatcher)
|
|
376
|
+
job = dispatcher.job(command.car) or
|
|
377
|
+
raise Refusal, "#{command.car} is not running; use set_model for its next round"
|
|
378
|
+
|
|
379
|
+
set_model(command, dispatcher)
|
|
380
|
+
model = @run_state.model_for(command.car)
|
|
381
|
+
adapter = dispatcher.adapter_for(job)
|
|
382
|
+
return dispatcher.compact(command.car, model:) if adapter.compacts? && job.handle.session
|
|
383
|
+
|
|
384
|
+
dispatcher.requeue(command.car)
|
|
385
|
+
why = adapter.compacts? ? "it has no session id yet" : "#{adapter.name} cannot resume a session"
|
|
386
|
+
"#{command.agent} stops at its next step and restarts on #{model} from its resume note " \
|
|
387
|
+
"in a fresh session: #{why}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# @return [String] the full model id
|
|
391
|
+
def model!(command, dispatcher)
|
|
392
|
+
subject!(command, dispatcher)
|
|
393
|
+
agent = dispatcher.runner.agents.find(command.agent) or raise Refusal, "no agent called #{command.agent}"
|
|
394
|
+
adapter = Adapters.for(agent.adapter)
|
|
395
|
+
return command["model"] if adapter.models.empty?
|
|
396
|
+
|
|
397
|
+
entry = adapter.model_entry(command["model"]) or raise Refusal, "#{adapter.name} offers no model #{command["model"]}"
|
|
398
|
+
# What the CLI will actually be asked for, after any ceiling, so the
|
|
399
|
+
# reply and the run state never name a model that will not run.
|
|
400
|
+
adapter.model(entry.id)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# ---- plans ------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# @return [String, nil]
|
|
406
|
+
def add_plan(command, dispatcher)
|
|
407
|
+
ordinal = Plans::Ordinal.parse(command["ordinal"]) or raise Refusal, "#{command["ordinal"]} is not a plan number"
|
|
408
|
+
subject = dispatcher.runner.tree.reload.find(ordinal) or raise Refusal, "there is no plan #{ordinal}"
|
|
409
|
+
raise Refusal, "#{ordinal} is #{subject.status}, with nothing left to do" if FINISHED.include?(subject.status.key)
|
|
410
|
+
|
|
411
|
+
claim!(ordinal)
|
|
412
|
+
dispatcher.runner.add_plan(ordinal)
|
|
413
|
+
dispatcher.wake!
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The idea becomes a ⚪️ plan whose spec.md opens with it, then joins the run.
|
|
418
|
+
#
|
|
419
|
+
# @return [String]
|
|
420
|
+
def create_plans(command, dispatcher)
|
|
421
|
+
tree = dispatcher.runner.tree
|
|
422
|
+
idea = command["idea"]
|
|
423
|
+
if (made = tree.reload.subjects.find { it.read("spec.md").to_s.include?(marker(command)) })
|
|
424
|
+
return created(made.feature, dispatcher)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
path = Lifecycle.create(dir: tree.dir, words: idea.split.first(8)).either(
|
|
428
|
+
->(created) { created },
|
|
429
|
+
->(problem) { raise Refusal, "could not create the plan: #{problem}" }
|
|
430
|
+
)
|
|
431
|
+
feature = Plans::Feature.parse(path)
|
|
432
|
+
spec = Lifecycle.brief(path:, title: feature.title, root: File.dirname(tree.dir), seed: "#{idea}\n\n#{marker(command)}")
|
|
433
|
+
.write_scaffold!
|
|
434
|
+
settings = idea_frontmatter(command.payload["frontmatter"])
|
|
435
|
+
File.write(spec, "#{YAML.dump(settings)}---\n\n#{File.read(spec)}") unless settings.empty?
|
|
436
|
+
created(feature, dispatcher)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# The idea dialog's form (W19) as spec.md frontmatter, in
|
|
440
|
+
# {Plans::Spec}'s vocabulary. It used to be sent and dropped, so a plan
|
|
441
|
+
# the page asked for in the quick lane at depth fast ran the full lane
|
|
442
|
+
# on every agent's default model. `auto` means "let the plan decide",
|
|
443
|
+
# which is what leaving the key out already says, and `research` has
|
|
444
|
+
# no key in spec.md to land in, so neither is written. Anything else
|
|
445
|
+
# is passed through as typed: {Plans::Spec#problems} names a bad
|
|
446
|
+
# value, the way it does for one typed by hand.
|
|
447
|
+
#
|
|
448
|
+
# @param form [Hash, nil] `payload.frontmatter`, as the page sends it
|
|
449
|
+
# @return [Hash{String => Object}] empty when there is nothing to say
|
|
450
|
+
def idea_frontmatter(form)
|
|
451
|
+
return {} unless form.is_a?(Hash)
|
|
452
|
+
|
|
453
|
+
form = form.transform_keys(&:to_s)
|
|
454
|
+
form.slice("lane", "frontend", "depth").each_with_object({}) do |(key, value), out|
|
|
455
|
+
text = value.to_s.strip
|
|
456
|
+
next if text.empty? || text == "auto"
|
|
457
|
+
|
|
458
|
+
out[key] = key == "frontend" && %w[true false].include?(text) ? text == "true" : text
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# @return [String]
|
|
463
|
+
def created(feature, dispatcher)
|
|
464
|
+
claim!(feature.ordinal)
|
|
465
|
+
dispatcher.runner.add_plan(feature.ordinal)
|
|
466
|
+
dispatcher.wake!
|
|
467
|
+
"created #{feature.ordinal} #{feature.slug}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# What a file written by a command carries, so the command applied a
|
|
471
|
+
# second time finds its own work and does not repeat it (W13).
|
|
472
|
+
#
|
|
473
|
+
# @param command [OpenLanes::Live::Command]
|
|
474
|
+
# @return [String]
|
|
475
|
+
def marker(command) = "<!-- cmd:#{command.id} -->"
|
|
476
|
+
|
|
477
|
+
# @return [void]
|
|
478
|
+
def claim!(ordinal)
|
|
479
|
+
return if @claims.nil? || @claims.claim(ordinal)
|
|
480
|
+
|
|
481
|
+
raise Refusal, "#{ordinal} is being worked on #{holder_label(@claims.held_by(ordinal))}"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# @param holder [String, nil] a node name
|
|
485
|
+
# @return [String] `user@host` when the holder's heartbeat says
|
|
486
|
+
def holder_label(holder)
|
|
487
|
+
json = holder && @store.hget(@keys.nodes, holder)
|
|
488
|
+
json ? JSON.parse(json).values_at("user", "host").join("@") : "another machine"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# ---- lookups ----------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
# @return [OpenLanes::Plans::Subject]
|
|
494
|
+
def subject!(command, dispatcher)
|
|
495
|
+
ordinal = Plans::Ordinal.parse(command.plan.to_s) or raise Refusal, "#{command.plan} is not a plan number"
|
|
496
|
+
dispatcher.runner.tree.reload.find(ordinal) or raise Refusal, "there is no plan #{command.plan}"
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# @return [Array<String>] agents working the plan now
|
|
500
|
+
def running_on(dispatcher, plan)
|
|
501
|
+
dispatcher.running.filter_map { |job| job.task.agent.name if job.key.start_with?("#{plan}/") }
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# Every Redis key openlanes writes, built here and nowhere else, as
|
|
6
|
+
# `.plans/004.00/contract.md` lays them out. `P` is
|
|
7
|
+
# `openlanes:<repo>`; a node is `<node id>:<checkout id>`.
|
|
8
|
+
#
|
|
9
|
+
# Methods that name one node's key take that node, defaulting to this
|
|
10
|
+
# one, so the API server reads another machine's snapshot through the
|
|
11
|
+
# same method the engine writes its own with.
|
|
12
|
+
#
|
|
13
|
+
# @!attribute [r] repo
|
|
14
|
+
# @return [String] `github.com/owner/name`
|
|
15
|
+
# @!attribute [r] node
|
|
16
|
+
# @return [String] this node's `<node id>:<checkout id>`
|
|
17
|
+
Keys = Data.define(:repo, :node) do
|
|
18
|
+
# @return [String] `openlanes:<repo>`
|
|
19
|
+
def base = "#{Keys::NAMESPACE}:#{repo}"
|
|
20
|
+
|
|
21
|
+
# Hash: node name to its `Node` JSON, refreshed every heartbeat.
|
|
22
|
+
#
|
|
23
|
+
# @return [String]
|
|
24
|
+
def nodes = "#{base}:nodes"
|
|
25
|
+
|
|
26
|
+
# String: the latest `Snapshot` JSON, `EX 30`. A node is alive while it exists.
|
|
27
|
+
#
|
|
28
|
+
# @param of [String] a node name
|
|
29
|
+
# @return [String]
|
|
30
|
+
def snapshot(of = node) = "#{base}:#{of}:snapshot"
|
|
31
|
+
|
|
32
|
+
# Stream: `{seq, json}` per published snapshot.
|
|
33
|
+
#
|
|
34
|
+
# @param of [String]
|
|
35
|
+
# @return [String]
|
|
36
|
+
def board(of = node) = "#{base}:#{of}:board"
|
|
37
|
+
|
|
38
|
+
# Stream: `{json}`, one `Command`, read by the engine through {Keys::GROUP}.
|
|
39
|
+
#
|
|
40
|
+
# @param of [String]
|
|
41
|
+
# @return [String]
|
|
42
|
+
def commands(of = node) = "#{base}:#{of}:commands"
|
|
43
|
+
|
|
44
|
+
# Stream: `{json}`, one `Reply`.
|
|
45
|
+
#
|
|
46
|
+
# @param of [String]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def replies(of = node) = "#{base}:#{of}:replies"
|
|
49
|
+
|
|
50
|
+
# Hash: command id to its first `Reply` JSON, for redelivery.
|
|
51
|
+
#
|
|
52
|
+
# @param of [String]
|
|
53
|
+
# @return [String]
|
|
54
|
+
def done(of = node) = "#{base}:#{of}:done"
|
|
55
|
+
|
|
56
|
+
# Hash: `seq` and `json`, the run state a person set from the page
|
|
57
|
+
# (held questions and cars, queued models), so a restarted engine
|
|
58
|
+
# gets it back (W6). {RunRecord} writes it; the engine flushes it to
|
|
59
|
+
# `.plans/openlanes-state.json` as well.
|
|
60
|
+
#
|
|
61
|
+
# @param of [String]
|
|
62
|
+
# @return [String]
|
|
63
|
+
def run(of = node) = "#{base}:#{of}:run"
|
|
64
|
+
|
|
65
|
+
# String: the node name holding a plan, `SET NX EX`.
|
|
66
|
+
#
|
|
67
|
+
# @param ordinal [#to_s] e.g. "004.00"
|
|
68
|
+
# @return [String]
|
|
69
|
+
def claim(ordinal) = "#{base}:claim:#{ordinal}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class Keys
|
|
73
|
+
# What every key starts with, so `SCAN openlanes:*` finds all of it.
|
|
74
|
+
NAMESPACE = "openlanes"
|
|
75
|
+
|
|
76
|
+
# The consumer group the engine reads `commands` through.
|
|
77
|
+
GROUP = "engine"
|
|
78
|
+
|
|
79
|
+
# Lengths of {#board} and {#replies}, kept approximately.
|
|
80
|
+
BOARD_LENGTH = 100
|
|
81
|
+
REPLIES_LENGTH = 1000
|
|
82
|
+
|
|
83
|
+
# Seconds a snapshot outlives its last refresh.
|
|
84
|
+
SNAPSHOT_TTL = 30
|
|
85
|
+
|
|
86
|
+
# Seconds a claim outlives its last renewal.
|
|
87
|
+
CLAIM_TTL = 90
|
|
88
|
+
|
|
89
|
+
# Seconds a `done` entry is kept before it is trimmed.
|
|
90
|
+
DONE_TTL = 86_400
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|