Phase 1: webhook handler + systemd unit + flock in dev-task
This commit is contained in:
@@ -18,6 +18,15 @@ REPO="${1:-}"
|
||||
ISSUE="${2:-}"
|
||||
[[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 <owner>/<repo> <issue-id>" >&2; exit 64; }
|
||||
|
||||
# Serialize: only one dev-task active at a time (Max-plan rate-limit pool).
|
||||
# Wait up to 60s for the lock; if still held, exit 75 so the webhook retries.
|
||||
LOCK_FILE="${LOCK_FILE:-/var/agent/dev-task.lock}"
|
||||
exec 200>"$LOCK_FILE"
|
||||
if ! flock -w 60 200; then
|
||||
echo "[dev-task] another dev-task is running; backing off" >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
MAX_WALLCLOCK="${MAX_WALLCLOCK:-30m}"
|
||||
MODEL_DEFAULT="${MODEL:-sonnet}"
|
||||
BASE_BRANCH="${BASE_BRANCH:-main}"
|
||||
|
||||
16
webhook/agent-webhook.service
Normal file
16
webhook/agent-webhook.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=agent-coding-empire webhook handler
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/agent-webhook.py
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
128
webhook/handler.py
Normal file
128
webhook/handler.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gitea webhook handler — agent-coding-empire dispatcher.
|
||||
|
||||
Listens on TCP :18790 for Gitea webhook deliveries. When an issue acquires the
|
||||
label "agent:assign" (or is opened with that label), SSH-triggers dev-task.sh
|
||||
on dev-01 (192.168.1.29).
|
||||
|
||||
Verifies the X-Gitea-Signature HMAC header against WEBHOOK_SECRET from
|
||||
/etc/agent/webhook-secret.
|
||||
|
||||
This is a deliberately tiny single-file service. It uses stdlib only.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socketserver
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
|
||||
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "18790"))
|
||||
SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/etc/agent/webhook-secret"))
|
||||
DEV_HOST = os.environ.get("DEV_HOST", "agent@192.168.1.29")
|
||||
SSH_KEY = os.environ.get("SSH_KEY", "/root/.ssh/id_ed25519_pve")
|
||||
LABEL = os.environ.get("AGENT_LABEL", "agent:assign")
|
||||
LOG_DIR = Path(os.environ.get("HANDLER_LOG_DIR", "/var/log/agent-webhook"))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("webhook")
|
||||
|
||||
SECRET = SECRET_FILE.read_text().strip()
|
||||
|
||||
|
||||
def verify_signature(body: bytes, sig_header: str) -> bool:
|
||||
if not sig_header:
|
||||
return False
|
||||
mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(mac, sig_header)
|
||||
|
||||
|
||||
def should_dispatch(event: str, payload: dict) -> tuple[bool, str | None, int | None]:
|
||||
"""Decide if this delivery should kick off a dev-task. Returns (yes, repo, issue#)."""
|
||||
if event != "issues":
|
||||
return False, None, None
|
||||
action = payload.get("action", "")
|
||||
issue = payload.get("issue", {})
|
||||
repo = payload.get("repository", {}).get("full_name")
|
||||
number = issue.get("number")
|
||||
labels = [l.get("name", "") for l in issue.get("labels", [])]
|
||||
if action == "opened" and LABEL in labels:
|
||||
return True, repo, number
|
||||
if action == "label_updated" and LABEL in labels:
|
||||
# Gitea fires label_updated for both add and remove; we only care about presence
|
||||
return True, repo, number
|
||||
return False, None, None
|
||||
|
||||
|
||||
def dispatch(repo: str, issue: int):
|
||||
"""SSH into dev-01 and fire dev-task in the background."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fn = LOG_DIR / f"dispatch-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log"
|
||||
log.info("dispatching dev-task for %s#%d -> %s", repo, issue, fn)
|
||||
cmd = [
|
||||
"ssh", "-i", SSH_KEY,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
DEV_HOST,
|
||||
f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH "
|
||||
f"nohup /usr/local/bin/dev-task.sh '{repo}' {issue} "
|
||||
f">>/var/agent/logs/dispatch-issue-{issue}.log 2>&1 &"
|
||||
]
|
||||
with open(fn, "wb") as f:
|
||||
rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode
|
||||
log.info("dispatch ssh rc=%d", rc)
|
||||
|
||||
|
||||
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(n)
|
||||
sig = self.headers.get("X-Gitea-Signature", "")
|
||||
ev = self.headers.get("X-Gitea-Event", "")
|
||||
|
||||
if not verify_signature(body, sig):
|
||||
log.warning("bad signature for event=%s", ev)
|
||||
self.send_response(401); self.end_headers(); self.wfile.write(b"bad sig\n"); return
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return
|
||||
|
||||
ok, repo, issue = should_dispatch(ev, payload)
|
||||
if ok and repo and issue is not None:
|
||||
threading.Thread(target=dispatch, args=(repo, issue), daemon=True).start()
|
||||
self.send_response(202); self.end_headers()
|
||||
self.wfile.write(b"dispatched\n")
|
||||
else:
|
||||
self.send_response(200); self.end_headers()
|
||||
self.wfile.write(b"ignored\n")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
log.info("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
|
||||
class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("agent-webhook listening on %s:%d, target=%s, label=%s",
|
||||
LISTEN_HOST, LISTEN_PORT, DEV_HOST, LABEL)
|
||||
ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()
|
||||
Reference in New Issue
Block a user