Installable PWA showing rigs, seats (working, idle, waiting for an answer), queue items and recent commits; seat screens with replies; push through ntfy. Reads state through the rig CLI only, Python standard library, no build step, styled entirely with the shared weirsoe-theme. Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ
284 lines
12 KiB
Python
284 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""rig-pocket: a phone-friendly status page and push notifier for OpenRig.
|
||
|
||
Reads state only through the documented `rig` CLI (`--json`), so it needs no
|
||
access to the daemon's internals. Standard library only.
|
||
"""
|
||
import glob
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
STATIC = HERE / "static"
|
||
|
||
RIG = os.environ.get("RIG_BIN", "rig")
|
||
BIND = os.environ.get("RIG_POCKET_BIND", "127.0.0.1")
|
||
PORT = int(os.environ.get("RIG_POCKET_PORT", "7440"))
|
||
POLL = int(os.environ.get("RIG_POCKET_POLL", "30"))
|
||
REPOS = os.environ.get("RIG_POCKET_REPOS", "") # space separated globs of git work trees
|
||
ORIGIN = os.environ.get("RIG_POCKET_ORIGIN", "") # e.g. https://rig.example.com, checked on POST
|
||
PUBLIC_URL = os.environ.get("RIG_POCKET_URL", "") # where a notification tap should go
|
||
THEME_CSS = os.environ.get("RIG_POCKET_THEME_CSS", "https://assets.weircon.dk/css/weirsoe-theme.css")
|
||
THEME_JS = os.environ.get("RIG_POCKET_THEME_JS", "https://assets.weircon.dk/js/weirsoe-theme.js")
|
||
NTFY_URL = os.environ.get("NTFY_URL", "")
|
||
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
|
||
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
|
||
DONE_DEST = re.compile(os.environ.get("RIG_POCKET_DONE_DEST", r"^orch-lead@"))
|
||
HUMAN_DEST = re.compile(os.environ.get("RIG_POCKET_HUMAN_DEST", r"^operator-agent@kernel$"))
|
||
|
||
PROMPT = re.compile(r"Do you want to proceed\?|^\s*❯ 1\. ", re.M)
|
||
SEAT = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$")
|
||
KEYS = {"1", "2", "3", "4"}
|
||
CLOSED = {"done", "closed", "failed", "canceled", "cancelled"}
|
||
|
||
|
||
def rig(*args, timeout=20):
|
||
try:
|
||
out = subprocess.run([RIG, *args], capture_output=True, text=True, timeout=timeout)
|
||
return out.stdout
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return ""
|
||
|
||
|
||
def rig_json(*args):
|
||
try:
|
||
return json.loads(rig(*args) or "null")
|
||
except json.JSONDecodeError:
|
||
return None
|
||
|
||
|
||
def git(path, *args):
|
||
try:
|
||
return subprocess.run(["git", "-C", path, *args], capture_output=True, text=True, timeout=10).stdout
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return ""
|
||
|
||
|
||
def as_list(data):
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
return data.get("items") or data.get("entries") or []
|
||
return []
|
||
|
||
|
||
def repo_paths():
|
||
paths = []
|
||
for pattern in REPOS.split():
|
||
for p in sorted(glob.glob(os.path.expanduser(pattern))):
|
||
if os.path.exists(os.path.join(p, ".git")):
|
||
paths.append(p)
|
||
return paths
|
||
|
||
|
||
def collect():
|
||
rigs = []
|
||
for r in as_list(rig_json("ps", "--json")):
|
||
if r.get("isArchived"):
|
||
continue
|
||
seats = []
|
||
for n in as_list(rig_json("ps", "--nodes", "--rig", r["name"], "--json")):
|
||
name = n.get("canonicalSessionName", "")
|
||
screen = rig("capture", name, "--lines", "25") if name else ""
|
||
seats.append({
|
||
"name": name,
|
||
"activity": (n.get("agentActivity") or {}).get("state", "unknown"),
|
||
"status": n.get("sessionStatus"),
|
||
"work": n.get("assignedWorkCount", 0),
|
||
"lastActivity": n.get("lastActivity"),
|
||
"waiting": bool(PROMPT.search(screen)),
|
||
})
|
||
rigs.append({"name": r["name"], "status": r.get("status"), "uptime": r.get("uptime"),
|
||
"attention": r.get("attentionCount", 0), "seats": seats})
|
||
|
||
queue = []
|
||
for q in as_list(rig_json("queue", "list", "-A", "-a", "--limit", "60", "--json")):
|
||
queue.append({"id": q.get("qitemId"), "state": q.get("state"), "dest": q.get("destinationSession"),
|
||
"source": q.get("sourceSession"), "summary": q.get("summary"), "updated": q.get("tsUpdated")})
|
||
|
||
repos = []
|
||
for p in repo_paths():
|
||
commits = []
|
||
for line in git(p, "log", "--branches", "-15", "--format=%H%x09%h%x09%cI%x09%D%x09%s").splitlines():
|
||
full, short, when, refs, subject = (line.split("\t") + [""] * 5)[:5]
|
||
commits.append({"hash": full, "short": short, "when": when, "refs": refs, "subject": subject})
|
||
repos.append({"name": os.path.basename(p), "commits": commits})
|
||
|
||
return {"updated": time.time(), "rigs": rigs, "queue": queue, "repos": repos}
|
||
|
||
|
||
def notify(title, body, priority=3, tags=""):
|
||
if not (NTFY_URL and NTFY_TOPIC):
|
||
return
|
||
headers = {"Title": title.encode("utf-8"), "Priority": str(priority)}
|
||
if tags:
|
||
headers["Tags"] = tags
|
||
if PUBLIC_URL:
|
||
headers["Click"] = PUBLIC_URL
|
||
if NTFY_TOKEN:
|
||
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
|
||
req = urllib.request.Request(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}", data=body.encode("utf-8"), headers=headers)
|
||
try:
|
||
urllib.request.urlopen(req, timeout=10).close()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
class Watcher:
|
||
"""Turns successive snapshots into notifications. The first snapshot only seeds."""
|
||
|
||
def __init__(self):
|
||
self.seen_commits = None
|
||
self.waiting_polls = {}
|
||
self.notified_waiting = set()
|
||
self.queue_state = None
|
||
|
||
def events(self, snap):
|
||
out = []
|
||
commits = {c["hash"]: (r["name"], c) for r in snap["repos"] for c in r["commits"]}
|
||
queue = {q["id"]: q for q in snap["queue"]}
|
||
if self.seen_commits is None:
|
||
self.seen_commits = set(commits)
|
||
self.queue_state = {k: q["state"] for k, q in queue.items()}
|
||
else:
|
||
new = [commits[h] for h in commits if h not in self.seen_commits]
|
||
self.seen_commits |= set(commits)
|
||
by_repo = {}
|
||
for repo, c in new:
|
||
by_repo.setdefault(repo, []).append(c["subject"])
|
||
for repo, subjects in by_repo.items():
|
||
out.append((f"{repo}: {len(subjects)} ny commit" + ("s" if len(subjects) > 1 else ""),
|
||
"\n".join(subjects), 2, "package"))
|
||
for k, q in queue.items():
|
||
before = self.queue_state.get(k)
|
||
if before is None and HUMAN_DEST.search(q["dest"] or "") and q["state"] not in CLOSED:
|
||
out.append(("Teamet spørger dig", q["summary"] or q["source"] or k, 4, "raising_hand"))
|
||
if before and before not in CLOSED and q["state"] in CLOSED and DONE_DEST.search(q["dest"] or ""):
|
||
out.append((f"Opgave {q['state']}: {q['dest']}", q["summary"] or k, 4, "white_check_mark"))
|
||
self.queue_state = {k: q["state"] for k, q in queue.items()}
|
||
|
||
# a prompt must survive two polls before it pings, so prompts answered quickly stay quiet
|
||
for r in snap["rigs"]:
|
||
for s in r["seats"]:
|
||
n = s["name"]
|
||
self.waiting_polls[n] = self.waiting_polls.get(n, 0) + 1 if s["waiting"] else 0
|
||
if self.waiting_polls[n] >= 2 and n not in self.notified_waiting:
|
||
self.notified_waiting.add(n)
|
||
out.append((f"{n} venter på svar", "Åbn for at se skærmen og svare.", 4, "warning"))
|
||
if not s["waiting"]:
|
||
self.notified_waiting.discard(n)
|
||
return out
|
||
|
||
|
||
STATE = {"snap": None}
|
||
LOCK = threading.Lock()
|
||
|
||
|
||
def poll_loop():
|
||
watcher = Watcher()
|
||
while True:
|
||
try:
|
||
snap = collect()
|
||
with LOCK:
|
||
STATE["snap"] = snap
|
||
for title, body, prio, tags in watcher.events(snap):
|
||
notify(title, body, prio, tags)
|
||
except Exception as e: # keep polling whatever one round did
|
||
print("poll failed:", e, flush=True)
|
||
time.sleep(POLL)
|
||
|
||
|
||
def known_seats():
|
||
with LOCK:
|
||
snap = STATE["snap"] or {"rigs": []}
|
||
return {s["name"] for r in snap["rigs"] for s in r["seats"]}
|
||
|
||
|
||
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
||
".webmanifest": "application/manifest+json", ".svg": "image/svg+xml", ".png": "image/png",
|
||
".json": "application/json"}
|
||
|
||
|
||
def csp():
|
||
ext = " ".join(sorted({u.split("/", 3)[0] + "//" + u.split("/", 3)[2] for u in (THEME_CSS, THEME_JS) if u.startswith("http")}))
|
||
return (f"default-src 'self'; style-src 'self' {ext}; script-src 'self' {ext}; img-src 'self' data:; "
|
||
"connect-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "rig-pocket"
|
||
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
def send(self, code, body, ctype="application/json", cache="no-store"):
|
||
data = body if isinstance(body, bytes) else body.encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.send_header("Cache-Control", cache)
|
||
self.send_header("Content-Security-Policy", csp())
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
def do_GET(self):
|
||
path = self.path.split("?", 1)[0]
|
||
if path == "/api/status":
|
||
with LOCK:
|
||
snap = STATE["snap"]
|
||
return self.send(200, json.dumps(snap or {"updated": 0, "rigs": [], "queue": [], "repos": []}))
|
||
if path.startswith("/api/capture/"):
|
||
seat = urllib.parse.unquote(path.rsplit("/", 1)[1])
|
||
if not SEAT.match(seat) or seat not in known_seats():
|
||
return self.send(404, '{"error":"unknown seat"}')
|
||
return self.send(200, json.dumps({"seat": seat, "screen": rig("capture", seat, "--lines", "60")}))
|
||
name = "index.html" if path in ("/", "") else path.lstrip("/")
|
||
f = (STATIC / name).resolve()
|
||
if STATIC not in f.parents or not f.is_file():
|
||
return self.send(404, "not found", "text/plain")
|
||
data = f.read_bytes()
|
||
if f.name == "index.html":
|
||
data = data.replace(b"{{THEME_CSS}}", THEME_CSS.encode()).replace(b"{{THEME_JS}}", THEME_JS.encode())
|
||
cache = "no-cache" if f.suffix in (".html", ".webmanifest") or f.name == "sw.js" else "max-age=300"
|
||
self.send(200, data, TYPES.get(f.suffix, "application/octet-stream"), cache)
|
||
|
||
def do_POST(self):
|
||
if self.path != "/api/send":
|
||
return self.send(404, '{"error":"not found"}')
|
||
# a custom header forces a CORS preflight, so other sites cannot post here
|
||
if self.headers.get("X-Rig-Pocket") != "1" or (ORIGIN and self.headers.get("Origin") != ORIGIN):
|
||
return self.send(403, '{"error":"forbidden"}')
|
||
try:
|
||
body = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", 0)), 8192)))
|
||
except (ValueError, json.JSONDecodeError):
|
||
return self.send(400, '{"error":"bad json"}')
|
||
seat, text, key = body.get("seat", ""), (body.get("text") or "").strip(), body.get("key")
|
||
if not SEAT.match(seat) or seat not in known_seats():
|
||
return self.send(404, '{"error":"unknown seat"}')
|
||
if key in KEYS:
|
||
out = rig("send", seat, key, "--dangerously-interact", "--reason", "rig-pocket: answered from phone")
|
||
elif text and len(text) <= 2000:
|
||
out = rig("send", seat, f"From the person (via rig-pocket on their phone): {text}")
|
||
else:
|
||
return self.send(400, '{"error":"nothing to send"}')
|
||
self.send(200, json.dumps({"ok": True, "result": out.strip()[:500]}))
|
||
|
||
|
||
def main():
|
||
threading.Thread(target=poll_loop, daemon=True).start()
|
||
ThreadingHTTPServer((BIND, PORT), Handler).serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|