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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
require "sinatra/base"
|
|
7
|
+
|
|
8
|
+
module OpenLanes
|
|
9
|
+
module Web
|
|
10
|
+
# The routes of contract.md, "HTTP". One subclass per server, built by
|
|
11
|
+
# {.serving}, carries that server's {Context} as a setting, so two apps in
|
|
12
|
+
# one process (the suite) never share a token or a store.
|
|
13
|
+
class App < Sinatra::Base
|
|
14
|
+
# The plan files the drawer and the preview may read. A name is looked
|
|
15
|
+
# up in this list and never joined onto a path as typed, which is what
|
|
16
|
+
# makes `../` meaningless here rather than merely refused.
|
|
17
|
+
PLAN_FILES = %w[spec.md plan.md blocked.md research.md design.md].freeze
|
|
18
|
+
|
|
19
|
+
# Host headers the server answers. Loopback only, so a page on another
|
|
20
|
+
# site cannot reach it through DNS rebinding.
|
|
21
|
+
LOCAL_HOSTS = ["localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
|
|
22
|
+
|
|
23
|
+
# What `/` says when the page was never built.
|
|
24
|
+
UNBUILT = <<~HTML
|
|
25
|
+
<!doctype html>
|
|
26
|
+
<html lang="en"><head><meta charset="utf-8"><title>openlanes: page not built</title></head>
|
|
27
|
+
<body><h1>The control room page is not built</h1>
|
|
28
|
+
<p>Run <code>just web-build</code> first, then reload.</p></body></html>
|
|
29
|
+
HTML
|
|
30
|
+
|
|
31
|
+
configure do
|
|
32
|
+
set :environment, :production
|
|
33
|
+
set :show_exceptions, false
|
|
34
|
+
set :raise_errors, false
|
|
35
|
+
set :dump_errors, false
|
|
36
|
+
set :logging, false
|
|
37
|
+
set :static, true
|
|
38
|
+
set :static_cache_control, [:public, { max_age: 3600 }]
|
|
39
|
+
# Rack::Protection's defaults (frame options, origin check on writes,
|
|
40
|
+
# JSON CSRF, path traversal, IP spoofing, XSS header), answering 403.
|
|
41
|
+
# Sinatra's own reaction is to drop the session and carry on, which
|
|
42
|
+
# with no session is no reaction at all. The token is the guard the
|
|
43
|
+
# session would otherwise have been.
|
|
44
|
+
set :protection, except: %i[remote_token], reaction: :deny
|
|
45
|
+
set :host_authorization, { permitted_hosts: LOCAL_HOSTS }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# @param context [OpenLanes::Web::Context]
|
|
50
|
+
# @return [Class] a subclass serving that context
|
|
51
|
+
def serving(context)
|
|
52
|
+
Class.new(self) do
|
|
53
|
+
set :context, context
|
|
54
|
+
set :public_folder, context.dist
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
helpers do
|
|
60
|
+
# @return [OpenLanes::Web::Context]
|
|
61
|
+
def context = settings.context
|
|
62
|
+
|
|
63
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
64
|
+
def store = context.store
|
|
65
|
+
|
|
66
|
+
# @param data [Object]
|
|
67
|
+
# @return [String]
|
|
68
|
+
def json(data)
|
|
69
|
+
content_type :json
|
|
70
|
+
JSON.generate(data)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def csrf? = Rack::Utils.secure_compare(request.env["HTTP_X_CSRF_TOKEN"].to_s, context.csrf)
|
|
75
|
+
|
|
76
|
+
# The machine this server speaks for, as the engine last described
|
|
77
|
+
# it, so the session and the board never disagree about who "me" is.
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash, nil]
|
|
80
|
+
def node
|
|
81
|
+
snapshot = store.get(context.keys.snapshot)
|
|
82
|
+
return JSON.parse(snapshot)["node"] if snapshot
|
|
83
|
+
|
|
84
|
+
context.node&.wire(heartbeat_at: Time.now)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param body [String]
|
|
88
|
+
# @return [String] the command's id when it has a usable one
|
|
89
|
+
def id_in(body)
|
|
90
|
+
id = JSON.parse(body)["id"].to_s
|
|
91
|
+
id.match?(Live::Command::ID) ? id : ""
|
|
92
|
+
rescue JSON::ParserError, TypeError, NoMethodError
|
|
93
|
+
""
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
before "/api/*" do
|
|
98
|
+
cache_control :no_store
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
error Live::Unavailable do
|
|
102
|
+
status 503
|
|
103
|
+
json(error: env["sinatra.error"].message)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
get "/healthz" do
|
|
107
|
+
store.ping
|
|
108
|
+
content_type :text
|
|
109
|
+
"ok #{Live.mask(context.url)}\n"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
get "/api/session" do
|
|
113
|
+
json(csrf: context.csrf, node:, repo: context.keys.repo)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
get "/api/board" do
|
|
117
|
+
json(Live.board(store:, keys: context.keys))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
get "/api/events" do
|
|
121
|
+
content_type "text/event-stream"
|
|
122
|
+
headers "Cache-Control" => "no-cache, no-transform", "X-Accel-Buffering" => "no"
|
|
123
|
+
events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"])
|
|
124
|
+
stream { |out| events.run(out) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
post "/api/commands" do
|
|
128
|
+
body = request.body.read
|
|
129
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
130
|
+
|
|
131
|
+
reply = Live.enqueue(store:, keys: context.keys, input: body)
|
|
132
|
+
status 202
|
|
133
|
+
json(reply.wire)
|
|
134
|
+
rescue Live::Command::Invalid => e
|
|
135
|
+
status 422
|
|
136
|
+
json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Demo only: the board back to the fixture, for a test that wants a
|
|
140
|
+
# known start. A real engine's run has no such thing.
|
|
141
|
+
post "/api/demo/reset" do
|
|
142
|
+
halt 404, json(error: "no such endpoint") unless context.demo
|
|
143
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
144
|
+
|
|
145
|
+
context.demo.reset
|
|
146
|
+
status 204
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
get "/api/plans/:ordinal/files/:name" do
|
|
150
|
+
name = PLAN_FILES.find { it == params[:name] } or halt 404, json(error: "not a plan file: #{params[:name]}")
|
|
151
|
+
ordinal = Plans::Ordinal.parse(params[:ordinal]) or halt 404, json(error: "not a plan number")
|
|
152
|
+
subject = Plans.tree(context.plans_dir).find(ordinal) or halt 404, json(error: "there is no plan #{ordinal}")
|
|
153
|
+
path = File.join(subject.feature.path, name)
|
|
154
|
+
halt 404, json(error: "#{ordinal} has no #{name}") unless File.file?(path)
|
|
155
|
+
|
|
156
|
+
content_type "text/markdown", charset: "utf-8"
|
|
157
|
+
File.read(path)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
get "/api/*" do
|
|
161
|
+
halt 404, json(error: "no such endpoint")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Every other path is the page's own route: the page decides what it
|
|
165
|
+
# means. A path naming a file is not one, so a missing asset is a 404
|
|
166
|
+
# rather than the page served as JavaScript.
|
|
167
|
+
get "/*" do
|
|
168
|
+
halt 404, { "Content-Type" => "text/plain" }, "not found\n" unless File.extname(request.path_info).empty?
|
|
169
|
+
halt 503, { "Content-Type" => "text/html; charset=utf-8" }, UNBUILT unless context.dist?
|
|
170
|
+
|
|
171
|
+
cache_control :no_cache
|
|
172
|
+
send_file context.index
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/atomic/atomic_boolean"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Web
|
|
7
|
+
# Everything one API server knows, fixed when it starts: where Redis is,
|
|
8
|
+
# whose keys it reads, the token every write must carry, and how often
|
|
9
|
+
# the event stream looks for news.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] stores
|
|
12
|
+
# @return [OpenLanes::Web::Stores] a store per request thread
|
|
13
|
+
# @!attribute [r] keys
|
|
14
|
+
# @return [OpenLanes::Live::Keys] this node's
|
|
15
|
+
# @!attribute [r] plans_dir
|
|
16
|
+
# @return [String] the `.plans` directory plan files are read from
|
|
17
|
+
# @!attribute [r] url
|
|
18
|
+
# @return [String] the Redis address
|
|
19
|
+
# @!attribute [r] csrf
|
|
20
|
+
# @return [String] what `X-CSRF-Token` must equal
|
|
21
|
+
# @!attribute [r] dist
|
|
22
|
+
# @return [String] the built page
|
|
23
|
+
# @!attribute [r] node
|
|
24
|
+
# @return [OpenLanes::Live::Node, nil] this machine, for `/api/session`
|
|
25
|
+
# while no engine has published a snapshot
|
|
26
|
+
# @!attribute [r] tick
|
|
27
|
+
# @return [Float] seconds between the event stream's looks (contract: 250 ms)
|
|
28
|
+
# @!attribute [r] ping_every
|
|
29
|
+
# @return [Float] seconds between `: ping` comments
|
|
30
|
+
# @!attribute [r] lifetime
|
|
31
|
+
# @return [Float, nil] seconds an event stream lives; nil for as long as
|
|
32
|
+
# the browser holds it. The suite sets one so a stream ends.
|
|
33
|
+
# @!attribute [r] demo
|
|
34
|
+
# @return [OpenLanes::Web::Demo, nil] the demo engine, under `--demo`,
|
|
35
|
+
# which `POST /api/demo/reset` puts back to the start
|
|
36
|
+
# @!attribute [r] stopping
|
|
37
|
+
# @return [Concurrent::AtomicBoolean] set when the server shuts down, so
|
|
38
|
+
# open streams end within a tick
|
|
39
|
+
Context = Data.define(:stores,
|
|
40
|
+
:keys,
|
|
41
|
+
:plans_dir,
|
|
42
|
+
:url,
|
|
43
|
+
:csrf,
|
|
44
|
+
:dist,
|
|
45
|
+
:node,
|
|
46
|
+
:tick,
|
|
47
|
+
:ping_every,
|
|
48
|
+
:lifetime,
|
|
49
|
+
:demo,
|
|
50
|
+
:stopping) do
|
|
51
|
+
def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil,
|
|
52
|
+
stopping: Concurrent::AtomicBoolean.new, **rest)
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore] this thread's
|
|
57
|
+
def store = stores.current
|
|
58
|
+
|
|
59
|
+
# @return [String] the page's entry point
|
|
60
|
+
def index = File.join(dist, "index.html")
|
|
61
|
+
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def dist? = File.file?(index)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Web
|
|
7
|
+
# `openlanes web --demo`: an engine with no agents. Every second it publishes
|
|
8
|
+
# a {DemoBoard} snapshot for this node and for one pretend other machine,
|
|
9
|
+
# through the same keys and in the same shape as {Live::Publisher}, and
|
|
10
|
+
# it answers the page's commands as {Live::Commands} does: read through
|
|
11
|
+
# the `engine` group, applied once, a `done` or `refused` reply written
|
|
12
|
+
# to `replies` and `done`, then acknowledged.
|
|
13
|
+
#
|
|
14
|
+
# Cypress and the front end's dev server run against it.
|
|
15
|
+
class Demo
|
|
16
|
+
# Seconds between snapshots.
|
|
17
|
+
INTERVAL = 1.0
|
|
18
|
+
|
|
19
|
+
# The other machine's checkout id, fixed so its keys are stable.
|
|
20
|
+
OTHER_CHECKOUT = Live::Node.digest("openlanes-demo-other", Live::Node::CHECKOUT_LENGTH)
|
|
21
|
+
|
|
22
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
23
|
+
# @param keys [OpenLanes::Live::Keys] this node's
|
|
24
|
+
# @param board [OpenLanes::Web::DemoBoard]
|
|
25
|
+
# @param interval [Float]
|
|
26
|
+
# @param clock [Proc] wall time
|
|
27
|
+
def initialize(store:, keys:, board: DemoBoard.new, interval: INTERVAL, clock: -> { Time.now })
|
|
28
|
+
@store = store
|
|
29
|
+
@keys = keys
|
|
30
|
+
@board = board
|
|
31
|
+
@interval = interval
|
|
32
|
+
@clock = clock
|
|
33
|
+
@seqs = {}
|
|
34
|
+
@ready = false
|
|
35
|
+
@running = false
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [OpenLanes::Web::DemoBoard]
|
|
40
|
+
def board = @mutex.synchronize { @board }
|
|
41
|
+
|
|
42
|
+
# Back to the fixture as it was, so each Cypress spec starts from the
|
|
43
|
+
# same board whatever the last one answered or stopped.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def reset = @mutex.synchronize { @board = DemoBoard.new }
|
|
47
|
+
|
|
48
|
+
# Publish and answer every {INTERVAL}, on a thread of its own.
|
|
49
|
+
#
|
|
50
|
+
# @return [self]
|
|
51
|
+
def start
|
|
52
|
+
@running = true
|
|
53
|
+
@thread = Thread.new do
|
|
54
|
+
while @running
|
|
55
|
+
tick
|
|
56
|
+
sleep @interval
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Stop, and take this demo's snapshots down so no page shows them.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def stop
|
|
66
|
+
@running = false
|
|
67
|
+
@thread&.join(@interval * 2)
|
|
68
|
+
@store.del(@keys.snapshot, @keys.snapshot(other))
|
|
69
|
+
@store.hdel(@keys.nodes, @keys.node, other)
|
|
70
|
+
rescue Live::Unavailable
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One second: commands first, as the engine applies them at the top of
|
|
75
|
+
# its tick, then the board moves and is published.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<OpenLanes::Live::Reply>] the commands answered
|
|
78
|
+
def tick
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
replies = commands
|
|
81
|
+
@board.advance(@clock.call)
|
|
82
|
+
publish(@keys.node, @board.me)
|
|
83
|
+
@board.others.each { publish(other, it) }
|
|
84
|
+
replies
|
|
85
|
+
end
|
|
86
|
+
rescue Live::Unavailable => e
|
|
87
|
+
UI.log("demo: #{e.message}")
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# @return [String] the pretend other machine's node name
|
|
94
|
+
def other = "#{@board.others.first&.dig("node", "id")}:#{OTHER_CHECKOUT}"
|
|
95
|
+
|
|
96
|
+
# @return [void]
|
|
97
|
+
def publish(name, snapshot)
|
|
98
|
+
snapshot["seq"] = next_seq(name)
|
|
99
|
+
json = JSON.generate(snapshot)
|
|
100
|
+
@store.multi do |tx|
|
|
101
|
+
tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
|
|
102
|
+
tx.xadd(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
|
|
103
|
+
tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Carries on from the last seq in the stream, as the engine does, so a
|
|
108
|
+
# restarted demo never looks like the past to an open page.
|
|
109
|
+
#
|
|
110
|
+
# @return [Integer]
|
|
111
|
+
def next_seq(name)
|
|
112
|
+
@seqs[name] ||= @store.xrevrange(@keys.board(name), count: 1).first&.fields&.fetch("seq", nil).to_i
|
|
113
|
+
@seqs[name] += 1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Array<OpenLanes::Live::Reply>]
|
|
117
|
+
def commands
|
|
118
|
+
unless @ready
|
|
119
|
+
# "$": commands left in the stream by an earlier demo were meant for
|
|
120
|
+
# a board that no longer exists.
|
|
121
|
+
@store.xgroup_create(@keys.commands, Live::Keys::GROUP, from: "$")
|
|
122
|
+
@ready = true
|
|
123
|
+
end
|
|
124
|
+
@store.xreadgroup(@keys.commands, Live::Keys::GROUP, @keys.node, id: ">", count: Live::Commands::BATCH)
|
|
125
|
+
.map { answer(it) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
129
|
+
# @return [OpenLanes::Live::Reply]
|
|
130
|
+
def answer(entry)
|
|
131
|
+
command = Live::Command.parse(entry.fields["json"].to_s)
|
|
132
|
+
first = @store.hget(@keys.done, command.id)
|
|
133
|
+
reply = first ? parse(first) : outcome(command)
|
|
134
|
+
@store.multi do |tx|
|
|
135
|
+
tx.hset(@keys.done, reply.id, reply.to_json) unless first
|
|
136
|
+
tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
|
|
137
|
+
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
138
|
+
end
|
|
139
|
+
reply
|
|
140
|
+
rescue Live::Command::Invalid => e
|
|
141
|
+
refuse(entry, e.message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [OpenLanes::Live::Reply]
|
|
145
|
+
def outcome(command)
|
|
146
|
+
reason = @board.apply(command)
|
|
147
|
+
Live::Reply.new(id: command.id, status: "done", reason: reason.is_a?(String) ? reason : nil, at: @clock.call)
|
|
148
|
+
rescue DemoBoard::Refusal => e
|
|
149
|
+
Live::Reply.new(id: command.id, status: "refused", reason: e.message, at: @clock.call)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [OpenLanes::Live::Reply]
|
|
153
|
+
def refuse(entry, reason)
|
|
154
|
+
reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
|
|
155
|
+
@store.multi do |tx|
|
|
156
|
+
tx.xadd(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
|
|
157
|
+
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
158
|
+
end
|
|
159
|
+
reply
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [OpenLanes::Live::Reply]
|
|
163
|
+
def parse(json)
|
|
164
|
+
data = JSON.parse(json)
|
|
165
|
+
Live::Reply.new(id: data["id"], status: data["status"], reason: data["reason"], at: Time.iso8601(data["at"]))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Web
|
|
7
|
+
# The demo's run, as plain snapshot hashes the shape of
|
|
8
|
+
# `web/src/api/fixtures/board.json`: what {Demo} publishes, and what its
|
|
9
|
+
# commands change. No plan folder is touched and no agent runs.
|
|
10
|
+
#
|
|
11
|
+
# Each command kind is one method, named as {Live::Commands} names its
|
|
12
|
+
# handlers, raising {Refusal} with the reason the engine would give.
|
|
13
|
+
class DemoBoard
|
|
14
|
+
# A command the demo will not apply.
|
|
15
|
+
class Refusal < StandardError; end
|
|
16
|
+
|
|
17
|
+
# Activity lines a running car cycles through, so the board moves.
|
|
18
|
+
CHATTER = [
|
|
19
|
+
"Reading the plan's work units",
|
|
20
|
+
"Running the suite: 412 examples",
|
|
21
|
+
"Editing lib/openlanes/web/app.rb",
|
|
22
|
+
"Writing the spec for the event stream",
|
|
23
|
+
"Checking rubocop: no offenses",
|
|
24
|
+
"Waiting on the reviewer's mailbox"
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# The status a plan takes once its last question is answered.
|
|
28
|
+
UNBLOCKED = { "key" => "building", "emoji" => "🟡", "label" => "Building" }.freeze
|
|
29
|
+
|
|
30
|
+
# What each car-signal kind does to the car.
|
|
31
|
+
SIGNALS = {
|
|
32
|
+
"hold" => { "held" => true },
|
|
33
|
+
"wrap_up" => { "phase" => "wrap_up" },
|
|
34
|
+
"compact" => { "phase" => "calm" },
|
|
35
|
+
"stop" => { "phase" => "stopped", "state" => "done" }
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# @param fixture [String] path to a `Board` JSON
|
|
39
|
+
# @param random [Random]
|
|
40
|
+
def initialize(fixture: FIXTURE, random: Random.new(4747))
|
|
41
|
+
board = JSON.parse(File.read(fixture))
|
|
42
|
+
@me = board.fetch("me")
|
|
43
|
+
@others = board.fetch("others")
|
|
44
|
+
@random = random
|
|
45
|
+
@ticks = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Hash] this node's snapshot
|
|
49
|
+
attr_reader :me
|
|
50
|
+
|
|
51
|
+
# @return [Array<Hash>] the other nodes' snapshots
|
|
52
|
+
attr_reader :others
|
|
53
|
+
|
|
54
|
+
# One second of pretend work: tokens grow, clocks run, messages turn.
|
|
55
|
+
#
|
|
56
|
+
# @param now [Time]
|
|
57
|
+
# @return [void]
|
|
58
|
+
def advance(now)
|
|
59
|
+
@ticks += 1
|
|
60
|
+
[@me, *@others].each { |snapshot| advance_snapshot(snapshot, now) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param command [OpenLanes::Live::Command]
|
|
64
|
+
# @return [String, nil] a reason to show beside "done"
|
|
65
|
+
# @raise [Refusal]
|
|
66
|
+
def apply(command)
|
|
67
|
+
send(Live::Commands::HANDLERS.fetch(command.kind), command)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# @return [void]
|
|
73
|
+
def advance_snapshot(snapshot, now)
|
|
74
|
+
stamp = now.utc.iso8601
|
|
75
|
+
snapshot["at"] = stamp
|
|
76
|
+
snapshot["node"]["heartbeat_at"] = stamp
|
|
77
|
+
snapshot["plans"].each { |plan| advance_plan(plan) }
|
|
78
|
+
run = snapshot["run"]
|
|
79
|
+
run["up"] = snapshot["plans"].sum { it["up"] }
|
|
80
|
+
run["down"] = snapshot["plans"].sum { it["down"] }
|
|
81
|
+
run["agents_working"] = running(snapshot).size
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [void]
|
|
85
|
+
def advance_plan(plan)
|
|
86
|
+
plan["cars"].select { it["state"] == "running" && !it["held"] }.each do |car|
|
|
87
|
+
up = @random.rand(800..2400)
|
|
88
|
+
down = @random.rand(60..240)
|
|
89
|
+
car["up"] += up
|
|
90
|
+
car["down"] += down
|
|
91
|
+
plan["up"] += up
|
|
92
|
+
plan["down"] += down
|
|
93
|
+
car["elapsed"] += 1
|
|
94
|
+
car["remaining"] = [car["remaining"] - 1, 0].max if car["remaining"]
|
|
95
|
+
chatter(car) if (@ticks % 4).zero?
|
|
96
|
+
end
|
|
97
|
+
plan["elapsed"] += 1 if plan["cars"].any? { it["state"] == "running" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def chatter(car)
|
|
102
|
+
car["message"] = CHATTER[(@ticks / 4) % CHATTER.size]
|
|
103
|
+
car["history"] = [car["message"], *car["history"]].first(50)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Array<Hash>] cars still at work
|
|
107
|
+
def running(snapshot) = snapshot["plans"].flat_map { it["cars"] }.select { it["state"] == "running" }
|
|
108
|
+
|
|
109
|
+
# ---- questions --------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# @return [nil]
|
|
112
|
+
def answer(command)
|
|
113
|
+
plan, question = question!(command)
|
|
114
|
+
plan["questions"].delete(question)
|
|
115
|
+
unblock(plan) if plan["questions"].empty?
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [nil]
|
|
120
|
+
def hold_blocked(command) = hold_question(command, true)
|
|
121
|
+
|
|
122
|
+
# @return [nil]
|
|
123
|
+
def release_hold(command) = hold_question(command, false)
|
|
124
|
+
|
|
125
|
+
# @return [nil]
|
|
126
|
+
def hold_question(command, held)
|
|
127
|
+
question!(command).last["held"] = held
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [void]
|
|
132
|
+
def unblock(plan)
|
|
133
|
+
plan["status"] = UNBLOCKED.dup if %w[blocked product_blocked].include?(plan.dig("status", "key"))
|
|
134
|
+
plan["cars"].each { it["state"] = "running" if it["state"] == "stuck" }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Array(Hash, Hash)] the plan and its question
|
|
138
|
+
def question!(command)
|
|
139
|
+
plan = plan!(command)
|
|
140
|
+
number = command["question"][/\AB?(\d+)\z/i, 1] or raise Refusal, "#{command["question"]} is not a question number like B2"
|
|
141
|
+
question = plan["questions"].find { it["number"] == number.to_i } or
|
|
142
|
+
raise Refusal, "#{command.plan} has no open question B#{number}"
|
|
143
|
+
[plan, question]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ---- agents -----------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
# @return [String]
|
|
149
|
+
def resume(command)
|
|
150
|
+
plan = plan!(command)
|
|
151
|
+
interrupted = plan["interrupted"] or raise Refusal, "#{command.plan} has no interrupted agent to resume"
|
|
152
|
+
plan["interrupted"] = nil
|
|
153
|
+
"#{interrupted["agent"]} resumes on #{command.plan} at its next turn"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Every agent on the plan stops and the interruption is forgotten; the
|
|
157
|
+
# engine also resets the worktree and sends the plan back one state,
|
|
158
|
+
# which a board with no folders behind it has nothing to show for.
|
|
159
|
+
#
|
|
160
|
+
# @return [String]
|
|
161
|
+
def discard(command)
|
|
162
|
+
plan = plan!(command)
|
|
163
|
+
plan["cars"].each { it.merge!(SIGNALS.fetch("stop")) }
|
|
164
|
+
plan["interrupted"] = nil
|
|
165
|
+
"discarded the work on #{command.plan}; its agents stopped"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [nil]
|
|
169
|
+
def message(command)
|
|
170
|
+
plan = plan!(command)
|
|
171
|
+
cars = plan["cars"].select { command.agent.to_s.empty? || it["agent"] == command.agent }
|
|
172
|
+
raise Refusal, "no agent named, and none is working #{command.plan}" if cars.empty?
|
|
173
|
+
|
|
174
|
+
cars.each do |car|
|
|
175
|
+
car["history"] = ["human: #{command["text"]}", *car["history"]].first(50)
|
|
176
|
+
car["held"] = false
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [nil]
|
|
182
|
+
def signal(command)
|
|
183
|
+
car!(command).merge!(SIGNALS.fetch(command.kind)).tap { it["held"] = command.kind == "hold" }
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The demo has no session to compact; the car calms as a real one would
|
|
188
|
+
# once its context is shed.
|
|
189
|
+
#
|
|
190
|
+
# @return [nil]
|
|
191
|
+
def compact(command) = signal(command)
|
|
192
|
+
|
|
193
|
+
# @return [String]
|
|
194
|
+
def stop_all(_command)
|
|
195
|
+
cars = running(@me)
|
|
196
|
+
cars.each { it.merge!(SIGNALS.fetch("stop")) }
|
|
197
|
+
@me["run"]["status"] = "quitting"
|
|
198
|
+
"stopping #{cars.size} agent#{"s" unless cars.size == 1} on #{@me.dig("node", "user")}@#{@me.dig("node", "host")}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Queues the model for the next round and leaves the running one
|
|
202
|
+
# alone, as Live::Snapshot reports it: `next_model` until a round runs
|
|
203
|
+
# on it, and nil when it is the model already running.
|
|
204
|
+
#
|
|
205
|
+
# @return [String]
|
|
206
|
+
def set_model(command) # rubocop:disable Naming/AccessorMethodName -- named for the command kind, as in Live::Commands
|
|
207
|
+
car = car!(command)
|
|
208
|
+
model = model!(command["model"])
|
|
209
|
+
car["next_model"] = (model unless model == car["model"])
|
|
210
|
+
"#{command.agent} runs on #{model} from its next round"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [String]
|
|
214
|
+
def switch_model(command)
|
|
215
|
+
car = car!(command)
|
|
216
|
+
raise Refusal, "#{command.car} is not running; use set_model for its next round" unless car["state"] == "running"
|
|
217
|
+
|
|
218
|
+
car["model"] = model!(command["model"])
|
|
219
|
+
# The switch is the model now, so nothing is queued behind it.
|
|
220
|
+
car["next_model"] = nil
|
|
221
|
+
"#{command.agent} compacted and carries on on #{car["model"]}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @return [String] the model's full id
|
|
225
|
+
def model!(name)
|
|
226
|
+
entry = @me["models"].find { [it["id"], it["label"]].include?(name) } or raise Refusal, "claude offers no model #{name}"
|
|
227
|
+
entry["id"]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# ---- plans ------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
# @return [nil]
|
|
233
|
+
def add_plan(command)
|
|
234
|
+
ordinal = Plans::Ordinal.parse(command["ordinal"])&.to_s or raise Refusal, "#{command["ordinal"]} is not a plan number"
|
|
235
|
+
entry = @me["backlog"].find { it["ordinal"] == ordinal } or raise Refusal, "there is no plan #{ordinal}"
|
|
236
|
+
@me["backlog"].delete(entry)
|
|
237
|
+
@me["plans"] << plan_from(entry)
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @return [String]
|
|
242
|
+
def create_plans(command)
|
|
243
|
+
ordinals = [*@me["plans"], *@me["backlog"]].map { it["ordinal"].to_i }
|
|
244
|
+
ordinal = format("%03d.00", ordinals.max.to_i + 1)
|
|
245
|
+
title = command["idea"].lines.first.to_s.strip.delete_prefix("#").strip
|
|
246
|
+
@me["plans"] << plan_from({ "ordinal" => ordinal,
|
|
247
|
+
"title" => title,
|
|
248
|
+
"status" => { "key" => "new", "emoji" => "⚪️", "label" => "New" },
|
|
249
|
+
"lane" => "full" })
|
|
250
|
+
"created #{ordinal} #{slug(title)}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [Hash] a `Plan` at its status's station, with nobody on it yet
|
|
254
|
+
def plan_from(entry)
|
|
255
|
+
station = @me["stations"].find { it["key"] == entry.dig("status", "key") }&.fetch("key") || @me["stations"].first["key"]
|
|
256
|
+
entry.merge("slug" => slug(entry["title"]),
|
|
257
|
+
"station" => station,
|
|
258
|
+
"up" => 0,
|
|
259
|
+
"down" => 0,
|
|
260
|
+
"cost" => 0,
|
|
261
|
+
"started_at" => nil,
|
|
262
|
+
"elapsed" => 0,
|
|
263
|
+
"cars" => [],
|
|
264
|
+
"pr" => nil,
|
|
265
|
+
"questions" => [],
|
|
266
|
+
"interrupted" => nil,
|
|
267
|
+
"claimed_by" => @me.dig("node", "id"))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [String]
|
|
271
|
+
def slug(title) = title.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
272
|
+
|
|
273
|
+
# ---- lookups ----------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
# @return [Hash]
|
|
276
|
+
def plan!(command)
|
|
277
|
+
@me["plans"].find { it["ordinal"] == command.plan } or raise Refusal, "there is no plan #{command.plan}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [Hash]
|
|
281
|
+
def car!(command)
|
|
282
|
+
plan!(command)["cars"].find { it["key"] == command.car } or raise Refusal, "#{command.car} is not running"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|