Phase 1: webhook handler + systemd unit + flock in dev-task
This commit is contained in:
@@ -18,6 +18,15 @@ REPO="${1:-}"
|
|||||||
ISSUE="${2:-}"
|
ISSUE="${2:-}"
|
||||||
[[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 <owner>/<repo> <issue-id>" >&2; exit 64; }
|
[[ -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}"
|
MAX_WALLCLOCK="${MAX_WALLCLOCK:-30m}"
|
||||||
MODEL_DEFAULT="${MODEL:-sonnet}"
|
MODEL_DEFAULT="${MODEL:-sonnet}"
|
||||||
BASE_BRANCH="${BASE_BRANCH:-main}"
|
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