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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# The stops on the page's board: the {Plans::StateMachine::SPINE}, with
|
|
6
|
+
# the states a person does not need told apart folded into one stop.
|
|
7
|
+
#
|
|
8
|
+
# The grouping and the labels are the page's vocabulary and are written
|
|
9
|
+
# here; which agents work at each stop is not, and comes from each
|
|
10
|
+
# agent's `handles:`. A spec walks the spine to prove every state on it
|
|
11
|
+
# has a stop, in spine order.
|
|
12
|
+
module Stations
|
|
13
|
+
# One stop.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute [r] key
|
|
16
|
+
# @return [Symbol] a spine state's key, or :pr
|
|
17
|
+
# @!attribute [r] label
|
|
18
|
+
# @return [String]
|
|
19
|
+
# @!attribute [r] states
|
|
20
|
+
# @return [Array<Symbol>] the states a plan at this stop may be in
|
|
21
|
+
# @!attribute [r] agents
|
|
22
|
+
# @return [Array<String>] agents whose `handles:` names one of {#states}
|
|
23
|
+
Station = Data.define(:key, :label, :states, :agents) do
|
|
24
|
+
# @return [Hash] `Station` in `types.ts`
|
|
25
|
+
def wire = { key: key.to_s, label:, agents: }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Key, label and the states folded into it, in spine order. ⭐️ Planned
|
|
29
|
+
# sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
|
|
30
|
+
# because on the board each pair is one stop with the same agents.
|
|
31
|
+
GROUPS = [
|
|
32
|
+
[:new, "New", %i[new]],
|
|
33
|
+
[:researched, "Researched", %i[researched retroactive]],
|
|
34
|
+
[:ready_for_planning, "Planning", %i[ready_for_planning]],
|
|
35
|
+
[:building, "Building", %i[planned building]],
|
|
36
|
+
[:building_ui, "Building UI", %i[building_ui]],
|
|
37
|
+
[:in_review, "Review", %i[ready_for_review in_review]],
|
|
38
|
+
[:pr, "Pull request", %i[approved deployed]]
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# @param registry [OpenLanes::Agents::Registry]
|
|
44
|
+
# @return [Array<Station>]
|
|
45
|
+
def all(registry)
|
|
46
|
+
GROUPS.map do |key, label, states|
|
|
47
|
+
Station.new(key:, label:, states:, agents: registry.all.select { |a| a.handles.intersect?(states) }.map(&:name))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The stop a plan in +state+ is shown at. A state off the spine (🔴,
|
|
52
|
+
# 💩, 😱) is shown where the spine takes it next; one with no way
|
|
53
|
+
# forward (⭕️, 🅱️) where the plan was when it stopped, which only
|
|
54
|
+
# its history knows.
|
|
55
|
+
#
|
|
56
|
+
# @param state [Symbol]
|
|
57
|
+
# @param before [Symbol, nil] the state the plan was worked in last
|
|
58
|
+
# @return [Symbol]
|
|
59
|
+
def key_for(state, before: nil)
|
|
60
|
+
[state, Plans::StateMachine::SPINE[state], before].each do |candidate|
|
|
61
|
+
found = GROUPS.find { |_, _, states| states.include?(candidate) } and return found.first
|
|
62
|
+
end
|
|
63
|
+
GROUPS.first.first
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "redis-client"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# Redis, narrowed to what the contract uses: strings with an expiry,
|
|
8
|
+
# `SET NX EX` and compare-and-delete for claims, hashes, streams with a
|
|
9
|
+
# consumer group, `SCAN` by prefix, and `MULTI`.
|
|
10
|
+
#
|
|
11
|
+
# Narrow on purpose. {MemoryStore} answers the same methods, and the
|
|
12
|
+
# suite runs one shared set of examples against both, so every method
|
|
13
|
+
# added here is a method the fake must earn too. A store that offered
|
|
14
|
+
# all of Redis would let the engine come to depend on a command the
|
|
15
|
+
# fake only pretends to understand.
|
|
16
|
+
#
|
|
17
|
+
# Every connection failure surfaces as {Unavailable}, so a caller that
|
|
18
|
+
# wants to keep running without Redis rescues one class, not a gem's.
|
|
19
|
+
class Store
|
|
20
|
+
# One stream entry: its id and its fields.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] id
|
|
23
|
+
# @return [String] `<ms>-<seq>`
|
|
24
|
+
# @!attribute [r] fields
|
|
25
|
+
# @return [Hash{String => String}] empty for an entry trimmed away
|
|
26
|
+
# while it was still pending
|
|
27
|
+
Entry = Data.define(:id, :fields)
|
|
28
|
+
|
|
29
|
+
# Deletes the key only when it still holds the value this process wrote.
|
|
30
|
+
# A plain GET then DEL would delete a claim another node took in between.
|
|
31
|
+
COMPARE_AND_DELETE = <<~LUA
|
|
32
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
|
|
33
|
+
LUA
|
|
34
|
+
|
|
35
|
+
# Extends the key only when it still holds this process's value, so a
|
|
36
|
+
# renewal can never keep alive a claim that has passed to someone else.
|
|
37
|
+
COMPARE_AND_EXPIRE = <<~LUA
|
|
38
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end
|
|
39
|
+
LUA
|
|
40
|
+
|
|
41
|
+
# Seconds a connection attempt or a command may take before the store
|
|
42
|
+
# reports Redis as unavailable. Short, because the engine asks on its
|
|
43
|
+
# tick and a hung Redis must not hang the run.
|
|
44
|
+
TIMEOUT = 2.0
|
|
45
|
+
|
|
46
|
+
# The writes {#multi} accepts. Reads inside a transaction answer
|
|
47
|
+
# nothing until it commits, so none are offered.
|
|
48
|
+
WRITES = %i[set del expire hset hdel xadd xack].freeze
|
|
49
|
+
|
|
50
|
+
# Records the writes of one {#multi} block, to be applied together.
|
|
51
|
+
class Transaction
|
|
52
|
+
def initialize
|
|
53
|
+
@calls = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Array<Array(Symbol, Array, Hash)>]
|
|
57
|
+
attr_reader :calls
|
|
58
|
+
|
|
59
|
+
WRITES.each do |name|
|
|
60
|
+
define_method(name) do |*args, **kwargs|
|
|
61
|
+
@calls << [name, args, kwargs]
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param url [String]
|
|
68
|
+
# @param timeout [Float] seconds
|
|
69
|
+
def initialize(url:, timeout: TIMEOUT)
|
|
70
|
+
@url = url
|
|
71
|
+
@client = RedisClient.config(url:, timeout:, reconnect_attempts: 1).new_client
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [String]
|
|
75
|
+
attr_reader :url
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] whether Redis answered
|
|
78
|
+
def ping = call("PING") == "PONG"
|
|
79
|
+
|
|
80
|
+
# @return [void]
|
|
81
|
+
def close = @client.close
|
|
82
|
+
|
|
83
|
+
# ---- strings ----------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
# @param key [String]
|
|
86
|
+
# @return [String, nil]
|
|
87
|
+
def get(key) = call("GET", key)
|
|
88
|
+
|
|
89
|
+
# @param key [String]
|
|
90
|
+
# @param value [String]
|
|
91
|
+
# @param ex [Integer, nil] seconds to live
|
|
92
|
+
# @param nx [Boolean] only when the key does not exist
|
|
93
|
+
# @return [Boolean] whether the value was written
|
|
94
|
+
def set(key, value, ex: nil, nx: false) = call(*set_command(key, value, ex:, nx:)) == "OK"
|
|
95
|
+
|
|
96
|
+
# @param keys [Array<String>]
|
|
97
|
+
# @return [Integer] how many were removed
|
|
98
|
+
def del(*keys) = keys.empty? ? 0 : call("DEL", *keys)
|
|
99
|
+
|
|
100
|
+
# @param key [String]
|
|
101
|
+
# @param seconds [Integer]
|
|
102
|
+
# @return [Boolean] whether the key exists to expire
|
|
103
|
+
def expire(key, seconds) = call("EXPIRE", key, seconds) == 1
|
|
104
|
+
|
|
105
|
+
# @param key [String]
|
|
106
|
+
# @return [Integer] seconds left, -1 without an expiry, -2 when absent
|
|
107
|
+
def ttl(key) = call("TTL", key)
|
|
108
|
+
|
|
109
|
+
# @param key [String]
|
|
110
|
+
# @param expected [String]
|
|
111
|
+
# @return [Boolean] whether the key held +expected+ and is now gone
|
|
112
|
+
def compare_and_delete(key, expected) = call("EVAL", COMPARE_AND_DELETE, 1, key, expected) == 1
|
|
113
|
+
|
|
114
|
+
# @param key [String]
|
|
115
|
+
# @param expected [String]
|
|
116
|
+
# @param seconds [Integer]
|
|
117
|
+
# @return [Boolean] whether the key held +expected+ and was extended
|
|
118
|
+
def compare_and_expire(key, expected, seconds)
|
|
119
|
+
call("EVAL", COMPARE_AND_EXPIRE, 1, key, expected, seconds) == 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ---- hashes -----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] whether the field is new
|
|
125
|
+
def hset(key, field, value) = call("HSET", key, field, value) == 1
|
|
126
|
+
|
|
127
|
+
# @return [String, nil]
|
|
128
|
+
def hget(key, field) = call("HGET", key, field)
|
|
129
|
+
|
|
130
|
+
# @return [Hash{String => String}]
|
|
131
|
+
def hgetall(key) = call("HGETALL", key).to_h
|
|
132
|
+
|
|
133
|
+
# @return [Integer] how many fields were removed
|
|
134
|
+
def hdel(key, *fields) = fields.empty? ? 0 : call("HDEL", key, *fields)
|
|
135
|
+
|
|
136
|
+
# ---- streams ----------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# @param key [String]
|
|
139
|
+
# @param fields [Hash{#to_s => #to_s}]
|
|
140
|
+
# @param maxlen [Integer, nil] trimmed to about this many entries
|
|
141
|
+
# @return [String] the new entry's id
|
|
142
|
+
def xadd(key, fields, maxlen: nil) = call(*xadd_command(key, fields, maxlen:))
|
|
143
|
+
|
|
144
|
+
# @return [Integer] entries in the stream
|
|
145
|
+
def xlen(key) = call("XLEN", key)
|
|
146
|
+
|
|
147
|
+
# Creates the group, and the stream with it. Asking twice is not an
|
|
148
|
+
# error: every engine start asks.
|
|
149
|
+
#
|
|
150
|
+
# @param key [String]
|
|
151
|
+
# @param group [String]
|
|
152
|
+
# @param from [String] "0" to deliver what is already there, "$" for
|
|
153
|
+
# only what arrives later
|
|
154
|
+
# @return [Boolean] whether the group is new
|
|
155
|
+
def xgroup_create(key, group, from: "0")
|
|
156
|
+
call("XGROUP", "CREATE", key, group, from, "MKSTREAM") == "OK"
|
|
157
|
+
rescue RedisClient::CommandError => e
|
|
158
|
+
raise unless e.message.start_with?("BUSYGROUP")
|
|
159
|
+
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @param key [String]
|
|
164
|
+
# @param group [String]
|
|
165
|
+
# @param consumer [String]
|
|
166
|
+
# @param id [String] ">" for entries never delivered, "0" for this
|
|
167
|
+
# consumer's delivered and unacknowledged ones
|
|
168
|
+
# @param count [Integer]
|
|
169
|
+
# @return [Array<Entry>]
|
|
170
|
+
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
171
|
+
entries(call("XREADGROUP", "GROUP", group, consumer, "COUNT", count, "STREAMS", key, id)&.fetch(key, nil))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Integer] how many were acknowledged
|
|
175
|
+
def xack(key, group, *ids) = ids.empty? ? 0 : call("XACK", key, group, *ids)
|
|
176
|
+
|
|
177
|
+
# Entries after +id+. With +block+ it waits up to that many
|
|
178
|
+
# milliseconds for one to arrive.
|
|
179
|
+
#
|
|
180
|
+
# @param key [String]
|
|
181
|
+
# @param id [String] "$" for only what arrives from now
|
|
182
|
+
# @param count [Integer]
|
|
183
|
+
# @param block [Integer, nil] milliseconds
|
|
184
|
+
# @return [Array<Entry>]
|
|
185
|
+
def xread(key, id: "0", count: 100, block: nil)
|
|
186
|
+
reply = if block
|
|
187
|
+
guarded { @client.blocking_call((block / 1000.0) + TIMEOUT, "XREAD", "BLOCK", block, "COUNT", count, "STREAMS", key, id) }
|
|
188
|
+
else
|
|
189
|
+
call("XREAD", "COUNT", count, "STREAMS", key, id)
|
|
190
|
+
end
|
|
191
|
+
entries(reply&.fetch(key, nil))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [Array<Entry>] oldest first
|
|
195
|
+
def xrange(key, start: "-", stop: "+", count: 100) = entries(call("XRANGE", key, start, stop, "COUNT", count))
|
|
196
|
+
|
|
197
|
+
# @return [Array<Entry>] newest first
|
|
198
|
+
def xrevrange(key, stop: "+", start: "-", count: 100) = entries(call("XREVRANGE", key, stop, start, "COUNT", count))
|
|
199
|
+
|
|
200
|
+
# ---- keys -------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# Every key starting with +prefix+, by cursor, so a large keyspace is
|
|
203
|
+
# never asked for in one blocking `KEYS`.
|
|
204
|
+
#
|
|
205
|
+
# @param prefix [String]
|
|
206
|
+
# @return [Array<String>]
|
|
207
|
+
def scan(prefix)
|
|
208
|
+
cursor = "0"
|
|
209
|
+
found = []
|
|
210
|
+
loop do
|
|
211
|
+
cursor, batch = call("SCAN", cursor, "MATCH", "#{glob_escape(prefix)}*", "COUNT", 500)
|
|
212
|
+
found.concat(batch)
|
|
213
|
+
break if cursor == "0"
|
|
214
|
+
end
|
|
215
|
+
found.uniq.sort
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ---- transactions -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
# The writes the block makes, applied atomically.
|
|
221
|
+
#
|
|
222
|
+
# @yieldparam tx [Transaction]
|
|
223
|
+
# @return [void]
|
|
224
|
+
def multi
|
|
225
|
+
tx = Transaction.new
|
|
226
|
+
yield tx
|
|
227
|
+
return if tx.calls.empty?
|
|
228
|
+
|
|
229
|
+
guarded { @client.multi { |t| tx.calls.each { |name, args, kwargs| t.call(*command_for(name, args, kwargs)) } } }
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
# @return [Object] the reply
|
|
236
|
+
def call(*command) = guarded { @client.call(*command) }
|
|
237
|
+
|
|
238
|
+
# @return [Object] the block's value
|
|
239
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
240
|
+
def guarded
|
|
241
|
+
yield
|
|
242
|
+
rescue RedisClient::CommandError
|
|
243
|
+
raise
|
|
244
|
+
rescue RedisClient::Error => e
|
|
245
|
+
raise Unavailable, "Redis at #{Live.mask(url)} is unavailable: #{e.message.sub(/\s*\(redis:.*\)\z/, "")}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Array] the raw command for one transaction write
|
|
249
|
+
def command_for(name, args, kwargs)
|
|
250
|
+
case name
|
|
251
|
+
when :set then set_command(*args, **kwargs)
|
|
252
|
+
when :xadd then xadd_command(*args, **kwargs)
|
|
253
|
+
else [name.to_s.upcase, *args]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @return [Array]
|
|
258
|
+
def set_command(key, value, ex: nil, nx: false)
|
|
259
|
+
["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @return [Array]
|
|
263
|
+
def xadd_command(key, fields, maxlen: nil)
|
|
264
|
+
["XADD", key, *(["MAXLEN", "~", maxlen] if maxlen), "*", *fields.flat_map { |k, v| [k.to_s, v.to_s] }]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @param raw [Array, nil] `[[id, [field, value, ...]], ...]`
|
|
268
|
+
# @return [Array<Entry>]
|
|
269
|
+
def entries(raw)
|
|
270
|
+
Array(raw).map { |id, pairs| Entry.new(id:, fields: Array(pairs).each_slice(2).to_h) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @return [String] +prefix+ with glob metacharacters taken literally
|
|
274
|
+
def glob_escape(prefix) = prefix.gsub(/[*?\[\]\\]/) { "\\#{it}" }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
# The live run in Redis (plan 004): the engine publishes its board there
|
|
7
|
+
# and reads the page's commands from there, and machines sharing one Redis
|
|
8
|
+
# see each other's boards and never take each other's plans (W6, W13, W26).
|
|
9
|
+
#
|
|
10
|
+
# The engine builds a {Session}. The API server reads boards with {.board},
|
|
11
|
+
# queues commands with {.enqueue} and follows answers with {.replies}, all
|
|
12
|
+
# over the same {Keys}, so neither side spells a key.
|
|
13
|
+
module Live
|
|
14
|
+
# Where Redis is when nothing says otherwise (W16).
|
|
15
|
+
DEFAULT_URL = "redis://localhost:6379/0"
|
|
16
|
+
|
|
17
|
+
# Redis could not be reached, or stopped answering.
|
|
18
|
+
class Unavailable < Error; end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# W16's order: `--redis-url`, then `.openlanes.yml`'s `redis_url:`, then
|
|
23
|
+
# `$REDIS_URL`, then {DEFAULT_URL}.
|
|
24
|
+
#
|
|
25
|
+
# @param option [String, nil] what `--redis-url` typed
|
|
26
|
+
# @param config [OpenLanes::Config]
|
|
27
|
+
# @param env [Hash]
|
|
28
|
+
# @return [String]
|
|
29
|
+
def redis_url(option: nil, config: Config.current, env: ENV)
|
|
30
|
+
[option, config.redis_url, env["REDIS_URL"]].map { it.to_s.strip }.find { !it.empty? } || DEFAULT_URL
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The address with any password replaced, for logs and the page.
|
|
34
|
+
#
|
|
35
|
+
# @param url [String]
|
|
36
|
+
# @return [String]
|
|
37
|
+
def mask(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)([^:@/]*):[^@/]+@}i, '\1\2:***@')
|
|
38
|
+
|
|
39
|
+
# @param url [String]
|
|
40
|
+
# @return [OpenLanes::Live::Store]
|
|
41
|
+
def store(url) = Store.new(url:)
|
|
42
|
+
|
|
43
|
+
# @return [OpenLanes::Live::MemoryStore]
|
|
44
|
+
def memory_store(**) = MemoryStore.new(**)
|
|
45
|
+
|
|
46
|
+
# A store that has answered, or the reason there is none.
|
|
47
|
+
#
|
|
48
|
+
# @param url [String]
|
|
49
|
+
# @return [OpenLanes::Live::Store]
|
|
50
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
51
|
+
def connect(url)
|
|
52
|
+
store = Store.new(url:)
|
|
53
|
+
store.ping or raise Unavailable, "Redis at #{mask(url)} did not answer PING"
|
|
54
|
+
store
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [OpenLanes::Live::Session]
|
|
58
|
+
def session(**) = Session.new(**)
|
|
59
|
+
|
|
60
|
+
# This checkout's keys, for the API server beside its engine.
|
|
61
|
+
#
|
|
62
|
+
# @param root [String]
|
|
63
|
+
# @return [OpenLanes::Live::Keys]
|
|
64
|
+
def keys(root:, node: Node.current(root:)) = Keys.new(repo: Repo.for(root), node: node.name)
|
|
65
|
+
|
|
66
|
+
# `Board` in `web/src/api/types.ts`: this node's snapshot and every other
|
|
67
|
+
# live node's on the same repository. A node is live while its snapshot
|
|
68
|
+
# key exists; one whose key has expired is left out.
|
|
69
|
+
#
|
|
70
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
71
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
72
|
+
# @return [Hash] `{me: Hash or nil, others: Array<Hash>}`, parsed JSON
|
|
73
|
+
def board(store:, keys:)
|
|
74
|
+
registered = store.hgetall(keys.nodes).keys
|
|
75
|
+
snapshots = (registered | [keys.node]).to_h { |name| [name, store.get(keys.snapshot(name))] }.compact
|
|
76
|
+
# A node is in the registry for as long as it heartbeats; one that died
|
|
77
|
+
# without closing never takes itself out, so whoever reads prunes it.
|
|
78
|
+
store.hdel(keys.nodes, *(registered - snapshots.keys))
|
|
79
|
+
others = snapshots.except(keys.node).values.map { JSON.parse(it) }
|
|
80
|
+
others.each { |snapshot| snapshot["node"]["me"] = false }
|
|
81
|
+
{ me: snapshots[keys.node]&.then { JSON.parse(it) }, others: others.sort_by { it.dig("node", "host").to_s } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Queue one command for this node's engine.
|
|
85
|
+
#
|
|
86
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
87
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
88
|
+
# @param input [Hash, String] a `Command`
|
|
89
|
+
# @return [OpenLanes::Live::Reply] accepted
|
|
90
|
+
# @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
|
|
91
|
+
def enqueue(store:, keys:, input:)
|
|
92
|
+
command = Command.parse(input)
|
|
93
|
+
store.xadd(keys.commands, { json: JSON.generate(command.wire) })
|
|
94
|
+
Reply.new(id: command.id, status: "accepted")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Replies after +after+, oldest first, with the stream id of each so the
|
|
98
|
+
# caller can carry on from the last.
|
|
99
|
+
#
|
|
100
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
101
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
102
|
+
# @param after [String] a stream id, "0" for all kept, "$" for new only
|
|
103
|
+
# @param block [Integer, nil] milliseconds to wait for one
|
|
104
|
+
# @return [Array<Array(String, Hash)>] stream id and the `Reply`
|
|
105
|
+
def replies(store:, keys:, after: "$", block: nil)
|
|
106
|
+
store.xread(keys.replies, id: after, block:).map { |entry| [entry.id, JSON.parse(entry.fields["json"])] }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# Telling "this belongs to no specification" apart from "nobody could tell".
|
|
6
|
+
#
|
|
7
|
+
# Both end up without a plan number, and filing them under one marker loses
|
|
8
|
+
# the difference between a finished thought and an open question. A
|
|
9
|
+
# dependency bump genuinely implements no specification and never will —
|
|
10
|
+
# that is an assertion, and `[dev]` records it. A pull request that adds
|
|
11
|
+
# Schedule K-1 for four tax years plainly implements *something*; nothing
|
|
12
|
+
# here could work out what, and `[none]` records that instead, as a job left
|
|
13
|
+
# for a human rather than a verdict.
|
|
14
|
+
#
|
|
15
|
+
# The bar for `[dev]` is deliberately high. Marking real work as `[dev]`
|
|
16
|
+
# tells everyone afterwards it was never worth a plan, and nobody re-opens a
|
|
17
|
+
# question that looks settled. `[none]` is visibly unfinished, so the cost of
|
|
18
|
+
# guessing wrong in that direction is somebody spending a minute on it.
|
|
19
|
+
module DevWork
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Titles that say what they are: dependency bumps, release chores, CI.
|
|
23
|
+
TITLES = [
|
|
24
|
+
/\Abump\s+\S+\s+from\s+\S+\s+to\s+\S+/i,
|
|
25
|
+
/\A(?:chore|ci|build|deps|style|refactor)(?:\([^)]*\))?:/i,
|
|
26
|
+
/\Abump\s+(?:to\s+)?v?\d+\.\d+/i
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Paths that are how the project is built rather than what it does.
|
|
30
|
+
PLUMBING = %r{\A(?:\.github/|\.circleci/|\.devcontainer/|bin/|scripts/|
|
|
31
|
+
Dockerfile|\.dockerignore|Gemfile(?:\.lock)?|justfile|Rakefile|
|
|
32
|
+
\.rubocop\.ya?ml|\.standard\.ya?ml|\.gitignore|\.tool-versions)}x
|
|
33
|
+
|
|
34
|
+
# Files that say nothing either way, so they neither prove plumbing nor
|
|
35
|
+
# disprove it — every pull request in this tool's world touches `.plans`.
|
|
36
|
+
NEUTRAL = %r{\A(?:#{PLANS_DIR}/|CHANGELOG|README)}
|
|
37
|
+
|
|
38
|
+
# @param title [String]
|
|
39
|
+
# @param files [Array<String>] paths in the diff
|
|
40
|
+
# @return [Boolean] whether this can be *asserted* to implement no plan
|
|
41
|
+
def developer?(title, files = [])
|
|
42
|
+
return true if TITLES.any? { |pattern| title.to_s.match?(pattern) }
|
|
43
|
+
|
|
44
|
+
judged = Array(files).reject { |path| path.match?(NEUTRAL) }
|
|
45
|
+
return false if judged.empty?
|
|
46
|
+
|
|
47
|
+
judged.all? { |path| path.match?(PLUMBING) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param title [String]
|
|
51
|
+
# @param files [Array<String>]
|
|
52
|
+
# @return [String] the marker to stamp on it
|
|
53
|
+
def marker(title, files = []) = developer?(title, files) ? NO_PLAN_PREFIX : NONE_PREFIX
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# Lowercase in the middle of a title, capitalised at the front.
|
|
6
|
+
SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
|
|
7
|
+
|
|
8
|
+
# Slug words that are really acronyms and should shout.
|
|
9
|
+
ACRONYMS = %w[
|
|
10
|
+
abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
|
|
11
|
+
html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
|
|
12
|
+
rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
|
|
13
|
+
ux uuid vpc vpn xml yaml
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Words with a house spelling that neither capitalize nor upcase gets right.
|
|
17
|
+
SPECIAL_CASE = {
|
|
18
|
+
"github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
|
|
19
|
+
"javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
|
|
20
|
+
"openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
|
|
21
|
+
"typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
|
|
25
|
+
#
|
|
26
|
+
# @param slug [String]
|
|
27
|
+
# @return [String]
|
|
28
|
+
def self.titleize(slug)
|
|
29
|
+
words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
|
|
30
|
+
return "" if words.empty?
|
|
31
|
+
|
|
32
|
+
words.each_with_index.map { |word, i|
|
|
33
|
+
lower = word.downcase
|
|
34
|
+
if SPECIAL_CASE.key?(lower)
|
|
35
|
+
SPECIAL_CASE[lower]
|
|
36
|
+
elsif ACRONYMS.include?(lower)
|
|
37
|
+
lower.upcase
|
|
38
|
+
elsif i.positive? && SMALL_WORDS.include?(lower)
|
|
39
|
+
lower
|
|
40
|
+
elsif lower.match?(/\A\d+\z/)
|
|
41
|
+
lower
|
|
42
|
+
else
|
|
43
|
+
lower.sub(/\A./, &:upcase)
|
|
44
|
+
end
|
|
45
|
+
}.join(" ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
|
|
49
|
+
#
|
|
50
|
+
# The spaced arrow after the emoji is deliberate: an emoji renders two cells
|
|
51
|
+
# wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
|
|
52
|
+
# as if the emoji and the slug were touching. Everything that mints or
|
|
53
|
+
# renames a folder goes through here; {Feature.parse} accepts the older
|
|
54
|
+
# `--` and single-dash spellings too, and `resync dirs` migrates them on
|
|
55
|
+
# contact.
|
|
56
|
+
#
|
|
57
|
+
# @param ordinal [OpenLanes::Plans::Ordinal, String]
|
|
58
|
+
# @param status [OpenLanes::Plans::Status]
|
|
59
|
+
# @param slug [String]
|
|
60
|
+
# @return [String]
|
|
61
|
+
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
|
|
62
|
+
|
|
63
|
+
# One `NNN.MM-<emoji> → <slug>` folder, decoded.
|
|
64
|
+
#
|
|
65
|
+
# @!attribute [r] ordinal
|
|
66
|
+
# @return [OpenLanes::Plans::Ordinal]
|
|
67
|
+
# @!attribute [r] status
|
|
68
|
+
# @return [OpenLanes::Plans::Status]
|
|
69
|
+
# @!attribute [r] slug
|
|
70
|
+
# @return [String]
|
|
71
|
+
# @!attribute [r] dirname
|
|
72
|
+
# @return [String] exactly as it is on disk
|
|
73
|
+
# @!attribute [r] path
|
|
74
|
+
# @return [String] absolute
|
|
75
|
+
Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
|
|
76
|
+
include Comparable
|
|
77
|
+
|
|
78
|
+
# Decode a folder name, or return nil when it is not a plan folder.
|
|
79
|
+
#
|
|
80
|
+
# @param path [String] absolute path to a candidate directory
|
|
81
|
+
# @return [OpenLanes::Plans::Feature, nil]
|
|
82
|
+
def self.parse(path)
|
|
83
|
+
dirname = File.basename(path)
|
|
84
|
+
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
85
|
+
|
|
86
|
+
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
87
|
+
head, tail = rest.split(/\s*→\s*|[-_]/, 2)
|
|
88
|
+
|
|
89
|
+
# A leading segment with no ASCII word character is the status emoji.
|
|
90
|
+
# The slug strips any further separators: the canonical spelling puts
|
|
91
|
+
# a spaced arrow after the emoji, and folders from before that rule put
|
|
92
|
+
# two dashes, or one.
|
|
93
|
+
status = (OpenLanes::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
94
|
+
slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
|
|
95
|
+
|
|
96
|
+
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [String] proper name, e.g. "Law as Data"
|
|
100
|
+
def title = OpenLanes::Plans.titleize(slug)
|
|
101
|
+
|
|
102
|
+
# The folder name this feature would carry in a given state — the slug
|
|
103
|
+
# never moves, and the number is always rendered canonically, so this is
|
|
104
|
+
# also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
|
|
105
|
+
#
|
|
106
|
+
# @param status [OpenLanes::Plans::Status]
|
|
107
|
+
# @return [String]
|
|
108
|
+
def dirname_as(status) = OpenLanes::Plans.plan_dirname(ordinal, status, slug)
|
|
109
|
+
|
|
110
|
+
# The number exactly as the folder writes it, which is not always the
|
|
111
|
+
# canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
|
|
112
|
+
# "018.00".
|
|
113
|
+
#
|
|
114
|
+
# @return [String]
|
|
115
|
+
def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
|
|
116
|
+
|
|
117
|
+
# @return [Boolean] whether the number is written in full `NNN.MM` form
|
|
118
|
+
def padded? = dirname_ordinal == ordinal.to_s
|
|
119
|
+
|
|
120
|
+
# Whether the folder is already named the way this tool would name it —
|
|
121
|
+
# number padded, emoji matching the state it claims, slug unchanged.
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def canonical? = dirname == dirname_as(status)
|
|
125
|
+
|
|
126
|
+
# @param other [Object]
|
|
127
|
+
# @return [Integer, nil]
|
|
128
|
+
def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|