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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
class Copilot
|
|
6
|
+
# The three things a person may tell the run from the copilot's box
|
|
7
|
+
# (plan 020.00): `/stop NNN`, `/restart NNN` and `/concurrency N`. Each
|
|
8
|
+
# becomes an engine {Command} on the same stream the page's buttons use
|
|
9
|
+
# (W13), so c3po-operator stays read-only and the commands get the
|
|
10
|
+
# gate's idempotency, replies and logging for nothing.
|
|
11
|
+
#
|
|
12
|
+
# The grammar lives here and nowhere else: the page draws a hint but
|
|
13
|
+
# parses nothing, so page and server cannot disagree on what a command is.
|
|
14
|
+
module Slash
|
|
15
|
+
# What a slash message that is not one of the three is answered with.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] text
|
|
18
|
+
# @return [String] the usage, then why this message was not a command
|
|
19
|
+
Help = Data.define(:text)
|
|
20
|
+
|
|
21
|
+
# The usage, which the help turn starts with.
|
|
22
|
+
USAGE = <<~TEXT.chomp
|
|
23
|
+
Slash commands:
|
|
24
|
+
/stop NNN — stop every agent on plan NNN and take it off the board
|
|
25
|
+
/restart NNN — start plan NNN again when it is on the board with no agent
|
|
26
|
+
/concurrency N — let N agents run at once; lowering it waits for agents to finish
|
|
27
|
+
TEXT
|
|
28
|
+
|
|
29
|
+
# Word to the command kind it queues.
|
|
30
|
+
PLAN_KINDS = { "stop" => "stop_plan", "restart" => "restart_plan" }.freeze
|
|
31
|
+
|
|
32
|
+
# @param text [String] the person's message, stripped
|
|
33
|
+
# @param id [String] the message id, which becomes the command's, so
|
|
34
|
+
# the engine's reply and its copilot turn name the message
|
|
35
|
+
# @return [OpenLanes::Live::Command, Help, nil] nil when +text+ is not
|
|
36
|
+
# a slash message, so it goes to the copilot as before
|
|
37
|
+
def self.parse(text, id:)
|
|
38
|
+
return unless text.start_with?("/")
|
|
39
|
+
|
|
40
|
+
word, *args = text.delete_prefix("/").split
|
|
41
|
+
word = word.to_s.downcase
|
|
42
|
+
if PLAN_KINDS.key?(word)
|
|
43
|
+
plan_command(word, args, id)
|
|
44
|
+
elsif word == "concurrency"
|
|
45
|
+
concurrency(args, id)
|
|
46
|
+
else
|
|
47
|
+
help("/#{word} is not one of them.")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [OpenLanes::Live::Command, Help]
|
|
52
|
+
def self.plan_command(word, args, id)
|
|
53
|
+
plan = Plans::Ordinal.parse(args.first) if args.size == 1
|
|
54
|
+
return help("/#{word} needs a plan number, like /#{word} 20 or /#{word} 020.00.") unless plan
|
|
55
|
+
|
|
56
|
+
command(id, PLAN_KINDS.fetch(word), plan: plan.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [OpenLanes::Live::Command, Help]
|
|
60
|
+
def self.concurrency(args, id)
|
|
61
|
+
limit = args.first.to_i if args.size == 1 && args.first.match?(/\A\d+\z/)
|
|
62
|
+
return help("/concurrency needs a whole number of at least 1, like /concurrency 3.") unless limit&.positive?
|
|
63
|
+
|
|
64
|
+
command(id, "set_concurrency", limit:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [OpenLanes::Live::Command]
|
|
68
|
+
def self.command(id, kind, plan: nil, **payload)
|
|
69
|
+
Command.parse("id" => id,
|
|
70
|
+
"kind" => kind,
|
|
71
|
+
"plan" => plan,
|
|
72
|
+
"payload" => payload.transform_keys(&:to_s).merge("source" => "copilot"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Help]
|
|
76
|
+
def self.help(why) = Help.new(text: "#{USAGE}\n\n#{why}")
|
|
77
|
+
|
|
78
|
+
private_class_method :plan_command, :concurrency, :command, :help
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -59,17 +59,35 @@ module OpenLanes
|
|
|
59
59
|
# whole of it would keep the page from showing the message, or that
|
|
60
60
|
# the copilot is reading, until the answer was already written.
|
|
61
61
|
#
|
|
62
|
+
# A slash command ({Slash}, plan 020.00) is not the copilot's to
|
|
63
|
+
# answer: it is queued for this node's engine under the message's id,
|
|
64
|
+
# and the engine's reply comes back into the conversation as turn
|
|
65
|
+
# `"<id>.1"`. A slash message that is not a command is answered here,
|
|
66
|
+
# at once, with the usage. Neither reaches the responder, so a server
|
|
67
|
+
# with no copilot still takes them, and {Turn#slash?} tells the caller
|
|
68
|
+
# not to {#answer} them.
|
|
69
|
+
#
|
|
62
70
|
# @param input [String, Hash] `CopilotMessage`: `{id, text}`
|
|
63
71
|
# @return [OpenLanes::Live::Copilot::Turn] the person's turn
|
|
64
72
|
# @raise [OpenLanes::Live::Copilot::Invalid] for the API server's 422
|
|
65
|
-
# @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would
|
|
73
|
+
# @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would
|
|
74
|
+
# answer: no copilot for plain text, no live engine for a command
|
|
66
75
|
def say(input)
|
|
67
76
|
turn = message(input)
|
|
77
|
+
slash = Slash.parse(turn.text, id: turn.id)
|
|
68
78
|
# Refused before it is stored: a message kept where nothing will ever
|
|
69
79
|
# answer it reads, on the page, as a copilot that is merely slow.
|
|
70
|
-
raise Unanswered, "the copilot is not running in this server" unless @responder
|
|
80
|
+
raise Unanswered, "the copilot is not running in this server" unless slash || @responder
|
|
81
|
+
if slash.is_a?(Command) && !engine?
|
|
82
|
+
raise Unanswered, "no engine is running to apply #{turn.text.split.first}"
|
|
83
|
+
end
|
|
71
84
|
|
|
72
|
-
|
|
85
|
+
append(turn)
|
|
86
|
+
case slash
|
|
87
|
+
when Command then EventBus.for(@store).append(@keys.commands, { json: JSON.generate(slash.wire) })
|
|
88
|
+
when Slash::Help then append(Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: slash.text))
|
|
89
|
+
end
|
|
90
|
+
turn
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
# Store whatever the responder answers to +turn+. A responder that
|
|
@@ -136,12 +154,24 @@ module OpenLanes
|
|
|
136
154
|
@store.xrevrange(@keys.copilot, count:).reverse.map { JSON.parse(it.fields["json"]) }
|
|
137
155
|
end
|
|
138
156
|
|
|
157
|
+
# Whether this node's engine is there to read {Keys#commands}: its
|
|
158
|
+
# snapshot key lives only while it publishes (`EX 30`), so a command
|
|
159
|
+
# queued without one would sit unanswered and the page would read on.
|
|
160
|
+
#
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def engine? = !@store.get(@keys.snapshot).nil?
|
|
163
|
+
|
|
139
164
|
# @return [Hash, nil]
|
|
140
165
|
def latest_usage(turns) = turns.reverse_each.map { it["usage"] }.find(&:itself)
|
|
141
166
|
|
|
167
|
+
# Written here, in the API server rather than by the Dispatcher, so the
|
|
168
|
+
# open streams are nudged here too: a turn has no other way to reach a
|
|
169
|
+
# stream that waits on {Keys#news}.
|
|
170
|
+
#
|
|
142
171
|
# @return [void]
|
|
143
172
|
def append(turn)
|
|
144
|
-
@store.
|
|
173
|
+
EventBus.for(@store).append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
|
|
174
|
+
News.publish(store: @store, keys: @keys, kind: "copilot")
|
|
145
175
|
end
|
|
146
176
|
end
|
|
147
177
|
|
|
@@ -174,6 +204,10 @@ module OpenLanes
|
|
|
174
204
|
# @return [Hash] as the wire carries it
|
|
175
205
|
def wire = { id:, from:, kind:, text:, at: at.utc.iso8601, usage: }.merge(fields.transform_keys(&:to_sym))
|
|
176
206
|
|
|
207
|
+
# @return [Boolean] whether this is the person's slash message, which
|
|
208
|
+
# the engine or {Slash} answers and the copilot never does
|
|
209
|
+
def slash? = from == "you" && text.start_with?("/")
|
|
210
|
+
|
|
177
211
|
# @return [String]
|
|
178
212
|
def to_json(*) = JSON.generate(wire)
|
|
179
213
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# What every bus answers, delegated to the store behind it.
|
|
7
|
+
class Base
|
|
8
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
9
|
+
attr_reader :store
|
|
10
|
+
|
|
11
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
12
|
+
def initialize(store)
|
|
13
|
+
@store = store
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param channel [String]
|
|
18
|
+
# @param payload [String]
|
|
19
|
+
# @return [Integer] how many subscriptions received it
|
|
20
|
+
def publish(channel, payload) = store.publish(channel, payload)
|
|
21
|
+
|
|
22
|
+
# A subscription of its own; the caller closes it.
|
|
23
|
+
#
|
|
24
|
+
# @param channels [Array<String>]
|
|
25
|
+
# @param patterns [Array<String>]
|
|
26
|
+
# @return [#next_message, #close]
|
|
27
|
+
def subscribe(channels: [], patterns: []) = store.subscriber(channels:, patterns:)
|
|
28
|
+
|
|
29
|
+
# A durable append, in stream order.
|
|
30
|
+
#
|
|
31
|
+
# @param stream [String]
|
|
32
|
+
# @param fields [Hash]
|
|
33
|
+
# @param maxlen [Integer, nil] approximate trim
|
|
34
|
+
# @return [String] the entry id
|
|
35
|
+
def append(stream, fields, maxlen: nil) = store.xadd(stream, fields, maxlen:)
|
|
36
|
+
|
|
37
|
+
# The appends and writes the block makes, applied atomically.
|
|
38
|
+
#
|
|
39
|
+
# @yieldparam tx [OpenLanes::Live::EventBus::Transaction]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def transaction
|
|
42
|
+
store.multi { |raw| yield Transaction.new(raw) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The database pub/sub channel names carry (see {Keys#news}).
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def db = store.db
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# The bus for the suite and `--demo`, over {MemoryStore}: one process,
|
|
7
|
+
# one keyspace, the same contract.
|
|
8
|
+
class MemoryBus < Base
|
|
9
|
+
# @param store [OpenLanes::Live::MemoryStore]
|
|
10
|
+
def initialize(store = MemoryStore.new) = super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# One {Base#transaction} block's writes: {#append} for the streams, and
|
|
7
|
+
# the store's other writes ({Store::WRITES}) beside them, so a site that
|
|
8
|
+
# sets a key and appends an entry together keeps doing both atomically.
|
|
9
|
+
class Transaction
|
|
10
|
+
# @param raw [OpenLanes::Live::Store::Transaction]
|
|
11
|
+
def initialize(raw)
|
|
12
|
+
@raw = raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param stream [String]
|
|
16
|
+
# @param fields [Hash]
|
|
17
|
+
# @param maxlen [Integer, nil]
|
|
18
|
+
# @return [nil]
|
|
19
|
+
def append(stream, fields, maxlen: nil) = @raw.xadd(stream, fields, maxlen:)
|
|
20
|
+
|
|
21
|
+
(Store::WRITES - %i[xadd]).each do |name|
|
|
22
|
+
define_method(name) { |*args, **kwargs| @raw.public_send(name, *args, **kwargs) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# The port every publish, subscribe and stream append goes through.
|
|
6
|
+
#
|
|
7
|
+
# Engine, Live and Web used to call `store.publish`, `store.subscriber`
|
|
8
|
+
# and `store.xadd` directly, on whichever of {Store} and {MemoryStore}
|
|
9
|
+
# they were handed, so the two shared a duck type nobody declared. A bus
|
|
10
|
+
# names that surface, and the stores become its backing: {RedisBus} over
|
|
11
|
+
# {Store}, {MemoryBus} over {MemoryStore}. Key layout, stream ids and the
|
|
12
|
+
# wire format are the store's, unchanged, because web cursors rely on them.
|
|
13
|
+
module EventBus
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The bus over +store+, or +store+ itself when it already is one.
|
|
17
|
+
#
|
|
18
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, Base]
|
|
19
|
+
# @return [Base]
|
|
20
|
+
def for(store)
|
|
21
|
+
case store
|
|
22
|
+
when Base then store
|
|
23
|
+
when MemoryStore then MemoryBus.new(store)
|
|
24
|
+
else RedisBus.new(store)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# Grills a new idea before its spec.md is written (M6): the questions the
|
|
8
|
+
# agents downstream would otherwise block to ask, each as a short context
|
|
9
|
+
# paragraph and lettered options with exactly one recommended. The page
|
|
10
|
+
# asks them one at a time, and the answers reach {Commands#create_plans}
|
|
11
|
+
# as `payload.decisions`, which writes them into the spec.
|
|
12
|
+
#
|
|
13
|
+
# One call to a small model on OpenRouter, as {ChoiceConverter} makes,
|
|
14
|
+
# and every question is checked the same way: written with
|
|
15
|
+
# {Plans::Choices.write} and read back with {Plans::Choices.parse}. A
|
|
16
|
+
# question that does not survive the trip is dropped, not the interview:
|
|
17
|
+
# four good questions are worth asking without the fifth.
|
|
18
|
+
#
|
|
19
|
+
# A seam: the suite sets {Live.interviewer} to nil, and examples about it
|
|
20
|
+
# hand it an adapter double, so nothing in the tests reaches OpenRouter.
|
|
21
|
+
class Interviewer
|
|
22
|
+
# The most questions one idea is asked. A person is interviewed while
|
|
23
|
+
# they wait, so the list stays short (M0).
|
|
24
|
+
LIMIT = 5
|
|
25
|
+
|
|
26
|
+
# Seconds a person waits for the questions before the plan is created
|
|
27
|
+
# without them. The HTTP client alone allows 10 to connect and 30 to
|
|
28
|
+
# read, and a Puma thread and the dialog are held for all of it.
|
|
29
|
+
TIMEOUT = 20
|
|
30
|
+
|
|
31
|
+
# What the model reads with the idea.
|
|
32
|
+
PROMPT = <<~PROMPT.freeze
|
|
33
|
+
A person described a software feature they want built. Agents will turn it into a spec, a plan and
|
|
34
|
+
code without being able to ask anything, so interview the person now. Return `questions`: an array
|
|
35
|
+
of at most #{LIMIT} objects, the decisions that most change what gets built and that the idea leaves open.
|
|
36
|
+
Ask nothing the idea already answers. Each object is:
|
|
37
|
+
|
|
38
|
+
- `context`: one short paragraph saying what must be decided and why it matters.
|
|
39
|
+
- `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
|
|
40
|
+
answer on one short line. Exactly one has `"recommended": true`: the one you would choose.
|
|
41
|
+
|
|
42
|
+
Never add an option for "something else" or "type something instead": the page has a text box.
|
|
43
|
+
An idea that leaves nothing open gets an empty array.
|
|
44
|
+
PROMPT
|
|
45
|
+
|
|
46
|
+
# One question, ready for the page.
|
|
47
|
+
#
|
|
48
|
+
# @!attribute [r] context
|
|
49
|
+
# @return [String]
|
|
50
|
+
# @!attribute [r] options
|
|
51
|
+
# @return [Array<String>] labels, in order
|
|
52
|
+
# @!attribute [r] recommended
|
|
53
|
+
# @return [Integer] the index of the recommended option
|
|
54
|
+
Question = Data.define(:context, :options, :recommended) do
|
|
55
|
+
# @return [Hash] as `POST /api/interview` sends it
|
|
56
|
+
def wire = { context:, options:, recommended: }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An interviewer when OPENROUTER_API_KEY is set, nil otherwise.
|
|
60
|
+
#
|
|
61
|
+
# @param env [#[]]
|
|
62
|
+
# @param config [OpenLanes::Config]
|
|
63
|
+
# @return [OpenLanes::Live::Interviewer, nil]
|
|
64
|
+
def self.from_env(env = ENV, config: Config.current)
|
|
65
|
+
key = env["OPENROUTER_API_KEY"].to_s.strip
|
|
66
|
+
key.empty? ? nil : new(api_key: key, model: config.questions_model || ChoiceConverter::DEFAULT_MODEL)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The model's questions, each cleaned the way {ChoiceConverter.render}
|
|
70
|
+
# cleans one, keeping those that survive.
|
|
71
|
+
#
|
|
72
|
+
# @param output [Hash] `questions`, as the adapter parsed it
|
|
73
|
+
# @return [Array<OpenLanes::Live::Interviewer::Question>] at most {LIMIT}
|
|
74
|
+
def self.render(output)
|
|
75
|
+
output = output.to_h.transform_keys(&:to_sym)
|
|
76
|
+
Array(output[:questions]).filter_map { question(it) }.first(LIMIT)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param raw [Object]
|
|
80
|
+
# @return [OpenLanes::Live::Interviewer::Question, nil]
|
|
81
|
+
def self.question(raw)
|
|
82
|
+
return unless raw.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
body = ChoiceConverter.render(raw)
|
|
85
|
+
choices = Plans::Choices.parse(body)
|
|
86
|
+
Question.new(context: choices.context,
|
|
87
|
+
options: choices.options.map(&:label),
|
|
88
|
+
recommended: choices.options.index(&:recommended))
|
|
89
|
+
rescue ChoiceConverter::Invalid
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
private_class_method :question
|
|
93
|
+
|
|
94
|
+
# @param api_key [String, nil] OpenRouter's; only read to build the default adapter
|
|
95
|
+
# @param model [String] an OpenRouter model id
|
|
96
|
+
# @param adapter [#call, nil] answers `call(node, answers)` with a Hash
|
|
97
|
+
# holding `questions`; built on first use when nil
|
|
98
|
+
# @param timeout [Numeric] seconds before the interview is given up
|
|
99
|
+
def initialize(api_key: nil, model: ChoiceConverter::DEFAULT_MODEL, adapter: nil, timeout: TIMEOUT)
|
|
100
|
+
@api_key = api_key
|
|
101
|
+
@model = model
|
|
102
|
+
@adapter = adapter
|
|
103
|
+
@timeout = timeout
|
|
104
|
+
@mutex = Mutex.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String]
|
|
108
|
+
attr_reader :model
|
|
109
|
+
|
|
110
|
+
# Blocks for at most {TIMEOUT} seconds.
|
|
111
|
+
#
|
|
112
|
+
# @param idea [String] Markdown, as the idea dialog sends it
|
|
113
|
+
# @return [Array<OpenLanes::Live::Interviewer::Question>]
|
|
114
|
+
# @raise [StandardError] whatever the adapter raises: no network, a 4xx,
|
|
115
|
+
# bad JSON, or Timeout::Error past the limit
|
|
116
|
+
def call(idea:)
|
|
117
|
+
output = Timeout.timeout(@timeout, Timeout::Error, "no questions within #{@timeout}s") do
|
|
118
|
+
adapter.call(node, { idea: idea.to_s })
|
|
119
|
+
end
|
|
120
|
+
self.class.render(output)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def adapter
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@adapter ||= begin
|
|
128
|
+
require "inquirex-llm"
|
|
129
|
+
Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def node
|
|
135
|
+
@node ||= begin
|
|
136
|
+
require "inquirex-llm"
|
|
137
|
+
Inquirex::LLM::Node.new(id: :interview,
|
|
138
|
+
verb: :extract,
|
|
139
|
+
prompt: PROMPT,
|
|
140
|
+
schema: Inquirex::LLM::Schema.new(questions: :array),
|
|
141
|
+
from_steps: %i[idea])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/openlanes/live/keys.rb
CHANGED
|
@@ -62,6 +62,14 @@ module OpenLanes
|
|
|
62
62
|
# @return [String]
|
|
63
63
|
def run(of = node) = "#{base}:#{of}:run"
|
|
64
64
|
|
|
65
|
+
# Pub/sub: `{json}`, one line of this node's {Engine::EventJournal},
|
|
66
|
+
# mirrored after it is fsync'd. The file is the record; a subscriber
|
|
67
|
+
# that misses a message reads the file.
|
|
68
|
+
#
|
|
69
|
+
# @param of [String]
|
|
70
|
+
# @return [String]
|
|
71
|
+
def journal(of = node) = "#{base}:#{of}:journal"
|
|
72
|
+
|
|
65
73
|
# Stream: `{json}`, one `CopilotTurn`: the conversation with this
|
|
66
74
|
# node's copilot (W17), the person's messages and its answers in the
|
|
67
75
|
# order they were said. {Copilot} writes and reads it.
|
|
@@ -79,6 +87,23 @@ module OpenLanes
|
|
|
79
87
|
# @return [String]
|
|
80
88
|
def copilot_session(of = node) = "#{base}:#{of}:copilot-session"
|
|
81
89
|
|
|
90
|
+
# Stream: `{json}`, one mailbox message and the plan it belongs to,
|
|
91
|
+
# what `openlanes mail send` publishes after writing `state.json`
|
|
92
|
+
# (plan 003, B3). One stream per checkout; the plan rides in the entry,
|
|
93
|
+
# and each plan reads it through a group of its own ({Keys#mail_group}).
|
|
94
|
+
#
|
|
95
|
+
# @param of [String]
|
|
96
|
+
# @return [String]
|
|
97
|
+
def mail(of = node) = "#{base}:#{of}:mail"
|
|
98
|
+
|
|
99
|
+
# The consumer group one plan drains {#mail} through. Every group sees
|
|
100
|
+
# every entry, so a plan's harness thread acks what it read, its own
|
|
101
|
+
# or not, without taking anything from another plan's.
|
|
102
|
+
#
|
|
103
|
+
# @param plan [#to_s] e.g. "003.00"
|
|
104
|
+
# @return [String]
|
|
105
|
+
def mail_group(plan) = "mail:#{plan}"
|
|
106
|
+
|
|
82
107
|
# Pub/sub channel: one chat's messages, joins, leaves and idle notices
|
|
83
108
|
# (plan 006). Redis delivers a `PUBLISH` to every database's
|
|
84
109
|
# subscribers, so the database is part of every chat channel's name: a
|
|
@@ -116,6 +141,33 @@ module OpenLanes
|
|
|
116
141
|
# @return [String]
|
|
117
142
|
def chat_base(db) = "#{base}:chat:#{db}"
|
|
118
143
|
|
|
144
|
+
# Pub/sub channel: "look again", one per project, published by
|
|
145
|
+
# whoever wrote something an open page shows ({News}). Every node's
|
|
146
|
+
# engine on the project publishes here, because every page shows every
|
|
147
|
+
# node's board. The database is in the name for the reason it is in
|
|
148
|
+
# {#chat_room}'s: a `PUBLISH` reaches every database's subscribers.
|
|
149
|
+
#
|
|
150
|
+
# @param db [Integer] the Redis database the nodes share
|
|
151
|
+
# @return [String]
|
|
152
|
+
def news(db:) = "#{base}:news:#{db}"
|
|
153
|
+
|
|
154
|
+
# Hash: plan ordinal to that plan's running app, its process statuses
|
|
155
|
+
# and a heartbeat ({Apps::Listing}). One per machine, not per
|
|
156
|
+
# checkout: the app runs in a plan's worktree and the control room in
|
|
157
|
+
# the main checkout, and both are this machine.
|
|
158
|
+
#
|
|
159
|
+
# @param machine [String] a node id, 24 hex digits of `user@host`
|
|
160
|
+
# @return [String]
|
|
161
|
+
def apps(machine = node.split(":").first) = "#{base}:apps:#{machine}"
|
|
162
|
+
|
|
163
|
+
# Stream: `{json}`, one request from the Preview tab's toolbar
|
|
164
|
+
# (restart, stop), read by `openlanes app start`. Not {#commands}: the
|
|
165
|
+
# engine never runs an app, so it would never read them.
|
|
166
|
+
#
|
|
167
|
+
# @param machine [String]
|
|
168
|
+
# @return [String]
|
|
169
|
+
def app_commands(machine = node.split(":").first) = "#{apps(machine)}:commands"
|
|
170
|
+
|
|
119
171
|
# String: the node name holding a plan, `SET NX EX`.
|
|
120
172
|
#
|
|
121
173
|
# @param ordinal [#to_s] e.g. "004.00"
|
|
@@ -138,6 +190,10 @@ module OpenLanes
|
|
|
138
190
|
# can scroll back through, not an archive.
|
|
139
191
|
COPILOT_LENGTH = 500
|
|
140
192
|
|
|
193
|
+
# Seconds a message stays in {#mail}. `state.json` is the record; the
|
|
194
|
+
# stream only has to outlast a harness tick, and an hour is generous.
|
|
195
|
+
MAIL_TTL = 3600
|
|
196
|
+
|
|
141
197
|
# Seconds a snapshot outlives its last refresh.
|
|
142
198
|
SNAPSHOT_TTL = 30
|
|
143
199
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# Mail between agents, over Redis as well as through `state.json`
|
|
9
|
+
# (plan 003, B3).
|
|
10
|
+
#
|
|
11
|
+
# `state.json` stays the record: `openlanes mail send` writes it first
|
|
12
|
+
# and publishes here only when Redis answers, so a run without Redis
|
|
13
|
+
# loses nothing. What the stream adds is delivery to the harness thread
|
|
14
|
+
# beside each agent, which drains it once a second (B4) instead of
|
|
15
|
+
# re-reading every plan's file.
|
|
16
|
+
#
|
|
17
|
+
# A stream with a consumer group per plan, not pub/sub: a message
|
|
18
|
+
# published while nobody drains waits for the next drain instead of
|
|
19
|
+
# vanishing. Every publish trims what is older than {Keys::MAIL_TTL}
|
|
20
|
+
# and renews the key's expiry, so the stream never outgrows an hour.
|
|
21
|
+
#
|
|
22
|
+
# Delivery is acked once the drain's block kept it, never before: an
|
|
23
|
+
# entry acked ahead of a failed write would be gone from the stream with
|
|
24
|
+
# only a log line left. An entry not kept stays pending and is offered
|
|
25
|
+
# again on the next drain. An ack says the bytes arrived, nothing more: a message is read when its recipient runs `openlanes mail ack`,
|
|
26
|
+
# and that lands in `state.json`, never here.
|
|
27
|
+
class Mail
|
|
28
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
29
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
30
|
+
# @param now [Proc] wall time, which the trim's cutoff is taken from
|
|
31
|
+
def initialize(store:, keys:, now: -> { Time.now })
|
|
32
|
+
@store = store
|
|
33
|
+
@keys = keys
|
|
34
|
+
@now = now
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Publish one message that `state.json` already holds.
|
|
38
|
+
#
|
|
39
|
+
# @param plan [#to_s] the plan's number, e.g. "003.00"
|
|
40
|
+
# @param message [OpenLanes::Plans::Mailbox::Message, Hash]
|
|
41
|
+
# @return [void]
|
|
42
|
+
def publish(plan:, message:)
|
|
43
|
+
payload = JSON.generate("plan" => plan.to_s, "message" => wire(message))
|
|
44
|
+
EventBus.for(@store).transaction do |tx|
|
|
45
|
+
tx.append(@keys.mail, { json: payload })
|
|
46
|
+
tx.xtrim(@keys.mail, minid: cutoff)
|
|
47
|
+
tx.expire(@keys.mail, Keys::MAIL_TTL)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Every message for +plan+ that this plan's group has not had kept
|
|
52
|
+
# yet: first the ones offered before and left pending, then the new.
|
|
53
|
+
# Entries for other plans, and entries that are not messages, are
|
|
54
|
+
# acked in this plan's group straight away, which leaves other plans'
|
|
55
|
+
# groups untouched.
|
|
56
|
+
#
|
|
57
|
+
# With a block, each message is yielded and acked only when the block
|
|
58
|
+
# returns truthy; one it returns false for, or raises on, stays pending
|
|
59
|
+
# for the next drain. Without one, each is acked as it is returned.
|
|
60
|
+
#
|
|
61
|
+
# @param plan [#to_s]
|
|
62
|
+
# @param consumer [String] who is draining, e.g. the agent's name
|
|
63
|
+
# @yieldparam message [Hash] as `state.json` holds it
|
|
64
|
+
# @yieldreturn [Boolean] whether it was kept
|
|
65
|
+
# @return [Array<Hash>] messages as `state.json` holds them, oldest
|
|
66
|
+
# first; with a block, the ones left pending
|
|
67
|
+
def drain(plan:, consumer:)
|
|
68
|
+
group = @keys.mail_group(plan)
|
|
69
|
+
@store.expire(@keys.mail, Keys::MAIL_TTL) if @store.xgroup_create(@keys.mail, group)
|
|
70
|
+
entries = @store.xreadgroup(@keys.mail, group, consumer, id: "0") + @store.xreadgroup(@keys.mail, group, consumer)
|
|
71
|
+
entries.filter_map do |entry|
|
|
72
|
+
message = message_in(entry, plan.to_s)
|
|
73
|
+
kept = message.nil? || !block_given? || yield(message)
|
|
74
|
+
@store.xack(@keys.mail, group, entry.id) if kept
|
|
75
|
+
message if message && (kept ? !block_given? : true)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Which plans have mail in the stream after +cursor+, so a drain can
|
|
80
|
+
# reach a plan with nobody running on it: a worktree's `mail send` has
|
|
81
|
+
# no other way into the main checkout's `state.json`, and the stream
|
|
82
|
+
# forgets it after {Keys::MAIL_TTL}.
|
|
83
|
+
#
|
|
84
|
+
# @param cursor [String] the last stream id already looked at, "0" for
|
|
85
|
+
# all of it
|
|
86
|
+
# @return [Array(Array<String>, String)] the plans, and the new cursor
|
|
87
|
+
def plans_since(cursor)
|
|
88
|
+
plans = []
|
|
89
|
+
loop do
|
|
90
|
+
entries = @store.xread(@keys.mail, id: cursor)
|
|
91
|
+
break if entries.empty?
|
|
92
|
+
|
|
93
|
+
cursor = entries.last.id
|
|
94
|
+
plans.concat(entries.filter_map { plan_in(it) })
|
|
95
|
+
end
|
|
96
|
+
[plans.uniq, cursor]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# The oldest id a publish keeps: {Keys::MAIL_TTL} seconds ago.
|
|
102
|
+
#
|
|
103
|
+
# @return [String]
|
|
104
|
+
def cutoff = "#{((@now.call.to_f - Keys::MAIL_TTL) * 1000).to_i}-0"
|
|
105
|
+
|
|
106
|
+
# @param message [OpenLanes::Plans::Mailbox::Message, Hash]
|
|
107
|
+
# @return [Hash] string keys, `at` in ISO 8601 as `state.json` has it
|
|
108
|
+
def wire(message)
|
|
109
|
+
hash = message.to_h.transform_keys(&:to_s)
|
|
110
|
+
hash.merge("at" => iso8601(hash["at"])).compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param at [String, Time, nil]
|
|
114
|
+
# @return [String, nil]
|
|
115
|
+
def iso8601(at)
|
|
116
|
+
return at.iso8601 if at.respond_to?(:iso8601)
|
|
117
|
+
|
|
118
|
+
Time.parse(at.to_s).iso8601
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
at&.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
124
|
+
# @return [String, nil] the plan the entry is for
|
|
125
|
+
def plan_in(entry)
|
|
126
|
+
data = JSON.parse(entry.fields["json"].to_s)
|
|
127
|
+
data["plan"] if data.is_a?(Hash) && data["plan"].is_a?(String)
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param entry [OpenLanes::Live::Store::Entry]
|
|
133
|
+
# @param plan [String]
|
|
134
|
+
# @return [Hash, nil] the message, when the entry is one and for +plan+
|
|
135
|
+
def message_in(entry, plan)
|
|
136
|
+
data = JSON.parse(entry.fields["json"].to_s)
|
|
137
|
+
data["message"] if data.is_a?(Hash) && data["plan"] == plan && data["message"].is_a?(Hash)
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|