feat(retention): implement Piece 2 job archival, CLI wiring, and rotation driver

This commit is contained in:
operator
2026-10-07 03:28:38 +00:00
parent fff5556eb6
commit c11d1d83ae
7 changed files with 1003 additions and 24 deletions
+128 -17
View File
@@ -960,7 +960,7 @@ def cmd_runtime(args):
state = r["state"]
if r["state"] == "approval-pending" and r["prompt_kind"]:
state = "%s(%s/%s)" % (state, r["prompt_kind"],
r["prompt_key"])
r["prompt_key"] or "…")
if r["is_muse"]:
approve = (badge_ok("YES") if r["auto_approve"]
else badge_err("NO"))
@@ -995,29 +995,30 @@ def cmd_runtime(args):
print(c_red("Error: no such pane %s on %s" % (pane, sock)),
file=sys.stderr)
sys.exit(1)
t_args = ["send-keys", "-t", pane, keys]
if enter:
t_args.append("Enter")
r = mcw._tmux(sock, *t_args, timeout=10)
ok = r.returncode == 0
# Verified send: keys + Enter in one tmux call arrive as a paste
# burst, which the muse composer takes as a newline instead of a
# submit. send_answer paces literal text and Enter apart (with a
# render check for composer-bound prompts) so the submit lands.
ok, detail = mcw.send_answer(sock, pane, keys, enter=enter,
kind=pre.get("prompt_kind"), sig=None)
if as_json:
print(json.dumps({
"ok": ok, "socket": sock, "pane": pane,
"pre_state": pre["state"],
"prompt_kind": pre["prompt_kind"],
"sent": keys, "enter": enter,
"error": (r.stderr or r.stdout or "").strip() or None
if not ok else None}, indent=2))
"verified": detail["verified"],
"retried": detail["retried"],
"error": None if ok else "tmux error"}, indent=2))
return
print("\n Pane %s on %s [%s]" % (
c_bold(pane), sock, c_cyan(pre["state"])))
if ok:
print(" %s sent %r%s" % (c_green("✔"),
keys, " + Enter" if enter else ""))
how = " (verified)" if detail["verified"] else ""
print(" %s sent %r%s%s" % (c_green("✔"), keys,
" + Enter" if enter else "", how))
else:
print(" %s send failed: %s" % (
c_red("✘"),
(r.stderr or r.stdout or "").strip() or "tmux error"))
print(" %s send failed: %s" % (c_red("✘"), "tmux error"))
sys.exit(1)
print()
@@ -2845,6 +2846,8 @@ def cmd_thread_view(args):
# ---------------------------------------------------------------------------
def cmd_job_list(args):
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-list"]
if getattr(args, "archived", False):
cmd.append("--archived")
res = subprocess.run(cmd, capture_output=True, text=True)
try:
@@ -2858,23 +2861,63 @@ def cmd_job_list(args):
return
jobs = data.get("jobs", [])
print("\n" + c_bold(f"=== DEFINED JOBS ({len(jobs)}) ===") + "\n")
title_extra = " (INCLUDING ARCHIVED)" if getattr(args, "archived", False) else ""
print("\n" + c_bold(f"=== DEFINED JOBS ({len(jobs)}){title_extra} ===") + "\n")
headers = ["NAME", "AGENT", "SCHEDULE", "TIMEOUT", "DESCRIPTION"]
headers = ["NAME", "AGENT", "SCHEDULE", "TIMEOUT", "STATUS", "DESCRIPTION"]
rows = []
for j in jobs:
sched = j.get("schedule", "-")
sched_disp = c_green(sched) if sched != "manual" else c_dim("manual")
is_arch = j.get("archived", False)
status_disp = c_yellow("ARCHIVED") if is_arch else c_green("ACTIVE")
rows.append([
c_bold(j.get("name", "")),
j.get("agent", "-"),
sched_disp,
f"{j.get('timeout', '-')}s",
(j.get("description") or "-")[:50]
status_disp,
(j.get("description") or "-")[:45]
])
print_table(headers, rows)
print("\n" + c_dim(" Commands: super job show <name> | super job status <name> | super job run <name> | super job create <name>") + "\n")
print("\n" + c_dim(" Commands: box job show <name> | box job archive <name> | box job unarchive <name> | box job run <name>") + "\n")
def cmd_job_archive(args):
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-archive", args.name]
if getattr(args, "force", False):
cmd.append("--force")
res = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(res.stdout)
except Exception:
print(c_red("Failed to archive job:") + f"\n{res.stderr or res.stdout}")
return
if args.json:
print(json.dumps(data, indent=2))
return
if data.get("ok"):
print(c_green(f"✔ Archived job '{args.name}' to jobs/archive/{args.name}.json"))
else:
print(c_red(f"Error archiving job '{args.name}': {data.get('error', 'unknown error')}"))
def cmd_job_unarchive(args):
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-unarchive", args.name]
res = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(res.stdout)
except Exception:
print(c_red("Failed to unarchive job:") + f"\n{res.stderr or res.stdout}")
return
if args.json:
print(json.dumps(data, indent=2))
return
if data.get("ok"):
print(c_green(f"✔ Unarchived job '{args.name}' to jobs/{args.name}.json"))
else:
print(c_red(f"Error unarchiving job '{args.name}': {data.get('error', 'unknown error')}"))
def cmd_job_show(args):
name = args.name
@@ -4718,6 +4761,58 @@ def cmd_ssh_info(args):
sys.stdout.write(res.stdout)
def cmd_ssh_check(args):
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "ssh-check"]
res = subprocess.run(cmd, capture_output=True, text=True)
if args.json:
sys.stdout.write(res.stdout)
return
try:
d = json.loads(res.stdout)
except Exception:
sys.stdout.write(res.stdout)
return
if not d.get("ok"):
print(c_red(f"✗ Failed: {d.get('error')}"))
return
jump = d.get("jump_host", "?")
reachable = d.get("jump_reachable")
wall = d.get("latency_ms")
print("\n" + c_bold(f"=== CONTAINER SSH TUNNEL HEALTH (JUMP: {jump}) ===") + "\n")
if not reachable:
print(c_red(f" ✗ Jump host unreachable: {d.get('error', 'unknown')}"))
print(c_dim(" Tunnel states below are UNKNOWN (last sweep failed).") + "\n")
elif wall is not None:
print(c_dim(f" Sweep completed in {wall}ms at {d.get('checked_at', '')}") + "\n")
headers = ["AGENT", "SSH", "LAT", "BANNER", "TERM", "STATE", "DIAL"]
rows = []
accounts = d.get("accounts", {})
def _state(v):
if v is True:
return c_green("UP")
if v is False:
return c_red("DOWN")
return c_dim("?")
for name, info in accounts.items():
sport = info.get("ssh_port", "?")
tport = info.get("term_port", "?")
lat = info.get("ssh_latency_ms")
lat_s = f"{lat}ms" if lat is not None else "-"
banner = (info.get("ssh_banner") or "-")[:28]
therm = info.get("term_http") or ""
tstate = _state(info.get("term_up"))
if info.get("term_up") and therm:
tstate += c_dim(f" {therm[:18]}")
u = info.get("container_user", "hatch")
dial = f"ssh -J super@{jump} -p {sport} {u}@localhost"
rows.append([c_cyan(name), f":{sport} {_state(info.get('ssh_up'))}",
lat_s, banner, f":{tport}", tstate, c_yellow(dial)])
print_table(headers, rows)
print()
def cmd_md_audit(args):
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "md-audit"] + (args.accounts or [])
res = subprocess.run(cmd, capture_output=True, text=True)
@@ -5931,6 +6026,7 @@ def build_parser():
job_sub = p_job.add_subparsers(dest="action")
p_job_list = job_sub.add_parser("list", parents=[common], help="List defined jobs")
p_job_list.add_argument("--archived", "-a", action="store_true", help="Include archived jobs from jobs/archive/")
p_job_show = job_sub.add_parser("show", parents=[common], help="Inspect full job definition and timer details")
p_job_show.add_argument("name", help="Job name")
@@ -5974,6 +6070,14 @@ def build_parser():
p_job_log.add_argument("-n", type=int, default=20, help="Number of events")
p_job_log.add_argument("name", nargs="?", default=None, help="Filter by job name")
p_job_archive = job_sub.add_parser("archive", parents=[common], help="Archive a retired manual job to jobs/archive/")
p_job_archive.add_argument("name", help="Job name to archive")
p_job_archive.add_argument("--force", action="store_true", help="Force archive even if marked protected")
p_job_unarchive = job_sub.add_parser("unarchive", parents=[common], help="Restore an archived job from jobs/archive/")
p_job_unarchive.add_argument("name", help="Job name to restore")
# Domain: WEB
p_web = subparsers.add_parser("web", parents=[common], help="Probe VM web surfaces (box.muse-dev.online), auth, sync")
web_sub = p_web.add_subparsers(dest="action")
@@ -6025,6 +6129,7 @@ def build_parser():
p_s_ports = ssh_sub.add_parser("ports", parents=[common], help="List container reverse tunnel port mappings")
p_s_info = ssh_sub.add_parser("info", parents=[common], help="Show SSH dial-in commands and reverse tunnel coordinates")
p_s_info.add_argument("name", help="Agent identity (e.g. 646, muse, pip)")
p_s_check = ssh_sub.add_parser("check", parents=[common], help="Live SSH/terminal tunnel health sweep via jump host")
# Domain: MD (Hatch Agent Markdown & Operational Drive)
p_md = subparsers.add_parser("md", parents=[common], help="Manage agent .md system files & inject operational DRIVE via Hatch")
@@ -6480,6 +6585,10 @@ def main():
cmd_job_run(args)
elif act == "log":
cmd_job_log(args)
elif act == "archive":
cmd_job_archive(args)
elif act == "unarchive":
cmd_job_unarchive(args)
else:
parser.print_help()
elif args.domain == "web":
@@ -6520,6 +6629,8 @@ def main():
cmd_ssh_ports(args)
elif act == "info":
cmd_ssh_info(args)
elif act == "check":
cmd_ssh_check(args)
else:
p_ssh.print_help()
elif args.domain == "md":