Files
Medios-Macina/cmdnat/worker.py

327 lines
10 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
"""Worker cmdlet: Display workers table in ResultTable format."""
from __future__ import annotations
import sys
2025-12-07 00:21:30 -08:00
from dataclasses import dataclass
2025-11-25 20:09:33 -08:00
from datetime import datetime, timezone
2025-12-07 00:21:30 -08:00
from typing import Any, Dict, Sequence, List
2025-11-25 20:09:33 -08:00
2025-12-12 21:55:38 -08:00
from cmdlet import register
from cmdlet._shared import Cmdlet, CmdletArg
2025-11-25 20:09:33 -08:00
import pipeline as ctx
2025-12-11 19:04:02 -08:00
from SYS.logger import log
2025-11-25 20:09:33 -08:00
from config import get_local_storage_path
2025-12-07 00:21:30 -08:00
DEFAULT_LIMIT = 100
WORKER_STATUS_FILTERS = {"running", "completed", "error", "cancelled"}
HELP_FLAGS = {"-?", "/?", "--help", "-h", "help", "--cmdlet"}
2025-11-25 20:09:33 -08:00
CMDLET = Cmdlet(
name=".worker",
summary="Display workers table in result table format.",
usage=".worker [status] [-limit N] [@N]",
2025-12-11 12:47:30 -08:00
arg=[
2025-12-24 17:58:57 -08:00
CmdletArg("status", description="Filter by status: running, completed, error (default: all)", requires_db=True),
CmdletArg("limit", type="integer", description="Limit results (default: 100)", requires_db=True),
CmdletArg("@N", description="Select worker by index (1-based) and display full logs", requires_db=True),
CmdletArg("-id", description="Show full logs for a specific worker", requires_db=True),
CmdletArg("-clear", type="flag", description="Remove completed workers from the database", requires_db=True),
2025-11-25 20:09:33 -08:00
],
2025-12-11 12:47:30 -08:00
detail=[
2025-11-25 20:09:33 -08:00
"- Shows all background worker tasks and their output",
"- Can filter by status: running, completed, error",
"- Search result stdout is captured from each worker",
"- Use @N to select a specific worker by index and display its full logs",
"Examples:",
".worker # Show all workers",
".worker running # Show running workers only",
".worker completed -limit 50 # Show 50 most recent completed workers",
".worker @3 # Show full logs for the 3rd worker",
".worker running @2 # Show full logs for the 2nd running worker",
],
)
2025-12-07 00:21:30 -08:00
def _has_help_flag(args_list: Sequence[str]) -> bool:
return any(str(arg).lower() in HELP_FLAGS for arg in args_list)
@dataclass
class WorkerCommandOptions:
status: str | None = None
limit: int = DEFAULT_LIMIT
worker_id: str | None = None
clear: bool = False
2025-11-25 20:09:33 -08:00
@register([".worker", "worker", "workers"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
2025-12-07 00:21:30 -08:00
"""Display workers table or show detailed logs for a specific worker."""
args_list = [str(arg) for arg in (args or [])]
selection_indices = ctx.get_last_selection()
selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0
if _has_help_flag(args_list):
2025-12-24 17:58:57 -08:00
ctx.emit(CMDLET.__dict__)
2025-12-07 00:21:30 -08:00
return 0
options = _parse_worker_args(args_list)
library_root = get_local_storage_path(config or {})
if not library_root:
log("No library root configured", file=sys.stderr)
return 1
try:
2025-12-11 19:04:02 -08:00
from API.folder import API_folder_store
2025-12-07 00:21:30 -08:00
2025-12-11 19:04:02 -08:00
with API_folder_store(library_root) as db:
2025-12-07 00:21:30 -08:00
if options.clear:
count = db.clear_finished_workers()
log(f"Cleared {count} finished workers.")
return 0
if options.worker_id:
worker = db.get_worker(options.worker_id)
if worker:
events: List[Dict[str, Any]] = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
log(f"Worker not found: {options.worker_id}", file=sys.stderr)
return 1
if selection_requested:
return _render_worker_selection(db, result)
return _render_worker_list(db, options.status, options.limit)
except Exception as exc:
log(f"Workers query failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return 1
def _parse_worker_args(args_list: Sequence[str]) -> WorkerCommandOptions:
options = WorkerCommandOptions()
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-limit", "--limit"} and i + 1 < len(args_list):
options.limit = _normalize_limit(args_list[i + 1])
i += 2
elif low in {"-id", "--id"} and i + 1 < len(args_list):
options.worker_id = args_list[i + 1]
i += 2
elif low in {"-clear", "--clear"}:
options.clear = True
i += 1
elif low in {"-status", "--status"} and i + 1 < len(args_list):
options.status = args_list[i + 1].lower()
i += 2
elif low in WORKER_STATUS_FILTERS:
options.status = low
i += 1
elif not arg.startswith("-"):
options.status = low
i += 1
else:
i += 1
return options
def _normalize_limit(value: Any) -> int:
try:
return max(1, int(value))
except (TypeError, ValueError):
return DEFAULT_LIMIT
2025-11-25 20:09:33 -08:00
def _render_worker_list(db, status_filter: str | None, limit: int) -> int:
2025-12-07 00:21:30 -08:00
workers = db.get_all_workers(limit=limit)
if status_filter:
workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter]
if not workers:
log("No workers found", file=sys.stderr)
return 0
for worker in workers:
started = worker.get("started_at", "")
ended = worker.get("completed_at", worker.get("last_updated", ""))
date_str = _extract_date(started)
start_time = _format_event_timestamp(started)
end_time = _format_event_timestamp(ended)
item = {
"columns": [
("Status", worker.get("status", "")),
("Pipe", _summarize_pipe(worker.get("pipe"))),
("Date", date_str),
("Start Time", start_time),
("End Time", end_time),
],
"__worker_metadata": worker,
"_selection_args": ["-id", worker.get("worker_id")],
}
ctx.emit(item)
return 0
2025-11-25 20:09:33 -08:00
def _render_worker_selection(db, selected_items: Any) -> int:
2025-12-07 00:21:30 -08:00
if not isinstance(selected_items, list):
log("Selection payload missing", file=sys.stderr)
return 1
emitted = False
for item in selected_items:
worker = _resolve_worker_record(db, item)
if not worker:
continue
events: List[Dict[str, Any]] = []
try:
events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else []
except Exception:
events = []
_emit_worker_detail(worker, events)
emitted = True
if not emitted:
log("Selected rows no longer exist", file=sys.stderr)
return 1
return 0
2025-11-25 20:09:33 -08:00
def _resolve_worker_record(db, payload: Any) -> Dict[str, Any] | None:
2025-12-07 00:21:30 -08:00
if not isinstance(payload, dict):
return None
worker_data = payload.get("__worker_metadata")
worker_id = None
if isinstance(worker_data, dict):
worker_id = worker_data.get("worker_id")
else:
worker_id = payload.get("worker_id")
worker_data = None
if worker_id:
fresh = db.get_worker(worker_id)
if fresh:
return fresh
return worker_data if isinstance(worker_data, dict) else None
2025-11-25 20:09:33 -08:00
def _emit_worker_detail(worker: Dict[str, Any], events: List[Dict[str, Any]]) -> None:
2025-12-07 00:21:30 -08:00
stdout_content = worker.get("stdout", "") or ""
lines = stdout_content.splitlines()
for line in lines:
line = line.strip()
if not line:
continue
timestamp = ""
level = "INFO"
message = line
try:
parts = line.split(" - ", 3)
if len(parts) >= 4:
ts_str, _, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
elif len(parts) == 3:
ts_str, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
except Exception:
pass
item = {
"columns": [
("Time", timestamp),
("Level", level),
("Message", message),
]
}
ctx.emit(item)
# Events are already always derived from stdout for now.
2025-11-25 20:09:33 -08:00
def _summarize_pipe(pipe_value: Any, limit: int = 60) -> str:
2025-12-07 00:21:30 -08:00
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..."
2025-11-25 20:09:33 -08:00
def _format_event_timestamp(raw_timestamp: Any) -> str:
2025-12-07 00:21:30 -08:00
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%H:%M:%S")
if not raw_timestamp:
return "--:--:--"
text = str(raw_timestamp)
if "T" in text:
time_part = text.split("T", 1)[1]
elif " " in text:
time_part = text.split(" ", 1)[1]
else:
time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part
2025-11-25 20:09:33 -08:00
def _parse_to_local(timestamp_str: Any) -> datetime | None:
2025-12-07 00:21:30 -08:00
if not timestamp_str:
return None
text = str(timestamp_str).strip()
if not text:
return None
try:
if "T" in text:
return datetime.fromisoformat(text)
if " " in text:
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone()
except Exception:
pass
return None
2025-11-25 20:09:33 -08:00
def _extract_date(raw_timestamp: Any) -> str:
2025-12-07 00:21:30 -08:00
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%m-%d-%y")
if not raw_timestamp:
return ""
text = str(raw_timestamp)
date_part = ""
if "T" in text:
date_part = text.split("T", 1)[0]
elif " " in text:
date_part = text.split(" ", 1)[0]
else:
date_part = text
try:
parts = date_part.split("-")
if len(parts) == 3:
year, month, day = parts
return f"{month}-{day}-{year[2:]}"
except Exception:
pass
return date_part