openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
data/lib/openlanes/live/keys.rb
CHANGED
|
@@ -62,6 +62,112 @@ module OpenLanes
|
|
|
62
62
|
# @return [String]
|
|
63
63
|
def run(of = node) = "#{base}:#{of}:run"
|
|
64
64
|
|
|
65
|
+
# Pub/sub: `{json}`, one line of this node's {Engine::EventJournal},
|
|
66
|
+
# mirrored after it is fsync'd. The file is the record; a subscriber
|
|
67
|
+
# that misses a message reads the file.
|
|
68
|
+
#
|
|
69
|
+
# @param of [String]
|
|
70
|
+
# @return [String]
|
|
71
|
+
def journal(of = node) = "#{base}:#{of}:journal"
|
|
72
|
+
|
|
73
|
+
# Stream: `{json}`, one `CopilotTurn`: the conversation with this
|
|
74
|
+
# node's copilot (W17), the person's messages and its answers in the
|
|
75
|
+
# order they were said. {Copilot} writes and reads it.
|
|
76
|
+
#
|
|
77
|
+
# @param of [String]
|
|
78
|
+
# @return [String]
|
|
79
|
+
def copilot(of = node) = "#{base}:#{of}:copilot"
|
|
80
|
+
|
|
81
|
+
# Hash: `session`, `model`, `up` and `down`, the copilot's `claude`
|
|
82
|
+
# session id and what it has spent in it. Kept beside {#copilot} so a
|
|
83
|
+
# reloaded page or a restarted server carries on the same conversation
|
|
84
|
+
# (W17: one session, resumed on each message).
|
|
85
|
+
#
|
|
86
|
+
# @param of [String]
|
|
87
|
+
# @return [String]
|
|
88
|
+
def copilot_session(of = node) = "#{base}:#{of}:copilot-session"
|
|
89
|
+
|
|
90
|
+
# Stream: `{json}`, one mailbox message and the plan it belongs to,
|
|
91
|
+
# what `openlanes mail send` publishes after writing `state.json`
|
|
92
|
+
# (plan 003, B3). One stream per checkout; the plan rides in the entry,
|
|
93
|
+
# and each plan reads it through a group of its own ({Keys#mail_group}).
|
|
94
|
+
#
|
|
95
|
+
# @param of [String]
|
|
96
|
+
# @return [String]
|
|
97
|
+
def mail(of = node) = "#{base}:#{of}:mail"
|
|
98
|
+
|
|
99
|
+
# The consumer group one plan drains {#mail} through. Every group sees
|
|
100
|
+
# every entry, so a plan's harness thread acks what it read, its own
|
|
101
|
+
# or not, without taking anything from another plan's.
|
|
102
|
+
#
|
|
103
|
+
# @param plan [#to_s] e.g. "003.00"
|
|
104
|
+
# @return [String]
|
|
105
|
+
def mail_group(plan) = "mail:#{plan}"
|
|
106
|
+
|
|
107
|
+
# Pub/sub channel: one chat's messages, joins, leaves and idle notices
|
|
108
|
+
# (plan 006). Redis delivers a `PUBLISH` to every database's
|
|
109
|
+
# subscribers, so the database is part of every chat channel's name: a
|
|
110
|
+
# chat between nodes on database 13 never reaches a page on database 0.
|
|
111
|
+
#
|
|
112
|
+
# @param id [String] the chat's UUID
|
|
113
|
+
# @param db [Integer] the Redis database the nodes share
|
|
114
|
+
# @return [String]
|
|
115
|
+
def chat_room(id, db:) = "#{chat_base(db)}:room:#{id}"
|
|
116
|
+
|
|
117
|
+
# Pub/sub pattern matching every {#chat_room} on +db+, which each API
|
|
118
|
+
# server subscribes to once, so joining a chat needs no new subscription.
|
|
119
|
+
#
|
|
120
|
+
# @param db [Integer]
|
|
121
|
+
# @return [String]
|
|
122
|
+
def chat_rooms(db:) = "#{chat_base(db).gsub(/[*?\[\]\\]/) { "\\#{it}" }}:room:*"
|
|
123
|
+
|
|
124
|
+
# Pub/sub channel: one machine's chat inbox, where invites to it and
|
|
125
|
+
# their answers arrive. Every API server for that machine listens, so
|
|
126
|
+
# an invite reaches every page open there (D9).
|
|
127
|
+
#
|
|
128
|
+
# @param machine [String] a node id, 24 hex digits of `user@host`
|
|
129
|
+
# @param db [Integer]
|
|
130
|
+
# @return [String]
|
|
131
|
+
def chat_inbox(machine, db:) = "#{chat_base(db)}:machine:#{machine}"
|
|
132
|
+
|
|
133
|
+
# String: how an invite was settled (`accepted`, `declined`, `expired`),
|
|
134
|
+
# `SET NX EX`, so the first answer from any page wins (D9).
|
|
135
|
+
#
|
|
136
|
+
# @param invite [String] the invite's UUID
|
|
137
|
+
# @return [String]
|
|
138
|
+
def chat_answer(invite) = "#{base}:chat:invite:#{invite}"
|
|
139
|
+
|
|
140
|
+
# @param db [Integer]
|
|
141
|
+
# @return [String]
|
|
142
|
+
def chat_base(db) = "#{base}:chat:#{db}"
|
|
143
|
+
|
|
144
|
+
# Pub/sub channel: "look again", one per project, published by
|
|
145
|
+
# whoever wrote something an open page shows ({News}). Every node's
|
|
146
|
+
# engine on the project publishes here, because every page shows every
|
|
147
|
+
# node's board. The database is in the name for the reason it is in
|
|
148
|
+
# {#chat_room}'s: a `PUBLISH` reaches every database's subscribers.
|
|
149
|
+
#
|
|
150
|
+
# @param db [Integer] the Redis database the nodes share
|
|
151
|
+
# @return [String]
|
|
152
|
+
def news(db:) = "#{base}:news:#{db}"
|
|
153
|
+
|
|
154
|
+
# Hash: plan ordinal to that plan's running app, its process statuses
|
|
155
|
+
# and a heartbeat ({Apps::Listing}). One per machine, not per
|
|
156
|
+
# checkout: the app runs in a plan's worktree and the control room in
|
|
157
|
+
# the main checkout, and both are this machine.
|
|
158
|
+
#
|
|
159
|
+
# @param machine [String] a node id, 24 hex digits of `user@host`
|
|
160
|
+
# @return [String]
|
|
161
|
+
def apps(machine = node.split(":").first) = "#{base}:apps:#{machine}"
|
|
162
|
+
|
|
163
|
+
# Stream: `{json}`, one request from the Preview tab's toolbar
|
|
164
|
+
# (restart, stop), read by `openlanes app start`. Not {#commands}: the
|
|
165
|
+
# engine never runs an app, so it would never read them.
|
|
166
|
+
#
|
|
167
|
+
# @param machine [String]
|
|
168
|
+
# @return [String]
|
|
169
|
+
def app_commands(machine = node.split(":").first) = "#{apps(machine)}:commands"
|
|
170
|
+
|
|
65
171
|
# String: the node name holding a plan, `SET NX EX`.
|
|
66
172
|
#
|
|
67
173
|
# @param ordinal [#to_s] e.g. "004.00"
|
|
@@ -80,6 +186,14 @@ module OpenLanes
|
|
|
80
186
|
BOARD_LENGTH = 100
|
|
81
187
|
REPLIES_LENGTH = 1000
|
|
82
188
|
|
|
189
|
+
# Length of {#copilot}, kept approximately: a conversation the page
|
|
190
|
+
# can scroll back through, not an archive.
|
|
191
|
+
COPILOT_LENGTH = 500
|
|
192
|
+
|
|
193
|
+
# Seconds a message stays in {#mail}. `state.json` is the record; the
|
|
194
|
+
# stream only has to outlast a harness tick, and an hour is generous.
|
|
195
|
+
MAIL_TTL = 3600
|
|
196
|
+
|
|
83
197
|
# Seconds a snapshot outlives its last refresh.
|
|
84
198
|
SNAPSHOT_TTL = 30
|
|
85
199
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# Mail between agents, over Redis as well as through `state.json`
|
|
9
|
+
# (plan 003, B3).
|
|
10
|
+
#
|
|
11
|
+
# `state.json` stays the record: `openlanes mail send` writes it first
|
|
12
|
+
# and publishes here only when Redis answers, so a run without Redis
|
|
13
|
+
# loses nothing. What the stream adds is delivery to the harness thread
|
|
14
|
+
# beside each agent, which drains it once a second (B4) instead of
|
|
15
|
+
# re-reading every plan's file.
|
|
16
|
+
#
|
|
17
|
+
# A stream with a consumer group per plan, not pub/sub: a message
|
|
18
|
+
# published while nobody drains waits for the next drain instead of
|
|
19
|
+
# vanishing. Every publish trims what is older than {Keys::MAIL_TTL}
|
|
20
|
+
# and renews the key's expiry, so the stream never outgrows an hour.
|
|
21
|
+
#
|
|
22
|
+
# Delivery is acked once the drain's block kept it, never before: an
|
|
23
|
+
# entry acked ahead of a failed write would be gone from the stream with
|
|
24
|
+
# only a log line left. An entry not kept stays pending and is offered
|
|
25
|
+
# again on the next drain. An ack says the bytes arrived, nothing more: a message is read when its recipient runs `openlanes mail ack`,
|
|
26
|
+
# and that lands in `state.json`, never here.
|
|
27
|
+
class Mail
|
|
28
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
29
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
30
|
+
# @param now [Proc] wall time, which the trim's cutoff is taken from
|
|
31
|
+
def initialize(store:, keys:, now: -> { Time.now })
|
|
32
|
+
@store = store
|
|
33
|
+
@keys = keys
|
|
34
|
+
@now = now
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Publish one message that `state.json` already holds.
|
|
38
|
+
#
|
|
39
|
+
# @param plan [#to_s] the plan's number, e.g. "003.00"
|
|
40
|
+
# @param message [OpenLanes::Plans::Mailbox::Message, Hash]
|
|
41
|
+
# @return [void]
|
|
42
|
+
def publish(plan:, message:)
|
|
43
|
+
payload = JSON.generate("plan" => plan.to_s, "message" => wire(message))
|
|
44
|
+
EventBus.for(@store).transaction do |tx|
|
|
45
|
+
tx.append(@keys.mail, { json: payload })
|
|
46
|
+
tx.xtrim(@keys.mail, minid: cutoff)
|
|
47
|
+
tx.expire(@keys.mail, Keys::MAIL_TTL)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Every message for +plan+ that this plan's group has not had kept
|
|
52
|
+
# yet: first the ones offered before and left pending, then the new.
|
|
53
|
+
# Entries for other plans, and entries that are not messages, are
|
|
54
|
+
# acked in this plan's group straight away, which leaves other plans'
|
|
55
|
+
# groups untouched.
|
|
56
|
+
#
|
|
57
|
+
# With a block, each message is yielded and acked only when the block
|
|
58
|
+
# returns truthy; one it returns false for, or raises on, stays pending
|
|
59
|
+
# for the next drain. Without one, each is acked as it is returned.
|
|
60
|
+
#
|
|
61
|
+
# @param plan [#to_s]
|
|
62
|
+
# @param consumer [String] who is draining, e.g. the agent's name
|
|
63
|
+
# @yieldparam message [Hash] as `state.json` holds it
|
|
64
|
+
# @yieldreturn [Boolean] whether it was kept
|
|
65
|
+
# @return [Array<Hash>] messages as `state.json` holds them, oldest
|
|
66
|
+
# first; with a block, the ones left pending
|
|
67
|
+
def drain(plan:, consumer:)
|
|
68
|
+
group = @keys.mail_group(plan)
|
|
69
|
+
@store.expire(@keys.mail, Keys::MAIL_TTL) if @store.xgroup_create(@keys.mail, group)
|
|
70
|
+
entries = @store.xreadgroup(@keys.mail, group, consumer, id: "0") + @store.xreadgroup(@keys.mail, group, consumer)
|
|
71
|
+
entries.filter_map do |entry|
|
|
72
|
+
message = message_in(entry, plan.to_s)
|
|
73
|
+
kept = message.nil? || !block_given? || yield(message)
|
|
74
|
+
@store.xack(@keys.mail, group, entry.id) if kept
|
|
75
|
+
message if message && (kept ? !block_given? : true)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Which plans have mail in the stream after +cursor+, so a drain can
|
|
80
|
+
# reach a plan with nobody running on it: a worktree's `mail send` has
|
|
81
|
+
# no other way into the main checkout's `state.json`, and the stream
|
|
82
|
+
# forgets it after {Keys::MAIL_TTL}.
|
|
83
|
+
#
|
|
84
|
+
# @param cursor [String] the last stream id already looked at, "0" for
|
|
85
|
+
# all of it
|
|
86
|
+
# @return [Array(Array<String>, String)] the plans, and the new cursor
|
|
87
|
+
def plans_since(cursor)
|
|
88
|
+
plans = []
|
|
89
|
+
loop do
|
|
90
|
+
entries = @store.xread(@keys.mail, id: cursor)
|
|
91
|
+
break if entries.empty?
|
|
92
|
+
|
|
93
|
+
cursor = entries.last.id
|
|
94
|
+
plans.concat(entries.filter_map { plan_in(it) })
|
|
95
|
+
end
|
|
96
|
+
[plans.uniq, cursor]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# The oldest id a publish keeps: {Keys::MAIL_TTL} seconds ago.
|
|
102
|
+
#
|
|
103
|
+
# @return [String]
|
|
104
|
+
def cutoff = "#{((@now.call.to_f - Keys::MAIL_TTL) * 1000).to_i}-0"
|
|
105
|
+
|
|
106
|
+
# @param message [OpenLanes::Plans::Mailbox::Message, Hash]
|
|
107
|
+
# @return [Hash] string keys, `at` in ISO 8601 as `state.json` has it
|
|
108
|
+
def wire(message)
|
|
109
|
+
hash = message.to_h.transform_keys(&:to_s)
|
|
110
|
+
hash.merge("at" => iso8601(hash["at"])).compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param at [String, Time, nil]
|
|
114
|
+
# @return [String, nil]
|
|
115
|
+
def iso8601(at)
|
|
116
|
+
return at.iso8601 if at.respond_to?(:iso8601)
|
|
117
|
+
|
|
118
|
+
Time.parse(at.to_s).iso8601
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
at&.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
124
|
+
# @return [String, nil] the plan the entry is for
|
|
125
|
+
def plan_in(entry)
|
|
126
|
+
data = JSON.parse(entry.fields["json"].to_s)
|
|
127
|
+
data["plan"] if data.is_a?(Hash) && data["plan"].is_a?(String)
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
133
|
+
# @param plan [String]
|
|
134
|
+
# @return [Hash, nil] the message, when the entry is one and for +plan+
|
|
135
|
+
def message_in(entry, plan)
|
|
136
|
+
data = JSON.parse(entry.fields["json"].to_s)
|
|
137
|
+
data["message"] if data.is_a?(Hash) && data["plan"] == plan && data["message"].is_a?(Hash)
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -22,6 +22,7 @@ module OpenLanes
|
|
|
22
22
|
@groups = Hash.new { |h, k| h[k] = {} }
|
|
23
23
|
@last_ms = 0
|
|
24
24
|
@last_seq = 0
|
|
25
|
+
@subscribers = []
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# @return [String] what {Live.mask} shows for it
|
|
@@ -125,6 +126,17 @@ module OpenLanes
|
|
|
125
126
|
# @return [Integer]
|
|
126
127
|
def xlen(key) = locked { (live(key) || []).size }
|
|
127
128
|
|
|
129
|
+
# @return [Integer] how many were removed
|
|
130
|
+
def xtrim(key, minid:)
|
|
131
|
+
locked do
|
|
132
|
+
stream = live(key) or next 0
|
|
133
|
+
floor = id_of(minid.include?("-") ? minid : "#{minid}-0")
|
|
134
|
+
before = stream.size
|
|
135
|
+
stream.reject! { (id_of(it.id) <=> floor).negative? }
|
|
136
|
+
before - stream.size
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
128
140
|
# @return [Boolean] whether the group is new
|
|
129
141
|
def xgroup_create(key, group, from: "0")
|
|
130
142
|
locked do
|
|
@@ -140,7 +152,7 @@ module OpenLanes
|
|
|
140
152
|
# @return [Array<Store::Entry>]
|
|
141
153
|
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
142
154
|
locked do
|
|
143
|
-
state = @groups[key][group] or raise
|
|
155
|
+
state = @groups[key][group] or raise GroupMissing, "NOGROUP #{group} on #{key}"
|
|
144
156
|
stream = live(key) || []
|
|
145
157
|
if id == ">"
|
|
146
158
|
fresh = stream.select { |e| (id_of(e.id) <=> state[:last]).positive? }.first(count)
|
|
@@ -193,6 +205,29 @@ module OpenLanes
|
|
|
193
205
|
# @return [Array<String>]
|
|
194
206
|
def scan(prefix) = locked { @data.keys.select { |k| k.start_with?(prefix) && !live(k).nil? }.sort }
|
|
195
207
|
|
|
208
|
+
# ---- pub/sub ----------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# @return [Integer] how many subscriptions received it
|
|
211
|
+
def publish(channel, message)
|
|
212
|
+
receivers = locked { @subscribers.dup }
|
|
213
|
+
receivers.sum do |subscriber|
|
|
214
|
+
subscriber.deliver(channel, message.to_s)
|
|
215
|
+
subscriber.matches(channel)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @return [OpenLanes::Live::MemorySubscriber]
|
|
220
|
+
def subscriber(channels: [], patterns: [])
|
|
221
|
+
MemorySubscriber.new(store: self, channels:, patterns:).tap { |s| locked { @subscribers << s } }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @param subscriber [OpenLanes::Live::MemorySubscriber]
|
|
225
|
+
# @return [void]
|
|
226
|
+
def unsubscribe(subscriber) = locked { @subscribers.delete(subscriber) }
|
|
227
|
+
|
|
228
|
+
# @return [Integer] always 0: one process, one keyspace
|
|
229
|
+
def db = 0
|
|
230
|
+
|
|
196
231
|
# ---- transactions -----------------------------------------------------
|
|
197
232
|
|
|
198
233
|
# @yieldparam tx [Store::Transaction]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# {Subscriber}'s methods over {MemoryStore#publish}: a queue in this
|
|
6
|
+
# process, matched by exact channel or by Redis-style glob.
|
|
7
|
+
class MemorySubscriber
|
|
8
|
+
# @param store [OpenLanes::Live::MemoryStore]
|
|
9
|
+
# @param channels [Array<String>]
|
|
10
|
+
# @param patterns [Array<String>]
|
|
11
|
+
def initialize(store:, channels: [], patterns: [])
|
|
12
|
+
@store = store
|
|
13
|
+
@channels = channels
|
|
14
|
+
@patterns = patterns
|
|
15
|
+
@queue = Thread::Queue.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param channel [String]
|
|
19
|
+
# @return [Integer] how many of its subscriptions match, as Redis counts receivers
|
|
20
|
+
def matches(channel)
|
|
21
|
+
@channels.count(channel) + @patterns.count { File.fnmatch?(it, channel) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param channel [String]
|
|
25
|
+
# @param message [String]
|
|
26
|
+
# @return [void]
|
|
27
|
+
def deliver(channel, message) = matches(channel).times { @queue << [channel, message] }
|
|
28
|
+
|
|
29
|
+
# @param timeout [Float]
|
|
30
|
+
# @return [Array(String, String), nil]
|
|
31
|
+
def next_message(timeout) = @queue.pop(timeout: [timeout, 0].max)
|
|
32
|
+
|
|
33
|
+
# @return [void]
|
|
34
|
+
def close
|
|
35
|
+
@store.unsubscribe(self)
|
|
36
|
+
@queue.close
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# The nudge that tells every open page's event stream to look again
|
|
8
|
+
# ({Keys#news}). It carries no data: whoever wrote something the page
|
|
9
|
+
# shows writes it first, then publishes `{"kind": ...}`, and the stream
|
|
10
|
+
# re-reads Redis as it always has. The data stays in its keys and
|
|
11
|
+
# streams, so a nudge lost to a dropped connection costs latency, never
|
|
12
|
+
# correctness, and the stream's cursors work exactly as before.
|
|
13
|
+
#
|
|
14
|
+
# Who publishes, and why each one does:
|
|
15
|
+
#
|
|
16
|
+
# - `board`: {Publisher}, on the Dispatcher's tick, only when the
|
|
17
|
+
# snapshot changed apart from its clock, so an idle engine is quiet.
|
|
18
|
+
# - `reply`: {Commands}, on the Dispatcher's tick, for each reply.
|
|
19
|
+
# - `copilot`: {Copilot}, in the API server, for each turn written.
|
|
20
|
+
# - `app`: {Apps::Runner}, when a process changes state or the app stops.
|
|
21
|
+
# Its once-a-second heartbeat does not: nothing on the page moves.
|
|
22
|
+
# - `chat`: {Chat}, after a step handed a page something to show.
|
|
23
|
+
# Its events live in this process, not in Redis, so the nudge is the
|
|
24
|
+
# only way a stream blocked on Redis can hear of them.
|
|
25
|
+
# - `stop`: {Web::Server#stop}, so open streams end now rather than at
|
|
26
|
+
# their next ping.
|
|
27
|
+
#
|
|
28
|
+
# The `--demo` engine publishes `board` and `reply` the way the real one does.
|
|
29
|
+
module News
|
|
30
|
+
# Every kind a nudge may name. The stream does not branch on them; they
|
|
31
|
+
# are there for whoever watches the channel with `redis-cli SUBSCRIBE`.
|
|
32
|
+
KINDS = %w[board reply copilot app chat stop].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Nudge every stream following +keys+' project on +store+'s database.
|
|
37
|
+
# Best effort: the write it announces is already in Redis, and a
|
|
38
|
+
# stream that misses the nudge still finds it at its next ping.
|
|
39
|
+
#
|
|
40
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
41
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
42
|
+
# @param kind [String] one of {KINDS}
|
|
43
|
+
# @return [Integer] how many subscriptions heard it, 0 when Redis did not answer
|
|
44
|
+
def publish(store:, keys:, kind:)
|
|
45
|
+
bus = EventBus.for(store)
|
|
46
|
+
bus.publish(keys.news(db: bus.db), JSON.generate(kind:))
|
|
47
|
+
rescue Unavailable
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A subscription of its own to the nudges, for one event stream. The
|
|
52
|
+
# caller closes it: each is a Redis connection.
|
|
53
|
+
#
|
|
54
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
55
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
56
|
+
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
57
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
58
|
+
def subscribe(store:, keys:) = EventBus.for(store).then { it.subscribe(channels: [keys.news(db: it.db)]) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/openlanes/live/node.rb
CHANGED
|
@@ -46,7 +46,27 @@ module OpenLanes
|
|
|
46
46
|
ID_LENGTH = 24
|
|
47
47
|
CHECKOUT_LENGTH = 12
|
|
48
48
|
|
|
49
|
+
# A declared `user@host`: one of each, no spaces, no second `@`.
|
|
50
|
+
IDENTITY = /\A(?<user>[^@\s]+)@(?<host>[^@\s]+)\z/
|
|
51
|
+
|
|
52
|
+
# A declared identity that is not `user@host`.
|
|
53
|
+
class Invalid < ArgumentError; end
|
|
54
|
+
|
|
49
55
|
class << self
|
|
56
|
+
# This checkout as a machine of another name: `openlanes web --demo
|
|
57
|
+
# --identity demo@second-machine` registers as a second machine on the
|
|
58
|
+
# same Redis and repository, through the same digest every process
|
|
59
|
+
# is already named by, so nothing reads a second naming scheme.
|
|
60
|
+
#
|
|
61
|
+
# @param identity [String] `user@host`
|
|
62
|
+
# @param root [String] the checkout
|
|
63
|
+
# @return [OpenLanes::Live::Node]
|
|
64
|
+
# @raise [Invalid]
|
|
65
|
+
def declared(identity, root:, **)
|
|
66
|
+
match = IDENTITY.match(identity.to_s.strip) or raise Invalid, "an identity is user@host, not #{identity.inspect}"
|
|
67
|
+
current(root:, user: match[:user], host: match[:host], **)
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
# @param root [String] the checkout
|
|
51
71
|
# @param user [String]
|
|
52
72
|
# @param host [String]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# What the engine does when its Redis stops answering (W32).
|
|
6
|
+
#
|
|
7
|
+
# A blip is not an outage: Redis that is back within {GRACE} seconds
|
|
8
|
+
# changes nothing. Past that, every running agent is paused through its
|
|
9
|
+
# control file ({Execution::Control::PAUSE}), nothing new starts, and the
|
|
10
|
+
# outage goes into the run's state file, the record that survives Redis.
|
|
11
|
+
# Redis is then asked again after 1, 1, 2, 3, 5, 8 … seconds, the
|
|
12
|
+
# Fibonacci numbers scaled by `redis_backoff.initial` and capped at
|
|
13
|
+
# `redis_backoff.cap` ({MAX_GAP} by default), so a short outage is noticed
|
|
14
|
+
# quickly, a long one is not hammered, and a Redis that comes back is
|
|
15
|
+
# never left unnoticed for longer than the cap. With full jitter (the
|
|
16
|
+
# default) each gap is drawn uniformly below that, so engines sharing one
|
|
17
|
+
# Redis do not all ask in the same second. When it answers, the agents
|
|
18
|
+
# carry on, their clocks having stood still meanwhile. When it has not answered for
|
|
19
|
+
# `redis_outage_hours` (an hour by default), the run shuts down the way
|
|
20
|
+
# `q` does, and says why.
|
|
21
|
+
#
|
|
22
|
+
# Run on the dispatcher's tick, ahead of everything that needs Redis.
|
|
23
|
+
class Outage
|
|
24
|
+
# Seconds Redis may be unreachable before the agents are paused.
|
|
25
|
+
GRACE = 10
|
|
26
|
+
|
|
27
|
+
# Seconds between pings while Redis answers.
|
|
28
|
+
PING_EVERY = 1
|
|
29
|
+
|
|
30
|
+
# The longest gap between two asks while paused, in seconds. Without
|
|
31
|
+
# it the Fibonacci gaps pass 16 minutes within fifteen asks, and a Redis
|
|
32
|
+
# back after 17 minutes would leave every agent paused for 10 more.
|
|
33
|
+
MAX_GAP = 30
|
|
34
|
+
|
|
35
|
+
# How many past outages the state file keeps.
|
|
36
|
+
HISTORY = 20
|
|
37
|
+
|
|
38
|
+
# One outage that ended in Redis answering: wall-clock bounds, and the
|
|
39
|
+
# length by the monotonic clock, which a changed system time cannot
|
|
40
|
+
# skew. {OutageReport} turns it into `run.outage` (plan 022).
|
|
41
|
+
Window = Data.define(:started_at, :ended_at, :seconds)
|
|
42
|
+
|
|
43
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
44
|
+
# @param limit [Numeric] seconds paused before the run gives up
|
|
45
|
+
# @param clock [#call] monotonic seconds
|
|
46
|
+
# @param wall [#call] wall time, for the state file
|
|
47
|
+
# @param initial [Numeric] seconds before the first ask; the Fibonacci gaps are its multiples
|
|
48
|
+
# @param cap [Numeric] the longest gap between two asks
|
|
49
|
+
# @param jitter [Boolean] full jitter: each gap is drawn from [0, gap)
|
|
50
|
+
# @param random [#rand] the jitter's source; a spec hands in a fixed one
|
|
51
|
+
def initialize(store:, limit:, clock: -> { UI.monotonic }, wall: -> { Time.now },
|
|
52
|
+
initial: 1, cap: MAX_GAP, jitter: true, random: Random.new)
|
|
53
|
+
@store = store
|
|
54
|
+
@limit = limit
|
|
55
|
+
@initial = initial
|
|
56
|
+
@cap = cap
|
|
57
|
+
@jitter = jitter
|
|
58
|
+
@random = random
|
|
59
|
+
@clock = clock
|
|
60
|
+
@wall = wall
|
|
61
|
+
@state = :up
|
|
62
|
+
@down_since = nil
|
|
63
|
+
@last_ping = nil
|
|
64
|
+
@resumed = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Symbol] :up, :paused or :gave_up
|
|
68
|
+
attr_reader :state
|
|
69
|
+
|
|
70
|
+
# One tick.
|
|
71
|
+
#
|
|
72
|
+
# @param dispatcher [OpenLanes::Engine::Dispatcher] for its state file
|
|
73
|
+
# @return [void]
|
|
74
|
+
def call(dispatcher)
|
|
75
|
+
@state_file = dispatcher.state_file
|
|
76
|
+
now = @clock.call
|
|
77
|
+
case @state
|
|
78
|
+
when :up then watch(now)
|
|
79
|
+
when :paused then probe(now)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] whether agents are paused for this outage
|
|
84
|
+
def paused? = @state == :paused
|
|
85
|
+
|
|
86
|
+
# @return [Proc] {#call}, for `Dispatcher#before_tick`
|
|
87
|
+
def to_proc = method(:call).to_proc
|
|
88
|
+
|
|
89
|
+
# Something to run once Redis answers again, after the agents resume.
|
|
90
|
+
#
|
|
91
|
+
# @yieldparam window [Window]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def on_resume(&hook) = @resumed << hook
|
|
94
|
+
|
|
95
|
+
# @return [Window, nil] the last outage that ended in Redis answering
|
|
96
|
+
attr_reader :window
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def watch(now)
|
|
102
|
+
return if @last_ping && now - @last_ping < PING_EVERY
|
|
103
|
+
|
|
104
|
+
@last_ping = now
|
|
105
|
+
if answers?
|
|
106
|
+
@down_since = nil
|
|
107
|
+
else
|
|
108
|
+
@down_since ||= now
|
|
109
|
+
pause(now) if now - @down_since >= GRACE
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [void]
|
|
114
|
+
def pause(now)
|
|
115
|
+
@state = :paused
|
|
116
|
+
@delays = fibonacci
|
|
117
|
+
@attempts = 0
|
|
118
|
+
@next_probe = next_probe(now)
|
|
119
|
+
@record = { "since" => (@wall.call - (now - @down_since)).iso8601, "paused_at" => @wall.call.iso8601,
|
|
120
|
+
"limit_seconds" => @limit.round, "attempts" => 0 }
|
|
121
|
+
UI.log("Redis has not answered for #{GRACE}s: pausing every agent, starting nothing new, " \
|
|
122
|
+
"and giving up after #{hours(@limit)}",
|
|
123
|
+
level: :warn)
|
|
124
|
+
Execution::Control.pause!
|
|
125
|
+
save
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [void]
|
|
129
|
+
def probe(now)
|
|
130
|
+
return if now < @next_probe
|
|
131
|
+
|
|
132
|
+
@attempts += 1
|
|
133
|
+
@record["attempts"] = @attempts
|
|
134
|
+
@record["last_attempt_at"] = @wall.call.iso8601
|
|
135
|
+
if answers?
|
|
136
|
+
resume(now)
|
|
137
|
+
elsif now - @down_since >= @limit
|
|
138
|
+
give_up(now)
|
|
139
|
+
else
|
|
140
|
+
@next_probe = next_probe(now)
|
|
141
|
+
save
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [void]
|
|
146
|
+
def resume(now)
|
|
147
|
+
@state = :up
|
|
148
|
+
@record["resumed_at"] = @wall.call.iso8601
|
|
149
|
+
@record["seconds"] = (now - @down_since).round
|
|
150
|
+
UI.log("Redis answers again after #{(now - @down_since).round}s and #{@attempts} attempts: resuming every agent")
|
|
151
|
+
Execution::Control.resume!
|
|
152
|
+
@window = Window.new(started_at: @wall.call - (now - @down_since),
|
|
153
|
+
ended_at: @wall.call,
|
|
154
|
+
seconds: now - @down_since)
|
|
155
|
+
@down_since = nil
|
|
156
|
+
save(over: true)
|
|
157
|
+
@resumed.each { |hook| hook.call(@window) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [void]
|
|
161
|
+
def give_up(now)
|
|
162
|
+
@state = :gave_up
|
|
163
|
+
@record["gave_up_at"] = @wall.call.iso8601
|
|
164
|
+
@record["seconds"] = (now - @down_since).round
|
|
165
|
+
UI.log("Redis has not answered for #{hours(@limit)}: shutting the run down", level: :error)
|
|
166
|
+
Execution::Control.quit!
|
|
167
|
+
save(over: true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The next Fibonacci gap, scaled, capped, jittered, and never past the
|
|
171
|
+
# limit: the last ask is the one at the limit.
|
|
172
|
+
#
|
|
173
|
+
# @return [Float]
|
|
174
|
+
def next_probe(now) = [now + gap, @down_since + @limit].min
|
|
175
|
+
|
|
176
|
+
# @return [Float] seconds to the next ask
|
|
177
|
+
def gap
|
|
178
|
+
gap = [@delays.next * @initial, @cap].min.to_f
|
|
179
|
+
@jitter ? gap * @random.rand : gap
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def answers?
|
|
184
|
+
@store.ping == true
|
|
185
|
+
rescue Unavailable, SystemCallError
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 1, 1, 2, 3, 5, 8, …
|
|
190
|
+
#
|
|
191
|
+
# @return [Enumerator]
|
|
192
|
+
def fibonacci
|
|
193
|
+
Enumerator.new do |out|
|
|
194
|
+
a, b = 1, 1
|
|
195
|
+
loop do
|
|
196
|
+
out << a
|
|
197
|
+
a, b = b, a + b
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The current outage, or, once it is over, into the history. Saved at
|
|
203
|
+
# once: this is the record a person reads to learn why agents stood
|
|
204
|
+
# still, and the run may not live to save it later.
|
|
205
|
+
#
|
|
206
|
+
# @param over [Boolean]
|
|
207
|
+
# @return [void]
|
|
208
|
+
def save(over: false)
|
|
209
|
+
return unless @state_file
|
|
210
|
+
|
|
211
|
+
@state_file.outage = over ? nil : @record
|
|
212
|
+
@state_file.outages = (@state_file.outages + [@record]).last(HISTORY) if over
|
|
213
|
+
@state_file.save
|
|
214
|
+
rescue SystemCallError => e
|
|
215
|
+
UI.log("outage not recorded: #{e.message}", level: :warn)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [String]
|
|
219
|
+
def hours(seconds)
|
|
220
|
+
hours = seconds / 3600.0
|
|
221
|
+
hours == hours.round ? "#{hours.round} hour#{"s" unless hours.round == 1}" : "#{hours.round(2)} hours"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|