Files
Medios-Macina/TUI/pipeline_runner.py

326 lines
9.9 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
"""Pipeline execution utilities for the Textual UI.
2025-12-24 02:13:21 -08:00
The TUI is a frontend to the CLI, so it must use the same pipeline executor
implementation as the CLI (`CLI.PipelineExecutor`).
2025-11-25 20:09:33 -08:00
"""
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
from __future__ import annotations
import contextlib
import io
import shlex
import sys
from pathlib import Path
2025-12-24 02:13:21 -08:00
from dataclasses import dataclass, field
2025-11-25 20:09:33 -08:00
from typing import Any, Callable, Dict, List, Optional, Sequence
BASE_DIR = Path(__file__).resolve().parent
ROOT_DIR = BASE_DIR.parent
for path in (ROOT_DIR, BASE_DIR):
str_path = str(path)
if str_path not in sys.path:
sys.path.insert(0, str_path)
import pipeline as ctx
2025-12-24 02:13:21 -08:00
from CLI import ConfigLoader, PipelineExecutor as CLIPipelineExecutor, WorkerManagerRegistry
from SYS.logger import set_debug
from rich_display import capture_rich_output
from result_table import ResultTable
2025-11-25 20:09:33 -08:00
@dataclass(slots=True)
class PipelineStageResult:
"""Summary for a single pipeline stage."""
name: str
args: Sequence[str]
emitted: List[Any] = field(default_factory=list)
2025-11-27 10:59:01 -08:00
result_table: Optional[Any] = None # ResultTable object if available
2025-11-25 20:09:33 -08:00
status: str = "pending"
error: Optional[str] = None
@dataclass(slots=True)
class PipelineRunResult:
"""Aggregate result for a pipeline run."""
pipeline: str
success: bool
stages: List[PipelineStageResult] = field(default_factory=list)
emitted: List[Any] = field(default_factory=list)
2025-11-27 10:59:01 -08:00
result_table: Optional[Any] = None # Final ResultTable object if available
2025-11-25 20:09:33 -08:00
stdout: str = ""
stderr: str = ""
error: Optional[str] = None
def to_summary(self) -> Dict[str, Any]:
"""Provide a JSON-friendly representation for logging or UI."""
return {
"pipeline":
self.pipeline,
"success":
self.success,
"error":
self.error,
2025-11-25 20:09:33 -08:00
"stages": [
{
"name": stage.name,
"status": stage.status,
"error": stage.error,
"emitted": len(stage.emitted),
} for stage in self.stages
2025-11-25 20:09:33 -08:00
],
}
2025-12-24 02:13:21 -08:00
class PipelineRunner:
"""TUI wrapper that delegates to the canonical CLI pipeline executor."""
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
def __init__(self) -> None:
self._config_loader = ConfigLoader(root=ROOT_DIR)
self._executor = CLIPipelineExecutor(config_loader=self._config_loader)
self._worker_manager = None
2025-11-25 20:09:33 -08:00
@property
2025-12-24 02:13:21 -08:00
def worker_manager(self):
2025-11-25 20:09:33 -08:00
return self._worker_manager
def run_pipeline(
self,
pipeline_text: str,
*,
2025-12-17 17:42:46 -08:00
seeds: Optional[Any] = None,
2025-12-24 02:13:21 -08:00
isolate: bool = False,
on_log: Optional[Callable[[str],
None]] = None,
2025-11-25 20:09:33 -08:00
) -> PipelineRunResult:
2025-12-24 02:13:21 -08:00
snapshot: Optional[Dict[str, Any]] = None
if isolate:
snapshot = self._snapshot_ctx_state()
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
normalized = str(pipeline_text or "").strip()
2025-11-25 20:09:33 -08:00
result = PipelineRunResult(pipeline=normalized, success=False)
if not normalized:
result.error = "Pipeline is empty"
return result
2025-12-24 02:13:21 -08:00
try:
from SYS.cli_syntax import validate_pipeline_text
2025-12-24 02:13:21 -08:00
syntax_error = validate_pipeline_text(normalized)
if syntax_error:
result.error = syntax_error.message
result.stderr = syntax_error.message
return result
except Exception:
pass
try:
tokens = shlex.split(normalized)
except Exception as exc:
result.error = f"Syntax error: {exc}"
result.stderr = result.error
return result
if not tokens:
result.error = "Pipeline contains no tokens"
2025-11-25 20:09:33 -08:00
return result
2025-12-24 02:13:21 -08:00
config = self._config_loader.load()
try:
set_debug(bool(config.get("debug", False)))
except Exception:
pass
try:
self._worker_manager = WorkerManagerRegistry.ensure(config)
except Exception:
self._worker_manager = None
2025-11-25 20:09:33 -08:00
ctx.reset()
ctx.set_current_command_text(normalized)
2025-12-17 17:42:46 -08:00
if seeds is not None:
try:
if not isinstance(seeds, list):
seeds = [seeds]
2025-12-24 02:13:21 -08:00
ctx.set_last_result_items_only(list(seeds))
2025-12-17 17:42:46 -08:00
except Exception:
pass
2025-11-25 20:09:33 -08:00
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
2025-12-24 02:13:21 -08:00
with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer):
2025-12-29 17:05:03 -08:00
with (
contextlib.redirect_stdout(stdout_buffer),
contextlib.redirect_stderr(stderr_buffer),
2025-12-29 17:05:03 -08:00
):
2025-12-24 02:13:21 -08:00
if on_log:
on_log("Executing pipeline via CLI executor...")
self._executor.execute_tokens(list(tokens))
except Exception as exc:
result.error = f"{type(exc).__name__}: {exc}"
2025-11-25 20:09:33 -08:00
finally:
2025-12-24 02:13:21 -08:00
try:
ctx.clear_current_command_text()
except Exception:
pass
2025-11-25 20:09:33 -08:00
result.stdout = stdout_buffer.getvalue()
result.stderr = stderr_buffer.getvalue()
2025-12-24 02:13:21 -08:00
# Pull the canonical state out of pipeline context.
table = None
2025-11-25 20:09:33 -08:00
try:
2025-12-29 17:05:03 -08:00
table = (
ctx.get_display_table() or ctx.get_current_stage_table()
2025-12-29 17:05:03 -08:00
or ctx.get_last_result_table()
)
2025-12-24 02:13:21 -08:00
except Exception:
table = None
items: List[Any] = []
2025-11-25 20:09:33 -08:00
try:
2025-12-24 02:13:21 -08:00
items = list(ctx.get_last_result_items() or [])
2025-11-25 20:09:33 -08:00
except Exception:
2025-12-24 02:13:21 -08:00
items = []
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
if table is None and items:
try:
synth = ResultTable("Results")
for item in items:
synth.add_result(item)
table = synth
except Exception:
table = None
result.emitted = items
result.result_table = table
combined = (result.stdout + "\n" + result.stderr).strip().lower()
failure_markers = (
"unknown command:",
"pipeline order error:",
"invalid selection:",
"invalid pipeline syntax",
"failed to execute pipeline",
"[error]",
)
if result.error:
result.success = False
elif any(m in combined for m in failure_markers):
result.success = False
if not result.error:
result.error = "Pipeline failed"
else:
result.success = True
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
if isolate and snapshot is not None:
try:
self._restore_ctx_state(snapshot)
except Exception:
# Best-effort; isolation should never break normal operation.
pass
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
return result
2025-11-25 20:09:33 -08:00
@staticmethod
2025-12-24 02:13:21 -08:00
def _snapshot_ctx_state() -> Dict[str, Any]:
"""Best-effort snapshot of pipeline context so TUI popups don't clobber UI state."""
def _copy(val: Any) -> Any:
if isinstance(val, list):
return val.copy()
if isinstance(val, dict):
return val.copy()
return val
snap: Dict[str,
Any] = {}
2025-12-24 02:13:21 -08:00
keys = [
"_LIVE_PROGRESS",
"_CURRENT_CONTEXT",
"_LAST_SEARCH_QUERY",
"_PIPELINE_REFRESHED",
"_PIPELINE_LAST_ITEMS",
"_LAST_RESULT_TABLE",
"_LAST_RESULT_ITEMS",
"_LAST_RESULT_SUBJECT",
"_RESULT_TABLE_HISTORY",
"_RESULT_TABLE_FORWARD",
"_CURRENT_STAGE_TABLE",
"_DISPLAY_ITEMS",
"_DISPLAY_TABLE",
"_DISPLAY_SUBJECT",
"_PIPELINE_LAST_SELECTION",
"_PIPELINE_COMMAND_TEXT",
"_CURRENT_CMDLET_NAME",
"_CURRENT_STAGE_TEXT",
"_PIPELINE_VALUES",
"_PENDING_PIPELINE_TAIL",
"_PENDING_PIPELINE_SOURCE",
"_UI_LIBRARY_REFRESH_CALLBACK",
]
for k in keys:
snap[k] = _copy(getattr(ctx, k, None))
# Deepen copies where nested lists are common.
try:
hist = list(getattr(ctx, "_RESULT_TABLE_HISTORY", []) or [])
snap["_RESULT_TABLE_HISTORY"] = [
2025-12-29 17:05:03 -08:00
(
t,
(
items.copy()
if isinstance(items,
list) else list(items) if items else []
),
2025-12-29 17:05:03 -08:00
subj,
) for (t, items, subj) in hist if isinstance((t, items, subj), tuple)
2025-12-24 02:13:21 -08:00
]
except Exception:
pass
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
try:
fwd = list(getattr(ctx, "_RESULT_TABLE_FORWARD", []) or [])
snap["_RESULT_TABLE_FORWARD"] = [
2025-12-29 17:05:03 -08:00
(
t,
(
items.copy()
if isinstance(items,
list) else list(items) if items else []
),
2025-12-29 17:05:03 -08:00
subj,
) for (t, items, subj) in fwd if isinstance((t, items, subj), tuple)
2025-12-24 02:13:21 -08:00
]
except Exception:
pass
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
try:
tail = list(getattr(ctx, "_PENDING_PIPELINE_TAIL", []) or [])
2025-12-29 17:05:03 -08:00
snap["_PENDING_PIPELINE_TAIL"] = [
list(stage) for stage in tail if isinstance(stage, list)
]
2025-12-24 02:13:21 -08:00
except Exception:
pass
2025-11-25 20:09:33 -08:00
try:
2025-12-24 02:13:21 -08:00
values = getattr(ctx, "_PIPELINE_VALUES", None)
if isinstance(values, dict):
snap["_PIPELINE_VALUES"] = values.copy()
2025-11-25 20:09:33 -08:00
except Exception:
pass
2025-12-24 02:13:21 -08:00
return snap
@staticmethod
def _restore_ctx_state(snapshot: Dict[str, Any]) -> None:
for k, v in (snapshot or {}).items():
try:
setattr(ctx, k, v)
except Exception:
pass