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/web/events.rb
CHANGED
|
@@ -6,32 +6,77 @@ module OpenLanes
|
|
|
6
6
|
module Web
|
|
7
7
|
# One page's event stream (contract.md, `GET /api/events`): a `board`
|
|
8
8
|
# event whenever any node's snapshot `seq` changes, a `reply` event for
|
|
9
|
-
# each reply the engine writes,
|
|
9
|
+
# each reply the engine writes, a `copilot` event for each turn of the
|
|
10
|
+
# conversation with the copilot (W17), and a `: ping` comment so proxies and the
|
|
10
11
|
# browser keep the connection open.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Pushed rather than polled: the stream holds a subscription of its own
|
|
14
|
+
# to {Live::Keys#news} and sleeps on it until a writer nudges ({Live::News}),
|
|
15
|
+
# then reads Redis exactly as it did when it looked every quarter second.
|
|
16
|
+
# The nudge carries no data, so the cursors below are untouched by it.
|
|
17
|
+
# It subscribes before its first read, so a write that lands between
|
|
18
|
+
# the two is read twice rather than missed. With no nudge it still looks
|
|
19
|
+
# once per ping, which is how it notices what nobody announces: a node
|
|
20
|
+
# whose snapshot expired, an app whose runner was killed. One
|
|
21
|
+
# subscription per stream, closed when the stream ends.
|
|
15
22
|
#
|
|
16
23
|
# A reply carries its stream id as the event's `id:`, so a browser that
|
|
17
24
|
# reconnects sends `Last-Event-ID` and gets what it missed. The stream
|
|
18
25
|
# opens with a `cursor` event carrying the id it starts from, so a
|
|
19
26
|
# browser that drops before the first reply still has one to send;
|
|
20
27
|
# without it, replies written during that gap were skipped.
|
|
28
|
+
#
|
|
29
|
+
# A `chat` event (plan 006) carries no `id:` either, and like the
|
|
30
|
+
# copilot's the stream opens with a `chat_cursor` when it carries a
|
|
31
|
+
# page's chat, so the page reads `GET /api/chat` only once relaying has
|
|
32
|
+
# begun. Nor is there a cursor id to carry: the chat between
|
|
33
|
+
# machines keeps no history (D2), so there is nothing to catch up on.
|
|
34
|
+
# The stream is how {Live::Chat} knows the page is still open; when it
|
|
35
|
+
# closes, the page has {Live::Chat::GRACE} to reconnect before it counts
|
|
36
|
+
# as having left (D6).
|
|
37
|
+
#
|
|
38
|
+
# A copilot turn carries no `id:`, so it never moves that cursor. The
|
|
39
|
+
# conversation has a boundary of its own instead: the stream samples the
|
|
40
|
+
# newest turn, says so in a `copilot_cursor` event, and from then on
|
|
41
|
+
# sends every turn after it. The page reads `GET /api/copilot` when that
|
|
42
|
+
# event arrives, never before it, so the read covers everything up to
|
|
43
|
+
# the boundary and the stream everything after. A turn written between a
|
|
44
|
+
# read the page started on its own and the stream opening fell in the
|
|
45
|
+
# gap between the two and was never shown.
|
|
46
|
+
#
|
|
47
|
+
# The apps running on this machine (plan 012) open the stream as one
|
|
48
|
+
# `apps` event, sent only when there is one, so a project with no app
|
|
49
|
+
# gets the stream it always had; the page starts each stream with no
|
|
50
|
+
# apps. After that each process whose status changed comes as an `app`
|
|
51
|
+
# event. A process whose plan's entry vanished (its runner
|
|
52
|
+
# stopped or died) is sent once more as `stopped`, so the page never
|
|
53
|
+
# keeps a tab for an app that is gone.
|
|
21
54
|
class Events
|
|
55
|
+
# How often a stream without a subscription looks again: the polling
|
|
56
|
+
# interval every stream used before nudges.
|
|
57
|
+
POLL = 0.25
|
|
58
|
+
|
|
59
|
+
# Block on the subscription for the next nudge, or, with none, poll.
|
|
60
|
+
WAIT = ->(news, seconds) { news ? news.next_message(seconds) : sleep([seconds, POLL].min) }
|
|
61
|
+
|
|
22
62
|
# @param context [OpenLanes::Web::Context]
|
|
23
63
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
24
64
|
# @param last_id [String, nil] the `Last-Event-ID` the browser sent
|
|
65
|
+
# @param page [String, nil] the page's own id, `?page=` on the stream, for its chat
|
|
25
66
|
# @param clock [Proc] seconds, monotonic
|
|
26
|
-
# @param
|
|
27
|
-
|
|
28
|
-
|
|
67
|
+
# @param waiter [Proc] given the subscription and at most how many
|
|
68
|
+
# seconds to wait, returns once a nudge arrives or the time is up
|
|
69
|
+
def initialize(context:, store:, last_id: nil, page: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
70
|
+
waiter: WAIT)
|
|
29
71
|
@context = context
|
|
30
72
|
@store = store
|
|
31
73
|
@keys = context.keys
|
|
74
|
+
@copilot = Live.copilot(store:, keys: @keys)
|
|
32
75
|
@last_id = last_id.to_s.match?(/\A\d+-\d+\z/) ? last_id : nil
|
|
76
|
+
@chat = context.chat
|
|
77
|
+
@page = page.to_s.match?(Live::Chat::PAGE) ? page : nil
|
|
33
78
|
@clock = clock
|
|
34
|
-
@
|
|
79
|
+
@waiter = waiter
|
|
35
80
|
end
|
|
36
81
|
|
|
37
82
|
# Write events to +out+ until the browser leaves, the server stops, or
|
|
@@ -40,21 +85,35 @@ module OpenLanes
|
|
|
40
85
|
# @param out [#<<, #closed?]
|
|
41
86
|
# @return [void]
|
|
42
87
|
def run(out)
|
|
88
|
+
@chat&.attach(@page) if @page
|
|
89
|
+
news = subscribe
|
|
43
90
|
started = @clock.call
|
|
44
91
|
pinged = started
|
|
45
92
|
cursor = @last_id || newest_reply
|
|
46
93
|
out << self.class.frame("cursor", { id: cursor }, id: cursor)
|
|
94
|
+
said = @copilot.newest
|
|
95
|
+
out << self.class.frame("copilot_cursor", { id: said }) unless out.closed?
|
|
96
|
+
# The page is attached, so every chat event from here on is queued for
|
|
97
|
+
# it: a `GET /api/chat` the page starts on this misses nothing.
|
|
98
|
+
out << self.class.frame("chat_cursor", {}) if @chat && @page && !out.closed?
|
|
99
|
+
apps = open_apps(out)
|
|
47
100
|
seqs = nil
|
|
48
101
|
until over?(out, started)
|
|
49
102
|
seqs = board(out, seqs)
|
|
103
|
+
apps = app_news(out, apps)
|
|
50
104
|
cursor = replies(out, cursor)
|
|
105
|
+
said = copilot(out, said)
|
|
106
|
+
chat(out)
|
|
51
107
|
pinged = ping(out) if @clock.call - pinged >= @context.ping_every
|
|
52
|
-
|
|
108
|
+
wait(news, started, pinged)
|
|
53
109
|
end
|
|
54
110
|
rescue Live::Unavailable => e
|
|
55
111
|
# Ending the stream is the right answer: the page reconnects with
|
|
56
112
|
# backoff and shows itself as reconnecting meanwhile.
|
|
57
113
|
out << ": #{e.message.tr("\n", " ")}\n\n"
|
|
114
|
+
ensure
|
|
115
|
+
news&.close
|
|
116
|
+
@chat&.detach(@page) if @page
|
|
58
117
|
end
|
|
59
118
|
|
|
60
119
|
# @param event [String]
|
|
@@ -65,11 +124,45 @@ module OpenLanes
|
|
|
65
124
|
|
|
66
125
|
private
|
|
67
126
|
|
|
127
|
+
# The stream's subscription to nudges, or nil to poll. A Redis user
|
|
128
|
+
# whose ACL withholds SUBSCRIBE got a working polled board before
|
|
129
|
+
# nudges existed, and ending the stream instead would leave its page
|
|
130
|
+
# reconnecting forever. A Redis that is gone fails the first read
|
|
131
|
+
# just after, and ends the stream there.
|
|
132
|
+
#
|
|
133
|
+
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber, nil]
|
|
134
|
+
def subscribe
|
|
135
|
+
Live::News.subscribe(store: @store, keys: @keys)
|
|
136
|
+
rescue Live::Unavailable => e
|
|
137
|
+
UI.log("events: no nudges, polling every #{POLL}s: #{e.message}", level: :warn)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
68
141
|
# @return [Boolean]
|
|
69
142
|
def over?(out, started)
|
|
70
143
|
out.closed? || @context.stopping.true? || (@context.lifetime && @clock.call - started >= @context.lifetime)
|
|
71
144
|
end
|
|
72
145
|
|
|
146
|
+
# Sleep until a nudge, the next ping, or the end of the stream's
|
|
147
|
+
# lifetime, whichever comes first. One nudge, one look: a nudge that
|
|
148
|
+
# arrives while the stream is reading stays queued and wakes the next
|
|
149
|
+
# wait at once, so nothing written during a look is missed. Queued
|
|
150
|
+
# nudges are not drained first because the drain costs more than it
|
|
151
|
+
# saves: redis-client cannot poll without waiting, so emptying the
|
|
152
|
+
# queue costs {Live::Subscriber::SHORTEST} on every wake, ten times
|
|
153
|
+
# what an extra look costs, and a tick nudges once or twice at most.
|
|
154
|
+
#
|
|
155
|
+
# @param news [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
156
|
+
# @param started [Float]
|
|
157
|
+
# @param pinged [Float]
|
|
158
|
+
# @return [void]
|
|
159
|
+
def wait(news, started, pinged)
|
|
160
|
+
now = @clock.call
|
|
161
|
+
limits = [@context.ping_every - (now - pinged)]
|
|
162
|
+
limits << (@context.lifetime - (now - started)) if @context.lifetime
|
|
163
|
+
@waiter.call(news, [limits.min, 0].max)
|
|
164
|
+
end
|
|
165
|
+
|
|
73
166
|
# @return [Array] the seqs now, after sending the board if they changed
|
|
74
167
|
def board(out, seqs)
|
|
75
168
|
board = Live.board(store: @store, keys: @keys)
|
|
@@ -78,6 +171,25 @@ module OpenLanes
|
|
|
78
171
|
now
|
|
79
172
|
end
|
|
80
173
|
|
|
174
|
+
# @return [Hash{Array(String, String) => Hash}] what was sent, by plan and process
|
|
175
|
+
def open_apps(out)
|
|
176
|
+
now = live_apps
|
|
177
|
+
out << self.class.frame("apps", { apps: now.values }) unless now.empty? || out.closed?
|
|
178
|
+
now
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param sent [Hash{Array(String, String) => Hash}]
|
|
182
|
+
# @return [Hash{Array(String, String) => Hash}]
|
|
183
|
+
def app_news(out, sent)
|
|
184
|
+
now = live_apps
|
|
185
|
+
now.each { |key, app| out << self.class.frame("app", app) unless sent[key] == app }
|
|
186
|
+
sent.except(*now.keys).each_value { out << self.class.frame("app", it.merge("state" => "stopped")) }
|
|
187
|
+
now
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @return [Hash{Array(String, String) => Hash}]
|
|
191
|
+
def live_apps = Apps::Listing.read(store: @store, keys: @keys).to_h { [[it["plan"], it["process"]], it] }
|
|
192
|
+
|
|
81
193
|
# @return [String] the last reply id sent
|
|
82
194
|
def replies(out, cursor)
|
|
83
195
|
Live.replies(store: @store, keys: @keys, after: cursor).each do |id, reply|
|
|
@@ -87,6 +199,22 @@ module OpenLanes
|
|
|
87
199
|
cursor
|
|
88
200
|
end
|
|
89
201
|
|
|
202
|
+
# @return [String] the last copilot turn's stream id sent
|
|
203
|
+
def copilot(out, said)
|
|
204
|
+
@copilot.since(said).each do |id, turn|
|
|
205
|
+
out << self.class.frame("copilot", turn)
|
|
206
|
+
said = id
|
|
207
|
+
end
|
|
208
|
+
said
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @return [void]
|
|
212
|
+
def chat(out)
|
|
213
|
+
return unless @chat && @page
|
|
214
|
+
|
|
215
|
+
@chat.drain(@page).each { out << self.class.frame("chat", it) }
|
|
216
|
+
end
|
|
217
|
+
|
|
90
218
|
# @return [Float] when it pinged
|
|
91
219
|
def ping(out)
|
|
92
220
|
out << ": ping\n\n"
|
|
@@ -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
|
|
@@ -137,7 +199,7 @@ module OpenLanes
|
|
|
137
199
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
200
|
|
|
139
201
|
# @return [String]
|
|
140
|
-
def default_log(root) =
|
|
202
|
+
def default_log(root) = Engine::ProgressLog.default_path(root, kind: "web")
|
|
141
203
|
end
|
|
142
204
|
end
|
|
143
205
|
end
|
|
@@ -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
|