ctrlrelay 0.16.1__tar.gz → 0.16.3__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.16.1 → ctrlrelay-0.16.3}/CHANGELOG.md +43 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/PKG-INFO +1 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/operations.md +5 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/pyproject.toml +1 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/github.py +11 -7
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/poller.py +24 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/server.py +18 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/static/index.html +139 -12
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/status.py +113 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_github.py +11 -5
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_node_claims.py +1 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_poller.py +74 -2
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_usage.py +1 -1
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_server.py +227 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_waiting.py +5 -3
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/.gitignore +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/LICENSE +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/README.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/SECURITY.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/Gemfile +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/_config.yml +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/architecture.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/bridge.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/cli.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/configuration.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/development.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/getting-started.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/index.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/personalization.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/__init__.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/conftest.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_archived.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_audit.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_config.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_events.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_install.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_network.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_obs.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_redact.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_setup.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_state.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_transport.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.16.1 → ctrlrelay-0.16.3}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.3] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
A security fix: **upgrade every machine.** No config edit is required.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Only issues the operator assigned to themselves are taken, as intended.
|
|
17
|
+
The check read `actor` on GitHub's `assigned` events, which is the account
|
|
18
|
+
assigned (the operator), not the one who assigned it (`assigner`), so an
|
|
19
|
+
issue anyone else assigned to the operator was taken too. It now reads
|
|
20
|
+
`assigner`, refuses an event without one, refuses an unnamed assignment
|
|
21
|
+
after the operator's last one, and reads every page of an issue's events.
|
|
22
|
+
The status page's "assigned by someone else" uses the same check (#275,
|
|
23
|
+
closes #274)
|
|
24
|
+
|
|
25
|
+
## [0.16.2] - 2026-11-01
|
|
26
|
+
|
|
27
|
+
Status page changes only. **No config edit is required.**
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- The Today counters (dispatched, PRs opened, merged, failed, blocked,
|
|
32
|
+
messages sent) are buttons: each opens today's events of that kind under
|
|
33
|
+
the counters. A failed run shows why (exit code, the run's summary and its
|
|
34
|
+
stored output), a blocked one the question, PRs link to the PR. New
|
|
35
|
+
`/api/events?kind=<kind>&day=today` (#271, closes #257)
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- A GitHub API bucket whose reset time has passed since the last read shows
|
|
40
|
+
as "refilled at <time>, not read since" instead of "unknown"; search,
|
|
41
|
+
which refills every minute, usually shows that way (#272)
|
|
42
|
+
- More space under the header and between the Machines strip and "Who has
|
|
43
|
+
what" (#271)
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- The live feed now scrubs credential shapes from event details: a
|
|
48
|
+
question an agent asked could show a token on the page (#271)
|
|
49
|
+
|
|
10
50
|
## [0.16.1] - 2026-11-01
|
|
11
51
|
|
|
12
52
|
Fixes the chat bridge, which did not start on 0.16.0. **No config edit is
|
|
@@ -1976,7 +2016,9 @@ pipeline).
|
|
|
1976
2016
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1977
2017
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1978
2018
|
|
|
1979
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.
|
|
2019
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...HEAD
|
|
2020
|
+
[0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
|
|
2021
|
+
[0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
|
|
1980
2022
|
[0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
|
|
1981
2023
|
[0.16.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...v0.16.0
|
|
1982
2024
|
[0.15.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...v0.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.3
|
|
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/
|
|
@@ -264,6 +264,7 @@ loopback or on the home network.
|
|
|
264
264
|
| `/` | The page. Refreshes itself every `web.refresh_seconds`. |
|
|
265
265
|
| `/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". |
|
|
266
266
|
| `/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. |
|
|
267
|
+
| `/api/events?kind=<kind>&day=today` | One Today counter's events, for the server's local day, newest first, at most 200: `{kind, events, total, truncated, error}`. `kind` is `dispatched`, `pr_opened`, `merged`, `failed`, `blocked` or `message_sent`. A `failed` or `blocked` event also carries `reason`: `exit_code`, the run's `summary` (up to 500 characters) and `output_tail` (up to 2,000), both scrubbed of credential shapes again before they are sent, the `question` asked, and `session: "not found"` when the run's row is gone. `events` is `null` with `error` set when they could not be read. A query with `kind` and anything other than exactly `kind` and `day=today` is refused with 400, except that one with `since` is the feed, as before. A summary or question longer than 500 characters ends in "…". On the page, clicking a Today counter shows this list under it, read again with every refresh. |
|
|
267
268
|
|
|
268
269
|
The page header shows how long ago it last heard from the server ("updated
|
|
269
270
|
12s ago", ticking every second) and a dot that is green while the last good
|
|
@@ -290,7 +291,10 @@ machine last read it: `read_at`, and for `core`, `search` and `graphql` each
|
|
|
290
291
|
`{remaining, limit, resets_at, state}` with `state` one of `ok`, `low`, `out`,
|
|
291
292
|
or `unknown` once its reset time has passed (the numbers are then old), or
|
|
292
293
|
`"unknown"` in place of the object for a bucket it could not read; the whole value is `"unknown"`
|
|
293
|
-
before any reading. The page shows it in the **Usage** box
|
|
294
|
+
before any reading. The page shows it in the **Usage** box; a bucket whose
|
|
295
|
+
reset time has passed since the last read shows as "refilled at <time>, not
|
|
296
|
+
read since" (GitHub refills it at that time), and `search`, which refills
|
|
297
|
+
every minute, usually shows that way. Low and used-up
|
|
294
298
|
buckets are described in [configuration: github_api]({{ '/configuration/' | relative_url }}#github_api).
|
|
295
299
|
Which bucket a `gh` command spends is worked out from the command where the
|
|
296
300
|
command says: `gh api` REST paths are `core`, `gh api graphql` is `graphql`,
|
|
@@ -424,17 +424,21 @@ class GitHubCLI:
|
|
|
424
424
|
) -> list[dict[str, Any]]:
|
|
425
425
|
"""List ``assigned`` events for an issue in chronological order.
|
|
426
426
|
|
|
427
|
-
Returns the GitHub issue-events payload filtered to ``event == "assigned"
|
|
428
|
-
Each entry includes ``
|
|
429
|
-
``assignee`` (who was assigned)
|
|
430
|
-
|
|
427
|
+
Returns the GitHub issue-events payload filtered to ``event == "assigned"``,
|
|
428
|
+
every page of it. Each entry includes ``assigner`` (who performed the
|
|
429
|
+
assignment) and ``assignee`` (who was assigned); its ``actor`` is the
|
|
430
|
+
assignee too, not who made it (#274). Used by the poller to verify
|
|
431
|
+
that the most recent self-assignment was actually performed by the
|
|
432
|
+
operator.
|
|
431
433
|
"""
|
|
434
|
+
# Every page: on an issue with more than one page of events the
|
|
435
|
+
# latest assignment would otherwise be missed.
|
|
432
436
|
output = await self._run_gh(
|
|
433
|
-
"api",
|
|
437
|
+
"api", "--paginate",
|
|
434
438
|
f"/repos/{repo}/issues/{issue_number}/events",
|
|
435
|
-
"--jq", '
|
|
439
|
+
"--jq", '.[] | select(.event=="assigned")',
|
|
436
440
|
)
|
|
437
|
-
return json.loads(output) if
|
|
441
|
+
return [json.loads(line) for line in output.splitlines() if line.strip()]
|
|
438
442
|
|
|
439
443
|
async def get_issue(
|
|
440
444
|
self,
|
|
@@ -617,6 +617,25 @@ class IssuePoller:
|
|
|
617
617
|
return True
|
|
618
618
|
|
|
619
619
|
events = await self.github.list_assignment_events(repo, issue_number)
|
|
620
|
+
# An assignment after the operator's last one that names nobody
|
|
621
|
+
# cannot be ruled out as a newer one by someone else: refuse rather
|
|
622
|
+
# than skip past it. One before it is overtaken and does not count.
|
|
623
|
+
last_mine = max(
|
|
624
|
+
(i for i, e in enumerate(events)
|
|
625
|
+
if (e.get("assignee") or {}).get("login") == self.username),
|
|
626
|
+
default=-1,
|
|
627
|
+
)
|
|
628
|
+
if any(not (e.get("assignee") or {}).get("login")
|
|
629
|
+
for e in events[last_mine + 1:]):
|
|
630
|
+
log_event(
|
|
631
|
+
_logger,
|
|
632
|
+
"poll.issue.foreign_assignment",
|
|
633
|
+
repo=repo,
|
|
634
|
+
number=issue_number,
|
|
635
|
+
assigner_login=None,
|
|
636
|
+
reason="assignment_without_assignee",
|
|
637
|
+
)
|
|
638
|
+
return False
|
|
620
639
|
relevant = [
|
|
621
640
|
e
|
|
622
641
|
for e in events
|
|
@@ -634,8 +653,12 @@ class IssuePoller:
|
|
|
634
653
|
return False
|
|
635
654
|
|
|
636
655
|
# Events endpoint returns chronological order; the last one wins.
|
|
656
|
+
# Who made the assignment is ``assigner``. On an ``assigned`` event
|
|
657
|
+
# ``actor`` is the account assigned, which is always the operator
|
|
658
|
+
# here, so reading it let anyone's assignment through (#274). No
|
|
659
|
+
# ``assigner`` is treated as foreign.
|
|
637
660
|
latest = relevant[-1]
|
|
638
|
-
assigner_login = (latest.get("
|
|
661
|
+
assigner_login = (latest.get("assigner") or {}).get("login")
|
|
639
662
|
if assigner_login == self.username:
|
|
640
663
|
return True
|
|
641
664
|
|
|
@@ -45,7 +45,7 @@ from ctrlrelay.core.obs import get_logger, log_event
|
|
|
45
45
|
from ctrlrelay.core.usage import UsageStore, pause_for, usage_json
|
|
46
46
|
from ctrlrelay.web.fleet import PeerReader
|
|
47
47
|
from ctrlrelay.web.health import read_server_health
|
|
48
|
-
from ctrlrelay.web.status import build_status, list_feed
|
|
48
|
+
from ctrlrelay.web.status import DAY_KINDS, build_status, list_day_events, list_feed
|
|
49
49
|
|
|
50
50
|
_logger = get_logger("web.server")
|
|
51
51
|
|
|
@@ -206,6 +206,23 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
206
206
|
self._json(200, srv.status())
|
|
207
207
|
elif url.path == "/api/events":
|
|
208
208
|
query = urllib.parse.parse_qs(url.query, keep_blank_values=True)
|
|
209
|
+
if "kind" in query and "since" not in query:
|
|
210
|
+
# One Today counter's events (#257). A query with ``since``
|
|
211
|
+
# is the feed, as it was before ``kind`` existed.
|
|
212
|
+
kinds, days = query.get("kind"), query.get("day")
|
|
213
|
+
if (
|
|
214
|
+
len(kinds) != 1 or kinds[0] not in DAY_KINDS
|
|
215
|
+
or days != ["today"] or set(query) != {"kind", "day"}
|
|
216
|
+
):
|
|
217
|
+
self._json(400, {
|
|
218
|
+
"error": "kind must be one of " + ", ".join(sorted(DAY_KINDS))
|
|
219
|
+
+ ", with day=today and nothing else",
|
|
220
|
+
})
|
|
221
|
+
return
|
|
222
|
+
self._json(200, list_day_events(
|
|
223
|
+
db_path=srv.config.paths.state_db, kind=kinds[0],
|
|
224
|
+
))
|
|
225
|
+
return
|
|
209
226
|
values = query.get("since")
|
|
210
227
|
if values is None:
|
|
211
228
|
since = srv.default_since()
|
|
@@ -90,6 +90,17 @@ ul.plain > li { padding: 7px 0; border-bottom: 1px solid var(--line); }
|
|
|
90
90
|
ul.plain > li:last-child { border-bottom: 0; }
|
|
91
91
|
.today { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
|
92
92
|
.today .n { font-size: 22px; font-weight: 700; }
|
|
93
|
+
.today button.card {
|
|
94
|
+
font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%;
|
|
95
|
+
}
|
|
96
|
+
.today button.card:hover { border-color: var(--accent); }
|
|
97
|
+
.today button.card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
98
|
+
#today-list { margin-top: 10px; }
|
|
99
|
+
#today-list .reason { font-size: 13px; color: var(--muted); margin: 2px 0 0; }
|
|
100
|
+
#today-list pre {
|
|
101
|
+
white-space: pre-wrap; word-break: break-word; font-size: 12px; margin: 6px 0 0;
|
|
102
|
+
max-height: 260px; overflow: auto; background: var(--tag); padding: 8px; border-radius: 6px;
|
|
103
|
+
}
|
|
93
104
|
.feed .when { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; min-width: 4.5em; }
|
|
94
105
|
.feed .detail { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
|
|
95
106
|
a { color: var(--accent); text-decoration: none; }
|
|
@@ -108,8 +119,8 @@ a:hover, a:focus-visible { text-decoration: underline; }
|
|
|
108
119
|
.usage .bar { height: 6px; background: var(--line, #ddd); border-radius: 3px; margin: 2px 0 6px; }
|
|
109
120
|
.usage .bar > span { display: block; height: 100%; border-radius: 3px; background: currentColor; }
|
|
110
121
|
.usage .bar.high { color: var(--bad); }
|
|
111
|
-
.fleet { margin:
|
|
112
|
-
.strip { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
122
|
+
.fleet { margin: 18px 20px 4px; }
|
|
123
|
+
.strip { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; }
|
|
113
124
|
.strip .machine { border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
|
|
114
125
|
min-width: 190px; background: var(--panel); font-size: 13px; }
|
|
115
126
|
.strip .machine.bad { border-color: var(--bad); }
|
|
@@ -164,6 +175,7 @@ details > summary .count { font-weight: 400; margin-left: 6px; }
|
|
|
164
175
|
<section>
|
|
165
176
|
<h2>Today</h2>
|
|
166
177
|
<div id="today"><p class="unknown">loading…</p></div>
|
|
178
|
+
<div id="today-list" aria-live="polite"></div>
|
|
167
179
|
</section>
|
|
168
180
|
</div>
|
|
169
181
|
<div>
|
|
@@ -782,18 +794,32 @@ function renderGitHubBudget(api, now, lines) {
|
|
|
782
794
|
}
|
|
783
795
|
["core", "search", "graphql"].forEach(function (name) {
|
|
784
796
|
var b = api[name];
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
lines.push(el("div", "line", "GitHub " + name + " unknown"));
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
var high = b.state === "low" || b.state === "out";
|
|
791
|
-
lines.push(el("div", "line" + (high ? " high" : ""),
|
|
792
|
-
"GitHub " + name + " " + b.remaining + " of " + b.limit + " left" +
|
|
793
|
-
(b.state === "out" ? " (used up)" : "") + ", " + untilText(b.resets_at, now)));
|
|
797
|
+
var high = b && !refilled(b, now) && (b.state === "low" || b.state === "out");
|
|
798
|
+
lines.push(el("div", "line" + (high ? " high" : ""), budgetLine(name, b, now)));
|
|
794
799
|
});
|
|
795
800
|
}
|
|
796
801
|
|
|
802
|
+
// One bucket's line. A reading whose reset time has passed is old: GitHub
|
|
803
|
+
// has refilled that bucket since, so say that rather than "unknown" (search
|
|
804
|
+
// refills every minute and is read every few, so it is usually in this state).
|
|
805
|
+
function refilled(b, now) {
|
|
806
|
+
var reset = b && typeof b === "object" ? Date.parse(b.resets_at) : NaN;
|
|
807
|
+
return !isNaN(reset) && reset / 1000 <= now;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function budgetLine(name, b, now) {
|
|
811
|
+
if (!b || typeof b !== "object" || typeof b.remaining !== "number") {
|
|
812
|
+
return "GitHub " + name + " unknown";
|
|
813
|
+
}
|
|
814
|
+
if (refilled(b, now)) {
|
|
815
|
+
return "GitHub " + name + " refilled at " + clock(Date.parse(b.resets_at) / 1000) +
|
|
816
|
+
", not read since";
|
|
817
|
+
}
|
|
818
|
+
if (b.state === "unknown") { return "GitHub " + name + " unknown"; }
|
|
819
|
+
return "GitHub " + name + " " + b.remaining + " of " + b.limit + " left" +
|
|
820
|
+
(b.state === "out" ? " (used up)" : "") + ", " + untilText(b.resets_at, now);
|
|
821
|
+
}
|
|
822
|
+
|
|
797
823
|
function renderUsage(data, now) {
|
|
798
824
|
var target = document.getElementById("usage");
|
|
799
825
|
var n = data.node;
|
|
@@ -835,21 +861,121 @@ function renderUsage(data, now) {
|
|
|
835
861
|
put(target, lines);
|
|
836
862
|
}
|
|
837
863
|
|
|
864
|
+
// The event kind each Today counter counts (#257); the counter's own key
|
|
865
|
+
// differs only for messages.
|
|
866
|
+
var TODAY_KIND = { messages_sent: "message_sent" };
|
|
867
|
+
var todayOpen = null;
|
|
868
|
+
// Each list request is numbered; only the newest one may draw, so a slow
|
|
869
|
+
// answer for an earlier refresh or another counter never replaces a newer one.
|
|
870
|
+
var todaySeq = 0;
|
|
871
|
+
// The kind whose list request is still out. A refresh does not start
|
|
872
|
+
// another for it (on a slow server every answer would be overtaken and the
|
|
873
|
+
// list would stay at "loading…"); a click always does.
|
|
874
|
+
var todayBusy = null;
|
|
875
|
+
// A refresh skipped while a request was out: read again when it lands, so
|
|
876
|
+
// the list does not lag the count by more than one answer.
|
|
877
|
+
var todayStale = false;
|
|
878
|
+
|
|
838
879
|
function renderToday(data) {
|
|
839
880
|
var target = document.getElementById("today");
|
|
840
881
|
var t = data.today || {};
|
|
841
882
|
var grid = el("div", "today");
|
|
842
883
|
TODAY.forEach(function (pair) {
|
|
843
884
|
var v = t[pair[0]];
|
|
844
|
-
var
|
|
885
|
+
var kind = TODAY_KIND[pair[0]] || pair[0];
|
|
886
|
+
var cell = el("button", "card");
|
|
887
|
+
cell.type = "button";
|
|
888
|
+
cell.setAttribute("aria-pressed", todayOpen === kind ? "true" : "false");
|
|
889
|
+
cell.title = "Show today's " + pair[1];
|
|
845
890
|
if (typeof v === "number") { cell.appendChild(el("div", "n", v)); }
|
|
846
891
|
else { cell.appendChild(el("div", "n unknown", "unknown")); }
|
|
847
892
|
cell.appendChild(el("div", "muted", pair[1]));
|
|
893
|
+
cell.addEventListener("click", function () {
|
|
894
|
+
todayOpen = todayOpen === kind ? null : kind;
|
|
895
|
+
renderToday(lastData || data);
|
|
896
|
+
if (todayOpen) { put(document.getElementById("today-list"), [empty("loading…")]); }
|
|
897
|
+
loadTodayList(true);
|
|
898
|
+
});
|
|
848
899
|
grid.appendChild(cell);
|
|
849
900
|
});
|
|
850
901
|
put(target, [grid]);
|
|
851
902
|
}
|
|
852
903
|
|
|
904
|
+
// The list behind the open counter, read again with every refresh so it
|
|
905
|
+
// follows the count.
|
|
906
|
+
function loadTodayList(force) {
|
|
907
|
+
var target = document.getElementById("today-list");
|
|
908
|
+
var kind = todayOpen;
|
|
909
|
+
if (!kind) { todaySeq++; todayBusy = null; put(target, []); return; }
|
|
910
|
+
if (!force && todayBusy === kind) { todayStale = true; return; }
|
|
911
|
+
var seq = ++todaySeq;
|
|
912
|
+
todayBusy = kind;
|
|
913
|
+
todayStale = false;
|
|
914
|
+
fetch("/api/events?kind=" + encodeURIComponent(kind) + "&day=today", { cache: "no-store" })
|
|
915
|
+
.then(function (resp) { return resp.json(); })
|
|
916
|
+
.then(function (data) { if (seq === todaySeq) { renderTodayList(data); } })
|
|
917
|
+
.catch(function (err) {
|
|
918
|
+
if (seq === todaySeq) {
|
|
919
|
+
put(target, [unknown("unknown: the list could not be read (" +
|
|
920
|
+
(err && err.message ? err.message : "error") + ")")]);
|
|
921
|
+
}
|
|
922
|
+
})
|
|
923
|
+
.then(function () {
|
|
924
|
+
if (seq !== todaySeq) { return; }
|
|
925
|
+
todayBusy = null;
|
|
926
|
+
if (todayStale && todayOpen === kind) { loadTodayList(false); }
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function renderTodayList(data) {
|
|
931
|
+
var target = document.getElementById("today-list");
|
|
932
|
+
if (!Array.isArray(data.events)) {
|
|
933
|
+
put(target, [unknown("unknown: " + (data.error || "the list could not be read"))]);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (data.events.length === 0) { put(target, [empty("none today")]); return; }
|
|
937
|
+
var list = el("ul", "plain");
|
|
938
|
+
data.events.forEach(function (ev) {
|
|
939
|
+
var li = el("li", "card");
|
|
940
|
+
var row = el("div", "row");
|
|
941
|
+
row.appendChild(el("span", "when", clock(ev.ts)));
|
|
942
|
+
if (ev.repo) {
|
|
943
|
+
row.appendChild(ghLink(ev.url,
|
|
944
|
+
ev.repo + (typeof ev.issue_number === "number" ? "#" + ev.issue_number : ""), "repo"));
|
|
945
|
+
}
|
|
946
|
+
if (ev.session_id) { row.appendChild(el("span", "muted", ev.session_id)); }
|
|
947
|
+
li.appendChild(row);
|
|
948
|
+
var r = ev.reason;
|
|
949
|
+
if (r) {
|
|
950
|
+
if (r.session === "not found") {
|
|
951
|
+
li.appendChild(el("p", "reason", "the run's record is gone"));
|
|
952
|
+
}
|
|
953
|
+
if (typeof r.exit_code === "number") {
|
|
954
|
+
li.appendChild(el("p", "reason", "exit code " + r.exit_code));
|
|
955
|
+
}
|
|
956
|
+
if (r.question) { li.appendChild(el("p", "reason", "asked: " + r.question)); }
|
|
957
|
+
if (r.summary) { li.appendChild(el("p", "reason", r.summary)); }
|
|
958
|
+
if (r.output_tail) {
|
|
959
|
+
var more = el("details");
|
|
960
|
+
more.appendChild(el("summary", null, "output"));
|
|
961
|
+
more.appendChild(el("pre", null, r.output_tail));
|
|
962
|
+
li.appendChild(more);
|
|
963
|
+
}
|
|
964
|
+
if (!r.question && !r.summary && !r.output_tail && typeof r.exit_code !== "number"
|
|
965
|
+
&& r.session !== "not found") {
|
|
966
|
+
li.appendChild(el("p", "reason", "no reason was recorded"));
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
var text = detailText(ev.detail);
|
|
970
|
+
if (text) { li.appendChild(el("div", "detail", text)); }
|
|
971
|
+
}
|
|
972
|
+
list.appendChild(li);
|
|
973
|
+
});
|
|
974
|
+
var note = data.truncated === true
|
|
975
|
+
? el("p", "empty", "showing newest " + data.events.length + " of " + data.total) : null;
|
|
976
|
+
put(target, note ? [note, list] : [list]);
|
|
977
|
+
}
|
|
978
|
+
|
|
853
979
|
function detailText(detail) {
|
|
854
980
|
if (detail === null || detail === undefined) { return ""; }
|
|
855
981
|
if (typeof detail !== "object") { return String(detail); }
|
|
@@ -917,6 +1043,7 @@ function render(data) {
|
|
|
917
1043
|
renderWaiting(data);
|
|
918
1044
|
renderSkipped(data);
|
|
919
1045
|
renderToday(data);
|
|
1046
|
+
loadTodayList();
|
|
920
1047
|
renderFleet(data, now);
|
|
921
1048
|
renderUsage(data, now);
|
|
922
1049
|
renderServer(data);
|
|
@@ -87,6 +87,7 @@ import json
|
|
|
87
87
|
import os
|
|
88
88
|
import re
|
|
89
89
|
import sqlite3
|
|
90
|
+
import time
|
|
90
91
|
import urllib.parse
|
|
91
92
|
from collections.abc import Callable, Mapping
|
|
92
93
|
from datetime import UTC, date, datetime, timedelta
|
|
@@ -369,9 +370,23 @@ def _event_url(repo: Any, issue_number: Any, detail: Any) -> str | None:
|
|
|
369
370
|
return github_url(repo, issue_number)
|
|
370
371
|
|
|
371
372
|
|
|
373
|
+
def _scrubbed(detail: Any) -> Any:
|
|
374
|
+
"""``detail`` with credential shapes removed from its text: a question
|
|
375
|
+
the agent asked is stored as it wrote it, and the page shows it."""
|
|
376
|
+
from ctrlrelay.core.redact import redact_secrets
|
|
377
|
+
|
|
378
|
+
if isinstance(detail, dict):
|
|
379
|
+
return {k: _scrubbed(v) for k, v in detail.items()}
|
|
380
|
+
if isinstance(detail, list):
|
|
381
|
+
return [_scrubbed(v) for v in detail]
|
|
382
|
+
if isinstance(detail, str):
|
|
383
|
+
return redact_secrets(detail)
|
|
384
|
+
return detail
|
|
385
|
+
|
|
386
|
+
|
|
372
387
|
def _event(row: dict[str, Any]) -> dict[str, Any]:
|
|
373
388
|
ts = int(row["ts"])
|
|
374
|
-
detail = _detail(row["detail"])
|
|
389
|
+
detail = _scrubbed(_detail(row["detail"]))
|
|
375
390
|
return {
|
|
376
391
|
"ts": ts,
|
|
377
392
|
"time": _iso(ts),
|
|
@@ -792,6 +807,103 @@ def list_feed(*, db_path: Path, since_ts: int, limit: int | None = None) -> dict
|
|
|
792
807
|
}
|
|
793
808
|
|
|
794
809
|
|
|
810
|
+
# The kinds a Today counter counts, which ``list_day_events`` lists (#257).
|
|
811
|
+
DAY_KINDS = frozenset(_TODAY)
|
|
812
|
+
# How much of a failed run's stored output and summary a row carries.
|
|
813
|
+
REASON_TAIL_CHARS = 2000
|
|
814
|
+
REASON_SUMMARY_CHARS = 500
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _read_day_events(
|
|
818
|
+
conn: sqlite3.Connection, kind: str, start: int, end: int, limit: int,
|
|
819
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
820
|
+
rows = conn.execute(
|
|
821
|
+
"""SELECT e.ts, e.kind, e.repo, e.issue_number, e.session_id, e.detail,
|
|
822
|
+
s.id AS s_id, s.claude_exit_code, s.summary, s.output_tail,
|
|
823
|
+
s.blocked_question
|
|
824
|
+
FROM events e LEFT JOIN sessions s ON s.id = e.session_id
|
|
825
|
+
WHERE e.kind = ? AND e.ts >= ? AND e.ts < ?
|
|
826
|
+
ORDER BY e.ts DESC, e.id DESC LIMIT ?""",
|
|
827
|
+
(kind, start, end, int(limit)),
|
|
828
|
+
).fetchall()
|
|
829
|
+
(total,) = conn.execute(
|
|
830
|
+
"SELECT count(*) FROM events WHERE kind = ? AND ts >= ? AND ts < ?",
|
|
831
|
+
(kind, start, end),
|
|
832
|
+
).fetchone()
|
|
833
|
+
out = []
|
|
834
|
+
for r in rows:
|
|
835
|
+
row = dict(r)
|
|
836
|
+
event = _event(row)
|
|
837
|
+
if kind in ("failed", "blocked"):
|
|
838
|
+
event["reason"] = _reason(row, event["detail"])
|
|
839
|
+
out.append(event)
|
|
840
|
+
return out, int(total)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _cut(text: str, limit: int) -> str:
|
|
844
|
+
"""``text`` up to ``limit`` characters, ending in "…" when cut."""
|
|
845
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _reason(row: dict[str, Any], detail: Any) -> dict[str, Any]:
|
|
849
|
+
"""Why a run failed or stopped, from what the relay stored: the exit
|
|
850
|
+
code, its summary and output tail, and the question asked, each
|
|
851
|
+
scrubbed of credential shapes again here. ``session`` says when the run's row is gone."""
|
|
852
|
+
from ctrlrelay.core.redact import redact_secrets, tail
|
|
853
|
+
|
|
854
|
+
detail = detail if isinstance(detail, dict) else {}
|
|
855
|
+
exit_code = detail.get("exit_code")
|
|
856
|
+
if not isinstance(exit_code, int) or isinstance(exit_code, bool):
|
|
857
|
+
exit_code = row.get("claude_exit_code")
|
|
858
|
+
question = detail.get("question") or row.get("blocked_question")
|
|
859
|
+
summary = row.get("summary")
|
|
860
|
+
output = row.get("output_tail")
|
|
861
|
+
reason: dict[str, Any] = {
|
|
862
|
+
"exit_code": exit_code if isinstance(exit_code, int) else None,
|
|
863
|
+
"summary": (
|
|
864
|
+
_cut(redact_secrets(summary), REASON_SUMMARY_CHARS)
|
|
865
|
+
if isinstance(summary, str) and summary else None
|
|
866
|
+
),
|
|
867
|
+
"output_tail": (
|
|
868
|
+
tail(redact_secrets(output), REASON_TAIL_CHARS)
|
|
869
|
+
if isinstance(output, str) and output else None
|
|
870
|
+
),
|
|
871
|
+
"question": (
|
|
872
|
+
_cut(redact_secrets(question), REASON_SUMMARY_CHARS)
|
|
873
|
+
if isinstance(question, str) and question else None
|
|
874
|
+
),
|
|
875
|
+
}
|
|
876
|
+
if row.get("s_id") is None:
|
|
877
|
+
reason["session"] = "not found"
|
|
878
|
+
return reason
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def list_day_events(
|
|
882
|
+
*, db_path: Path, kind: str, now: int | None = None, limit: int | None = None,
|
|
883
|
+
) -> dict:
|
|
884
|
+
"""Today's events of one counted ``kind``, newest first, for the Today
|
|
885
|
+
counters (#257): ``{"kind", "events", "total", "truncated", "error"}``.
|
|
886
|
+
A failed or blocked event carries ``reason``. ``events`` and ``total``
|
|
887
|
+
are ``null`` when they could not be read, never an empty list."""
|
|
888
|
+
if limit is None:
|
|
889
|
+
limit = FEED_LIMIT
|
|
890
|
+
start, end = _local_day(int(now if now is not None else time.time()))
|
|
891
|
+
try:
|
|
892
|
+
events, total = _read(
|
|
893
|
+
Path(db_path),
|
|
894
|
+
lambda conn: _read_day_events(conn, kind, start, end, limit),
|
|
895
|
+
)
|
|
896
|
+
except Exception as exc:
|
|
897
|
+
return {
|
|
898
|
+
"kind": kind, "events": None, "total": None, "truncated": False,
|
|
899
|
+
"error": f"events could not be read ({db_path}): {type(exc).__name__}: {exc}",
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
"kind": kind, "events": events, "total": total,
|
|
903
|
+
"truncated": total > len(events), "error": None,
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
795
907
|
def make_sessions_reader(db_path: Path) -> SessionsReader:
|
|
796
908
|
"""A ``SessionsReader`` for ``WaitingTracker``: running sessions (oldest
|
|
797
909
|
first) and repo locks, read-only. Raises on any database failure, which
|
|
@@ -460,17 +460,21 @@ class TestGitHubCLI:
|
|
|
460
460
|
"""Should return only 'assigned' events for an issue."""
|
|
461
461
|
from ctrlrelay.core.github import GitHubCLI
|
|
462
462
|
|
|
463
|
-
|
|
463
|
+
# ``gh api --paginate --jq '.[] | ...'`` prints one event per line,
|
|
464
|
+
# across every page.
|
|
465
|
+
mock_output = "\n".join(json.dumps(e) for e in [
|
|
464
466
|
{
|
|
465
467
|
"event": "assigned",
|
|
466
468
|
"actor": {"login": "alice"},
|
|
467
469
|
"assignee": {"login": "alice"},
|
|
470
|
+
"assigner": {"login": "alice"},
|
|
468
471
|
"created_at": "2026-01-01T00:00:00Z",
|
|
469
472
|
},
|
|
470
473
|
{
|
|
471
474
|
"event": "assigned",
|
|
472
|
-
"actor": {"login": "
|
|
475
|
+
"actor": {"login": "alice"},
|
|
473
476
|
"assignee": {"login": "alice"},
|
|
477
|
+
"assigner": {"login": "bob"},
|
|
474
478
|
"created_at": "2026-01-02T00:00:00Z",
|
|
475
479
|
},
|
|
476
480
|
])
|
|
@@ -481,11 +485,13 @@ class TestGitHubCLI:
|
|
|
481
485
|
events = await gh.list_assignment_events("owner/repo", 42)
|
|
482
486
|
|
|
483
487
|
assert len(events) == 2
|
|
484
|
-
assert events[0]["
|
|
485
|
-
assert events[1]["
|
|
488
|
+
assert events[0]["assigner"]["login"] == "alice"
|
|
489
|
+
assert events[1]["assigner"]["login"] == "bob"
|
|
486
490
|
|
|
487
491
|
args = mock_run.call_args[0]
|
|
488
|
-
assert "api" in args
|
|
492
|
+
assert "api" in args and "--paginate" in args
|
|
493
|
+
# Only assigned events, one per line across every page.
|
|
494
|
+
assert args[args.index("--jq") + 1] == '.[] | select(.event=="assigned")'
|
|
489
495
|
assert "/repos/owner/repo/issues/42/events" in args
|
|
490
496
|
|
|
491
497
|
@pytest.mark.asyncio
|
|
@@ -842,7 +842,7 @@ class TestThePollerLooksAgainWhenTheClaimCouldGoStale:
|
|
|
842
842
|
gh.list_issues_by_label = AsyncMock(return_value=[])
|
|
843
843
|
gh.list_assignment_events = AsyncMock(return_value=[{
|
|
844
844
|
"event": "assigned", "actor": {"login": "alice"},
|
|
845
|
-
"assignee": {"login": "alice"},
|
|
845
|
+
"assignee": {"login": "alice"}, "assigner": {"login": "alice"},
|
|
846
846
|
}])
|
|
847
847
|
return IssuePoller(
|
|
848
848
|
github=gh, username="alice", repos=["o/r"],
|