04339bad14
- bin/box-ctl.py: wire tmux-tally, tmux-auto-status, tmux-auto-toggle, tmux-auto-once, and onboard-connects actions with idempotent allowlists - bin/exec-constrained.py: register tmux.tally, tmux.auto_status, and onboard.connects ops for HTTPS execution - bin/super-cli.py: wire box tmux dispatch to approver, add cmd_run/cmd_watch, and json unread formatting - .agents/skills/box/SKILL.md: document tmux worker tally and auto-approval capabilities
4538 lines
175 KiB
Python
Executable File
4538 lines
175 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""box-ctl.py — allowlisted bl helper for Box API mutations.
|
||
|
||
The board server (VM) never runs raw systemctl or shell over SSH.
|
||
All bl mutations go through this helper, invoked as:
|
||
|
||
/home/super/Projects/NetVM/bin/box-ctl.py <action> [args...]
|
||
|
||
Security properties:
|
||
- Fixed verb set; every argument validated before acting.
|
||
- <name> must match ^[a-z0-9-]{1,64}$ (kills path traversal).
|
||
- Unit files generated from a fixed template; only the validated
|
||
name is interpolated. User-controlled strings never appear in
|
||
unit files.
|
||
- No shell=True anywhere. No string interpolation into commands.
|
||
- Job JSON schema-validated before writing; changes git-committed.
|
||
- Every action audit-logged to box-ctl.jsonl with caller identity.
|
||
|
||
Output: JSON to stdout ({"ok": true, ...} or {"ok": false, ...}),
|
||
exit 0 on success, nonzero on failure.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
NETVM_ROOT = Path("/home/super/Projects/NetVM")
|
||
BIN = NETVM_ROOT / "bin"
|
||
if str(BIN) not in sys.path:
|
||
sys.path.insert(0, str(BIN))
|
||
JOBS_DIR = NETVM_ROOT / "jobs"
|
||
SYSTEMD_USER = Path.home() / ".config" / "systemd" / "user"
|
||
CTL_LOG = NETVM_ROOT / "box-ctl.jsonl"
|
||
JOB_LOG = NETVM_ROOT / "job-log.jsonl"
|
||
DISPATCHER = BIN / "job-dispatch.py"
|
||
DM_PY = BIN / "dm.py"
|
||
POLICY_FILE = NETVM_ROOT / "CHAT_POLICY.md"
|
||
DM_LOG = NETVM_ROOT / "dm-log.jsonl"
|
||
WATCHDOG_STATE = NETVM_ROOT / "main-chat-watchdog.state"
|
||
|
||
NAME_RE = re.compile(r"^[a-z0-9-]{1,64}$")
|
||
VAR_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||
VALID_AGENTS = {"muse", "pip", "646", "opm", "dev", "def"}
|
||
THREAD_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||
THREAD_OPS = ("list", "pin", "unpin", "archive", "unarchive")
|
||
|
||
# Default sidechat per agent for `box notify`. Mirrors super-cli.py
|
||
# DEFAULT_AGENT_SIDECHATS (kept in sync manually; super-cli is the
|
||
# canonical copy). Sidechat-first policy: notify never targets main
|
||
# chat unless --allow-main-chat is passed explicitly.
|
||
NOTIFY_SIDECHATS = {
|
||
"646": "646 tasks",
|
||
"opm": "main-loop brain",
|
||
"pip": "pip tasks",
|
||
"muse": "muse tasks",
|
||
"dev": "onboarding-dev",
|
||
"def": "def tasks",
|
||
}
|
||
VALID_ON_FAILURE = {"retry", "alert", "ignore"}
|
||
KNOWN_PLACEHOLDERS = {"job_id", "job_name", "datetime", "date", "last_run"}
|
||
PROTOCOL_LITERALS = ("[REQ", "[CONFIRM", "[JOB", "[RESULT")
|
||
DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||
|
||
|
||
def utcnow():
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def out(ok, **kw):
|
||
payload = {"ok": ok}
|
||
payload.update(kw)
|
||
print(json.dumps(payload))
|
||
|
||
|
||
def fail(code, error, detail=None, exit_code=1):
|
||
payload = {"ok": False, "code": code, "error": error}
|
||
if detail is not None:
|
||
payload["detail"] = detail
|
||
print(json.dumps(payload))
|
||
sys.exit(exit_code)
|
||
|
||
|
||
def audit(action, name=None):
|
||
"""Append {ts, action, name, caller} to the bl-side log."""
|
||
try:
|
||
entry = {
|
||
"ts": utcnow(),
|
||
"action": action,
|
||
"name": name,
|
||
"caller": os.environ.get("BOX_CALLER", "unknown"),
|
||
}
|
||
with open(CTL_LOG, "a") as f:
|
||
f.write(json.dumps(entry) + "\n")
|
||
except Exception:
|
||
pass # audit failure must not break the action
|
||
|
||
|
||
def check_name(name):
|
||
if not name or not NAME_RE.match(name):
|
||
fail("BAD_NAME",
|
||
"name must match ^[a-z0-9-]{1,64}$",
|
||
{"field": "name", "value": name})
|
||
return name
|
||
|
||
|
||
def run(cmd, timeout=30):
|
||
"""Run a command with no shell. Returns CompletedProcess."""
|
||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cron validation and conversion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CRON_RANGES = {
|
||
0: (0, 59), # minute
|
||
1: (0, 23), # hour
|
||
2: (1, 31), # day of month
|
||
3: (1, 12), # month
|
||
4: (0, 7), # day of week (0 and 7 = Sunday)
|
||
}
|
||
|
||
|
||
def validate_cron_field(field, idx):
|
||
"""Validate one cron field. Returns True/False."""
|
||
lo, hi = CRON_RANGES[idx]
|
||
if field == "*":
|
||
return True
|
||
for part in field.split(","):
|
||
# step: base/n
|
||
if "/" in part:
|
||
base, step = part.split("/", 1)
|
||
if not step.isdigit() or int(step) < 1:
|
||
return False
|
||
if base != "*" and not validate_cron_field(base, idx):
|
||
return False
|
||
continue
|
||
# range: a-b
|
||
if "-" in part:
|
||
a, b = part.split("-", 1)
|
||
if not (a.isdigit() and b.isdigit()):
|
||
return False
|
||
if not (lo <= int(a) <= hi and lo <= int(b) <= hi):
|
||
return False
|
||
if int(a) > int(b):
|
||
return False
|
||
continue
|
||
# single value
|
||
if not part.isdigit():
|
||
return False
|
||
if not (lo <= int(part) <= hi):
|
||
return False
|
||
return True
|
||
|
||
|
||
def validate_cron(schedule):
|
||
"""Validate a 5-field cron string. Returns (ok, reason)."""
|
||
parts = schedule.split()
|
||
if len(parts) != 5:
|
||
return False, "schedule must have exactly 5 fields (M H dom mon dow)"
|
||
for i, p in enumerate(parts):
|
||
if not validate_cron_field(p, i):
|
||
return False, f"schedule field {i + 1} ({p!r}) is invalid"
|
||
return True, ""
|
||
|
||
|
||
def cron_to_oncalendar(schedule):
|
||
"""Convert common cron patterns to systemd OnCalendar.
|
||
|
||
Returns the OnCalendar string, or None if the pattern is not
|
||
supported (caller rejects with INVALID_SCHEDULE).
|
||
"""
|
||
m, h, dom, mon, dow = schedule.split()
|
||
|
||
def is_star(f):
|
||
return f == "*"
|
||
|
||
# */n * * * * -> *:0/n (every n minutes)
|
||
if m.startswith("*/") and m[2:].isdigit() and all(is_star(f) for f in (h, dom, mon, dow)):
|
||
return f"*:0/{m[2:]}"
|
||
# M * * * * -> hourly or *:M
|
||
if m.isdigit() and all(is_star(f) for f in (h, dom, mon, dow)):
|
||
if m == "0":
|
||
return "hourly"
|
||
return f"*:{int(m):02d}"
|
||
# M H * * * -> HH:MM (daily)
|
||
if m.isdigit() and h.isdigit() and all(is_star(f) for f in (dom, mon, dow)):
|
||
return f"{int(h):02d}:{int(m):02d}"
|
||
# M H * * DOW -> Dow HH:MM (weekly)
|
||
if m.isdigit() and h.isdigit() and dow != "*" and is_star(dom) and is_star(mon):
|
||
# take first dow value for the weekly form
|
||
d = dow.split(",")[0].split("-")[0].split("/")[0]
|
||
if d.isdigit() and 0 <= int(d) <= 7:
|
||
return f"{DOW_NAMES[int(d) % 7]} {int(h):02d}:{int(m):02d}"
|
||
return None
|
||
# M H DOM * * -> *-*-DOM HH:MM:00 (monthly)
|
||
if m.isdigit() and h.isdigit() and dom.isdigit() and is_star(mon) and is_star(dow):
|
||
return f"*-*-{int(dom):02d} {int(h):02d}:{int(m):02d}:00"
|
||
# M,M... * * * * -> *:M1,M2...
|
||
if all(part.isdigit() for part in m.split(",")) and all(is_star(f) for f in (h, dom, mon, dow)):
|
||
formatted_m = ",".join(f"{int(part):02d}" for part in m.split(","))
|
||
return f"*:{formatted_m}"
|
||
return None
|
||
|
||
|
||
def validate_oncalendar(expr):
|
||
"""Check an OnCalendar expression with systemd-analyze."""
|
||
r = run(["systemd-analyze", "calendar", expr, "--iterations=2"], timeout=15)
|
||
return r.returncode == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Job schema validation (§6 of the design doc)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def validate_job(data):
|
||
"""Validate a job definition dict. Returns (ok, field, reason)."""
|
||
if not isinstance(data, dict):
|
||
return False, None, "job must be a JSON object"
|
||
|
||
# name
|
||
name = data.get("name")
|
||
if not isinstance(name, str) or not NAME_RE.match(name):
|
||
return False, "name", "must match ^[a-z0-9-]{1,64}$"
|
||
|
||
# description
|
||
desc = data.get("description")
|
||
if desc is not None:
|
||
if not isinstance(desc, str) or len(desc) > 280:
|
||
return False, "description", "must be a string ≤ 280 chars"
|
||
|
||
# schedule
|
||
sched = data.get("schedule")
|
||
if not isinstance(sched, str):
|
||
return False, "schedule", "is required and must be a string"
|
||
if sched != "manual":
|
||
ok, reason = validate_cron(sched)
|
||
if not ok:
|
||
return False, "schedule", reason
|
||
oc = cron_to_oncalendar(sched)
|
||
if oc is None:
|
||
return False, "schedule", (
|
||
"cron pattern not supported for OnCalendar conversion; "
|
||
"supported: */n * * * *, M * * * *, M H * * *, M H * * DOW, M H DOM * *"
|
||
)
|
||
if not validate_oncalendar(oc):
|
||
return False, "schedule", f"converted OnCalendar {oc!r} rejected by systemd-analyze"
|
||
|
||
# scheduler
|
||
scheduler = data.get("scheduler", "systemd")
|
||
if scheduler != "systemd":
|
||
return False, "scheduler", 'v1 accepts only "systemd"'
|
||
|
||
# agent
|
||
agent = data.get("agent")
|
||
if agent not in VALID_AGENTS:
|
||
return False, "agent", f"must be one of {sorted(VALID_AGENTS)}"
|
||
|
||
# prompt_template
|
||
pt = data.get("prompt_template")
|
||
if not isinstance(pt, str) or not (1 <= len(pt) <= 4000):
|
||
return False, "prompt_template", "must be a string of 1–4000 chars"
|
||
for lit in PROTOCOL_LITERALS:
|
||
if lit in pt:
|
||
return False, "prompt_template", (
|
||
f"must not contain {lit!r} literal (protocol framing is added by the dispatcher)"
|
||
)
|
||
for ph in re.findall(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", pt):
|
||
if ph not in KNOWN_PLACEHOLDERS:
|
||
return False, "prompt_template", f"unknown placeholder {{{ph}}}"
|
||
|
||
# timeout
|
||
timeout = data.get("timeout", 300)
|
||
if not isinstance(timeout, int) or isinstance(timeout, bool) or not (60 <= timeout <= 3600):
|
||
return False, "timeout", "must be an integer 60–3600"
|
||
|
||
# on_failure
|
||
onf = data.get("on_failure", "alert")
|
||
if onf not in VALID_ON_FAILURE:
|
||
return False, "on_failure", f"must be one of {sorted(VALID_ON_FAILURE)}"
|
||
|
||
# chain_next
|
||
cn = data.get("chain_next")
|
||
if cn is not None:
|
||
if not isinstance(cn, str) or not NAME_RE.match(cn):
|
||
return False, "chain_next", "must be null or a valid job name"
|
||
if not (JOBS_DIR / f"{cn}.json").exists():
|
||
return False, "chain_next", f"job {cn!r} does not exist (dangling chain)"
|
||
|
||
# sidechat
|
||
sc = data.get("sidechat")
|
||
if sc is not None:
|
||
if not isinstance(sc, dict):
|
||
return False, "sidechat", "must be an object"
|
||
create = sc.get("create", False)
|
||
if not isinstance(create, bool):
|
||
return False, "sidechat.create", "must be a boolean"
|
||
nt = sc.get("name_template")
|
||
if nt is not None:
|
||
if not isinstance(nt, str) or len(nt) > 120:
|
||
return False, "sidechat.name_template", "must be a string ≤ 120 chars"
|
||
for ph in re.findall(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", nt):
|
||
if ph not in {"job_name", "date", "job_id"}:
|
||
return False, "sidechat.name_template", f"unknown placeholder {{{ph}}}"
|
||
rk = sc.get("reuse_key")
|
||
if rk is not None:
|
||
if not isinstance(rk, str) or not NAME_RE.match(rk):
|
||
return False, "sidechat.reuse_key", "must match ^[a-z0-9-]{1,64}$"
|
||
|
||
return True, None, ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit file templates (fixed; only <name> interpolated, regex-validated)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SERVICE_TEMPLATE = """[Unit]
|
||
Description=Dispatch {name} job
|
||
After=network.target
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=/home/super/Projects/NetVM/bin/job-dispatch.py {name}
|
||
User=super
|
||
WorkingDirectory=/home/super/Projects/NetVM
|
||
"""
|
||
|
||
TIMER_TEMPLATE = """[Unit]
|
||
Description=Run {name} job on schedule
|
||
|
||
[Timer]
|
||
OnCalendar={oncalendar}
|
||
Persistent=true
|
||
AccuracySec=1min
|
||
|
||
[Install]
|
||
WantedBy=timers.target
|
||
"""
|
||
|
||
|
||
def unit_path(name, kind):
|
||
return SYSTEMD_USER / f"job-{name}.{kind}"
|
||
|
||
|
||
def systemctl(*args, timeout=30):
|
||
return run(["systemctl", "--user"] + list(args), timeout=timeout)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Actions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def act_timer_list():
|
||
timers = []
|
||
try:
|
||
entries = sorted(SYSTEMD_USER.glob("job-*.timer"))
|
||
except Exception:
|
||
entries = []
|
||
for p in entries:
|
||
unit = p.name
|
||
name = unit[len("job-"):-len(".timer")]
|
||
active = systemctl("is-active", unit).stdout.strip() == "active"
|
||
enabled_out = systemctl("is-enabled", unit).stdout.strip()
|
||
enabled = enabled_out == "enabled"
|
||
job_path = JOBS_DIR / f"{name}.json"
|
||
timers.append({
|
||
"name": name,
|
||
"unit": unit,
|
||
"active": active,
|
||
"enabled": enabled,
|
||
"orphan": not job_path.exists(),
|
||
})
|
||
audit("timer-list")
|
||
out(True, timers=timers)
|
||
|
||
|
||
def _timer_show_property(unit, prop):
|
||
r = systemctl("show", unit, f"--property={prop}", "--value")
|
||
if r.returncode != 0:
|
||
return None
|
||
return r.stdout.strip() or None
|
||
|
||
|
||
def act_timer_status(name):
|
||
check_name(name)
|
||
unit = f"job-{name}.timer"
|
||
if not unit_path(name, "timer").exists():
|
||
fail("NOT_FOUND", f"no such timer: {unit}")
|
||
active = systemctl("is-active", unit).stdout.strip() == "active"
|
||
enabled = systemctl("is-enabled", unit).stdout.strip() == "enabled"
|
||
last_usec = _timer_show_property(unit, "LastTriggerUSec")
|
||
next_usec = _timer_show_property(unit, "NextElapseUSec")
|
||
oncalendar_raw = _timer_show_property(unit, "TimersCalendar")
|
||
# TimersCalendar returns "{ OnCalendar=... ; next_elapse=... }" — extract just the expr
|
||
oncalendar = oncalendar_raw
|
||
if oncalendar_raw:
|
||
m = re.search(r"OnCalendar=([^;}]+)", oncalendar_raw)
|
||
if m:
|
||
oncalendar = m.group(1).strip()
|
||
result = _timer_show_property(unit, "Result")
|
||
|
||
def usec_to_iso(v):
|
||
if not v or v in ("0", "n/a"):
|
||
return None
|
||
try:
|
||
ts = int(v) / 1_000_000
|
||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
except Exception:
|
||
return None
|
||
|
||
# last_result from job-log.jsonl
|
||
last_result = "unknown"
|
||
try:
|
||
if JOB_LOG.exists():
|
||
for line in reversed(JOB_LOG.read_text().splitlines()[-500:]):
|
||
try:
|
||
e = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
if e.get("job_name") != name and not str(e.get("job_id", "")).startswith(name + "-"):
|
||
continue
|
||
t = e.get("type")
|
||
if t in ("job_dispatched",):
|
||
last_result = "success"
|
||
break
|
||
if t in ("job_failed", "job_timeout"):
|
||
last_result = "failed"
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
job_def = None
|
||
job_path = JOBS_DIR / f"{name}.json"
|
||
if job_path.exists():
|
||
try:
|
||
job_def = json.loads(job_path.read_text())
|
||
except Exception:
|
||
job_def = None
|
||
|
||
audit("timer-status", name)
|
||
out(True, name=name, unit=unit, active=active, enabled=enabled,
|
||
last_run=usec_to_iso(last_usec), next_run=usec_to_iso(next_usec),
|
||
last_result=last_result, oncalendar=oncalendar,
|
||
job_definition=job_def)
|
||
|
||
|
||
def act_timer_create(name):
|
||
check_name(name)
|
||
job_path = JOBS_DIR / f"{name}.json"
|
||
if not job_path.exists():
|
||
fail("NOT_FOUND", f"job definition missing: jobs/{name}.json — create the job first")
|
||
try:
|
||
job = json.loads(job_path.read_text())
|
||
except Exception as e:
|
||
fail("INVALID_JOB", f"job JSON unreadable: {e}")
|
||
ok, field, reason = validate_job(job)
|
||
if not ok:
|
||
fail("INVALID_JOB", f"job validation failed: {field}: {reason}",
|
||
{"field": field, "reason": reason})
|
||
if unit_path(name, "timer").exists():
|
||
fail("ALREADY_EXISTS", f"timer job-{name}.timer already exists — delete it first")
|
||
|
||
if job.get("schedule") == "manual":
|
||
fail("INVALID_SCHEDULE", "cannot create a systemd timer for a job with schedule 'manual'")
|
||
|
||
oncalendar = cron_to_oncalendar(job["schedule"])
|
||
if oncalendar is None or not validate_oncalendar(oncalendar):
|
||
fail("INVALID_SCHEDULE", "schedule did not convert to a valid OnCalendar")
|
||
|
||
SYSTEMD_USER.mkdir(parents=True, exist_ok=True)
|
||
unit_path(name, "service").write_text(SERVICE_TEMPLATE.format(name=name))
|
||
unit_path(name, "timer").write_text(
|
||
TIMER_TEMPLATE.format(name=name, oncalendar=oncalendar))
|
||
|
||
r = systemctl("daemon-reload")
|
||
if r.returncode != 0:
|
||
fail("TIMER_CREATE_FAILED", f"daemon-reload failed: {r.stderr.strip()}")
|
||
r = systemctl("enable", "--now", f"job-{name}.timer")
|
||
if r.returncode != 0:
|
||
fail("TIMER_CREATE_FAILED", f"enable --now failed: {r.stderr.strip()}")
|
||
|
||
audit("timer-create", name)
|
||
out(True, name=name, unit=f"job-{name}.timer", created=True,
|
||
oncalendar=oncalendar)
|
||
|
||
|
||
def act_timer_delete(name, keep_job=False):
|
||
check_name(name)
|
||
unit = f"job-{name}.timer"
|
||
if not unit_path(name, "timer").exists():
|
||
fail("NOT_FOUND", f"no such timer: {unit}")
|
||
systemctl("stop", unit)
|
||
systemctl("disable", unit)
|
||
for kind in ("timer", "service"):
|
||
p = unit_path(name, kind)
|
||
try:
|
||
p.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
r = systemctl("daemon-reload")
|
||
if r.returncode != 0:
|
||
fail("TIMER_CREATE_FAILED", f"daemon-reload failed: {r.stderr.strip()}")
|
||
audit("timer-delete", name)
|
||
out(True, name=name, unit=unit, deleted=True, job_kept=keep_job or (JOBS_DIR / f"{name}.json").exists())
|
||
|
||
|
||
def act_timer_control(name, op):
|
||
check_name(name)
|
||
unit = f"job-{name}.timer"
|
||
if not unit_path(name, "timer").exists():
|
||
fail("NOT_FOUND", f"no such timer: {unit}")
|
||
valid = {"start", "stop", "enable", "disable"}
|
||
if op not in valid:
|
||
fail("BAD_NAME", f"unknown timer op: {op}")
|
||
args = ["--now", unit] if op == "enable" else [unit]
|
||
r = systemctl(op, *args)
|
||
if r.returncode != 0:
|
||
fail("TIMER_CREATE_FAILED", f"systemctl {op} failed: {r.stderr.strip()}")
|
||
audit(f"timer-{op}", name)
|
||
out(True, name=name, unit=unit, op=op)
|
||
|
||
|
||
def _job_summary(name, path):
|
||
try:
|
||
d = json.loads(path.read_text())
|
||
except Exception:
|
||
return {"name": name, "error": "unreadable"}
|
||
return {
|
||
"name": name,
|
||
"description": d.get("description"),
|
||
"schedule": d.get("schedule"),
|
||
"agent": d.get("agent"),
|
||
"timeout": d.get("timeout", 300),
|
||
}
|
||
|
||
|
||
def act_job_list():
|
||
jobs = []
|
||
if JOBS_DIR.exists():
|
||
for p in sorted(JOBS_DIR.glob("*.json")):
|
||
jobs.append(_job_summary(p.stem, p))
|
||
audit("job-list")
|
||
out(True, jobs=jobs)
|
||
|
||
|
||
def act_job_get(name):
|
||
check_name(name)
|
||
p = JOBS_DIR / f"{name}.json"
|
||
if not p.exists():
|
||
fail("NOT_FOUND", f"no such job: {name}")
|
||
try:
|
||
data = json.loads(p.read_text())
|
||
except Exception as e:
|
||
fail("INVALID_JOB", f"job JSON unreadable: {e}")
|
||
audit("job-get", name)
|
||
out(True, job=data)
|
||
|
||
|
||
def _git(*args):
|
||
return run(["git", "-C", str(NETVM_ROOT)] + list(args), timeout=30)
|
||
|
||
|
||
def act_job_put(name):
|
||
check_name(name)
|
||
raw = sys.stdin.read()
|
||
try:
|
||
data = json.loads(raw)
|
||
except Exception as e:
|
||
fail("INVALID_JOB", f"stdin is not valid JSON: {e}")
|
||
if data.get("name") != name:
|
||
fail("NAME_MISMATCH", "path name != body name",
|
||
{"path": name, "body": data.get("name")})
|
||
ok, field, reason = validate_job(data)
|
||
if not ok:
|
||
fail("INVALID_JOB", f"schema validation failed: {field}: {reason}",
|
||
{"field": field, "reason": reason})
|
||
existed = (JOBS_DIR / f"{name}.json").exists()
|
||
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||
(JOBS_DIR / f"{name}.json").write_text(json.dumps(data, indent=2) + "\n")
|
||
r = _git("add", f"jobs/{name}.json")
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"git add failed: {r.stderr.strip()}")
|
||
msg = f"{'Update' if existed else 'Add'} job {name} via box-ctl"
|
||
r = _git("-c", "user.name=box-ctl", "-c", "user.email=box-ctl@bl.local",
|
||
"commit", "-m", msg)
|
||
if r.returncode != 0 and "nothing to commit" not in (r.stdout + r.stderr):
|
||
fail("DISPATCH_FAILED", f"git commit failed: {r.stderr.strip()}")
|
||
audit("job-put", name)
|
||
out(True, name=name, created=not existed, updated=existed)
|
||
|
||
|
||
def act_job_delete(name, force=False):
|
||
check_name(name)
|
||
p = JOBS_DIR / f"{name}.json"
|
||
if not p.exists():
|
||
fail("NOT_FOUND", f"no such job: {name}")
|
||
if unit_path(name, "timer").exists() and not force:
|
||
fail("TIMER_STILL_ACTIVE",
|
||
f"timer job-{name}.timer still exists — delete it first or use --force")
|
||
r = _git("rm", "-q", f"jobs/{name}.json")
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"git rm failed: {r.stderr.strip()}")
|
||
r = _git("-c", "user.name=box-ctl", "-c", "user.email=box-ctl@bl.local",
|
||
"commit", "-m", f"Delete job {name} via box-ctl")
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"git commit failed: {r.stderr.strip()}")
|
||
audit("job-delete", name)
|
||
out(True, name=name, deleted=True)
|
||
|
||
|
||
def act_job_trigger(name):
|
||
check_name(name)
|
||
p = JOBS_DIR / f"{name}.json"
|
||
if not p.exists():
|
||
fail("NOT_FOUND", f"no such job: {name}")
|
||
r = run([sys.executable, str(DISPATCHER), name], timeout=300)
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"job-dispatch.py failed: {(r.stderr or r.stdout).strip()[-500:]}")
|
||
audit("job-trigger", name)
|
||
out(True, name=name, triggered=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recursive workflow: box -> agent -> box -> agent ...
|
||
# The box stays in the loop: every RESULT recorded here advances the chain
|
||
# through the box (shared dedupe state with response-harvester.py).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CHAINED_JOBS_FILE = BIN / "chained-jobs.json"
|
||
|
||
|
||
def _chain_has(job_id):
|
||
try:
|
||
if CHAINED_JOBS_FILE.exists():
|
||
return job_id in json.loads(CHAINED_JOBS_FILE.read_text())
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _chain_mark(job_id):
|
||
try:
|
||
c = []
|
||
if CHAINED_JOBS_FILE.exists():
|
||
c = json.loads(CHAINED_JOBS_FILE.read_text())
|
||
if job_id not in c:
|
||
c.append(job_id)
|
||
c = c[-1000:]
|
||
CHAINED_JOBS_FILE.write_text(json.dumps(c))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _job_name_from_id(job_id):
|
||
m = re.match(r"^(.*)-(\d{8}-\d{6}-[a-f0-9]{8})$", str(job_id))
|
||
if m:
|
||
return m.group(1)
|
||
parts = str(job_id).split("-")
|
||
if len(parts) < 3:
|
||
return None
|
||
return "-".join(parts[:-2])
|
||
|
||
|
||
def _chain_target(job_name, success):
|
||
"""Return (next_job, via_field) for a completed job, or (None, None)."""
|
||
p = JOBS_DIR / f"{job_name}.json"
|
||
if not p.exists():
|
||
return None, None
|
||
try:
|
||
cfg = json.loads(p.read_text())
|
||
except Exception:
|
||
return None, None
|
||
if success:
|
||
nxt = cfg.get("on_success") or cfg.get("chain_next")
|
||
via = "on_success" if cfg.get("on_success") else "chain_next"
|
||
else:
|
||
nxt = cfg.get("on_failure")
|
||
via = "on_failure"
|
||
if nxt and isinstance(nxt, str) and (JOBS_DIR / f"{nxt}.json").exists():
|
||
return nxt, via
|
||
return None, None
|
||
|
||
|
||
def _dispatch_chained(next_job, prev_job_id, prev_result):
|
||
"""Dispatch the next job in a chain. Returns (ok, err)."""
|
||
env = os.environ.copy()
|
||
env["CHAIN_PREV_JOB_ID"] = prev_job_id
|
||
env["CHAIN_PREV_RESULT"] = (prev_result or "")[:1000]
|
||
try:
|
||
r = subprocess.run([sys.executable, str(DISPATCHER), next_job],
|
||
capture_output=True, text=True, timeout=300, env=env)
|
||
except Exception as e:
|
||
return False, f"dispatch exception: {e}"
|
||
if r.returncode != 0:
|
||
return False, (r.stderr or r.stdout).strip()[-500:]
|
||
return True, None
|
||
|
||
|
||
def act_job_result(job_id):
|
||
"""Agent -> box: record a RESULT and advance the chain (the recursion step)."""
|
||
raw = sys.stdin.read()
|
||
try:
|
||
data = json.loads(raw)
|
||
except Exception as e:
|
||
fail("INVALID_RESULT", f"stdin is not valid JSON: {e}")
|
||
agent = data.get("agent")
|
||
if agent not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
|
||
success = data.get("success", True)
|
||
if not isinstance(success, bool):
|
||
fail("INVALID_RESULT", "success must be a boolean")
|
||
summary = data.get("summary", "")
|
||
if not isinstance(summary, str) or len(summary) > 2000:
|
||
fail("INVALID_RESULT", "summary must be a string of 0-2000 chars")
|
||
rec = {
|
||
"ts": utcnow(),
|
||
"type": "job_result",
|
||
"job_id": job_id,
|
||
"agent": agent,
|
||
"success": success,
|
||
"result_snippet": summary[:300],
|
||
"thread_id": None,
|
||
"msg_id": None,
|
||
"source": "box-ctl",
|
||
}
|
||
try:
|
||
with open(JOB_LOG, "a") as f:
|
||
f.write(json.dumps(rec) + "\n")
|
||
except Exception as e:
|
||
fail("LOG_FAILED", f"could not append job-log.jsonl: {e}")
|
||
# recursion: the box dispatches the next step
|
||
job_name = _job_name_from_id(job_id)
|
||
chained_to = None
|
||
chain_via = None
|
||
chain_dispatched = False
|
||
chain_error = None
|
||
if job_name and not _chain_has(job_id):
|
||
_chain_mark(job_id)
|
||
nxt, via = _chain_target(job_name, success)
|
||
if nxt:
|
||
try:
|
||
ccfg = json.loads((JOBS_DIR / f"{job_name}.json").read_text())
|
||
except Exception:
|
||
ccfg = {}
|
||
try:
|
||
step_delay = int(ccfg.get("step_delay", 0) or 0)
|
||
except Exception:
|
||
step_delay = 0
|
||
if step_delay > 0:
|
||
time.sleep(min(step_delay, 30))
|
||
ok, err = _dispatch_chained(nxt, job_id, summary)
|
||
chain_dispatched = ok
|
||
chain_error = err
|
||
if ok:
|
||
chained_to = nxt
|
||
chain_via = via
|
||
audit("job-result", job_id)
|
||
out(True, job_id=job_id, agent=agent, success=success, recorded=True,
|
||
chained_to=chained_to, chain_via=chain_via,
|
||
chain_dispatched=chain_dispatched, chain_error=chain_error)
|
||
|
||
|
||
def act_job_status(name):
|
||
"""Box-side view of a job's run state: dispatches, results, chain wiring."""
|
||
events = []
|
||
try:
|
||
if JOB_LOG.exists():
|
||
for line in JOB_LOG.read_text().splitlines()[-2000:]:
|
||
try:
|
||
e = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
jn = e.get("job_name")
|
||
jid = str(e.get("job_id", ""))
|
||
if jn == name or jid == name or jid.startswith(name + "-"):
|
||
events.append(e)
|
||
except Exception as e:
|
||
fail("LOG_FAILED", f"could not read job-log.jsonl: {e}")
|
||
dispatches = [e for e in events if e.get("type") == "job_dispatched"]
|
||
results = [e for e in events if e.get("type") == "job_result"]
|
||
failures = [e for e in events if e.get("type") in ("job_failed", "job_timeout")]
|
||
last_result = results[-1] if results else None
|
||
state = "pending"
|
||
if last_result:
|
||
state = "resulted"
|
||
elif dispatches:
|
||
state = "dispatched"
|
||
chain = {}
|
||
p = JOBS_DIR / f"{name}.json"
|
||
if p.exists():
|
||
try:
|
||
cfg = json.loads(p.read_text())
|
||
for k in ("chain_next", "on_success", "on_failure"):
|
||
if cfg.get(k):
|
||
chain[k] = cfg[k]
|
||
except Exception:
|
||
pass
|
||
audit("job-status", name)
|
||
out(True, name=name, state=state,
|
||
dispatches=len(dispatches), results=len(results), failures=len(failures),
|
||
last_dispatch=dispatches[-1].get("ts") if dispatches else None,
|
||
last_result=({
|
||
"ts": last_result.get("ts"),
|
||
"job_id": last_result.get("job_id"),
|
||
"agent": last_result.get("agent"),
|
||
"success": last_result.get("success"),
|
||
"snippet": (last_result.get("result_snippet") or "")[:200],
|
||
} if last_result else None),
|
||
chain=chain)
|
||
|
||
|
||
def act_job_next(job_id, success=None):
|
||
"""Dry-run: what WOULD the box dispatch next for this job_id? No dispatch."""
|
||
job_name = _job_name_from_id(job_id)
|
||
if not job_name:
|
||
fail("BAD_NAME", f"cannot parse job name from job_id: {job_id}")
|
||
if success is None:
|
||
success = True
|
||
try:
|
||
if JOB_LOG.exists():
|
||
for line in reversed(JOB_LOG.read_text().splitlines()[-2000:]):
|
||
try:
|
||
e = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
if e.get("type") == "job_result" and str(e.get("job_id")) == job_id:
|
||
success = bool(e.get("success", True))
|
||
break
|
||
except Exception:
|
||
pass
|
||
nxt, via = _chain_target(job_name, success)
|
||
already = _chain_has(job_id)
|
||
audit("job-next", job_id)
|
||
out(True, job_id=job_id, job_name=job_name, success=success,
|
||
would_dispatch=bool(nxt) and not already,
|
||
next_job=nxt, via=via, already_chained=already)
|
||
|
||
|
||
def act_job_chain(frm, to, on_failure=False):
|
||
"""Wire recursion: set chain_next (or on_failure) from one job to another."""
|
||
check_name(frm)
|
||
check_name(to)
|
||
if frm == to:
|
||
fail("INVALID_JOB", "a job cannot chain to itself")
|
||
fp = JOBS_DIR / f"{frm}.json"
|
||
if not fp.exists():
|
||
fail("NOT_FOUND", f"no such job: {frm}")
|
||
if not (JOBS_DIR / f"{to}.json").exists():
|
||
fail("NOT_FOUND", f"no such job: {to}")
|
||
try:
|
||
cfg = json.loads(fp.read_text())
|
||
except Exception as e:
|
||
fail("INVALID_JOB", f"job JSON unreadable: {e}")
|
||
field = "on_failure" if on_failure else "chain_next"
|
||
cfg[field] = to
|
||
# cycle guard
|
||
seen = {frm}
|
||
cur = to
|
||
while cur:
|
||
if cur in seen:
|
||
fail("INVALID_JOB", f"chain would create a cycle at {cur!r}")
|
||
seen.add(cur)
|
||
try:
|
||
c = json.loads((JOBS_DIR / f"{cur}.json").read_text())
|
||
except Exception:
|
||
break
|
||
cur = c.get("chain_next") or c.get("on_success")
|
||
fp.write_text(json.dumps(cfg, indent=2) + "\n")
|
||
r = _git("add", f"jobs/{frm}.json")
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"git add failed: {r.stderr.strip()}")
|
||
r = _git("-c", "user.name=box-ctl", "-c", "user.email=box-ctl@bl.local",
|
||
"commit", "-m", f"Chain job {frm} -> {to} via box-ctl")
|
||
if r.returncode != 0 and "nothing to commit" not in (r.stdout + r.stderr):
|
||
fail("DISPATCH_FAILED", f"git commit failed: {r.stderr.strip()}")
|
||
audit("job-chain", f"{frm}->{to}")
|
||
out(True, **{"from": frm, "to": to, "field": field, "chained": True})
|
||
|
||
|
||
def _valid_sidechat_name(name):
|
||
# Sidechat names may contain spaces ("646 tasks"); reject only
|
||
# control characters and enforce a sane length. dm.py resolves
|
||
# the name (alias -> job-sidechats.json -> autoprovision).
|
||
return bool(name) and len(name) <= 64 and not any(
|
||
ord(c) < 32 or ord(c) == 127 for c in name)
|
||
|
||
|
||
def act_notify(agent, message, sidechat=None, allow_main_chat=False, sender=None):
|
||
if agent not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
|
||
if sender is not None and sender not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"sender must be one of {sorted(VALID_AGENTS)}")
|
||
sender_agent = sender if sender else "opm"
|
||
if not message or len(message) > 1000:
|
||
fail("INVALID_JOB", "message must be 1\u20131000 chars")
|
||
# Sidechat-first policy: default to the agent's sidechat, never main.
|
||
if allow_main_chat:
|
||
target = "main"
|
||
extra = ["--allow-main-chat"]
|
||
else:
|
||
target = sidechat if sidechat else NOTIFY_SIDECHATS.get(agent)
|
||
if not target:
|
||
fail("BAD_NAME", f"no default sidechat for agent {agent}; pass --sidechat <name>")
|
||
if sidechat and not _valid_sidechat_name(sidechat):
|
||
fail("BAD_NAME", "sidechat name must be 1-64 chars, no control characters")
|
||
extra = []
|
||
r = run([sys.executable, str(DM_PY), "send",
|
||
"--agent", sender_agent, "--to", agent, "--target", target,
|
||
*extra, message],
|
||
timeout=120)
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"dm.py send failed: {(r.stderr or r.stdout).strip()[-500:]}")
|
||
audit("notify", agent)
|
||
out(True, agent=agent, target=target, sent=True)
|
||
|
||
|
||
def act_main_loop(sub, agent=None):
|
||
"""Run the main-chat self-monitor loop (self_main_loop.py).
|
||
|
||
check: one loop iteration -> JSON {ok, prompted, errors, new_total, agents}
|
||
status: watermark / last run / prompt-sidechat config, no chat reads.
|
||
enable: enable the loop (per-agent or all); check skips disabled agents.
|
||
disable: disable the loop (per-agent or all).
|
||
"""
|
||
if sub not in ("check", "status", "enable", "disable"):
|
||
fail("BAD_NAME", "usage: main-loop check|status|enable|disable [--agent <name>]")
|
||
if agent is not None and agent not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
|
||
audit("main-loop-" + sub + (f"-{agent}" if agent else ""))
|
||
script = BIN / "self_main_loop.py"
|
||
cmd = [sys.executable, str(script), sub]
|
||
if agent:
|
||
cmd += ["--agent", agent]
|
||
try:
|
||
r = subprocess.run(cmd,
|
||
capture_output=True, text=True, timeout=600)
|
||
except subprocess.TimeoutExpired:
|
||
fail("LOOP_TIMEOUT", "self_main_loop.py timed out")
|
||
try:
|
||
data = json.loads(r.stdout.strip())
|
||
except Exception:
|
||
fail("LOOP_ERROR", "self_main_loop.py returned non-JSON",
|
||
{"stdout": (r.stdout or "")[-300:], "stderr": (r.stderr or "")[-300:]})
|
||
ok = bool(data.pop("ok", False))
|
||
out(ok, **data)
|
||
|
||
|
||
def act_watchdog_alerts(no_advance=False):
|
||
"""Check for new failed browser relaunches since the watermark.
|
||
|
||
Runs bin/watchdog-alert-check.sh, which exits 0 (no new failures)
|
||
or 1 (new failures printed to stdout) and maintains its own
|
||
watermark at NETVM_ROOT/watchdog-alert-watermark.txt.
|
||
|
||
no_advance=True passes --no-advance to the script: a peek-only read
|
||
that leaves the watermark untouched (for web-surface polling).
|
||
Default False preserves the classic CLI advance-on-read semantics.
|
||
"""
|
||
audit("watchdog-alerts")
|
||
script = BIN / "watchdog-alert-check.sh"
|
||
cmd = [str(script)] + (["--no-advance"] if no_advance else [])
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
failures = [l for l in (r.stdout or "").splitlines() if l.strip()]
|
||
# Exit 0 = no new failures, exit 1 = new failures found. Any other
|
||
# exit code is a script error.
|
||
if r.returncode not in (0, 1):
|
||
fail("WATCHDOG_CHECK_ERROR", "watchdog-alert-check.sh failed",
|
||
{"stderr": (r.stderr or "").strip()[-500:]})
|
||
out(True, new_failures=len(failures), failures=failures)
|
||
|
||
|
||
def act_fleet_status():
|
||
audit("fleet-status")
|
||
cmd = [sys.executable, str(BIN / "super-cli.py"), "fleet", "status", "--json"]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode == 0:
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
print(json.dumps(data))
|
||
return
|
||
except Exception:
|
||
pass
|
||
fail("FLEET_ERROR", "failed to collect fleet data", {"stderr": r.stderr})
|
||
|
||
|
||
def act_approval_check(node=None):
|
||
audit("approval-check", node)
|
||
if node is not None and node not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown node: {node}")
|
||
import approvals
|
||
nodes = [node] if node else list(VALID_AGENTS)
|
||
res = approvals.check_fleet_approvals(nodes)
|
||
out(True, approvals=res)
|
||
|
||
|
||
def act_approval_allow(node, always=False, force=False, message=None, allow_main_chat=False):
|
||
audit("approval-allow", node)
|
||
if node not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown node: {node}")
|
||
import approvals
|
||
res = approvals.allow_node_approval(node, always=always, force=force, caller="box-ctl",
|
||
message=message, allow_main_chat=allow_main_chat)
|
||
if res.get("ok"):
|
||
kw = {k: v for k, v in res.items() if k != "ok"}
|
||
out(True, **kw)
|
||
else:
|
||
fail("APPROVAL_FAILED", res.get("error", "approval failed"), res)
|
||
|
||
|
||
def act_approval_deny(node, message=None, allow_main_chat=False):
|
||
audit("approval-deny", node)
|
||
if node not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown node: {node}")
|
||
import approvals
|
||
res = approvals.deny_node_approval(node, caller="box-ctl",
|
||
message=message, allow_main_chat=allow_main_chat)
|
||
if res.get("ok"):
|
||
kw = {k: v for k, v in res.items() if k != "ok"}
|
||
out(True, **kw)
|
||
else:
|
||
fail("APPROVAL_FAILED", res.get("error", "deny failed"), res)
|
||
|
||
|
||
def act_approval_auto(node=None):
|
||
audit("approval-auto", node)
|
||
if node is not None and node not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown node: {node}")
|
||
import approvals
|
||
nodes = [node] if node else list(VALID_AGENTS)
|
||
res = approvals.auto_approve_fleet(nodes, caller="box-ctl")
|
||
kw = {k: v for k, v in res.items() if k != "ok"}
|
||
out(True, **kw)
|
||
|
||
|
||
def act_tmux_tally():
|
||
audit("tmux-tally")
|
||
try:
|
||
import tmux_auto_approver
|
||
tally = tmux_auto_approver.gather_tmux_tally()
|
||
out(True, **tmux_auto_approver.asdict(tally))
|
||
except Exception as e:
|
||
fail("TMUX_ERROR", f"tmux tally failed: {e}")
|
||
|
||
|
||
def act_tmux_auto_status():
|
||
audit("tmux-auto-status")
|
||
try:
|
||
import tmux_auto_approver
|
||
st = tmux_auto_approver.AutoApproverState.load()
|
||
out(True, **tmux_auto_approver.asdict(st))
|
||
except Exception as e:
|
||
fail("TMUX_ERROR", f"tmux auto status failed: {e}")
|
||
|
||
|
||
def act_tmux_auto_toggle(enable=True, node=None, session=None):
|
||
audit("tmux-auto-toggle", f"{'on' if enable else 'off'}:{node or session or 'all'}")
|
||
if node and node not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown node: {node}")
|
||
try:
|
||
import tmux_auto_approver
|
||
st = tmux_auto_approver.AutoApproverState.load()
|
||
if node:
|
||
st.agents_enabled[node] = bool(enable)
|
||
elif session:
|
||
st.sessions_enabled[session] = bool(enable)
|
||
else:
|
||
st.global_enabled = bool(enable)
|
||
if enable:
|
||
for a in VALID_AGENTS:
|
||
st.agents_enabled[a] = True
|
||
st.save()
|
||
out(True, enabled=bool(enable), node=node, session=session,
|
||
global_enabled=st.global_enabled, agents_enabled=st.agents_enabled)
|
||
except Exception as e:
|
||
fail("TMUX_ERROR", f"tmux auto toggle failed: {e}")
|
||
|
||
|
||
def act_tmux_auto_once(dry_run=False):
|
||
audit("tmux-auto-once", f"dry_run={dry_run}")
|
||
try:
|
||
import tmux_auto_approver
|
||
runner = tmux_auto_approver.AutoApproverRunner(dry_run=dry_run)
|
||
actions = runner.run_once()
|
||
out(True, actions=actions, dry_run=dry_run, count=len(actions))
|
||
except Exception as e:
|
||
fail("TMUX_ERROR", f"tmux auto run once failed: {e}")
|
||
|
||
|
||
def act_onboard_connects():
|
||
audit("onboard-connects")
|
||
try:
|
||
import onboard_pipeline
|
||
connects = onboard_pipeline.get_all_connects()
|
||
out(True, connects=connects, count=len(connects))
|
||
except Exception as e:
|
||
fail("ONBOARD_ERROR", f"onboard connects failed: {e}")
|
||
|
||
|
||
def act_relay_health():
|
||
"""Run relay-health-check.sh and return JSON results."""
|
||
audit("relay-health")
|
||
script = BIN / "relay-health-check.sh"
|
||
r = subprocess.run([str(script)], capture_output=True, text=True, timeout=60)
|
||
results = {}
|
||
for line in r.stdout.strip().split("\n"):
|
||
if ":" in line:
|
||
name, code = line.split(":", 1)
|
||
results[name.strip()] = code.strip()
|
||
all_healthy = r.returncode == 0
|
||
out(all_healthy, relays=results, healthy=all_healthy)
|
||
|
||
|
||
def act_identity_audit():
|
||
"""Run identity-audit-check.sh and return drift as JSON."""
|
||
audit("identity-audit")
|
||
script = BIN / "identity-audit-check.sh"
|
||
r = subprocess.run([str(script)], capture_output=True, text=True, timeout=60)
|
||
if r.returncode == 2:
|
||
fail("AUDIT_UNAVAILABLE", (r.stderr or r.stdout).strip()[:500])
|
||
drift = []
|
||
for line in r.stdout.strip().split("\n"):
|
||
if line.startswith("DRIFT: "):
|
||
drift.append(line[len("DRIFT: "):])
|
||
clean = r.returncode == 0
|
||
out(clean, drift=drift, clean=clean)
|
||
|
||
|
||
|
||
def act_cdp_latency():
|
||
"""Run cdp-latency-check.sh and return per-node latency JSON."""
|
||
audit("cdp-latency")
|
||
script = BIN / "cdp-latency-check.sh"
|
||
try:
|
||
r = subprocess.run([str(script)], capture_output=True, text=True, timeout=120)
|
||
except subprocess.TimeoutExpired:
|
||
fail("LATENCY_TIMEOUT", "cdp-latency-check.sh timed out")
|
||
nodes = []
|
||
for line in r.stdout.strip().split("\n"):
|
||
parts = line.strip().split(":")
|
||
if len(parts) != 3:
|
||
continue
|
||
name, lat, code = parts
|
||
entry = {"node": name.strip(), "http_code": code.strip()}
|
||
if lat.strip() == "FAIL":
|
||
entry["ok"] = False
|
||
entry["latency_ms"] = None
|
||
else:
|
||
try:
|
||
entry["latency_ms"] = int(lat.strip())
|
||
except ValueError:
|
||
entry["latency_ms"] = None
|
||
entry["ok"] = code.strip() == "200"
|
||
nodes.append(entry)
|
||
all_ok = bool(nodes) and all(n["ok"] for n in nodes)
|
||
out(all_ok, nodes=nodes)
|
||
|
||
def act_chrome_errors(no_advance=False):
|
||
"""Run chrome-error-scan.sh and return per-profile error counts as JSON.
|
||
|
||
no_advance=True passes --no-advance: a peek-only read that leaves the
|
||
watermark untouched (for web-surface polling). Default False preserves
|
||
the classic CLI advance-on-read semantics.
|
||
"""
|
||
audit("chrome-errors")
|
||
cmd = ["/bin/bash", str(BIN / "chrome-error-scan.sh"), "--json"]
|
||
if no_advance:
|
||
cmd.append("--no-advance")
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode == 0:
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
out(True, profiles=data)
|
||
return
|
||
except Exception:
|
||
pass
|
||
fail("SCAN_ERROR", "chrome-error-scan.sh failed", {"stderr": r.stderr})
|
||
|
||
|
||
def act_dm_log(limit=50, agent=None):
|
||
if agent is not None and agent not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown agent: {agent}")
|
||
audit("dm-log", f"{agent or 'all'}/{limit}")
|
||
cmd = [sys.executable, str(BIN / "super-cli.py"), "dm", "log", "--json", "-n", str(limit)]
|
||
if agent:
|
||
cmd += ["--agent", agent]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode == 0:
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
data["dms"] = data.get("entries", [])
|
||
print(json.dumps(data))
|
||
return
|
||
except Exception:
|
||
pass
|
||
fail("DM_LOG_ERROR", "failed to read dm log", {"stderr": r.stderr})
|
||
|
||
|
||
def act_unread(agent=None):
|
||
if agent is not None and agent not in VALID_AGENTS:
|
||
fail("BAD_NODE", f"unknown agent: {agent}")
|
||
audit("unread", agent or "all")
|
||
cmd = [sys.executable, str(BIN / "super-cli.py"), "lookup", "unread", "--json"]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode == 0:
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
if agent:
|
||
data["nodes"] = [n for n in data.get("nodes", []) if n.get("node") == agent]
|
||
print(json.dumps(data))
|
||
return
|
||
except Exception:
|
||
pass
|
||
fail("UNREAD_ERROR", "failed to collect unread counts", {"stderr": r.stderr})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# No-SSH agent development: git visibility + test runs + ack.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
GIT_BIN = shutil.which("git")
|
||
GIT_MAX_DIFF = 64 * 1024
|
||
GIT_MAX_STATUS = 200
|
||
TESTS_MAX_OUTPUT = 32 * 1024
|
||
TEST_MODULE_RE = re.compile(r"^tests\.[a-z0-9_]+$")
|
||
|
||
|
||
def _git_path(value):
|
||
"""Validate a repo-relative path for git subcommands (no escapes)."""
|
||
if not value or not isinstance(value, str):
|
||
fail("BAD_NAME", "path must be a non-empty string")
|
||
if ".." in value or value.startswith("/") or \
|
||
any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||
fail("BAD_NAME", "path must be repo-relative without '..'")
|
||
try:
|
||
resolved = (NETVM_ROOT / value).resolve()
|
||
resolved.relative_to(NETVM_ROOT.resolve())
|
||
except (OSError, ValueError):
|
||
fail("BAD_NAME", "path escapes the repository")
|
||
return value
|
||
|
||
|
||
def _run_git(args, timeout=30):
|
||
if GIT_BIN is None:
|
||
fail("GIT_ERROR", "git executable not found")
|
||
try:
|
||
return subprocess.run([GIT_BIN, "-C", str(NETVM_ROOT), *args],
|
||
capture_output=True, text=True, timeout=timeout)
|
||
except subprocess.TimeoutExpired:
|
||
fail("GIT_ERROR", "git command timed out")
|
||
except OSError as e:
|
||
fail("GIT_ERROR", f"cannot run git: {e}")
|
||
|
||
|
||
def act_git_status():
|
||
audit("git-status")
|
||
r = _run_git(["status", "--short", "--branch"])
|
||
if r.returncode != 0:
|
||
fail("GIT_ERROR", "git status failed", {"stderr": (r.stderr or "")[:500]})
|
||
lines = (r.stdout or "").splitlines()
|
||
branch = "unknown"
|
||
if lines and lines[0].startswith("## "):
|
||
branch = lines[0][3:].split("...")[0] or "unknown"
|
||
lines = lines[1:]
|
||
changes = [l for l in lines if l.strip()]
|
||
truncated = len(changes) > GIT_MAX_STATUS
|
||
out(True, branch=branch, changes=changes[:GIT_MAX_STATUS], truncated=truncated)
|
||
|
||
|
||
def act_git_diff(stat=False, path=None):
|
||
if path is not None:
|
||
path = _git_path(path)
|
||
audit("git-diff", f"{'stat' if stat else 'full'}:{path or 'all'}")
|
||
args = ["diff"]
|
||
if stat:
|
||
args.append("--stat")
|
||
if path:
|
||
args += ["--", path]
|
||
r = _run_git(args)
|
||
if r.returncode != 0:
|
||
fail("GIT_ERROR", "git diff failed", {"stderr": (r.stderr or "")[:500]})
|
||
text = r.stdout or ""
|
||
truncated = len(text) > GIT_MAX_DIFF
|
||
out(True, stat=bool(stat), path=path, diff=text[:GIT_MAX_DIFF],
|
||
truncated=truncated)
|
||
|
||
|
||
def act_git_log(limit=10, path=None):
|
||
if path is not None:
|
||
path = _git_path(path)
|
||
try:
|
||
limit = int(limit)
|
||
except (TypeError, ValueError):
|
||
fail("BAD_LIMIT", "limit must be an integer")
|
||
if not 1 <= limit <= 50:
|
||
fail("BAD_LIMIT", "limit must be 1..50")
|
||
audit("git-log", f"{path or 'all'}/{limit}")
|
||
args = ["log", "--oneline", "-n", str(limit)]
|
||
if path:
|
||
args += ["--", path]
|
||
r = _run_git(args)
|
||
if r.returncode != 0:
|
||
fail("GIT_ERROR", "git log failed", {"stderr": (r.stderr or "")[:500]})
|
||
commits = []
|
||
for line in (r.stdout or "").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
sha, _, subject = line.partition(" ")
|
||
commits.append({"sha": sha, "subject": subject})
|
||
out(True, commits=commits)
|
||
|
||
|
||
def act_tests_run(module=None, filter=None):
|
||
if module is not None:
|
||
if not TEST_MODULE_RE.match(module):
|
||
fail("BAD_NAME", "module must match ^tests\\.[a-z0-9_]+$")
|
||
if not (NETVM_ROOT / "tests" / (module.split(".", 1)[1] + ".py")).is_file():
|
||
fail("NOT_FOUND", f"unknown test module: {module}")
|
||
if filter is not None:
|
||
if not isinstance(filter, str) or not filter.strip() or len(filter) > 200:
|
||
fail("BAD_ARGS", "filter must be 1-200 chars")
|
||
if any(ord(c) < 32 or ord(c) == 127 for c in filter):
|
||
fail("BAD_ARGS", "filter contains control characters")
|
||
audit("tests-run", f"{module or 'all'}:{filter or '-'}")
|
||
if module:
|
||
argv = [sys.executable, "-m", "unittest", module]
|
||
else:
|
||
# No -t: tests/ has no __init__.py, so it is not importable as a
|
||
# package from top-level-dir '.'. Plain `discover -s tests` (the
|
||
# repo's own invocation) imports modules top-level and works.
|
||
argv = [sys.executable, "-m", "unittest", "discover", "-s", "tests"]
|
||
if filter:
|
||
argv += ["-k", filter]
|
||
# Hermetic import root: `python -m` prepends CWD to sys.path, but that
|
||
# is suppressed under PYTHONSAFEPATH/-P/isolated interpreters. Pin it.
|
||
env = dict(os.environ)
|
||
env["PYTHONPATH"] = str(NETVM_ROOT) + (
|
||
os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||
try:
|
||
r = subprocess.run(argv, capture_output=True, text=True,
|
||
timeout=600, cwd=str(NETVM_ROOT), env=env)
|
||
except subprocess.TimeoutExpired:
|
||
fail("TESTS_ERROR", "test run timed out after 600s")
|
||
except OSError as e:
|
||
fail("TESTS_ERROR", f"cannot run tests: {e}")
|
||
combined = (r.stdout or "") + (r.stderr or "")
|
||
truncated = len(combined) > TESTS_MAX_OUTPUT
|
||
out(r.returncode == 0, returncode=r.returncode,
|
||
output=combined[-TESTS_MAX_OUTPUT:], truncated=truncated,
|
||
module=module or "all")
|
||
|
||
|
||
def act_ack(ref_id, to, sender, sidechat=None, allow_main_chat=False):
|
||
if not ref_id or not DM_ID_RE.match(ref_id):
|
||
fail("BAD_NAME", "id must be 6-64 hex chars",
|
||
{"field": "id", "value": ref_id})
|
||
if to not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"to must be one of {sorted(VALID_AGENTS)}")
|
||
if sender not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"sender must be one of {sorted(VALID_AGENTS)}")
|
||
# Sidechat-first policy (mirrors notify): default to the agent's
|
||
# sidechat, never main, unless explicitly overridden.
|
||
if allow_main_chat:
|
||
target = "main"
|
||
extra = ["--allow-main-chat"]
|
||
else:
|
||
target = sidechat if sidechat else NOTIFY_SIDECHATS.get(to)
|
||
if not target:
|
||
fail("BAD_NAME", f"no default sidechat for agent {to}; pass --sidechat <name>")
|
||
if sidechat and not _valid_sidechat_name(sidechat):
|
||
fail("BAD_NAME", "sidechat name must be 1-64 chars, no control characters")
|
||
extra = []
|
||
message = f"[ACK:{ref_id}]"
|
||
r = run([sys.executable, str(DM_PY), "send",
|
||
"--agent", sender, "--to", to, "--target", target,
|
||
*extra, message],
|
||
timeout=120)
|
||
if r.returncode != 0:
|
||
fail("DISPATCH_FAILED", f"dm.py send failed: {(r.stderr or r.stdout).strip()[-500:]}")
|
||
audit("ack", f"{sender}->{to}:{ref_id}")
|
||
out(True, to=to, target=target, ack=ref_id, sent=True)
|
||
|
||
|
||
|
||
def _policy_meta():
|
||
"""Parse Version:/Date: from CHAT_POLICY.md."""
|
||
version, date = "unknown", "unknown"
|
||
try:
|
||
for line in POLICY_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
s = line.strip()
|
||
if s.startswith("**Version:**"):
|
||
version = s.split("**Version:**", 1)[1].strip().rstrip("\\")
|
||
elif s.startswith("**Date:**"):
|
||
date = s.split("**Date:**", 1)[1].strip().rstrip("\\")
|
||
except OSError:
|
||
pass
|
||
return version, date
|
||
|
||
|
||
def _watchdog_state():
|
||
"""Read the main-chat watchdog watermark (None if missing)."""
|
||
try:
|
||
return json.loads(WATCHDOG_STATE.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _policy_scan():
|
||
"""Classify dm-log.jsonl events for the sidechat-first policy.
|
||
|
||
Mirrors main-chat-watchdog.py classification:
|
||
VIOLATION : type=sent, target=main, no tags.allow_main_chat
|
||
AUTHORIZED: type=sent, target=main, tags.allow_main_chat set
|
||
BLOCKED : type=main_chat_blocked (gate worked)
|
||
Events before the allow_main_chat audit marker was adopted cannot be
|
||
classified, so the compliance window starts at marker adoption.
|
||
"""
|
||
per_agent = {}
|
||
legacy_untagged = 0
|
||
malformed = 0
|
||
adoption_ts = None
|
||
total_scanned = 0
|
||
|
||
def bucket(agent):
|
||
return per_agent.setdefault(agent, {
|
||
"blocked": 0,
|
||
"authorized_main": 0,
|
||
"violations": 0,
|
||
"total_sends": 0,
|
||
})
|
||
|
||
try:
|
||
with open(DM_LOG, "r", encoding="utf-8", errors="replace") as f:
|
||
lines = f.readlines()
|
||
except OSError:
|
||
return None, {"error": f"cannot read {DM_LOG}"}
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
ev = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
tags = ev.get("tags")
|
||
if isinstance(tags, dict) and "allow_main_chat" in tags:
|
||
ts = ev.get("ts") or ""
|
||
if adoption_ts is None or ts < adoption_ts:
|
||
adoption_ts = ts
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
total_scanned += 1
|
||
try:
|
||
ev = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
malformed += 1
|
||
continue
|
||
ts = ev.get("ts") or ""
|
||
if adoption_ts is not None and ts < adoption_ts:
|
||
if ev.get("type") == "sent" and ev.get("target") == "main":
|
||
legacy_untagged += 1
|
||
continue
|
||
etype = ev.get("type")
|
||
agent = ev.get("agent") or "unknown"
|
||
if etype == "main_chat_blocked":
|
||
bucket(agent)["blocked"] += 1
|
||
elif etype == "sent":
|
||
bucket(agent)["total_sends"] += 1
|
||
if ev.get("target") == "main":
|
||
tags = ev.get("tags") or {}
|
||
if tags.get("allow_main_chat"):
|
||
bucket(agent)["authorized_main"] += 1
|
||
else:
|
||
bucket(agent)["violations"] += 1
|
||
|
||
return per_agent, {
|
||
"window_start": adoption_ts,
|
||
"events_scanned": total_scanned,
|
||
"malformed": malformed,
|
||
"legacy_untagged_main_sends": legacy_untagged,
|
||
}
|
||
|
||
|
||
def act_policy():
|
||
audit("policy")
|
||
version, date = _policy_meta()
|
||
per_agent, meta = _policy_scan()
|
||
if per_agent is None:
|
||
fail("POLICY_ERROR", "failed to read dm log", meta)
|
||
totals = {"blocked": 0, "authorized_main": 0, "violations": 0, "total_sends": 0}
|
||
for counts in per_agent.values():
|
||
for k in totals:
|
||
totals[k] += counts[k]
|
||
out(True,
|
||
version=version,
|
||
date=date,
|
||
rule=("No DM naturally lands in main chat. Main requires explicit "
|
||
"opt-in (--allow-main-chat / allow_main_chat)."),
|
||
compliance_window=meta,
|
||
watchdog_state=_watchdog_state(),
|
||
agents=per_agent,
|
||
totals=totals,
|
||
status="clean" if totals["violations"] == 0 else "violations found")
|
||
|
||
|
||
def act_policy_check(agent):
|
||
if agent not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
|
||
audit("policy-check", agent)
|
||
per_agent, meta = _policy_scan()
|
||
if per_agent is None:
|
||
fail("POLICY_ERROR", "failed to read dm log", meta)
|
||
counts = per_agent.get(agent, {
|
||
"blocked": 0, "authorized_main": 0, "violations": 0, "total_sends": 0})
|
||
out(True, agent=agent,
|
||
compliance_window=meta, **counts,
|
||
status="clean" if counts["violations"] == 0 else "violations found")
|
||
|
||
|
||
def act_policy_show():
|
||
audit("policy-show")
|
||
try:
|
||
text = POLICY_FILE.read_text(encoding="utf-8", errors="replace")
|
||
except OSError as e:
|
||
fail("POLICY_ERROR", f"cannot read {POLICY_FILE}: {e}")
|
||
version, date = _policy_meta()
|
||
out(True, version=version, date=date, path=str(POLICY_FILE), policy=text)
|
||
|
||
|
||
def act_vars_list():
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
audit("vars-list")
|
||
out(True, variables=v.all(), schemas={n: v.schema(n) for n in v.names()})
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_vars_get(name):
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
audit("vars-get", name)
|
||
out(True, name=name, value=v.get(name), schema=v.schema(name))
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_vars_set(name, val_str):
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
spec = v.schema(name)
|
||
vtype = spec.get("type", "str")
|
||
if vtype == "int":
|
||
val = int(val_str)
|
||
elif vtype == "float":
|
||
val = float(val_str)
|
||
elif vtype == "bool":
|
||
val = val_str.lower() in ("true", "1", "yes")
|
||
else:
|
||
val = val_str
|
||
caller = os.environ.get("BOX_CALLER", "box-ctl")
|
||
new_val = v.set(name, val, by=caller)
|
||
audit("vars-set", f"{name}={new_val}")
|
||
out(True, name=name, value=new_val)
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_vars_reset(name):
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
caller = os.environ.get("BOX_CALLER", "box-ctl")
|
||
new_val = v.reset(name, by=caller)
|
||
audit("vars-reset", name)
|
||
out(True, name=name, value=new_val)
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_vars_history(name=None, limit=20):
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
entries = v.history(name=name, limit=limit)
|
||
audit("vars-history", name or "*")
|
||
out(True, history=entries, count=len(entries))
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_vars_rollback(name, revision=None):
|
||
try:
|
||
from variables import Variables
|
||
v = Variables()
|
||
caller = os.environ.get("BOX_CALLER", "box-ctl")
|
||
new_val = v.rollback(name, revision=revision, by=caller)
|
||
audit("vars-rollback", f"{name}={new_val}")
|
||
out(True, name=name, value=new_val)
|
||
except Exception as e:
|
||
fail("VARS_ERROR", str(e))
|
||
|
||
|
||
def act_strat_list():
|
||
try:
|
||
from modulate import get_all_strategies
|
||
audit("strat-list")
|
||
out(True, strategies=get_all_strategies())
|
||
except Exception as e:
|
||
fail("STRAT_ERROR", str(e))
|
||
|
||
|
||
def act_strat_get(itype, subtype=None, agent=None):
|
||
try:
|
||
from modulate import get_strategy_row, InputType, Priority
|
||
it = InputType(itype.lower())
|
||
row = get_strategy_row(it, subtype.upper() if subtype else None, agent=agent)
|
||
audit("strat-get", f"{itype}:{subtype or '*'}:{agent or '*'}")
|
||
out(True, type=it.value, subtype=subtype, agent=agent, track=row[0],
|
||
priority=row[1].value if isinstance(row[1], Priority) else str(row[1]),
|
||
timeout_s=row[2], nudges=row[3], escalate=row[4])
|
||
except Exception as e:
|
||
fail("STRAT_ERROR", str(e))
|
||
|
||
|
||
def act_strat_set(itype, payload_str=None):
|
||
try:
|
||
from modulate import set_strategy_override
|
||
if not payload_str:
|
||
payload_str = sys.stdin.read()
|
||
data = json.loads(payload_str)
|
||
subtype = data.get("subtype")
|
||
agent = data.get("agent")
|
||
caller = os.environ.get("BOX_CALLER", "box-ctl")
|
||
res = set_strategy_override(
|
||
itype, subtype, agent,
|
||
track=data.get("track"),
|
||
priority=data.get("priority"),
|
||
timeout_s=data.get("timeout_s") or data.get("timeout"),
|
||
nudges=data.get("nudges"),
|
||
escalate=data.get("escalate"),
|
||
by=caller
|
||
)
|
||
audit("strat-set", f"{itype}:{subtype or '*'}:{agent or '*'}")
|
||
out(True, type=itype, subtype=subtype, agent=agent, strategy=res)
|
||
except Exception as e:
|
||
fail("STRAT_ERROR", str(e))
|
||
|
||
|
||
def act_strat_reset(itype, subtype=None, agent=None):
|
||
try:
|
||
from modulate import reset_strategy_override
|
||
caller = os.environ.get("BOX_CALLER", "box-ctl")
|
||
ok = reset_strategy_override(itype, subtype, agent, by=caller)
|
||
audit("strat-reset", f"{itype}:{subtype or '*'}:{agent or '*'}")
|
||
out(True, type=itype, subtype=subtype, agent=agent, reset=ok)
|
||
except Exception as e:
|
||
fail("STRAT_ERROR", str(e))
|
||
|
||
|
||
def act_loop_status(agent=None, limit=20, status=None):
|
||
try:
|
||
from gravity import reconstruct_loops
|
||
loops = reconstruct_loops(limit=limit, agent=agent, status_filter=status)
|
||
audit("loop-status", f"agent={agent or '*'}")
|
||
out(True, loops=loops, count=len(loops))
|
||
except Exception as e:
|
||
fail("LOOP_ERROR", str(e))
|
||
|
||
|
||
def act_loop_health(threshold=None):
|
||
try:
|
||
from gravity import get_fleet_loop_health
|
||
t_val = float(threshold) if threshold is not None else None
|
||
h = get_fleet_loop_health(threshold=t_val)
|
||
audit("loop-health")
|
||
out(True, **h)
|
||
except Exception as e:
|
||
fail("LOOP_ERROR", str(e))
|
||
|
||
|
||
def act_loop_breaks():
|
||
try:
|
||
from gravity import diagnose_breaks
|
||
breaks = diagnose_breaks()
|
||
audit("loop-breaks")
|
||
out(True, breaks=breaks, count=len(breaks))
|
||
except Exception as e:
|
||
fail("LOOP_ERROR", str(e))
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SSH key minting & management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
KEY_DIR = Path("/home/super/.ssh")
|
||
ALLOWED_SIGNERS_PATHS = [
|
||
NETVM_ROOT / "dm-signers" / "allowed_signers",
|
||
Path("/home/super/.exec-signers")
|
||
]
|
||
|
||
def act_ssh_mint(name, force=False):
|
||
"""Mint a new ed25519 SSH keypair and register it in allowed_signers."""
|
||
key_name = f"id_{name}"
|
||
priv_path = KEY_DIR / key_name
|
||
pub_path = KEY_DIR / f"{key_name}.pub"
|
||
|
||
if priv_path.exists() and not force:
|
||
fail("KEY_EXISTS", f"SSH key {priv_path} already exists -- pass --force to overwrite",
|
||
{"key_path": str(priv_path)})
|
||
|
||
if priv_path.exists() and force:
|
||
try:
|
||
priv_path.unlink()
|
||
if pub_path.exists():
|
||
pub_path.unlink()
|
||
except Exception as e:
|
||
fail("IO_ERROR", f"Failed removing old key: {e}")
|
||
|
||
cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-C", f"{name}@netvm", "-f", str(priv_path)]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
fail("KEYGEN_FAILED", f"ssh-keygen failed: {r.stderr.strip()}")
|
||
|
||
pub_line = pub_path.read_text(encoding="utf-8").strip()
|
||
pub_key_body = pub_line.split()[1] if len(pub_line.split()) > 1 else pub_line
|
||
|
||
# Save to dm-signers/{name}.pub
|
||
ds_pub = NETVM_ROOT / "dm-signers" / f"{name}.pub"
|
||
try:
|
||
ds_pub.write_text(pub_line + "\n", encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
# Register into signers files
|
||
registered_files = []
|
||
for sp in ALLOWED_SIGNERS_PATHS:
|
||
try:
|
||
lines = sp.read_text(encoding="utf-8").splitlines() if sp.exists() else []
|
||
new_lines = []
|
||
found = False
|
||
for line in lines:
|
||
if not line.strip():
|
||
continue
|
||
parts = line.split()
|
||
if parts[0] in (name, f"operator-{name}"):
|
||
new_lines.append(f"{parts[0]} ssh-ed25519 {pub_key_body} {name}@netvm")
|
||
found = True
|
||
else:
|
||
new_lines.append(line)
|
||
if not found:
|
||
new_lines.append(f"{name} ssh-ed25519 {pub_key_body} {name}@netvm")
|
||
new_lines.append(f"operator-{name} ssh-ed25519 {pub_key_body} {name}@netvm")
|
||
|
||
sp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||
registered_files.append(str(sp))
|
||
except Exception as e:
|
||
sys.stderr.write(f"Warning: failed updating {sp}: {e}\n")
|
||
|
||
audit("ssh-mint", f"name={name} priv={priv_path}")
|
||
out(True, name=name, private_key=str(priv_path), public_key=str(pub_path),
|
||
public_key_content=pub_line, registered_in=registered_files)
|
||
|
||
|
||
def act_ssh_list():
|
||
"""List managed SSH keypairs and registrations."""
|
||
keys = []
|
||
if KEY_DIR.exists():
|
||
for p in sorted(KEY_DIR.glob("id_*")):
|
||
if not p.name.endswith(".pub"):
|
||
pub_p = p.with_name(p.name + ".pub")
|
||
pub_content = pub_p.read_text(encoding="utf-8").strip() if pub_p.exists() else None
|
||
keys.append({
|
||
"name": p.name[3:],
|
||
"private_key": str(p),
|
||
"has_public": pub_p.exists(),
|
||
"public_key": pub_content,
|
||
})
|
||
out(True, keys=keys, count=len(keys))
|
||
|
||
|
||
def act_ssh_show(name):
|
||
"""Show public key details and fingerprints for an identity."""
|
||
priv_path = KEY_DIR / f"id_{name}"
|
||
pub_path = KEY_DIR / f"id_{name}.pub"
|
||
if not pub_path.exists():
|
||
fail("NOT_FOUND", f"No public key found for {name} at {pub_path}")
|
||
|
||
pub_line = pub_path.read_text(encoding="utf-8").strip()
|
||
cmd = ["ssh-keygen", "-lf", str(pub_path)]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
fp = r.stdout.strip() if r.returncode == 0 else ""
|
||
|
||
out(True, name=name, private_key=str(priv_path), public_key=str(pub_path),
|
||
public_key_content=pub_line, fingerprint=fp)
|
||
|
||
|
||
def act_ssh_ports():
|
||
"""List container reverse tunnel port mappings."""
|
||
import agent_md
|
||
out(True, **agent_md.get_ssh_info())
|
||
|
||
|
||
def act_ssh_info(name):
|
||
"""Show SSH dial-in command and reverse tunnel coordinates for a specific agent."""
|
||
import agent_md
|
||
out(True, **agent_md.get_ssh_info(name))
|
||
|
||
|
||
MD_MAX_READ = 64 * 1024
|
||
MD_MAX_DIFF = 64 * 1024
|
||
MD_MAX_LIST = 200
|
||
|
||
|
||
def act_md_audit(accounts=None):
|
||
"""Audit markdown files and operational drive across fleet agents."""
|
||
import agent_md
|
||
audit("md-audit", ",".join(accounts or []))
|
||
try:
|
||
res = agent_md.audit_agents(accounts=accounts)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
out(True, agents=res)
|
||
|
||
|
||
def act_md_list(account, path=""):
|
||
"""List container files via Hatch."""
|
||
import agent_md
|
||
audit("md-list", f"{account}:{path}")
|
||
try:
|
||
entries = agent_md.list_files(account, path=path)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except FileNotFoundError as e:
|
||
fail("NOT_FOUND", str(e))
|
||
except Exception as e:
|
||
fail("GATEWAY_ERROR", f"md-list failed: {e}")
|
||
truncated = len(entries) > MD_MAX_LIST
|
||
out(True, account=account, path=path, entries=entries[:MD_MAX_LIST],
|
||
truncated=truncated)
|
||
|
||
|
||
def act_md_read(account, filename):
|
||
"""Read a markdown file from the agent container via Hatch."""
|
||
import agent_md
|
||
audit("md-read", f"{account}:{filename}")
|
||
try:
|
||
res = agent_md.read_md(account, filename)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except FileNotFoundError as e:
|
||
fail("NOT_FOUND", str(e))
|
||
except Exception as e:
|
||
fail("GATEWAY_ERROR", f"md-read failed: {e}")
|
||
ok = res.pop("ok", True)
|
||
text = res.get("text", "")
|
||
truncated = len(text) > MD_MAX_READ
|
||
if truncated:
|
||
res["text"] = text[:MD_MAX_READ]
|
||
out(ok, truncated=truncated, **res)
|
||
|
||
|
||
def act_md_write(account, filename, content, overwrite=True, append=False):
|
||
"""Write content to an agent's container file via Hatch."""
|
||
import agent_md
|
||
audit("md-write", f"{account}:{filename}")
|
||
try:
|
||
res = agent_md.write_md(account, filename, content, overwrite=overwrite, append=append)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except FileNotFoundError as e:
|
||
fail("NOT_FOUND", str(e))
|
||
except Exception as e:
|
||
fail("GATEWAY_ERROR", f"md-write failed: {e}")
|
||
out(res.pop("ok", True), **res)
|
||
|
||
|
||
def act_md_diff(account, filename):
|
||
"""Diff remote container file against local shared/operators template."""
|
||
import agent_md
|
||
audit("md-diff", f"{account}:{filename}")
|
||
try:
|
||
res = agent_md.diff_md(account, filename)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except FileNotFoundError as e:
|
||
fail("NOT_FOUND", str(e))
|
||
except Exception as e:
|
||
fail("GATEWAY_ERROR", f"md-diff failed: {e}")
|
||
ok = res.pop("ok", True)
|
||
diff = res.get("diff", "")
|
||
truncated = len(diff) > MD_MAX_DIFF
|
||
if truncated:
|
||
res["diff"] = diff[:MD_MAX_DIFF]
|
||
out(ok, truncated=truncated, **res)
|
||
|
||
|
||
def _md_amend_parse(args, usage):
|
||
"""Parse amend argv: filename + content/--content/--file/--stdin + flags."""
|
||
if not args:
|
||
fail("BAD_ARGS", usage)
|
||
filename = args[0]
|
||
content = None
|
||
author = "operator"
|
||
reason = ""
|
||
idx = 1
|
||
while idx < len(args):
|
||
if args[idx] == "--file" and idx + 1 < len(args):
|
||
content = Path(args[idx + 1]).read_text(encoding="utf-8")
|
||
idx += 2
|
||
elif args[idx] == "--content" and idx + 1 < len(args):
|
||
content = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--stdin":
|
||
content = sys.stdin.read()
|
||
idx += 1
|
||
elif args[idx] == "--author" and idx + 1 < len(args):
|
||
author = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--reason" and idx + 1 < len(args):
|
||
reason = args[idx + 1]
|
||
idx += 2
|
||
elif content is None and not args[idx].startswith("--"):
|
||
content = args[idx]
|
||
idx += 1
|
||
else:
|
||
idx += 1
|
||
if content is None:
|
||
fail("BAD_ARGS", usage)
|
||
return filename, content, author, reason
|
||
|
||
|
||
def _md_append_parse(args, usage):
|
||
"""Parse append argv: filename + text/--content/--file/--stdin + flags."""
|
||
if not args:
|
||
fail("BAD_ARGS", usage)
|
||
filename = args[0]
|
||
text = None
|
||
author = "operator"
|
||
section = None
|
||
idx = 1
|
||
while idx < len(args):
|
||
if args[idx] == "--file" and idx + 1 < len(args):
|
||
text = Path(args[idx + 1]).read_text(encoding="utf-8")
|
||
idx += 2
|
||
elif args[idx] == "--content" and idx + 1 < len(args):
|
||
text = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--stdin":
|
||
text = sys.stdin.read()
|
||
idx += 1
|
||
elif args[idx] == "--author" and idx + 1 < len(args):
|
||
author = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--section" and idx + 1 < len(args):
|
||
section = args[idx + 1]
|
||
idx += 2
|
||
elif text is None and not args[idx].startswith("--"):
|
||
text = args[idx]
|
||
idx += 1
|
||
else:
|
||
idx += 1
|
||
if text is None:
|
||
fail("BAD_ARGS", usage)
|
||
return filename, text, author, section
|
||
|
||
|
||
def act_md_amend(filename, content, author="operator", reason=""):
|
||
"""Amend centralized shared template with safety checks and git commit."""
|
||
import agent_md
|
||
audit("md-amend", f"{author}:{filename}")
|
||
try:
|
||
res = agent_md.amend_md(filename, content, author=author, reason=reason)
|
||
out(res.pop("ok", True), **res)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except Exception as e:
|
||
fail("AMEND_FAILED", str(e))
|
||
|
||
|
||
def act_md_append(filename, text, author="operator", section=None):
|
||
"""Safely append an amendment or lesson to a centralized shared template."""
|
||
import agent_md
|
||
audit("md-append", f"{author}:{filename}")
|
||
try:
|
||
res = agent_md.append_md(filename, text, author=author, section=section)
|
||
out(res.pop("ok", True), **res)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except Exception as e:
|
||
fail("APPEND_FAILED", str(e))
|
||
|
||
|
||
def act_md_pull(account, filename):
|
||
"""Pull canonical shared template into an agent container."""
|
||
import agent_md
|
||
audit("md-pull", f"{account}:{filename}")
|
||
try:
|
||
res = agent_md.pull_md(account, filename)
|
||
out(res.pop("ok", True), **res)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except Exception as e:
|
||
fail("PULL_FAILED", str(e))
|
||
|
||
|
||
def act_md_inject_drive(account, force=False):
|
||
"""Inject high-drive operational templates into an agent container via Hatch."""
|
||
import agent_md
|
||
audit("md-inject-drive", account)
|
||
try:
|
||
res = agent_md.inject_drive(account, force=force)
|
||
except agent_md.MDValidationError as e:
|
||
fail("BAD_NAME", str(e))
|
||
except FileNotFoundError as e:
|
||
fail("NOT_FOUND", str(e))
|
||
except Exception as e:
|
||
fail("GATEWAY_ERROR", f"md-inject-drive failed: {e}")
|
||
out(res.pop("ok", True), **res)
|
||
|
||
|
||
def act_md_sync_all(force=False):
|
||
"""Inject high-drive operational templates across all active agents."""
|
||
import agent_md
|
||
audit("md-sync-all")
|
||
results = {}
|
||
for acct in agent_md.VALID_ACCOUNTS:
|
||
try:
|
||
results[acct] = agent_md.inject_drive(acct, force=force)
|
||
except Exception as e:
|
||
results[acct] = {"ok": False, "error": str(e)}
|
||
out(True, results=results)
|
||
|
||
|
||
def act_loop_resolve(dm_id, note=None):
|
||
f_path = NETVM_ROOT / "followups.json"
|
||
resolved = False
|
||
if f_path.exists():
|
||
try:
|
||
with open(f_path, "r") as f:
|
||
data = json.load(f)
|
||
if dm_id in data:
|
||
data[dm_id]["status"] = "resolved"
|
||
data[dm_id]["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
||
if note:
|
||
data[dm_id]["note"] = note
|
||
tmp = f"{f_path}.tmp.{os.getpid()}"
|
||
with open(tmp, "w") as f:
|
||
json.dump(data, f, indent=2)
|
||
os.replace(tmp, f_path)
|
||
resolved = True
|
||
except Exception as e:
|
||
fail("LOOP_ERROR", f"Failed updating followups.json: {e}")
|
||
|
||
# Append to job-log.jsonl
|
||
try:
|
||
with open(JOB_LOG, "a") as f:
|
||
f.write(json.dumps({
|
||
"ts": datetime.now(timezone.utc).isoformat(),
|
||
"type": "loop_resolved",
|
||
"dm_id": dm_id,
|
||
"note": note or "manually resolved via box-ctl",
|
||
"caller": os.environ.get("BOX_CALLER", "box-ctl"),
|
||
}) + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
audit("loop-resolve", dm_id)
|
||
out(True, dm_id=dm_id, resolved=resolved)
|
||
|
||
|
||
def act_loop_remediate(dry_run=False):
|
||
try:
|
||
from gravity import remediate_breaks
|
||
res = remediate_breaks(dry_run=dry_run)
|
||
audit("loop-remediate", f"dry_run={dry_run}")
|
||
res.pop("ok", None)
|
||
out(True, **res)
|
||
except Exception as e:
|
||
fail("LOOP_ERROR", str(e))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
USAGE = """usage: box-ctl.py <action> [args]
|
||
|
||
fleet:
|
||
fleet-status
|
||
chrome-errors per-profile chrome FATAL/crash counts (new since watermark)
|
||
watchdog-alerts
|
||
relay-health
|
||
cdp-latency
|
||
identity-audit VM identity audit drift check
|
||
main-loop check|status|enable|disable [--agent <name>]
|
||
main-chat self-monitor; enable/disable per agent or all
|
||
|
||
timer actions:
|
||
timer-list
|
||
timer-status <name>
|
||
timer-create <name>
|
||
timer-delete <name> [--keep-job]
|
||
timer-start|timer-stop|timer-enable|timer-disable <name>
|
||
|
||
job actions:
|
||
job-list
|
||
job-get <name>
|
||
job-put <name> (job JSON on stdin)
|
||
job-delete <name> [--force]
|
||
job-trigger <name>
|
||
job-result <job-id> (result JSON on stdin; records RESULT + advances chain)
|
||
job-status <name-or-id> (dispatch/result/chain state)
|
||
job-next <job-id> [--success|--fail] (dry-run: what would box dispatch next)
|
||
job-chain <from> <to> [--on-failure] (wire chain_next between jobs)
|
||
|
||
variable actions:
|
||
vars-list
|
||
vars-get <name>
|
||
vars-set <name> <value>
|
||
vars-reset <name>
|
||
vars-history [name] [limit]
|
||
vars-rollback <name> [revision]
|
||
|
||
strategy actions:
|
||
strat-list
|
||
strat-get <type> [subtype] [--agent AGENT]
|
||
strat-set <type> (JSON on stdin or as arg)
|
||
strat-reset <type> [subtype] [--agent AGENT]
|
||
|
||
loop actions:
|
||
loop-status [--agent AGENT] [--limit N] [--status STATUS]
|
||
loop-health [--threshold T]
|
||
loop-breaks
|
||
loop-resolve <dm_id> [note]
|
||
loop-remediate [--dry-run]
|
||
|
||
thread actions (muse-cli gateway; hyphenated aliases for `thread <sub>`):
|
||
thread-list --agent <agent> [--limit N]
|
||
thread-pin --agent <agent> --thread <id>
|
||
thread-unpin --agent <agent> --thread <id>
|
||
thread-archive --agent <agent> --thread <id> --confirm
|
||
thread-unarchive --agent <agent> --thread <id>
|
||
thread-rename --agent <agent> --thread <id> --title <title>
|
||
|
||
notify:
|
||
notify <agent> <message> [--sidechat <name>] [--allow-main-chat]
|
||
ack <id> --to <agent> --sender <agent> [--sidechat <name>] [--allow-main-chat]
|
||
acknowledge a DM or work order (sidechat-first)
|
||
Send a DM to an agent's default sidechat (never main chat unless
|
||
--allow-main-chat is passed explicitly).
|
||
|
||
policy:
|
||
policy chat policy version, rule, and compliance summary
|
||
policy check <agent> per-agent compliance detail
|
||
policy show print the full CHAT_POLICY.md
|
||
|
||
thread actions (muse-cli gateway):
|
||
thread list <agent> [--limit N]
|
||
thread pin <agent> <thread-id>
|
||
thread unpin <agent> <thread-id>
|
||
thread archive <agent> <thread-id> --confirm
|
||
thread unarchive <agent> <thread-id>
|
||
|
||
swarm actions (agent swarms; bl owns state, operator agents do the spawning):
|
||
swarm-spawn <count> <task...> [--label L]
|
||
swarm-list
|
||
swarm-status <swarm-id>
|
||
swarm-attach <swarm-id> <slot> <agent-id>
|
||
swarm-report <swarm-id> <slot> (result JSON on stdin)
|
||
swarm-results <swarm-id>
|
||
swarm-kill <swarm-id> --confirm
|
||
swarm-prune [--stale-hours H] [--confirm]
|
||
archive stale terminal swarms (previews count without --confirm)
|
||
(space-separated aliases: swarm spawn|list|status|attach|report|results|kill|prune)
|
||
quality:
|
||
quality-check run box-ctl self-diagnostics (validators, output
|
||
contract, audit path, atomic writes)
|
||
quality-validate <action> [args...]
|
||
dry-run: validate args without executing
|
||
(space-separated alias: quality check|validate)
|
||
|
||
dm-log:
|
||
dm-log [limit] [--agent <agent>]
|
||
recent DM send log (agent-scoped when given)
|
||
unread [--agent <agent>] fleet unread/activity counts (read-only)
|
||
|
||
dev (no-SSH agent development):
|
||
git-status git status --short --branch (read-only)
|
||
git-diff [--stat] [--path <p>]
|
||
git diff, capped at 64KB (read-only)
|
||
git-log [--limit N] [--path <p>]
|
||
recent commits as sha/subject (read-only)
|
||
tests-run [tests.<module>] [--filter <pattern>]
|
||
run repo unit tests (full suite when omitted;
|
||
--filter is unittest -k)
|
||
|
||
md actions (agent .md files via Hatch; space-separated aliases: md audit|list|read|write|diff|amend|append|pull|inject-drive|sync-all):
|
||
md-audit [accounts...] fleet drive audit (read-only)
|
||
md-list <account> [path] list container files, capped at 200 (read-only)
|
||
md-read <account> <filename>
|
||
read container file, capped at 64KB (read-only)
|
||
md-diff <account> <filename>
|
||
diff against shared template, capped at 64KB (read-only)
|
||
md-pull <account> <filename>
|
||
pull canonical template into container
|
||
md-inject-drive <account> [--force]
|
||
inject high-drive templates into one agent
|
||
md-sync-all [--force] inject high-drive templates across all agents
|
||
md-amend <filename> (<content>|--content t|--file p|--stdin)
|
||
rewrite shared template (validated, git-committed)
|
||
[--author name] [--reason why]
|
||
md-append <filename> (<text>|--content t|--file p|--stdin)
|
||
append to shared template (git-committed)
|
||
[--author name] [--section header]
|
||
md-write <account> <filename> <content>
|
||
raw container write (SSH-only, no HTTPS op)
|
||
|
||
approval actions (fleet browser approvals via CDP):
|
||
approval-check [node] pending approvals across fleet or one node (read-only)
|
||
approval-list [node] (alias for approval-check)
|
||
approval-allow <node> [--always] [--force] [--message TEXT] [--allow-main-chat]
|
||
allow the node's active approval
|
||
approval-approve <node> [...] (alias for approval-allow)
|
||
approval-deny <node> [--message TEXT] [--allow-main-chat]
|
||
deny the node's active approval
|
||
approval-auto [node] auto-allow TRUSTED (non-key) prompts
|
||
|
||
tmux & worker actions (multi-socket tally & regex auto-approvals):
|
||
tmux-tally tally all tmux sessions, workers, and panes across fleet sockets (read-only)
|
||
tmux-auto-status runtime status of tmux auto-approvals & rule inventory (read-only)
|
||
tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]
|
||
toggle tmux auto-approvals globally, per-agent, or per-session
|
||
tmux-auto-once [--dry-run] one-shot scan & auto-approve terminal prompts
|
||
(space-separated alias: tmux tally|auto|status|toggle|once)
|
||
|
||
onboard actions:
|
||
onboard-connects consolidated active fleet and client onboard connects (read-only)
|
||
(space-separated alias: onboard connects)
|
||
|
||
ssh actions (space-separated alias: ssh mint|list|show|ports|info):
|
||
ssh-mint <name> [--force] mint a new SSH key
|
||
ssh-list list minted keys
|
||
ssh-show <name> show key detail
|
||
ssh-ports tunnel port inventory
|
||
ssh-info [name] connection coordinates"""
|
||
|
||
|
||
def act_thread(op, agent, thread=None, title=None, limit=None, confirm=False):
|
||
"""Thread bookkeeping via bin/muse-threads.py (hybrid gateway).
|
||
|
||
ops: list | pin | unpin | archive | unarchive | rename
|
||
archive requires confirm=True (the --confirm flag; BatchMode SSH
|
||
cannot prompt, so the flag is the explicit confirmation).
|
||
"""
|
||
if op not in ("list", "pin", "unpin", "archive", "unarchive", "rename"):
|
||
fail("BAD_NAME", "usage: thread list|pin|unpin|archive|unarchive|rename <agent> [args]")
|
||
if agent not in VALID_AGENTS:
|
||
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
|
||
if op == "list":
|
||
audit("thread-list-" + agent)
|
||
else:
|
||
if not thread or not THREAD_RE.match(thread):
|
||
fail("BAD_NAME", "thread id must match ^[A-Za-z0-9-]{1,64}$",
|
||
{"field": "thread", "value": thread})
|
||
if op == "archive" and not confirm:
|
||
fail("CONFIRM_REQUIRED",
|
||
"archive is a visible side effect -- pass --confirm to proceed",
|
||
{"would_archive": {"agent": agent, "thread": thread}})
|
||
if op == "rename" and not title:
|
||
fail("BAD_NAME", "rename requires a title")
|
||
audit("thread-" + op, f"{agent}/{thread}")
|
||
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "muse-threads.py")
|
||
cmd = [sys.executable, helper, op, "--agent", agent]
|
||
if thread:
|
||
cmd += ["--thread", thread]
|
||
if title:
|
||
cmd += ["--title", title]
|
||
try:
|
||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
except subprocess.TimeoutExpired:
|
||
fail("GATEWAY_UNAVAILABLE", f"muse-threads.py timed out (op={op})")
|
||
except OSError as e:
|
||
fail("GATEWAY_UNAVAILABLE", f"cannot run muse-threads.py: {e}")
|
||
# Helper prints one JSON object; relay it, applying --limit for list
|
||
if p.returncode == 0:
|
||
raw = p.stdout.strip()
|
||
if limit is not None and op == "list" and raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data.get("threads"), list):
|
||
data["threads"] = data["threads"][:limit]
|
||
print(json.dumps(data))
|
||
return
|
||
except ValueError:
|
||
pass
|
||
print(raw if raw else '{"ok": true}')
|
||
return
|
||
# Failure: try to extract the helper's JSON error, else wrap
|
||
try:
|
||
err = json.loads(p.stdout.strip() or "{}")
|
||
fail(err.get("code", "GATEWAY_ERROR"), err.get("error", "thread op failed"),
|
||
err.get("detail"))
|
||
except (ValueError, AttributeError):
|
||
fail("GATEWAY_ERROR", f"thread {op} failed",
|
||
(p.stderr.strip() or p.stdout.strip())[:300])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Agent swarms — parallel subagent groups tracked on bl.
|
||
#
|
||
# box-ctl.py owns the swarm STATE (bl is the source of truth). Actual agent
|
||
# spawning is done by an operator agent in the container via subagent.spawn;
|
||
# the spawner attaches agent IDs with swarm-attach and records outcomes with
|
||
# swarm-report. A `swarm-spawner` cron agent can poll `swarm-list` for pending
|
||
# swarms and drive them without human involvement.
|
||
#
|
||
# Lifecycle: pending -> running -> completed|partial|killed
|
||
# Slot lifecycle: pending -> running -> done|failed|killed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SWARM_FILE = NETVM_ROOT / "swarms.json"
|
||
SWARM_ID_RE = re.compile(r"^[a-z0-9-]{1,64}$")
|
||
SWARM_AGENT_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||
SWARM_OPS = ("spawn", "list", "status", "attach", "report", "results", "kill", "prune")
|
||
SWARM_MAX_COUNT = 50
|
||
SWARM_MAX_TASK_LEN = 2000
|
||
|
||
|
||
def _swarm_load():
|
||
try:
|
||
with open(SWARM_FILE) as f:
|
||
data = json.load(f)
|
||
return data if isinstance(data, dict) else {}
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
|
||
def _swarm_save(swarms):
|
||
tmp = str(SWARM_FILE) + ".tmp"
|
||
with open(tmp, "w") as f:
|
||
json.dump(swarms, f, indent=2)
|
||
os.replace(tmp, SWARM_FILE)
|
||
|
||
|
||
def _swarm_new_id():
|
||
return ("sw-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||
+ "-" + os.urandom(2).hex())
|
||
|
||
|
||
def _swarm_get(swarms, sid):
|
||
if not sid or not SWARM_ID_RE.match(sid):
|
||
fail("BAD_NAME", "swarm id must match ^[a-z0-9-]{1,64}$",
|
||
{"field": "swarm_id", "value": sid})
|
||
swarm = swarms.get(sid)
|
||
if swarm is None:
|
||
fail("SWARM_NOT_FOUND", "no such swarm: %s" % sid,
|
||
{"swarm_id": sid})
|
||
return swarm
|
||
|
||
|
||
def _swarm_counts(swarm):
|
||
counts = {"pending": 0, "running": 0, "done": 0, "failed": 0,
|
||
"killed": 0}
|
||
for slot in swarm.get("slots", []):
|
||
st = slot.get("status")
|
||
if st in counts:
|
||
counts[st] += 1
|
||
return counts
|
||
|
||
|
||
def _swarm_rollup(swarm):
|
||
"""Recompute swarm-level status from slot statuses."""
|
||
counts = _swarm_counts(swarm)
|
||
total = len(swarm.get("slots", []))
|
||
if total and counts["killed"] == total:
|
||
return "killed"
|
||
if total and counts["pending"] + counts["running"] == 0:
|
||
return "completed" if counts["failed"] == 0 else "partial"
|
||
if counts["running"] or counts["done"] or counts["failed"]:
|
||
return "running"
|
||
return "pending"
|
||
|
||
|
||
def _swarm_slot(swarm, slot_str):
|
||
try:
|
||
slot = int(slot_str)
|
||
except (TypeError, ValueError):
|
||
fail("BAD_SLOT", "slot must be an integer", {"value": slot_str})
|
||
slots = swarm.get("slots", [])
|
||
if not 0 <= slot < len(slots):
|
||
fail("BAD_SLOT", "slot must be 0-%d" % (len(slots) - 1),
|
||
{"value": slot_str})
|
||
return slots[slot]
|
||
|
||
|
||
def act_swarm_spawn(count_str, task, label=None):
|
||
try:
|
||
count = int(count_str)
|
||
except (TypeError, ValueError):
|
||
fail("BAD_COUNT", "count must be an integer 1-50",
|
||
{"value": count_str})
|
||
if not 1 <= count <= SWARM_MAX_COUNT:
|
||
fail("BAD_COUNT", "count must be 1-%d" % SWARM_MAX_COUNT,
|
||
{"value": count})
|
||
if not task or not task.strip():
|
||
fail("BAD_TASK", "task must be a non-empty string")
|
||
if len(task) > SWARM_MAX_TASK_LEN:
|
||
fail("BAD_TASK", "task must be <= %d chars" % SWARM_MAX_TASK_LEN,
|
||
{"length": len(task)})
|
||
if label is not None and not SWARM_ID_RE.match(label):
|
||
fail("BAD_NAME", "label must match ^[a-z0-9-]{1,64}$",
|
||
{"field": "label", "value": label})
|
||
swarms = _swarm_load()
|
||
sid = _swarm_new_id()
|
||
while sid in swarms: # vanishingly unlikely; be safe
|
||
sid = _swarm_new_id()
|
||
now = utcnow()
|
||
swarm = {
|
||
"swarm_id": sid,
|
||
"task": task,
|
||
"count": count,
|
||
"label": label,
|
||
"status": "pending",
|
||
"created_ts": now,
|
||
"created_by": os.environ.get("BOX_CALLER", "unknown"),
|
||
"updated_ts": now,
|
||
"slots": [
|
||
{"slot": i, "agent_id": None, "status": "pending",
|
||
"result": None, "updated_ts": now}
|
||
for i in range(count)
|
||
],
|
||
}
|
||
swarms[sid] = swarm
|
||
_swarm_save(swarms)
|
||
audit("swarm-spawn", sid)
|
||
out(True, swarm_id=sid, count=count, status="pending", label=label)
|
||
|
||
|
||
def act_swarm_list():
|
||
swarms = _swarm_load()
|
||
items = []
|
||
for sid, swarm in sorted(
|
||
swarms.items(), key=lambda kv: kv[1].get("created_ts", ""),
|
||
reverse=True):
|
||
counts = _swarm_counts(swarm)
|
||
item = {
|
||
"swarm_id": sid,
|
||
"label": swarm.get("label"),
|
||
"status": swarm.get("status"),
|
||
"count": swarm.get("count"),
|
||
"task_preview": (swarm.get("task") or "")[:80],
|
||
"created_ts": swarm.get("created_ts"),
|
||
}
|
||
item.update(counts)
|
||
items.append(item)
|
||
out(True, swarms=items)
|
||
|
||
|
||
def act_swarm_status(sid):
|
||
swarms = _swarm_load()
|
||
swarm = _swarm_get(swarms, sid)
|
||
out(True, swarm=swarm, counts=_swarm_counts(swarm))
|
||
|
||
|
||
def act_swarm_attach(sid, slot_str, agent_id, session_id=None):
|
||
if not agent_id or not SWARM_AGENT_RE.match(agent_id):
|
||
fail("BAD_NAME", "agent id must match ^[A-Za-z0-9-]{1,64}$",
|
||
{"field": "agent_id", "value": agent_id})
|
||
swarms = _swarm_load()
|
||
swarm = _swarm_get(swarms, sid)
|
||
if swarm.get("status") in ("killed", "completed", "partial"):
|
||
fail("SWARM_CLOSED", "swarm is %s; cannot attach" % swarm["status"],
|
||
{"swarm_id": sid})
|
||
slot_rec = _swarm_slot(swarm, slot_str)
|
||
if slot_rec["status"] != "pending":
|
||
fail("SWARM_SLOT_BUSY",
|
||
"slot %d is %s, not pending" % (slot_rec["slot"],
|
||
slot_rec["status"]),
|
||
{"swarm_id": sid, "slot": slot_rec["slot"]})
|
||
slot_rec["agent_id"] = agent_id
|
||
slot_rec["status"] = "running"
|
||
if session_id:
|
||
slot_rec["subagent_session_id"] = str(session_id)
|
||
slot_rec["updated_ts"] = utcnow()
|
||
swarm["status"] = _swarm_rollup(swarm)
|
||
swarm["updated_ts"] = utcnow()
|
||
_swarm_save(swarms)
|
||
audit("swarm-attach", "%s/%d" % (sid, slot_rec["slot"]))
|
||
out(True, swarm_id=sid, slot=slot_rec["slot"], agent_id=agent_id,
|
||
subagent_session_id=session_id, status="running")
|
||
|
||
|
||
def act_swarm_report(sid, slot_str):
|
||
swarms = _swarm_load()
|
||
swarm = _swarm_get(swarms, sid)
|
||
slot_rec = _swarm_slot(swarm, slot_str)
|
||
if slot_rec["status"] in ("done", "failed", "killed"):
|
||
fail("SWARM_SLOT_CLOSED",
|
||
"slot %d already %s" % (slot_rec["slot"], slot_rec["status"]),
|
||
{"swarm_id": sid, "slot": slot_rec["slot"]})
|
||
raw = sys.stdin.read()
|
||
ok = True
|
||
result = None
|
||
if raw.strip():
|
||
try:
|
||
payload = json.loads(raw)
|
||
if isinstance(payload, dict):
|
||
ok = bool(payload.get("ok", True))
|
||
result = payload.get("result", payload)
|
||
else:
|
||
result = payload
|
||
except ValueError:
|
||
result = raw[:4000] # plain-text result
|
||
slot_rec["status"] = "done" if ok else "failed"
|
||
slot_rec["result"] = result
|
||
slot_rec["updated_ts"] = utcnow()
|
||
swarm["status"] = _swarm_rollup(swarm)
|
||
swarm["updated_ts"] = utcnow()
|
||
_swarm_save(swarms)
|
||
audit("swarm-report", "%s/%d" % (sid, slot_rec["slot"]))
|
||
out(True, swarm_id=sid, slot=slot_rec["slot"],
|
||
status=slot_rec["status"], swarm_status=swarm["status"])
|
||
|
||
|
||
def act_swarm_results(sid):
|
||
swarms = _swarm_load()
|
||
swarm = _swarm_get(swarms, sid)
|
||
results = [
|
||
{"slot": s["slot"], "agent_id": s.get("agent_id"),
|
||
"status": s["status"], "result": s.get("result")}
|
||
for s in swarm.get("slots", [])
|
||
]
|
||
out(True, swarm_id=sid, status=swarm.get("status"),
|
||
counts=_swarm_counts(swarm), results=results)
|
||
|
||
|
||
def act_swarm_prune(stale_hours=6, confirm=False):
|
||
import datetime
|
||
swarms = _swarm_load()
|
||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=stale_hours)
|
||
pruned = []
|
||
|
||
for sid, swarm in swarms.items():
|
||
st = swarm.get("status")
|
||
up_ts = swarm.get("updated_ts") or swarm.get("created_ts")
|
||
if not up_ts:
|
||
continue
|
||
try:
|
||
dt = datetime.datetime.fromisoformat(up_ts.replace("Z", "+00:00"))
|
||
except Exception:
|
||
continue
|
||
|
||
if dt < cutoff and st in ("partial", "completed", "killed"):
|
||
pruned.append(sid)
|
||
|
||
if not confirm:
|
||
fail("CONFIRM_REQUIRED",
|
||
f"prune transitions {len(pruned)} stale swarms to 'archived' -- pass --confirm to proceed",
|
||
{"stale_hours": stale_hours, "matching_count": len(pruned), "sample": pruned[:10]})
|
||
|
||
now = utcnow()
|
||
archived_count = 0
|
||
for sid in pruned:
|
||
swarms[sid]["status"] = "archived"
|
||
swarms[sid]["updated_ts"] = now
|
||
archived_count += 1
|
||
|
||
_swarm_save(swarms)
|
||
audit("swarm-prune", f"stale_hours={stale_hours} count={archived_count}")
|
||
out(True, archived_count=archived_count, stale_hours=stale_hours)
|
||
|
||
|
||
def act_swarm_kill(sid, confirm=False):
|
||
swarms = _swarm_load()
|
||
swarm = _swarm_get(swarms, sid)
|
||
if not confirm:
|
||
fail("CONFIRM_REQUIRED",
|
||
"kill terminates a swarm -- pass --confirm to proceed",
|
||
{"would_kill": {"swarm_id": sid,
|
||
"active_slots": _swarm_counts(swarm)}})
|
||
now = utcnow()
|
||
killed_slots = 0
|
||
for s in swarm.get("slots", []):
|
||
if s["status"] in ("pending", "running"):
|
||
s["status"] = "killed"
|
||
s["updated_ts"] = now
|
||
killed_slots += 1
|
||
counts = _swarm_counts(swarm)
|
||
# All slots are terminal now: killed if nothing ever completed/failed,
|
||
# otherwise partial (some work finished before the kill).
|
||
if counts["done"] + counts["failed"] > 0:
|
||
swarm["status"] = "partial"
|
||
else:
|
||
swarm["status"] = "killed"
|
||
swarm["updated_ts"] = now
|
||
_swarm_save(swarms)
|
||
audit("swarm-kill", sid)
|
||
out(True, swarm_id=sid, status=swarm["status"],
|
||
killed_slots=killed_slots)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Quality functions — input validation, output contract, idempotency helpers,
|
||
# and self-diagnostics.
|
||
#
|
||
# quality check run self-diagnostics (validators, output
|
||
# contract, audit path, atomic writes)
|
||
# quality validate <action> [...] dry-run: validate args for <action>
|
||
# without executing it
|
||
# (hyphenated aliases: quality-check, quality-validate)
|
||
#
|
||
# Conventions:
|
||
# - qv_*() validators are NON-FATAL: they return check dicts, never exit.
|
||
# (The fatal check_*() helpers above are for live actions; qv_*() are for
|
||
# pre-flight validation and diagnostics.)
|
||
# - A check dict is {"check": name, "ok": bool, "code": str|None,
|
||
# "reason": str|None}.
|
||
# - Output contract: every response is {"ok": bool, ...}; ok=false responses
|
||
# MUST carry "code" (a member of KNOWN_ERROR_CODES) and "error".
|
||
# - Idempotency: mutating actions append state and are NOT implicitly
|
||
# retry-safe. q_atomic_write() is the primitive for retry-safe writes
|
||
# (tmp file + os.replace); IDEMPOTENT_ACTIONS lists the read-only/dry-run
|
||
# actions that are safe to retry.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
QUALITY_VERSION = "1"
|
||
|
||
KNOWN_ERROR_CODES = frozenset([
|
||
"ALREADY_EXISTS", "AMEND_FAILED", "APPEND_FAILED", "APPROVAL_FAILED",
|
||
"AUDIT_UNAVAILABLE", "BAD_ARGS", "BAD_COUNT",
|
||
"BAD_LIMIT", "BAD_NAME", "BAD_NODE", "BAD_SLOT", "BAD_TASK",
|
||
"CONFIRM_REQUIRED",
|
||
"DISPATCH_FAILED", "DM_LOG_ERROR", "FLEET_ERROR", "GATEWAY_ERROR",
|
||
"GATEWAY_UNAVAILABLE", "INVALID_JOB", "INVALID_RESULT",
|
||
"INVALID_SCHEDULE", "IO_ERROR", "KEYGEN_FAILED", "KEY_EXISTS",
|
||
"LATENCY_TIMEOUT", "LOG_FAILED", "LOOP_ERROR",
|
||
"LOOP_TIMEOUT", "NAME_MISMATCH", "NOT_FOUND", "POLICY_ERROR",
|
||
"PULL_FAILED",
|
||
"SCAN_ERROR", "STRAT_ERROR", "SWARM_CLOSED", "SWARM_NOT_FOUND",
|
||
"SWARM_SLOT_BUSY", "SWARM_SLOT_CLOSED", "TIMER_CREATE_FAILED",
|
||
"TIMER_STILL_ACTIVE", "UNREAD_ERROR", "VARS_ERROR", "WATCHDOG_CHECK_ERROR",
|
||
"GIT_ERROR", "TESTS_ERROR", "TMUX_ERROR", "ONBOARD_ERROR",
|
||
])
|
||
|
||
# Read-only / dry-run actions: safe to retry.
|
||
IDEMPOTENT_ACTIONS = frozenset([
|
||
"fleet-status", "watchdog-alerts", "relay-health", "cdp-latency",
|
||
"chrome-errors", "identity-audit", "timer-list", "timer-status",
|
||
"job-list", "job-get", "job-status", "job-next", "vars-list",
|
||
"vars-get", "vars-history", "strat-list", "strat-get", "loop-status",
|
||
"loop-health", "loop-breaks", "policy", "policy-check", "policy-show",
|
||
"thread-list", "dm-log", "unread", "swarm-list", "swarm-status", "swarm-results",
|
||
"quality-check", "quality-validate", "main-loop",
|
||
"git-status", "git-diff", "git-log",
|
||
"md-audit", "md-list", "md-read", "md-diff",
|
||
"approval-check", "approval-list",
|
||
"tmux-tally", "tmux-auto-status", "onboard-connects",
|
||
])
|
||
|
||
JOB_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,127}$")
|
||
SWARM_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||
AGENT_REF_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
|
||
DM_ID_RE = re.compile(r"^[0-9a-fA-F]{6,64}$")
|
||
# Mirrors agent_md.py validation (single-copy lives there; these keep
|
||
# dry-run validation in sync without importing the gateway module).
|
||
MD_ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
|
||
MD_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
|
||
MD_SUBPATH_RE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$")
|
||
MD_TEMPLATE_FILES = frozenset([
|
||
"SOUL.md", "PROACTIVE_PREFERENCES.md", "HEARTBEAT.md", "AGENTS.md",
|
||
"MEMORY.md", "USER.md", "TOOLS.md", "IDENTITY.md",
|
||
])
|
||
|
||
|
||
def _qcheck(name, ok, code=None, reason=None):
|
||
d = {"check": name, "ok": bool(ok)}
|
||
if code:
|
||
d["code"] = code
|
||
if reason:
|
||
d["reason"] = reason
|
||
return d
|
||
|
||
|
||
def qv_name(value, field="name"):
|
||
if not value or not NAME_RE.match(value):
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"must match ^[a-z0-9-]{1,64}$")
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_var_name(value, field="name"):
|
||
# Variable names (loop_health_threshold, ...) allow underscores and
|
||
# dots, unlike job names. Mirrors exec-constrained.py NAME_RE.
|
||
if not value or not VAR_NAME_RE.match(value):
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"must match ^[A-Za-z0-9_.-]{1,64}$")
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_agent(value):
|
||
if value not in VALID_AGENTS:
|
||
return _qcheck("agent", False, "BAD_NAME",
|
||
"unknown agent %r; valid: %s" % (value, sorted(VALID_AGENTS)))
|
||
return _qcheck("agent", True)
|
||
|
||
|
||
def qv_thread_id(value):
|
||
if not value or not THREAD_RE.match(value):
|
||
return _qcheck("thread_id", False, "BAD_NAME",
|
||
"must match ^[A-Za-z0-9-]{1,64}$")
|
||
return _qcheck("thread_id", True)
|
||
|
||
|
||
def qv_job_id(value):
|
||
if not value or not JOB_ID_RE.match(value):
|
||
return _qcheck("job_id", False, "BAD_NAME",
|
||
"must match ^[a-z0-9][a-z0-9-]{0,127}$")
|
||
return _qcheck("job_id", True)
|
||
|
||
|
||
def qv_swarm_id(value):
|
||
if not value or not SWARM_ID_RE.match(value):
|
||
return _qcheck("swarm_id", False, "BAD_NAME",
|
||
"must match ^[a-z0-9][a-z0-9-]{0,63}$")
|
||
return _qcheck("swarm_id", True)
|
||
|
||
|
||
def qv_agent_ref(value, field="agent_id"):
|
||
# Looser than qv_agent: accepts subagent UUIDs / external refs.
|
||
if not value or not AGENT_REF_RE.match(value):
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"must match ^[A-Za-z0-9_.-]{1,128}$")
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_dm_id(value):
|
||
if not value or not DM_ID_RE.match(value):
|
||
return _qcheck("dm_id", False, "BAD_NAME",
|
||
"must be 6-64 hex chars")
|
||
return _qcheck("dm_id", True)
|
||
|
||
|
||
def qv_relpath(value, field="path"):
|
||
if not value or not isinstance(value, str):
|
||
return _qcheck(field, False, "BAD_NAME", "path must be non-empty")
|
||
if ".." in value or value.startswith("/"):
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"path must be repo-relative without '..'")
|
||
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"path contains control characters")
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_test_module(value):
|
||
if not value or not TEST_MODULE_RE.match(value):
|
||
return _qcheck("module", False, "BAD_NAME",
|
||
"must match ^tests\\.[a-z0-9_]+$")
|
||
return _qcheck("module", True)
|
||
|
||
|
||
def qv_int_range(value, lo, hi, field, code):
|
||
try:
|
||
n = int(str(value))
|
||
except (TypeError, ValueError):
|
||
return _qcheck(field, False, code, "not an integer: %r" % (value,))
|
||
if not (lo <= n <= hi):
|
||
return _qcheck(field, False, code, "must be %d..%d" % (lo, hi))
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_count(value, lo=1, hi=50):
|
||
return qv_int_range(value, lo, hi, "count", "BAD_COUNT")
|
||
|
||
|
||
def qv_limit(value):
|
||
return qv_int_range(value, 1, 10000, "limit", "BAD_LIMIT")
|
||
|
||
|
||
def qv_slot(value):
|
||
return qv_int_range(value, 0, 9999, "slot", "BAD_SLOT")
|
||
|
||
|
||
def qv_nonempty(value, field):
|
||
if value is None or not str(value).strip():
|
||
return _qcheck(field, False, "BAD_ARGS", "%s must be non-empty" % field)
|
||
return _qcheck(field, True)
|
||
|
||
|
||
def qv_md_account(value):
|
||
if not value or not MD_ACCOUNT_RE.match(value):
|
||
return _qcheck("account", False, "BAD_NAME",
|
||
"must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
|
||
return _qcheck("account", True)
|
||
|
||
|
||
def qv_md_file(value):
|
||
if not value or value in (".", "..") or not MD_FILENAME_RE.match(value):
|
||
return _qcheck("filename", False, "BAD_NAME",
|
||
"must be a plain basename (no directories)")
|
||
return _qcheck("filename", True)
|
||
|
||
|
||
def qv_md_template(value):
|
||
if value not in MD_TEMPLATE_FILES:
|
||
return _qcheck("filename", False, "BAD_NAME",
|
||
"must be one of %s" % sorted(MD_TEMPLATE_FILES))
|
||
return _qcheck("filename", True)
|
||
|
||
|
||
def qv_md_subpath(value):
|
||
if value in (None, ""):
|
||
return _qcheck("path", True)
|
||
if not MD_SUBPATH_RE.match(value) or ".." in value.split("/"):
|
||
return _qcheck("path", False, "BAD_NAME",
|
||
"path must be a subdir without '..'")
|
||
return _qcheck("path", True)
|
||
|
||
|
||
def qv_title(value):
|
||
c = qv_nonempty(value, "title")
|
||
if not c["ok"]:
|
||
return c
|
||
if len(str(value)) > 200:
|
||
return _qcheck("title", False, "BAD_ARGS", "title must be <= 200 chars")
|
||
return _qcheck("title", True)
|
||
|
||
|
||
def qv_name_or_job_id(value, field="name_or_job_id"):
|
||
c1 = qv_name(value, field)
|
||
if c1["ok"]:
|
||
return c1
|
||
c2 = qv_job_id(value)
|
||
if c2["ok"]:
|
||
c2["check"] = field
|
||
return c2
|
||
return _qcheck(field, False, "BAD_NAME",
|
||
"must be a job name ^[a-z0-9-]{1,64}$ or job id "
|
||
"^[a-z0-9][a-z0-9-]{0,127}$")
|
||
|
||
|
||
def q_output_contract(payload):
|
||
"""Validate a box-ctl response dict against the JSON output contract.
|
||
Returns (ok_bool, issues_list)."""
|
||
issues = []
|
||
if not isinstance(payload, dict):
|
||
return False, ["payload is not a JSON object"]
|
||
if "ok" not in payload:
|
||
issues.append("missing 'ok'")
|
||
elif not isinstance(payload["ok"], bool):
|
||
issues.append("'ok' is not a boolean")
|
||
if payload.get("ok") is False:
|
||
if "code" not in payload:
|
||
issues.append("ok:false missing 'code'")
|
||
elif payload["code"] not in KNOWN_ERROR_CODES:
|
||
issues.append("unknown error code %r" % (payload["code"],))
|
||
if "error" not in payload:
|
||
issues.append("ok:false missing 'error'")
|
||
return (not issues), issues
|
||
|
||
|
||
def q_atomic_write(path, text):
|
||
"""Idempotency-safe file write: write temp + os.replace (atomic).
|
||
Returns (ok_bool, reason). Cleans up the temp file on failure."""
|
||
p = Path(path)
|
||
tmp = p.parent / (p.name + ".tmp")
|
||
try:
|
||
tmp.write_text(text)
|
||
os.replace(tmp, p)
|
||
return True, ""
|
||
except OSError as e:
|
||
try:
|
||
if tmp.exists():
|
||
tmp.unlink()
|
||
except OSError:
|
||
pass
|
||
return False, str(e)
|
||
|
||
|
||
def _qsrc():
|
||
return Path(__file__).read_text()
|
||
|
||
|
||
def act_quality_check():
|
||
"""Run box-ctl self-diagnostics. Read-only (no state mutations)."""
|
||
results = []
|
||
|
||
def rec(name, ok, detail=None):
|
||
r = {"check": name, "ok": bool(ok)}
|
||
if detail is not None:
|
||
r["detail"] = detail
|
||
results.append(r)
|
||
return ok
|
||
|
||
src = _qsrc()
|
||
|
||
# 1. validator functions present and callable
|
||
for fn in ("qv_name", "qv_agent", "qv_thread_id", "qv_job_id",
|
||
"qv_swarm_id", "qv_agent_ref", "qv_dm_id", "qv_count",
|
||
"qv_limit", "qv_slot", "qv_nonempty", "qv_title",
|
||
"qv_name_or_job_id", "q_output_contract", "q_atomic_write"):
|
||
rec("validator:%s" % fn, callable(globals().get(fn)))
|
||
|
||
# 2. every fail() code is a member of KNOWN_ERROR_CODES (typo guard)
|
||
codes = set(re.findall(r'fail\(\s*"([A-Z0-9_]+)"', src))
|
||
unknown = sorted(codes - KNOWN_ERROR_CODES)
|
||
rec("error_codes_known", not unknown,
|
||
{"codes_found": len(codes), "unknown": unknown})
|
||
|
||
# 3. every dispatched action is documented in USAGE
|
||
actions = set(re.findall(r'(?:el)?if action == "([a-z0-9-]+)"', src))
|
||
for tup in re.findall(r'elif action in \((.*?)\)', src, re.DOTALL):
|
||
actions.update(re.findall(r'"([a-z0-9-]+)"', tup))
|
||
missing = sorted(a for a in actions if a not in USAGE)
|
||
rec("usage_coverage", not missing,
|
||
{"actions": len(actions), "undocumented": missing})
|
||
|
||
# 4. output contract on the failure path (live subprocess probe)
|
||
try:
|
||
r = run([sys.executable, str(Path(__file__)),
|
||
"quality-probe-bogus-action"], timeout=30)
|
||
payload = json.loads(r.stdout)
|
||
okc, issues = q_output_contract(payload)
|
||
rec("output_contract:fail-path",
|
||
okc and payload.get("code") == "BAD_NAME", {"issues": issues})
|
||
except Exception as e:
|
||
rec("output_contract:fail-path", False, {"error": str(e)})
|
||
|
||
# 5. output contract on the success path (live subprocess probe)
|
||
try:
|
||
r = run([sys.executable, str(Path(__file__)), "swarm-list"], timeout=30)
|
||
payload = json.loads(r.stdout)
|
||
okc, issues = q_output_contract(payload)
|
||
rec("output_contract:ok-path",
|
||
okc and payload.get("ok") is True, {"issues": issues})
|
||
except Exception as e:
|
||
rec("output_contract:ok-path", False, {"error": str(e)})
|
||
|
||
# 6. audit log path writable
|
||
try:
|
||
rec("audit_path_writable", os.access(CTL_LOG.parent, os.W_OK),
|
||
{"log": str(CTL_LOG)})
|
||
except Exception as e:
|
||
rec("audit_path_writable", False, {"error": str(e)})
|
||
|
||
# 7. atomic-write round trip (idempotency primitive)
|
||
import tempfile
|
||
tmpd = tempfile.mkdtemp(prefix="qcheck-")
|
||
tp = os.path.join(tmpd, "probe.json")
|
||
okw, why = q_atomic_write(tp, '{"probe": true}')
|
||
leftover = os.path.exists(tp + ".tmp")
|
||
try:
|
||
content_ok = okw and open(tp).read() == '{"probe": true}'
|
||
except OSError:
|
||
content_ok = False
|
||
try:
|
||
if os.path.exists(tp):
|
||
os.unlink(tp)
|
||
os.rmdir(tmpd)
|
||
except OSError:
|
||
pass
|
||
rec("atomic_write_roundtrip", content_ok and not leftover,
|
||
{"write_error": why or None})
|
||
|
||
# 8. state directories exist and are writable
|
||
for label, p in (("netvm_root", NETVM_ROOT), ("jobs_dir", JOBS_DIR)):
|
||
rec("state_dir:%s" % label, p.is_dir() and os.access(p, os.W_OK),
|
||
{"path": str(p)})
|
||
|
||
passed = sum(1 for r_ in results if r_["ok"])
|
||
failed = len(results) - passed
|
||
out(True, quality_version=QUALITY_VERSION, passed=passed,
|
||
failed=failed, checks=results,
|
||
idempotent_actions=sorted(IDEMPOTENT_ACTIONS))
|
||
|
||
|
||
def _qv_flag(args, flag, takes_value=False):
|
||
"""Split flag out of an arg list copy.
|
||
Returns (present, value_or_None, remaining_args)."""
|
||
a = list(args)
|
||
if flag not in a:
|
||
return False, None, a
|
||
i = a.index(flag)
|
||
if takes_value:
|
||
if i + 1 < len(a) and not a[i + 1].startswith("--"):
|
||
return True, a[i + 1], a[:i] + a[i + 2:]
|
||
return True, None, a[:i] + a[i + 1:]
|
||
return True, None, a[:i] + a[i + 1:]
|
||
|
||
|
||
def _qv_no_unknown(args, usage):
|
||
"""All remaining args must be consumed; anything left is unknown."""
|
||
if args:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"unknown arguments %r; usage: %s" % (args, usage))]
|
||
return []
|
||
|
||
|
||
def _qv_args(action, rest):
|
||
"""Return a list of check dicts for (action, rest), or None if the
|
||
action is unknown to quality-validate."""
|
||
a = list(rest)
|
||
|
||
# -- timer -----------------------------------------------------------
|
||
if action in ("timer-status", "timer-create", "timer-start",
|
||
"timer-stop", "timer-enable", "timer-disable"):
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <name>" % action)]
|
||
return [qv_name(a[0])]
|
||
if action == "timer-delete":
|
||
_, _, b = _qv_flag(a, "--keep-job")
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: timer-delete <name> [--keep-job]")]
|
||
return [qv_name(b[0])]
|
||
if action == "timer-list":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: timer-list")]
|
||
return [_qcheck("argv", True)]
|
||
|
||
# -- job -------------------------------------------------------------
|
||
if action in ("job-get", "job-trigger"):
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <name>" % action)]
|
||
return [qv_name(a[0])]
|
||
if action == "job-list":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: job-list")]
|
||
return [_qcheck("argv", True)]
|
||
if action == "job-put":
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-put <name> (job JSON on stdin)")]
|
||
return [qv_name(a[0]),
|
||
_qcheck("stdin", True, reason="job JSON payload read from "
|
||
"stdin at execution; not consumed by dry-run")]
|
||
if action == "job-delete":
|
||
_, _, b = _qv_flag(a, "--force")
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-delete <name> [--force]")]
|
||
return [qv_name(b[0])]
|
||
if action == "job-result":
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-result <job-id> (result JSON on stdin)")]
|
||
return [qv_job_id(a[0]),
|
||
_qcheck("stdin", True, reason="result JSON payload read from "
|
||
"stdin at execution; not consumed by dry-run")]
|
||
if action == "job-status":
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-status <name-or-job-id>")]
|
||
return [qv_name_or_job_id(a[0])]
|
||
if action == "job-next":
|
||
succ, _, b = _qv_flag(a, "--success")
|
||
failf, _, b = _qv_flag(b, "--fail")
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-next <job-id> [--success|--fail]")]
|
||
return [qv_job_id(b[0])]
|
||
if action == "job-chain":
|
||
_, _, b = _qv_flag(a, "--on-failure")
|
||
if len(b) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: job-chain <from-job> <to-job> [--on-failure]")]
|
||
return [qv_name(b[0], "from_job"), qv_name(b[1], "to_job")]
|
||
|
||
# -- notify ----------------------------------------------------------
|
||
if action == "notify":
|
||
allow, _, b = _qv_flag(a, "--allow-main-chat")
|
||
has_sc, sc_val, b = _qv_flag(b, "--sidechat", takes_value=True)
|
||
if has_sc and sc_val is None:
|
||
return [_qcheck("sidechat", False, "BAD_ARGS",
|
||
"--sidechat requires a value")]
|
||
if len(b) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: notify <agent> <message> [--sidechat <name>] "
|
||
"[--allow-main-chat]")]
|
||
checks = [qv_agent(b[0]), qv_nonempty(b[1], "message")]
|
||
if has_sc:
|
||
checks.append(qv_nonempty(sc_val, "sidechat"))
|
||
return checks
|
||
|
||
# -- thread (hyphenated) ---------------------------------------------
|
||
if action in ("thread-list", "thread-pin", "thread-unpin",
|
||
"thread-archive", "thread-unarchive", "thread-rename"):
|
||
op = action[len("thread-"):]
|
||
has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True)
|
||
has_thread, thread, b = _qv_flag(b, "--thread", takes_value=True)
|
||
has_title, title, b = _qv_flag(b, "--title", takes_value=True)
|
||
has_limit, limit, b = _qv_flag(b, "--limit", takes_value=True)
|
||
has_confirm, _, b = _qv_flag(b, "--confirm")
|
||
checks = _qv_no_unknown(b, "%s --agent <agent> [...]" % action)
|
||
if has_agent and agent is None:
|
||
checks.append(_qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
elif not has_agent:
|
||
checks.append(_qcheck("agent", False, "BAD_NAME",
|
||
"--agent is required"))
|
||
else:
|
||
checks.append(qv_agent(agent))
|
||
if op == "list":
|
||
if has_thread:
|
||
checks.append(_qcheck("thread", False, "BAD_ARGS",
|
||
"--thread does not apply to thread-list"))
|
||
if has_limit:
|
||
checks.append(qv_limit(limit) if limit is not None
|
||
else _qcheck("limit", False, "BAD_ARGS",
|
||
"--limit requires a value"))
|
||
if has_confirm:
|
||
checks.append(_qcheck("confirm", False, "BAD_ARGS",
|
||
"--confirm only applies to thread-archive"))
|
||
else:
|
||
if has_limit:
|
||
checks.append(_qcheck("limit", False, "BAD_ARGS",
|
||
"--limit only applies to thread-list"))
|
||
if has_confirm and op != "archive":
|
||
checks.append(_qcheck("confirm", False, "BAD_ARGS",
|
||
"--confirm only applies to thread-archive"))
|
||
if not has_thread or thread is None:
|
||
checks.append(_qcheck("thread_id", False, "BAD_NAME",
|
||
"--thread <id> is required"))
|
||
else:
|
||
checks.append(qv_thread_id(thread))
|
||
if op == "archive" and not has_confirm:
|
||
checks.append(_qcheck("confirm", False, "CONFIRM_REQUIRED",
|
||
"archive requires --confirm"))
|
||
if op == "rename":
|
||
if not has_title or title is None:
|
||
checks.append(_qcheck("title", False, "BAD_ARGS",
|
||
"--title is required for rename"))
|
||
else:
|
||
checks.append(qv_title(title))
|
||
return checks
|
||
|
||
# -- thread (space-separated) ----------------------------------------
|
||
if action == "thread":
|
||
if not a or a[0] not in THREAD_OPS:
|
||
return [_qcheck("sub", False, "BAD_NAME",
|
||
"usage: thread list|pin|unpin|archive|unarchive "
|
||
"<agent> [...]")]
|
||
sub, b = a[0], a[1:]
|
||
has_confirm, _, b = _qv_flag(b, "--confirm")
|
||
has_limit, limit, b = _qv_flag(b, "--limit", takes_value=True)
|
||
checks = []
|
||
if not b:
|
||
return [_qcheck("argv", False, "BAD_NAME",
|
||
"usage: thread %s <agent> [...]" % sub)]
|
||
checks.append(qv_agent(b[0]))
|
||
pos = b[1:]
|
||
if sub == "list":
|
||
if pos:
|
||
checks.append(_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: thread list <agent> [--limit N]"))
|
||
if has_limit:
|
||
checks.append(qv_limit(limit) if limit is not None
|
||
else _qcheck("limit", False, "BAD_ARGS",
|
||
"--limit requires a value"))
|
||
if has_confirm:
|
||
checks.append(_qcheck("confirm", False, "BAD_ARGS",
|
||
"--confirm only applies to thread archive"))
|
||
else:
|
||
if has_limit:
|
||
checks.append(_qcheck("limit", False, "BAD_ARGS",
|
||
"--limit only applies to thread list"))
|
||
if has_confirm and sub != "archive":
|
||
checks.append(_qcheck("confirm", False, "BAD_ARGS",
|
||
"--confirm only applies to thread archive"))
|
||
if len(pos) != 1:
|
||
checks.append(_qcheck("argv", False, "BAD_NAME",
|
||
"usage: thread %s <agent> <thread-id> "
|
||
"[--confirm]" % sub))
|
||
else:
|
||
checks.append(qv_thread_id(pos[0]))
|
||
if sub == "archive" and not has_confirm:
|
||
checks.append(_qcheck("confirm", False, "CONFIRM_REQUIRED",
|
||
"archive requires --confirm"))
|
||
checks.extend(_qv_no_unknown(
|
||
[x for x in b if x.startswith("--")],
|
||
"thread %s <agent> [...]" % sub))
|
||
return checks
|
||
|
||
# -- swarm (hyphenated) ----------------------------------------------
|
||
if action == "swarm-spawn":
|
||
has_label, label, b = _qv_flag(a, "--label", takes_value=True)
|
||
checks = []
|
||
if has_label and label is None:
|
||
checks.append(_qcheck("label", False, "BAD_ARGS",
|
||
"--label requires a value"))
|
||
for x in b:
|
||
if x.startswith("--"):
|
||
checks.append(_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: swarm-spawn <count> <task...> "
|
||
"[--label L]"))
|
||
return checks
|
||
if len(b) < 2:
|
||
checks.append(_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: swarm-spawn <count> <task...> "
|
||
"[--label L]"))
|
||
return checks
|
||
checks.append(qv_count(b[0]))
|
||
checks.append(qv_nonempty(" ".join(b[1:]), "task"))
|
||
return checks
|
||
if action == "swarm-list":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: swarm-list")]
|
||
return [_qcheck("argv", True)]
|
||
if action in ("swarm-status", "swarm-results"):
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <swarm-id>" % action)]
|
||
return [qv_swarm_id(a[0])]
|
||
if action == "swarm-attach":
|
||
if len(a) != 3:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: swarm-attach <swarm-id> <slot> <agent-id>")]
|
||
return [qv_swarm_id(a[0]), qv_slot(a[1]), qv_agent_ref(a[2])]
|
||
if action == "swarm-report":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: swarm-report <swarm-id> <slot> "
|
||
"(result JSON on stdin)")]
|
||
return [qv_swarm_id(a[0]), qv_slot(a[1]),
|
||
_qcheck("stdin", True, reason="result JSON payload read from "
|
||
"stdin at execution; not consumed by dry-run")]
|
||
if action == "swarm-kill":
|
||
has_confirm, _, b = _qv_flag(a, "--confirm")
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: swarm-kill <swarm-id> --confirm")]
|
||
checks = [qv_swarm_id(b[0])]
|
||
if not has_confirm:
|
||
checks.append(_qcheck("confirm", False, "CONFIRM_REQUIRED",
|
||
"kill requires --confirm"))
|
||
return checks
|
||
|
||
# -- swarm (space-separated) -----------------------------------------
|
||
if action == "swarm":
|
||
if not a or a[0] not in SWARM_OPS:
|
||
return [_qcheck("sub", False, "BAD_NAME",
|
||
"usage: swarm spawn|list|status|attach|report|"
|
||
"results|kill [...]")]
|
||
return _qv_args("swarm-" + a[0], a[1:])
|
||
|
||
# -- job (space-separated) -------------------------------------------
|
||
if action == "job":
|
||
if not a or a[0] not in ("result", "status", "next", "chain"):
|
||
return [_qcheck("sub", False, "BAD_NAME",
|
||
"usage: job result|status|next|chain [...]")]
|
||
return _qv_args("job-" + a[0], a[1:])
|
||
|
||
# -- vars ------------------------------------------------------------
|
||
if action in ("vars-get", "vars-reset"):
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <name>" % action)]
|
||
return [qv_var_name(a[0])]
|
||
if action == "vars-list":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: vars-list")]
|
||
return [_qcheck("argv", True)]
|
||
if action == "vars-set":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: vars-set <name> <value>")]
|
||
return [qv_var_name(a[0]), _qcheck("value", True)]
|
||
if action == "vars-history":
|
||
checks = []
|
||
b = list(a)
|
||
if b and not b[0].isdigit():
|
||
checks.append(qv_var_name(b.pop(0), "name"))
|
||
if b:
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: vars-history [name] [limit]")]
|
||
checks.append(qv_limit(b[0]))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "vars-rollback":
|
||
if not (1 <= len(a) <= 2):
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: vars-rollback <name> [revision]")]
|
||
checks = [qv_var_name(a[0])]
|
||
if len(a) == 2:
|
||
checks.append(qv_nonempty(a[1], "revision"))
|
||
return checks
|
||
|
||
# -- strategy --------------------------------------------------------
|
||
if action == "strat-list":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: strat-list")]
|
||
return [_qcheck("argv", True)]
|
||
if action in ("strat-get", "strat-reset"):
|
||
has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True)
|
||
checks = []
|
||
if has_agent:
|
||
if agent is None:
|
||
checks.append(_qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
else:
|
||
checks.append(qv_agent(agent))
|
||
if not b:
|
||
checks.append(_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <type> [subtype] [--agent AGENT]"
|
||
% action))
|
||
else:
|
||
checks.append(qv_nonempty(b[0], "type"))
|
||
if len(b) > 1:
|
||
checks.append(qv_nonempty(b[1], "subtype"))
|
||
if len(b) > 2:
|
||
checks.append(_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <type> [subtype] [--agent AGENT]"
|
||
% action))
|
||
return checks
|
||
if action == "strat-set":
|
||
if not a:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: strat-set <type> [JSON]")]
|
||
return [qv_nonempty(a[0], "type"),
|
||
_qcheck("stdin", True, reason="JSON payload may come from "
|
||
"stdin at execution; not consumed by dry-run")]
|
||
|
||
# -- loop ------------------------------------------------------------
|
||
if action == "loop-status":
|
||
has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True)
|
||
has_limit, limit, b = _qv_flag(b, "--limit", takes_value=True)
|
||
has_status, status, b = _qv_flag(b, "--status", takes_value=True)
|
||
checks = _qv_no_unknown(b, "loop-status [--agent A] [--limit N] "
|
||
"[--status S]")
|
||
if has_agent:
|
||
checks.append(qv_agent(agent) if agent is not None
|
||
else _qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
if has_limit:
|
||
checks.append(qv_limit(limit) if limit is not None
|
||
else _qcheck("limit", False, "BAD_ARGS",
|
||
"--limit requires a value"))
|
||
if has_status:
|
||
checks.append(qv_nonempty(status, "status") if status is not None
|
||
else _qcheck("status", False, "BAD_ARGS",
|
||
"--status requires a value"))
|
||
return checks
|
||
if action == "loop-health":
|
||
if len(a) > 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: loop-health [--threshold T]")]
|
||
if not a:
|
||
return [_qcheck("argv", True)]
|
||
try:
|
||
float(a[0])
|
||
return [_qcheck("threshold", True)]
|
||
except (TypeError, ValueError):
|
||
return [_qcheck("threshold", False, "BAD_ARGS",
|
||
"threshold must be numeric")]
|
||
if action == "loop-breaks":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: loop-breaks")]
|
||
return [_qcheck("argv", True)]
|
||
if action == "loop-resolve":
|
||
if not (1 <= len(a) <= 2):
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: loop-resolve <dm_id> [note]")]
|
||
checks = [qv_dm_id(a[0])]
|
||
if len(a) == 2:
|
||
checks.append(_qcheck("note", True))
|
||
return checks
|
||
if action == "loop-remediate":
|
||
_, _, b = _qv_flag(a, "--dry-run")
|
||
if b:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: loop-remediate [--dry-run]")]
|
||
return [_qcheck("argv", True)]
|
||
|
||
# -- policy / fleet / misc -------------------------------------------
|
||
if action == "policy":
|
||
if not a:
|
||
return [_qcheck("argv", True)]
|
||
if a[0] == "check":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: policy check <agent>")]
|
||
return [qv_agent(a[1])]
|
||
if a[0] == "show":
|
||
if len(a) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: policy show")]
|
||
return [_qcheck("argv", True)]
|
||
return [_qcheck("argv", False, "BAD_NAME",
|
||
"usage: policy [check <agent>|show]")]
|
||
if action in ("fleet-status", "watchdog-alerts", "relay-health",
|
||
"cdp-latency", "chrome-errors", "identity-audit"):
|
||
# watchdog-alerts / chrome-errors accept an optional --no-advance
|
||
# (peek-only read for web-surface polling; leaves the watermark).
|
||
extra_ok = (["--no-advance"] if action in ("watchdog-alerts",
|
||
"chrome-errors") else [])
|
||
if a and a != extra_ok:
|
||
usage = "%s [--no-advance]" % action if extra_ok else "%s (no arguments)" % action
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s" % usage)]
|
||
return [_qcheck("argv", True)]
|
||
if action == "main-loop":
|
||
if not a or a[0] not in ("check", "status", "enable", "disable"):
|
||
return [_qcheck("sub", False, "BAD_NAME",
|
||
"usage: main-loop check|status|enable|disable "
|
||
"[--agent <name>]")]
|
||
has_agent, agent, b = _qv_flag(a[1:], "--agent", takes_value=True)
|
||
checks = _qv_no_unknown(b, "main-loop %s [--agent <name>]" % a[0])
|
||
if has_agent:
|
||
checks.append(qv_agent(agent) if agent is not None
|
||
else _qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
return checks
|
||
if action == "dm-log":
|
||
has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True)
|
||
if len(b) > 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: dm-log [limit] [--agent <agent>]")]
|
||
checks = [qv_limit(b[0])] if b else []
|
||
if has_agent:
|
||
checks.append(qv_agent(agent) if agent is not None
|
||
else _qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "unread":
|
||
has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True)
|
||
checks = _qv_no_unknown(b, "unread [--agent <agent>]")
|
||
if has_agent:
|
||
checks.append(qv_agent(agent) if agent is not None
|
||
else _qcheck("agent", False, "BAD_ARGS",
|
||
"--agent requires a value"))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "git-status":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: git-status")]
|
||
return [_qcheck("argv", True)]
|
||
if action == "git-diff":
|
||
has_stat, _, b = _qv_flag(a, "--stat")
|
||
has_path, path, b = _qv_flag(b, "--path", takes_value=True)
|
||
checks = _qv_no_unknown(b, "git-diff [--stat] [--path <path>]")
|
||
if has_path:
|
||
checks.append(qv_relpath(path) if path is not None
|
||
else _qcheck("path", False, "BAD_ARGS",
|
||
"--path requires a value"))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "git-log":
|
||
has_limit, limit, b = _qv_flag(a, "--limit", takes_value=True)
|
||
has_path, path, b = _qv_flag(b, "--path", takes_value=True)
|
||
checks = _qv_no_unknown(b, "git-log [--limit N] [--path <path>]")
|
||
if has_limit:
|
||
checks.append(qv_int_range(limit, 1, 50, "limit", "BAD_LIMIT")
|
||
if limit is not None
|
||
else _qcheck("limit", False, "BAD_ARGS",
|
||
"--limit requires a value"))
|
||
if has_path:
|
||
checks.append(qv_relpath(path) if path is not None
|
||
else _qcheck("path", False, "BAD_ARGS",
|
||
"--path requires a value"))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "tests-run":
|
||
has_filter, filt, b = _qv_flag(a, "--filter", takes_value=True)
|
||
if len(b) > 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: tests-run [tests.<module>] [--filter <pattern>]")]
|
||
checks = [qv_test_module(b[0])] if b else []
|
||
if has_filter:
|
||
if filt is None:
|
||
checks.append(_qcheck("filter", False, "BAD_ARGS",
|
||
"--filter requires a value"))
|
||
elif not filt.strip() or len(filt) > 200:
|
||
checks.append(_qcheck("filter", False, "BAD_ARGS",
|
||
"filter must be 1-200 chars"))
|
||
elif any(ord(c) < 32 or ord(c) == 127 for c in filt):
|
||
checks.append(_qcheck("filter", False, "BAD_ARGS",
|
||
"filter contains control characters"))
|
||
else:
|
||
checks.append(_qcheck("filter", True))
|
||
return checks or [_qcheck("argv", True)]
|
||
if action == "ack":
|
||
_, _, b = _qv_flag(a, "--allow-main-chat")
|
||
has_to, to, b = _qv_flag(b, "--to", takes_value=True)
|
||
has_sender, sender, b = _qv_flag(b, "--sender", takes_value=True)
|
||
has_sc, sc_val, b = _qv_flag(b, "--sidechat", takes_value=True)
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: ack <id> --to <agent> --sender <agent> "
|
||
"[--sidechat <name>] [--allow-main-chat]")]
|
||
checks = [qv_dm_id(b[0])]
|
||
if not has_to or to is None:
|
||
checks.append(_qcheck("to", False, "BAD_ARGS",
|
||
"--to <agent> is required"))
|
||
else:
|
||
checks.append(qv_agent(to))
|
||
if not has_sender or sender is None:
|
||
checks.append(_qcheck("sender", False, "BAD_ARGS",
|
||
"--sender <agent> is required"))
|
||
else:
|
||
checks.append(qv_agent(sender))
|
||
if has_sc:
|
||
checks.append(qv_nonempty(sc_val, "sidechat") if sc_val is not None
|
||
else _qcheck("sidechat", False, "BAD_ARGS",
|
||
"--sidechat requires a value"))
|
||
return checks
|
||
|
||
# -- quality (self) ----------------------------------------------------
|
||
if action == "quality-check":
|
||
if a:
|
||
return [_qcheck("argv", False, "BAD_ARGS", "usage: quality-check")]
|
||
return [_qcheck("argv", True)]
|
||
# -- md --------------------------------------------------------------
|
||
if action == "md-audit":
|
||
if not a:
|
||
return [_qcheck("argv", True)]
|
||
return [qv_md_account(x) for x in a]
|
||
if action == "md-list":
|
||
if len(a) not in (1, 2):
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-list <account> [path]")]
|
||
checks = [qv_md_account(a[0])]
|
||
if len(a) == 2:
|
||
checks.append(qv_md_subpath(a[1]))
|
||
return checks
|
||
if action == "md-read":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-read <account> <filename>")]
|
||
return [qv_md_account(a[0]), qv_md_file(a[1])]
|
||
if action == "md-diff":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-diff <account> <filename>")]
|
||
return [qv_md_account(a[0]), qv_md_template(a[1])]
|
||
if action == "md-pull":
|
||
if len(a) != 2:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-pull <account> <filename>")]
|
||
return [qv_md_account(a[0]), qv_md_template(a[1])]
|
||
if action == "md-inject-drive":
|
||
_, _, b = _qv_flag(a, "--force")
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-inject-drive <account> [--force]")]
|
||
return [qv_md_account(b[0])]
|
||
if action == "md-sync-all":
|
||
_, _, b = _qv_flag(a, "--force")
|
||
if b:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-sync-all [--force]")]
|
||
return [_qcheck("argv", True)]
|
||
if action == "md-amend":
|
||
_, _, b = _qv_flag(a, "--stdin")
|
||
_, author, b = _qv_flag(b, "--author", takes_value=True)
|
||
_, reason, b = _qv_flag(b, "--reason", takes_value=True)
|
||
_, content, b = _qv_flag(b, "--content", takes_value=True)
|
||
_, file, b = _qv_flag(b, "--file", takes_value=True)
|
||
if len(b) not in (1, 2):
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-amend <filename> [<content>] [--stdin] "
|
||
"[--content t] [--file p] [--author n] [--reason r]")]
|
||
checks = [qv_md_template(b[0])]
|
||
if author is not None:
|
||
checks.append(qv_nonempty(author, "author"))
|
||
return checks + [_qcheck("stdin", True, reason="content may come from "
|
||
"stdin/--file/--content at execution; not "
|
||
"consumed by dry-run")]
|
||
if action == "md-append":
|
||
_, _, b = _qv_flag(a, "--stdin")
|
||
_, author, b = _qv_flag(b, "--author", takes_value=True)
|
||
_, section, b = _qv_flag(b, "--section", takes_value=True)
|
||
_, content, b = _qv_flag(b, "--content", takes_value=True)
|
||
_, file, b = _qv_flag(b, "--file", takes_value=True)
|
||
if len(b) not in (1, 2):
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-append <filename> [<text>] [--stdin] "
|
||
"[--content t] [--file p] [--author n] [--section s]")]
|
||
checks = [qv_md_template(b[0])]
|
||
if author is not None:
|
||
checks.append(qv_nonempty(author, "author"))
|
||
if section is not None:
|
||
checks.append(qv_nonempty(section, "section"))
|
||
return checks + [_qcheck("stdin", True, reason="text may come from "
|
||
"stdin/--file/--content at execution; not "
|
||
"consumed by dry-run")]
|
||
if action == "md-write":
|
||
if len(a) != 3:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: md-write <account> <filename> <content>")]
|
||
return [qv_md_account(a[0]), qv_md_file(a[1]),
|
||
qv_nonempty(a[2], "content")]
|
||
# -- approval --------------------------------------------------------
|
||
if action in ("approval-check", "approval-list"):
|
||
if len(a) > 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s [node]" % action)]
|
||
if not a:
|
||
return [_qcheck("argv", True)]
|
||
return [qv_agent(a[0])]
|
||
if action in ("approval-allow", "approval-approve"):
|
||
_, _, b = _qv_flag(a, "--always")
|
||
_, _, b = _qv_flag(b, "--force")
|
||
_, _, b = _qv_flag(b, "--allow-main-chat")
|
||
_, message, b = _qv_flag(b, "--message", takes_value=True)
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: %s <node> [--always] [--force] [--message TEXT] "
|
||
"[--allow-main-chat]" % action)]
|
||
checks = [qv_agent(b[0])]
|
||
if message is not None:
|
||
checks.append(qv_nonempty(message, "message"))
|
||
return checks
|
||
if action == "approval-deny":
|
||
_, _, b = _qv_flag(a, "--allow-main-chat")
|
||
_, message, b = _qv_flag(b, "--message", takes_value=True)
|
||
if len(b) != 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: approval-deny <node> [--message TEXT] "
|
||
"[--allow-main-chat]")]
|
||
checks = [qv_agent(b[0])]
|
||
if message is not None:
|
||
checks.append(qv_nonempty(message, "message"))
|
||
return checks
|
||
if action == "approval-auto":
|
||
if len(a) > 1:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: approval-auto [node]")]
|
||
if not a:
|
||
return [_qcheck("argv", True)]
|
||
return [qv_agent(a[0])]
|
||
if action == "quality-validate":
|
||
if not a:
|
||
return [_qcheck("argv", False, "BAD_ARGS",
|
||
"usage: quality-validate <action> [args...]")]
|
||
inner = _qv_args(a[0], a[1:])
|
||
if inner is None:
|
||
return [_qcheck("action", False, "BAD_NAME",
|
||
"unknown action %r" % (a[0],))]
|
||
bad = [c for c in inner if not c["ok"]]
|
||
code = bad[0].get("code") if bad else None
|
||
return [_qcheck("nested:%s" % a[0], not bad, code=code,
|
||
reason="nested validation of %s" % a[0])]
|
||
if action == "quality":
|
||
if not a or a[0] not in ("check", "validate"):
|
||
return [_qcheck("sub", False, "BAD_NAME",
|
||
"usage: quality check|validate [...]")]
|
||
return _qv_args("quality-" + a[0], a[1:])
|
||
|
||
return None
|
||
|
||
|
||
def act_quality_validate(action, rest):
|
||
"""Dry-run: validate argv for <action> without executing it."""
|
||
checks = _qv_args(action, rest)
|
||
if checks is None:
|
||
fail("BAD_NAME", "quality validate: unknown action %r" % (action,))
|
||
valid = all(c["ok"] for c in checks)
|
||
out(True, action=action, valid=valid, checks=checks,
|
||
note="stdin payloads are never consumed by dry-run validation")
|
||
|
||
def main(argv):
|
||
if len(argv) < 2:
|
||
print(USAGE, file=sys.stderr)
|
||
sys.exit(2)
|
||
action = argv[1]
|
||
rest = argv[2:]
|
||
|
||
if action == "timer-list":
|
||
act_timer_list()
|
||
elif action == "timer-status":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: timer-status <name>")
|
||
act_timer_status(rest[0])
|
||
elif action == "timer-create":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: timer-create <name>")
|
||
act_timer_create(rest[0])
|
||
elif action == "timer-delete":
|
||
if not rest or len(rest) > 2:
|
||
fail("BAD_NAME", "usage: timer-delete <name> [--keep-job]")
|
||
keep = "--keep-job" in rest[1:]
|
||
act_timer_delete(rest[0], keep_job=keep)
|
||
elif action in ("timer-start", "timer-stop", "timer-enable", "timer-disable"):
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", f"usage: {action} <name>")
|
||
act_timer_control(rest[0], action[len("timer-"):])
|
||
elif action == "timer":
|
||
if not rest:
|
||
act_timer_list()
|
||
else:
|
||
sub = rest[0]
|
||
if sub in ("list",):
|
||
act_timer_list()
|
||
elif sub in ("status",):
|
||
if len(rest) != 2:
|
||
fail("BAD_NAME", "usage: timer status <name>")
|
||
act_timer_status(rest[1])
|
||
elif sub in ("create",):
|
||
if len(rest) != 2:
|
||
fail("BAD_NAME", "usage: timer create <name>")
|
||
act_timer_create(rest[1])
|
||
elif sub in ("delete",):
|
||
keep = "--keep-job" in rest[2:]
|
||
act_timer_delete(rest[1], keep_job=keep)
|
||
elif sub in ("start", "stop", "enable", "disable"):
|
||
if len(rest) != 2:
|
||
fail("BAD_NAME", f"usage: timer {sub} <name>")
|
||
act_timer_control(rest[1], sub)
|
||
else:
|
||
fail("BAD_NAME", "usage: timer list|status|create|delete|start|stop|enable|disable [...]")
|
||
elif action == "job-list":
|
||
act_job_list()
|
||
elif action == "job-get":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: job-get <name>")
|
||
act_job_get(rest[0])
|
||
elif action == "job-put":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: job-put <name> (JSON on stdin)")
|
||
act_job_put(rest[0])
|
||
elif action == "job-delete":
|
||
if not rest or len(rest) > 2:
|
||
fail("BAD_NAME", "usage: job-delete <name> [--force]")
|
||
force = "--force" in rest[1:]
|
||
act_job_delete(rest[0], force=force)
|
||
elif action == "job-trigger":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: job-trigger <name>")
|
||
act_job_trigger(rest[0])
|
||
elif action == "job-result":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: job-result <job-id> (result JSON on stdin)")
|
||
act_job_result(rest[0])
|
||
elif action == "job-status":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: job-status <name-or-job-id>")
|
||
act_job_status(rest[0])
|
||
elif action == "job-next":
|
||
if not rest or len(rest) > 2:
|
||
fail("BAD_NAME", "usage: job-next <job-id> [--success|--fail]")
|
||
s = None
|
||
if "--success" in rest[1:]:
|
||
s = True
|
||
if "--fail" in rest[1:]:
|
||
s = False
|
||
act_job_next(rest[0], s)
|
||
elif action == "job-chain":
|
||
if len(rest) < 2 or len(rest) > 3:
|
||
fail("BAD_NAME", "usage: job-chain <from-job> <to-job> [--on-failure]")
|
||
act_job_chain(rest[0], rest[1], on_failure=("--on-failure" in rest[2:]))
|
||
elif action == "vars-list":
|
||
act_vars_list()
|
||
elif action == "vars-get":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: vars-get <name>")
|
||
act_vars_get(rest[0])
|
||
elif action == "vars-set":
|
||
if len(rest) != 2:
|
||
fail("BAD_NAME", "usage: vars-set <name> <value>")
|
||
act_vars_set(rest[0], rest[1])
|
||
elif action == "vars-reset":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: vars-reset <name>")
|
||
act_vars_reset(rest[0])
|
||
elif action == "vars-history":
|
||
name = None
|
||
limit = 20
|
||
if rest:
|
||
name = rest[0]
|
||
if len(rest) > 1:
|
||
try:
|
||
limit = int(rest[1])
|
||
except ValueError:
|
||
limit = 20
|
||
act_vars_history(name=name, limit=limit)
|
||
elif action == "vars-rollback":
|
||
if len(rest) < 1 or len(rest) > 2:
|
||
fail("BAD_NAME", "usage: vars-rollback <name> [revision]")
|
||
rev = rest[1] if len(rest) > 1 else None
|
||
act_vars_rollback(rest[0], revision=rev)
|
||
elif action == "strat-list":
|
||
act_strat_list()
|
||
elif action == "strat-get":
|
||
if not rest:
|
||
fail("BAD_NAME", "usage: strat-get <type> [subtype] [--agent AGENT]")
|
||
itype = rest[0]
|
||
subtype = None
|
||
agent = None
|
||
idx = 1
|
||
while idx < len(rest):
|
||
if rest[idx] == "--agent" and idx + 1 < len(rest):
|
||
agent = rest[idx + 1]
|
||
idx += 2
|
||
elif subtype is None and not rest[idx].startswith("--"):
|
||
subtype = rest[idx]
|
||
idx += 1
|
||
else:
|
||
idx += 1
|
||
act_strat_get(itype, subtype=subtype, agent=agent)
|
||
elif action == "strat-set":
|
||
if len(rest) < 1:
|
||
fail("BAD_NAME", "usage: strat-set <type> [JSON]")
|
||
payload = rest[1] if len(rest) > 1 else None
|
||
act_strat_set(rest[0], payload)
|
||
elif action == "strat-reset":
|
||
if not rest:
|
||
fail("BAD_NAME", "usage: strat-reset <type> [subtype] [--agent AGENT]")
|
||
itype = rest[0]
|
||
subtype = None
|
||
agent = None
|
||
idx = 1
|
||
while idx < len(rest):
|
||
if rest[idx] == "--agent" and idx + 1 < len(rest):
|
||
agent = rest[idx + 1]
|
||
idx += 2
|
||
elif subtype is None and not rest[idx].startswith("--"):
|
||
subtype = rest[idx]
|
||
idx += 1
|
||
else:
|
||
idx += 1
|
||
act_strat_reset(itype, subtype=subtype, agent=agent)
|
||
elif action == "loop-status":
|
||
agent = None
|
||
limit = 20
|
||
status = None
|
||
idx = 0
|
||
while idx < len(rest):
|
||
if rest[idx] == "--agent" and idx + 1 < len(rest):
|
||
agent = rest[idx + 1]
|
||
idx += 2
|
||
elif rest[idx] == "--limit" and idx + 1 < len(rest):
|
||
limit = int(rest[idx + 1])
|
||
idx += 2
|
||
elif rest[idx] == "--status" and idx + 1 < len(rest):
|
||
status = rest[idx + 1]
|
||
idx += 2
|
||
else:
|
||
idx += 1
|
||
act_loop_status(agent=agent, limit=limit, status=status)
|
||
elif action == "loop-health":
|
||
thresh = rest[0] if rest else None
|
||
act_loop_health(threshold=thresh)
|
||
elif action == "loop-breaks":
|
||
act_loop_breaks()
|
||
elif action == "loop-resolve":
|
||
if len(rest) < 1:
|
||
fail("BAD_NAME", "usage: loop-resolve <dm_id> [note]")
|
||
note = rest[1] if len(rest) > 1 else None
|
||
act_loop_resolve(rest[0], note=note)
|
||
elif action in ("ssh", "ssh-mint", "ssh-list", "ssh-show", "ssh-ports", "ssh-info"):
|
||
if action == "ssh-mint":
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: ssh-mint <name> [--force]")
|
||
act_ssh_mint(rest[0], force=("--force" in rest[1:]))
|
||
elif action == "ssh-list":
|
||
act_ssh_list()
|
||
elif action == "ssh-show":
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: ssh-show <name>")
|
||
act_ssh_show(rest[0])
|
||
elif action == "ssh-ports":
|
||
act_ssh_ports()
|
||
elif action == "ssh-info":
|
||
act_ssh_info(rest[0] if rest else None)
|
||
elif action == "ssh":
|
||
if not rest or rest[0] not in ("mint", "list", "show", "ports", "info"):
|
||
fail("BAD_NAME", "usage: ssh mint|list|show|ports|info [...]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if sub == "mint":
|
||
if not args:
|
||
fail("BAD_ARGS", "usage: ssh mint <name> [--force]")
|
||
act_ssh_mint(args[0], force=("--force" in args[1:]))
|
||
elif sub == "list":
|
||
act_ssh_list()
|
||
elif sub == "show":
|
||
if not args:
|
||
fail("BAD_ARGS", "usage: ssh show <name>")
|
||
act_ssh_show(args[0])
|
||
elif sub == "ports":
|
||
act_ssh_ports()
|
||
elif sub == "info":
|
||
act_ssh_info(args[0] if args else None)
|
||
elif action in ("md", "md-audit", "md-list", "md-read", "md-write", "md-diff", "md-inject-drive", "md-sync-all",
|
||
"md-amend", "md-append", "md-pull"):
|
||
if action == "md-audit":
|
||
act_md_audit(accounts=rest or None)
|
||
elif action == "md-list":
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: md-list <account> [path]")
|
||
act_md_list(rest[0], path=rest[1] if len(rest) > 1 else "")
|
||
elif action == "md-read":
|
||
if len(rest) < 2:
|
||
fail("BAD_ARGS", "usage: md-read <account> <filename>")
|
||
act_md_read(rest[0], rest[1])
|
||
elif action == "md-write":
|
||
if len(rest) < 3:
|
||
fail("BAD_ARGS", "usage: md-write <account> <filename> <content>")
|
||
act_md_write(rest[0], rest[1], rest[2])
|
||
elif action == "md-diff":
|
||
if len(rest) < 2:
|
||
fail("BAD_ARGS", "usage: md-diff <account> <filename>")
|
||
act_md_diff(rest[0], rest[1])
|
||
elif action == "md-inject-drive":
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: md-inject-drive <account> [--force]")
|
||
act_md_inject_drive(rest[0], force=("--force" in rest[1:]))
|
||
elif action == "md-sync-all":
|
||
act_md_sync_all(force=("--force" in rest))
|
||
elif action == "md-amend":
|
||
filename, content, author, reason = _md_amend_parse(
|
||
rest, "usage: md-amend <filename> (<content>|--content t|--file p|--stdin) [--author name] [--reason why]")
|
||
act_md_amend(filename, content, author=author, reason=reason)
|
||
elif action == "md-append":
|
||
filename, text, author, section = _md_append_parse(
|
||
rest, "usage: md-append <filename> (<text>|--content t|--file p|--stdin) [--author name] [--section header]")
|
||
act_md_append(filename, text, author=author, section=section)
|
||
elif action == "md-pull":
|
||
if len(rest) != 2:
|
||
fail("BAD_ARGS", "usage: md-pull <account> <filename>")
|
||
act_md_pull(rest[0], rest[1])
|
||
elif action == "md":
|
||
if not rest:
|
||
fail("BAD_NAME", "usage: md audit|list|read|write|diff|inject-drive|sync-all [...]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if sub == "audit":
|
||
act_md_audit(accounts=args or None)
|
||
elif sub == "list":
|
||
if not args:
|
||
fail("BAD_ARGS", "usage: md list <account> [path]")
|
||
act_md_list(args[0], path=args[1] if len(args) > 1 else "")
|
||
elif sub == "read":
|
||
if len(args) < 2:
|
||
fail("BAD_ARGS", "usage: md read <account> <filename>")
|
||
act_md_read(args[0], args[1])
|
||
elif sub == "write":
|
||
if len(args) < 2:
|
||
fail("BAD_ARGS", "usage: md write <account> <filename> [--content text | --file path]")
|
||
content = None
|
||
if "--file" in args:
|
||
fidx = args.index("--file")
|
||
if fidx + 1 < len(args):
|
||
content = Path(args[fidx + 1]).read_text(encoding="utf-8")
|
||
elif "--content" in args:
|
||
cidx = args.index("--content")
|
||
if cidx + 1 < len(args):
|
||
content = args[cidx + 1]
|
||
elif len(args) >= 3:
|
||
content = args[2]
|
||
if content is None:
|
||
fail("BAD_ARGS", "usage: md write <account> <filename> <content>")
|
||
act_md_write(args[0], args[1], content)
|
||
elif sub == "diff":
|
||
if len(args) < 2:
|
||
fail("BAD_ARGS", "usage: md diff <account> <filename>")
|
||
act_md_diff(args[0], args[1])
|
||
elif sub == "inject-drive":
|
||
if not args:
|
||
fail("BAD_ARGS", "usage: md inject-drive <account> [--force]")
|
||
act_md_inject_drive(args[0], force=("--force" in args[1:]))
|
||
elif sub == "sync-all":
|
||
act_md_sync_all(force=("--force" in args))
|
||
elif sub == "amend":
|
||
filename, content, author, reason = _md_amend_parse(
|
||
args, "usage: md amend <filename> (<content>|--content t|--file p|--stdin) [--author name] [--reason why]")
|
||
act_md_amend(filename, content, author=author, reason=reason)
|
||
elif sub == "append":
|
||
filename, text, author, section = _md_append_parse(
|
||
args, "usage: md append <filename> (<text>|--content t|--file p|--stdin) [--author name] [--section header]")
|
||
act_md_append(filename, text, author=author, section=section)
|
||
elif sub == "pull":
|
||
if len(args) < 2:
|
||
fail("BAD_ARGS", "usage: md pull <account> <filename>")
|
||
act_md_pull(args[0], args[1])
|
||
else:
|
||
fail("BAD_NAME", f"unknown md subcommand: {sub}")
|
||
elif action == "loop-remediate":
|
||
dry = "--dry-run" in rest
|
||
act_loop_remediate(dry_run=dry)
|
||
elif action == "notify":
|
||
# notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]
|
||
args = list(rest)
|
||
allow_main = False
|
||
sidechat = None
|
||
sender = None
|
||
if "--allow-main-chat" in args:
|
||
allow_main = True
|
||
args.remove("--allow-main-chat")
|
||
if "--sidechat" in args:
|
||
i = args.index("--sidechat")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
|
||
sidechat = args[i + 1]
|
||
del args[i:i + 2]
|
||
if "--sender" in args:
|
||
i = args.index("--sender")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
|
||
sender = args[i + 1]
|
||
del args[i:i + 2]
|
||
if len(args) != 2:
|
||
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
|
||
act_notify(args[0], args[1], sidechat=sidechat, allow_main_chat=allow_main, sender=sender)
|
||
elif action == "policy":
|
||
if not rest:
|
||
act_policy()
|
||
elif rest[0] == "check":
|
||
if len(rest) != 2:
|
||
fail("BAD_NAME", "usage: policy check <agent>")
|
||
act_policy_check(rest[1])
|
||
elif rest[0] == "show":
|
||
if len(rest) != 1:
|
||
fail("BAD_NAME", "usage: policy show")
|
||
act_policy_show()
|
||
else:
|
||
fail("BAD_NAME", "usage: policy [check <agent>|show]")
|
||
elif action == "fleet-status":
|
||
act_fleet_status()
|
||
elif action == "watchdog-alerts":
|
||
if rest and rest != ["--no-advance"]:
|
||
fail("BAD_NAME", "usage: watchdog-alerts [--no-advance]")
|
||
act_watchdog_alerts(no_advance=rest == ["--no-advance"])
|
||
elif action == "relay-health":
|
||
act_relay_health()
|
||
elif action == "identity-audit":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: identity-audit")
|
||
act_identity_audit()
|
||
elif action == "cdp-latency":
|
||
act_cdp_latency()
|
||
elif action == "chrome-errors":
|
||
if rest and rest != ["--no-advance"]:
|
||
fail("BAD_ARGS", "usage: chrome-errors [--no-advance]")
|
||
act_chrome_errors(no_advance=rest == ["--no-advance"])
|
||
elif action == "main-loop":
|
||
if not rest or rest[0] not in ("check", "status", "enable", "disable"):
|
||
fail("BAD_NAME", "usage: main-loop check|status|enable|disable [--agent <name>]")
|
||
sub = rest[0]
|
||
agent = None
|
||
args = rest[1:]
|
||
if "--agent" in args:
|
||
i = args.index("--agent")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: main-loop %s [--agent <name>]" % sub)
|
||
agent = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: main-loop %s [--agent <name>]" % sub)
|
||
act_main_loop(sub, agent)
|
||
elif action == "thread":
|
||
if not rest or rest[0] not in THREAD_OPS:
|
||
fail("BAD_NAME", "usage: thread list|pin|unpin|archive|unarchive <agent> [args]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if not args:
|
||
fail("BAD_NAME", f"usage: thread {sub} <agent> [...]")
|
||
agent = args[0]
|
||
args = args[1:]
|
||
thread_id = None
|
||
limit = None
|
||
confirm = "--confirm" in args
|
||
args = [a for a in args if a != "--confirm"]
|
||
if confirm and sub != "archive":
|
||
fail("BAD_ARGS", "--confirm only applies to thread archive")
|
||
idx = 0
|
||
while idx < len(args):
|
||
if args[idx] == "--limit" and idx + 1 < len(args):
|
||
try:
|
||
limit = int(args[idx + 1])
|
||
except ValueError:
|
||
fail("BAD_LIMIT", "usage: thread list <agent> [--limit N]")
|
||
idx += 2
|
||
elif thread_id is None and not args[idx].startswith("--"):
|
||
thread_id = args[idx]
|
||
idx += 1
|
||
else:
|
||
fail("BAD_ARGS", f"usage: thread {sub} <agent> [...]")
|
||
if limit is not None and sub != "list":
|
||
fail("BAD_ARGS", "--limit only applies to thread list")
|
||
if sub in ("pin", "unpin", "archive", "unarchive") and not thread_id:
|
||
fail("BAD_NAME", f"usage: thread {sub} <agent> <thread-id> [--confirm]")
|
||
if sub == "list" and thread_id:
|
||
fail("BAD_ARGS", "usage: thread list <agent> [--limit N]")
|
||
act_thread(sub, agent, thread=thread_id, limit=limit, confirm=confirm)
|
||
elif action in ("thread-list", "thread-pin", "thread-unpin",
|
||
"thread-archive", "thread-unarchive", "thread-rename"):
|
||
op = action[len("thread-"):]
|
||
agent = None
|
||
thread_id = None
|
||
title = None
|
||
limit = None
|
||
confirm = "--confirm" in rest
|
||
args = [a for a in rest if a != "--confirm"]
|
||
if confirm and op != "archive":
|
||
fail("BAD_ARGS", "--confirm only applies to thread-archive")
|
||
idx = 0
|
||
while idx < len(args):
|
||
if args[idx] == "--agent" and idx + 1 < len(args):
|
||
agent = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--thread" and idx + 1 < len(args):
|
||
thread_id = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--title" and idx + 1 < len(args):
|
||
title = args[idx + 1]
|
||
idx += 2
|
||
elif args[idx] == "--limit" and idx + 1 < len(args):
|
||
try:
|
||
limit = int(args[idx + 1])
|
||
except ValueError:
|
||
fail("BAD_LIMIT", "usage: thread-list --agent <agent> [--limit N]")
|
||
idx += 2
|
||
else:
|
||
fail("BAD_ARGS", f"usage: {action} --agent <agent> [--thread <id>] [--title <t>] [--confirm] [--limit N]")
|
||
if not agent:
|
||
fail("BAD_NAME", f"usage: {action} --agent <agent> [...]")
|
||
if limit is not None and op != "list":
|
||
fail("BAD_ARGS", "--limit only applies to thread-list")
|
||
if op in ("pin", "unpin", "archive", "unarchive", "rename") and not thread_id:
|
||
fail("BAD_NAME", f"usage: {action} --agent <agent> --thread <id> [...]")
|
||
act_thread(op, agent, thread=thread_id, title=title, limit=limit, confirm=confirm)
|
||
elif action in ("swarm-spawn", "swarm-list", "swarm-status",
|
||
"swarm-attach", "swarm-report", "swarm-results",
|
||
"swarm-kill", "swarm-prune"):
|
||
op = action[len("swarm-"):]
|
||
if op == "spawn":
|
||
if len(rest) < 2:
|
||
fail("BAD_ARGS", "usage: swarm-spawn <count> <task...> [--label L]")
|
||
label = None
|
||
pargs = list(rest[1:])
|
||
if "--label" in pargs:
|
||
i = pargs.index("--label")
|
||
if i + 1 >= len(pargs):
|
||
fail("BAD_ARGS", "usage: swarm-spawn <count> <task...> [--label L]")
|
||
label = pargs[i + 1]
|
||
del pargs[i:i + 2]
|
||
for a in pargs:
|
||
if a.startswith("--"):
|
||
fail("BAD_ARGS", "usage: swarm-spawn <count> <task...> [--label L]")
|
||
task = " ".join(pargs)
|
||
if not task.strip():
|
||
fail("BAD_TASK", "task must be a non-empty string")
|
||
act_swarm_spawn(rest[0], task, label)
|
||
elif op == "list":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: swarm-list")
|
||
act_swarm_list()
|
||
elif op == "status":
|
||
if len(rest) != 1:
|
||
fail("BAD_ARGS", "usage: swarm-status <swarm-id>")
|
||
act_swarm_status(rest[0])
|
||
elif op == "attach":
|
||
if len(rest) not in (3, 4):
|
||
fail("BAD_ARGS", "usage: swarm-attach <swarm-id> <slot> <agent-id> [session-id]")
|
||
act_swarm_attach(rest[0], rest[1], rest[2], rest[3] if len(rest) > 3 else None)
|
||
elif op == "report":
|
||
if len(rest) != 2:
|
||
fail("BAD_ARGS", "usage: swarm-report <swarm-id> <slot> (result JSON on stdin)")
|
||
act_swarm_report(rest[0], rest[1])
|
||
elif op == "results":
|
||
if len(rest) != 1:
|
||
fail("BAD_ARGS", "usage: swarm-results <swarm-id>")
|
||
act_swarm_results(rest[0])
|
||
elif op == "kill":
|
||
if not rest or len(rest) > 2:
|
||
fail("BAD_ARGS", "usage: swarm-kill <swarm-id> --confirm")
|
||
act_swarm_kill(rest[0], confirm=("--confirm" in rest[1:]))
|
||
elif op == "prune":
|
||
stale_h = 6
|
||
confirm = "--confirm" in rest
|
||
if "--stale-hours" in rest:
|
||
i = rest.index("--stale-hours")
|
||
if i + 1 < len(rest):
|
||
try:
|
||
stale_h = float(rest[i + 1])
|
||
except ValueError:
|
||
pass
|
||
act_swarm_prune(stale_hours=stale_h, confirm=confirm)
|
||
elif action == "swarm":
|
||
if not rest or rest[0] not in SWARM_OPS:
|
||
fail("BAD_NAME", "usage: swarm spawn|list|status|attach|report|results|kill|prune [...]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if sub == "spawn":
|
||
if len(args) < 2:
|
||
fail("BAD_ARGS", "usage: swarm spawn <count> <task...> [--label L]")
|
||
label = None
|
||
pargs = list(args[1:])
|
||
if "--label" in pargs:
|
||
i = pargs.index("--label")
|
||
if i + 1 >= len(pargs):
|
||
fail("BAD_ARGS", "usage: swarm spawn <count> <task...> [--label L]")
|
||
label = pargs[i + 1]
|
||
del pargs[i:i + 2]
|
||
for a in pargs:
|
||
if a.startswith("--"):
|
||
fail("BAD_ARGS", "usage: swarm spawn <count> <task...> [--label L]")
|
||
task = " ".join(pargs)
|
||
if not task.strip():
|
||
fail("BAD_TASK", "task must be a non-empty string")
|
||
act_swarm_spawn(args[0], task, label)
|
||
elif sub == "list":
|
||
if args:
|
||
fail("BAD_ARGS", "usage: swarm list")
|
||
act_swarm_list()
|
||
elif sub == "status":
|
||
if len(args) != 1:
|
||
fail("BAD_ARGS", "usage: swarm status <swarm-id>")
|
||
act_swarm_status(args[0])
|
||
elif sub == "attach":
|
||
if len(args) not in (3, 4):
|
||
fail("BAD_ARGS", "usage: swarm attach <swarm-id> <slot> <agent-id> [session-id]")
|
||
act_swarm_attach(args[0], args[1], args[2], args[3] if len(args) > 3 else None)
|
||
elif sub == "report":
|
||
if len(args) != 2:
|
||
fail("BAD_ARGS", "usage: swarm report <swarm-id> <slot> (result JSON on stdin)")
|
||
act_swarm_report(args[0], args[1])
|
||
elif sub == "results":
|
||
if len(args) != 1:
|
||
fail("BAD_ARGS", "usage: swarm results <swarm-id>")
|
||
act_swarm_results(args[0])
|
||
elif sub == "kill":
|
||
if not args or len(args) > 2:
|
||
fail("BAD_ARGS", "usage: swarm kill <swarm-id> --confirm")
|
||
act_swarm_kill(args[0], confirm=("--confirm" in args[1:]))
|
||
elif sub == "prune":
|
||
stale_h = 6
|
||
confirm = "--confirm" in args
|
||
if "--stale-hours" in args:
|
||
i = args.index("--stale-hours")
|
||
if i + 1 < len(args):
|
||
try:
|
||
stale_h = float(args[i+1])
|
||
except ValueError:
|
||
pass
|
||
act_swarm_prune(stale_hours=stale_h, confirm=confirm)
|
||
elif action == "job":
|
||
if not rest or rest[0] not in ("result", "status", "next", "chain"):
|
||
fail("BAD_NAME", "usage: job result|status|next|chain [...]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if sub == "result":
|
||
if len(args) != 1:
|
||
fail("BAD_ARGS", "usage: job result <job-id> (result JSON on stdin)")
|
||
act_job_result(args[0])
|
||
elif sub == "status":
|
||
if len(args) != 1:
|
||
fail("BAD_ARGS", "usage: job status <name-or-job-id>")
|
||
act_job_status(args[0])
|
||
elif sub == "next":
|
||
if not args or len(args) > 2:
|
||
fail("BAD_ARGS", "usage: job next <job-id> [--success|--fail]")
|
||
s = None
|
||
if "--success" in args[1:]:
|
||
s = True
|
||
if "--fail" in args[1:]:
|
||
s = False
|
||
act_job_next(args[0], s)
|
||
elif sub == "chain":
|
||
if len(args) < 2 or len(args) > 3:
|
||
fail("BAD_ARGS", "usage: job chain <from-job> <to-job> [--on-failure]")
|
||
act_job_chain(args[0], args[1], on_failure=("--on-failure" in args[2:]))
|
||
elif action in ("quality-check", "quality-validate"):
|
||
op = action[len("quality-"):]
|
||
if op == "check":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: quality-check")
|
||
audit("quality-check")
|
||
act_quality_check()
|
||
else:
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: quality-validate <action> [args...]")
|
||
audit("quality-validate", rest[0])
|
||
act_quality_validate(rest[0], rest[1:])
|
||
elif action == "quality":
|
||
if not rest or rest[0] not in ("check", "validate"):
|
||
fail("BAD_NAME", "usage: quality check|validate [...]")
|
||
if rest[0] == "check":
|
||
if len(rest) != 1:
|
||
fail("BAD_ARGS", "usage: quality check")
|
||
audit("quality-check")
|
||
act_quality_check()
|
||
else:
|
||
if len(rest) < 2:
|
||
fail("BAD_ARGS", "usage: quality validate <action> [args...]")
|
||
audit("quality-validate", rest[1])
|
||
act_quality_validate(rest[1], rest[2:])
|
||
elif action in ("approval-check", "approval-list"):
|
||
node = rest[0] if rest else None
|
||
act_approval_check(node)
|
||
elif action in ("approval-allow", "approval-approve"):
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: approval-allow <node> [--always] [--force] [--message TEXT] [--allow-main-chat]")
|
||
node = rest[0]
|
||
rest_args = rest[1:]
|
||
always = "--always" in rest_args
|
||
force = "--force" in rest_args
|
||
allow_main_chat = "--allow-main-chat" in rest_args
|
||
message = None
|
||
if "--message" in rest_args:
|
||
mi = rest_args.index("--message")
|
||
if mi + 1 < len(rest_args):
|
||
message = rest_args[mi + 1]
|
||
act_approval_allow(node, always=always, force=force, message=message, allow_main_chat=allow_main_chat)
|
||
elif action == "approval-deny":
|
||
if not rest:
|
||
fail("BAD_ARGS", "usage: approval-deny <node> [--message TEXT] [--allow-main-chat]")
|
||
node = rest[0]
|
||
rest_args = rest[1:]
|
||
allow_main_chat = "--allow-main-chat" in rest_args
|
||
message = None
|
||
if "--message" in rest_args:
|
||
mi = rest_args.index("--message")
|
||
if mi + 1 < len(rest_args):
|
||
message = rest_args[mi + 1]
|
||
act_approval_deny(node, message=message, allow_main_chat=allow_main_chat)
|
||
elif action == "approval-auto":
|
||
node = rest[0] if rest else None
|
||
act_approval_auto(node)
|
||
elif action == "tmux-tally":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: tmux-tally")
|
||
act_tmux_tally()
|
||
elif action == "tmux-auto-status":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: tmux-auto-status")
|
||
act_tmux_auto_status()
|
||
elif action == "tmux-auto-toggle":
|
||
args = list(rest)
|
||
enable = True
|
||
if "--disable" in args:
|
||
enable = False
|
||
args = [a for a in args if a != "--disable"]
|
||
elif "--enable" in args:
|
||
enable = True
|
||
args = [a for a in args if a != "--enable"]
|
||
node = None
|
||
session = None
|
||
if "--node" in args:
|
||
i = args.index("--node")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
node = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if "--session" in args:
|
||
i = args.index("--session")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
session = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
act_tmux_auto_toggle(enable=enable, node=node, session=session)
|
||
elif action == "tmux-auto-once":
|
||
dry_run = "--dry-run" in rest
|
||
args = [a for a in rest if a != "--dry-run"]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: tmux-auto-once [--dry-run]")
|
||
act_tmux_auto_once(dry_run=dry_run)
|
||
elif action == "tmux":
|
||
if not rest:
|
||
fail("BAD_NAME", "usage: tmux tally|auto|status|toggle|once [...]")
|
||
sub = rest[0]
|
||
args = rest[1:]
|
||
if sub == "tally":
|
||
if args:
|
||
fail("BAD_ARGS", "usage: tmux tally")
|
||
act_tmux_tally()
|
||
elif sub in ("auto", "status"):
|
||
if args:
|
||
fail("BAD_ARGS", f"usage: tmux {sub}")
|
||
act_tmux_auto_status()
|
||
elif sub == "toggle":
|
||
enable = True
|
||
if "--disable" in args:
|
||
enable = False
|
||
args = [a for a in args if a != "--disable"]
|
||
elif "--enable" in args:
|
||
enable = True
|
||
args = [a for a in args if a != "--enable"]
|
||
node = None
|
||
session = None
|
||
if "--node" in args:
|
||
i = args.index("--node")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
node = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if "--session" in args:
|
||
i = args.index("--session")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
session = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]")
|
||
act_tmux_auto_toggle(enable=enable, node=node, session=session)
|
||
elif sub == "once":
|
||
dry_run = "--dry-run" in args
|
||
args = [a for a in args if a != "--dry-run"]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: tmux once [--dry-run]")
|
||
act_tmux_auto_once(dry_run=dry_run)
|
||
else:
|
||
fail("BAD_NAME", "usage: tmux tally|auto|status|toggle|once [...]")
|
||
elif action == "onboard-connects":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: onboard-connects")
|
||
act_onboard_connects()
|
||
elif action == "onboard":
|
||
if not rest or rest[0] != "connects":
|
||
fail("BAD_NAME", "usage: onboard connects")
|
||
if len(rest) > 1:
|
||
fail("BAD_ARGS", "usage: onboard connects")
|
||
act_onboard_connects()
|
||
elif action == "dm-log":
|
||
limit = 50
|
||
agent = None
|
||
args = list(rest)
|
||
if "--agent" in args:
|
||
i = args.index("--agent")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: dm-log [limit] [--agent <agent>]")
|
||
agent = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if len(args) > 1:
|
||
fail("BAD_ARGS", "usage: dm-log [limit] [--agent <agent>]")
|
||
if args:
|
||
try:
|
||
limit = int(args[0])
|
||
except ValueError:
|
||
fail("BAD_LIMIT", "usage: dm-log [limit] [--agent <agent>]")
|
||
act_dm_log(limit=limit, agent=agent)
|
||
elif action == "unread":
|
||
agent = None
|
||
args = list(rest)
|
||
if "--agent" in args:
|
||
i = args.index("--agent")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: unread [--agent <agent>]")
|
||
agent = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: unread [--agent <agent>]")
|
||
act_unread(agent=agent)
|
||
elif action == "git-status":
|
||
if rest:
|
||
fail("BAD_ARGS", "usage: git-status")
|
||
act_git_status()
|
||
elif action == "git-diff":
|
||
args = list(rest)
|
||
stat = "--stat" in args
|
||
args = [a for a in args if a != "--stat"]
|
||
path = None
|
||
if "--path" in args:
|
||
i = args.index("--path")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: git-diff [--stat] [--path <path>]")
|
||
path = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: git-diff [--stat] [--path <path>]")
|
||
act_git_diff(stat=stat, path=path)
|
||
elif action == "git-log":
|
||
args = list(rest)
|
||
limit = 10
|
||
path = None
|
||
if "--limit" in args:
|
||
i = args.index("--limit")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: git-log [--limit N] [--path <path>]")
|
||
limit = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if "--path" in args:
|
||
i = args.index("--path")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: git-log [--limit N] [--path <path>]")
|
||
path = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if args:
|
||
fail("BAD_ARGS", "usage: git-log [--limit N] [--path <path>]")
|
||
act_git_log(limit=limit, path=path)
|
||
elif action == "tests-run":
|
||
args = list(rest)
|
||
filt = None
|
||
if "--filter" in args:
|
||
i = args.index("--filter")
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: tests-run [tests.<module>] [--filter <pattern>]")
|
||
filt = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if len(args) > 1:
|
||
fail("BAD_ARGS", "usage: tests-run [tests.<module>] [--filter <pattern>]")
|
||
act_tests_run(args[0] if args else None, filter=filt)
|
||
elif action == "ack":
|
||
args = list(rest)
|
||
allow_main = "--allow-main-chat" in args
|
||
args = [a for a in args if a != "--allow-main-chat"]
|
||
to = sender = sidechat = None
|
||
for flag in ("--to", "--sender", "--sidechat"):
|
||
if flag in args:
|
||
i = args.index(flag)
|
||
if i + 1 >= len(args):
|
||
fail("BAD_ARGS", "usage: ack <id> --to <agent> --sender <agent> [--sidechat <name>] [--allow-main-chat]")
|
||
val = args[i + 1]
|
||
args = args[:i] + args[i + 2:]
|
||
if flag == "--to":
|
||
to = val
|
||
elif flag == "--sender":
|
||
sender = val
|
||
else:
|
||
sidechat = val
|
||
if len(args) != 1 or not to or not sender:
|
||
fail("BAD_ARGS", "usage: ack <id> --to <agent> --sender <agent> [--sidechat <name>] [--allow-main-chat]")
|
||
act_ack(args[0], to, sender, sidechat=sidechat, allow_main_chat=allow_main)
|
||
else:
|
||
print(USAGE, file=sys.stderr)
|
||
fail("BAD_NAME", f"unknown action: {action}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main(sys.argv)
|