326 lines
9.9 KiB
Python
326 lines
9.9 KiB
Python
"""Pipeline execution utilities for the Textual UI.
|
|
|
|
The TUI is a frontend to the CLI, so it must use the same pipeline executor
|
|
implementation as the CLI (`CLI.PipelineExecutor`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import shlex
|
|
import sys
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, field
|
|
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
|
|
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
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PipelineStageResult:
|
|
"""Summary for a single pipeline stage."""
|
|
|
|
name: str
|
|
args: Sequence[str]
|
|
emitted: List[Any] = field(default_factory=list)
|
|
result_table: Optional[Any] = None # ResultTable object if available
|
|
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)
|
|
result_table: Optional[Any] = None # Final ResultTable object if available
|
|
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,
|
|
"stages": [
|
|
{
|
|
"name": stage.name,
|
|
"status": stage.status,
|
|
"error": stage.error,
|
|
"emitted": len(stage.emitted),
|
|
} for stage in self.stages
|
|
],
|
|
}
|
|
|
|
|
|
class PipelineRunner:
|
|
"""TUI wrapper that delegates to the canonical CLI pipeline executor."""
|
|
|
|
def __init__(self) -> None:
|
|
self._config_loader = ConfigLoader(root=ROOT_DIR)
|
|
self._executor = CLIPipelineExecutor(config_loader=self._config_loader)
|
|
self._worker_manager = None
|
|
|
|
@property
|
|
def worker_manager(self):
|
|
return self._worker_manager
|
|
|
|
def run_pipeline(
|
|
self,
|
|
pipeline_text: str,
|
|
*,
|
|
seeds: Optional[Any] = None,
|
|
isolate: bool = False,
|
|
on_log: Optional[Callable[[str],
|
|
None]] = None,
|
|
) -> PipelineRunResult:
|
|
snapshot: Optional[Dict[str, Any]] = None
|
|
if isolate:
|
|
snapshot = self._snapshot_ctx_state()
|
|
|
|
normalized = str(pipeline_text or "").strip()
|
|
result = PipelineRunResult(pipeline=normalized, success=False)
|
|
if not normalized:
|
|
result.error = "Pipeline is empty"
|
|
return result
|
|
|
|
try:
|
|
from SYS.cli_syntax import validate_pipeline_text
|
|
|
|
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"
|
|
return result
|
|
|
|
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
|
|
|
|
ctx.reset()
|
|
ctx.set_current_command_text(normalized)
|
|
|
|
if seeds is not None:
|
|
try:
|
|
if not isinstance(seeds, list):
|
|
seeds = [seeds]
|
|
ctx.set_last_result_items_only(list(seeds))
|
|
except Exception:
|
|
pass
|
|
|
|
stdout_buffer = io.StringIO()
|
|
stderr_buffer = io.StringIO()
|
|
|
|
try:
|
|
with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer):
|
|
with (
|
|
contextlib.redirect_stdout(stdout_buffer),
|
|
contextlib.redirect_stderr(stderr_buffer),
|
|
):
|
|
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}"
|
|
finally:
|
|
try:
|
|
ctx.clear_current_command_text()
|
|
except Exception:
|
|
pass
|
|
result.stdout = stdout_buffer.getvalue()
|
|
result.stderr = stderr_buffer.getvalue()
|
|
|
|
# Pull the canonical state out of pipeline context.
|
|
table = None
|
|
try:
|
|
table = (
|
|
ctx.get_display_table() or ctx.get_current_stage_table()
|
|
or ctx.get_last_result_table()
|
|
)
|
|
except Exception:
|
|
table = None
|
|
|
|
items: List[Any] = []
|
|
try:
|
|
items = list(ctx.get_last_result_items() or [])
|
|
except Exception:
|
|
items = []
|
|
|
|
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
|
|
|
|
if isolate and snapshot is not None:
|
|
try:
|
|
self._restore_ctx_state(snapshot)
|
|
except Exception:
|
|
# Best-effort; isolation should never break normal operation.
|
|
pass
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
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] = {}
|
|
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"] = [
|
|
(
|
|
t,
|
|
(
|
|
items.copy()
|
|
if isinstance(items,
|
|
list) else list(items) if items else []
|
|
),
|
|
subj,
|
|
) for (t, items, subj) in hist if isinstance((t, items, subj), tuple)
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
fwd = list(getattr(ctx, "_RESULT_TABLE_FORWARD", []) or [])
|
|
snap["_RESULT_TABLE_FORWARD"] = [
|
|
(
|
|
t,
|
|
(
|
|
items.copy()
|
|
if isinstance(items,
|
|
list) else list(items) if items else []
|
|
),
|
|
subj,
|
|
) for (t, items, subj) in fwd if isinstance((t, items, subj), tuple)
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
tail = list(getattr(ctx, "_PENDING_PIPELINE_TAIL", []) or [])
|
|
snap["_PENDING_PIPELINE_TAIL"] = [
|
|
list(stage) for stage in tail if isinstance(stage, list)
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
values = getattr(ctx, "_PIPELINE_VALUES", None)
|
|
if isinstance(values, dict):
|
|
snap["_PIPELINE_VALUES"] = values.copy()
|
|
except Exception:
|
|
pass
|
|
|
|
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
|