openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Apps
|
|
8
|
+
# Whether a process is ready (plan 012, decision 1): something accepts a
|
|
9
|
+
# TCP connection on its port, and, when its `ready:` names a path, a GET
|
|
10
|
+
# of that path answers 2xx or 3xx.
|
|
11
|
+
#
|
|
12
|
+
# Only ever on the port the process was given. An app that moved to
|
|
13
|
+
# another port (Vite without `--strictPort` does) therefore never
|
|
14
|
+
# becomes ready, which is how a moved port fails the start rather than
|
|
15
|
+
# leaving the Preview tab pointing at nothing.
|
|
16
|
+
module Probe
|
|
17
|
+
# Seconds a connect, and a read, may take.
|
|
18
|
+
TIMEOUT = 1
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param port [Integer]
|
|
23
|
+
# @param path [String, nil]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def ready?(port, path: nil)
|
|
26
|
+
return false unless listening?(port)
|
|
27
|
+
return true if path.nil?
|
|
28
|
+
|
|
29
|
+
status(port, path).between?(200, 399)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Something accepted a connection on the port. Asking the socket for
|
|
33
|
+
# its peer is the part that matters: behind a network filter (Little
|
|
34
|
+
# Snitch on macOS) `Socket.tcp` with `connect_timeout` returns a socket
|
|
35
|
+
# for a refused connect, and only `getpeername` then says EINVAL. Without
|
|
36
|
+
# it every port on the machine reads as taken.
|
|
37
|
+
#
|
|
38
|
+
# @param port [Integer]
|
|
39
|
+
# @param timeout [Numeric] seconds the connect may take
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def listening?(port, timeout: TIMEOUT)
|
|
42
|
+
Socket.tcp(Status::HOST, port, connect_timeout: timeout) do |socket|
|
|
43
|
+
socket.remote_address
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
rescue SystemCallError, IOError, SocketError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param port [Integer]
|
|
51
|
+
# @param path [String]
|
|
52
|
+
# @return [Integer] the status, 0 when nothing answered
|
|
53
|
+
def status(port, path)
|
|
54
|
+
http = Net::HTTP.new(Status::HOST, port)
|
|
55
|
+
http.open_timeout = TIMEOUT
|
|
56
|
+
http.read_timeout = TIMEOUT
|
|
57
|
+
http.request_get(path).code.to_i
|
|
58
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# One process of an app, as `.openlanes.yml` declares it under
|
|
6
|
+
# `app.processes` (M12).
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] name
|
|
9
|
+
# @return [String] `api`, `web`; unique within the app
|
|
10
|
+
# @!attribute [r] start
|
|
11
|
+
# @return [String] a shell line, run by `sh -c` in the checkout, so
|
|
12
|
+
# `$PORT_WEB` expands there
|
|
13
|
+
# @!attribute [r] ready
|
|
14
|
+
# @return [String, nil] an HTTP path that must answer 2xx or 3xx; nil
|
|
15
|
+
# means a TCP connect is enough
|
|
16
|
+
# @!attribute [r] restart_on
|
|
17
|
+
# @return [Array<String>] `Dir.glob` patterns, relative to the checkout
|
|
18
|
+
# @!attribute [r] preview
|
|
19
|
+
# @return [Boolean] the one process the Preview tab shows
|
|
20
|
+
ProcessSpec = Data.define(:name, :start, :ready, :restart_on, :preview) do
|
|
21
|
+
def initialize(name:, start:, ready: nil, restart_on: [], preview: false) = super
|
|
22
|
+
|
|
23
|
+
# `PORT_<NAME>`: upcased, anything not a letter or digit as `_`, so
|
|
24
|
+
# `api-v2` is `PORT_API_V2` and a shell can expand it.
|
|
25
|
+
#
|
|
26
|
+
# @return [String]
|
|
27
|
+
def port_env = "PORT_#{name.upcase.gsub(/[^A-Z0-9]/, "_")}"
|
|
28
|
+
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def preview? = preview == true
|
|
31
|
+
|
|
32
|
+
# @return [Hash] as `.openlanes.yml` spells it, defaults left out
|
|
33
|
+
def to_h
|
|
34
|
+
{ "name" => name, "start" => start, "ready" => ready,
|
|
35
|
+
"restart_on" => (restart_on unless restart_on.empty?), "preview" => (true if preview?) }.compact
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/atomic/atomic_boolean"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Apps
|
|
8
|
+
# `openlanes app start` in the foreground (plan 012, B6): one plan's app
|
|
9
|
+
# started, kept, and stopped.
|
|
10
|
+
#
|
|
11
|
+
# Each tick it heartbeats the app's statuses into Redis for the Preview
|
|
12
|
+
# tab ({Listing}), restarts the processes whose `restart_on` files
|
|
13
|
+
# changed ({Watcher}), and applies what the tab's toolbar asked for
|
|
14
|
+
# (`Live::Keys#app_commands`). It ends on INT or TERM, which is what
|
|
15
|
+
# `openlanes app stop` sends, or on the toolbar's Stop, and either way
|
|
16
|
+
# stops every process and takes the app off the board.
|
|
17
|
+
#
|
|
18
|
+
# Redis is optional. Without a store the app runs just the same and the
|
|
19
|
+
# page simply never hears of it.
|
|
20
|
+
class Runner
|
|
21
|
+
# Seconds between ticks: the watcher's poll and the heartbeat.
|
|
22
|
+
INTERVAL = 1
|
|
23
|
+
|
|
24
|
+
# Commands kept on the stream; the runner reads only what arrives
|
|
25
|
+
# after it started, so this is only a bound on Redis.
|
|
26
|
+
COMMANDS_LENGTH = 100
|
|
27
|
+
|
|
28
|
+
# What the toolbar may ask for.
|
|
29
|
+
ACTIONS = %w[restart stop].freeze
|
|
30
|
+
|
|
31
|
+
# @param definition [OpenLanes::Apps::Definition]
|
|
32
|
+
# @param root [String] the checkout
|
|
33
|
+
# @param plan [String]
|
|
34
|
+
# @param branch [String]
|
|
35
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, nil]
|
|
36
|
+
# @param keys [OpenLanes::Live::Keys, nil]
|
|
37
|
+
# @param interval [Numeric]
|
|
38
|
+
# @param sleeper [#call] waits the given seconds
|
|
39
|
+
# @param stopping [Concurrent::AtomicBoolean] set to end the loop
|
|
40
|
+
# @param say [#call] given a line for the terminal
|
|
41
|
+
# @param supervisor [Hash] more arguments for {Supervisor.new}
|
|
42
|
+
def initialize(definition:, root:, plan:, branch:, store: nil, keys: nil, interval: INTERVAL,
|
|
43
|
+
sleeper: ->(seconds) { sleep seconds }, stopping: Concurrent::AtomicBoolean.new, say: ->(_) {}, supervisor: {})
|
|
44
|
+
@definition = definition
|
|
45
|
+
@plan = plan
|
|
46
|
+
@store = store
|
|
47
|
+
@keys = keys
|
|
48
|
+
@interval = interval
|
|
49
|
+
@sleeper = sleeper
|
|
50
|
+
@stopping = stopping
|
|
51
|
+
@say = say
|
|
52
|
+
@supervisor = Supervisor.new(definition:, root:, plan:, branch:, publisher: method(:publish), **supervisor)
|
|
53
|
+
@watcher = Watcher.new(root:, definition:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [OpenLanes::Apps::Supervisor]
|
|
57
|
+
attr_reader :supervisor
|
|
58
|
+
|
|
59
|
+
# @return [Concurrent::AtomicBoolean]
|
|
60
|
+
attr_reader :stopping
|
|
61
|
+
|
|
62
|
+
# Start the app and keep it until told to stop.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
# @raise [OpenLanes::Apps::Supervisor::StartFailed] after the failed
|
|
66
|
+
# statuses were published, so the tab can show why
|
|
67
|
+
def call
|
|
68
|
+
cursor = newest_command
|
|
69
|
+
@supervisor.start
|
|
70
|
+
until @stopping.true?
|
|
71
|
+
@watcher.changed.each { restart(it, why: "its files changed") }
|
|
72
|
+
cursor = commands(cursor)
|
|
73
|
+
heartbeat
|
|
74
|
+
@sleeper.call(@interval) unless @stopping.true?
|
|
75
|
+
end
|
|
76
|
+
rescue Supervisor::StartFailed
|
|
77
|
+
# The failed statuses stay on the board until their heartbeat runs
|
|
78
|
+
# out, so the tab can say why before it goes.
|
|
79
|
+
failed = true
|
|
80
|
+
raise
|
|
81
|
+
ensure
|
|
82
|
+
@supervisor.stop
|
|
83
|
+
live { Listing.remove(store: @store, keys: @keys, plan: @plan) } unless failed
|
|
84
|
+
nudge
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The supervisor's publisher: every transition goes out at once, with
|
|
88
|
+
# the whole app, since the page reads a plan's processes together.
|
|
89
|
+
#
|
|
90
|
+
# @param status [OpenLanes::Apps::Status]
|
|
91
|
+
# @return [void]
|
|
92
|
+
def publish(status)
|
|
93
|
+
@say.call("#{status.process} #{status.state} on port #{status.port}#{": #{status.reason}" if status.reason}")
|
|
94
|
+
heartbeat
|
|
95
|
+
nudge
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
def heartbeat = live { Listing.write(store: @store, keys: @keys, plan: @plan, statuses: @supervisor.statuses) }
|
|
102
|
+
|
|
103
|
+
# Tells open pages the app changed. Only on a transition or a stop,
|
|
104
|
+
# never on the once-a-second {#heartbeat}, which changes nothing a
|
|
105
|
+
# page shows: an app that is merely up keeps every stream asleep.
|
|
106
|
+
#
|
|
107
|
+
# @return [void]
|
|
108
|
+
def nudge = live { Live::News.publish(store: @store, keys: @keys, kind: "app") }
|
|
109
|
+
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def restart(name, why:)
|
|
112
|
+
@say.call("restarting #{name}: #{why}")
|
|
113
|
+
@supervisor.restart(name)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Apply the toolbar's requests for this plan that arrived after +cursor+.
|
|
117
|
+
#
|
|
118
|
+
# @param cursor [String] a stream id
|
|
119
|
+
# @return [String] the last id read
|
|
120
|
+
def commands(cursor)
|
|
121
|
+
entries = live { @store.xread(@keys.app_commands, id: cursor) } || []
|
|
122
|
+
entries.each do |entry|
|
|
123
|
+
cursor = entry.id
|
|
124
|
+
command = Listing.parse(entry.fields["json"])
|
|
125
|
+
apply(command) if command && command["plan"] == @plan
|
|
126
|
+
end
|
|
127
|
+
cursor
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param command [Hash] `plan`, `action`, `target`
|
|
131
|
+
# @return [void]
|
|
132
|
+
def apply(command)
|
|
133
|
+
case command["action"]
|
|
134
|
+
when "stop"
|
|
135
|
+
@say.call("stopping: asked from the page")
|
|
136
|
+
@stopping.make_true
|
|
137
|
+
when "restart"
|
|
138
|
+
targets(command["target"]).each { restart(it.name, why: "asked from the page") }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `backend` is every process but the previewed one, in declared order
|
|
143
|
+
# (spec open question 9); a name is that process; nothing is all of them.
|
|
144
|
+
#
|
|
145
|
+
# @return [Array<OpenLanes::Apps::ProcessSpec>]
|
|
146
|
+
def targets(target)
|
|
147
|
+
case target
|
|
148
|
+
when nil, "" then @definition.processes
|
|
149
|
+
when "backend" then @definition.backend
|
|
150
|
+
else Array(@definition.process(target))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Commands written before this runner started are not for it.
|
|
155
|
+
#
|
|
156
|
+
# @return [String]
|
|
157
|
+
def newest_command = live { @store.xrevrange(@keys.app_commands, count: 1).first&.id } || "0-0"
|
|
158
|
+
|
|
159
|
+
# Run a Redis call when there is a store, and survive one that went
|
|
160
|
+
# away: the app matters more than the board.
|
|
161
|
+
#
|
|
162
|
+
# @return [Object, nil]
|
|
163
|
+
def live
|
|
164
|
+
return nil unless @store
|
|
165
|
+
|
|
166
|
+
yield
|
|
167
|
+
rescue Live::Unavailable => e
|
|
168
|
+
@say.call("Redis: #{e.message}") unless @said_unavailable
|
|
169
|
+
@said_unavailable = true
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Apps
|
|
7
|
+
# Where one process of a running app stands, and the `<app>` object of
|
|
8
|
+
# the `apps` and `app` events the control room reads (plan 012, B1).
|
|
9
|
+
# The Preview tab draws only what arrives in this shape, so every field
|
|
10
|
+
# is said here once and {#to_h} is the wire.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute [r] plan
|
|
13
|
+
# @return [String] the ordinal, `012.00`
|
|
14
|
+
# @!attribute [r] branch
|
|
15
|
+
# @return [String] the checkout's branch
|
|
16
|
+
# @!attribute [r] process
|
|
17
|
+
# @return [String] the process's name
|
|
18
|
+
# @!attribute [r] state
|
|
19
|
+
# @return [String] one of {STATES}
|
|
20
|
+
# @!attribute [r] port
|
|
21
|
+
# @return [Integer] the port it was given
|
|
22
|
+
# @!attribute [r] preview
|
|
23
|
+
# @return [Boolean] the process the tab shows
|
|
24
|
+
# @!attribute [r] url
|
|
25
|
+
# @return [String] `http://127.0.0.1:<port><ready path or />`
|
|
26
|
+
# @!attribute [r] framing
|
|
27
|
+
# @return [String, nil] `frame` or `window` once the preview process is
|
|
28
|
+
# ready; nil before that and on every other process
|
|
29
|
+
# @!attribute [r] reason
|
|
30
|
+
# @return [String, nil] why it failed, for a person
|
|
31
|
+
# @!attribute [r] at
|
|
32
|
+
# @return [Time] when it entered this state
|
|
33
|
+
Status = Data.define(:plan, :branch, :process, :state, :port, :preview, :url, :framing, :reason, :at) do
|
|
34
|
+
def initialize(plan:, branch:, process:, state:, port:, preview: false, url: nil, framing: nil, reason: nil, at: Time.now)
|
|
35
|
+
raise ArgumentError, "state #{state.inspect} is not one of #{Status::STATES.join(", ")}" unless Status::STATES.include?(state.to_s)
|
|
36
|
+
|
|
37
|
+
super(plan:, branch:, process:, state: state.to_s, port:, preview:, url: url || "http://#{Status::HOST}:#{port}/",
|
|
38
|
+
framing: framing&.to_s, reason:, at:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def ready? = state == "ready"
|
|
43
|
+
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def failed? = state == "failed"
|
|
46
|
+
|
|
47
|
+
# @return [Hash] the wire's `<app>`, string keys
|
|
48
|
+
def to_h
|
|
49
|
+
{ "plan" => plan, "branch" => branch, "process" => process, "state" => state, "port" => port,
|
|
50
|
+
"preview" => preview, "url" => url, "framing" => framing, "reason" => reason, "at" => at.utc.iso8601 }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Status
|
|
55
|
+
# Every state a process passes through.
|
|
56
|
+
STATES = %w[starting ready restarting stopped failed].freeze
|
|
57
|
+
|
|
58
|
+
# Where every app listens: nothing here is reachable from another machine.
|
|
59
|
+
HOST = "127.0.0.1"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# Runs one plan's app (plan 012, B4): every process in `app:` started in
|
|
6
|
+
# declared order, each on a port from {Ports}, the next only once the
|
|
7
|
+
# previous is ready, and all of them stopped together.
|
|
8
|
+
#
|
|
9
|
+
# Every process is told every port (`PORT_API`, `PORT_WEB`) before the
|
|
10
|
+
# first one starts, so a web process can find its API. Ports are
|
|
11
|
+
# reserved up front for the same reason.
|
|
12
|
+
#
|
|
13
|
+
# A process runs as `sh -c <start>` through {Execution::Child}, which
|
|
14
|
+
# leads a process group of its own: a dev server's watcher and workers
|
|
15
|
+
# are grandchildren, and only a group stop reaches them. `Child` also
|
|
16
|
+
# kills every group still running when this Ruby exits, which is what
|
|
17
|
+
# stops the app when its runner does.
|
|
18
|
+
#
|
|
19
|
+
# Each transition is handed to +publisher+ as a {Status}; the runner
|
|
20
|
+
# writes it to Redis for the Preview tab, and plan 014 reads the same
|
|
21
|
+
# {#statuses} for its report.
|
|
22
|
+
class Supervisor
|
|
23
|
+
# The app did not come up. The message is the reason, for a person;
|
|
24
|
+
# everything that had started has been stopped by the time it is raised.
|
|
25
|
+
# {#process} names the one that failed, so its {#log_tail} can be read
|
|
26
|
+
# after the stop: verification quotes it when it rejects (M1).
|
|
27
|
+
class StartFailed < Error
|
|
28
|
+
# @return [String, nil] the process that did not become ready
|
|
29
|
+
attr_reader :process
|
|
30
|
+
|
|
31
|
+
# @param message [String]
|
|
32
|
+
# @param process [String, nil]
|
|
33
|
+
def initialize(message = nil, process: nil)
|
|
34
|
+
super(message)
|
|
35
|
+
@process = process
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Seconds a process has to become ready (spec open question 10).
|
|
40
|
+
READY_TIMEOUT = 30
|
|
41
|
+
|
|
42
|
+
# Seconds between readiness probes.
|
|
43
|
+
POLL = 0.1
|
|
44
|
+
|
|
45
|
+
# Lines of a process's output kept, for the reason when it fails.
|
|
46
|
+
TAIL = 20
|
|
47
|
+
|
|
48
|
+
# What a process says when another took its port between the probe
|
|
49
|
+
# and the bind: the one failure worth a second attempt.
|
|
50
|
+
RACE = /EADDRINUSE|address already in use/i
|
|
51
|
+
|
|
52
|
+
# One process the supervisor started.
|
|
53
|
+
Running = Struct.new(:spec, :child, :waiter, :reader, :tail)
|
|
54
|
+
|
|
55
|
+
# @param definition [OpenLanes::Apps::Definition]
|
|
56
|
+
# @param root [String] the checkout every process runs in
|
|
57
|
+
# @param plan [String] the ordinal
|
|
58
|
+
# @param branch [String]
|
|
59
|
+
# @param registry [OpenLanes::Apps::Ports]
|
|
60
|
+
# @param publisher [#call, nil] given each {Status} as it happens
|
|
61
|
+
# @param probe [#ready?]
|
|
62
|
+
# @param framing [#check]
|
|
63
|
+
# @param origin [String] the control room's origin, for {Framing}
|
|
64
|
+
# @param ready_timeout [Numeric] seconds
|
|
65
|
+
# @param poll [Numeric] seconds between probes
|
|
66
|
+
# @param output [#call, nil] given a process's name and each line it prints
|
|
67
|
+
def initialize(definition:, root:, plan:, branch:, registry: Ports.new, publisher: nil, probe: Probe, framing: Framing,
|
|
68
|
+
origin: Framing::ORIGIN, ready_timeout: READY_TIMEOUT, poll: POLL, output: nil)
|
|
69
|
+
@definition = definition
|
|
70
|
+
@root = root
|
|
71
|
+
@plan = plan
|
|
72
|
+
@branch = branch
|
|
73
|
+
@registry = registry
|
|
74
|
+
@publisher = publisher
|
|
75
|
+
@probe = probe
|
|
76
|
+
@framing = framing
|
|
77
|
+
@origin = origin
|
|
78
|
+
@ready_timeout = ready_timeout
|
|
79
|
+
@poll = poll
|
|
80
|
+
@output = output
|
|
81
|
+
@running = {}
|
|
82
|
+
@ports = {}
|
|
83
|
+
@statuses = {}
|
|
84
|
+
@tails = {}
|
|
85
|
+
@mutex = Mutex.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [OpenLanes::Apps::Definition]
|
|
89
|
+
attr_reader :definition
|
|
90
|
+
|
|
91
|
+
# @return [String]
|
|
92
|
+
attr_reader :plan
|
|
93
|
+
|
|
94
|
+
# Start every process in order and wait for each to be ready.
|
|
95
|
+
#
|
|
96
|
+
# A start whose process lost its port to somebody else between the
|
|
97
|
+
# reservation and the bind is tried once more, from fresh ports.
|
|
98
|
+
#
|
|
99
|
+
# @return [self]
|
|
100
|
+
# @raise [OpenLanes::Apps::Supervisor::StartFailed]
|
|
101
|
+
def start
|
|
102
|
+
launch
|
|
103
|
+
self
|
|
104
|
+
rescue StartFailed => e
|
|
105
|
+
raise unless e.message.match?(RACE)
|
|
106
|
+
|
|
107
|
+
launch
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Stop every process, last started first, and give its port back.
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def stop
|
|
115
|
+
@running.keys.reverse_each { halt(it) }
|
|
116
|
+
@ports.each_value { @registry.release(it) }
|
|
117
|
+
@ports.clear
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Stop one process and start it again on the same port (B5): what a
|
|
121
|
+
# change to its `restart_on` files, or Restart backend, asks for.
|
|
122
|
+
# Its siblings are not touched.
|
|
123
|
+
#
|
|
124
|
+
# @param name [String]
|
|
125
|
+
# @return [Boolean] whether it came back ready
|
|
126
|
+
def restart(name)
|
|
127
|
+
spec = definition.process(name) or raise ArgumentError, "no process #{name.inspect} in this app"
|
|
128
|
+
return false unless @ports.key?(spec.name)
|
|
129
|
+
|
|
130
|
+
publish(spec, "restarting")
|
|
131
|
+
halt(spec.name, quietly: true)
|
|
132
|
+
bring_up(spec, nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Array<OpenLanes::Apps::Status>] the latest of each process, in start order
|
|
136
|
+
def statuses = @mutex.synchronize { definition.processes.filter_map { @statuses[it.name] } }
|
|
137
|
+
|
|
138
|
+
# @return [OpenLanes::Apps::Status, nil] the process the Preview tab shows
|
|
139
|
+
def preview = statuses.find(&:preview)
|
|
140
|
+
|
|
141
|
+
# @param name [String]
|
|
142
|
+
# @return [Integer, nil] the pid of the process's group leader
|
|
143
|
+
def pid(name) = @running[name.to_s]&.child&.pid
|
|
144
|
+
|
|
145
|
+
# @param name [String]
|
|
146
|
+
# @return [Integer, nil]
|
|
147
|
+
def port(name) = @ports[name.to_s]
|
|
148
|
+
|
|
149
|
+
# The last lines a process printed, kept after it is stopped. A failed
|
|
150
|
+
# start stops everything before it raises, and M1 wants the failing
|
|
151
|
+
# process's last 20 lines in the rejection, which {Status#reason}
|
|
152
|
+
# (five lines, for the Preview tab) is too short to carry.
|
|
153
|
+
#
|
|
154
|
+
# @param name [String]
|
|
155
|
+
# @param lines [Integer] at most {TAIL}
|
|
156
|
+
# @return [Array<String>] oldest first; empty for a process never started
|
|
157
|
+
def log_tail(name, lines = TAIL) = @mutex.synchronize { Array(@tails[name.to_s]).last(lines) }
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# @return [void]
|
|
162
|
+
# @raise [StartFailed]
|
|
163
|
+
def launch
|
|
164
|
+
@ports = definition.processes.to_h { [it.name, @registry.reserve(root: @root, plan:, process: it.name)] }
|
|
165
|
+
@env = definition.processes.to_h { [it.port_env, @ports.fetch(it.name).to_s] }
|
|
166
|
+
definition.processes.each do |spec|
|
|
167
|
+
next if bring_up(spec, "starting")
|
|
168
|
+
|
|
169
|
+
reason = @statuses.fetch(spec.name).reason
|
|
170
|
+
stop
|
|
171
|
+
raise StartFailed.new(reason, process: spec.name)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Spawn, then wait until it is ready, it exits, or time runs out.
|
|
176
|
+
#
|
|
177
|
+
# @param spec [OpenLanes::Apps::ProcessSpec]
|
|
178
|
+
# @param state [String, nil] what to say while it comes up; nil when
|
|
179
|
+
# that was said already, as a restart does before it stops the old one
|
|
180
|
+
# @return [Boolean] ready
|
|
181
|
+
def bring_up(spec, state)
|
|
182
|
+
publish(spec, state) if state
|
|
183
|
+
running = spawn(spec)
|
|
184
|
+
outcome = await(spec, running)
|
|
185
|
+
return publish(spec, "failed", reason: reason(spec, running, outcome)).then { false } unless outcome == :ready
|
|
186
|
+
|
|
187
|
+
publish(spec, "ready", framing: (@framing.check(url(spec), origin: @origin) if spec.preview?))
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [Running]
|
|
192
|
+
def spawn(spec)
|
|
193
|
+
child = Execution::Child.spawn(["sh", "-c", spec.start], chdir: @root, env: @env)
|
|
194
|
+
tail = []
|
|
195
|
+
@mutex.synchronize { @tails[spec.name] = tail }
|
|
196
|
+
reader = Thread.new { read(spec.name, child, tail) }
|
|
197
|
+
waiter = Thread.new { child.wait }
|
|
198
|
+
@running[spec.name] = Running.new(spec, child, waiter, reader, tail)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [void]
|
|
202
|
+
def read(name, child, tail)
|
|
203
|
+
pending = +""
|
|
204
|
+
child.each_chunk do |text|
|
|
205
|
+
pending << text
|
|
206
|
+
while (line = pending.slice!(/\A[^\n]*\n/))
|
|
207
|
+
line = line.chomp
|
|
208
|
+
@mutex.synchronize do
|
|
209
|
+
tail.push(line)
|
|
210
|
+
tail.shift while tail.size > TAIL
|
|
211
|
+
end
|
|
212
|
+
@output&.call(name, line)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @return [Symbol] :ready, :exited or :timeout
|
|
218
|
+
def await(spec, running)
|
|
219
|
+
deadline = UI.monotonic + @ready_timeout
|
|
220
|
+
loop do
|
|
221
|
+
return :exited unless running.waiter.alive?
|
|
222
|
+
return :ready if @probe.ready?(@ports.fetch(spec.name), path: spec.ready)
|
|
223
|
+
return :timeout if UI.monotonic >= deadline
|
|
224
|
+
|
|
225
|
+
sleep @poll
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @return [String]
|
|
230
|
+
def reason(spec, running, outcome)
|
|
231
|
+
port = @ports.fetch(spec.name)
|
|
232
|
+
said = if outcome == :exited
|
|
233
|
+
running.reader.join(1)
|
|
234
|
+
"#{spec.name} exited with status #{running.waiter.value&.exitstatus.inspect} before it was ready"
|
|
235
|
+
else
|
|
236
|
+
halt(spec.name, quietly: true)
|
|
237
|
+
what = spec.ready ? "2xx or 3xx on #{spec.ready}" : "there"
|
|
238
|
+
"#{spec.name} was given port #{port} and nothing answered #{what} within #{@ready_timeout} s"
|
|
239
|
+
end
|
|
240
|
+
lines = @mutex.synchronize { running.tail.last(5) }
|
|
241
|
+
lines.empty? ? said : "#{said}; it said:\n#{lines.join("\n")}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# TERM the process's group, KILL what is left after the grace, and
|
|
245
|
+
# wait for the leader.
|
|
246
|
+
#
|
|
247
|
+
# @return [void]
|
|
248
|
+
def halt(name, quietly: false)
|
|
249
|
+
running = @running.delete(name) or return
|
|
250
|
+
running.child.terminate
|
|
251
|
+
running.waiter.join
|
|
252
|
+
running.reader.join(Execution::Child::DRAIN_GRACE)
|
|
253
|
+
# A process that failed stays failed: its reason is what a person needs.
|
|
254
|
+
publish(running.spec, "stopped") unless quietly || @statuses[name]&.failed?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @return [String] what the tab opens: the app's root, not its health check
|
|
258
|
+
def url(spec) = "http://#{Status::HOST}:#{@ports.fetch(spec.name)}/"
|
|
259
|
+
|
|
260
|
+
# @return [OpenLanes::Apps::Status]
|
|
261
|
+
def publish(spec, state, framing: nil, reason: nil)
|
|
262
|
+
status = Status.new(plan:,
|
|
263
|
+
branch: @branch,
|
|
264
|
+
process: spec.name,
|
|
265
|
+
state:,
|
|
266
|
+
port: @ports.fetch(spec.name),
|
|
267
|
+
preview: spec.preview?,
|
|
268
|
+
url: url(spec),
|
|
269
|
+
framing:,
|
|
270
|
+
reason:)
|
|
271
|
+
@mutex.synchronize { @statuses[spec.name] = status }
|
|
272
|
+
@publisher&.call(status)
|
|
273
|
+
status
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# Which processes' `restart_on` files changed since it last looked
|
|
6
|
+
# (plan 012, decision 5).
|
|
7
|
+
#
|
|
8
|
+
# Polling, not a file-event gem: one more dependency for a check that
|
|
9
|
+
# runs once a second over a handful of globs is not worth its native
|
|
10
|
+
# extension, and a poll is something a spec can drive tick by tick.
|
|
11
|
+
#
|
|
12
|
+
# A file counts as changed when it appears, disappears, or its `mtime`
|
|
13
|
+
# or size moves. Globs are Ruby's `Dir.glob`, relative to the checkout,
|
|
14
|
+
# with `**/` recursive and `{a,b}` alternatives; a dotfile matches only
|
|
15
|
+
# a pattern that names the dot.
|
|
16
|
+
class Watcher
|
|
17
|
+
# @param root [String] the checkout the globs are relative to
|
|
18
|
+
# @param definition [OpenLanes::Apps::Definition]
|
|
19
|
+
def initialize(root:, definition:)
|
|
20
|
+
@root = root
|
|
21
|
+
@watched = definition.processes.reject { it.restart_on.empty? }
|
|
22
|
+
@seen = snapshot
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<String>] names of processes with a changed file, in
|
|
26
|
+
# declared order; empty on a quiet tick
|
|
27
|
+
def changed
|
|
28
|
+
now = snapshot
|
|
29
|
+
names = @watched.map(&:name).reject { now[it] == @seen[it] }
|
|
30
|
+
@seen = now
|
|
31
|
+
names
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# @return [Hash{String => Hash{String => Array}}] process name to path to mtime and size
|
|
37
|
+
def snapshot
|
|
38
|
+
@watched.to_h do |spec|
|
|
39
|
+
files = Dir.glob(spec.restart_on, File::FNM_EXTGLOB, base: @root).sort.to_h do |path|
|
|
40
|
+
stat = File.stat(File.join(@root, path))
|
|
41
|
+
[path, [stat.mtime.to_r, stat.size]]
|
|
42
|
+
rescue SystemCallError
|
|
43
|
+
[path, nil]
|
|
44
|
+
end
|
|
45
|
+
[spec.name, files]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|