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,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# {Store}'s methods, kept in this process. The suite's Redis.
|
|
8
|
+
#
|
|
9
|
+
# It emulates exactly as much of Redis as the contract uses, and the
|
|
10
|
+
# shared examples in `spec/openlanes/live/store_examples.rb` hold it to
|
|
11
|
+
# the real thing: an expiry read off a clock the example controls,
|
|
12
|
+
# compare-and-delete, a consumer group's pending list, and approximate
|
|
13
|
+
# trimming done exactly (which `MAXLEN ~` permits: it promises at least
|
|
14
|
+
# that many, never exactly that many).
|
|
15
|
+
class MemoryStore
|
|
16
|
+
# @param clock [Proc] seconds, monotonic; an example moves it to expire keys
|
|
17
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
18
|
+
@clock = clock
|
|
19
|
+
@mutex = Monitor.new
|
|
20
|
+
@data = {}
|
|
21
|
+
@expiry = {}
|
|
22
|
+
@groups = Hash.new { |h, k| h[k] = {} }
|
|
23
|
+
@last_ms = 0
|
|
24
|
+
@last_seq = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] what {Live.mask} shows for it
|
|
28
|
+
def url = "memory://"
|
|
29
|
+
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def ping = true
|
|
32
|
+
|
|
33
|
+
# @return [void]
|
|
34
|
+
def close = nil
|
|
35
|
+
|
|
36
|
+
# ---- strings ----------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# @return [String, nil]
|
|
39
|
+
def get(key) = locked { live(key) }
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] whether the value was written
|
|
42
|
+
def set(key, value, ex: nil, nx: false)
|
|
43
|
+
locked do
|
|
44
|
+
next false if nx && !live(key).nil?
|
|
45
|
+
|
|
46
|
+
@data[key] = value.to_s
|
|
47
|
+
ex ? @expiry[key] = now + ex : @expiry.delete(key)
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Integer] how many were removed
|
|
53
|
+
def del(*keys) = locked { keys.count { |key| forget(key) } }
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether the key exists to expire
|
|
56
|
+
def expire(key, seconds)
|
|
57
|
+
locked do
|
|
58
|
+
next false if live(key).nil?
|
|
59
|
+
|
|
60
|
+
@expiry[key] = now + seconds
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer] seconds left, -1 without an expiry, -2 when absent
|
|
66
|
+
def ttl(key)
|
|
67
|
+
locked do
|
|
68
|
+
next -2 if live(key).nil?
|
|
69
|
+
|
|
70
|
+
@expiry.key?(key) ? (@expiry[key] - now).ceil : -1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def compare_and_delete(key, expected)
|
|
76
|
+
locked { live(key) == expected ? forget(key) : false }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def compare_and_expire(key, expected, seconds)
|
|
81
|
+
locked { live(key) == expected ? expire(key, seconds) : false }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- hashes -----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
# @return [Boolean] whether the field is new
|
|
87
|
+
def hset(key, field, value)
|
|
88
|
+
locked do
|
|
89
|
+
hash = live(key) || (@data[key] = {})
|
|
90
|
+
fresh = !hash.key?(field.to_s)
|
|
91
|
+
hash[field.to_s] = value.to_s
|
|
92
|
+
fresh
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [String, nil]
|
|
97
|
+
def hget(key, field) = locked { live(key)&.fetch(field.to_s, nil) }
|
|
98
|
+
|
|
99
|
+
# @return [Hash{String => String}]
|
|
100
|
+
def hgetall(key) = locked { (live(key) || {}).dup }
|
|
101
|
+
|
|
102
|
+
# @return [Integer]
|
|
103
|
+
def hdel(key, *fields)
|
|
104
|
+
locked do
|
|
105
|
+
hash = live(key) or next 0
|
|
106
|
+
removed = fields.count { |f| hash.delete(f.to_s) }
|
|
107
|
+
forget(key) if hash.empty?
|
|
108
|
+
removed
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ---- streams ----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# @return [String] the new entry's id
|
|
115
|
+
def xadd(key, fields, maxlen: nil)
|
|
116
|
+
locked do
|
|
117
|
+
stream = live(key) || (@data[key] = [])
|
|
118
|
+
id = next_id
|
|
119
|
+
stream << Store::Entry.new(id:, fields: fields.to_h { |k, v| [k.to_s, v.to_s] })
|
|
120
|
+
stream.shift(stream.size - maxlen) if maxlen && stream.size > maxlen
|
|
121
|
+
id
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Integer]
|
|
126
|
+
def xlen(key) = locked { (live(key) || []).size }
|
|
127
|
+
|
|
128
|
+
# @return [Boolean] whether the group is new
|
|
129
|
+
def xgroup_create(key, group, from: "0")
|
|
130
|
+
locked do
|
|
131
|
+
stream = live(key) || (@data[key] = [])
|
|
132
|
+
next false if @groups[key].key?(group)
|
|
133
|
+
|
|
134
|
+
last = from == "$" ? stream.last&.id || "0-0" : from
|
|
135
|
+
@groups[key][group] = { last: id_of(last.include?("-") ? last : "#{last}-0"), pending: {} }
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Array<Store::Entry>]
|
|
141
|
+
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
142
|
+
locked do
|
|
143
|
+
state = @groups[key][group] or raise Unavailable, "NOGROUP #{group} on #{key}"
|
|
144
|
+
stream = live(key) || []
|
|
145
|
+
if id == ">"
|
|
146
|
+
fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
|
|
147
|
+
fresh.each { |e| state[:pending][e.id] = consumer }
|
|
148
|
+
state[:last] = id_of(fresh.last.id) unless fresh.empty?
|
|
149
|
+
fresh
|
|
150
|
+
else
|
|
151
|
+
floor = id_of(id.include?("-") ? id : "#{id}-0")
|
|
152
|
+
state[:pending].select { |pid, who| who == consumer && (id_of(pid) <=> floor).positive? }.keys
|
|
153
|
+
.sort_by { id_of(it) }.first(count)
|
|
154
|
+
.map { |pid| stream.find { |e| e.id == pid } || Store::Entry.new(id: pid, fields: {}) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Integer]
|
|
160
|
+
def xack(key, group, *ids)
|
|
161
|
+
locked do
|
|
162
|
+
pending = @groups.dig(key, group, :pending) or next 0
|
|
163
|
+
ids.count { |id| pending.delete(id) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Never waits: nothing else can add to this process's streams while
|
|
168
|
+
# the caller is blocked, so +block+ is accepted and ignored.
|
|
169
|
+
#
|
|
170
|
+
# @return [Array<Store::Entry>]
|
|
171
|
+
def xread(key, id: "0", count: 100, block: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
172
|
+
locked do
|
|
173
|
+
stream = live(key) || []
|
|
174
|
+
next [] if id == "$"
|
|
175
|
+
|
|
176
|
+
floor = id_of(id.include?("-") ? id : "#{id}-0")
|
|
177
|
+
stream.select { |e| (id_of(e.id) <=> floor).positive? }.first(count)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [Array<Store::Entry>] oldest first
|
|
182
|
+
def xrange(key, start: "-", stop: "+", count: 100)
|
|
183
|
+
locked { within(live(key) || [], start, stop).first(count) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [Array<Store::Entry>] newest first
|
|
187
|
+
def xrevrange(key, stop: "+", start: "-", count: 100)
|
|
188
|
+
locked { within(live(key) || [], start, stop).reverse.first(count) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ---- keys -------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
# @return [Array<String>]
|
|
194
|
+
def scan(prefix) = locked { @data.keys.select { |k| k.start_with?(prefix) && !live(k).nil? }.sort }
|
|
195
|
+
|
|
196
|
+
# ---- transactions -----------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# @yieldparam tx [Store::Transaction]
|
|
199
|
+
# @return [void]
|
|
200
|
+
def multi
|
|
201
|
+
tx = Store::Transaction.new
|
|
202
|
+
yield tx
|
|
203
|
+
locked { tx.calls.each { |name, args, kwargs| public_send(name, *args, **kwargs) } }
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# @return [Object] the block's value, under the lock
|
|
210
|
+
def locked(&) = @mutex.synchronize(&)
|
|
211
|
+
|
|
212
|
+
# @return [Float]
|
|
213
|
+
def now = @clock.call
|
|
214
|
+
|
|
215
|
+
# @return [Object, nil] the value, unless it has expired
|
|
216
|
+
def live(key)
|
|
217
|
+
if @expiry.key?(key) && @expiry[key] <= now
|
|
218
|
+
forget(key)
|
|
219
|
+
return nil
|
|
220
|
+
end
|
|
221
|
+
@data[key]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @return [Boolean] whether there was anything to forget
|
|
225
|
+
def forget(key)
|
|
226
|
+
@expiry.delete(key)
|
|
227
|
+
@groups.delete(key)
|
|
228
|
+
!@data.delete(key).nil?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Ids as Redis makes them: milliseconds, then a sequence within the
|
|
232
|
+
# millisecond, always increasing even if the clock does not.
|
|
233
|
+
#
|
|
234
|
+
# @return [String]
|
|
235
|
+
def next_id
|
|
236
|
+
ms = (Time.now.to_f * 1000).to_i
|
|
237
|
+
if ms > @last_ms
|
|
238
|
+
@last_ms = ms
|
|
239
|
+
@last_seq = 0
|
|
240
|
+
else
|
|
241
|
+
@last_seq += 1
|
|
242
|
+
end
|
|
243
|
+
"#{@last_ms}-#{@last_seq}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [Array(Integer, Integer)]
|
|
247
|
+
def id_of(id) = id.split("-", 2).map(&:to_i)
|
|
248
|
+
|
|
249
|
+
# @return [Array<Store::Entry>]
|
|
250
|
+
def within(stream, start, stop)
|
|
251
|
+
low = if start == "-"
|
|
252
|
+
[0, 0]
|
|
253
|
+
else
|
|
254
|
+
id_of(start.include?("-") ? start : "#{start}-0")
|
|
255
|
+
end
|
|
256
|
+
high = if stop == "+"
|
|
257
|
+
[Float::INFINITY, Float::INFINITY]
|
|
258
|
+
else
|
|
259
|
+
id_of(stop.include?("-") ? stop : "#{stop}-#{2**63}")
|
|
260
|
+
end
|
|
261
|
+
stream.select { (low..high).cover?(id_of(it.id)) }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# What each plan has spent this run, kept across the board's rows.
|
|
6
|
+
#
|
|
7
|
+
# A row leaves the board ten seconds after its agent finishes, and what it
|
|
8
|
+
# spent must not leave the plan's total with it. So every row seen is
|
|
9
|
+
# remembered by plan, agent, round and invocation, at the highest figures
|
|
10
|
+
# it showed. The invocation matters because switch_model starts an agent
|
|
11
|
+
# again in the same round, on another model: keyed by round alone, the
|
|
12
|
+
# restart's smaller figures would hide the first run's spend, and the
|
|
13
|
+
# whole round would be priced at the second model.
|
|
14
|
+
class Meter
|
|
15
|
+
# One invocation of an agent's round on one plan, at its latest reading.
|
|
16
|
+
Spend = Data.define(:up, :down, :model)
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@spends = Hash.new { |h, k| h[k] = {} }
|
|
20
|
+
@first_seen = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param rows [Array<OpenLanes::Engine::Board::Row>]
|
|
24
|
+
# @param now [Time]
|
|
25
|
+
# @return [void]
|
|
26
|
+
def record(rows, now: Time.now)
|
|
27
|
+
rows.each do |row|
|
|
28
|
+
@first_seen[row.ordinal] ||= now
|
|
29
|
+
key = [row.agent, row.round, row.since]
|
|
30
|
+
known = @spends[row.ordinal][key]
|
|
31
|
+
@spends[row.ordinal][key] =
|
|
32
|
+
Spend.new(up: [row.up.to_i, known&.up.to_i].max, down: [row.down.to_i, known&.down.to_i].max, model: row.model)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param ordinal [String]
|
|
37
|
+
# @return [Time, nil] when this run first saw an agent on the plan
|
|
38
|
+
def started_at(ordinal) = @first_seen[ordinal]
|
|
39
|
+
|
|
40
|
+
# @param ordinal [String]
|
|
41
|
+
# @param price [#call] model name to an {Adapters::Model}, or nil
|
|
42
|
+
# @return [Hash{Symbol => Numeric}] `up`, `down` and `cost`
|
|
43
|
+
def for(ordinal, price)
|
|
44
|
+
spends = @spends.fetch(ordinal, {}).values
|
|
45
|
+
{ up: spends.sum(&:up),
|
|
46
|
+
down: spends.sum(&:down),
|
|
47
|
+
cost: spends.sum { |s| price.call(s.model)&.cost(s.up, s.down).to_f }.round(4) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Live
|
|
9
|
+
# This machine and user, as other machines sharing the Redis see it (W26).
|
|
10
|
+
#
|
|
11
|
+
# The id is a digest of `user@host` so keys are short and uniform, not to
|
|
12
|
+
# hide anyone: the plain names travel beside it. The checkout id keeps
|
|
13
|
+
# two clones on one machine apart.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute [r] id
|
|
16
|
+
# @return [String] 24 hex digits of SHA-256 of `user@host`
|
|
17
|
+
# @!attribute [r] user
|
|
18
|
+
# @return [String]
|
|
19
|
+
# @!attribute [r] host
|
|
20
|
+
# @return [String]
|
|
21
|
+
# @!attribute [r] ip
|
|
22
|
+
# @return [String, nil] shown, never used to identify the node
|
|
23
|
+
# @!attribute [r] checkout
|
|
24
|
+
# @return [String] the checkout's path, `$HOME` written as `~`
|
|
25
|
+
# @!attribute [r] checkout_id
|
|
26
|
+
# @return [String] 12 hex digits of SHA-256 of the checkout's realpath
|
|
27
|
+
Node = Data.define(:id, :user, :host, :ip, :checkout, :checkout_id) do
|
|
28
|
+
# @return [String] `<id>:<checkout_id>`, what keys and claims carry
|
|
29
|
+
def name = "#{id}:#{checkout_id}"
|
|
30
|
+
|
|
31
|
+
# @return [String] `user@host`, what the page shows
|
|
32
|
+
def label = "#{user}@#{host}"
|
|
33
|
+
|
|
34
|
+
# The `Node` of `web/src/api/types.ts`.
|
|
35
|
+
#
|
|
36
|
+
# @param heartbeat_at [Time]
|
|
37
|
+
# @param me [Boolean]
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def wire(heartbeat_at:, me: true)
|
|
40
|
+
{ id:, user:, host:, ip:, checkout:, heartbeat_at: heartbeat_at.utc.iso8601, me: }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Node
|
|
45
|
+
# Hex digits kept of each digest.
|
|
46
|
+
ID_LENGTH = 24
|
|
47
|
+
CHECKOUT_LENGTH = 12
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# @param root [String] the checkout
|
|
51
|
+
# @param user [String]
|
|
52
|
+
# @param host [String]
|
|
53
|
+
# @param home [String]
|
|
54
|
+
# @return [OpenLanes::Live::Node]
|
|
55
|
+
def current(root:, user: login, host: Socket.gethostname, home: Dir.home, ip: address)
|
|
56
|
+
real = File.realpath(root)
|
|
57
|
+
new(id: digest("#{user}@#{host}", ID_LENGTH),
|
|
58
|
+
user:,
|
|
59
|
+
host:,
|
|
60
|
+
ip:,
|
|
61
|
+
checkout: tilde(real, home),
|
|
62
|
+
checkout_id: digest(real, CHECKOUT_LENGTH))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param text [String]
|
|
66
|
+
# @param length [Integer]
|
|
67
|
+
# @return [String]
|
|
68
|
+
def digest(text, length) = Digest::SHA256.hexdigest(text)[0, length]
|
|
69
|
+
|
|
70
|
+
# @return [String]
|
|
71
|
+
def login = Etc.getlogin || ENV.fetch("USER", "unknown")
|
|
72
|
+
|
|
73
|
+
# The first address that is neither loopback nor link-local: the one
|
|
74
|
+
# other machines on the network most likely reach this one by.
|
|
75
|
+
#
|
|
76
|
+
# @return [String, nil]
|
|
77
|
+
def address
|
|
78
|
+
Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? && !a.ipv4_multicast? }&.ip_address
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param path [String]
|
|
82
|
+
# @param home [String]
|
|
83
|
+
# @return [String]
|
|
84
|
+
def tilde(path, home) = path == home || path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# The engine's side of the board (W13): handed each tick's
|
|
8
|
+
# {Engine::Board} as the runner's `on_board`, it writes the snapshot,
|
|
9
|
+
# appends it to the board stream, refreshes this node's heartbeat and
|
|
10
|
+
# renews its claims.
|
|
11
|
+
#
|
|
12
|
+
# It also keeps the latest log lines for the page, by tapping {UI.log}
|
|
13
|
+
# rather than asking callers to log twice.
|
|
14
|
+
#
|
|
15
|
+
# A Redis that goes away mid-run is reported once and otherwise
|
|
16
|
+
# ignored: the run is the agents' work, and the page is a view of it.
|
|
17
|
+
class Publisher
|
|
18
|
+
# Log lines a snapshot carries.
|
|
19
|
+
LOG_LINES = 200
|
|
20
|
+
|
|
21
|
+
# Words in a log line that mark it as something gone wrong.
|
|
22
|
+
PROBLEMS = /\b(?:fail(?:ed|ure)?|error|refused|unavailable|died)\b/i
|
|
23
|
+
|
|
24
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
25
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
26
|
+
# @param node [OpenLanes::Live::Node]
|
|
27
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
28
|
+
# @param agents [OpenLanes::Agents::Registry]
|
|
29
|
+
# @param project [String]
|
|
30
|
+
# @param claims [OpenLanes::Live::Claims, nil]
|
|
31
|
+
# @param run_state [OpenLanes::Live::RunState]
|
|
32
|
+
# @param description [String, nil]
|
|
33
|
+
# @param clock [Proc] wall time
|
|
34
|
+
def initialize(store:, keys:, node:, tree:, agents:, project:, claims: nil, run_state: RunState.new,
|
|
35
|
+
description: nil, clock: -> { Time.now })
|
|
36
|
+
@store = store
|
|
37
|
+
@keys = keys
|
|
38
|
+
@node = node
|
|
39
|
+
@tree = tree
|
|
40
|
+
@agents = agents
|
|
41
|
+
@project = project
|
|
42
|
+
@claims = claims
|
|
43
|
+
@run_state = run_state
|
|
44
|
+
@description = description
|
|
45
|
+
@clock = clock
|
|
46
|
+
@meter = Meter.new
|
|
47
|
+
@log = []
|
|
48
|
+
@log_mutex = Mutex.new
|
|
49
|
+
@seq = nil
|
|
50
|
+
@warned = false
|
|
51
|
+
@on_lost = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What to do with plans whose claim lapsed and went to another node:
|
|
55
|
+
# the session stops their agents, so two machines never work one plan.
|
|
56
|
+
#
|
|
57
|
+
# @yieldparam ordinals [Array<String>]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def on_lost(&block)
|
|
60
|
+
@on_lost = block
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [OpenLanes::Live::Meter]
|
|
64
|
+
attr_reader :meter
|
|
65
|
+
|
|
66
|
+
# @return [Integer, nil] the last sequence number published
|
|
67
|
+
attr_reader :seq
|
|
68
|
+
|
|
69
|
+
# @return [Proc] for {Engine::Runner}'s `on_board:`
|
|
70
|
+
def to_proc = method(:call).to_proc
|
|
71
|
+
|
|
72
|
+
# @param board [OpenLanes::Engine::Board]
|
|
73
|
+
# @return [Hash, nil] the snapshot published, nil when Redis was not there
|
|
74
|
+
def call(board)
|
|
75
|
+
now = @clock.call
|
|
76
|
+
@started_at ||= now - (UI.monotonic - board.started_at)
|
|
77
|
+
@meter.record(board.rows, now:)
|
|
78
|
+
snapshot = build(board, now)
|
|
79
|
+
json = JSON.generate(snapshot)
|
|
80
|
+
@store.multi do |tx|
|
|
81
|
+
tx.set(@keys.snapshot, json, ex: Keys::SNAPSHOT_TTL)
|
|
82
|
+
tx.xadd(@keys.board, { seq: snapshot[:seq], json: }, maxlen: Keys::BOARD_LENGTH)
|
|
83
|
+
tx.hset(@keys.nodes, @node.name, JSON.generate(@node.wire(heartbeat_at: now)))
|
|
84
|
+
end
|
|
85
|
+
lost = @claims&.renew || []
|
|
86
|
+
unless lost.empty?
|
|
87
|
+
UI.log("claims lost to another machine: #{lost.join(", ")}; stopping their agents")
|
|
88
|
+
@on_lost&.call(lost)
|
|
89
|
+
end
|
|
90
|
+
@warned = false
|
|
91
|
+
snapshot
|
|
92
|
+
rescue Unavailable => e
|
|
93
|
+
unavailable(e)
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Start keeping {UI.log}'s lines.
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def listen
|
|
101
|
+
return if @tap
|
|
102
|
+
|
|
103
|
+
@tap = UI.tap_log(method(:hear))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Stop keeping them.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def close
|
|
110
|
+
UI.untap_log(@tap) if @tap
|
|
111
|
+
@tap = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# One log line, as the page shows it. Public so a component that does
|
|
115
|
+
# not go through {UI.log} can still be heard.
|
|
116
|
+
#
|
|
117
|
+
# @param message [String]
|
|
118
|
+
# @param fields [Hash] `plan:`, `agent:` and the rest of {UI.log}'s columns
|
|
119
|
+
# @param source [String, nil]
|
|
120
|
+
# @param level [String, nil]
|
|
121
|
+
# @return [void]
|
|
122
|
+
def hear(message, fields = {}, source: nil, level: nil)
|
|
123
|
+
line = { at: @clock.call.utc.iso8601,
|
|
124
|
+
source: source || fields[:agent]&.to_s || "engine",
|
|
125
|
+
level: level || (message.to_s.match?(PROBLEMS) ? "warn" : "info"),
|
|
126
|
+
text: message.to_s,
|
|
127
|
+
plan: fields[:plan]&.to_s }
|
|
128
|
+
@log_mutex.synchronize do
|
|
129
|
+
@log << line
|
|
130
|
+
@log.shift(@log.size - LOG_LINES) if @log.size > LOG_LINES
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Array<Hash>] the lines kept, newest last
|
|
135
|
+
def log = @log_mutex.synchronize { @log.dup }
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# @param board [OpenLanes::Engine::Board]
|
|
140
|
+
# @param now [Time]
|
|
141
|
+
# @return [Hash]
|
|
142
|
+
def build(board, now)
|
|
143
|
+
Snapshot.new(board:,
|
|
144
|
+
tree: @tree,
|
|
145
|
+
agents: @agents,
|
|
146
|
+
node: @node,
|
|
147
|
+
repo: @keys.repo,
|
|
148
|
+
project: @project,
|
|
149
|
+
description: @description,
|
|
150
|
+
seq: next_seq,
|
|
151
|
+
at: now,
|
|
152
|
+
started_at: @started_at,
|
|
153
|
+
claims: @claims,
|
|
154
|
+
run_state: @run_state,
|
|
155
|
+
meter: @meter,
|
|
156
|
+
log:).to_h
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One more than the last published, even across an engine restart:
|
|
160
|
+
# the API server reads a change of `seq` as news, and a restart that
|
|
161
|
+
# counted from one again would look like the past.
|
|
162
|
+
#
|
|
163
|
+
# @return [Integer]
|
|
164
|
+
def next_seq
|
|
165
|
+
@seq ||= @store.xrevrange(@keys.board, count: 1).first&.fields&.fetch("seq", nil).to_i
|
|
166
|
+
@seq += 1
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param error [OpenLanes::Live::Unavailable]
|
|
170
|
+
# @return [void]
|
|
171
|
+
def unavailable(error)
|
|
172
|
+
return if @warned
|
|
173
|
+
|
|
174
|
+
@warned = true
|
|
175
|
+
UI.log("board not published: #{error.message}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# The repository a run belongs to, as the part of every key that machines
|
|
9
|
+
# sharing one Redis have in common (W26, W29): `github.com/<owner>/<name>`.
|
|
10
|
+
#
|
|
11
|
+
# Readable rather than hashed, so `redis-cli --scan` shows whose
|
|
12
|
+
# repository a key belongs to. The clone decides which factory a machine
|
|
13
|
+
# joins, and nothing else does, which is why there is no override.
|
|
14
|
+
module Repo
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# `git@github.com:Owner/Name.git`, `https://github.com/owner/name`,
|
|
18
|
+
# `ssh://git@github.com/owner/name.git` and their trailing slashes and
|
|
19
|
+
# letter case all fold to one spelling.
|
|
20
|
+
#
|
|
21
|
+
# @param url [String]
|
|
22
|
+
# @return [String, nil] `host/owner/name`, nil for a string that is not
|
|
23
|
+
# a remote URL
|
|
24
|
+
def normalise(url)
|
|
25
|
+
text = url.to_s.strip.downcase.delete_suffix("/").delete_suffix(".git")
|
|
26
|
+
path = case text
|
|
27
|
+
when %r{\A[a-z][a-z0-9+.-]*://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+)\z},
|
|
28
|
+
%r{\A(?:[^@/]+@)?([^/:]+):(.+)\z} then "#{::Regexp.last_match(1)}/#{::Regexp.last_match(2)}"
|
|
29
|
+
end
|
|
30
|
+
path&.squeeze("/")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The repository of the checkout at +root+: its `origin`, normalised,
|
|
34
|
+
# or, with no remote to share, `local/<name>-<12 hex>`, the directory's
|
|
35
|
+
# name and a digest of its real path. The name alone collided: two
|
|
36
|
+
# unrelated clones both called `app` on one Redis would have read each
|
|
37
|
+
# other's boards and taken each other's claims.
|
|
38
|
+
#
|
|
39
|
+
# @param root [String]
|
|
40
|
+
# @return [String]
|
|
41
|
+
def for(root)
|
|
42
|
+
out, status = Open3.capture2("git", "-C", root, "remote", "get-url", "origin", err: File::NULL)
|
|
43
|
+
(status.success? && normalise(out)) || local(root)
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
local(root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param root [String]
|
|
49
|
+
# @return [String] `local/<basename>-<12 hex of SHA-256 of the real path>`
|
|
50
|
+
def local(root)
|
|
51
|
+
path = begin
|
|
52
|
+
File.realpath(root)
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
File.expand_path(root)
|
|
55
|
+
end
|
|
56
|
+
"local/#{File.basename(path)}-#{Digest::SHA256.hexdigest(path)[0, 12]}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|