openlanes 3.0.1 → 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 +4 -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 +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- 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 +7 -0
- 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 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- 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 +84 -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 +1175 -117
- 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 +6 -6
- 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 +12 -120
- 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 +223 -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 +17 -13
- 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 +60 -11
- 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 +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- 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 +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- 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 +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -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 +64 -5
- 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 +39 -24
- 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 +226 -8
- 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 +167 -6
- 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 +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- 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 +29 -10
- data/lib/openlanes/web.rb +6 -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/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- 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/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "socket"
|
|
4
5
|
require "tmpdir"
|
|
5
6
|
|
|
6
7
|
module OpenLanes
|
|
@@ -26,19 +27,35 @@ module OpenLanes
|
|
|
26
27
|
# Seconds {#stop} gives the server after TERM before KILL.
|
|
27
28
|
STOP_WITHIN = 5.0
|
|
28
29
|
|
|
30
|
+
# Ports {#start} tries, from the one asked for, before giving up.
|
|
31
|
+
PORTS_TRIED = 10
|
|
32
|
+
|
|
29
33
|
# @param root [String] the checkout the run works in
|
|
30
34
|
# @param plans_dir [String]
|
|
31
35
|
# @param port [Integer]
|
|
32
36
|
# @param redis_url [String]
|
|
33
|
-
# @param log [String] where the server's
|
|
37
|
+
# @param log [String] where the server's raw STDOUT and STDERR go: a
|
|
38
|
+
# crash before its logger is up, and Puma's own complaints
|
|
39
|
+
# @param logging [OpenLanes::Log::Settings, nil] where the server logs
|
|
40
|
+
# its requests; the engine's own, so both processes write one file
|
|
34
41
|
# @param ready_within [Float]
|
|
35
42
|
# @param probe [Proc, nil] answers whether the server is up; `/healthz` by default
|
|
36
|
-
|
|
43
|
+
# @param listen [OpenLanes::Web::Listen] the addresses the server listens on
|
|
44
|
+
# @param credentials [OpenLanes::Web::AdminAuth::Credentials, nil] its
|
|
45
|
+
# login for requests from off this machine, handed over in {#env}
|
|
46
|
+
# @param spawner [#spawn] what starts the server process
|
|
47
|
+
def initialize(root:, plans_dir:, port:, redis_url:, log: default_log(root), logging: Log.settings,
|
|
48
|
+
ready_within: READY_WITHIN, probe: nil, listen: Listen.parse(nil), credentials: nil,
|
|
49
|
+
spawner: Spawner::System)
|
|
37
50
|
@root = root
|
|
51
|
+
@spawner = spawner
|
|
38
52
|
@plans_dir = plans_dir
|
|
39
53
|
@port = Integer(port)
|
|
40
54
|
@redis_url = redis_url
|
|
55
|
+
@listen = listen
|
|
56
|
+
@credentials = credentials
|
|
41
57
|
@log = log
|
|
58
|
+
@logging = logging
|
|
42
59
|
@ready_within = ready_within
|
|
43
60
|
@probe = probe || method(:healthy?)
|
|
44
61
|
end
|
|
@@ -49,22 +66,46 @@ module OpenLanes
|
|
|
49
66
|
# @return [Integer, nil]
|
|
50
67
|
attr_reader :pid
|
|
51
68
|
|
|
69
|
+
# @return [Integer] the port asked for, or the free one {#start} moved to
|
|
70
|
+
attr_reader :port
|
|
71
|
+
|
|
72
|
+
# Loopback, where the login is never asked. Every server it launches
|
|
73
|
+
# answers there: on loopback by default, and on a wildcard under -I.
|
|
74
|
+
#
|
|
52
75
|
# @return [String]
|
|
53
76
|
def url = "http://#{HOST}:#{@port}"
|
|
54
77
|
|
|
55
|
-
# @return [Array<String>] what is spawned; the Redis URL
|
|
78
|
+
# @return [Array<String>] what is spawned; the Redis URL and the
|
|
79
|
+
# credentials are in {#env}, never here
|
|
56
80
|
def argv
|
|
57
|
-
|
|
81
|
+
ips = @listen.public? ? ["--ips", @listen.ips.join(",")] : []
|
|
82
|
+
[EXE, "web", "--port", @port.to_s, "--no-open", "--dir", @plans_dir, *ips]
|
|
58
83
|
end
|
|
59
84
|
|
|
85
|
+
# The Redis URL, the login, and where to log. A server left to find its
|
|
86
|
+
# own log would resolve `log/development.log` from its own root and
|
|
87
|
+
# defaults, and a `--log` typed on `run` would move the engine's lines
|
|
88
|
+
# but not the server's.
|
|
89
|
+
#
|
|
60
90
|
# @return [Hash{String => String}] added to the server's environment
|
|
61
|
-
def env
|
|
91
|
+
def env
|
|
92
|
+
login = @credentials ? { AdminAuth::ENV_NAME => "#{@credentials.user}:#{@credentials.password}" } : {}
|
|
93
|
+
{ REDIS_URL_ENV => @redis_url, **login, **(@logging ? @logging.env : {}) }
|
|
94
|
+
end
|
|
62
95
|
|
|
63
|
-
# Spawn the server
|
|
96
|
+
# Spawn the server on the first free port from the one asked for, and
|
|
97
|
+
# wait for it to answer. A taken port is skipped before spawning, never
|
|
98
|
+
# probed: a leftover `openlanes web` for another project answers
|
|
99
|
+
# `/healthz` there, and the run once declared that server its own and
|
|
100
|
+
# opened the browser on someone else's board.
|
|
64
101
|
#
|
|
65
102
|
# @return [self]
|
|
66
103
|
def start
|
|
67
|
-
|
|
104
|
+
free = free_port
|
|
105
|
+
return taken unless free
|
|
106
|
+
|
|
107
|
+
@port = free
|
|
108
|
+
@pid = @spawner.spawn(env, *argv, chdir: @root, pgroup: true, in: File::NULL, %i[out err] => [@log, "a"])
|
|
68
109
|
@ready = wait_until_ready
|
|
69
110
|
self
|
|
70
111
|
end
|
|
@@ -88,6 +129,27 @@ module OpenLanes
|
|
|
88
129
|
|
|
89
130
|
private
|
|
90
131
|
|
|
132
|
+
# @return [Integer, nil]
|
|
133
|
+
def free_port = (@port...(@port + PORTS_TRIED)).find { free?(it) }
|
|
134
|
+
|
|
135
|
+
# @param port [Integer]
|
|
136
|
+
# @return [Boolean] whether nothing listens there
|
|
137
|
+
def free?(port)
|
|
138
|
+
TCPServer.new(HOST, port).close
|
|
139
|
+
true
|
|
140
|
+
rescue SystemCallError
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Every port tried is taken: said in the log {#start}'s caller points at.
|
|
145
|
+
#
|
|
146
|
+
# @return [self]
|
|
147
|
+
def taken
|
|
148
|
+
File.write(@log, "Ports #{@port}-#{@port + PORTS_TRIED - 1} are all taken. Pass --port to use another.\n", mode: "a")
|
|
149
|
+
@ready = false
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
91
153
|
# @return [Boolean]
|
|
92
154
|
def wait_until_ready
|
|
93
155
|
deadline = monotonic + @ready_within
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Web
|
|
8
|
+
# The addresses one server listens on, and what follows from them: the
|
|
9
|
+
# Host headers it answers, the URLs it prints, the one a browser opens.
|
|
10
|
+
#
|
|
11
|
+
# Loopback, {HOST}, unless told otherwise (W3). The page can stop agents
|
|
12
|
+
# and answer their questions, and has no login, so listening beyond this
|
|
13
|
+
# machine is something a person asks for by name, and is warned about.
|
|
14
|
+
#
|
|
15
|
+
# listen = Listen.parse("0.0.0.0")
|
|
16
|
+
# listen.public? # => true
|
|
17
|
+
# listen.urls(11_000) # => ["http://192.168.1.20:11000", ...]
|
|
18
|
+
# listen.browse_url(11_000) # => "http://127.0.0.1:11000"
|
|
19
|
+
Listen = Data.define(:ips) do
|
|
20
|
+
# @return [Boolean] whether anything off this machine can reach it
|
|
21
|
+
def public? = ips.any? { !IPAddr.new(it).loopback? }
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the addresses past loopback, as given
|
|
24
|
+
def public_ips = ips.reject { IPAddr.new(it).loopback? }
|
|
25
|
+
|
|
26
|
+
# Where this machine's browser opens the page: loopback, which every
|
|
27
|
+
# wildcard listener also answers on, else the first address given.
|
|
28
|
+
#
|
|
29
|
+
# @param port [Integer]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def browse_url(port)
|
|
32
|
+
ip = ips.find { Listen.wildcard?(it) || IPAddr.new(it).loopback? } || ips.first
|
|
33
|
+
Listen.url(if Listen.wildcard?(ip)
|
|
34
|
+
IPAddr.new(ip).ipv6? ? "::1" : HOST
|
|
35
|
+
else
|
|
36
|
+
ip
|
|
37
|
+
end,
|
|
38
|
+
port)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every URL the page answers on, a wildcard spelled out as this
|
|
42
|
+
# machine's own addresses, since `http://0.0.0.0` opens nothing.
|
|
43
|
+
#
|
|
44
|
+
# @param port [Integer]
|
|
45
|
+
# @param addresses [Array<String>] this machine's interface addresses
|
|
46
|
+
# @return [Array<String>]
|
|
47
|
+
def urls(port, addresses: Listen.addresses) = reached(addresses).map { Listen.url(it, port) }
|
|
48
|
+
|
|
49
|
+
# The Host headers answered beside loopback, which {App} always
|
|
50
|
+
# permits: each address listened on, a wildcard's interfaces, and this
|
|
51
|
+
# machine's name. Anything else is refused, so a page on another site
|
|
52
|
+
# still cannot reach the server through DNS rebinding.
|
|
53
|
+
#
|
|
54
|
+
# @param addresses [Array<String>]
|
|
55
|
+
# @param hostname [String]
|
|
56
|
+
# @return [Array<String, IPAddr>]
|
|
57
|
+
def permitted_hosts(addresses: Listen.addresses, hostname: Socket.gethostname)
|
|
58
|
+
return [] unless public?
|
|
59
|
+
|
|
60
|
+
names = [hostname, hostname.split(".").first].uniq
|
|
61
|
+
names + reached(addresses).map { IPAddr.new(it) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @param addresses [Array<String>]
|
|
67
|
+
# @return [Array<String>] the addresses listened on, each wildcard
|
|
68
|
+
# replaced by the interface addresses it answers on
|
|
69
|
+
def reached(addresses) = ips.flat_map { Listen.wildcard?(it) ? Listen.covered(it, addresses) : [it] }.uniq
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class Listen
|
|
73
|
+
# Raised for an address that is not one.
|
|
74
|
+
class Invalid < OpenLanes::Error; end
|
|
75
|
+
|
|
76
|
+
# What `-I` stands for.
|
|
77
|
+
EVERYWHERE = "0.0.0.0"
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
# An address a wildcard given beside it already covers is dropped:
|
|
81
|
+
# Linux refuses to bind `127.0.0.1` on a port `0.0.0.0` holds, even
|
|
82
|
+
# with SO_REUSEADDR, and the EADDRINUSE read as "port taken".
|
|
83
|
+
#
|
|
84
|
+
# @param text [String, nil] comma-separated addresses; blank for {HOST}
|
|
85
|
+
# @return [Listen]
|
|
86
|
+
# @raise [Invalid] naming the address that is not one
|
|
87
|
+
def parse(text)
|
|
88
|
+
ips = text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
89
|
+
ips = [HOST] if ips.empty?
|
|
90
|
+
ips.each do |ip|
|
|
91
|
+
IPAddr.new(ip)
|
|
92
|
+
rescue IPAddr::Error
|
|
93
|
+
raise Invalid, "#{ip.inspect} is not an IP address"
|
|
94
|
+
end
|
|
95
|
+
ips = ips.uniq
|
|
96
|
+
wildcards = ips.select { wildcard?(it) }
|
|
97
|
+
new(ips: ips.reject { |ip| wildcards.any? { it != ip && covered(it, [ip]).any? } })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param ip [String]
|
|
101
|
+
# @return [Boolean] whether it means every interface
|
|
102
|
+
def wildcard?(ip) = IPAddr.new(ip).to_i.zero?
|
|
103
|
+
|
|
104
|
+
# @param ip [String]
|
|
105
|
+
# @param port [Integer]
|
|
106
|
+
# @return [String]
|
|
107
|
+
def url(ip, port) = "http://#{IPAddr.new(ip).ipv6? ? "[#{ip}]" : ip}:#{port}"
|
|
108
|
+
|
|
109
|
+
# The interface addresses a wildcard answers on. `0.0.0.0` is IPv4
|
|
110
|
+
# only; `::` is both, because Ruby's TCPServer leaves IPV6_V6ONLY
|
|
111
|
+
# off, so Linux and macOS hand it IPv4 clients too. Treating `::` as
|
|
112
|
+
# IPv6 alone refused an IPv4 LAN client's Host header with a 403.
|
|
113
|
+
#
|
|
114
|
+
# @param wildcard [String] `0.0.0.0` or `::`
|
|
115
|
+
# @param addresses [Array<String>]
|
|
116
|
+
# @return [Array<String>]
|
|
117
|
+
def covered(wildcard, addresses)
|
|
118
|
+
return addresses if IPAddr.new(wildcard).ipv6?
|
|
119
|
+
|
|
120
|
+
addresses.select { IPAddr.new(it).ipv4? }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# This machine's interface addresses, loopback first, link-local
|
|
124
|
+
# IPv6 left out: it needs a zone to be dialed, which no URL carries.
|
|
125
|
+
#
|
|
126
|
+
# @return [Array<String>]
|
|
127
|
+
def addresses
|
|
128
|
+
Socket.ip_address_list.reject(&:ipv6_linklocal?).map(&:ip_address).sort_by { IPAddr.new(it).loopback? ? 0 : 1 }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Web
|
|
9
|
+
# The plan files the page may read, and the two it may write (plan 017).
|
|
10
|
+
#
|
|
11
|
+
# A name is looked up in {READABLE} and never joined onto a path as typed,
|
|
12
|
+
# which is what makes `../` meaningless here rather than merely refused.
|
|
13
|
+
# This knows nothing of HTTP or of cars: {App} maps each refusal to a
|
|
14
|
+
# status and checks the board before it calls {#write}.
|
|
15
|
+
class PlanFiles
|
|
16
|
+
# What the drawer lists and the preview reads, in the order listed.
|
|
17
|
+
READABLE = %w[spec.md plan.md contract.md blocked.md research.md design.md].freeze
|
|
18
|
+
|
|
19
|
+
# What the popup may save. `blocked.md` is appended by the `answer`
|
|
20
|
+
# command, and the build documents belong to the agents that own them.
|
|
21
|
+
EDITABLE = %w[spec.md plan.md].freeze
|
|
22
|
+
|
|
23
|
+
# Not a readable name, not a plan, or not a file in that plan.
|
|
24
|
+
class NotFound < OpenLanes::Error; end
|
|
25
|
+
|
|
26
|
+
# A readable name the page may not write.
|
|
27
|
+
class ReadOnly < OpenLanes::Error; end
|
|
28
|
+
|
|
29
|
+
# The file changed since the page read it.
|
|
30
|
+
class Stale < OpenLanes::Error; end
|
|
31
|
+
|
|
32
|
+
# The body's frontmatter block is not the one on disk. Frontmatter
|
|
33
|
+
# routes agents (`lane`, `frontend`, `depth`), so a crafted save must
|
|
34
|
+
# not reach it even when the page would never send one.
|
|
35
|
+
class FrontmatterChanged < OpenLanes::Error; end
|
|
36
|
+
|
|
37
|
+
# One file as read.
|
|
38
|
+
#
|
|
39
|
+
# @!attribute body [String] the raw bytes
|
|
40
|
+
# @!attribute etag [String] a strong, quoted ETag of those bytes
|
|
41
|
+
Document = Data.define(:body, :etag)
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# @param body [String]
|
|
45
|
+
# @return [String] `"<sha256 hex>"`, quotes included, as HTTP sends it
|
|
46
|
+
def etag(body) = %("#{Digest::SHA256.hexdigest(body.b)}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param plans_dir [String] the `.plans` directory
|
|
50
|
+
def initialize(plans_dir:)
|
|
51
|
+
@plans_dir = plans_dir
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param ordinal [String] a plan number, as typed in the URL
|
|
55
|
+
# @return [Array<String>] readable names that exist, in {READABLE} order
|
|
56
|
+
# @raise [NotFound] when the ordinal is not a plan
|
|
57
|
+
def list(ordinal)
|
|
58
|
+
_, folder = folder_for(ordinal)
|
|
59
|
+
READABLE.select { File.file?(File.join(folder, it)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param ordinal [String]
|
|
63
|
+
# @param name [String]
|
|
64
|
+
# @return [Document]
|
|
65
|
+
# @raise [NotFound]
|
|
66
|
+
def read(ordinal, name)
|
|
67
|
+
body = File.binread(path_for(ordinal, name)).force_encoding(Encoding::UTF_8)
|
|
68
|
+
Document.new(body:, etag: self.class.etag(body))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Replace a whole file, guarded three ways: an editable name, the
|
|
72
|
+
# ETag the page read, and the frontmatter block unchanged.
|
|
73
|
+
#
|
|
74
|
+
# @param ordinal [String]
|
|
75
|
+
# @param name [String]
|
|
76
|
+
# @param body [String] the whole new file
|
|
77
|
+
# @param if_match [String] the ETag the page read
|
|
78
|
+
# @return [String] the new ETag
|
|
79
|
+
# @raise [NotFound, ReadOnly, Stale, FrontmatterChanged]
|
|
80
|
+
def write(ordinal, name, body:, if_match:)
|
|
81
|
+
path = path_for(ordinal, name)
|
|
82
|
+
raise ReadOnly, "#{name} is read only" unless EDITABLE.include?(name)
|
|
83
|
+
|
|
84
|
+
current = read(ordinal, name)
|
|
85
|
+
raise Stale, "#{name} changed since it was read; reload it" unless current.etag == if_match.to_s.strip
|
|
86
|
+
raise FrontmatterChanged, "the frontmatter of #{name} is locked" unless frontmatter(current.body) == frontmatter(body)
|
|
87
|
+
|
|
88
|
+
replace(path, body)
|
|
89
|
+
self.class.etag(body)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The raw frontmatter block, or nil when the file has none. Compared as
|
|
95
|
+
# bytes, not as parsed YAML, so a reordered key or a changed comment is
|
|
96
|
+
# a change too.
|
|
97
|
+
#
|
|
98
|
+
# @param content [String]
|
|
99
|
+
# @return [String, nil]
|
|
100
|
+
def frontmatter(content) = Frontmatter::PATTERN.match(content.b)&.then { it[1] }
|
|
101
|
+
|
|
102
|
+
# @param path [String]
|
|
103
|
+
# @param body [String]
|
|
104
|
+
# @return [void]
|
|
105
|
+
def replace(path, body)
|
|
106
|
+
temp = File.join(File.dirname(path), ".#{File.basename(path)}.#{SecureRandom.hex(6)}.tmp")
|
|
107
|
+
File.binwrite(temp, body)
|
|
108
|
+
File.rename(temp, path)
|
|
109
|
+
ensure
|
|
110
|
+
FileUtils.rm_f(temp) if temp && File.exist?(temp)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param ordinal [String]
|
|
114
|
+
# @param name [String]
|
|
115
|
+
# @return [String]
|
|
116
|
+
# @raise [NotFound]
|
|
117
|
+
def path_for(ordinal, name)
|
|
118
|
+
known = READABLE.find { it == name } or raise NotFound, "not a plan file: #{name}"
|
|
119
|
+
number, folder = folder_for(ordinal)
|
|
120
|
+
path = File.join(folder, known)
|
|
121
|
+
raise NotFound, "#{number} has no #{known}" unless File.file?(path)
|
|
122
|
+
|
|
123
|
+
path
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param ordinal [String]
|
|
127
|
+
# @return [Array(Plans::Ordinal, String)] the plan number and its folder
|
|
128
|
+
# @raise [NotFound]
|
|
129
|
+
def folder_for(ordinal)
|
|
130
|
+
number = Plans::Ordinal.parse(ordinal.to_s) or raise NotFound, "not a plan number"
|
|
131
|
+
subject = Plans.tree(@plans_dir).find(number) or raise NotFound, "there is no plan #{number}"
|
|
132
|
+
[number, subject.feature.path]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/body_proxy"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Web
|
|
8
|
+
# One log entry per request, through {Log}, wrapped around the whole app
|
|
9
|
+
# so a request Rack::Protection or host authorization turns away is
|
|
10
|
+
# logged too.
|
|
11
|
+
#
|
|
12
|
+
# Not Sinatra's `logging` or Rack::CommonLogger: both write Apache-style
|
|
13
|
+
# text to `rack.errors`, which is the launcher's raw output file rather
|
|
14
|
+
# than the log the engine writes, and neither knows a page from a request.
|
|
15
|
+
#
|
|
16
|
+
# The event stream is the exception to "one entry per request". It stays
|
|
17
|
+
# open as long as the page does, so it is logged twice: when it opens, and
|
|
18
|
+
# when Puma closes its body, with how long it was open. Logged once, at
|
|
19
|
+
# the end, a page left open all day would not appear until evening.
|
|
20
|
+
#
|
|
21
|
+
# Static assets and `/healthz` are logged at debug: the launcher polls
|
|
22
|
+
# `/healthz` ten times a second while it waits, and a page load fetches a
|
|
23
|
+
# dozen assets, so at info they would bury the API calls.
|
|
24
|
+
class RequestLog
|
|
25
|
+
# A response of this type is a stream that outlives its request.
|
|
26
|
+
STREAM = "text/event-stream"
|
|
27
|
+
|
|
28
|
+
# Paths logged at info. Everything else is the page's own files.
|
|
29
|
+
API = %r{\A/api(/|\z)}
|
|
30
|
+
|
|
31
|
+
# @param app [#call]
|
|
32
|
+
# @param logger [#info, #debug, #error, nil] {Log.logger} when nil, read
|
|
33
|
+
# per request so a {Log.setup} after this was built still applies
|
|
34
|
+
# @param clock [#call] monotonic seconds
|
|
35
|
+
def initialize(app, logger: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
36
|
+
@app = app
|
|
37
|
+
@logger = logger
|
|
38
|
+
@clock = clock
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param env [Hash] the Rack environment
|
|
42
|
+
# @return [Array] status, headers and body, the body wrapped for a stream
|
|
43
|
+
def call(env)
|
|
44
|
+
started = @clock.call
|
|
45
|
+
status, headers, body = @app.call(env)
|
|
46
|
+
return [status, headers, opened(env, status, started, body)] if stream?(headers)
|
|
47
|
+
|
|
48
|
+
write(level(env), env, "#{line(env)} #{status}", status:, started:)
|
|
49
|
+
[status, headers, body]
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
write(:error, env, "#{line(env)} raised #{e.class}", status: 500, started:, exception: e)
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Logs the stream opening, and hands back a body that logs its close.
|
|
58
|
+
#
|
|
59
|
+
# @return [Rack::BodyProxy]
|
|
60
|
+
def opened(env, status, started, body)
|
|
61
|
+
write(:info, env, "#{line(env)} stream opened", status:)
|
|
62
|
+
Rack::BodyProxy.new(body) { write(:info, env, "#{line(env)} stream closed", status:, started:) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param headers [Hash]
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def stream?(headers)
|
|
68
|
+
type = headers["content-type"] || headers["Content-Type"]
|
|
69
|
+
type.to_s.start_with?(STREAM)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param env [Hash]
|
|
73
|
+
# @return [Symbol]
|
|
74
|
+
def level(env) = env["PATH_INFO"].to_s.match?(API) ? :info : :debug
|
|
75
|
+
|
|
76
|
+
# @param env [Hash]
|
|
77
|
+
# @return [String] e.g. "GET /api/board"
|
|
78
|
+
def line(env) = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
|
79
|
+
|
|
80
|
+
# The page's own id, from `?page=`, so the requests one browser tab
|
|
81
|
+
# made can be picked out of several tabs' worth.
|
|
82
|
+
#
|
|
83
|
+
# @param env [Hash]
|
|
84
|
+
# @return [String, nil]
|
|
85
|
+
def page(env)
|
|
86
|
+
id = Rack::Utils.parse_query(env["QUERY_STRING"].to_s)["page"]
|
|
87
|
+
id.is_a?(String) && !id.empty? ? id : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param level [Symbol]
|
|
91
|
+
# @param env [Hash]
|
|
92
|
+
# @param message [String]
|
|
93
|
+
# @param status [Integer]
|
|
94
|
+
# @param started [Float, nil] when given, the entry carries the duration since
|
|
95
|
+
# @param exception [Exception, nil]
|
|
96
|
+
# @return [void]
|
|
97
|
+
def write(level, env, message, status:, started: nil, exception: nil)
|
|
98
|
+
payload = { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_i, page: page(env) }.compact
|
|
99
|
+
entry = { message:, payload:, exception: }.compact
|
|
100
|
+
entry[:duration] = ((@clock.call - started) * 1000).round(1) if started
|
|
101
|
+
(@logger || Log.logger).public_send(level, **entry)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
# A log that cannot be written is no reason to fail the request.
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/openlanes/web/server.rb
CHANGED
|
@@ -7,7 +7,8 @@ require "puma/server"
|
|
|
7
7
|
module OpenLanes
|
|
8
8
|
module Web
|
|
9
9
|
# The app on Puma, multi-threaded because every open page holds one
|
|
10
|
-
# thread for its event stream, and bound to {HOST}
|
|
10
|
+
# thread for its event stream, and bound to {HOST} unless a {Listen}
|
|
11
|
+
# names other addresses.
|
|
11
12
|
#
|
|
12
13
|
# server = Web.server(app:, context:, port: 11000).start
|
|
13
14
|
# server.url # => "http://127.0.0.1:11000"
|
|
@@ -18,29 +19,31 @@ module OpenLanes
|
|
|
18
19
|
MAX_THREADS = 64
|
|
19
20
|
|
|
20
21
|
# Seconds Puma waits for open streams after {#stop}. They notice the
|
|
21
|
-
# context's `stopping`
|
|
22
|
+
# context's `stopping` on the `stop` nudge {#stop} publishes; this is
|
|
23
|
+
# the backstop, for a Redis that went away and took the nudge with it.
|
|
22
24
|
SHUTDOWN_AFTER = 2
|
|
23
25
|
|
|
24
26
|
# @param app [#call]
|
|
25
27
|
# @param context [OpenLanes::Web::Context]
|
|
26
28
|
# @param port [Integer] 0 for any free port
|
|
27
|
-
# @param
|
|
29
|
+
# @param listen [OpenLanes::Web::Listen] the addresses to listen on
|
|
28
30
|
# @param log [IO] where Puma writes its errors
|
|
29
|
-
def initialize(app:, context:, port: DEFAULT_PORT,
|
|
31
|
+
def initialize(app:, context:, port: DEFAULT_PORT, listen: Listen.parse(nil), log: $stderr)
|
|
30
32
|
@app = app
|
|
31
33
|
@context = context
|
|
32
34
|
@port = Integer(port)
|
|
33
|
-
@
|
|
35
|
+
@listen = listen
|
|
34
36
|
@log = log
|
|
35
37
|
end
|
|
36
38
|
|
|
37
|
-
# @return [
|
|
38
|
-
attr_reader :
|
|
39
|
+
# @return [OpenLanes::Web::Listen]
|
|
40
|
+
attr_reader :listen
|
|
39
41
|
|
|
40
42
|
# Listen, and serve on Puma's own threads.
|
|
41
43
|
#
|
|
42
44
|
# @return [self]
|
|
43
45
|
# @raise [Errno::EADDRINUSE] when the port is taken
|
|
46
|
+
# @raise [Errno::EADDRNOTAVAIL] when an address is not this machine's
|
|
44
47
|
def start
|
|
45
48
|
@puma = Puma::Server.new(@app,
|
|
46
49
|
nil,
|
|
@@ -48,17 +51,29 @@ module OpenLanes
|
|
|
48
51
|
max_threads: MAX_THREADS,
|
|
49
52
|
force_shutdown_after: SHUTDOWN_AFTER,
|
|
50
53
|
log_writer: Puma::LogWriter.new(File.open(File::NULL, "w"), @log))
|
|
51
|
-
|
|
54
|
+
# Port 0 is resolved by the first listener, and the rest share it, so
|
|
55
|
+
# every address answers on the one port {#url} reports.
|
|
56
|
+
@listen.ips.each { @puma.add_tcp_listener(it, @puma.connected_ports.first || @port) }
|
|
52
57
|
@thread = @puma.run
|
|
53
58
|
@context.chat&.start
|
|
54
59
|
self
|
|
60
|
+
rescue SystemCallError
|
|
61
|
+
# A later address failing leaves the earlier ones bound; Puma is not
|
|
62
|
+
# running yet, so close its sockets here rather than leak them to
|
|
63
|
+
# whoever retries in this process.
|
|
64
|
+
@puma&.binder&.close
|
|
65
|
+
@puma = nil
|
|
66
|
+
raise
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
# @return [Integer] the port listened on, the real one when 0 was asked
|
|
58
70
|
def port = @puma&.connected_ports&.first || @port
|
|
59
71
|
|
|
60
|
-
# @return [String]
|
|
61
|
-
def url =
|
|
72
|
+
# @return [String] where this machine's browser opens the page
|
|
73
|
+
def url = @listen.browse_url(port)
|
|
74
|
+
|
|
75
|
+
# @return [Array<String>] every address the page answers on
|
|
76
|
+
def urls = @listen.urls(port)
|
|
62
77
|
|
|
63
78
|
# Block until the server stops.
|
|
64
79
|
#
|
|
@@ -70,6 +85,10 @@ module OpenLanes
|
|
|
70
85
|
# @return [void]
|
|
71
86
|
def stop
|
|
72
87
|
@context.stopping.make_true
|
|
88
|
+
# Wakes every stream blocked waiting for news, here and on any other
|
|
89
|
+
# server following this project, so ours see `stopping` now rather
|
|
90
|
+
# than at their next ping. The others look, find nothing, and sleep.
|
|
91
|
+
Live::News.publish(store: @context.store, keys: @context.keys, kind: "stop")
|
|
73
92
|
@context.chat&.stop
|
|
74
93
|
@puma&.stop(true)
|
|
75
94
|
@context.stores.close
|
data/lib/openlanes/web.rb
CHANGED
|
@@ -25,8 +25,8 @@ module OpenLanes
|
|
|
25
25
|
# Where the server listens unless told otherwise (contract.md).
|
|
26
26
|
DEFAULT_PORT = 11_000
|
|
27
27
|
|
|
28
|
-
# Loopback
|
|
29
|
-
#
|
|
28
|
+
# Loopback by default: the page can stop agents and has no login, so
|
|
29
|
+
# nothing off this machine reaches it unless `--ips` or `-I` says so (W3).
|
|
30
30
|
HOST = "127.0.0.1"
|
|
31
31
|
|
|
32
32
|
module_function
|
|
@@ -54,9 +54,11 @@ module OpenLanes
|
|
|
54
54
|
Live::Chat.new(stores:, keys:, machine:, label: node&.label || machine, **)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# The routes, with every request logged on the way out (see {RequestLog}).
|
|
58
|
+
#
|
|
57
59
|
# @param context [OpenLanes::Web::Context]
|
|
58
|
-
# @return [
|
|
59
|
-
def app(context) = App.serving(context)
|
|
60
|
+
# @return [#call] a Rack application
|
|
61
|
+
def app(context) = RequestLog.new(App.serving(context))
|
|
60
62
|
|
|
61
63
|
# @param app [#call]
|
|
62
64
|
# @param context [OpenLanes::Web::Context]
|