openlanes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
data/lib/openlanes/web/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
|
|
@@ -63,6 +63,58 @@ module OpenLanes
|
|
|
63
63
|
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
64
64
|
def store = context.store
|
|
65
65
|
|
|
66
|
+
# @return [OpenLanes::Live::Copilot]
|
|
67
|
+
def copilot = Live.copilot(store:, keys: context.keys, responder: context.copilot)
|
|
68
|
+
|
|
69
|
+
# Answer +turn+ apart from this request, on a store of its own,
|
|
70
|
+
# since a Redis connection is never shared between threads.
|
|
71
|
+
#
|
|
72
|
+
# @param turn [OpenLanes::Live::Copilot::Turn]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def answer_later(turn)
|
|
75
|
+
context.background.call do
|
|
76
|
+
Live.copilot(store: context.stores.current, keys: context.keys, responder: context.copilot).answer(turn)
|
|
77
|
+
ensure
|
|
78
|
+
context.stores.release
|
|
79
|
+
end
|
|
80
|
+
end
|
|
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
|
+
|
|
95
|
+
# @return [OpenLanes::Live::Chat]
|
|
96
|
+
def chat = context.chat || halt(404, json(error: "this server has no chat"))
|
|
97
|
+
|
|
98
|
+
# The request's JSON object, or a 422.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash]
|
|
101
|
+
def body_json
|
|
102
|
+
data = JSON.parse(request.body.read)
|
|
103
|
+
data.is_a?(Hash) ? data : halt(422, json(error: "the body is a JSON object"))
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
halt 422, json(error: "not JSON: #{e.message.lines.first.to_s.strip}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Another machine on the board, as `user@host`, so an invite names
|
|
109
|
+
# who it went to and is only sent to a machine that is there.
|
|
110
|
+
#
|
|
111
|
+
# @param id [String] a node id
|
|
112
|
+
# @return [String]
|
|
113
|
+
def machine_label(id)
|
|
114
|
+
node = Live.board(store:, keys: context.keys)[:others].map { it["node"] }.find { it["id"] == id }
|
|
115
|
+
node ? "#{node["user"]}@#{node["host"]}" : halt(422, json(error: "no machine #{id} is on this Redis now"))
|
|
116
|
+
end
|
|
117
|
+
|
|
66
118
|
# @param data [Object]
|
|
67
119
|
# @return [String]
|
|
68
120
|
def json(data)
|
|
@@ -98,6 +150,26 @@ module OpenLanes
|
|
|
98
150
|
cache_control :no_store
|
|
99
151
|
end
|
|
100
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
|
+
|
|
101
173
|
error Live::Unavailable do
|
|
102
174
|
status 503
|
|
103
175
|
json(error: env["sinatra.error"].message)
|
|
@@ -120,7 +192,7 @@ module OpenLanes
|
|
|
120
192
|
get "/api/events" do
|
|
121
193
|
content_type "text/event-stream"
|
|
122
194
|
headers "Cache-Control" => "no-cache, no-transform", "X-Accel-Buffering" => "no"
|
|
123
|
-
events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"])
|
|
195
|
+
events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"], page: params[:page])
|
|
124
196
|
stream { |out| events.run(out) }
|
|
125
197
|
end
|
|
126
198
|
|
|
@@ -136,6 +208,108 @@ module OpenLanes
|
|
|
136
208
|
json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
|
|
137
209
|
end
|
|
138
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
|
+
|
|
252
|
+
get "/api/copilot" do
|
|
253
|
+
json(copilot.history)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The person's message to the copilot (W17). It is stored and accepted
|
|
257
|
+
# at once; the answer is written afterwards, apart from this request,
|
|
258
|
+
# and comes on the event stream as `event: copilot`, like a command's
|
|
259
|
+
# replies do. The answer is c3po-operator's ({Context#copilot}), except
|
|
260
|
+
# for a slash command (plan 020.00), which the engine answers.
|
|
261
|
+
post "/api/copilot" do
|
|
262
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
263
|
+
|
|
264
|
+
turn = copilot.say(request.body.read)
|
|
265
|
+
answer_later(turn) unless turn.slash?
|
|
266
|
+
status 202
|
|
267
|
+
json(turn.wire)
|
|
268
|
+
rescue Live::Copilot::Invalid => e
|
|
269
|
+
status 422
|
|
270
|
+
json(error: e.message)
|
|
271
|
+
rescue Live::Copilot::Unanswered => e
|
|
272
|
+
status 503
|
|
273
|
+
json(error: e.message)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The chat between machines (plan 006): this page's chat, the invites
|
|
277
|
+
# open to this machine, and the ones this page waits on.
|
|
278
|
+
get "/api/chat" do
|
|
279
|
+
json(chat.state(page: params[:page]))
|
|
280
|
+
rescue Live::Chat::Invalid => e
|
|
281
|
+
status 422
|
|
282
|
+
json(error: e.message)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# What a page does in a chat. Everyone hears of it as `event: chat`
|
|
286
|
+
# on their own stream, this page included.
|
|
287
|
+
post "/api/chat/:action" do
|
|
288
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
289
|
+
|
|
290
|
+
data = body_json
|
|
291
|
+
page = data["page"]
|
|
292
|
+
machine = data["machine"].to_s
|
|
293
|
+
answer = case params[:action]
|
|
294
|
+
when "start" then chat.start_chat(page:, name: data["name"], machine:, label: machine_label(machine))
|
|
295
|
+
when "invite" then chat.invite(page:, machine:, label: machine_label(machine))
|
|
296
|
+
when "accept" then chat.accept(page:, invite: data["invite"].to_s, name: data["name"])
|
|
297
|
+
when "decline" then chat.decline(page:, invite: data["invite"].to_s).then { {} }
|
|
298
|
+
when "leave" then chat.leave(page:).then { {} }
|
|
299
|
+
when "say" then chat.say(page:, id: data["id"], text: data["text"])
|
|
300
|
+
when "presence" then chat.presence(page:, idle: data["idle"]).then { {} }
|
|
301
|
+
else halt 404, json(error: "no such chat action: #{params[:action]}")
|
|
302
|
+
end
|
|
303
|
+
status 202
|
|
304
|
+
json(answer)
|
|
305
|
+
rescue Live::Chat::Invalid => e
|
|
306
|
+
status 422
|
|
307
|
+
json(error: e.message)
|
|
308
|
+
rescue Live::Chat::Settled => e
|
|
309
|
+
status 409
|
|
310
|
+
json(error: e.message)
|
|
311
|
+
end
|
|
312
|
+
|
|
139
313
|
# Demo only: the board back to the fixture, for a test that wants a
|
|
140
314
|
# known start. A real engine's run has no such thing.
|
|
141
315
|
post "/api/demo/reset" do
|
|
@@ -146,15 +320,39 @@ module OpenLanes
|
|
|
146
320
|
status 204
|
|
147
321
|
end
|
|
148
322
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
155
329
|
|
|
330
|
+
get "/api/plans/:ordinal/files/:name" do
|
|
331
|
+
file = plan_files.read(params[:ordinal], params[:name])
|
|
332
|
+
headers "ETag" => file.etag
|
|
156
333
|
content_type "text/markdown", charset: "utf-8"
|
|
157
|
-
|
|
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)
|
|
158
356
|
end
|
|
159
357
|
|
|
160
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,19 +23,34 @@ 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.
|
|
33
32
|
# @!attribute [r] demo
|
|
34
33
|
# @return [OpenLanes::Web::Demo, nil] the demo engine, under `--demo`,
|
|
35
34
|
# which `POST /api/demo/reset` puts back to the start
|
|
35
|
+
# @!attribute [r] copilot
|
|
36
|
+
# @return [#call, nil] who answers the copilot's messages (W17):
|
|
37
|
+
# {Live::Copilot::Operator} beside a real run, {Demo::COPILOT} under
|
|
38
|
+
# `--demo`, nil where nothing does, and `POST /api/copilot` says so
|
|
39
|
+
# @!attribute [r] background
|
|
40
|
+
# @return [#call] runs the block it is given apart from the request, a
|
|
41
|
+
# thread by default; the suite passes one that runs it when told
|
|
42
|
+
# @!attribute [r] chat
|
|
43
|
+
# @return [OpenLanes::Live::Chat, nil] the chat between machines (plan 006),
|
|
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
|
|
36
51
|
# @!attribute [r] stopping
|
|
37
52
|
# @return [Concurrent::AtomicBoolean] set when the server shuts down, so
|
|
38
|
-
# open streams end
|
|
53
|
+
# open streams end on the `stop` nudge that follows
|
|
39
54
|
Context = Data.define(:stores,
|
|
40
55
|
:keys,
|
|
41
56
|
:plans_dir,
|
|
@@ -43,13 +58,17 @@ module OpenLanes
|
|
|
43
58
|
:csrf,
|
|
44
59
|
:dist,
|
|
45
60
|
:node,
|
|
46
|
-
:tick,
|
|
47
61
|
:ping_every,
|
|
48
62
|
:lifetime,
|
|
49
63
|
:demo,
|
|
64
|
+
:copilot,
|
|
65
|
+
:background,
|
|
66
|
+
:chat,
|
|
67
|
+
:listen,
|
|
68
|
+
:credentials,
|
|
50
69
|
:stopping) do
|
|
51
|
-
def initialize(dist: DIST, node: nil,
|
|
52
|
-
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)
|
|
53
72
|
super
|
|
54
73
|
end
|
|
55
74
|
|
|
@@ -62,5 +81,10 @@ module OpenLanes
|
|
|
62
81
|
# @return [Boolean]
|
|
63
82
|
def dist? = File.file?(index)
|
|
64
83
|
end
|
|
84
|
+
|
|
85
|
+
class Context
|
|
86
|
+
# Work started apart from a request, on a thread of its own.
|
|
87
|
+
THREAD = ->(&work) { Thread.new(&work) }
|
|
88
|
+
end
|
|
65
89
|
end
|
|
66
90
|
end
|
data/lib/openlanes/web/demo.rb
CHANGED
|
@@ -19,15 +19,28 @@ module OpenLanes
|
|
|
19
19
|
# The other machine's checkout id, fixed so its keys are stable.
|
|
20
20
|
OTHER_CHECKOUT = Live::Node.digest("openlanes-demo-other", Live::Node::CHECKOUT_LENGTH)
|
|
21
21
|
|
|
22
|
+
# What the copilot answers in the demo, where no agent runs and the
|
|
23
|
+
# board is made up: one plain line, so the panel can still be tried.
|
|
24
|
+
COPILOT_TEXT = "This is the demo run: c3po-operator does not run here, so nothing reads this or acts on it."
|
|
25
|
+
|
|
26
|
+
# The copilot's responder under `--demo` (W17), in place of
|
|
27
|
+
# {Live::Copilot::Operator}.
|
|
28
|
+
COPILOT = lambda do |turn|
|
|
29
|
+
[Live::Copilot::Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: COPILOT_TEXT)]
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
23
33
|
# @param keys [OpenLanes::Live::Keys] this node's
|
|
24
|
-
# @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
|
|
25
37
|
# @param interval [Float]
|
|
26
38
|
# @param clock [Proc] wall time
|
|
27
|
-
def initialize(store:, keys:, board:
|
|
39
|
+
def initialize(store:, keys:, board: nil, node: nil, interval: INTERVAL, clock: -> { Time.now })
|
|
28
40
|
@store = store
|
|
29
41
|
@keys = keys
|
|
30
|
-
@
|
|
42
|
+
@node = node
|
|
43
|
+
@board = board || DemoBoard.new(node:)
|
|
31
44
|
@interval = interval
|
|
32
45
|
@clock = clock
|
|
33
46
|
@seqs = {}
|
|
@@ -43,7 +56,7 @@ module OpenLanes
|
|
|
43
56
|
# same board whatever the last one answered or stopped.
|
|
44
57
|
#
|
|
45
58
|
# @return [void]
|
|
46
|
-
def reset = @mutex.synchronize { @board = DemoBoard.new }
|
|
59
|
+
def reset = @mutex.synchronize { @board = DemoBoard.new(node: @node) }
|
|
47
60
|
|
|
48
61
|
# Publish and answer every {INTERVAL}, on a thread of its own.
|
|
49
62
|
#
|
|
@@ -65,8 +78,9 @@ module OpenLanes
|
|
|
65
78
|
def stop
|
|
66
79
|
@running = false
|
|
67
80
|
@thread&.join(@interval * 2)
|
|
68
|
-
@
|
|
69
|
-
@store.
|
|
81
|
+
names = [@keys.node, other].compact
|
|
82
|
+
@store.del(*names.map { @keys.snapshot(it) })
|
|
83
|
+
@store.hdel(@keys.nodes, *names)
|
|
70
84
|
rescue Live::Unavailable
|
|
71
85
|
nil
|
|
72
86
|
end
|
|
@@ -81,6 +95,9 @@ module OpenLanes
|
|
|
81
95
|
@board.advance(@clock.call)
|
|
82
96
|
publish(@keys.node, @board.me)
|
|
83
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")
|
|
84
101
|
replies
|
|
85
102
|
end
|
|
86
103
|
rescue Live::Unavailable => e
|
|
@@ -90,16 +107,19 @@ module OpenLanes
|
|
|
90
107
|
|
|
91
108
|
private
|
|
92
109
|
|
|
93
|
-
# @return [String] the pretend other machine's node name
|
|
94
|
-
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}" }
|
|
95
112
|
|
|
96
113
|
# @return [void]
|
|
97
114
|
def publish(name, snapshot)
|
|
98
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)
|
|
99
119
|
json = JSON.generate(snapshot)
|
|
100
|
-
@store.
|
|
120
|
+
Live::EventBus.for(@store).transaction do |tx|
|
|
101
121
|
tx.set(@keys.snapshot(name), json, ex: Live::Keys::SNAPSHOT_TTL)
|
|
102
|
-
tx.
|
|
122
|
+
tx.append(@keys.board(name), { seq: snapshot["seq"], json: }, maxlen: Live::Keys::BOARD_LENGTH)
|
|
103
123
|
tx.hset(@keys.nodes, name, JSON.generate(snapshot["node"]))
|
|
104
124
|
end
|
|
105
125
|
end
|
|
@@ -131,9 +151,14 @@ module OpenLanes
|
|
|
131
151
|
command = Live::Command.parse(entry.fields["json"].to_s)
|
|
132
152
|
first = @store.hget(@keys.done, command.id)
|
|
133
153
|
reply = first ? parse(first) : outcome(command)
|
|
134
|
-
|
|
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|
|
|
135
159
|
tx.hset(@keys.done, reply.id, reply.to_json) unless first
|
|
136
|
-
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
|
|
137
162
|
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
138
163
|
end
|
|
139
164
|
reply
|
|
@@ -152,8 +177,8 @@ module OpenLanes
|
|
|
152
177
|
# @return [OpenLanes::Live::Reply]
|
|
153
178
|
def refuse(entry, reason)
|
|
154
179
|
reply = Live::Reply.new(id: entry.id, status: "refused", reason:, at: @clock.call)
|
|
155
|
-
@store.
|
|
156
|
-
tx.
|
|
180
|
+
Live::EventBus.for(@store).transaction do |tx|
|
|
181
|
+
tx.append(@keys.replies, { json: reply.to_json }, maxlen: Live::Keys::REPLIES_LENGTH)
|
|
157
182
|
tx.xack(@keys.commands, Live::Keys::GROUP, entry.id)
|
|
158
183
|
end
|
|
159
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]
|