ctrlrelay 0.13.0__tar.gz → 0.14.0__tar.gz
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.
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/CHANGELOG.md +27 -1
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/PKG-INFO +1 -1
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/config/orchestrator.yaml.example +14 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/configuration.md +32 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/operations.md +72 -0
- ctrlrelay-0.14.0/docs/superpowers/plans/2026-10-30-status-page.md +304 -0
- ctrlrelay-0.14.0/docs/superpowers/specs/2026-10-30-status-page-design.md +222 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/pyproject.toml +1 -1
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/server.py +27 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/cli.py +283 -1
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/config.py +29 -0
- ctrlrelay-0.14.0/src/ctrlrelay/core/events.py +173 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/github.py +39 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/state.py +311 -1
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/install.py +5 -4
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/dev.py +158 -10
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/post_merge.py +18 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/secops.py +62 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/task.py +55 -0
- ctrlrelay-0.14.0/src/ctrlrelay/templates/systemd/web.service.template +22 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/__init__.py +1 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/server.py +339 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/static/index.html +477 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/status.py +636 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/transcript.py +480 -0
- ctrlrelay-0.14.0/src/ctrlrelay/web/waiting.py +430 -0
- ctrlrelay-0.14.0/tests/fixtures/gh_search_issues.json +162 -0
- ctrlrelay-0.14.0/tests/fixtures/transcript_sample.jsonl +105 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_binary_resolution.py +3 -2
- ctrlrelay-0.14.0/tests/test_event_emission.py +1047 -0
- ctrlrelay-0.14.0/tests/test_events.py +425 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_install.py +22 -1
- ctrlrelay-0.14.0/tests/test_web_server.py +887 -0
- ctrlrelay-0.14.0/tests/test_web_status.py +1065 -0
- ctrlrelay-0.14.0/tests/test_web_transcript.py +549 -0
- ctrlrelay-0.14.0/tests/test_web_waiting.py +905 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/.gitignore +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/LICENSE +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/README.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/SECURITY.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/cli.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/development.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/index.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.13.0 → ctrlrelay-0.14.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
A read-only status page. `state.db` switches to WAL mode and gains an
|
|
13
|
+
`events` table; both happen automatically. The page is off until you start
|
|
14
|
+
it. **No config edit is required** to upgrade.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `ctrlrelay web start|stop|status` and a `ctrlrelay-web` systemd unit: a
|
|
19
|
+
read-only page plus `/api/status` and `/api/events` JSON showing what each
|
|
20
|
+
agent is doing (phase and last command), the waiting line, questions that
|
|
21
|
+
need you, today's counts, and a feed of dispatches, PRs, CI, reviews,
|
|
22
|
+
merges, messages and alerts. New `web:` config block (defaults
|
|
23
|
+
`127.0.0.1:8787`, 7 days). Requests for other host names are refused; add
|
|
24
|
+
LAN names to `web.allowed_hosts` (#217, closes #209)
|
|
25
|
+
- An `events` table, written best-effort by the daemon on a separate
|
|
26
|
+
connection with a 0.25s lock wait, so a slow or failed write never affects
|
|
27
|
+
the work it records (#217)
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `state.db` uses WAL mode so the page can read while the daemon writes. If
|
|
32
|
+
the switch cannot get the lock at startup it is deferred to the next start
|
|
33
|
+
instead of failing (#217)
|
|
34
|
+
|
|
10
35
|
## [0.13.0] - 2026-10-30
|
|
11
36
|
|
|
12
37
|
New database columns and one new config value. All of them are added
|
|
@@ -1771,7 +1796,8 @@ pipeline).
|
|
|
1771
1796
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1772
1797
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1773
1798
|
|
|
1774
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1799
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...HEAD
|
|
1800
|
+
[0.14.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...v0.14.0
|
|
1775
1801
|
[0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
|
|
1776
1802
|
[0.12.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.4
|
|
1777
1803
|
[0.12.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -88,6 +88,20 @@ dashboard:
|
|
|
88
88
|
url: "https://ctrlrelay-dashboard.example.com"
|
|
89
89
|
auth_token_env: "CTRLRELAY_DASHBOARD_TOKEN"
|
|
90
90
|
|
|
91
|
+
# Read-only status page: `ctrlrelay web start`. Loopback by default; set
|
|
92
|
+
# host to this machine's LAN address to open it from the home network.
|
|
93
|
+
# It has no login: never expose it to the internet.
|
|
94
|
+
web:
|
|
95
|
+
host: "127.0.0.1"
|
|
96
|
+
port: 8787
|
|
97
|
+
history_days: 7
|
|
98
|
+
refresh_seconds: 5
|
|
99
|
+
github_check_seconds: 60
|
|
100
|
+
stale_after_minutes: 15
|
|
101
|
+
# Extra names you open the page by from another machine (the Host
|
|
102
|
+
# header); loopback, web.host and this machine's hostname are allowed.
|
|
103
|
+
allowed_hosts: []
|
|
104
|
+
|
|
91
105
|
# Scheduled jobs run in-process by the poller daemon. Cron expressions are
|
|
92
106
|
# standard 5-field (minute hour dom month dow) and evaluate in the `timezone`
|
|
93
107
|
# set above. Change `secops_cron` to e.g. "0 6 * * 1" for weekly (Mondays).
|
|
@@ -26,6 +26,7 @@ paths: { ... }
|
|
|
26
26
|
claude: { ... }
|
|
27
27
|
transport: { ... }
|
|
28
28
|
dashboard: { ... }
|
|
29
|
+
web: { ... }
|
|
29
30
|
repos: [ ... ]
|
|
30
31
|
```
|
|
31
32
|
|
|
@@ -38,6 +39,7 @@ repos: [ ... ]
|
|
|
38
39
|
| `claude` | object | no | (defaults) | See [claude](#claude). |
|
|
39
40
|
| `transport` | object | **yes** | — | See [transport](#transport). |
|
|
40
41
|
| `dashboard` | object | no | (defaults) | See [dashboard](#dashboard). |
|
|
42
|
+
| `web` | object | no | (defaults) | The read-only status page. See [web](#web). |
|
|
41
43
|
| `repos` | list | no | `[]` | See [repos](#repos). |
|
|
42
44
|
| `schedules` | object | no | (defaults) | See [schedules](#schedules). |
|
|
43
45
|
| `personalization` | object | no | unset | Cross-machine sync of Claude state. See [personalization](#personalization) and [Personalization sync]({{ '/personalization/' | relative_url }}). |
|
|
@@ -205,6 +207,36 @@ dashboard:
|
|
|
205
207
|
The dashboard is optional. Leaving `url` empty (the default) is the supported
|
|
206
208
|
no-op configuration.
|
|
207
209
|
|
|
210
|
+
## web
|
|
211
|
+
|
|
212
|
+
The read-only status page served by `ctrlrelay web start` (see
|
|
213
|
+
[Operations: status page]({{ '/operations/' | relative_url }}#status-page)). Every key is optional;
|
|
214
|
+
without a `web:` block the defaults below apply.
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
web:
|
|
218
|
+
host: "127.0.0.1"
|
|
219
|
+
port: 8787
|
|
220
|
+
history_days: 7
|
|
221
|
+
refresh_seconds: 5
|
|
222
|
+
github_check_seconds: 60
|
|
223
|
+
stale_after_minutes: 15
|
|
224
|
+
allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Key | Type | Default | Description |
|
|
228
|
+
|---|---|---|---|
|
|
229
|
+
| `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
|
|
230
|
+
| `port` | int (1-65535) | `8787` | Port to listen on. |
|
|
231
|
+
| `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
|
|
232
|
+
| `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
|
|
233
|
+
| `github_check_seconds` | int (>= 15) | `60` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). |
|
|
234
|
+
| `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
|
|
235
|
+
| `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
|
|
236
|
+
|
|
237
|
+
The page has no login and shows repository names, issue titles and the
|
|
238
|
+
questions agents asked. **Never expose it to the internet.**
|
|
239
|
+
|
|
208
240
|
## repos
|
|
209
241
|
|
|
210
242
|
A list of repositories the orchestrator manages.
|
|
@@ -21,6 +21,7 @@ ctrlrelay generates platform-appropriate service unit files for you with
|
|
|
21
21
|
|---|---|---|
|
|
22
22
|
| Bridge | `ctrlrelay bridge start` | Delivers BLOCKED questions to Telegram and routes answers back. |
|
|
23
23
|
| Poller | `ctrlrelay poller start --interval 300` | Watches GitHub for newly assigned issues and runs the dev pipeline. |
|
|
24
|
+
| Status page (optional) | `ctrlrelay web start` | Read-only page and JSON API of what ctrlrelay is doing. See [Status page](#status-page). |
|
|
24
25
|
|
|
25
26
|
Both should restart on failure and on login.
|
|
26
27
|
|
|
@@ -234,6 +235,76 @@ systemctl --user status ctrlrelay-poller
|
|
|
234
235
|
journalctl --user -u ctrlrelay-poller -f
|
|
235
236
|
```
|
|
236
237
|
|
|
238
|
+
## Status page
|
|
239
|
+
|
|
240
|
+
`ctrlrelay web start` serves a read-only page and JSON API showing what each
|
|
241
|
+
agent is doing (phase, last command), what is waiting and why, the questions
|
|
242
|
+
that need an answer, and a feed of what ctrlrelay did and said. It is a third
|
|
243
|
+
process next to the bridge and the poller, so a slow or broken page can never
|
|
244
|
+
stall them.
|
|
245
|
+
|
|
246
|
+
It is **read-only**: it opens `state.db` read-only, reads the tail of each
|
|
247
|
+
agent's transcript, and asks GitHub (through the `gh` login ctrlrelay already
|
|
248
|
+
uses) for the waiting line once a minute. It writes nothing but its PID file
|
|
249
|
+
(`web.pid`, next to `state.db`), and serves `GET` only; any other method is
|
|
250
|
+
`405`. It has **no login** and shows repository names, issue titles and the
|
|
251
|
+
questions agents asked, so **never expose it to the internet**. Keep it on
|
|
252
|
+
loopback or on the home network.
|
|
253
|
+
|
|
254
|
+
| Path | What |
|
|
255
|
+
|---|---|
|
|
256
|
+
| `/` | The page. Refreshes itself every `web.refresh_seconds`. |
|
|
257
|
+
| `/api/status` | Everything on the page, as JSON (the contract is in `src/ctrlrelay/web/status.py`). A section that could not be read is `null` and `errors` says why; `[]` means "read, and empty". The feed holds at most the newest `feed_limit` (200) events: `feed_total` is how many the history window holds (`null` if unreadable) and `feed_truncated` is `true` when some were left out, in which case the page says "showing newest N of M". |
|
|
258
|
+
| `/api/events?since=<epoch seconds>` | The feed, newest first, at most `limit` (200) events. Without `since`, the last `web.history_days` days. Carries the same `feed_total` and `feed_truncated`, plus the `limit` used. |
|
|
259
|
+
|
|
260
|
+
Try it on loopback first (the default `web.host` is `127.0.0.1`, port `8787`):
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
ctrlrelay web start
|
|
264
|
+
ctrlrelay web status
|
|
265
|
+
curl -s http://127.0.0.1:8787/api/status | python3 -m json.tool | head -40
|
|
266
|
+
curl -s "http://127.0.0.1:8787/api/events?since=$(( $(date +%s) - 3600 ))"
|
|
267
|
+
ctrlrelay web stop
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
To open it from another machine on the home network, set `web.host` in
|
|
271
|
+
`orchestrator.yaml` to this machine's LAN address (see
|
|
272
|
+
[web]({{ '/configuration/' | relative_url }}#web)), then run it under
|
|
273
|
+
systemd. `ctrlrelay install systemd` renders `ctrlrelay-web.service` with the
|
|
274
|
+
other two units. It refuses to overwrite units that already exist, so on a
|
|
275
|
+
host where the bridge and poller are installed, render into a scratch
|
|
276
|
+
directory and copy only the web unit:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
ctrlrelay install systemd --workdir ~/Projects/ctrlrelay --target-dir ~/ctrlrelay-units
|
|
280
|
+
cp ~/ctrlrelay-units/ctrlrelay-web.service ~/.config/systemd/user/
|
|
281
|
+
rm -r ~/ctrlrelay-units # the other two carry the chat token
|
|
282
|
+
systemctl --user daemon-reload
|
|
283
|
+
systemctl --user enable --now ctrlrelay-web.service
|
|
284
|
+
journalctl --user -u ctrlrelay-web -n 20
|
|
285
|
+
tail -n 20 ~/.ctrlrelay/logs/web.log
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The first line in `web.log` is a `web.started` event naming the address it
|
|
289
|
+
bound. If `web.host` is `0.0.0.0` it also logs a warning: that listens on
|
|
290
|
+
every interface, which is rarely what you want.
|
|
291
|
+
|
|
292
|
+
The page answers only to names it knows: `localhost`, `127.0.0.1`, `[::1]`,
|
|
293
|
+
`web.host`, and this machine's own hostname (plus `.local`). A request whose
|
|
294
|
+
`Host` header carries any other name gets `421 misdirected request`, and the
|
|
295
|
+
refused name is logged once as `web.misdirected_host`. So if you open it from
|
|
296
|
+
another machine by a name that is none of those, for example a DNS name your
|
|
297
|
+
router hands out, add that name to `web.allowed_hosts` and restart the unit:
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
web:
|
|
301
|
+
host: "192.168.1.20"
|
|
302
|
+
allowed_hosts: ["mybox.lan"]
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`grep misdirected_host ~/.ctrlrelay/logs/web.log` shows any name that was
|
|
306
|
+
refused, which is the quickest way to find the one to add.
|
|
307
|
+
|
|
237
308
|
## Scheduled jobs
|
|
238
309
|
|
|
239
310
|
The poller daemon also hosts an in-process cron (APScheduler). The jobs run
|
|
@@ -300,6 +371,7 @@ Tail them:
|
|
|
300
371
|
tail -f ~/.ctrlrelay/logs/poller.log
|
|
301
372
|
tail -f ~/.ctrlrelay/logs/bridge.log
|
|
302
373
|
tail -f ~/.ctrlrelay/logs/poller.error.log
|
|
374
|
+
tail -f ~/.ctrlrelay/logs/web.log # if the status page is installed
|
|
303
375
|
```
|
|
304
376
|
|
|
305
377
|
The poller prints one line per detected issue and one line per pipeline outcome.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Status page plan
|
|
3
|
+
layout: default
|
|
4
|
+
nav_order: 101
|
|
5
|
+
nav_exclude: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Status Page Implementation Plan
|
|
9
|
+
|
|
10
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
11
|
+
|
|
12
|
+
**Goal:** A read-only web page and JSON API, served by a new `ctrlrelay web` process, that show what each agent is doing (phase, last command), what is waiting, what needs an answer, and a feed of what ctrlrelay did and said.
|
|
13
|
+
|
|
14
|
+
**Architecture:** The daemon gains one thing: best-effort rows in a new `events` table (and WAL mode on `state.db`). A separate `ctrlrelay web` process reads `state.db` read-only, tails agent transcripts, asks GitHub once a minute for the waiting line, and serves `/`, `/api/status`, `/api/events` from the standard-library HTTP server.
|
|
15
|
+
|
|
16
|
+
**Tech Stack:** Python 3.12, sqlite3, `http.server.ThreadingHTTPServer`, existing `GitHubCLI` (`gh`), typer, pydantic, pytest + pytest-asyncio (explicit `@pytest.mark.asyncio`, not auto mode).
|
|
17
|
+
|
|
18
|
+
**Spec:** `docs/superpowers/specs/2026-10-30-status-page-design.md`
|
|
19
|
+
|
|
20
|
+
## Global Constraints
|
|
21
|
+
|
|
22
|
+
- Read-only: the web process never writes to `state.db`, the filesystem (except its pid file), or GitHub. No endpoint changes state; only `GET` is served, anything else is `405`.
|
|
23
|
+
- No new runtime dependency. Standard library HTTP server; no external CDN in the page.
|
|
24
|
+
- Daemon changes are limited to: WAL mode, and best-effort event writes that log and swallow every exception and never change, delay or fail the work they describe.
|
|
25
|
+
- Unknown is shown as unknown: missing transcript, busy database, failed GitHub call each produce an explicit `"unknown"`/`null` plus a reason, never an empty list that reads as "nothing".
|
|
26
|
+
- Config defaults: `host: 127.0.0.1`, `port: 8787`, `history_days: 7`, refresh every 5 s, GitHub checked every 60 s, "looks stuck" after 15 minutes.
|
|
27
|
+
- Every timestamp in JSON is a pair: `<name>_ts` (epoch seconds, int) and `<name>` (ISO 8601 UTC string).
|
|
28
|
+
- No secrets in output: no environment, no tokens, no full prompts. Commands are truncated to 200 chars.
|
|
29
|
+
- Run the suite on Python 3.12: `uv run -p 3.12 --extra dev pytest` and `uv run -p 3.12 --extra dev ruff check src tests`.
|
|
30
|
+
|
|
31
|
+
## Review Focus
|
|
32
|
+
|
|
33
|
+
1. An issue title or a shell command containing `<script>` or `"` renders as text on the page, never as HTML (titles come from GitHub, where anyone can file on a public repo). Test in Task 6.
|
|
34
|
+
2. A transcript whose last line is half-written, a transcript dir with no `.jsonl`, or a multi-MB transcript gives the last complete step or `unknown`, never an exception, and reads at most the tail. Test in Task 3.
|
|
35
|
+
3. `state.db` missing, locked, or older than the `events` table returns `200` with `"error"` fields and `unknown` sections, not a `500`. Test in Task 5.
|
|
36
|
+
4. `gh` failing (not logged in, rate limit, timeout) leaves the waiting line `unknown` with the time of the last good answer, not empty. Test in Task 4.
|
|
37
|
+
5. A secops session (no issue number, no title) and a `task` pipeline session render without errors. Test in Task 5.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## File map
|
|
42
|
+
|
|
43
|
+
| File | Responsibility |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `src/ctrlrelay/core/state.py` (modify) | WAL, `events` table, `record_event`, `list_events`, `prune_events` |
|
|
46
|
+
| `src/ctrlrelay/core/events.py` (create) | `EventKind`, `emit_event()` best-effort wrapper |
|
|
47
|
+
| `src/ctrlrelay/pipelines/dev.py`, `task.py`, `secops.py`, `post_merge.py`, `bridge/server.py`, `cli.py` (modify) | call `emit_event` at existing points |
|
|
48
|
+
| `src/ctrlrelay/web/__init__.py` (create) | package marker |
|
|
49
|
+
| `src/ctrlrelay/web/transcript.py` (create) | find and tail an agent transcript, classify phase |
|
|
50
|
+
| `src/ctrlrelay/web/waiting.py` (create) | compute the waiting line from GitHub + poller rules |
|
|
51
|
+
| `src/ctrlrelay/web/status.py` (create) | read-only DB snapshot → JSON contract |
|
|
52
|
+
| `src/ctrlrelay/web/server.py` (create) | HTTP server, routes, background GitHub refresh |
|
|
53
|
+
| `src/ctrlrelay/web/static/index.html` (create) | the page (layout B) |
|
|
54
|
+
| `src/ctrlrelay/core/config.py` (modify) | `WebConfig` |
|
|
55
|
+
| `src/ctrlrelay/core/github.py` (modify) | `search_assigned_issues` |
|
|
56
|
+
| `src/ctrlrelay/templates/systemd/web.service.template`, `install.py` (create/modify) | third unit |
|
|
57
|
+
| `docs/configuration.md`, `docs/operations.md` (modify) | `web:` block, how to enable |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### Task 1: WAL mode and the events table
|
|
62
|
+
|
|
63
|
+
**Files:**
|
|
64
|
+
- Modify: `src/ctrlrelay/core/state.py` (`SCHEMA` :15-179, `__init__` :189-202, new methods near `list_pr_watches` :1160)
|
|
65
|
+
- Create: `src/ctrlrelay/core/events.py`
|
|
66
|
+
- Test: `tests/test_events.py`
|
|
67
|
+
|
|
68
|
+
**Interfaces:**
|
|
69
|
+
- Produces:
|
|
70
|
+
- `StateDB.record_event(kind: str, *, repo: str | None = None, issue_number: int | None = None, session_id: str | None = None, detail: dict | None = None, ts: int | None = None) -> None` (commits)
|
|
71
|
+
- `StateDB.list_events(*, since_ts: int = 0, limit: int = 200) -> list[dict]` newest first; `detail` decoded from JSON
|
|
72
|
+
- `StateDB.prune_events(older_than_ts: int) -> int`
|
|
73
|
+
- `events.EventKind` (`StrEnum`): `DISPATCHED="dispatched"`, `DONE="done"`, `BLOCKED="blocked"`, `FAILED="failed"`, `PR_OPENED="pr_opened"`, `MERGED="merged"`, `CI_STARTED="ci_started"`, `CI_FINISHED="ci_finished"`, `REVIEW_STARTED="review_started"`, `REVIEW_FINISHED="review_finished"`, `MESSAGE_SENT="message_sent"`, `QUESTION_POSTED="question_posted"`, `QUESTION_HELD="question_held"`, `ALERT_SENT="alert_sent"`, `RESTART="restart"`
|
|
74
|
+
- `events.emit_event(state_db: StateDB | None, kind: EventKind, **fields) -> None`: no-op when `state_db` is None; any exception is logged via `log_event(_logger, "events.write_failed", kind=..., reason=type(e).__name__)` and swallowed.
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 1: Write the failing tests** in `tests/test_events.py`:
|
|
77
|
+
- `test_state_db_uses_wal`: `StateDB(tmp_path/"s.db")`; `db.execute("PRAGMA journal_mode").fetchone()[0] == "wal"`.
|
|
78
|
+
- `test_record_and_list_events_newest_first`: record `dispatched` at ts=100 and `merged` at ts=200 with `detail={"pr": 5}`; `list_events()` returns kinds `["merged","dispatched"]` and `[0]["detail"] == {"pr": 5}`; `list_events(since_ts=150)` returns only `merged`.
|
|
79
|
+
- `test_prune_events`: rows at 100 and 200, `prune_events(150) == 1`, one row left.
|
|
80
|
+
- `test_existing_db_gains_events_table`: create a DB with the old schema by running `SCHEMA` minus the events DDL on a raw `sqlite3` connection, then open with `StateDB`; `record_event` works.
|
|
81
|
+
- `test_emit_event_swallows_failure`: a `StateDB` whose `record_event` raises `sqlite3.OperationalError("database is locked")` (monkeypatch); `emit_event(db, EventKind.DONE, repo="a/b")` returns `None`, and `caplog` holds `events.write_failed`.
|
|
82
|
+
- `test_emit_event_none_db_is_noop`.
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_events.py -v`. Expected: FAIL (no `record_event`, no `ctrlrelay.core.events`).
|
|
85
|
+
|
|
86
|
+
- [ ] **Step 3: Implement.** In `__init__`, right after connect: `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`. Add to `SCHEMA`:
|
|
87
|
+
|
|
88
|
+
```sql
|
|
89
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
ts INTEGER NOT NULL,
|
|
92
|
+
kind TEXT NOT NULL,
|
|
93
|
+
repo TEXT,
|
|
94
|
+
issue_number INTEGER,
|
|
95
|
+
session_id TEXT,
|
|
96
|
+
detail TEXT
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`detail` is `json.dumps(detail)` or NULL. `emit_event` lives in `core/events.py`.
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 4: Run** the same command. Expected: PASS. Then the full suite; expected: same count as main plus the new tests, 0 failed.
|
|
105
|
+
|
|
106
|
+
- [ ] **Step 5: Commit** `git add src/ctrlrelay/core/state.py src/ctrlrelay/core/events.py tests/test_events.py && git commit -m "feat(state): events table and WAL mode for the status page (#209)"`
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
### Task 2: Emit events from the daemon
|
|
111
|
+
|
|
112
|
+
**Files:**
|
|
113
|
+
- Modify: `src/ctrlrelay/pipelines/dev.py` (running INSERT :1282-1291, final UPDATE :1503-1508, failed :1605, `add_pending_resume` :1517, PR number read :1362, `_run_verify` :1045-1105, `_review_and_comment` :904-933), `src/ctrlrelay/pipelines/task.py` (:375, :431, :471), `src/ctrlrelay/pipelines/secops.py` (:746, :895, :945, :992, `_question_hold` :122), `src/ctrlrelay/pipelines/post_merge.py` (:355-359), `src/ctrlrelay/bridge/server.py` (SEND :467-478, ASK :545-550), `src/ctrlrelay/cli.py` (`deliver_relay_alert` after `mark_dispatch_alert_delivered` :1140; `poller_start` after `scheduler.started`; `_run_question_expiry_sweeper` :3020)
|
|
114
|
+
- Test: `tests/test_event_emission.py`
|
|
115
|
+
|
|
116
|
+
**Interfaces:**
|
|
117
|
+
- Consumes: `emit_event`, `EventKind`, `StateDB.list_events`, `StateDB.prune_events` (Task 1).
|
|
118
|
+
- Produces: event rows with these `detail` keys, which Task 5 reads:
|
|
119
|
+
- `dispatched`: `{"pipeline": str}`
|
|
120
|
+
- `done`/`failed`/`blocked`: `{"pipeline": str, "exit_code": int|None, "question": str|None}` (question truncated to 300 chars)
|
|
121
|
+
- `pr_opened`, `merged`: `{"pr_number": int, "pr_url": str|None}`
|
|
122
|
+
- `ci_started`/`ci_finished`, `review_started`/`review_finished`: `{"pr_number": int}`, finished adds `{"ok": bool}`
|
|
123
|
+
- `message_sent`: `{"request_id": str|None, "chars": int}`; `question_posted`: `{"request_id": str|None}`
|
|
124
|
+
- `question_held`: `{"held_until": int}`; `alert_sent`: `{"consecutive_failures": int|None}`; `restart`: `{"process": "poller"|"bridge", "version": str}`
|
|
125
|
+
|
|
126
|
+
- [ ] **Step 1: Write the failing tests** in `tests/test_event_emission.py`, each driving the real function with the repo's existing fakes (follow the fixtures already used by `tests/test_dev_pipeline*.py`, `tests/test_secops*.py`, `tests/test_post_merge*.py`, `tests/test_bridge_server.py`) and asserting on `state_db.list_events()`:
|
|
127
|
+
- `test_dev_run_records_dispatched_pr_ci_review_done`: a dev run that opens PR 7 and passes CI and review yields kinds in time order `dispatched, pr_opened, ci_started, ci_finished, review_started, review_finished, done`, all with the session's `session_id`, `pr_opened.detail["pr_number"] == 7`.
|
|
128
|
+
- `test_dev_blocked_records_blocked_with_question`.
|
|
129
|
+
- `test_task_and_secops_record_dispatched_and_outcome`: secops rows have `issue_number is None`.
|
|
130
|
+
- `test_secops_hold_records_question_held` with `detail["held_until"]` equal to the hold time.
|
|
131
|
+
- `test_pr_watch_merge_records_merged`.
|
|
132
|
+
- `test_bridge_send_and_ask_record_events`.
|
|
133
|
+
- `test_event_write_failure_does_not_change_the_run`: patch `StateDB.record_event` to raise; the dev run result and its `sessions` row equal those of the same run without the patch.
|
|
134
|
+
- `test_hourly_sweep_prunes_events_past_history`: events at now-8d and now-1d; after the sweep only the 1-day one remains (window: `config.web.history_days`, default 7 → use 7 here since Task 6 adds the config; read it with `getattr(getattr(config, "web", None), "history_days", 7)`).
|
|
135
|
+
|
|
136
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_event_emission.py -v`. Expected: FAIL (no rows).
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 3: Implement.** Add one `emit_event(...)` call at each point listed under Files, after the work it describes has happened. The prune goes in `_run_question_expiry_sweeper` before its early return on missing TTL, in its own `try` that logs and continues, so it runs even with no chat transport.
|
|
139
|
+
|
|
140
|
+
- [ ] **Step 4: Run** the new file, then the full suite. Expected: PASS, no existing test changed.
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 5: Commit** `git commit -m "feat(events): record dispatch, PR, CI, review, merge and message events (#209)"` with the files listed above staged by name.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
### Task 3: Agent transcript reader
|
|
147
|
+
|
|
148
|
+
**Files:**
|
|
149
|
+
- Create: `src/ctrlrelay/web/__init__.py`, `src/ctrlrelay/web/transcript.py`
|
|
150
|
+
- Create: `tests/fixtures/transcript_sample.jsonl` (real rows, see Step 1)
|
|
151
|
+
- Test: `tests/test_web_transcript.py`
|
|
152
|
+
|
|
153
|
+
**Interfaces:**
|
|
154
|
+
- Produces:
|
|
155
|
+
- `Phase` (`StrEnum`): `READING="reading"`, `CODING="coding"`, `TESTING="testing"`, `CI="ci"`, `REVIEW="review"`, `PR="pr"`, `UNKNOWN="unknown"`
|
|
156
|
+
- `@dataclass(frozen=True) AgentActivity: phase: Phase; last_command: str | None; last_step_ts: int | None; transcript: str | None; reason: str | None`
|
|
157
|
+
- `transcript_dir_for(worktree_path: str, home: Path) -> Path`: `home/".claude"/"projects"/re.sub(r"[/._]", "-", worktree_path)`
|
|
158
|
+
- `classify(tool_name: str, tool_input: dict) -> Phase | None`
|
|
159
|
+
- `read_activity(worktree_path: str | None, *, home: Path | None = None, tail_bytes: int = 262_144) -> AgentActivity`
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 1: Capture a real fixture.** On alkaturbo, copy ~30 consecutive lines from a finished dev transcript under `~/.claude/projects/-home-ovalenzuela--ctrlrelay-worktrees-*/` that include `Read`, `Edit`, a `Bash` running `pytest`, and a `Bash` running `git push`. Replace repo-private text in `content` strings with `"..."`; keep keys, `type`, `timestamp`, tool names and inputs. Write failing tests:
|
|
162
|
+
- `test_transcript_dir_matches_measured_layout`: `transcript_dir_for("/home/u/.ctrlrelay/worktrees/AInvirion-yt-dev-215-49a5", Path("/home/u"))` == `Path("/home/u/.claude/projects/-home-u--ctrlrelay-worktrees-AInvirion-yt-dev-215-49a5")`.
|
|
163
|
+
- `test_classify`: `("Read",{})→READING`, `("Edit",{})→CODING`, `("Bash",{"command":"uv run pytest -q"})→TESTING`, `("Bash",{"command":"npx playwright test"})→TESTING`, `("Bash",{"command":"git push -u origin x"})→PR`, `("Bash",{"command":"gh pr create --fill"})→PR`, `("Bash",{"command":"sed -n '1,60p' f.py"})→READING`, `("TodoWrite",{})→None`.
|
|
164
|
+
- `test_read_activity_from_real_fixture`: last step's phase, `last_command`, `last_step_ts` match the fixture's last `tool_use`.
|
|
165
|
+
- `test_half_written_last_line_is_ignored`: fixture + a truncated JSON line → same result as without it.
|
|
166
|
+
- `test_no_transcript_is_unknown`: empty dir → `phase=UNKNOWN`, `reason="no transcript"`; `worktree_path=None` → `UNKNOWN`, `reason="no worktree"`.
|
|
167
|
+
- `test_reads_only_the_tail`: a 5 MB file whose last line is a `Bash pytest` step returns `TESTING`, and monkeypatched `open().read` is never asked for more than `tail_bytes`.
|
|
168
|
+
- `test_last_command_truncated_to_200`.
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_transcript.py -v`. Expected: FAIL (module missing).
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 3: Implement.** Newest `*.jsonl` by mtime in the dir. Seek to `max(0, size - tail_bytes)`, drop the first (partial) line and any line that fails `json.loads`. Walk `assistant` rows' `message.content` lists for `type == "tool_use"`. `last_command` is `input.command`, else `input.file_path`, else `input.pattern`, newline-collapsed, max 200 chars. Phase is `classify` of the last tool use that classifies to non-None. Test-runner patterns: `pytest`, `npm test`, `npm run test`, `npx playwright`, `go test`, `cargo test`, `jest`, `vitest`. PR patterns: `git push`, `gh pr create`. Read-only shell (everything else under `Bash`) → `READING`.
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 4: Run** the test file. Expected: PASS.
|
|
175
|
+
|
|
176
|
+
- [ ] **Step 5: Commit** `git commit -m "feat(web): read an agent's phase from its live transcript (#209)"`
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### Task 4: Waiting line
|
|
181
|
+
|
|
182
|
+
**Files:**
|
|
183
|
+
- Modify: `src/ctrlrelay/core/github.py` (new method after `list_assigned_issues` :273)
|
|
184
|
+
- Create: `src/ctrlrelay/web/waiting.py`
|
|
185
|
+
- Test: `tests/test_web_waiting.py`
|
|
186
|
+
|
|
187
|
+
**Interfaces:**
|
|
188
|
+
- Consumes: `IssuePoller._matched_exclude_label`, `_matched_include_label`, `_matched_require_label`, `_issue_is_assigned_to` (static, `core/poller.py:602-669`); `Config.repos[*].automation` (`exclude_labels`, `include_labels`, `require_labels`, `accept_foreign_assignments`).
|
|
189
|
+
- Produces:
|
|
190
|
+
- `GitHubCLI.search_assigned_issues(assignee: str, *, limit: int = 200) -> list[dict]`: `gh search issues --assignee <a> --state open --limit <n> --json repository,number,title,labels,assignees,updatedAt`; each dict gets `"repo": repository["nameWithOwner"]`.
|
|
191
|
+
- `@dataclass(frozen=True) WaitingItem: repo: str; issue_number: int; title: str; reason: str; updated_ts: int`
|
|
192
|
+
- `compute_waiting(config: Config, issues: list[dict], *, seen: set[str], running: list[dict], locks: dict[str, str]) -> list[WaitingItem]` (pure)
|
|
193
|
+
- `load_seen(poller_state_file: Path) -> set[str]`: the poller's `seen_issues` from `poller_state.json` (empty set if the file is missing; raises nothing).
|
|
194
|
+
- `class WaitingTracker` with `async def refresh(self) -> None` and `snapshot(self) -> dict` returning `{"items": [...], "status": "ok"|"unknown", "checked": iso, "checked_ts": int, "last_good": iso|None, "last_good_ts": int|None, "error": str|None}`. Username from `gh api user --jq .login` once, retried on next refresh if it failed.
|
|
195
|
+
|
|
196
|
+
- [ ] **Step 1: Write failing tests** in `tests/test_web_waiting.py`, with issue dicts in the exact shape `gh search issues --json ...` returns (capture one real response on alkaturbo into `tests/fixtures/gh_search_issues.json`):
|
|
197
|
+
- `test_only_configured_repos`.
|
|
198
|
+
- `test_label_rules_match_the_poller`: an issue with an exclude label is dropped; a repo with `require_labels=["ready"]` drops an issue without it; behaviour is asserted against calling the poller's own static methods on the same input.
|
|
199
|
+
- `test_unlabeled_foreign_assignment_needs_accept`: in a repo not in `accept_foreign_assignments`, an issue admitted by assignment only has reason `"waits for the poller to confirm the assignment"`; in an accepting repo it gets the normal reason.
|
|
200
|
+
- `test_seen_and_running_issues_are_dropped`.
|
|
201
|
+
- `test_reasons`: repo locked by session of issue 215 → `"same repo as #215"`; any session running elsewhere → `"poller busy with <repo>#<n>"`; nothing running → `"next poll"`.
|
|
202
|
+
- `test_order_is_config_repo_order_then_github_order`.
|
|
203
|
+
- `test_gh_failure_keeps_last_good` (asyncio): first refresh ok with 2 items, second refresh `GitHubError` → `status == "unknown"`, `items` are the last good 2, `last_good_ts` is the first refresh's, `error` set.
|
|
204
|
+
- `test_never_succeeded_is_unknown_not_empty`: first refresh fails → `status "unknown"`, `items == []`, `last_good is None`.
|
|
205
|
+
|
|
206
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_waiting.py -v`. Expected: FAIL.
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 3: Implement** as specified. `seen` keys use the poller's own key format (read it from `IssuePoller.mark_seen` :688). No other GitHub calls.
|
|
209
|
+
|
|
210
|
+
- [ ] **Step 4: Run.** Expected: PASS.
|
|
211
|
+
|
|
212
|
+
- [ ] **Step 5: Commit** `git commit -m "feat(web): compute the waiting line from GitHub with the poller's own rules (#209)"`
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### Task 5: Status snapshot (the JSON contract)
|
|
217
|
+
|
|
218
|
+
**Files:**
|
|
219
|
+
- Create: `src/ctrlrelay/web/status.py`
|
|
220
|
+
- Test: `tests/test_web_status.py`
|
|
221
|
+
|
|
222
|
+
**Interfaces:**
|
|
223
|
+
- Consumes: `read_activity` (Task 3), `WaitingTracker.snapshot` (Task 4), event kinds and detail keys (Task 2).
|
|
224
|
+
- Produces:
|
|
225
|
+
- `open_readonly(db_path: Path) -> sqlite3.Connection`: `sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)`, `row_factory = sqlite3.Row`.
|
|
226
|
+
- `build_status(*, db_path: Path, waiting: dict, version: str, host: str, now: int | None = None, stale_after_s: int = 900, history_days: int = 7, home: Path | None = None) -> dict`
|
|
227
|
+
- `list_feed(*, db_path: Path, since_ts: int, limit: int = 200) -> dict` → `{"events": [...], "error": str|None}`
|
|
228
|
+
|
|
229
|
+
The contract (keys exactly):
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
{
|
|
233
|
+
"version": str, "host": str, "generated": iso, "generated_ts": int,
|
|
234
|
+
"running": [ {"session_id", "pipeline", "repo", "issue_number": int|null,
|
|
235
|
+
"title": str|null, "started", "started_ts", "elapsed_s": int,
|
|
236
|
+
"phase": "reading|coding|testing|ci|review|pr|unknown",
|
|
237
|
+
"phase_source": "transcript|pipeline|none",
|
|
238
|
+
"last_command": str|null, "last_step", "last_step_ts",
|
|
239
|
+
"looks_stuck": bool, "reason": str|null } ],
|
|
240
|
+
"needs_you": [ {"session_id", "pipeline", "repo", "question", "asked", "asked_ts",
|
|
241
|
+
"held_until", "held_until_ts"} ],
|
|
242
|
+
"waiting": <WaitingTracker.snapshot()>,
|
|
243
|
+
"today": {"dispatched", "pr_opened", "merged", "failed", "blocked", "messages_sent"},
|
|
244
|
+
"feed": [ {"ts", "time", "kind", "repo", "issue_number", "session_id", "detail"} ],
|
|
245
|
+
"errors": [str]
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 1: Write failing tests**, building real DBs with `StateDB(tmp_path/"state.db")` and the real write methods:
|
|
250
|
+
- `test_running_dev_session_with_transcript_phase`: a running dev session whose worktree has the Task 3 fixture under a temp `home` → `phase == "pr"` (or the fixture's last phase), `phase_source == "transcript"`.
|
|
251
|
+
- `test_open_ci_event_overrides_transcript`: `ci_started` with no matching `ci_finished` for that session → `phase == "ci"`, `phase_source == "pipeline"`, and `looks_stuck is False` even when the transcript's last step is 2 hours old.
|
|
252
|
+
- `test_looks_stuck_after_threshold`: last step 16 min old, no open pipeline phase → `True`; 14 min → `False`.
|
|
253
|
+
- `test_secops_session_has_no_issue` and `test_task_session_renders`.
|
|
254
|
+
- `test_needs_you_lists_open_unanswered_questions` including `held_until`.
|
|
255
|
+
- `test_today_counts_from_events` using the local day of `now`.
|
|
256
|
+
- `test_missing_db_returns_unknown_sections`: nonexistent path → `running == []`, `errors` names the DB, no exception.
|
|
257
|
+
- `test_locked_db_reports_error`: hold an `EXCLUSIVE` transaction on a second connection → `errors` non-empty, no exception, returns within 3 s.
|
|
258
|
+
- `test_never_writes`: file mtime and size of `state.db` (and `-wal`) unchanged after `build_status`.
|
|
259
|
+
- `test_feed_since`.
|
|
260
|
+
|
|
261
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_status.py -v`. Expected: FAIL.
|
|
262
|
+
|
|
263
|
+
- [ ] **Step 3: Implement.** Pipeline phase = latest of `ci_started`/`review_started` for the session without a later matching `*_finished`. Title: from the waiting tracker's last GitHub answer when the issue is there, else `null` (no extra GitHub calls in this task).
|
|
264
|
+
|
|
265
|
+
- [ ] **Step 4: Run.** Expected: PASS.
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 5: Commit** `git commit -m "feat(web): build the status JSON from a read-only snapshot (#209)"`
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
### Task 6: Server, page, config, command and systemd unit
|
|
272
|
+
|
|
273
|
+
**Files:**
|
|
274
|
+
- Create: `src/ctrlrelay/web/server.py`, `src/ctrlrelay/web/static/index.html`, `src/ctrlrelay/templates/systemd/web.service.template`
|
|
275
|
+
- Modify: `src/ctrlrelay/core/config.py` (new `WebConfig`, field on `Config` :694-700), `src/ctrlrelay/cli.py` (new `web_app` typer group like `poller_app` :2212-2305), `src/ctrlrelay/install.py` (`render_systemd` :154 loop), `tests/test_install.py:68`, `docs/configuration.md`, `docs/operations.md`, `config/orchestrator.yaml.example`, `pyproject.toml` (only if `static/*.html` is not already shipped as package data; check with `uv build` and `unzip -l`)
|
|
276
|
+
- Test: `tests/test_web_server.py`, `tests/test_install.py`
|
|
277
|
+
|
|
278
|
+
**Interfaces:**
|
|
279
|
+
- Consumes: `build_status`, `list_feed` (Task 5), `WaitingTracker` (Task 4).
|
|
280
|
+
- Produces:
|
|
281
|
+
- `WebConfig(BaseModel)`: `host: str = "127.0.0.1"`, `port: int = Field(8787, ge=1, le=65535)`, `history_days: int = Field(7, ge=1)`, `refresh_seconds: int = Field(5, ge=1)`, `github_check_seconds: int = Field(60, ge=15)`, `stale_after_minutes: int = Field(15, ge=1)`; `Config.web: WebConfig = Field(default_factory=WebConfig)`.
|
|
282
|
+
- `make_server(config: Config, *, waiting: WaitingTracker) -> ThreadingHTTPServer`
|
|
283
|
+
- CLI: `ctrlrelay web start [--config/-c] [--foreground/-F]`, pid file `state_db.parent/"web.pid"`, same daemonize pattern as `poller start`.
|
|
284
|
+
- Unit `ctrlrelay-web.service`: `ExecStart=${CTRLRELAY_BIN} web start --foreground`, logs `append:%h/.ctrlrelay/logs/web.log`.
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 1: Write failing tests** in `tests/test_web_server.py` (server on port 0 in a thread, real DB from Task 5's helpers):
|
|
287
|
+
- `test_index_served_with_no_external_urls`: `GET /` 200, `text/html`, body contains no `http://` or `https://`.
|
|
288
|
+
- `test_api_status_contract`: `GET /api/status` 200, `application/json`, top-level keys equal the Task 5 contract.
|
|
289
|
+
- `test_api_events_since`; `test_api_events_bad_since_is_400` (`?since=abc`).
|
|
290
|
+
- `test_unknown_path_404`; `test_post_put_delete_are_405` and the DB is unchanged after them.
|
|
291
|
+
- `test_page_escapes_titles_and_commands`: a session titled `<img src=x onerror=alert(1)>` and a last command `"><script>` — load `index.html` and assert the page builds DOM with `textContent` (no `innerHTML` assignment of data anywhere in the file: `assert "innerHTML" not in html` or every `innerHTML` use is a static literal, checked by regex).
|
|
292
|
+
- `test_status_error_is_200_with_errors` when `state_db` is missing.
|
|
293
|
+
- `test_web_config_defaults`: `Config` without a `web` key → `host "127.0.0.1"`, `port 8787`, `history_days 7`.
|
|
294
|
+
- In `tests/test_install.py`: systemd render now writes `ctrlrelay-web.service` with the ExecStart above; update the `{"bridge","poller"}` expectation at :68 only for systemd if launchd is out of scope (keep launchd unchanged).
|
|
295
|
+
|
|
296
|
+
- [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_server.py tests/test_install.py -v`. Expected: FAIL.
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 3: Implement.** The waiting tracker refreshes on its own `asyncio` loop in a background thread every `github_check_seconds`; request handlers only read its last snapshot. The page is layout B from the spec: left column running cards (phase bar `reading → coding → testing → CI → review → PR`, last command, how long ago, "looks stuck" mark), "Needs you", "Waiting", "Today"; right column the feed. It polls `/api/status` every `refresh_seconds` with `fetch`, builds every data node with `textContent`, and shows the `errors` and any `unknown` section as visible text. No external URLs.
|
|
299
|
+
|
|
300
|
+
- [ ] **Step 4: Run** the two test files, then the full suite and ruff. Expected: all pass. Then run it for real: `uv run -p 3.12 ctrlrelay web start -F -c <a test config pointing at a copy of state.db>` and `curl -s localhost:8787/api/status | python -m json.tool | head -40`; expected: the contract with real sessions.
|
|
301
|
+
|
|
302
|
+
- [ ] **Step 5: Docs.** `docs/configuration.md`: the `web:` block with every field and default. `docs/operations.md`: enabling on a host (set `web.host` to the LAN address, `ctrlrelay install systemd`, `systemctl --user enable --now ctrlrelay-web`), and that it is read-only and must not be exposed to the internet. Run every command written there once.
|
|
303
|
+
|
|
304
|
+
- [ ] **Step 6: Commit** `git commit -m "feat(web): serve the read-only status page and JSON API (#209)"`
|