Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ accepts a possible duplicate rather than risk dropping a legitimate action.
operational cycle has no unexplained IMAP-only actionable events.

Phase 1 is exposed as `POST /api/webhooks/github` by the dashboard service.
For production, nginx should route that exact path to the dedicated
socket-activated `github-agent-bridge-webhook.service` on port 8766. The
dashboard keeps the route for backward compatibility, but using it couples
GitHub delivery availability to dashboard/UI restarts.
For a single trusted owner, configure `GITHUB_AGENT_BRIDGE_WEBHOOK_SECRET`;
during rotation, `GITHUB_AGENT_BRIDGE_WEBHOOK_PREVIOUS_SECRET` accepts the old
secret as well. This legacy form accepts any repository signed with that shared
Expand Down
15 changes: 15 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ cp systemd/github-agent-bridge-autoupdate.service ~/.config/systemd/user/
cp systemd/github-agent-bridge-autoupdate.timer ~/.config/systemd/user/
# Optional dashboard API for operator tooling:
cp systemd/github-agent-bridge-dashboard.service ~/.config/systemd/user/
# Recommended for public GitHub webhooks. The socket remains owned by systemd
# while the small ingress process restarts.
cp systemd/github-agent-bridge-webhook.service ~/.config/systemd/user/
cp systemd/github-agent-bridge-webhook.socket ~/.config/systemd/user/

systemctl --user daemon-reload
systemctl --user enable --now github-agent-bridge.service
Expand All @@ -239,6 +243,7 @@ systemctl --user enable --now github-agent-bridge-feedback.timer
systemctl --user enable --now github-agent-bridge-autoupdate.timer
# Optional:
# systemctl --user enable --now github-agent-bridge-dashboard.service
# systemctl --user enable --now github-agent-bridge-webhook.socket
```

The reader timer calls the packaged `github-agent-bridge-reader-run` console
Expand Down Expand Up @@ -292,6 +297,13 @@ Set `GITHUB_AGENT_BRIDGE_GITHUB_APP_ID` or
configured on the GitHub App automatically. `GITHUB_AGENT_BRIDGE_WEB_PUSH_ICON_URL`
can still override the notification icon with an explicit URL.

When webhooks are enabled, start `github-agent-bridge-webhook.socket` and route
the exact `/api/webhooks/github` path to `127.0.0.1:8766`. systemd owns the
listening socket and keeps a backlog while `github-agent-bridge-webhook.service`
is replaced, so dashboard restarts and short ingress restarts do not produce a
connection-refused window. Do not enable the service directly; enabling the
socket starts it on demand.

When the dashboard is published through nginx, use the proxy settings from
[`operations.md`](operations.md#dashboard-api-service) or start from
[`nginx-dashboard.conf`](nginx-dashboard.conf). The example keeps live SSE
Expand All @@ -301,7 +313,10 @@ briefly unavailable.

```bash
systemctl --user status github-agent-bridge-dashboard.service
systemctl --user status github-agent-bridge-webhook.socket
systemctl --user status github-agent-bridge-webhook.service
curl http://127.0.0.1:8765/api/health
curl http://127.0.0.1:8766/api/health
```

## Monitor health
Expand Down
14 changes: 14 additions & 0 deletions docs/nginx-dashboard.conf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ server {
# ssl_certificate /etc/letsencrypt/live/bridge.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/bridge.example.com/privkey.pem;

# Keep webhook ingestion independent from dashboard/UI restarts. systemd
# owns this socket and queues short bursts while the ingress process swaps.
location = /api/webhooks/github {
proxy_pass http://127.0.0.1:8766;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}

location / {
proxy_pass http://127.0.0.1:8765;
proxy_http_version 1.1;
Expand Down
12 changes: 12 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,16 @@ them in the viewer's local timezone from `Intl.DateTimeFormat`; hovering a
rendered timestamp shows the UTC value.
Production serves the static bundle from
`src/github_agent_bridge/dashboard_static`.

Public webhook ingestion should use the separate
`github-agent-bridge-webhook.socket` and `github-agent-bridge-webhook.service`.
The ingress app exposes only `GET /api/health` and
`POST /api/webhooks/github`; dashboard, OAuth, monitoring and administration
routes are deliberately absent. systemd owns `127.0.0.1:8766` and passes file
descriptor 3 to Uvicorn, retaining queued TCP connections across short process
restarts. Consequently a dashboard/UI deployment does not interrupt webhook
delivery, and an ingress deployment has no connection-refused gap while the
service is replaced.
When VAPID keys are configured and the dashboard is exposed over HTTPS, signed-in
users can enable the header bell control. The executor sends final `done` and
`blocked` job notifications through those browser push subscriptions for the
Expand Down Expand Up @@ -509,6 +519,8 @@ restart errors so browser users see a short auto-refreshing maintenance page
instead of nginx's generic "Bad Gateway" response while the dashboard service is
restarting. A complete example is available in
[`nginx-dashboard.conf`](nginx-dashboard.conf).
The example routes the exact webhook path to the socket-activated ingress on
port 8766 before the generic dashboard location.

```nginx
location / {
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27", "uvicorn>=0.29"]
gab = "github_agent_bridge.cli:main"
github-agent-bridge = "github_agent_bridge.cli:main"
github-agent-bridge-dashboard = "github_agent_bridge.backend:main"
github-agent-bridge-webhook = "github_agent_bridge.backend:webhook_main"
github-agent-bridge-reader-run = "github_agent_bridge.reader_run:main"
github-agent-bridge-monitor-alert = "github_agent_bridge.monitor_alert:main"
github-agent-bridge-autoupdate-run = "github_agent_bridge.autoupdate_run:main"
Expand Down
39 changes: 37 additions & 2 deletions src/github_agent_bridge/autoupdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,20 @@
"src/github_agent_bridge/dashboard_data.py",
"src/github_agent_bridge/dashboard_static/",
)
WEBHOOK_PATH_PREFIXES = (
"src/github_agent_bridge/backend.py",
"src/github_agent_bridge/models.py",
"src/github_agent_bridge/parser.py",
"src/github_agent_bridge/policy.py",
"src/github_agent_bridge/queue.py",
"src/github_agent_bridge/sql/",
"src/github_agent_bridge/webhook.py",
)
SYSTEMD_PATH_PREFIXES = ("systemd/",)
DEFAULT_SYSTEMD_UNITS = {
"executor": "github-agent-bridge.service",
"dashboard": "github-agent-bridge-dashboard.service",
"webhook": "github-agent-bridge-webhook.service",
"reader": "github-agent-bridge-reader.timer",
"monitor": "github-agent-bridge-monitor.timer",
"feedback": "github-agent-bridge-feedback.timer",
Expand Down Expand Up @@ -129,9 +139,15 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]:
risky_files = [path for path in files if path.startswith(RISKY_PATH_PREFIXES)]
migration_files = [path for path in files if path.startswith("src/github_agent_bridge/sql/") or "/migrations/" in path]
dashboard_files = [path for path in files if path.startswith(DASHBOARD_PATH_PREFIXES)]
webhook_files = [path for path in files if path.startswith(WEBHOOK_PATH_PREFIXES)]
systemd_files = [path for path in files if path.startswith(SYSTEMD_PATH_PREFIXES)]
dashboard_only = bool(files) and len(dashboard_files) == len(files)
risk = "dashboard_only" if dashboard_only else "executor_or_shared"
dashboard_only = bool(files) and len(dashboard_files) == len(files) and not webhook_files
webhook_only = bool(files) and len(webhook_files) == len(files) and not dashboard_files and not risky_files
api_only = bool(files) and all(
path.startswith(DASHBOARD_PATH_PREFIXES) or path.startswith(WEBHOOK_PATH_PREFIXES)
for path in files
) and not risky_files
risk = "dashboard_only" if dashboard_only else "webhook_only" if webhook_only else "api_only" if api_only else "executor_or_shared"
if migration_files:
risk = "migration_required"
elif risky_files:
Expand All @@ -143,6 +159,9 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]:
return {
"risk": risk,
"dashboard_only": dashboard_only,
"webhook_only": webhook_only,
"api_only": api_only,
"webhook_files": webhook_files,
"risky_files": risky_files,
"migration_files": migration_files,
"systemd_files": systemd_files,
Expand All @@ -168,15 +187,25 @@ def action(command: str, unit_key: str, reason: str) -> dict[str, str]:

if decision == "stage_dashboard_reload":
immediate.append(action("try-restart", "dashboard", "dashboard-only update can reload independently"))
elif decision == "stage_webhook_reload":
immediate.append(action("try-restart", "webhook", "webhook ingress update can reload independently"))
elif decision == "stage_api_reload":
immediate.append(action("try-restart", "dashboard", "dashboard API update can reload independently"))
immediate.append(action("try-restart", "webhook", "webhook ingress update can reload independently"))
elif decision == "stage_defer_executor_reload":
immediate.append(action("try-restart", "dashboard", "dashboard can refresh while executor jobs finish"))
if classification.get("webhook_files"):
immediate.append(action("try-restart", "webhook", "webhook ingress can refresh independently"))
deferred.append(action("restart", "executor", "executor/shared update waits for active queue to drain"))
elif decision == "stage_full_reload":
immediate.append(action("try-restart", "dashboard", "refresh dashboard after package update"))
if classification.get("webhook_files"):
immediate.append(action("try-restart", "webhook", "refresh webhook ingress after package update"))
immediate.append(action("restart", "executor", "queue is quiet, executor reload is allowed"))
elif decision == "defer_migration":
deferred.append(action("restart", "executor", "schema migration must wait for active queue to drain"))
deferred.append(action("try-restart", "dashboard", "dashboard refresh waits for migration window"))
deferred.append(action("try-restart", "webhook", "webhook ingress refresh waits for migration window"))

if daemon_reload:
affected_units = sorted(
Expand All @@ -186,6 +215,7 @@ def action(command: str, unit_key: str, reason: str) -> dict[str, str]:
for key, filename in (
("executor", "github-agent-bridge.service"),
("dashboard", "github-agent-bridge-dashboard.service"),
("webhook", "github-agent-bridge-webhook.service"),
("reader", "github-agent-bridge-reader.timer"),
("monitor", "github-agent-bridge-monitor.timer"),
("feedback", "github-agent-bridge-feedback.timer"),
Expand Down Expand Up @@ -743,6 +773,11 @@ def plan_update(
elif classification["dashboard_only"]:
decision = "stage_dashboard_reload"
dashboard_restart_allowed = True
elif classification["webhook_only"]:
decision = "stage_webhook_reload"
elif classification["api_only"]:
decision = "stage_api_reload"
dashboard_restart_allowed = True
elif active_total:
decision = "stage_defer_executor_reload"
executor_reload_pending = True
Expand Down
Loading
Loading