main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import argparse
  2. import os
  3. import threading
  4. import gc
  5. from typing import Any, Dict, Optional
  6. # Must be set before first CUDA context initialization.
  7. os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
  8. import toml
  9. from textual import work
  10. from textual.app import App, ComposeResult
  11. from textual.containers import Horizontal, Vertical
  12. from textual.widgets import (
  13. Button,
  14. Footer,
  15. Header,
  16. Input,
  17. Label,
  18. ProgressBar,
  19. RichLog,
  20. Rule,
  21. )
  22. from tasks import PIPELINE_TASKS, SCENARIOS, describe_protected_conflicts
  23. from util.config_manager import handle_directory_config
  24. from util.progress import MAX_LEVELS, ProgressTracker
  25. from util.screens import OverwriteConfirmScreen
  26. from util.ui_logger import PipelineLogger
  27. class CNNHarnessApp(App[None]):
  28. CSS_PATH = "style.tcss"
  29. def __init__(
  30. self,
  31. work_dir: Optional[str] = None,
  32. autorun: bool = False,
  33. autoquit: bool = False,
  34. force: bool = False,
  35. ):
  36. super().__init__()
  37. self.stop_event = threading.Event()
  38. self.pause_event = threading.Event()
  39. self.current_config: Dict[str, Any] = {}
  40. self.ui_logger = PipelineLogger(self, widget_id="#live_log")
  41. # CLI overrides. These force the corresponding behavior ON; when not
  42. # passed, the effective value falls back to the loaded config's [run]
  43. # section (see _effective_* helpers).
  44. self._cli_work_dir = work_dir
  45. self._cli_autorun = autorun
  46. self._cli_autoquit = autoquit
  47. self._cli_force = force
  48. self._last_pipeline_ok = False
  49. # ==========================================
  50. # UI Layout
  51. # ==========================================
  52. def compose(self) -> ComposeResult:
  53. yield Header()
  54. with Vertical(id="main_container"):
  55. # --- TOP SECTION: Two-Column Configuration ---
  56. with Horizontal(id="config_section"):
  57. # Left Column: Path entry and buttons
  58. with Vertical(id="config_left_col"):
  59. yield Label("Working Directory:")
  60. yield Input(placeholder="./outputs/experiment_1", id="work_dir")
  61. yield Button("Load / Init Dir", variant="primary", id="load_btn")
  62. yield Button("Reload Config", variant="default", id="reload_btn")
  63. # Right Column: Read-only parameters display
  64. with Vertical(id="config_right_col"):
  65. yield Label("Current Parameters:")
  66. yield RichLog(id="config_display", highlight=True, markup=True)
  67. yield Rule()
  68. # --- BOTTOM SECTION: Controls, Progress, and Logs ---
  69. with Horizontal(id="bottom_area"):
  70. # --- Pipeline Controls ---
  71. with Vertical(id="control_panel"):
  72. yield Button("Start Pipeline", variant="success", id="btn_start")
  73. with Horizontal(id="active_controls"):
  74. yield Button("Pause", variant="warning", id="btn_pause")
  75. yield Button("Stop", variant="error", id="btn_stop")
  76. # --- Progress Trackers ---
  77. with Vertical(id="progress_area"):
  78. for i in range(MAX_LEVELS):
  79. with Horizontal(
  80. id=f"progress_row_{i}", classes="progress_row"
  81. ):
  82. yield Label(
  83. id=f"progress_title_{i}", classes="progress_title"
  84. )
  85. yield ProgressBar(
  86. id=f"progress_bar_{i}",
  87. classes="progress_bar",
  88. show_eta=True,
  89. )
  90. yield Label(
  91. id=f"progress_stats_{i}", classes="progress_stats"
  92. )
  93. # --- Live Log Output ---
  94. with Vertical(id="log_panel"):
  95. with Horizontal(id="log_status_row"):
  96. yield Label("STATUS: READY", id="pipeline_status_label")
  97. with Horizontal(id="log_header_row"):
  98. yield Label("Live Logs:", classes="section_label")
  99. yield RichLog(id="live_log", highlight=True, markup=True)
  100. yield Footer()
  101. def on_mount(self) -> None:
  102. self.query_one("#active_controls").display = False
  103. self.ui_logger.info("Application initialized and ready.")
  104. # Script mode: a CLI work_dir auto-loads its config; autorun then starts
  105. # the pipeline automatically once the config is available.
  106. if self._cli_work_dir:
  107. self.query_one("#work_dir", Input).value = self._cli_work_dir
  108. if self._load_directory(self._cli_work_dir):
  109. if self._effective_autorun():
  110. self.call_after_refresh(self._request_start, False)
  111. elif self._effective_autoquit():
  112. # Load failed and we're running headless: exit with error code.
  113. self.call_after_refresh(self._quit_after_run, False)
  114. # ==========================================
  115. # Helper Methods
  116. # ==========================================
  117. def _set_pipeline_status(self, text: str) -> None:
  118. try:
  119. self.query_one("#pipeline_status_label", Label).update(text)
  120. except Exception:
  121. pass
  122. def _update_config_display(self) -> None:
  123. config_log = self.query_one("#config_display", RichLog)
  124. config_log.clear()
  125. if not self.current_config:
  126. config_log.write("[italic]No configuration loaded.[/italic]")
  127. return
  128. toml_string = toml.dumps(self.current_config)
  129. config_log.write(toml_string)
  130. def _toggle_config_inputs(self, disabled: bool) -> None:
  131. self.query_one("#work_dir", Input).disabled = disabled
  132. self.query_one("#load_btn", Button).disabled = disabled
  133. self.query_one("#reload_btn", Button).disabled = disabled
  134. # ------------------------------------------
  135. # Script-mode helpers (autorun / autoquit / force)
  136. # ------------------------------------------
  137. def _run_cfg(self) -> Dict[str, Any]:
  138. run = self.current_config.get("run", {}) if self.current_config else {}
  139. return run if isinstance(run, dict) else {}
  140. def _effective_autorun(self) -> bool:
  141. return self._cli_autorun or bool(self._run_cfg().get("autorun", False))
  142. def _effective_autoquit(self) -> bool:
  143. return self._cli_autoquit or bool(self._run_cfg().get("autoquit", False))
  144. def _effective_force(self) -> bool:
  145. return self._cli_force or bool(self._run_cfg().get("force_overwrite", False))
  146. def _load_directory(self, work_dir: str) -> bool:
  147. """Load-or-init a work_dir's config into current_config. Returns success."""
  148. success, message, config_data = handle_directory_config(work_dir)
  149. if success:
  150. self.current_config = config_data
  151. self.current_config["work_dir"] = work_dir
  152. self._update_config_display()
  153. self.ui_logger.info(message)
  154. return True
  155. self.ui_logger.error(message)
  156. return False
  157. def _quit_after_run(self, ok: bool) -> None:
  158. """Exit the app with a script-friendly return code (0 ok, 1 failure)."""
  159. self.exit(return_code=0 if ok else 1)
  160. def _request_start(self, interactive: bool) -> None:
  161. """Validate and start the pipeline. In non-interactive (autorun) mode a
  162. protected-overwrite conflict aborts unless force is set, since a modal
  163. prompt cannot be answered by a script."""
  164. if not self.current_config:
  165. self.ui_logger.error(
  166. "No configuration loaded! Please load a directory first."
  167. )
  168. if not interactive and self._effective_autoquit():
  169. self._quit_after_run(False)
  170. return
  171. scenario_id = self.current_config.get("scenario")
  172. if not scenario_id or scenario_id not in SCENARIOS:
  173. self.ui_logger.error(
  174. f"Invalid or missing scenario '{scenario_id}' in config.toml!"
  175. )
  176. if not interactive and self._effective_autoquit():
  177. self._quit_after_run(False)
  178. return
  179. work_dir = self.current_config["work_dir"]
  180. # Only PROTECTED (hard-to-regenerate) outputs trigger a warning. An
  181. # analysis-only rerun writes nothing protected, so it proceeds silently.
  182. conflicts = describe_protected_conflicts(work_dir, scenario_id)
  183. if conflicts and not self._effective_force():
  184. if interactive:
  185. def check_overwrite_callback(proceed: bool) -> None:
  186. if proceed:
  187. self._execute_pipeline()
  188. else:
  189. self.ui_logger.error("Pipeline start cancelled by user.")
  190. self.app.push_screen(
  191. OverwriteConfirmScreen(work_dir, conflicts),
  192. check_overwrite_callback, # pyright: ignore
  193. )
  194. return
  195. # Non-interactive with a protected conflict and no force: refuse.
  196. self.ui_logger.error(
  197. "Autorun aborted: scenario would overwrite protected outputs "
  198. f"({'; '.join(conflicts)}). Set run.force_overwrite = true or pass "
  199. "--force to allow."
  200. )
  201. if self._effective_autoquit():
  202. self._quit_after_run(False)
  203. return
  204. self._execute_pipeline()
  205. # ==========================================
  206. # Event Handlers
  207. # ==========================================
  208. def on_button_pressed(self, event: Button.Pressed) -> None:
  209. if event.button.id in ("load_btn", "reload_btn"):
  210. work_dir = self.query_one("#work_dir", Input).value
  211. self._load_directory(work_dir)
  212. elif event.button.id == "btn_start":
  213. self._request_start(interactive=True)
  214. elif event.button.id == "btn_pause":
  215. if self.pause_event.is_set():
  216. self.pause_event.clear()
  217. event.button.label = "Pause"
  218. event.button.variant = "warning"
  219. self._set_pipeline_status("[bold green]PIPELINE RUNNING[/bold green]")
  220. self.ui_logger.info("Pipeline Resumed.")
  221. else:
  222. self.pause_event.set()
  223. event.button.label = "Resume"
  224. event.button.variant = "success"
  225. self._set_pipeline_status("[bold yellow]PIPELINE PAUSED[/bold yellow]")
  226. self.ui_logger.info(
  227. "Pipeline Paused. Waiting for current operation to yield..."
  228. )
  229. elif event.button.id == "btn_stop":
  230. self._set_pipeline_status("[bold red]STOPPING PIPELINE...[/bold red]")
  231. self.ui_logger.error("Stop requested! Terminating gracefully...")
  232. event.button.disabled = True
  233. self.stop_event.set()
  234. # ==========================================
  235. # Pipeline Execution
  236. # ==========================================
  237. def _execute_pipeline(self) -> None:
  238. self._toggle_config_inputs(disabled=True)
  239. self.query_one("#btn_start").display = False
  240. self.query_one("#active_controls").display = True
  241. self._set_pipeline_status("[bold green]PIPELINE RUNNING[/bold green]")
  242. self.stop_event.clear()
  243. self.pause_event.clear()
  244. btn_pause = self.query_one("#btn_pause", Button)
  245. btn_pause.label = "Pause"
  246. btn_pause.variant = "warning"
  247. self.run_background_pipeline(self.current_config.copy())
  248. @work(exclusive=True, thread=True)
  249. def run_background_pipeline(self, config: Dict[str, Any]) -> None:
  250. scenario_id = config["scenario"]
  251. steps = SCENARIOS[scenario_id]["tasks"]
  252. scenario_label = SCENARIOS[scenario_id]["label"]
  253. root_tracker = ProgressTracker(self, level=0)
  254. pipeline_state: Dict[str, Any] = {
  255. "events": {"stop": self.stop_event, "pause": self.pause_event}
  256. }
  257. self._last_pipeline_ok = False
  258. try:
  259. self.ui_logger.info(f"Initiating Scenario: {scenario_label}")
  260. total_tasks = len(steps)
  261. root_tracker.reset()
  262. root_tracker.set_title("Pipeline Status")
  263. root_tracker.update(total=total_tasks, advance=0)
  264. for i, task_id in enumerate(steps):
  265. if self.stop_event.is_set():
  266. break
  267. task_name = PIPELINE_TASKS[task_id]["task_name"]
  268. task_func = PIPELINE_TASKS[task_id]["task_func"]
  269. task_logger = self.ui_logger.get_task_logger(task_name)
  270. self.ui_logger.info(f"Starting Task {i + 1}/{total_tasks}: {task_name}")
  271. task_tracker = root_tracker.get_sub_tracker(task_name)
  272. task_func(task_tracker, task_logger, config, pipeline_state)
  273. if not self.stop_event.is_set():
  274. task_logger.info("Task completed.")
  275. root_tracker.update(advance=1)
  276. if self.stop_event.is_set():
  277. self.call_from_thread(
  278. self._set_pipeline_status, "[bold red]PIPELINE STOPPED[/bold red]"
  279. )
  280. self.ui_logger.error("Pipeline stopped by user.")
  281. root_tracker.reset()
  282. else:
  283. self.call_from_thread(
  284. self._set_pipeline_status,
  285. "[bold blue]PIPELINE COMPLETE[/bold blue]",
  286. )
  287. self.ui_logger.info("All tasks finished successfully!")
  288. self._last_pipeline_ok = True
  289. except InterruptedError as e:
  290. self.ui_logger.error(f"STOPPED: {str(e)}")
  291. self.call_from_thread(
  292. self._set_pipeline_status, "[bold red]PIPELINE STOPPED[/bold red]"
  293. )
  294. root_tracker.reset()
  295. except Exception as e:
  296. self.ui_logger.file_logger.error(
  297. f"Pipeline failed: {str(e)}", exc_info=True
  298. )
  299. self.ui_logger.error(f"ERROR: {str(e)}")
  300. self.call_from_thread(
  301. self._set_pipeline_status, "[bold red]PIPELINE FAILED[/bold red]"
  302. )
  303. finally:
  304. pipeline_state.clear()
  305. gc.collect()
  306. self.call_from_thread(self._reset_ui)
  307. # Script mode: quit once the run finishes, with a return code that
  308. # reflects success/failure so it can be used in shell pipelines.
  309. if self._effective_autoquit():
  310. self.call_from_thread(self._quit_after_run, self._last_pipeline_ok)
  311. def _reset_ui(self) -> None:
  312. self.query_one("#btn_stop", Button).disabled = False
  313. self.query_one("#btn_start").display = True
  314. self.query_one("#active_controls").display = False
  315. self._toggle_config_inputs(disabled=False)
  316. def _parse_args() -> argparse.Namespace:
  317. parser = argparse.ArgumentParser(
  318. description=(
  319. "ALNN evaluation harness (TUI). Pass a working directory to auto-load "
  320. "it; combine with autorun/autoquit (CLI flags or the config [run] "
  321. "section) to use it as a simple script."
  322. )
  323. )
  324. parser.add_argument(
  325. "work_dir",
  326. nargs="?",
  327. default=None,
  328. help="Working/output directory to load on startup (its config.toml is "
  329. "loaded, or the default config is copied in if absent).",
  330. )
  331. parser.add_argument(
  332. "--autorun",
  333. action="store_true",
  334. help="Start the pipeline automatically once the config loads "
  335. "(overrides run.autorun).",
  336. )
  337. parser.add_argument(
  338. "--autoquit",
  339. action="store_true",
  340. help="Exit the app when the pipeline finishes, with a 0/1 return code "
  341. "(overrides run.autoquit).",
  342. )
  343. parser.add_argument(
  344. "--force",
  345. action="store_true",
  346. help="In autorun, overwrite protected outputs (models/evaluations) "
  347. "without prompting (overrides run.force_overwrite).",
  348. )
  349. return parser.parse_args()
  350. if __name__ == "__main__":
  351. args = _parse_args()
  352. app = CNNHarnessApp(
  353. work_dir=args.work_dir,
  354. autorun=args.autorun,
  355. autoquit=args.autoquit,
  356. force=args.force,
  357. )
  358. app.run()
  359. raise SystemExit(app.return_code or 0)