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,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Plans
|
|
7
|
+
# An agent's journal (M2): what it decided, did, left to do, asked and
|
|
8
|
+
# must not forget, kept from one round to the next.
|
|
9
|
+
#
|
|
10
|
+
# The agent writes no file and runs no command. It prints each entry as
|
|
11
|
+
# one line of JSON in its own output, which the harness reads anyway, and
|
|
12
|
+
# the dispatcher keeps it in `state.json` under `memory.<agent>`. One
|
|
13
|
+
# writer, under the lock {PlanState} already takes, is what lets a pair
|
|
14
|
+
# journal at once without either losing the other's line; an agent that
|
|
15
|
+
# wrote the file itself would be a second writer racing the first.
|
|
16
|
+
#
|
|
17
|
+
# {"openlanes_memory": {"kind": "decision", "text": "Money is integer cents"}}
|
|
18
|
+
# {"openlanes_memory": {"kind": "override", "text": "BigDecimal after all", "overrides": 3}}
|
|
19
|
+
#
|
|
20
|
+
# The single key {KEY} is what tells an entry from any other JSON an
|
|
21
|
+
# agent prints: a status object, a `package.json` it quotes, a `jq`
|
|
22
|
+
# result. Nothing else an agent has reason to print starts with that key.
|
|
23
|
+
#
|
|
24
|
+
# There is no timestamp. Entries are numbered in order across the
|
|
25
|
+
# agent's rounds on the plan, and a later one wins over an earlier one;
|
|
26
|
+
# an `override` names the number it overturns.
|
|
27
|
+
#
|
|
28
|
+
# Pure: text in, entries out. The dispatcher decides where they go.
|
|
29
|
+
module Journal
|
|
30
|
+
# The one key an entry line carries.
|
|
31
|
+
KEY = "openlanes_memory"
|
|
32
|
+
|
|
33
|
+
# What an entry may be. `question` is the agent stopping to ask; the
|
|
34
|
+
# harness adds the answer beside it once one arrives (M3).
|
|
35
|
+
KINDS = %w[todo done decision question override note].freeze
|
|
36
|
+
|
|
37
|
+
# Longest text kept, in characters. Every round's prompt carries the
|
|
38
|
+
# journal, so an entry is a line to remember, not a document; one that
|
|
39
|
+
# runs on is cut rather than refused, since what it says first is what
|
|
40
|
+
# it most needed to keep.
|
|
41
|
+
TEXT_LIMIT = 500
|
|
42
|
+
|
|
43
|
+
# A line that may be an entry: the key first, after an optional
|
|
44
|
+
# backtick, since agents quote JSON in Markdown as often as not.
|
|
45
|
+
CANDIDATE = /\A`*\{\s*"#{KEY}"\s*:/
|
|
46
|
+
|
|
47
|
+
# One entry, as the agent printed it or the harness recorded it.
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [r] kind
|
|
50
|
+
# @return [String] one of {KINDS}
|
|
51
|
+
# @!attribute [r] text
|
|
52
|
+
# @return [String]
|
|
53
|
+
# @!attribute [r] overrides
|
|
54
|
+
# @return [Integer, nil] the number of the entry an `override` overturns
|
|
55
|
+
# @!attribute [r] ref
|
|
56
|
+
# @return [String, nil] `B<n>`, for a question the harness recorded
|
|
57
|
+
# from `blocked.md`
|
|
58
|
+
# @!attribute [r] answer
|
|
59
|
+
# @return [String, nil] a person's answer to that question
|
|
60
|
+
Entry = Data.define(:kind, :text, :overrides, :ref, :answer) do
|
|
61
|
+
def initialize(kind:, text:, overrides: nil, ref: nil, answer: nil) = super
|
|
62
|
+
|
|
63
|
+
# @param hash [Hash] as `state.json` keeps it
|
|
64
|
+
# @return [OpenLanes::Plans::Journal::Entry]
|
|
65
|
+
def self.from_h(hash)
|
|
66
|
+
new(kind: hash["kind"].to_s,
|
|
67
|
+
text: hash["text"].to_s,
|
|
68
|
+
overrides: hash["overrides"],
|
|
69
|
+
ref: hash["ref"],
|
|
70
|
+
answer: hash["answer"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash] string keys, nils left out, as the schema wants
|
|
74
|
+
def to_json_h = to_h.transform_keys(&:to_s).compact
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A line that announced itself as an entry and was not one, kept so the
|
|
78
|
+
# dispatcher can say so: an agent whose journal is silently dropped
|
|
79
|
+
# believes it is remembering.
|
|
80
|
+
#
|
|
81
|
+
# @!attribute [r] line
|
|
82
|
+
# @return [String]
|
|
83
|
+
# @!attribute [r] reason
|
|
84
|
+
# @return [String]
|
|
85
|
+
Rejection = Data.define(:line, :reason)
|
|
86
|
+
|
|
87
|
+
# One round's worth of one agent's journal.
|
|
88
|
+
#
|
|
89
|
+
# @!attribute [r] number
|
|
90
|
+
# @return [Integer] the agent's journal round on this plan, 1 up,
|
|
91
|
+
# counted across runs and states. Not the dispatcher's round, which
|
|
92
|
+
# restarts at 1 in every run and every state and would land a later
|
|
93
|
+
# run's entries on an earlier run's.
|
|
94
|
+
# @!attribute [r] round
|
|
95
|
+
# @return [Integer, nil] the dispatcher's round it was printed in
|
|
96
|
+
# @!attribute [r] state
|
|
97
|
+
# @return [String, nil] the state the plan was in when it started
|
|
98
|
+
# @!attribute [r] entries
|
|
99
|
+
# @return [Array<Entry>]
|
|
100
|
+
# @!attribute [r] first
|
|
101
|
+
# @return [Integer] the number of its first entry in the whole journal
|
|
102
|
+
Round = Data.define(:number, :round, :state, :entries, :first)
|
|
103
|
+
|
|
104
|
+
module_function
|
|
105
|
+
|
|
106
|
+
# Every entry line in one piece of the agent's text, in order, each
|
|
107
|
+
# read into an {Entry} or a {Rejection}. Lines that do not start with
|
|
108
|
+
# {KEY} are none of this method's business and are skipped.
|
|
109
|
+
#
|
|
110
|
+
# @param text [String, nil] what the agent said
|
|
111
|
+
# @return [Array<Entry, Rejection>]
|
|
112
|
+
def scan(text)
|
|
113
|
+
text.to_s.each_line.filter_map do |raw|
|
|
114
|
+
line = raw.strip
|
|
115
|
+
read(line) if line.match?(CANDIDATE)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param line [String] one candidate line
|
|
120
|
+
# @return [Entry, Rejection]
|
|
121
|
+
def read(line)
|
|
122
|
+
json = line.delete_prefix("`" * line[/\A`*/].size).delete_suffix("`" * line[/`*\z/].size)
|
|
123
|
+
value = JSON.parse(json)[KEY]
|
|
124
|
+
problem = problem_with(value)
|
|
125
|
+
return Rejection.new(line:, reason: problem) if problem
|
|
126
|
+
|
|
127
|
+
Entry.new(kind: value["kind"], text: clip(value["text"]), overrides: value["overrides"])
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
Rejection.new(line:, reason: "not JSON: #{e.message.lines.first.to_s.strip}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Only the fields an agent may set are read; `ref` and `answer` are the
|
|
133
|
+
# harness's to write, and an agent claiming a person answered something
|
|
134
|
+
# is exactly the line that must not be trusted.
|
|
135
|
+
#
|
|
136
|
+
# @param value [Object]
|
|
137
|
+
# @return [String, nil] why it is not an entry
|
|
138
|
+
def problem_with(value)
|
|
139
|
+
return "#{KEY} is not an object" unless value.is_a?(Hash)
|
|
140
|
+
return "kind #{value["kind"].inspect} is not one of #{KINDS.join(", ")}" unless KINDS.include?(value["kind"])
|
|
141
|
+
return "text is missing" unless value["text"].is_a?(String) && !value["text"].strip.empty?
|
|
142
|
+
|
|
143
|
+
overrides = value["overrides"]
|
|
144
|
+
return nil if overrides.nil?
|
|
145
|
+
return "overrides must be an entry number" unless overrides.is_a?(Integer) && overrides.positive?
|
|
146
|
+
|
|
147
|
+
"only an override may name an entry it overrides" unless value["kind"] == "override"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param text [String]
|
|
151
|
+
# @return [String] stripped, and cut to {TEXT_LIMIT}
|
|
152
|
+
def clip(text)
|
|
153
|
+
text = text.to_s.strip
|
|
154
|
+
text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT - 1]}…" : text
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The line an agent prints, for the prompt's examples, so the prompt and
|
|
158
|
+
# the parser cannot describe two shapes.
|
|
159
|
+
#
|
|
160
|
+
# @param kind [String]
|
|
161
|
+
# @param text [String]
|
|
162
|
+
# @param overrides [Integer, nil]
|
|
163
|
+
# @return [String]
|
|
164
|
+
def line(kind, text, overrides: nil)
|
|
165
|
+
JSON.generate(KEY => { "kind" => kind, "text" => text, "overrides" => overrides }.compact)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Plans
|
|
7
|
+
# What an interrupted agent had done, as a record the next round acts on.
|
|
8
|
+
#
|
|
9
|
+
# It replaces the `RESUME: ...` mail, which was prose the next agent had
|
|
10
|
+
# to read and believe. The fields are fixed: freeform JSON would be prose
|
|
11
|
+
# with braces, and no easier to act on.
|
|
12
|
+
#
|
|
13
|
+
# Two writers. The agent, through `openlanes state resume`, when it
|
|
14
|
+
# reaches a polling point after INTERRUPT. The harness, when the agent
|
|
15
|
+
# never did: killed when GRACE ran out, or dead outright. The agent knows
|
|
16
|
+
# what it did, the harness only what it saw, so an agent's entry is never
|
|
17
|
+
# replaced by a harness one from the same round or an earlier one.
|
|
18
|
+
#
|
|
19
|
+
# Pids, tokens and exit codes are not here and cannot be: this lands in
|
|
20
|
+
# the committed `state.json`, and process detail belongs to one machine's
|
|
21
|
+
# `openlanes-state.json`.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] source
|
|
24
|
+
# @return [String] "agent" or "harness"
|
|
25
|
+
# @!attribute [r] at
|
|
26
|
+
# @return [String] ISO 8601
|
|
27
|
+
# @!attribute [r] round
|
|
28
|
+
# @return [Integer]
|
|
29
|
+
# @!attribute [r] status
|
|
30
|
+
# @return [String] one of {Ledger::STATUSES}
|
|
31
|
+
# @!attribute [r] done
|
|
32
|
+
# @return [Array<String>]
|
|
33
|
+
# @!attribute [r] remaining
|
|
34
|
+
# @return [Array<String>]
|
|
35
|
+
# @!attribute [r] next_step
|
|
36
|
+
# @return [String, nil] the one thing to do first
|
|
37
|
+
# @!attribute [r] files_touched
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
# @!attribute [r] note
|
|
40
|
+
# @return [String, nil] what the harness saw, for a harness entry
|
|
41
|
+
LastKnownState = Data.define(:source, :at, :round, :status, :done, :remaining, :next_step, :files_touched, :note) do
|
|
42
|
+
# @return [Array<String>]
|
|
43
|
+
def self.sources = %w[agent harness]
|
|
44
|
+
|
|
45
|
+
# @param hash [Hash] as `state.json` holds it
|
|
46
|
+
# @return [OpenLanes::Plans::LastKnownState]
|
|
47
|
+
def self.from_h(hash)
|
|
48
|
+
new(**members.to_h { |k| [k, hash[k.to_s]] }.compact)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @raise [OpenLanes::Error] for a source or status outside the vocabulary
|
|
52
|
+
def initialize(source:, round:, status:, at: Time.now.iso8601, done: [], remaining: [], next_step: nil,
|
|
53
|
+
files_touched: [], note: nil)
|
|
54
|
+
raise Error, "source must be one of #{self.class.sources.join(", ")}" unless self.class.sources.include?(source)
|
|
55
|
+
raise Error, "status must be one of #{Ledger::STATUSES.join(", ")}" unless Ledger::STATUSES.include?(status)
|
|
56
|
+
|
|
57
|
+
super(source:, at: at.respond_to?(:iso8601) ? at.iso8601 : at.to_s, round: Integer(round), status:,
|
|
58
|
+
done: Array(done), remaining: Array(remaining), next_step:, files_touched: Array(files_touched), note:)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def agent? = source == "agent"
|
|
63
|
+
|
|
64
|
+
# Whether this entry may replace +other+, the one already kept. An
|
|
65
|
+
# agent's entry replaces anything; the harness's only what is not an
|
|
66
|
+
# agent's of the same round or later.
|
|
67
|
+
#
|
|
68
|
+
# @param other [OpenLanes::Plans::LastKnownState, nil]
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def supersedes?(other)
|
|
71
|
+
return true if other.nil? || agent?
|
|
72
|
+
|
|
73
|
+
!(other.agent? && other.round >= round)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Hash] as `state.json` holds it, empty fields left out
|
|
77
|
+
def to_json_h
|
|
78
|
+
to_h.transform_keys(&:to_s).reject { |_, v| v.nil? || v == [] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module OpenLanes
|
|
8
|
+
module Plans
|
|
9
|
+
# An agent is working on this plan right now. The folder name never
|
|
10
|
+
# changes for it, because a rename while an agent holds the old path is
|
|
11
|
+
# how its writes land in a directory that no longer exists.
|
|
12
|
+
#
|
|
13
|
+
# The file lives in the runtime state directory ({OpenLanes::Runtime}),
|
|
14
|
+
# named for the plan's ordinal, not in the plan folder: it says something
|
|
15
|
+
# true only on the machine that wrote it, and keyed by ordinal it
|
|
16
|
+
# survives a rename. A `locked-by.pid` an older release left in the
|
|
17
|
+
# folder is still read ({.legacy_path}), and removed with the lock.
|
|
18
|
+
#
|
|
19
|
+
# @!attribute [r] pid
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
# @!attribute [r] host
|
|
22
|
+
# @return [String]
|
|
23
|
+
# @!attribute [r] started_at
|
|
24
|
+
# @return [Time]
|
|
25
|
+
Lock = Data.define(:pid, :host, :started_at) do
|
|
26
|
+
# The plan folder's parent is the plans directory, and its parent the
|
|
27
|
+
# root {Runtime} keys by, so the harness and an agent handed the same
|
|
28
|
+
# `--dir` find one lock.
|
|
29
|
+
#
|
|
30
|
+
# @param dir [String] a plan folder
|
|
31
|
+
# @return [String] `<locks_dir>/<ordinal>.lock`
|
|
32
|
+
def self.path(dir)
|
|
33
|
+
name = File.basename(dir)
|
|
34
|
+
ordinal = Ordinal.from_dirname(name)&.to_s || name
|
|
35
|
+
File.join(Runtime.locks_dir(root: File.dirname(File.expand_path(dir), 2)), "#{ordinal}.lock")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param dir [String] a plan folder
|
|
39
|
+
# @return [String] where releases before the state directory wrote it
|
|
40
|
+
def self.legacy_path(dir) = File.join(dir, self::FILENAME)
|
|
41
|
+
|
|
42
|
+
# The one entry point every reader goes through: the state directory
|
|
43
|
+
# first, a legacy file second.
|
|
44
|
+
#
|
|
45
|
+
# @param dir [String] a plan folder
|
|
46
|
+
# @return [OpenLanes::Plans::Lock, nil] nil when there is no lock, or it cannot be read
|
|
47
|
+
def self.read(dir) = parse(path(dir)) || parse(legacy_path(dir))
|
|
48
|
+
|
|
49
|
+
# @param file [String]
|
|
50
|
+
# @return [OpenLanes::Plans::Lock, nil]
|
|
51
|
+
def self.parse(file)
|
|
52
|
+
data = JSON.parse(File.read(file))
|
|
53
|
+
new(pid: Integer(data.fetch("pid")), host: data.fetch("host").to_s, started_at: Time.iso8601(data.fetch("started_at")))
|
|
54
|
+
rescue Errno::ENOENT, Errno::EISDIR, JSON::ParserError, KeyError, ArgumentError, TypeError, NoMethodError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
private_class_method :parse
|
|
58
|
+
|
|
59
|
+
# Claim the plan for this process. Creating the file is the claim
|
|
60
|
+
# (`O_EXCL`), so two runs that both saw no lock cannot both write it
|
|
61
|
+
# and have the later one win in silence. A lock this process already
|
|
62
|
+
# holds is rewritten, which is how a pair's partner and a renamed
|
|
63
|
+
# folder find it; one left by a local process that has died is removed
|
|
64
|
+
# and the claim tried once more.
|
|
65
|
+
#
|
|
66
|
+
# @param dir [String] a plan folder
|
|
67
|
+
# @param pid [Integer]
|
|
68
|
+
# @param now [Time]
|
|
69
|
+
# @return [OpenLanes::Plans::Lock]
|
|
70
|
+
# @raise [OpenLanes::Plans::Lock::Held] when another live run holds it
|
|
71
|
+
def self.write(dir, pid: Process.pid, now: Time.now)
|
|
72
|
+
lock = new(pid:, host: Socket.gethostname, started_at: now.utc)
|
|
73
|
+
body = "#{JSON.generate(lock.to_json_hash)}\n"
|
|
74
|
+
legacy = parse(legacy_path(dir))
|
|
75
|
+
raise self::Held, "#{dir} is locked by pid #{legacy.pid} on #{legacy.host}" if legacy&.held_elsewhere? && legacy.pid != pid
|
|
76
|
+
|
|
77
|
+
2.times do
|
|
78
|
+
File.open(path(dir), File::WRONLY | File::CREAT | File::EXCL) { it.write(body) }
|
|
79
|
+
return lock
|
|
80
|
+
rescue Errno::EEXIST
|
|
81
|
+
held = read(dir)
|
|
82
|
+
if held&.local? && held.pid == pid
|
|
83
|
+
File.write(path(dir), body)
|
|
84
|
+
return lock
|
|
85
|
+
end
|
|
86
|
+
raise self::Held, "#{dir} is locked by pid #{held.pid} on #{held.host}" if held && !held.stale?
|
|
87
|
+
raise self::Held, "#{dir} has a lock being written or unreadable" if held.nil? && fresh?(dir)
|
|
88
|
+
|
|
89
|
+
remove(dir)
|
|
90
|
+
end
|
|
91
|
+
raise self::Held, "#{dir}: another run claimed it first"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param dir [String]
|
|
95
|
+
# @return [Boolean] an unreadable lock young enough to be one another
|
|
96
|
+
# run has just created and not yet filled
|
|
97
|
+
def self.fresh?(dir)
|
|
98
|
+
Time.now - File.mtime(path(dir)) < self::UNREADABLE_GRACE
|
|
99
|
+
rescue Errno::ENOENT
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
private_class_method :fresh?
|
|
103
|
+
|
|
104
|
+
# Also clears a legacy file, but only one a dead process on this host
|
|
105
|
+
# left: that is how `resync dirs` keeps clearing old stale locks, and a
|
|
106
|
+
# live or foreign one in the folder is somebody else's claim.
|
|
107
|
+
#
|
|
108
|
+
# @param dir [String] a plan folder
|
|
109
|
+
# @return [void]
|
|
110
|
+
def self.remove(dir)
|
|
111
|
+
FileUtils.rm_f(path(dir))
|
|
112
|
+
legacy = parse(legacy_path(dir))
|
|
113
|
+
FileUtils.rm_f(legacy_path(dir)) if legacy&.stale?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Boolean] whether this machine wrote it, so its pid means something here
|
|
117
|
+
def local? = host == Socket.gethostname
|
|
118
|
+
|
|
119
|
+
# Written on another host. Held for as long as it exists: never stale,
|
|
120
|
+
# never cleared here, since nothing on this machine can tell it is over.
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def foreign? = !local?
|
|
124
|
+
|
|
125
|
+
# Only a local lock can be checked; one from another host is never
|
|
126
|
+
# declared dead, since its pid names a process on a machine we cannot see.
|
|
127
|
+
#
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def alive?
|
|
130
|
+
return false unless local?
|
|
131
|
+
|
|
132
|
+
Process.kill(0, pid)
|
|
133
|
+
true
|
|
134
|
+
rescue Errno::ESRCH
|
|
135
|
+
false
|
|
136
|
+
rescue Errno::EPERM
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] written on this machine by a process that is gone
|
|
141
|
+
def stale? = local? && !alive?
|
|
142
|
+
|
|
143
|
+
# @return [Boolean] written by this very process, as a pair's partner finds it
|
|
144
|
+
def ours? = local? && pid == Process.pid
|
|
145
|
+
|
|
146
|
+
# Another run is in this folder: a live local process that is not this
|
|
147
|
+
# one, or any process on another machine. Such a folder is neither
|
|
148
|
+
# dispatched nor renamed here.
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean]
|
|
151
|
+
def held_elsewhere? = !ours? && !stale?
|
|
152
|
+
|
|
153
|
+
# @return [Hash] what the file and the snapshot carry
|
|
154
|
+
def to_json_hash = { pid:, host:, started_at: started_at.utc.iso8601, foreign: foreign? }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The lock file's name in a plan folder, where releases before the
|
|
158
|
+
# runtime state directory wrote it. Read, never written.
|
|
159
|
+
Lock::FILENAME = "locked-by.pid"
|
|
160
|
+
|
|
161
|
+
# Seconds an unreadable lock is taken for one being written. Between the
|
|
162
|
+
# `O_EXCL` create and the write the file is empty; older than this, it is
|
|
163
|
+
# debris from a crash and is cleared like a dead pid's.
|
|
164
|
+
Lock::UNREADABLE_GRACE = 5
|
|
165
|
+
|
|
166
|
+
class Lock
|
|
167
|
+
# Another live run holds the plan's lock; the claim was not made.
|
|
168
|
+
class Held < OpenLanes::Error; end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -36,8 +36,12 @@ module OpenLanes
|
|
|
36
36
|
|
|
37
37
|
# One entry.
|
|
38
38
|
#
|
|
39
|
+
# @!attribute [r] id
|
|
40
|
+
# @return [String, nil] what a drained copy is recognised by; nil on
|
|
41
|
+
# messages from before ids, and on every mailbox.md one
|
|
39
42
|
# @!attribute [r] number
|
|
40
|
-
# @return [Integer] 1-based, in order of writing
|
|
43
|
+
# @return [Integer] 1-based, in order of writing, within one copy of
|
|
44
|
+
# the plan's files
|
|
41
45
|
# @!attribute [r] at
|
|
42
46
|
# @return [String] when, as {TIME_FORMAT} writes it
|
|
43
47
|
# @!attribute [r] from
|
|
@@ -46,7 +50,18 @@ module OpenLanes
|
|
|
46
50
|
# @return [String] the agent it is for
|
|
47
51
|
# @!attribute [r] body
|
|
48
52
|
# @return [String]
|
|
49
|
-
|
|
53
|
+
# @!attribute [r] read
|
|
54
|
+
# @return [String] "yes" once its recipient ran `mail ack`, "no" until
|
|
55
|
+
# then. Set by the reader, never by delivery: a stream ack says the
|
|
56
|
+
# bytes arrived, not that anybody read them.
|
|
57
|
+
Message = Data.define(:number, :at, :from, :to, :body, :read, :id) do
|
|
58
|
+
# A message written before acks existed carries no `read`, and reads
|
|
59
|
+
# as unread: nobody ever said they read it.
|
|
60
|
+
def initialize(number:, at:, from:, to:, body:, read: "no", id: nil) = super
|
|
61
|
+
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def read? = read == "yes"
|
|
64
|
+
|
|
50
65
|
# @return [String] as it stands in the file
|
|
51
66
|
def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
|
|
52
67
|
end
|
|
@@ -109,6 +124,100 @@ module OpenLanes
|
|
|
109
124
|
from_state(state.message!(from:, to:, body: text, at:, after: legacy.map(&:number).max.to_i, command:))
|
|
110
125
|
end
|
|
111
126
|
|
|
127
|
+
# Keep a message the harness drained from Redis, unless this plan
|
|
128
|
+
# already has it, here or in `mailbox.md`. A different message whose
|
|
129
|
+
# number is taken is kept under the next free number: see
|
|
130
|
+
# {PlanState#deliver!}.
|
|
131
|
+
#
|
|
132
|
+
# @param message [Hash] as {Live::Mail#drain} returns it
|
|
133
|
+
# @return [OpenLanes::Plans::Mailbox::Message, nil] as written, nil when
|
|
134
|
+
# it was already here
|
|
135
|
+
# @raise [OpenLanes::Error] for one with no positive number, no body,
|
|
136
|
+
# or a sender or recipient that is not one agent name
|
|
137
|
+
def deliver(message)
|
|
138
|
+
number = message["number"]
|
|
139
|
+
raise Error, "a delivered message needs a positive number" unless number.is_a?(Integer) && number.positive?
|
|
140
|
+
raise Error, "a message needs a body" if message["body"].to_s.strip.empty?
|
|
141
|
+
raise Error, "a message needs --from and --to, each one agent name" unless name?(message["from"]) && name?(message["to"])
|
|
142
|
+
|
|
143
|
+
old = legacy
|
|
144
|
+
return nil if old.any? { |m| same_as_legacy?(m, message) }
|
|
145
|
+
|
|
146
|
+
state.deliver!(message, reserved: old.map(&:number))&.then { from_state(it) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# How many messages nobody has acked. The dashboard shows it, and
|
|
150
|
+
# nothing else reads it: an unacked message is a warning, never an input
|
|
151
|
+
# to a round (D3).
|
|
152
|
+
#
|
|
153
|
+
# @param recipient [String, nil] one reader, or everybody
|
|
154
|
+
# @return [Integer]
|
|
155
|
+
def unacked(recipient = nil)
|
|
156
|
+
messages.count { |m| !m.read? && (recipient.nil? || m.to == recipient) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The unacked messages, one entry per sender and recipient, so the
|
|
160
|
+
# dashboard can say who is waiting on whom rather than only how many.
|
|
161
|
+
#
|
|
162
|
+
# @return [Array<Hash{Symbol => String, Integer}>] `from`, `to`, `count`
|
|
163
|
+
# and `since`, the oldest one's timestamp as written, oldest pair first
|
|
164
|
+
def unacked_pairs
|
|
165
|
+
messages.reject(&:read?).group_by { [it.from, it.to] }
|
|
166
|
+
.map { |(from, to), sent| { from:, to:, count: sent.size, since: sent.map(&:at).min.to_s } }
|
|
167
|
+
.sort_by { it[:since] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether +number+ is a `mailbox.md` message: history, counted as read,
|
|
171
|
+
# where an ack records nothing.
|
|
172
|
+
#
|
|
173
|
+
# @param number [Integer, String]
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def legacy?(number)
|
|
176
|
+
number = Integer(number.to_s, exception: false)
|
|
177
|
+
legacy.any? { |m| m.number == number }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Mark one message read. A `mailbox.md` number is returned as it
|
|
181
|
+
# stands, with nothing written: ask {#legacy?} before saying it was
|
|
182
|
+
# recorded.
|
|
183
|
+
#
|
|
184
|
+
# @param number [Integer]
|
|
185
|
+
# @param by [String] the reader, who must be its recipient
|
|
186
|
+
# @return [OpenLanes::Plans::Mailbox::Message] as it stands now
|
|
187
|
+
# @raise [OpenLanes::Error] for a number with no message, or a reader
|
|
188
|
+
# it was not addressed to
|
|
189
|
+
def ack(number, by:)
|
|
190
|
+
number = Integer(number.to_s, exception: false)
|
|
191
|
+
raise Error, "a message number is a positive integer" unless number&.positive?
|
|
192
|
+
|
|
193
|
+
if (old = legacy.find { |m| m.number == number })
|
|
194
|
+
raise Error, "message ##{number} is for #{old.to}, not #{by}" unless old.to == by
|
|
195
|
+
|
|
196
|
+
return old
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
message = state.ack!(number, by:)
|
|
200
|
+
raise Error, "no message ##{number} in #{File.basename(dir)}" unless message
|
|
201
|
+
|
|
202
|
+
from_state(message)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The whole exchange as Markdown, for a person after the round. Returns
|
|
206
|
+
# the text and writes nothing: the JSON is the record, and a rendered
|
|
207
|
+
# copy beside it would be a second one to drift.
|
|
208
|
+
#
|
|
209
|
+
# @return [String]
|
|
210
|
+
def render
|
|
211
|
+
title = "# Mailbox: #{File.basename(dir)}\n"
|
|
212
|
+
return "#{title}\nNo messages.\n" if messages.empty?
|
|
213
|
+
|
|
214
|
+
entries = messages.map do |m|
|
|
215
|
+
"## #{m.number}#{SEPARATOR}#{m.at}#{SEPARATOR}#{m.from} → #{m.to}#{SEPARATOR}" \
|
|
216
|
+
"#{m.read? ? "read" : "unread"}\n\n#{m.body.strip}\n"
|
|
217
|
+
end
|
|
218
|
+
([title] + entries).join("\n")
|
|
219
|
+
end
|
|
220
|
+
|
|
112
221
|
private
|
|
113
222
|
|
|
114
223
|
# @return [OpenLanes::Plans::PlanState]
|
|
@@ -129,7 +238,32 @@ module OpenLanes
|
|
|
129
238
|
rescue ArgumentError
|
|
130
239
|
message["at"].to_s
|
|
131
240
|
end
|
|
132
|
-
Message.new(number: message["number"],
|
|
241
|
+
Message.new(number: message["number"],
|
|
242
|
+
at:,
|
|
243
|
+
from: message["from"],
|
|
244
|
+
to: message["to"],
|
|
245
|
+
body: message["body"],
|
|
246
|
+
read: message["read"] == "yes" ? "yes" : "no",
|
|
247
|
+
id: message["id"])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# mailbox.md has no ids, so a drained copy of one of its messages is
|
|
251
|
+
# recognised by what it says.
|
|
252
|
+
#
|
|
253
|
+
# @param old [OpenLanes::Plans::Mailbox::Message]
|
|
254
|
+
# @param message [Hash]
|
|
255
|
+
# @return [Boolean]
|
|
256
|
+
def same_as_legacy?(old, message)
|
|
257
|
+
old.from == message["from"] && old.to == message["to"] && old.body.strip == message["body"].to_s.strip &&
|
|
258
|
+
time_of(old.at) == time_of(message["at"])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @param text [String, nil]
|
|
262
|
+
# @return [Time, nil]
|
|
263
|
+
def time_of(text)
|
|
264
|
+
Time.parse(text.to_s)
|
|
265
|
+
rescue ArgumentError
|
|
266
|
+
nil
|
|
133
267
|
end
|
|
134
268
|
|
|
135
269
|
# @param value [Object]
|
|
@@ -149,7 +283,14 @@ module OpenLanes
|
|
|
149
283
|
text.to_s.each_line(chomp: true) do |line|
|
|
150
284
|
if (heading = HEADING.match(line))
|
|
151
285
|
messages << current.with(body: body.join("\n").strip) if current
|
|
152
|
-
|
|
286
|
+
# mailbox.md predates acking, and nobody is left to ack it: it
|
|
287
|
+
# is history, so it counts as read rather than as a warning forever.
|
|
288
|
+
current = Message.new(number: heading[1].to_i,
|
|
289
|
+
at: heading[2],
|
|
290
|
+
from: heading[3],
|
|
291
|
+
to: heading[4],
|
|
292
|
+
body: "",
|
|
293
|
+
read: "yes")
|
|
153
294
|
body = []
|
|
154
295
|
elsif current
|
|
155
296
|
body << line
|