2025-11-25 20:09:33 -08:00
|
|
|
"""Worker cmdlet: Display workers table in ResultTable format."""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-01-01 20:37:27 -08:00
|
|
|
import re
|
2025-11-25 20:09:33 -08:00
|
|
|
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-12-29 23:28:15 -08:00
|
|
|
from SYS import pipeline as ctx
|
2025-12-11 19:04:02 -08:00
|
|
|
from SYS.logger import log
|
2025-12-29 18:42:02 -08:00
|
|
|
from SYS.config import get_local_storage_path
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-07 00:21:30 -08:00
|
|
|
DEFAULT_LIMIT = 100
|
2025-12-29 18:42:02 -08:00
|
|
|
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-29 17:05:03 -08:00
|
|
|
CmdletArg(
|
|
|
|
|
"status",
|
|
|
|
|
description="Filter by status: running, completed, error (default: all)",
|
|
|
|
|
requires_db=True,
|
|
|
|
|
),
|
|
|
|
|
CmdletArg(
|
2025-12-29 18:42:02 -08:00
|
|
|
"limit",
|
|
|
|
|
type="integer",
|
|
|
|
|
description="Limit results (default: 100)",
|
|
|
|
|
requires_db=True
|
2025-12-29 17:05:03 -08:00
|
|
|
),
|
|
|
|
|
CmdletArg(
|
|
|
|
|
"@N",
|
|
|
|
|
description="Select worker by index (1-based) and display full logs",
|
|
|
|
|
requires_db=True,
|
|
|
|
|
),
|
2025-12-29 18:42:02 -08:00
|
|
|
CmdletArg(
|
|
|
|
|
"-id",
|
|
|
|
|
description="Show full logs for a specific worker",
|
|
|
|
|
requires_db=True
|
|
|
|
|
),
|
2025-12-29 17:05:03 -08:00
|
|
|
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()
|
2025-12-29 18:42:02 -08:00
|
|
|
selection_requested = bool(selection_indices) and isinstance(result,
|
|
|
|
|
list
|
|
|
|
|
) and len(result) > 0
|
2025-12-07 00:21:30 -08:00
|
|
|
|
|
|
|
|
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()
|
2025-12-29 18:42:02 -08:00
|
|
|
if low in {"-limit",
|
|
|
|
|
"--limit"} and i + 1 < len(args_list):
|
2025-12-07 00:21:30 -08:00
|
|
|
options.limit = _normalize_limit(args_list[i + 1])
|
|
|
|
|
i += 2
|
2025-12-29 18:42:02 -08:00
|
|
|
elif low in {"-id",
|
|
|
|
|
"--id"} and i + 1 < len(args_list):
|
2025-12-07 00:21:30 -08:00
|
|
|
options.worker_id = args_list[i + 1]
|
|
|
|
|
i += 2
|
2025-12-29 18:42:02 -08:00
|
|
|
elif low in {"-clear",
|
|
|
|
|
"--clear"}:
|
2025-12-07 00:21:30 -08:00
|
|
|
options.clear = True
|
|
|
|
|
i += 1
|
2025-12-29 18:42:02 -08:00
|
|
|
elif low in {"-status",
|
|
|
|
|
"--status"} and i + 1 < len(args_list):
|
2025-12-07 00:21:30 -08:00
|
|
|
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:
|
2025-12-29 18:42:02 -08:00
|
|
|
workers = [
|
|
|
|
|
w for w in workers if str(w.get("status", "")).lower() == status_filter
|
|
|
|
|
]
|
2025-12-07 00:21:30 -08:00
|
|
|
|
|
|
|
|
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)
|
2026-01-01 20:37:27 -08:00
|
|
|
worker_id = str(worker.get("worker_id") or worker.get("id") or "unknown")
|
|
|
|
|
status = str(worker.get("status") or "unknown")
|
|
|
|
|
result_state = str(worker.get("result") or "")
|
|
|
|
|
status_label = status
|
|
|
|
|
if result_state and result_state.lower() not in {"", status.lower()}:
|
|
|
|
|
status_label = f"{status_label} ({result_state})"
|
|
|
|
|
pipe_display = _summarize_pipe(worker.get("pipe"))
|
|
|
|
|
error_message = _normalize_text(worker.get("error_message"))
|
|
|
|
|
description = _normalize_text(worker.get("description"))
|
|
|
|
|
|
|
|
|
|
columns = [
|
|
|
|
|
("ID", worker_id[:8]),
|
|
|
|
|
("Status", status_label),
|
|
|
|
|
("Pipe", pipe_display),
|
|
|
|
|
("Date", date_str),
|
|
|
|
|
("Start", start_time),
|
|
|
|
|
("End", end_time),
|
|
|
|
|
]
|
|
|
|
|
if error_message:
|
|
|
|
|
columns.append(("Error", error_message[:140]))
|
|
|
|
|
if description and description != error_message:
|
|
|
|
|
columns.append(("Details", description[:200]))
|
2025-12-07 00:21:30 -08:00
|
|
|
|
|
|
|
|
item = {
|
2026-01-01 20:37:27 -08:00
|
|
|
"columns": columns,
|
|
|
|
|
"__worker_metadata": worker,
|
|
|
|
|
"_selection_args": ["-id", worker.get("worker_id")],
|
2025-12-07 00:21:30 -08:00
|
|
|
}
|
|
|
|
|
ctx.emit(item)
|
2026-01-01 20:37:27 -08:00
|
|
|
log(
|
|
|
|
|
f"Worker {worker_id[:8]} status={status_label} pipe={pipe_display} "
|
|
|
|
|
f"error={error_message or 'none'}",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
2025-12-07 00:21:30 -08:00
|
|
|
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:
|
2025-12-29 17:05:03 -08:00
|
|
|
events = (
|
|
|
|
|
db.get_worker_events(worker.get("worker_id"))
|
2025-12-29 18:42:02 -08:00
|
|
|
if hasattr(db,
|
|
|
|
|
"get_worker_events") else []
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
2025-12-07 00:21:30 -08:00
|
|
|
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": [
|
2025-12-29 18:42:02 -08:00
|
|
|
("Time",
|
|
|
|
|
timestamp),
|
|
|
|
|
("Level",
|
|
|
|
|
level),
|
|
|
|
|
("Message",
|
|
|
|
|
message),
|
2025-12-07 00:21:30 -08:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
ctx.emit(item)
|
|
|
|
|
|
|
|
|
|
# Events are already always derived from stdout for now.
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
|
2026-01-01 20:37:27 -08:00
|
|
|
def _summarize_pipe(pipe_value: Any, limit: int = 200) -> str:
|
|
|
|
|
text = _normalize_text(pipe_value)
|
2025-12-07 00:21:30 -08:00
|
|
|
if not text:
|
|
|
|
|
return "(none)"
|
2026-01-01 20:37:27 -08:00
|
|
|
|
|
|
|
|
stage_count = text.count("|") + 1 if text else 0
|
|
|
|
|
display = text
|
|
|
|
|
if len(display) > limit:
|
|
|
|
|
trimmed = display[:max(limit - 3, 0)].rstrip()
|
|
|
|
|
if not trimmed:
|
|
|
|
|
trimmed = display[:limit]
|
|
|
|
|
display = f"{trimmed}..."
|
|
|
|
|
if stage_count > 1:
|
|
|
|
|
suffix = f" ({stage_count} stages)"
|
|
|
|
|
if not display.endswith("..."):
|
|
|
|
|
display = f"{display}{suffix}"
|
|
|
|
|
else:
|
|
|
|
|
display = f"{display}{suffix}"
|
|
|
|
|
return display
|
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
|
2026-01-01 20:37:27 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_text(value: Any) -> str:
|
|
|
|
|
text = str(value or "").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return ""
|
|
|
|
|
# collapse whitespace to keep table columns aligned
|
|
|
|
|
normalized = re.sub(r"\s+", " ", text)
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _truncate_text(value: str, limit: int) -> str:
|
|
|
|
|
if limit <= 0:
|
|
|
|
|
return ""
|
|
|
|
|
if len(value) <= limit:
|
|
|
|
|
return value
|
|
|
|
|
cutoff = max(limit - 3, 0)
|
|
|
|
|
trimmed = value[:cutoff].rstrip()
|
|
|
|
|
if not trimmed:
|
|
|
|
|
return value[:limit]
|
|
|
|
|
return f"{trimmed}..."
|