openlanes 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
data/lib/openlanes/web/app.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "ipaddr"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "rack/utils"
|
|
6
6
|
require "sinatra/base"
|
|
7
|
+
require "newrelic_rpm"
|
|
8
|
+
require 'newrelic/infinite_tracing'
|
|
7
9
|
|
|
8
10
|
module OpenLanes
|
|
9
11
|
module Web
|
|
@@ -11,13 +13,9 @@ module OpenLanes
|
|
|
11
13
|
# {.serving}, carries that server's {Context} as a setting, so two apps in
|
|
12
14
|
# one process (the suite) never share a token or a store.
|
|
13
15
|
class App < Sinatra::Base
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
PLAN_FILES = %w[spec.md plan.md blocked.md research.md design.md].freeze
|
|
18
|
-
|
|
19
|
-
# Host headers the server answers. Loopback only, so a page on another
|
|
20
|
-
# site cannot reach it through DNS rebinding.
|
|
16
|
+
# Host headers the server always answers: loopback, so a page on another
|
|
17
|
+
# site cannot reach it through DNS rebinding. A server listening beyond
|
|
18
|
+
# this machine adds its own addresses and name ({Listen#permitted_hosts}).
|
|
21
19
|
LOCAL_HOSTS = ["localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
|
|
22
20
|
|
|
23
21
|
# What `/` says when the page was never built.
|
|
@@ -50,7 +48,9 @@ module OpenLanes
|
|
|
50
48
|
# @return [Class] a subclass serving that context
|
|
51
49
|
def serving(context)
|
|
52
50
|
Class.new(self) do
|
|
51
|
+
use AdminAuth, credentials: context.credentials if context.credentials
|
|
53
52
|
set :context, context
|
|
53
|
+
set :host_authorization, { permitted_hosts: LOCAL_HOSTS + context.listen.permitted_hosts }
|
|
54
54
|
set :public_folder, context.dist
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -79,6 +79,19 @@ module OpenLanes
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# @return [OpenLanes::Web::PlanFiles]
|
|
83
|
+
def plan_files = PlanFiles.new(plans_dir: context.plans_dir)
|
|
84
|
+
|
|
85
|
+
# The rule the page draws cars by: this machine's board lists the
|
|
86
|
+
# plan with at least one car. No snapshot means no engine, so no car.
|
|
87
|
+
#
|
|
88
|
+
# @param ordinal [String] e.g. "017.00"
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def live_car?(ordinal)
|
|
91
|
+
plans = Live.board(store:, keys: context.keys).dig(:me, "plans") || []
|
|
92
|
+
plans.any? { it["ordinal"].to_s == ordinal && Array(it["cars"]).any? }
|
|
93
|
+
end
|
|
94
|
+
|
|
82
95
|
# @return [OpenLanes::Live::Chat]
|
|
83
96
|
def chat = context.chat || halt(404, json(error: "this server has no chat"))
|
|
84
97
|
|
|
@@ -137,6 +150,26 @@ module OpenLanes
|
|
|
137
150
|
cache_control :no_store
|
|
138
151
|
end
|
|
139
152
|
|
|
153
|
+
error PlanFiles::NotFound do
|
|
154
|
+
status 404
|
|
155
|
+
json(error: env["sinatra.error"].message)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
error PlanFiles::ReadOnly do
|
|
159
|
+
status 403
|
|
160
|
+
json(error: env["sinatra.error"].message)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
error PlanFiles::Stale do
|
|
164
|
+
status 412
|
|
165
|
+
json(error: env["sinatra.error"].message)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
error PlanFiles::FrontmatterChanged do
|
|
169
|
+
status 422
|
|
170
|
+
json(error: env["sinatra.error"].message)
|
|
171
|
+
end
|
|
172
|
+
|
|
140
173
|
error Live::Unavailable do
|
|
141
174
|
status 503
|
|
142
175
|
json(error: env["sinatra.error"].message)
|
|
@@ -175,6 +208,47 @@ module OpenLanes
|
|
|
175
208
|
json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
|
|
176
209
|
end
|
|
177
210
|
|
|
211
|
+
# The Preview tab's toolbar (plan 012): Restart backend, a named
|
|
212
|
+
# restart, or Stop, for the `openlanes app start` running that plan's
|
|
213
|
+
# app. Queued on the app runner's stream, not the engine's: the engine
|
|
214
|
+
# never runs an app, so it would never read them.
|
|
215
|
+
post "/api/apps/commands" do
|
|
216
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
217
|
+
|
|
218
|
+
data = body_json
|
|
219
|
+
plan = Plans::Ordinal.parse(data["plan"].to_s)&.to_s or halt 422, json(error: "plan must be a plan number like 012.00")
|
|
220
|
+
action = data["action"].to_s
|
|
221
|
+
halt 422, json(error: "action must be one of #{Apps::Runner::ACTIONS.join(", ")}") unless Apps::Runner::ACTIONS.include?(action)
|
|
222
|
+
target = data["target"]
|
|
223
|
+
unless target.nil? || target.to_s.match?(Apps::Definition::NAME)
|
|
224
|
+
halt 422, json(error: "target must be backend or a process name")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
command = { plan:, action:, target: }
|
|
228
|
+
Live::EventBus.for(store).append(context.keys.app_commands, { json: JSON.generate(command) }, maxlen: Apps::Runner::COMMANDS_LENGTH)
|
|
229
|
+
status 202
|
|
230
|
+
json(command)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The interview a new idea gets before its spec is written (M6). No
|
|
234
|
+
# interviewer, or one that failed, is not an error: the page gets no
|
|
235
|
+
# questions and a reason, and creates the plan as it always has.
|
|
236
|
+
post "/api/interview" do
|
|
237
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
238
|
+
|
|
239
|
+
idea = body_json["idea"].to_s.strip
|
|
240
|
+
halt 422, json(error: "interview needs an idea") if idea.empty?
|
|
241
|
+
|
|
242
|
+
interviewer = Live.interviewer
|
|
243
|
+
next json(questions: [], reason: "no interviewer: OPENROUTER_API_KEY is not set") unless interviewer
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
json(questions: interviewer.call(idea:).map(&:wire))
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
json(questions: [], reason: "the interviewer failed: #{e.message.lines.first.to_s.strip}")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
178
252
|
get "/api/copilot" do
|
|
179
253
|
json(copilot.history)
|
|
180
254
|
end
|
|
@@ -182,12 +256,13 @@ module OpenLanes
|
|
|
182
256
|
# The person's message to the copilot (W17). It is stored and accepted
|
|
183
257
|
# at once; the answer is written afterwards, apart from this request,
|
|
184
258
|
# and comes on the event stream as `event: copilot`, like a command's
|
|
185
|
-
# replies do. The answer is c3po-operator's ({Context#copilot})
|
|
259
|
+
# replies do. The answer is c3po-operator's ({Context#copilot}), except
|
|
260
|
+
# for a slash command (plan 020.00), which the engine answers.
|
|
186
261
|
post "/api/copilot" do
|
|
187
262
|
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
188
263
|
|
|
189
264
|
turn = copilot.say(request.body.read)
|
|
190
|
-
answer_later(turn)
|
|
265
|
+
answer_later(turn) unless turn.slash?
|
|
191
266
|
status 202
|
|
192
267
|
json(turn.wire)
|
|
193
268
|
rescue Live::Copilot::Invalid => e
|
|
@@ -245,15 +320,39 @@ module OpenLanes
|
|
|
245
320
|
status 204
|
|
246
321
|
end
|
|
247
322
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
323
|
+
# The plan files (plan 017). The page may read six names and write
|
|
324
|
+
# two; {PlanFiles} holds both lists and every guard but the car, so a
|
|
325
|
+
# name is never joined onto a path as typed.
|
|
326
|
+
get "/api/plans/:ordinal/files" do
|
|
327
|
+
json(files: plan_files.list(params[:ordinal]))
|
|
328
|
+
end
|
|
254
329
|
|
|
330
|
+
get "/api/plans/:ordinal/files/:name" do
|
|
331
|
+
file = plan_files.read(params[:ordinal], params[:name])
|
|
332
|
+
headers "ETag" => file.etag
|
|
255
333
|
content_type "text/markdown", charset: "utf-8"
|
|
256
|
-
|
|
334
|
+
file.body
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# A whole file, saved from the popup. The car is checked here rather
|
|
338
|
+
# than in {PlanFiles}, since the board is this server's to read: an
|
|
339
|
+
# agent working the plan would overwrite the save, or be overwritten.
|
|
340
|
+
put "/api/plans/:ordinal/files/:name" do
|
|
341
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
342
|
+
|
|
343
|
+
ordinal, name = params.values_at(:ordinal, :name)
|
|
344
|
+
plan_files.read(ordinal, name)
|
|
345
|
+
halt 403, json(error: "#{name} is read only") unless PlanFiles::EDITABLE.include?(name)
|
|
346
|
+
|
|
347
|
+
if_match = request.env["HTTP_IF_MATCH"].to_s.strip
|
|
348
|
+
halt 428, json(error: "If-Match is required; send the ETag from GET") if if_match.empty?
|
|
349
|
+
|
|
350
|
+
number = Plans::Ordinal.parse(ordinal).to_s
|
|
351
|
+
halt 409, json(error: "#{number} has an agent working on it; wait until it stops") if live_car?(number)
|
|
352
|
+
|
|
353
|
+
tag = plan_files.write(ordinal, name, body: request.body.read, if_match:)
|
|
354
|
+
headers "ETag" => tag
|
|
355
|
+
json(etag: tag)
|
|
257
356
|
end
|
|
258
357
|
|
|
259
358
|
get "/api/*" do
|
|
@@ -5,8 +5,8 @@ require "concurrent/atomic/atomic_boolean"
|
|
|
5
5
|
module OpenLanes
|
|
6
6
|
module Web
|
|
7
7
|
# Everything one API server knows, fixed when it starts: where Redis is,
|
|
8
|
-
# whose keys it reads, the token every write must carry, and how
|
|
9
|
-
#
|
|
8
|
+
# whose keys it reads, the token every write must carry, and how long
|
|
9
|
+
# an event stream sleeps when nobody nudges it.
|
|
10
10
|
#
|
|
11
11
|
# @!attribute [r] stores
|
|
12
12
|
# @return [OpenLanes::Web::Stores] a store per request thread
|
|
@@ -23,10 +23,9 @@ module OpenLanes
|
|
|
23
23
|
# @!attribute [r] node
|
|
24
24
|
# @return [OpenLanes::Live::Node, nil] this machine, for `/api/session`
|
|
25
25
|
# while no engine has published a snapshot
|
|
26
|
-
# @!attribute [r] tick
|
|
27
|
-
# @return [Float] seconds between the event stream's looks (contract: 250 ms)
|
|
28
26
|
# @!attribute [r] ping_every
|
|
29
|
-
# @return [Float] seconds between `: ping` comments
|
|
27
|
+
# @return [Float] seconds between `: ping` comments, and the longest an
|
|
28
|
+
# event stream waits for a nudge before it looks anyway
|
|
30
29
|
# @!attribute [r] lifetime
|
|
31
30
|
# @return [Float, nil] seconds an event stream lives; nil for as long as
|
|
32
31
|
# the browser holds it. The suite sets one so a stream ends.
|
|
@@ -43,9 +42,15 @@ module OpenLanes
|
|
|
43
42
|
# @!attribute [r] chat
|
|
44
43
|
# @return [OpenLanes::Live::Chat, nil] the chat between machines (plan 006),
|
|
45
44
|
# relayed to this server's pages; nil where nothing serves it
|
|
45
|
+
# @!attribute [r] listen
|
|
46
|
+
# @return [OpenLanes::Web::Listen] the addresses served, which decide
|
|
47
|
+
# the Host headers answered
|
|
48
|
+
# @!attribute [r] credentials
|
|
49
|
+
# @return [OpenLanes::Web::AdminAuth::Credentials, nil] the login asked
|
|
50
|
+
# of every request from off this machine; nil asks for none
|
|
46
51
|
# @!attribute [r] stopping
|
|
47
52
|
# @return [Concurrent::AtomicBoolean] set when the server shuts down, so
|
|
48
|
-
# open streams end
|
|
53
|
+
# open streams end on the `stop` nudge that follows
|
|
49
54
|
Context = Data.define(:stores,
|
|
50
55
|
:keys,
|
|
51
56
|
:plans_dir,
|
|
@@ -53,16 +58,17 @@ module OpenLanes
|
|
|
53
58
|
:csrf,
|
|
54
59
|
:dist,
|
|
55
60
|
:node,
|
|
56
|
-
:tick,
|
|
57
61
|
:ping_every,
|
|
58
62
|
:lifetime,
|
|
59
63
|
:demo,
|
|
60
64
|
:copilot,
|
|
61
65
|
:background,
|
|
62
66
|
:chat,
|
|
67
|
+
:listen,
|
|
68
|
+
:credentials,
|
|
63
69
|
:stopping) do
|
|
64
|
-
def initialize(dist: DIST, node: nil,
|
|
65
|
-
background: Context::THREAD, stopping: Concurrent::AtomicBoolean.new, **rest)
|
|
70
|
+
def initialize(dist: DIST, node: nil, ping_every: 15.0, lifetime: nil, demo: nil, copilot: nil, chat: nil,
|
|
71
|
+
background: Context::THREAD, listen: Listen.parse(nil), credentials: nil, stopping: Concurrent::AtomicBoolean.new, **rest)
|
|
66
72
|
super
|
|
67
73
|
end
|
|
68
74
|
|
data/lib/openlanes/web/demo.rb
CHANGED
|
@@ -31,13 +31,16 @@ module OpenLanes
|
|
|
31
31
|
|
|
32
32
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
33
33
|
# @param keys [OpenLanes::Live::Keys] this node's
|
|
34
|
-
# @param board [OpenLanes::Web::DemoBoard]
|
|
34
|
+
# @param board [OpenLanes::Web::DemoBoard, nil] the fixture's, by default
|
|
35
|
+
# @param node [OpenLanes::Live::Node, nil] a declared identity
|
|
36
|
+
# (`--identity`), which the board, and every reset of it, is published as
|
|
35
37
|
# @param interval [Float]
|
|
36
38
|
# @param clock [Proc] wall time
|
|
37
|
-
def initialize(store:, keys:, board:
|
|
39
|
+
def initialize(store:, keys:, board: nil, node: nil, interval: INTERVAL, clock: -> { Time.now })
|
|
38
40
|
@store = store
|
|
39
41
|
@keys = keys
|
|
40
|
-
@
|
|
42
|
+
@node = node
|
|
43
|
+
@board = board || DemoBoard.new(node:)
|
|
41
44
|
@interval = interval
|
|
42
45
|
@clock = clock
|
|
43
46
|
@seqs = {}
|
|
@@ -53,7 +56,7 @@ module OpenLanes
|
|
|
53
56
|
# same board whatever the last one answered or stopped.
|
|
54
57
|
#
|
|
55
58
|
# @return [void]
|
|
56
|
-
def reset = @mutex.synchronize { @board = DemoBoard.new }
|
|
59
|
+
def reset = @mutex.synchronize { @board = DemoBoard.new(node: @node) }
|
|
57
60
|
|
|
58
61
|
# Publish and answer every {INTERVAL}, on a thread of its own.
|
|
59
62
|
#
|
|
@@ -75,8 +78,9 @@ module OpenLanes
|
|
|
75
78
|
def stop
|
|
76
79
|
@running = false
|
|
77
80
|
@thread&.join(@interval * 2)
|
|
78
|
-
@
|
|
79
|
-
@store.
|
|
81
|
+
names = [@keys.node, other].compact
|
|
82
|
+
@store.del(*names.map { @keys.snapshot(it) })
|
|
83
|
+
@store.hdel(@keys.nodes, *names)
|
|
80
84
|
rescue Live::Unavailable
|
|
81
85
|
nil
|
|
82
86
|
end
|
|
@@ -91,6 +95,9 @@ module OpenLanes
|
|
|
91
95
|
@board.advance(@clock.call)
|
|
92
96
|
publish(@keys.node, @board.me)
|
|
93
97
|
@board.others.each { publish(other, it) }
|
|
98
|
+
# The demo's board moves on every tick, so every tick is news, and
|
|
99
|
+
# the one nudge covers this tick's replies as well.
|
|
100
|
+
Live::News.publish(store: @store, keys: @keys, kind: "board")
|
|
94
101
|
replies
|
|
95
102
|
end
|
|
96
103
|
rescue Live::Unavailable => e
|
|
@@ -100,16 +107,19 @@ module OpenLanes
|
|
|
100
107
|
|
|
101
108
|
private
|
|
102
109
|
|
|
103
|
-
# @return [String] the pretend other machine's node name
|
|
104
|
-
def other =
|
|
110
|
+
# @return [String, nil] the pretend other machine's node name, when this demo has one
|
|
111
|
+
def other = @board.others.first&.dig("node", "id")&.then { "#{it}:#{OTHER_CHECKOUT}" }
|
|
105
112
|
|
|
106
113
|
# @return [void]
|
|
107
114
|
def publish(name, snapshot)
|
|
108
115
|
snapshot["seq"] = next_seq(name)
|
|
116
|
+
# The fixture names a Redis for the page to show; the demo says the
|
|
117
|
+
# one it is really publishing through, as the engine does.
|
|
118
|
+
snapshot["redis_url"] = Live.redact(@store.url)
|
|
109
119
|
json = JSON.generate(snapshot)
|
|
110
|
-
@store.
|
|
120
|
+
Live::EventBus.for(@store).transaction do |tx|
|
|
111
121
|
tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
|
|
112
|
-
tx.
|
|
122
|
+
tx.append(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
|
|
113
123
|
tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
|
|
114
124
|
end
|
|
115
125
|
end
|
|
@@ -141,9 +151,14 @@ module OpenLanes
|
|
|
141
151
|
command = Live::Command.parse(entry.fields["json"].to_s)
|
|
142
152
|
first = @store.hget(@keys.done, command.id)
|
|
143
153
|
reply = first ? parse(first) : outcome(command)
|
|
144
|
-
|
|
154
|
+
# A slash command is answered in the conversation too, or the page's
|
|
155
|
+
# copilot waits for an engine reply that never comes. Once only: a
|
|
156
|
+
# redelivery has its turn already.
|
|
157
|
+
turn = Live::Commands.echo_turn(reply) if !first && command.payload["source"] == "copilot"
|
|
158
|
+
Live::EventBus.for(@store).transaction do |tx|
|
|
145
159
|
tx.hset(@keys.done, reply.id, reply.to_json) unless first
|
|
146
|
-
tx.
|
|
160
|
+
tx.append(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
|
|
161
|
+
tx.append(@keys.copilot, { json: turn.to_json }, maxlen: Live::Keys::COPILOT_LENGTH) if turn
|
|
147
162
|
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
148
163
|
end
|
|
149
164
|
reply
|
|
@@ -162,8 +177,8 @@ module OpenLanes
|
|
|
162
177
|
# @return [OpenLanes::Live::Reply]
|
|
163
178
|
def refuse(entry, reason)
|
|
164
179
|
reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
|
|
165
|
-
@store.
|
|
166
|
-
tx.
|
|
180
|
+
Live::EventBus.for(@store).transaction do |tx|
|
|
181
|
+
tx.append(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
|
|
167
182
|
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
168
183
|
end
|
|
169
184
|
reply
|
|
@@ -10,6 +10,10 @@ module OpenLanes
|
|
|
10
10
|
#
|
|
11
11
|
# Each command kind is one method, named as {Live::Commands} names its
|
|
12
12
|
# handlers, raising {Refusal} with the reason the engine would give.
|
|
13
|
+
#
|
|
14
|
+
# The fixture carries one plan hansolo approved and one whose pull
|
|
15
|
+
# request merged, each with `finished` set and no cars, so the demo
|
|
16
|
+
# shows the half-height rows a real run keeps until it restarts.
|
|
13
17
|
class DemoBoard
|
|
14
18
|
# A command the demo will not apply.
|
|
15
19
|
class Refusal < StandardError; end
|
|
@@ -35,12 +39,20 @@ module OpenLanes
|
|
|
35
39
|
"stop" => { "phase" => "stopped", "state" => "done" }
|
|
36
40
|
}.freeze
|
|
37
41
|
|
|
42
|
+
# The fields of a snapshot's `node` a declared identity replaces.
|
|
43
|
+
IDENTITY = %w[id user host ip checkout].freeze
|
|
44
|
+
|
|
38
45
|
# @param fixture [String] path to a `Board` JSON
|
|
39
46
|
# @param random [Random]
|
|
40
|
-
|
|
47
|
+
# @param node [OpenLanes::Live::Node, nil] a declared identity
|
|
48
|
+
# (`--identity`): this board is then that machine's, and the pretend
|
|
49
|
+
# other machine is left to the demo that has none, so two demos on
|
|
50
|
+
# one Redis never publish the same node twice
|
|
51
|
+
def initialize(fixture: FIXTURE, random: Random.new(4747), node: nil)
|
|
41
52
|
board = JSON.parse(File.read(fixture))
|
|
42
53
|
@me = board.fetch("me")
|
|
43
|
-
@others = board.fetch("others")
|
|
54
|
+
@others = node ? [] : board.fetch("others")
|
|
55
|
+
@me["node"].merge!(node.wire(heartbeat_at: Time.now).transform_keys(&:to_s).slice(*IDENTITY)) if node
|
|
44
56
|
@random = random
|
|
45
57
|
@ticks = 0
|
|
46
58
|
end
|
|
@@ -64,7 +76,12 @@ module OpenLanes
|
|
|
64
76
|
# @return [String, nil] a reason to show beside "done"
|
|
65
77
|
# @raise [Refusal]
|
|
66
78
|
def apply(command)
|
|
67
|
-
|
|
79
|
+
handler = Live::Commands::HANDLERS.fetch(command.kind)
|
|
80
|
+
# A kind the engine gained and the demo did not would otherwise raise
|
|
81
|
+
# NoMethodError on the demo's thread, and the board would freeze.
|
|
82
|
+
raise Refusal, "the demo cannot #{command.kind}" unless respond_to?(handler, true)
|
|
83
|
+
|
|
84
|
+
send(handler, command)
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
private
|
|
@@ -227,6 +244,46 @@ module OpenLanes
|
|
|
227
244
|
entry["id"]
|
|
228
245
|
end
|
|
229
246
|
|
|
247
|
+
# ---- lanes (plan 020.00) ----------------------------------------------
|
|
248
|
+
|
|
249
|
+
# `/stop NNN`: every running agent on the plan stops. The plan stays on
|
|
250
|
+
# the board, stopped, so `/restart` has something to bring back.
|
|
251
|
+
#
|
|
252
|
+
# @return [String]
|
|
253
|
+
def stop_plan(command)
|
|
254
|
+
cars = plan!(command)["cars"].select { it["state"] == "running" }
|
|
255
|
+
cars.each { it.merge!(SIGNALS.fetch("stop"), "held" => false) }
|
|
256
|
+
"stopped #{cars.size} agent#{"s" unless cars.size == 1} on #{command.plan} and took it off the board"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# `/restart NNN`: the plan's stopped cars run again, unless one still does.
|
|
260
|
+
#
|
|
261
|
+
# @return [String]
|
|
262
|
+
def restart_plan(command)
|
|
263
|
+
plan = plan!(command)
|
|
264
|
+
working = plan["cars"].select { it["state"] == "running" }.map { it["agent"] }
|
|
265
|
+
raise Refusal, "#{command.plan} already has an agent: #{working.join(", ")}" unless working.empty?
|
|
266
|
+
|
|
267
|
+
# A stuck car waits on a question, and a restart answers none.
|
|
268
|
+
plan["cars"].select { it["state"] == "done" }.each { it.merge!("state" => "running", "phase" => "calm", "held" => false) }
|
|
269
|
+
"#{command.plan} restarts at the next tick"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# `/concurrency N`, worded as the engine words it. Lowering it stops
|
|
273
|
+
# nobody, as in the engine.
|
|
274
|
+
#
|
|
275
|
+
# @return [String]
|
|
276
|
+
def set_concurrency(command) # rubocop:disable Naming/AccessorMethodName -- the engine's handler name
|
|
277
|
+
limit = command.payload["limit"]
|
|
278
|
+
raise Refusal, "concurrency must be a whole number of at least 1, not #{limit.inspect}" unless limit.is_a?(Integer) && limit >= 1
|
|
279
|
+
|
|
280
|
+
before = @me["run"]["jobs"]
|
|
281
|
+
@me["run"]["jobs"] = limit
|
|
282
|
+
over = running(@me).size - limit
|
|
283
|
+
wait = over.positive? ? "; the run waits for #{over} agent#{"s" unless over == 1} to finish" : ""
|
|
284
|
+
"concurrency #{before} → #{limit}#{wait}"
|
|
285
|
+
end
|
|
286
|
+
|
|
230
287
|
# ---- plans ------------------------------------------------------------
|
|
231
288
|
|
|
232
289
|
# @return [nil]
|
|
@@ -245,7 +302,7 @@ module OpenLanes
|
|
|
245
302
|
title = command["idea"].lines.first.to_s.strip.delete_prefix("#").strip
|
|
246
303
|
@me["plans"] << plan_from({ "ordinal" => ordinal,
|
|
247
304
|
"title" => title,
|
|
248
|
-
"status" => { "key" => "new", "emoji" => "
|
|
305
|
+
"status" => { "key" => "new", "emoji" => "⚪", "label" => "New" },
|
|
249
306
|
"lane" => "full" })
|
|
250
307
|
"created #{ordinal} #{slug(title)}"
|
|
251
308
|
end
|
|
@@ -264,7 +321,8 @@ module OpenLanes
|
|
|
264
321
|
"pr" => nil,
|
|
265
322
|
"questions" => [],
|
|
266
323
|
"interrupted" => nil,
|
|
267
|
-
"claimed_by" => @me.dig("node", "id")
|
|
324
|
+
"claimed_by" => @me.dig("node", "id"),
|
|
325
|
+
"finished" => nil)
|
|
268
326
|
end
|
|
269
327
|
|
|
270
328
|
# @return [String]
|
data/lib/openlanes/web/events.rb
CHANGED
|
@@ -10,9 +10,15 @@ module OpenLanes
|
|
|
10
10
|
# conversation with the copilot (W17), and a `: ping` comment so proxies and the
|
|
11
11
|
# browser keep the connection open.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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.
|
|
16
22
|
#
|
|
17
23
|
# A reply carries its stream id as the event's `id:`, so a browser that
|
|
18
24
|
# reconnects sends `Last-Event-ID` and gets what it missed. The stream
|
|
@@ -37,15 +43,31 @@ module OpenLanes
|
|
|
37
43
|
# the boundary and the stream everything after. A turn written between a
|
|
38
44
|
# read the page started on its own and the stream opening fell in the
|
|
39
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.
|
|
40
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
|
+
|
|
41
62
|
# @param context [OpenLanes::Web::Context]
|
|
42
63
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
43
64
|
# @param last_id [String, nil] the `Last-Event-ID` the browser sent
|
|
44
65
|
# @param page [String, nil] the page's own id, `?page=` on the stream, for its chat
|
|
45
66
|
# @param clock [Proc] seconds, monotonic
|
|
46
|
-
# @param
|
|
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
|
|
47
69
|
def initialize(context:, store:, last_id: nil, page: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
48
|
-
|
|
70
|
+
waiter: WAIT)
|
|
49
71
|
@context = context
|
|
50
72
|
@store = store
|
|
51
73
|
@keys = context.keys
|
|
@@ -54,7 +76,7 @@ module OpenLanes
|
|
|
54
76
|
@chat = context.chat
|
|
55
77
|
@page = page.to_s.match?(Live::Chat::PAGE) ? page : nil
|
|
56
78
|
@clock = clock
|
|
57
|
-
@
|
|
79
|
+
@waiter = waiter
|
|
58
80
|
end
|
|
59
81
|
|
|
60
82
|
# Write events to +out+ until the browser leaves, the server stops, or
|
|
@@ -64,6 +86,7 @@ module OpenLanes
|
|
|
64
86
|
# @return [void]
|
|
65
87
|
def run(out)
|
|
66
88
|
@chat&.attach(@page) if @page
|
|
89
|
+
news = subscribe
|
|
67
90
|
started = @clock.call
|
|
68
91
|
pinged = started
|
|
69
92
|
cursor = @last_id || newest_reply
|
|
@@ -73,20 +96,23 @@ module OpenLanes
|
|
|
73
96
|
# The page is attached, so every chat event from here on is queued for
|
|
74
97
|
# it: a `GET /api/chat` the page starts on this misses nothing.
|
|
75
98
|
out << self.class.frame("chat_cursor", {}) if @chat && @page && !out.closed?
|
|
99
|
+
apps = open_apps(out)
|
|
76
100
|
seqs = nil
|
|
77
101
|
until over?(out, started)
|
|
78
102
|
seqs = board(out, seqs)
|
|
103
|
+
apps = app_news(out, apps)
|
|
79
104
|
cursor = replies(out, cursor)
|
|
80
105
|
said = copilot(out, said)
|
|
81
106
|
chat(out)
|
|
82
107
|
pinged = ping(out) if @clock.call - pinged >= @context.ping_every
|
|
83
|
-
|
|
108
|
+
wait(news, started, pinged)
|
|
84
109
|
end
|
|
85
110
|
rescue Live::Unavailable => e
|
|
86
111
|
# Ending the stream is the right answer: the page reconnects with
|
|
87
112
|
# backoff and shows itself as reconnecting meanwhile.
|
|
88
113
|
out << ": #{e.message.tr("\n", " ")}\n\n"
|
|
89
114
|
ensure
|
|
115
|
+
news&.close
|
|
90
116
|
@chat&.detach(@page) if @page
|
|
91
117
|
end
|
|
92
118
|
|
|
@@ -98,11 +124,45 @@ module OpenLanes
|
|
|
98
124
|
|
|
99
125
|
private
|
|
100
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
|
+
|
|
101
141
|
# @return [Boolean]
|
|
102
142
|
def over?(out, started)
|
|
103
143
|
out.closed? || @context.stopping.true? || (@context.lifetime && @clock.call - started >= @context.lifetime)
|
|
104
144
|
end
|
|
105
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
|
+
|
|
106
166
|
# @return [Array] the seqs now, after sending the board if they changed
|
|
107
167
|
def board(out, seqs)
|
|
108
168
|
board = Live.board(store: @store, keys: @keys)
|
|
@@ -111,6 +171,25 @@ module OpenLanes
|
|
|
111
171
|
now
|
|
112
172
|
end
|
|
113
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
|
+
|
|
114
193
|
# @return [String] the last reply id sent
|
|
115
194
|
def replies(out, cursor)
|
|
116
195
|
Live.replies(store: @store, keys: @keys, after: cursor).each do |id, reply|
|