Odysseus v1.0
This commit is contained in:
145
scripts/odysseus-logs
Executable file
145
scripts/odysseus-logs
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""odysseus-logs — unified view of log files across the app.
|
||||
|
||||
Right now logs live in two places:
|
||||
- logs/ app-level (compound.log, future runtime)
|
||||
- /tmp/odysseus-tmux/*.log per-tmux-session model download/serve logs
|
||||
|
||||
This CLI surfaces them so you don't have to remember which directory
|
||||
holds what.
|
||||
|
||||
odysseus-logs list # every log we know about
|
||||
odysseus-logs tail NAME # tail -f a specific log
|
||||
odysseus-logs tail NAME --lines 200 # last N lines only (no follow)
|
||||
odysseus-logs cat NAME # print full content
|
||||
odysseus-logs clean # delete tmux logs older than 7 days
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
||||
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
||||
|
||||
quiet_logs()
|
||||
|
||||
import argparse, json, subprocess, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_APP_LOGS = _REPO_ROOT / "logs"
|
||||
_TMUX_LOGS = Path("/tmp/odysseus-tmux")
|
||||
|
||||
|
||||
def _enumerate() -> list[dict]:
|
||||
"""Return every known log file as {name, path, bytes, modified}."""
|
||||
out = []
|
||||
for base in (_APP_LOGS, _TMUX_LOGS):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for p in sorted(base.glob("*.log")):
|
||||
try:
|
||||
st = p.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"path": str(p),
|
||||
"bytes": st.st_size,
|
||||
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
out.sort(key=lambda r: r["modified"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve(name: str) -> Path | None:
|
||||
"""Match a log by exact filename, basename-without-extension, or
|
||||
substring. Returns the most-recently-modified match if there are
|
||||
ties."""
|
||||
candidates = []
|
||||
for base in (_APP_LOGS, _TMUX_LOGS):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for p in base.glob("*.log"):
|
||||
if p.name == name or p.stem == name or name in p.name:
|
||||
candidates.append(p)
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
emit(_enumerate(), args)
|
||||
|
||||
|
||||
def cmd_tail(args):
|
||||
p = _resolve(args.name)
|
||||
if p is None:
|
||||
fail(f"no log matching {args.name!r}; run `odysseus-logs list`")
|
||||
if args.follow:
|
||||
# Exec tail -f so signals + buffering are native.
|
||||
os.execvp("tail", ["tail", "-n", str(args.lines), "-f", str(p)])
|
||||
# Non-follow: print last N lines + exit.
|
||||
proc = subprocess.run(["tail", "-n", str(args.lines), str(p)],
|
||||
capture_output=True, text=True)
|
||||
sys.stdout.write(proc.stdout)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
|
||||
def cmd_cat(args):
|
||||
p = _resolve(args.name)
|
||||
if p is None:
|
||||
fail(f"no log matching {args.name!r}")
|
||||
sys.stdout.write(p.read_text(errors="replace"))
|
||||
|
||||
|
||||
def cmd_clean(args):
|
||||
"""Delete tmux logs older than --days. Doesn't touch app logs."""
|
||||
if not _TMUX_LOGS.is_dir():
|
||||
emit({"deleted": 0, "kept": 0}, args)
|
||||
return
|
||||
cutoff = time.time() - args.days * 86400
|
||||
deleted, kept = [], 0
|
||||
for p in _TMUX_LOGS.glob("*.log"):
|
||||
try:
|
||||
if p.stat().st_mtime < cutoff:
|
||||
p.unlink()
|
||||
deleted.append(p.name)
|
||||
else:
|
||||
kept += 1
|
||||
except OSError:
|
||||
continue
|
||||
emit({"deleted": deleted, "kept": kept, "cutoff_days": args.days}, args)
|
||||
|
||||
|
||||
def _build_parser():
|
||||
p = common_parser("odysseus-logs", "Tail / list logs across the app.")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pl = sub.add_parser("list", parents=p._common_parents)
|
||||
pl.set_defaults(func=cmd_list)
|
||||
|
||||
pt = sub.add_parser("tail", parents=p._common_parents)
|
||||
pt.add_argument("name")
|
||||
pt.add_argument("-n", "--lines", type=int, default=80)
|
||||
pt.add_argument("-f", "--follow", action="store_true",
|
||||
help="follow new lines (tail -f); omit for one-shot")
|
||||
pt.set_defaults(func=cmd_tail)
|
||||
|
||||
pc = sub.add_parser("cat", parents=p._common_parents)
|
||||
pc.add_argument("name")
|
||||
pc.set_defaults(func=cmd_cat)
|
||||
|
||||
pcl = sub.add_parser("clean", parents=p._common_parents)
|
||||
pcl.add_argument("--days", type=int, default=7,
|
||||
help="delete tmux logs older than N days (default 7)")
|
||||
pcl.set_defaults(func=cmd_clean)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(_build_parser()))
|
||||
Reference in New Issue
Block a user