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
|
@@ -13,17 +13,6 @@ module OpenLanes
|
|
|
13
13
|
# to the page's fixture, level by level, so a field renamed on one side
|
|
14
14
|
# fails the other's suite.
|
|
15
15
|
class Snapshot
|
|
16
|
-
# How an agent marks a question as answerable with Yes or No (W30), on
|
|
17
|
-
# its own line under the question's heading. The agents' prompt shows
|
|
18
|
-
# it, and {#questions} looks for it; neither spells it twice.
|
|
19
|
-
YES_NO_MARK = "answers: yes, no"
|
|
20
|
-
|
|
21
|
-
# {YES_NO_MARK}, read forgivingly: brackets, case and spacing vary.
|
|
22
|
-
YES_NO = /answers:\s*\[?\s*yes\s*,\s*no\s*\]?/i
|
|
23
|
-
|
|
24
|
-
# Any Markdown heading, which ends the body of the question above it.
|
|
25
|
-
HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
|
|
26
|
-
|
|
27
16
|
# Plan states finished with, kept off "Choose an existing plan".
|
|
28
17
|
FINISHED = %i[approved deployed discarded].freeze
|
|
29
18
|
|
|
@@ -42,9 +31,16 @@ module OpenLanes
|
|
|
42
31
|
# @param meter [OpenLanes::Live::Meter]
|
|
43
32
|
# @param log [Array<Hash>] `LogLine`s, newest last
|
|
44
33
|
# @param adapter [OpenLanes::Adapters::Base] whose models the page offers
|
|
34
|
+
# @param judge [#call, nil] a second opinion on whether a question asks
|
|
35
|
+
# to choose ({ChoiceJudge}); nil leaves the parser's verdict alone
|
|
36
|
+
# @param redis_url [String, nil] the store the engine itself is using,
|
|
37
|
+
# unredacted: the snapshot redacts it ({Live.redact})
|
|
38
|
+
# @param outage [Hash, nil] {OutageReport#to_h} of the outage this run
|
|
39
|
+
# last recovered from; nil when there was none
|
|
45
40
|
def initialize(board:, tree:, agents:, node:, repo:, project:, seq:, description: nil, at: Time.now,
|
|
46
41
|
started_at: nil, claims: nil, run_state: RunState.new, meter: Meter.new, log: [],
|
|
47
|
-
adapter: Adapters.for(Adapters::DEFAULT)
|
|
42
|
+
adapter: Adapters.for(Adapters::DEFAULT), judge: Live.choice_judge, redis_url: nil,
|
|
43
|
+
outage: nil)
|
|
48
44
|
@board = board
|
|
49
45
|
@tree = tree
|
|
50
46
|
@agents = agents
|
|
@@ -60,6 +56,9 @@ module OpenLanes
|
|
|
60
56
|
@meter = meter
|
|
61
57
|
@log = log
|
|
62
58
|
@adapter = adapter
|
|
59
|
+
@judge = judge
|
|
60
|
+
@redis_url = redis_url
|
|
61
|
+
@outage = outage
|
|
63
62
|
end
|
|
64
63
|
|
|
65
64
|
# @return [Hash]
|
|
@@ -76,8 +75,10 @@ module OpenLanes
|
|
|
76
75
|
stations: Stations.all(@agents).map(&:wire),
|
|
77
76
|
plans: mine,
|
|
78
77
|
backlog: backlog(subjects, mine.map { it[:ordinal] }),
|
|
78
|
+
bombs: bombs(subjects),
|
|
79
79
|
models: @adapter.models.map(&:wire),
|
|
80
80
|
autocompact: Adapters::AUTOCOMPACT / 100.0,
|
|
81
|
+
redis_url: @redis_url && Live.redact(@redis_url),
|
|
81
82
|
log: @log }
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -93,7 +94,9 @@ module OpenLanes
|
|
|
93
94
|
up: @board.up.to_i,
|
|
94
95
|
down: @board.down.to_i,
|
|
95
96
|
cost: plans.sum { it[:cost] }.round(4),
|
|
96
|
-
agents_working: @board.running.to_i
|
|
97
|
+
agents_working: @board.running.to_i,
|
|
98
|
+
jobs: @board.jobs.to_i,
|
|
99
|
+
outage: @outage }
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
# @return [String]
|
|
@@ -121,12 +124,15 @@ module OpenLanes
|
|
|
121
124
|
ordinal = subject.feature.ordinal.to_s
|
|
122
125
|
state = Plans::PlanState.for(subject.feature.path)
|
|
123
126
|
stages = state.stages
|
|
127
|
+
finished = state.finished
|
|
124
128
|
rows = @board.rows.select { it.ordinal == ordinal }
|
|
125
129
|
spent = @meter.for(ordinal, method(:price))
|
|
126
130
|
started = @meter.started_at(ordinal)
|
|
131
|
+
mailbox = Plans.mailbox(subject.feature.path)
|
|
127
132
|
{ ordinal:,
|
|
128
133
|
title: subject.feature.title,
|
|
129
134
|
slug: subject.feature.slug,
|
|
135
|
+
summary: Plans::Spec.summary(subject.read(Plans::Spec::FILENAME).to_s),
|
|
130
136
|
status: status(subject.status),
|
|
131
137
|
lane: Plans::Spec.for(subject).lane.to_s,
|
|
132
138
|
station: Stations.key_for(subject.status.key, before: stages.last&.fetch("from", nil)&.to_sym).to_s,
|
|
@@ -136,15 +142,20 @@ module OpenLanes
|
|
|
136
142
|
started_at: started && iso(started),
|
|
137
143
|
elapsed: started ? (@at - started).round : 0,
|
|
138
144
|
cars: rows.map { car(it, subject, stages) },
|
|
139
|
-
pr: pull_request(subject),
|
|
145
|
+
pr: pull_request(subject, finished),
|
|
140
146
|
questions: questions(subject, state),
|
|
141
147
|
interrupted: interrupted(stages, rows),
|
|
142
|
-
claimed_by: claimed_by(ordinal)
|
|
148
|
+
claimed_by: claimed_by(ordinal),
|
|
149
|
+
unacked: mailbox.unacked,
|
|
150
|
+
mail: mailbox.unacked_pairs,
|
|
151
|
+
last_known: last_known(state),
|
|
152
|
+
locked: Plans::Lock.read(subject.feature.path)&.to_json_hash,
|
|
153
|
+
finished: finished && { kind: finished.kind, at: iso(finished.at) } }
|
|
143
154
|
end
|
|
144
155
|
|
|
145
156
|
# @param status [OpenLanes::Plans::Status]
|
|
146
157
|
# @return [Hash] `Status`
|
|
147
|
-
def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label }
|
|
158
|
+
def status(status) = { key: status.key.to_s, emoji: status.emoji, label: status.label, note: status.note.to_s.delete("`") }
|
|
148
159
|
|
|
149
160
|
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
150
161
|
# @param taken [Array<String>] ordinals already on the board
|
|
@@ -158,6 +169,24 @@ module OpenLanes
|
|
|
158
169
|
end
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
# Every 💣 in the tree that is not another machine's, on the board or
|
|
173
|
+
# not: a bomb halts the whole run, so the page shows it whatever this
|
|
174
|
+
# node is working on (plan 009).
|
|
175
|
+
#
|
|
176
|
+
# @param subjects [Array<OpenLanes::Plans::Subject>]
|
|
177
|
+
# @return [Array<Hash>] `Bomb`
|
|
178
|
+
def bombs(subjects)
|
|
179
|
+
aging = Lifecycle::Aging.new(tree: @tree, now: @at)
|
|
180
|
+
subjects.select { it.status.key == :bomb && !foreign?(it.feature.ordinal.to_s) }.map do |s|
|
|
181
|
+
resume = Plans::PlanState.for(s.feature.path).resume_until
|
|
182
|
+
{ plan: s.feature.ordinal.to_s,
|
|
183
|
+
slug: s.feature.slug,
|
|
184
|
+
blocked_md: s.read("blocked.md").to_s,
|
|
185
|
+
blocked_since: iso(aging.started_at(s)),
|
|
186
|
+
resume_until: resume && iso(resume) }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
161
190
|
# ---- cars -------------------------------------------------------------
|
|
162
191
|
|
|
163
192
|
# @param row [OpenLanes::Engine::Board::Row]
|
|
@@ -183,6 +212,7 @@ module OpenLanes
|
|
|
183
212
|
elapsed: row.elapsed.to_i,
|
|
184
213
|
remaining: row.remaining,
|
|
185
214
|
held: @run_state.car_held?(row.key),
|
|
215
|
+
subagents: Array(row.running),
|
|
186
216
|
next_model: next_model(row) }
|
|
187
217
|
end
|
|
188
218
|
|
|
@@ -229,16 +259,24 @@ module OpenLanes
|
|
|
229
259
|
# ---- pull request, interruption, claim --------------------------------
|
|
230
260
|
|
|
231
261
|
# @param subject [OpenLanes::Plans::Subject]
|
|
262
|
+
# @param finished [OpenLanes::Plans::PlanState::Finished, nil]
|
|
232
263
|
# @return [Hash, nil] `PullRequest`
|
|
233
|
-
def pull_request(subject)
|
|
264
|
+
def pull_request(subject, finished)
|
|
234
265
|
pr = subject.pull_requests.find(&:open?) || subject.pull_requests.last or return nil
|
|
235
|
-
|
|
266
|
+
merged_at = finished.at if pr.merged? && finished&.merged?
|
|
267
|
+
{ number: pr.number.to_s[/\d+/].to_i, url: pr.url, state: pr_state(pr, subject, finished), ci: nil,
|
|
268
|
+
merged_at: merged_at && iso(merged_at) }
|
|
236
269
|
end
|
|
237
270
|
|
|
271
|
+
# hansolo's approval leaves the plan at 👀 with its pull request open,
|
|
272
|
+
# which the status key alone reads as "open": `finished` is what says
|
|
273
|
+
# it was approved, so the page's pill turns green.
|
|
274
|
+
#
|
|
238
275
|
# @return [String]
|
|
239
|
-
def pr_state(pr, subject)
|
|
276
|
+
def pr_state(pr, subject, finished)
|
|
240
277
|
return "merged" if pr.merged?
|
|
241
278
|
return "closed" unless pr.open?
|
|
279
|
+
return "approved" if finished&.kind == "approved"
|
|
242
280
|
|
|
243
281
|
{ rejected: "rejected", approved: "approved" }.fetch(subject.status.key, "open")
|
|
244
282
|
end
|
|
@@ -255,6 +293,18 @@ module OpenLanes
|
|
|
255
293
|
{ agent: last["agent"], at: last["finished_at"] || last["started_at"], note: last["note"].to_s }
|
|
256
294
|
end
|
|
257
295
|
|
|
296
|
+
# Where each interrupted agent stood, as the next round will read it.
|
|
297
|
+
# Read-only: an unacked count or a resume record never changes a round.
|
|
298
|
+
#
|
|
299
|
+
# @param state [OpenLanes::Plans::PlanState]
|
|
300
|
+
# @return [Array<Hash>] `LastKnown`, one per agent
|
|
301
|
+
def last_known(state)
|
|
302
|
+
state.last_known_states.map do |agent, entry|
|
|
303
|
+
{ agent:, source: entry.source, at: entry.at, round: entry.round, status: entry.status,
|
|
304
|
+
next_step: entry.next_step, note: entry.note }
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
258
308
|
# @param ordinal [String]
|
|
259
309
|
# @return [String, nil] the node id, without its checkout
|
|
260
310
|
def claimed_by(ordinal) = @claims&.held_by(ordinal)&.then { it.split(":").first }
|
|
@@ -265,17 +315,25 @@ module OpenLanes
|
|
|
265
315
|
|
|
266
316
|
# ---- questions --------------------------------------------------------
|
|
267
317
|
|
|
268
|
-
# The questions `blocked.md` still asks, each with its
|
|
269
|
-
#
|
|
318
|
+
# The questions `blocked.md` still asks, each with its context, the
|
|
319
|
+
# options it offers (D3), and who asked; then the harness's own
|
|
320
|
+
# requests for more time or rounds (E1), and R4's quality question on
|
|
321
|
+
# the plan that asked it.
|
|
270
322
|
#
|
|
271
323
|
# @param subject [OpenLanes::Plans::Subject]
|
|
272
324
|
# @param state [OpenLanes::Plans::PlanState]
|
|
273
325
|
# @return [Array<Hash>] `Question`
|
|
274
326
|
def questions(subject, state)
|
|
327
|
+
blocked(subject, state) + Engine::Extensions.questions(subject, state) +
|
|
328
|
+
Engine::QualityQuestion.questions(subject, state)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# @return [Array<Hash>] `Question`, from `blocked.md`
|
|
332
|
+
def blocked(subject, state)
|
|
275
333
|
open = Lifecycle::Unblocker.questions(subject).reject(&:answered)
|
|
276
334
|
return [] if open.empty?
|
|
277
335
|
|
|
278
|
-
bodies = bodies(subject.read("blocked.md").to_s)
|
|
336
|
+
bodies = Plans::BlockedFile.bodies(subject.read("blocked.md").to_s)
|
|
279
337
|
asker = state.signatures.rfind { it["status"] == "Blocked" }
|
|
280
338
|
asked_at = asker&.fetch("at", nil) || iso(File.mtime(File.join(subject.feature.path, "blocked.md")))
|
|
281
339
|
open.map { |q| question(subject, q, bodies.fetch(q.number, ""), asker, asked_at) }
|
|
@@ -284,34 +342,37 @@ module OpenLanes
|
|
|
284
342
|
# @return [Hash]
|
|
285
343
|
def question(subject, open, body, asker, asked_at)
|
|
286
344
|
id = "#{subject.feature.ordinal}/B#{open.number}"
|
|
287
|
-
|
|
345
|
+
choices = choices(open.heading, body)
|
|
288
346
|
{ id:,
|
|
289
347
|
number: open.number,
|
|
290
|
-
|
|
291
|
-
|
|
348
|
+
kind: "block",
|
|
349
|
+
heading: open.heading.sub(Plans::Choices::YES_NO, "").strip,
|
|
350
|
+
body: choices ? choices.context : body.strip,
|
|
292
351
|
agent: asker&.fetch("agent", nil),
|
|
293
352
|
owner: subject.status.key == :product_blocked ? "product" : "technical",
|
|
294
|
-
|
|
353
|
+
options: choices&.to_a,
|
|
295
354
|
held: @run_state.question_held?(id),
|
|
296
355
|
asked_at: }
|
|
297
356
|
end
|
|
298
357
|
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
found
|
|
358
|
+
# The options a question offers, or nil for one that wants words.
|
|
359
|
+
#
|
|
360
|
+
# A question its agent marked yes/no (W30) is taken at its word. Any
|
|
361
|
+
# other the parser found options in goes past the judge, which may say
|
|
362
|
+
# the list was not a choice after all; it is never asked about a
|
|
363
|
+
# question the parser found nothing in, since there would be no buttons
|
|
364
|
+
# to show.
|
|
365
|
+
#
|
|
366
|
+
# @param heading [String]
|
|
367
|
+
# @param body [String]
|
|
368
|
+
# @return [OpenLanes::Plans::Choices, nil]
|
|
369
|
+
def choices(heading, body)
|
|
370
|
+
return Plans::Choices.yes_no(body) if heading.match?(Plans::Choices::YES_NO)
|
|
371
|
+
|
|
372
|
+
found = Plans::Choices.parse(body) or return nil
|
|
373
|
+
return found if body.match?(Plans::Choices::YES_NO)
|
|
374
|
+
|
|
375
|
+
@judge&.call(body) == false ? nil : found
|
|
315
376
|
end
|
|
316
377
|
|
|
317
378
|
# @param time [Time]
|
|
@@ -25,7 +25,7 @@ module OpenLanes
|
|
|
25
25
|
def wire = { key: key.to_s, label:, agents: }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Key, label and the states folded into it, in spine order.
|
|
28
|
+
# Key, label and the states folded into it, in spine order. ⭐ Planned
|
|
29
29
|
# sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
|
|
30
30
|
# because on the board each pair is one stop with the same agents.
|
|
31
31
|
# 🔴 Rejected sits at Building too: luke and rey rework it there, and
|
|
@@ -37,8 +37,8 @@ module OpenLanes
|
|
|
37
37
|
[:ready_for_planning, "Planning", %i[ready_for_planning]],
|
|
38
38
|
[:building, "Building", %i[planned building rejected]],
|
|
39
39
|
[:building_ui, "Building UI", %i[building_ui]],
|
|
40
|
-
[:in_review, "Review", %i[ready_for_review in_review]],
|
|
41
|
-
[:pr, "Pull request", %i[approved deployed]]
|
|
40
|
+
[:in_review, "Review", %i[ready_for_review in_review verifying]],
|
|
41
|
+
[:pr, "Pull request", %i[completed approved deployed]]
|
|
42
42
|
].freeze
|
|
43
43
|
|
|
44
44
|
module_function
|
|
@@ -54,7 +54,7 @@ module OpenLanes
|
|
|
54
54
|
# The stop a plan in +state+ is shown at. A state a {GROUPS} entry
|
|
55
55
|
# names is shown at that stop, which is how 🔴 Rejected lands at
|
|
56
56
|
# Building. Any other state off the spine (💩, 😱) is shown where the
|
|
57
|
-
# spine takes it next; one with no way forward (
|
|
57
|
+
# spine takes it next; one with no way forward (⭕, 🛑) where the
|
|
58
58
|
# plan was when it stopped, which only its history knows.
|
|
59
59
|
#
|
|
60
60
|
# @param state [Symbol]
|
data/lib/openlanes/live/store.rb
CHANGED
|
@@ -45,7 +45,7 @@ module OpenLanes
|
|
|
45
45
|
|
|
46
46
|
# The writes {#multi} accepts. Reads inside a transaction answer
|
|
47
47
|
# nothing until it commits, so none are offered.
|
|
48
|
-
WRITES = %i[set del expire hset hdel xadd xack].freeze
|
|
48
|
+
WRITES = %i[set del expire hset hdel xadd xack xtrim].freeze
|
|
49
49
|
|
|
50
50
|
# Records the writes of one {#multi} block, to be applied together.
|
|
51
51
|
class Transaction
|
|
@@ -144,6 +144,15 @@ module OpenLanes
|
|
|
144
144
|
# @return [Integer] entries in the stream
|
|
145
145
|
def xlen(key) = call("XLEN", key)
|
|
146
146
|
|
|
147
|
+
# Drops every entry older than +minid+, exactly rather than with `~`,
|
|
148
|
+
# so a message an hour old is gone when the hour is up, not when Redis
|
|
149
|
+
# next finds a whole node to free.
|
|
150
|
+
#
|
|
151
|
+
# @param key [String]
|
|
152
|
+
# @param minid [String] `<ms>-<seq>`, the oldest id kept
|
|
153
|
+
# @return [Integer] how many were removed
|
|
154
|
+
def xtrim(key, minid:) = call(*xtrim_command(key, minid:))
|
|
155
|
+
|
|
147
156
|
# Creates the group, and the stream with it. Asking twice is not an
|
|
148
157
|
# error: every engine start asks.
|
|
149
158
|
#
|
|
@@ -169,6 +178,10 @@ module OpenLanes
|
|
|
169
178
|
# @return [Array<Entry>]
|
|
170
179
|
def xreadgroup(key, group, consumer, id: ">", count: 100)
|
|
171
180
|
entries(call("XREADGROUP", "GROUP", group, consumer, "COUNT", count, "STREAMS", key, id)&.fetch(key, nil))
|
|
181
|
+
rescue RedisClient::CommandError => e
|
|
182
|
+
raise unless e.message.start_with?("NOGROUP")
|
|
183
|
+
|
|
184
|
+
raise GroupMissing, e.message.lines.first.to_s.strip
|
|
172
185
|
end
|
|
173
186
|
|
|
174
187
|
# @return [Integer] how many were acknowledged
|
|
@@ -268,10 +281,14 @@ module OpenLanes
|
|
|
268
281
|
case name
|
|
269
282
|
when :set then set_command(*args, **kwargs)
|
|
270
283
|
when :xadd then xadd_command(*args, **kwargs)
|
|
284
|
+
when :xtrim then xtrim_command(*args, **kwargs)
|
|
271
285
|
else [name.to_s.upcase, *args]
|
|
272
286
|
end
|
|
273
287
|
end
|
|
274
288
|
|
|
289
|
+
# @return [Array]
|
|
290
|
+
def xtrim_command(key, minid:) = ["XTRIM", key, "MINID", minid]
|
|
291
|
+
|
|
275
292
|
# @return [Array]
|
|
276
293
|
def set_command(key, value, ex: nil, nx: false)
|
|
277
294
|
["SET", key, value, *("NX" if nx), *(["EX", ex] if ex)]
|
|
@@ -27,6 +27,12 @@ module OpenLanes
|
|
|
27
27
|
@pubsub.call("PSUBSCRIBE", *patterns) unless patterns.empty?
|
|
28
28
|
confirm(channels.size + patterns.size, timeout)
|
|
29
29
|
end
|
|
30
|
+
rescue Unavailable
|
|
31
|
+
# The caller never gets this object, so nobody else can close the
|
|
32
|
+
# connection, and a chat retrying a NOPERM every step would pile up
|
|
33
|
+
# one open socket per try until the GC got round to them.
|
|
34
|
+
close
|
|
35
|
+
raise
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# The next message, waiting up to +timeout+ seconds for one.
|
|
@@ -36,7 +42,7 @@ module OpenLanes
|
|
|
36
42
|
# @raise [OpenLanes::Live::Unavailable]
|
|
37
43
|
def next_message(timeout)
|
|
38
44
|
loop do
|
|
39
|
-
event =
|
|
45
|
+
event = read([timeout, SHORTEST].max) or return nil
|
|
40
46
|
case event.first
|
|
41
47
|
when "message" then return [event[1], event[2]]
|
|
42
48
|
when "pmessage" then return [event[2], event[3]]
|
|
@@ -56,11 +62,25 @@ module OpenLanes
|
|
|
56
62
|
def confirm(expected, timeout)
|
|
57
63
|
confirmed = 0
|
|
58
64
|
while confirmed < expected
|
|
59
|
-
event =
|
|
65
|
+
event = read(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
|
|
60
66
|
confirmed += 1 if %w[subscribe psubscribe].include?(event.first)
|
|
61
67
|
end
|
|
62
68
|
end
|
|
63
69
|
|
|
70
|
+
# The next event on the connection. redis-client hands a refusal back
|
|
71
|
+
# as an error object rather than raising it, so a `NOPERM` from an ACL
|
|
72
|
+
# that withholds `PSUBSCRIBE` would otherwise reach `event.first` as a
|
|
73
|
+
# NoMethodError and kill the hub's thread without a word.
|
|
74
|
+
#
|
|
75
|
+
# @param timeout [Float]
|
|
76
|
+
# @return [Array, nil]
|
|
77
|
+
# @raise [OpenLanes::Live::Unavailable]
|
|
78
|
+
def read(timeout)
|
|
79
|
+
event = guarded { @pubsub.next_event(timeout) }
|
|
80
|
+
guarded { raise event } if event.is_a?(RedisClient::Error)
|
|
81
|
+
event
|
|
82
|
+
end
|
|
83
|
+
|
|
64
84
|
# @return [Object] the block's value
|
|
65
85
|
# @raise [OpenLanes::Live::Unavailable]
|
|
66
86
|
def guarded
|
data/lib/openlanes/live.rb
CHANGED
|
@@ -17,6 +17,11 @@ module OpenLanes
|
|
|
17
17
|
# Redis could not be reached, or stopped answering.
|
|
18
18
|
class Unavailable < Error; end
|
|
19
19
|
|
|
20
|
+
# A stream's consumer group is not there: a Redis that restarted empty
|
|
21
|
+
# (W32) took it with everything else. Whoever reads the group creates it
|
|
22
|
+
# again; nothing is wrong with the connection.
|
|
23
|
+
class GroupMissing < Error; end
|
|
24
|
+
|
|
20
25
|
module_function
|
|
21
26
|
|
|
22
27
|
# W16's order: `--redis-url`, then `.openlanes.yml`'s `redis_url:`, then
|
|
@@ -36,6 +41,16 @@ module OpenLanes
|
|
|
36
41
|
# @return [String]
|
|
37
42
|
def mask(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)([^:@/]*):[^@/]+@}i, '\1\2:***@')
|
|
38
43
|
|
|
44
|
+
# The address with all of its userinfo replaced, user name included, for
|
|
45
|
+
# a page anyone on the network can open. {.mask} keeps the user name
|
|
46
|
+
# because a log read by the person who ran the engine is better for it; a
|
|
47
|
+
# page shown on a shared screen is not.
|
|
48
|
+
#
|
|
49
|
+
# @param url [String]
|
|
50
|
+
# @return [String] `redis://:***@host:6379/0`, or the URL as it was when
|
|
51
|
+
# it carries no userinfo
|
|
52
|
+
def redact(url) = url.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://)[^@/]*@}i, '\1:***@')
|
|
53
|
+
|
|
39
54
|
# @param url [String]
|
|
40
55
|
# @return [OpenLanes::Live::Store]
|
|
41
56
|
def store(url) = Store.new(url:)
|
|
@@ -54,6 +69,56 @@ module OpenLanes
|
|
|
54
69
|
store
|
|
55
70
|
end
|
|
56
71
|
|
|
72
|
+
# The process's {ChoiceJudge}: one, so its answers are shared by every
|
|
73
|
+
# snapshot built here; nil without TYPESAFE_API_KEY, and nil in the suite.
|
|
74
|
+
#
|
|
75
|
+
# @return [OpenLanes::Live::ChoiceJudge, nil]
|
|
76
|
+
def choice_judge
|
|
77
|
+
return @choice_judge if defined?(@choice_judge)
|
|
78
|
+
|
|
79
|
+
@choice_judge = ChoiceJudge.from_env
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param judge [#call, nil] answers `call(text)` with true, false or nil
|
|
83
|
+
# @return [void]
|
|
84
|
+
def choice_judge=(judge)
|
|
85
|
+
@choice_judge = judge
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The process's {ChoiceConverter}: nil without OPENROUTER_API_KEY, and
|
|
89
|
+
# nil in the suite.
|
|
90
|
+
#
|
|
91
|
+
# @return [OpenLanes::Live::ChoiceConverter, nil]
|
|
92
|
+
def choice_converter
|
|
93
|
+
return @choice_converter if defined?(@choice_converter)
|
|
94
|
+
|
|
95
|
+
@choice_converter = ChoiceConverter.from_env
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param converter [#call, nil] answers `call(heading:, body:)` with a
|
|
99
|
+
# {ChoiceConverter::Result}
|
|
100
|
+
# @return [void]
|
|
101
|
+
def choice_converter=(converter)
|
|
102
|
+
@choice_converter = converter
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The process's {Interviewer} (M6): nil without OPENROUTER_API_KEY, and
|
|
106
|
+
# nil in the suite.
|
|
107
|
+
#
|
|
108
|
+
# @return [OpenLanes::Live::Interviewer, nil]
|
|
109
|
+
def interviewer
|
|
110
|
+
return @interviewer if defined?(@interviewer)
|
|
111
|
+
|
|
112
|
+
@interviewer = Interviewer.from_env
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param interviewer [#call, nil] answers `call(idea:)` with
|
|
116
|
+
# {Interviewer::Question}s
|
|
117
|
+
# @return [void]
|
|
118
|
+
def interviewer=(interviewer)
|
|
119
|
+
@interviewer = interviewer
|
|
120
|
+
end
|
|
121
|
+
|
|
57
122
|
# @return [OpenLanes::Live::Session]
|
|
58
123
|
def session(**) = Session.new(**)
|
|
59
124
|
|
|
@@ -95,7 +160,7 @@ module OpenLanes
|
|
|
95
160
|
# @raise [OpenLanes::Live::Command::Invalid] for the API server's 422
|
|
96
161
|
def enqueue(store:, keys:, input:)
|
|
97
162
|
command = Command.parse(input)
|
|
98
|
-
store.
|
|
163
|
+
EventBus.for(store).append(keys.commands, { json: JSON.generate(command.wire) })
|
|
99
164
|
Reply.new(id: command.id, status: "accepted")
|
|
100
165
|
end
|
|
101
166
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Plans
|
|
5
|
+
# The bodies of the questions in `blocked.md`: the lines under each
|
|
6
|
+
# `## Bn` heading, up to the next heading of any kind or the next `## An`;
|
|
7
|
+
# and the answers under `## An`, which run to the next `Bn` or `An`.
|
|
8
|
+
#
|
|
9
|
+
# The page reads a body to show it and the engine reads one to rewrite it
|
|
10
|
+
# into choices, so both find its edges here; two readers with two ideas
|
|
11
|
+
# of where a question ends would rewrite text the page never showed.
|
|
12
|
+
# Pure: text in, text out.
|
|
13
|
+
module BlockedFile
|
|
14
|
+
# Any Markdown heading, which ends the body of the question above it.
|
|
15
|
+
HEADING = /^[ \t]{0,3}\#{1,6}[ \t]/
|
|
16
|
+
|
|
17
|
+
# What sits between `A1` and the answer on its heading: a `.`, `:` or
|
|
18
|
+
# `)`, the close of a bold `**A1.**`, or a dash with a space after it.
|
|
19
|
+
# Only that, because the answer's own punctuation is part of it: `-1`
|
|
20
|
+
# and `--no-delete` are decisions, and `1` and `no-delete` the opposite.
|
|
21
|
+
SEPARATOR = /\A[ \t]*[.:)]?\**[ \t]*(?:[-–—][ \t]+)?/
|
|
22
|
+
|
|
23
|
+
# A question or an answer heading, either of which ends an answer.
|
|
24
|
+
BLOCK = Regexp.union(OPEN_BLOCK, ANSWER_BLOCK)
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param text [String] the whole of blocked.md
|
|
29
|
+
# @return [Hash{Integer => String}] each question's number to the
|
|
30
|
+
# lines under its heading; a number written twice keeps the last
|
|
31
|
+
def bodies(text)
|
|
32
|
+
lines = text.to_s.lines
|
|
33
|
+
spans(lines).transform_values { |(from, to)| lines[from...to].join }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The answers waiting under `## An` headings, by number, which is how
|
|
37
|
+
# the harness copies each question and its answer into the asking
|
|
38
|
+
# agent's journal (M3) before the broker drains them.
|
|
39
|
+
#
|
|
40
|
+
# An answer is often its heading: `## A2. Retain, do not delete`, with
|
|
41
|
+
# only who decided under it. So the words after the number are part of
|
|
42
|
+
# it. And it is a person's text, which the page writes verbatim, so a
|
|
43
|
+
# heading inside it (`### Database`) is part of it too; only the next
|
|
44
|
+
# question or answer ends it.
|
|
45
|
+
#
|
|
46
|
+
# @param text [String] the whole of blocked.md
|
|
47
|
+
# @return [Hash{Integer => String}] each answer's number to its heading's
|
|
48
|
+
# words, if any, and the lines under it
|
|
49
|
+
def answers(text)
|
|
50
|
+
lines = text.to_s.lines
|
|
51
|
+
spans(lines, ANSWER_BLOCK, ends: BLOCK).to_h do |number, (from, to)|
|
|
52
|
+
title = lines[from - 1].sub(ANSWER_BLOCK, "").sub(SEPARATOR, "").strip
|
|
53
|
+
[number, "#{"#{title}\n" unless title.empty?}#{lines[from...to].join}"]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +text+ with question +number+'s body replaced by +body+, its heading
|
|
58
|
+
# and everything else as they were.
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] the whole of blocked.md
|
|
61
|
+
# @param number [Integer]
|
|
62
|
+
# @param body [String]
|
|
63
|
+
# @return [String, nil] nil when the file has no such question
|
|
64
|
+
def replace(text, number, body)
|
|
65
|
+
lines = text.to_s.lines
|
|
66
|
+
from, to = spans(lines)[number]
|
|
67
|
+
return nil unless from
|
|
68
|
+
|
|
69
|
+
lines[from - 1] = "#{lines[from - 1].chomp}\n"
|
|
70
|
+
section = ["\n", "#{body.strip}\n"]
|
|
71
|
+
section << "\n" if to < lines.size
|
|
72
|
+
(lines[0...from] + section + lines[to..]).join
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# +text+ with answer +number+ taken out and +note+ put at the top of its
|
|
76
|
+
# question's body, so the question is open again and says why.
|
|
77
|
+
#
|
|
78
|
+
# An answer the broker read and would not fold in otherwise sits under
|
|
79
|
+
# its question for good: the page counts the question answered and
|
|
80
|
+
# hides it, and the broker refuses it every time it is asked. Before
|
|
81
|
+
# the options, because the page reads a body's options off its end.
|
|
82
|
+
#
|
|
83
|
+
# @param text [String] the whole of blocked.md
|
|
84
|
+
# @param number [Integer]
|
|
85
|
+
# @param note [String]
|
|
86
|
+
# @return [String, nil] nil when the file has no such question or answer
|
|
87
|
+
def reject(text, number, note)
|
|
88
|
+
lines = text.to_s.lines
|
|
89
|
+
from, to = spans(lines, ANSWER_BLOCK, ends: BLOCK)[number]
|
|
90
|
+
return nil unless from && bodies(text).key?(number)
|
|
91
|
+
|
|
92
|
+
reopened = (lines[0...(from - 1)] + lines[to..]).join
|
|
93
|
+
replace(reopened, number, "#{note.strip}\n\n#{bodies(reopened)[number].strip}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param lines [Array<String>]
|
|
97
|
+
# @param pattern [Regexp] {OPEN_BLOCK} for questions, {ANSWER_BLOCK}
|
|
98
|
+
# for answers; either kind of heading ends the other's body
|
|
99
|
+
# @param ends [Regexp] what else ends a body: any heading, by default
|
|
100
|
+
# @return [Hash{Integer => Array(Integer, Integer)}] each block's
|
|
101
|
+
# number to the first line under its heading and the line that ends it
|
|
102
|
+
def spans(lines, pattern = OPEN_BLOCK, ends: HEADING)
|
|
103
|
+
found = {}
|
|
104
|
+
open = nil
|
|
105
|
+
lines.each_with_index do |line, i|
|
|
106
|
+
number = line[pattern, 1]
|
|
107
|
+
if number || line.match?(ends) || line.match?(OPEN_BLOCK) || line.match?(ANSWER_BLOCK)
|
|
108
|
+
found[open.first] = [open.last, i] if open
|
|
109
|
+
open = number && [number.to_i, i + 1]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
found[open.first] = [open.last, lines.size] if open
|
|
113
|
+
found
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|