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
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module OpenLanes
|
|
9
|
+
module Live
|
|
10
|
+
# Chat between machines (plan 006): one per API server, relaying Redis
|
|
11
|
+
# pub/sub to the pages it serves. Nothing said is stored (D2): a message
|
|
12
|
+
# is published, every API server with a page in that chat hears it, and
|
|
13
|
+
# each hands it to its pages over the event stream they already hold
|
|
14
|
+
# (`event: chat`). The page sends by POST, as it does everything else.
|
|
15
|
+
#
|
|
16
|
+
# Two kinds of channel, named by {Keys}: a room per chat
|
|
17
|
+
# ({Keys#chat_room}), which every server takes through one pattern, and an
|
|
18
|
+
# inbox per machine ({Keys#chat_inbox}), where invites and their answers
|
|
19
|
+
# arrive. Both carry the Redis database in their name, because pub/sub
|
|
20
|
+
# does not.
|
|
21
|
+
#
|
|
22
|
+
# A participant is one page, named by the id the page keeps in its
|
|
23
|
+
# session storage, so a reload is the same participant. The roster is not
|
|
24
|
+
# stored either: a joiner announces itself, everyone already there answers
|
|
25
|
+
# `here`, and every server repeats `here` for its own participants each
|
|
26
|
+
# {HEARTBEAT}, which is also how one whose server died is noticed.
|
|
27
|
+
#
|
|
28
|
+
# The listening thread calls {#step}; the suite calls it by hand.
|
|
29
|
+
class Chat
|
|
30
|
+
# A request the page may not make as written: the API server's 422.
|
|
31
|
+
class Invalid < Error; end
|
|
32
|
+
|
|
33
|
+
# An invite already answered, expired or never seen: the API server's 409.
|
|
34
|
+
class Settled < Error; end
|
|
35
|
+
|
|
36
|
+
# Characters one message may carry (D10).
|
|
37
|
+
LIMIT = 4000
|
|
38
|
+
|
|
39
|
+
# Characters a name may carry.
|
|
40
|
+
NAME_LIMIT = 60
|
|
41
|
+
|
|
42
|
+
# Seconds an invite waits for an answer (D9).
|
|
43
|
+
INVITE_TTL = 60
|
|
44
|
+
|
|
45
|
+
# Seconds a page may be without its event stream before it counts as
|
|
46
|
+
# having left (D6): long enough for a reload or a dropped connection.
|
|
47
|
+
GRACE = 10
|
|
48
|
+
|
|
49
|
+
# Seconds between a server's `here` for each of its participants.
|
|
50
|
+
HEARTBEAT = 20
|
|
51
|
+
|
|
52
|
+
# Seconds without a word from a participant before it is taken as gone.
|
|
53
|
+
FORGET_AFTER = HEARTBEAT * 3
|
|
54
|
+
|
|
55
|
+
# Events kept for a page that is not reading them.
|
|
56
|
+
BACKLOG = 200
|
|
57
|
+
|
|
58
|
+
# Seconds the listening thread waits for a message before it looks
|
|
59
|
+
# after expiries and heartbeats.
|
|
60
|
+
POLL = 0.25
|
|
61
|
+
|
|
62
|
+
# A page id: what the page mints for itself.
|
|
63
|
+
PAGE = /\A[A-Za-z0-9_-]{8,64}\z/
|
|
64
|
+
|
|
65
|
+
# A chat or invite id.
|
|
66
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
67
|
+
|
|
68
|
+
# A node id, 24 hex digits.
|
|
69
|
+
MACHINE = /\A\h{24}\z/
|
|
70
|
+
|
|
71
|
+
# What a page tells the room it is doing.
|
|
72
|
+
ROOM_KINDS = %w[join here say idle back leave].freeze
|
|
73
|
+
|
|
74
|
+
# One page this server serves.
|
|
75
|
+
Page = Struct.new(:id, :events, :streams, :gone_at, :chat, :name, :idle)
|
|
76
|
+
|
|
77
|
+
# @param stores [#current] a store per thread, as {Web::Stores} gives
|
|
78
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
79
|
+
# @param machine [String] this node's id
|
|
80
|
+
# @param label [String] `user@host`, what other pages show for it
|
|
81
|
+
# @param clock [Proc] seconds, monotonic
|
|
82
|
+
# @param wall [Proc] the time, for what the page shows
|
|
83
|
+
def initialize(stores:, keys:, machine:, label:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
84
|
+
wall: -> { Time.now })
|
|
85
|
+
@stores = stores
|
|
86
|
+
@keys = keys
|
|
87
|
+
@machine = machine
|
|
88
|
+
@label = label
|
|
89
|
+
@clock = clock
|
|
90
|
+
@wall = wall
|
|
91
|
+
@lock = Monitor.new
|
|
92
|
+
@pages = {}
|
|
93
|
+
@rooms = Hash.new { |h, k| h[k] = {} }
|
|
94
|
+
@incoming = {}
|
|
95
|
+
@outgoing = {}
|
|
96
|
+
@beat = nil
|
|
97
|
+
@running = false
|
|
98
|
+
@unavailable = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [String]
|
|
102
|
+
attr_reader :machine
|
|
103
|
+
|
|
104
|
+
# ---- the listening thread ---------------------------------------------
|
|
105
|
+
|
|
106
|
+
# Listen on a thread of its own until {#stop}.
|
|
107
|
+
#
|
|
108
|
+
# @return [self]
|
|
109
|
+
def start
|
|
110
|
+
@running = true
|
|
111
|
+
@thread = Thread.new do
|
|
112
|
+
while @running
|
|
113
|
+
listen(POLL) or sleep 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Say goodbye for every page here, then stop listening.
|
|
120
|
+
#
|
|
121
|
+
# @return [void]
|
|
122
|
+
def stop
|
|
123
|
+
@running = false
|
|
124
|
+
@thread&.join(POLL * 4)
|
|
125
|
+
pages = @lock.synchronize { @pages.values.select(&:chat) }
|
|
126
|
+
pages.each { leave(page: it.id) }
|
|
127
|
+
rescue Unavailable
|
|
128
|
+
nil
|
|
129
|
+
ensure
|
|
130
|
+
close_subscriber
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Handle whatever has arrived, waiting up to +timeout+ for the first,
|
|
134
|
+
# then expire, prune and heartbeat, and nudge the event streams when a
|
|
135
|
+
# page was handed something to show.
|
|
136
|
+
#
|
|
137
|
+
# @param timeout [Float]
|
|
138
|
+
# @return [void]
|
|
139
|
+
def step(timeout = 0)
|
|
140
|
+
sub = subscriber
|
|
141
|
+
message = sub.next_message(timeout)
|
|
142
|
+
while message
|
|
143
|
+
receive(*message)
|
|
144
|
+
message = sub.next_message(0)
|
|
145
|
+
end
|
|
146
|
+
housekeep
|
|
147
|
+
nudge
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# {#step}, kept from raising when Redis refuses the subscription (an ACL
|
|
151
|
+
# without `PSUBSCRIBE` answers `NOPERM`) or drops the connection. The
|
|
152
|
+
# board does not need the chat, so the server carries on serving it:
|
|
153
|
+
# one warning per outage, `unavailable` in {#state} until a step works
|
|
154
|
+
# again, and a fresh subscription on the next try.
|
|
155
|
+
#
|
|
156
|
+
# @param timeout [Float]
|
|
157
|
+
# @return [Boolean] whether the step went through
|
|
158
|
+
def listen(timeout = 0)
|
|
159
|
+
step(timeout)
|
|
160
|
+
@lock.synchronize { @unavailable = nil }
|
|
161
|
+
true
|
|
162
|
+
rescue Unavailable => e
|
|
163
|
+
close_subscriber
|
|
164
|
+
first = @lock.synchronize { @unavailable.nil?.tap { @unavailable = e.message } }
|
|
165
|
+
warn_unavailable(e.message) if first
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [String, nil] why the chat cannot listen, while it cannot
|
|
170
|
+
def unavailable = @lock.synchronize { @unavailable }
|
|
171
|
+
|
|
172
|
+
# ---- the event stream -------------------------------------------------
|
|
173
|
+
|
|
174
|
+
# A page's event stream opened.
|
|
175
|
+
#
|
|
176
|
+
# @param id [String]
|
|
177
|
+
# @return [void]
|
|
178
|
+
def attach(id)
|
|
179
|
+
@lock.synchronize do
|
|
180
|
+
page = page!(id)
|
|
181
|
+
page.streams += 1
|
|
182
|
+
page.gone_at = nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A page's event stream closed. It has {GRACE} to come back.
|
|
187
|
+
#
|
|
188
|
+
# @param id [String]
|
|
189
|
+
# @return [void]
|
|
190
|
+
def detach(id)
|
|
191
|
+
@lock.synchronize do
|
|
192
|
+
page = @pages[id] or return
|
|
193
|
+
page.streams = [page.streams - 1, 0].max
|
|
194
|
+
page.gone_at = now if page.streams.zero?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Every event for +id+ since the last call, oldest first.
|
|
199
|
+
#
|
|
200
|
+
# @param id [String]
|
|
201
|
+
# @return [Array<Hash>] `ChatEvent`s
|
|
202
|
+
def drain(id) = @lock.synchronize { @pages[id]&.events&.slice!(0..) || [] }
|
|
203
|
+
|
|
204
|
+
# ---- what the page asks -----------------------------------------------
|
|
205
|
+
|
|
206
|
+
# `ChatState`: the chat this page is in, the invites open to this
|
|
207
|
+
# machine, the ones this page is waiting on, and, while Redis refuses
|
|
208
|
+
# the subscription, why the chat is unavailable.
|
|
209
|
+
#
|
|
210
|
+
# @param page [String]
|
|
211
|
+
# @return [Hash]
|
|
212
|
+
def state(page:)
|
|
213
|
+
@lock.synchronize do
|
|
214
|
+
me = page!(page)
|
|
215
|
+
chat = me.chat && { id: me.chat, me: participant(me), participants: roster(me.chat) }
|
|
216
|
+
{ chat:,
|
|
217
|
+
invites: @incoming.values.map { it[:invite] },
|
|
218
|
+
outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) },
|
|
219
|
+
unavailable: @unavailable }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Start a chat and invite +machine+ to it, leaving any chat the page was in.
|
|
224
|
+
#
|
|
225
|
+
# @param page [String]
|
|
226
|
+
# @param name [String]
|
|
227
|
+
# @param machine [String]
|
|
228
|
+
# @param label [String] the machine's `user@host`
|
|
229
|
+
# @return [Hash] `{chat:, invite:}`
|
|
230
|
+
def start_chat(page:, name:, machine:, label:)
|
|
231
|
+
name = name!(name)
|
|
232
|
+
target!(machine)
|
|
233
|
+
chat = SecureRandom.uuid
|
|
234
|
+
enter(page, chat, name)
|
|
235
|
+
{ chat:, invite: invite(page:, machine:, label:)[:id] }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Invite +machine+ into the chat this page is in (D5).
|
|
239
|
+
#
|
|
240
|
+
# @return [Hash] `{id, machine, label}`
|
|
241
|
+
def invite(page:, machine:, label:)
|
|
242
|
+
target!(machine)
|
|
243
|
+
me, chat = @lock.synchronize do
|
|
244
|
+
me = page!(page)
|
|
245
|
+
raise Invalid, "start a chat before inviting anyone" unless me.chat
|
|
246
|
+
|
|
247
|
+
[participant(me), me.chat]
|
|
248
|
+
end
|
|
249
|
+
id = SecureRandom.uuid
|
|
250
|
+
wire = { id:, chat:, from: me, machine:, label:, expires_at: (@wall.call + INVITE_TTL).utc.iso8601 }
|
|
251
|
+
# The receivers stay unknown until PUBLISH says: an `absent` heard
|
|
252
|
+
# before then is counted, never compared against a count not yet set.
|
|
253
|
+
sent = { id:, page:, machine:, label:, deadline: now + INVITE_TTL, receivers: nil, absent: 0 }
|
|
254
|
+
@lock.synchronize { @outgoing[id] = sent }
|
|
255
|
+
receivers = publish(@keys.chat_inbox(machine, db:), kind: "invite", invite: wire)
|
|
256
|
+
nobody = @lock.synchronize do
|
|
257
|
+
sent[:receivers] = receivers
|
|
258
|
+
sent[:absent] >= receivers
|
|
259
|
+
end
|
|
260
|
+
expire(id) if nobody
|
|
261
|
+
sent.slice(:id, :machine, :label)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Accept an invite open to this machine and join its chat (D4: leaving
|
|
265
|
+
# the one the page was in).
|
|
266
|
+
#
|
|
267
|
+
# @return [Hash] `{chat:}`
|
|
268
|
+
def accept(page:, invite:, name:)
|
|
269
|
+
name = name!(name)
|
|
270
|
+
open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
|
|
271
|
+
settle(open[:invite], "accepted")
|
|
272
|
+
enter(page, open[:invite][:chat], name)
|
|
273
|
+
{ chat: open[:invite][:chat] }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [void]
|
|
277
|
+
def decline(page:, invite:)
|
|
278
|
+
page!(page)
|
|
279
|
+
open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
|
|
280
|
+
settle(open[:invite], "declined")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Stop Chat (D6). Nothing happens for a page in no chat.
|
|
284
|
+
#
|
|
285
|
+
# @return [void]
|
|
286
|
+
def leave(page:)
|
|
287
|
+
me = @lock.synchronize do
|
|
288
|
+
me = @pages[page]
|
|
289
|
+
next nil unless me&.chat
|
|
290
|
+
|
|
291
|
+
gone = [participant(me), me.chat]
|
|
292
|
+
@rooms[me.chat].delete(me.id)
|
|
293
|
+
@rooms.delete(me.chat) if local(me.chat).one?
|
|
294
|
+
me.chat = nil
|
|
295
|
+
me.idle = false
|
|
296
|
+
gone
|
|
297
|
+
end
|
|
298
|
+
publish(@keys.chat_room(me.last, db:), kind: "leave", from: me.first) if me
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @return [Hash] `ChatMessage` as sent
|
|
302
|
+
def say(page:, id:, text:)
|
|
303
|
+
raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.to_s.match?(Command::ID)
|
|
304
|
+
|
|
305
|
+
text = text.to_s.strip
|
|
306
|
+
raise Invalid, "a message needs text" if text.empty?
|
|
307
|
+
raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
|
|
308
|
+
|
|
309
|
+
me, chat = in_chat(page)
|
|
310
|
+
said = { id:, text:, at: @wall.call.utc.iso8601 }
|
|
311
|
+
publish(@keys.chat_room(chat, db:), kind: "say", from: me, **said)
|
|
312
|
+
said.merge(who: me)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Idle or back (D7). Only a change is announced.
|
|
316
|
+
#
|
|
317
|
+
# @return [void]
|
|
318
|
+
def presence(page:, idle:)
|
|
319
|
+
idle = idle == true
|
|
320
|
+
changed = @lock.synchronize do
|
|
321
|
+
me = page!(page)
|
|
322
|
+
next nil if !me.chat || me.idle == idle
|
|
323
|
+
|
|
324
|
+
me.idle = idle
|
|
325
|
+
[participant(me), me.chat]
|
|
326
|
+
end
|
|
327
|
+
publish(@keys.chat_room(changed.last, db:), kind: idle ? "idle" : "back", from: changed.first) if changed
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
# ---- receiving --------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
# @return [void]
|
|
335
|
+
def receive(channel, payload)
|
|
336
|
+
data = JSON.parse(payload)
|
|
337
|
+
return unless data.is_a?(Hash)
|
|
338
|
+
|
|
339
|
+
if channel == inbox then mail(data)
|
|
340
|
+
elsif (chat = channel.delete_prefix(room_prefix)) != channel then room(chat, data)
|
|
341
|
+
end
|
|
342
|
+
rescue JSON::ParserError
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @return [void]
|
|
347
|
+
def room(chat, data)
|
|
348
|
+
kind = data["kind"]
|
|
349
|
+
who = person(data["from"])
|
|
350
|
+
return unless ROOM_KINDS.include?(kind) && who
|
|
351
|
+
|
|
352
|
+
answer = @lock.synchronize do
|
|
353
|
+
next nil unless @rooms.key?(chat) && local(chat).any?
|
|
354
|
+
|
|
355
|
+
case kind
|
|
356
|
+
when "leave"
|
|
357
|
+
@rooms[chat].delete(who[:id])
|
|
358
|
+
post(chat, kind: "left", chat:, who:, participants: roster(chat))
|
|
359
|
+
when "say"
|
|
360
|
+
seen(chat, who)
|
|
361
|
+
post(chat, kind: "said", chat:, who:, id: data["id"].to_s, text: data["text"].to_s.slice(0, LIMIT), at: data["at"].to_s)
|
|
362
|
+
when "here"
|
|
363
|
+
fresh = seen(chat, who)
|
|
364
|
+
post(chat, kind: "roster", chat:, participants: roster(chat)) if fresh
|
|
365
|
+
else
|
|
366
|
+
seen(chat, who)
|
|
367
|
+
post(chat, kind: kind == "join" ? "joined" : kind, chat:, who:, participants: roster(chat))
|
|
368
|
+
end
|
|
369
|
+
kind == "join" ? local(chat).reject { it.id == who[:id] }.map { participant(it) } : []
|
|
370
|
+
end
|
|
371
|
+
Array(answer).each { publish(@keys.chat_room(chat, db:), kind: "here", from: it) }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# @return [void]
|
|
375
|
+
def mail(data)
|
|
376
|
+
case data["kind"]
|
|
377
|
+
when "invite" then invited(data["invite"])
|
|
378
|
+
when "absent" then absent(data["invite"].to_s)
|
|
379
|
+
when "settled" then settled(data["invite"].to_s, data["answer"].to_s)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# @return [void]
|
|
384
|
+
def invited(invite)
|
|
385
|
+
invite = invitation(invite) or return
|
|
386
|
+
reply = @lock.synchronize do
|
|
387
|
+
next invite[:from][:machine] if @pages.values.none? { it.streams.positive? }
|
|
388
|
+
|
|
389
|
+
@incoming[invite[:id]] = { invite:, deadline: now + INVITE_TTL + GRACE }
|
|
390
|
+
@pages.each_value { push(it, kind: "invited", invite:) }
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
publish(@keys.chat_inbox(reply, db:), kind: "absent", invite: invite[:id]) if reply
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# A server on the invited machine with no page open. When every one
|
|
397
|
+
# that heard the invite says so, nobody is there to answer (D9).
|
|
398
|
+
#
|
|
399
|
+
# @return [void]
|
|
400
|
+
def absent(id)
|
|
401
|
+
nobody = @lock.synchronize do
|
|
402
|
+
sent = @outgoing[id] or next false
|
|
403
|
+
sent[:absent] += 1
|
|
404
|
+
sent[:receivers] && sent[:absent] >= sent[:receivers]
|
|
405
|
+
end
|
|
406
|
+
expire(id) if nobody
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# @return [void]
|
|
410
|
+
def settled(id, answer)
|
|
411
|
+
@lock.synchronize do
|
|
412
|
+
if @incoming.delete(id)
|
|
413
|
+
@pages.each_value { push(it, kind: "invite_closed", invite: id, answer:) }
|
|
414
|
+
end
|
|
415
|
+
sent = @outgoing.delete(id)
|
|
416
|
+
told(sent, answer) if sent
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The inviting page hears a decline or a silence; an accept it hears as a join.
|
|
421
|
+
#
|
|
422
|
+
# @return [void]
|
|
423
|
+
def told(sent, answer)
|
|
424
|
+
kind = { "declined" => "declined", "expired" => "unanswered" }[answer] or return
|
|
425
|
+
page = sent && @pages[sent[:page]] or return
|
|
426
|
+
push(page, kind:, invite: sent[:id], machine: sent[:machine], label: sent[:label])
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# ---- settling invites -------------------------------------------------
|
|
430
|
+
|
|
431
|
+
# The first answer wins, from whichever page or server gives it.
|
|
432
|
+
#
|
|
433
|
+
# @return [void]
|
|
434
|
+
# @raise [Settled] when another answer came first
|
|
435
|
+
def settle(invite, answer)
|
|
436
|
+
unless store.set(@keys.chat_answer(invite[:id]), answer, ex: INVITE_TTL * 2, nx: true)
|
|
437
|
+
@lock.synchronize { @incoming.delete(invite[:id]) }
|
|
438
|
+
raise Settled, "that invite was already answered"
|
|
439
|
+
end
|
|
440
|
+
publish(inbox, kind: "settled", invite: invite[:id], answer:)
|
|
441
|
+
publish(@keys.chat_inbox(invite[:from][:machine], db:), kind: "settled", invite: invite[:id], answer:)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Nobody answered in time, or nobody could. The invited machine's
|
|
445
|
+
# pages close their modals; this page hears "did not answer".
|
|
446
|
+
#
|
|
447
|
+
# @return [void]
|
|
448
|
+
def expire(id)
|
|
449
|
+
sent = @lock.synchronize { @outgoing[id] } or return
|
|
450
|
+
if store.set(@keys.chat_answer(id), "expired", ex: INVITE_TTL * 2, nx: true)
|
|
451
|
+
publish(@keys.chat_inbox(sent[:machine], db:), kind: "settled", invite: id, answer: "expired")
|
|
452
|
+
@lock.synchronize { told(@outgoing.delete(id), "expired") }
|
|
453
|
+
else
|
|
454
|
+
# Answered after all: the answer's own message says how.
|
|
455
|
+
answer = store.get(@keys.chat_answer(id)).to_s
|
|
456
|
+
@lock.synchronize { told(@outgoing.delete(id), answer) if @outgoing.key?(id) }
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# ---- housekeeping -----------------------------------------------------
|
|
461
|
+
|
|
462
|
+
# @return [void]
|
|
463
|
+
def housekeep
|
|
464
|
+
t = now
|
|
465
|
+
gone = @lock.synchronize { @pages.values.select { it.gone_at && t - it.gone_at >= GRACE } }
|
|
466
|
+
gone.each do |page|
|
|
467
|
+
leave(page: page.id)
|
|
468
|
+
@lock.synchronize { @pages.delete(page.id) if page.streams.zero? }
|
|
469
|
+
end
|
|
470
|
+
@lock.synchronize { @outgoing.values.select { t >= it[:deadline] }.map { it[:id] } }.each { expire(it) }
|
|
471
|
+
forget_stale_invites(t)
|
|
472
|
+
prune(t)
|
|
473
|
+
heartbeat(t)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# An invite whose inviter's server went away never hears its expiry.
|
|
477
|
+
#
|
|
478
|
+
# @return [void]
|
|
479
|
+
def forget_stale_invites(at)
|
|
480
|
+
@lock.synchronize do
|
|
481
|
+
@incoming.select { |_, open| at >= open[:deadline] }.each_key do |id|
|
|
482
|
+
@incoming.delete(id)
|
|
483
|
+
@pages.each_value { push(it, kind: "invite_closed", invite: id, answer: "expired") }
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# A participant on another server that has stopped saying `here`.
|
|
489
|
+
#
|
|
490
|
+
# @return [void]
|
|
491
|
+
def prune(at)
|
|
492
|
+
@lock.synchronize do
|
|
493
|
+
@rooms.each do |chat, people|
|
|
494
|
+
mine = local(chat).map(&:id)
|
|
495
|
+
stale = people.reject { |id, p| mine.include?(id) || at - p[:seen] < FORGET_AFTER }
|
|
496
|
+
stale.each_key { people.delete(it) }
|
|
497
|
+
stale.each_value { post(chat, kind: "left", chat:, who: it.except(:seen), participants: roster(chat)) }
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# @return [void]
|
|
503
|
+
def heartbeat(at)
|
|
504
|
+
return if @beat && at - @beat < HEARTBEAT
|
|
505
|
+
|
|
506
|
+
@beat = at
|
|
507
|
+
mine = @lock.synchronize { @pages.values.select(&:chat).map { [participant(it), it.chat] } }
|
|
508
|
+
mine.each { |who, chat| publish(@keys.chat_room(chat, db:), kind: "here", from: who) }
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# ---- helpers ----------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
# @return [void]
|
|
514
|
+
def enter(page, chat, name)
|
|
515
|
+
leave(page:)
|
|
516
|
+
who = @lock.synchronize do
|
|
517
|
+
me = page!(page)
|
|
518
|
+
me.chat = chat
|
|
519
|
+
me.name = name
|
|
520
|
+
me.idle = false
|
|
521
|
+
seen(chat, participant(me))
|
|
522
|
+
participant(me)
|
|
523
|
+
end
|
|
524
|
+
publish(@keys.chat_room(chat, db:), kind: "join", from: who)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# @return [Array(Hash, String)] the page as a participant, and its chat
|
|
528
|
+
# @raise [Invalid] when it is in none
|
|
529
|
+
def in_chat(page)
|
|
530
|
+
@lock.synchronize do
|
|
531
|
+
me = page!(page)
|
|
532
|
+
raise Invalid, "this page is not in a chat" unless me.chat
|
|
533
|
+
|
|
534
|
+
[participant(me), me.chat]
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# @return [Boolean] whether the participant is new to the roster
|
|
539
|
+
def seen(chat, who)
|
|
540
|
+
fresh = !@rooms[chat].key?(who[:id])
|
|
541
|
+
@rooms[chat][who[:id]] = who.merge(seen: now)
|
|
542
|
+
fresh
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# @return [Array<Hash>] `ChatParticipant`s, in the order they arrived
|
|
546
|
+
def roster(chat) = @rooms.fetch(chat, {}).values.map { it.except(:seen) }
|
|
547
|
+
|
|
548
|
+
# @return [Array<Page>] this server's pages in +chat+
|
|
549
|
+
def local(chat) = @pages.values.select { it.chat == chat }
|
|
550
|
+
|
|
551
|
+
# @return [void]
|
|
552
|
+
def post(chat, event) = local(chat).each { push(it, event) }
|
|
553
|
+
|
|
554
|
+
# @return [void]
|
|
555
|
+
def push(page, event)
|
|
556
|
+
page.events << event
|
|
557
|
+
page.events.shift(page.events.size - BACKLOG) if page.events.size > BACKLOG
|
|
558
|
+
@pushed = true
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# A page's events wait in this process, where an event stream blocked
|
|
562
|
+
# on {Keys#news} cannot see them, so a step that queued any says so on
|
|
563
|
+
# that channel: one nudge per step, however many it queued. Nearly
|
|
564
|
+
# every push happens inside a step, on the listening thread; the one
|
|
565
|
+
# that does not (an invite nobody was there to hear, expired on the
|
|
566
|
+
# request's own thread) is nudged by the next step, {POLL} later.
|
|
567
|
+
#
|
|
568
|
+
# @return [void]
|
|
569
|
+
def nudge
|
|
570
|
+
pushed = @lock.synchronize { @pushed.tap { @pushed = false } }
|
|
571
|
+
News.publish(store:, keys: @keys, kind: "chat") if pushed
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# @return [Hash] `ChatParticipant`
|
|
575
|
+
def participant(page) = { id: page.id, name: page.name, machine: @machine, label: @label, idle: page.idle }
|
|
576
|
+
|
|
577
|
+
# @return [Page]
|
|
578
|
+
# @raise [Invalid]
|
|
579
|
+
def page!(id)
|
|
580
|
+
raise Invalid, "page must be 8 to 64 letters, digits, - or _" unless id.to_s.match?(PAGE)
|
|
581
|
+
|
|
582
|
+
@lock.synchronize { @pages[id] ||= Page.new(id:, events: [], streams: 0, gone_at: now, chat: nil, name: nil, idle: false) }
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# @return [String]
|
|
586
|
+
# @raise [Invalid]
|
|
587
|
+
def name!(name)
|
|
588
|
+
name = name.to_s.gsub(/[[:cntrl:]]/, " ").strip
|
|
589
|
+
raise Invalid, "a name is needed" if name.empty?
|
|
590
|
+
raise Invalid, "a name is at most #{NAME_LIMIT} characters" if name.length > NAME_LIMIT
|
|
591
|
+
|
|
592
|
+
name
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# @return [void]
|
|
596
|
+
# @raise [Invalid]
|
|
597
|
+
def target!(machine)
|
|
598
|
+
raise Invalid, "not a machine id" unless machine.to_s.match?(MACHINE)
|
|
599
|
+
raise Invalid, "this machine cannot invite itself" if machine == @machine
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# A participant as another server sent it, or nil when it is not one.
|
|
603
|
+
#
|
|
604
|
+
# @return [Hash, nil]
|
|
605
|
+
def person(data)
|
|
606
|
+
return nil unless data.is_a?(Hash) && data["id"].to_s.match?(PAGE)
|
|
607
|
+
|
|
608
|
+
{ id: data["id"].to_s, name: data["name"].to_s.slice(0, NAME_LIMIT), machine: data["machine"].to_s,
|
|
609
|
+
label: data["label"].to_s, idle: data["idle"] == true }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# An invite as another server sent it, for this machine, or nil.
|
|
613
|
+
#
|
|
614
|
+
# @return [Hash, nil]
|
|
615
|
+
def invitation(data)
|
|
616
|
+
return nil unless data.is_a?(Hash) && data["machine"] == @machine
|
|
617
|
+
return nil unless data["id"].to_s.match?(UUID) && data["chat"].to_s.match?(UUID)
|
|
618
|
+
|
|
619
|
+
from = person(data["from"]) or return nil
|
|
620
|
+
{ id: data["id"], chat: data["chat"], from:, machine: @machine, label: data["label"].to_s,
|
|
621
|
+
expires_at: data["expires_at"].to_s }
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# @return [Integer] receivers
|
|
625
|
+
def publish(channel, data) = EventBus.for(store).publish(channel, JSON.generate(data))
|
|
626
|
+
|
|
627
|
+
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
628
|
+
def subscriber = @subscriber ||= EventBus.for(store).subscribe(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
|
|
629
|
+
|
|
630
|
+
# {UI.log} alone reaches only a log file or a tap, and `openlanes web`
|
|
631
|
+
# sets up neither, so there the one warning an outage gets would be
|
|
632
|
+
# dropped and nobody would learn why the chat went dark. With nothing
|
|
633
|
+
# listening it goes to STDERR instead.
|
|
634
|
+
#
|
|
635
|
+
# @param reason [String]
|
|
636
|
+
# @return [void]
|
|
637
|
+
def warn_unavailable(reason)
|
|
638
|
+
message = "chat unavailable: #{reason}"
|
|
639
|
+
UI.log(message)
|
|
640
|
+
UI.line(message, bullet: "!") unless UI.log_path || UI.log_taps.any?
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# @return [void]
|
|
644
|
+
def close_subscriber
|
|
645
|
+
@subscriber&.close
|
|
646
|
+
@subscriber = nil
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# @return [String]
|
|
650
|
+
def inbox = @keys.chat_inbox(@machine, db:)
|
|
651
|
+
|
|
652
|
+
# @return [String]
|
|
653
|
+
def room_prefix = @keys.chat_room("", db:)
|
|
654
|
+
|
|
655
|
+
# @return [Integer]
|
|
656
|
+
def db = @db ||= store.db
|
|
657
|
+
|
|
658
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
659
|
+
def store = @stores.current
|
|
660
|
+
|
|
661
|
+
# @return [Float]
|
|
662
|
+
def now = @clock.call
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
end
|