Coverage for openhcs/pyqt_gui/widgets/plate_manager.py: 0.0%
751 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""
2Plate Manager Widget for PyQt6
4Manages plate selection, initialization, and execution with full feature parity
5to the Textual TUI version. Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9import asyncio
10import inspect
11import copy
12import sys
13import subprocess
14import tempfile
15from typing import List, Dict, Optional, Callable
16from pathlib import Path
18from PyQt6.QtWidgets import (
19 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QListWidget,
20 QListWidgetItem, QLabel, QMessageBox, QFileDialog, QProgressBar,
21 QCheckBox, QFrame, QSplitter
22)
23from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QThread
24from PyQt6.QtGui import QFont
26from openhcs.core.config import GlobalPipelineConfig
27from openhcs.core.config import PipelineConfig
28from openhcs.io.filemanager import FileManager
29from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator, OrchestratorState
30from openhcs.core.pipeline import Pipeline
31from openhcs.constants.constants import VariableComponents, GroupBy
32from openhcs.pyqt_gui.widgets.mixins import (
33 preserve_selection_during_update,
34 handle_selection_change_with_prevention
35)
36from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
37from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
39logger = logging.getLogger(__name__)
42class PlateManagerWidget(QWidget):
43 """
44 PyQt6 Plate Manager Widget.
46 Manages plate selection, initialization, compilation, and execution.
47 Preserves all business logic from Textual version with clean PyQt6 UI.
48 """
50 # Signals
51 plate_selected = pyqtSignal(str) # plate_path
52 status_message = pyqtSignal(str) # status message
53 orchestrator_state_changed = pyqtSignal(str, str) # plate_path, state
54 orchestrator_config_changed = pyqtSignal(str, object) # plate_path, effective_config
56 # Configuration change signals for tier 3 UI-code conversion
57 global_config_changed = pyqtSignal() # global config updated
58 pipeline_data_changed = pyqtSignal() # pipeline data updated
60 # Log viewer integration signals
61 subprocess_log_started = pyqtSignal(str) # base_log_path
62 subprocess_log_stopped = pyqtSignal()
63 clear_subprocess_logs = pyqtSignal()
65 # Progress update signals (thread-safe UI updates)
66 progress_started = pyqtSignal(int) # max_value
67 progress_updated = pyqtSignal(int) # current_value
68 progress_finished = pyqtSignal()
70 # Error handling signals (thread-safe error reporting)
71 compilation_error = pyqtSignal(str, str) # plate_name, error_message
72 initialization_error = pyqtSignal(str, str) # plate_name, error_message
74 def __init__(self, file_manager: FileManager, service_adapter,
75 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
76 """
77 Initialize the plate manager widget.
79 Args:
80 file_manager: FileManager instance for file operations
81 service_adapter: PyQt service adapter for dialogs and operations
82 color_scheme: Color scheme for styling (optional, uses service adapter if None)
83 parent: Parent widget
84 """
85 super().__init__(parent)
87 # Core dependencies
88 self.file_manager = file_manager
89 self.service_adapter = service_adapter
90 self.global_config = service_adapter.get_global_config()
91 self.pipeline_editor = None # Will be set by main window
93 # Initialize color scheme and style generator
94 self.color_scheme = color_scheme or service_adapter.get_current_color_scheme()
95 self.style_generator = StyleSheetGenerator(self.color_scheme)
97 # Business logic state (extracted from Textual version)
98 self.plates: List[Dict] = [] # List of plate dictionaries
99 self.selected_plate_path: str = ""
100 self.orchestrators: Dict[str, PipelineOrchestrator] = {}
101 self.plate_configs: Dict[str, Dict] = {}
102 self.plate_compiled_data: Dict[str, tuple] = {} # Store compiled pipeline data
103 self.current_process = None
104 self.execution_state = "idle"
105 self.log_file_path: Optional[str] = None
106 self.log_file_position: int = 0
108 # UI components
109 self.plate_list: Optional[QListWidget] = None
110 self.buttons: Dict[str, QPushButton] = {}
111 self.status_label: Optional[QLabel] = None
112 self.progress_bar: Optional[QProgressBar] = None
114 # Setup UI
115 self.setup_ui()
116 self.setup_connections()
117 self.update_button_states()
119 logger.debug("Plate manager widget initialized")
121 # ========== UI Setup ==========
123 def setup_ui(self):
124 """Setup the user interface."""
125 layout = QVBoxLayout(self)
126 layout.setContentsMargins(2, 2, 2, 2)
127 layout.setSpacing(2)
129 # Title
130 title_label = QLabel("Plate Manager")
131 title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
132 title_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; padding: 5px;")
133 layout.addWidget(title_label)
135 # Main content splitter
136 splitter = QSplitter(Qt.Orientation.Vertical)
137 layout.addWidget(splitter)
139 # Plate list
140 self.plate_list = QListWidget()
141 self.plate_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
142 # Apply explicit styling to plate list for consistent background
143 self.plate_list.setStyleSheet(f"""
144 QListWidget {{
145 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
146 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
147 border: none;
148 padding: 5px;
149 }}
150 QListWidget::item {{
151 padding: 8px;
152 border: none;
153 border-radius: 3px;
154 margin: 2px;
155 }}
156 QListWidget::item:selected {{
157 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
158 color: {self.color_scheme.to_hex(self.color_scheme.selection_text)};
159 }}
160 QListWidget::item:hover {{
161 background-color: {self.color_scheme.to_hex(self.color_scheme.hover_bg)};
162 }}
163 """)
164 # Apply centralized styling to main widget
165 self.setStyleSheet(self.style_generator.generate_plate_manager_style())
166 splitter.addWidget(self.plate_list)
168 # Button panel
169 button_panel = self.create_button_panel()
170 splitter.addWidget(button_panel)
172 # Status section
173 status_frame = self.create_status_section()
174 layout.addWidget(status_frame)
176 # Set splitter proportions - make button panel much smaller
177 splitter.setSizes([400, 80])
179 def create_button_panel(self) -> QWidget:
180 """
181 Create the button panel with all plate management actions.
183 Returns:
184 Widget containing action buttons
185 """
186 panel = QWidget()
187 # Set consistent background
188 panel.setStyleSheet(f"""
189 QWidget {{
190 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
191 border: none;
192 padding: 0px;
193 }}
194 """)
196 layout = QVBoxLayout(panel)
197 layout.setContentsMargins(0, 0, 0, 0)
198 layout.setSpacing(2)
200 # Button configurations (extracted from Textual version)
201 button_configs = [
202 ("Add", "add_plate", "Add new plate directory"),
203 ("Del", "del_plate", "Delete selected plates"),
204 ("Edit", "edit_config", "Edit plate configuration"),
205 ("Init", "init_plate", "Initialize selected plates"),
206 ("Compile", "compile_plate", "Compile plate pipelines"),
207 ("Run", "run_plate", "Run/Stop plate execution"),
208 ("Code", "code_plate", "Generate Python code"),
209 ("Save", "save_python_script", "Save Python script"),
210 ]
212 # Create buttons in rows
213 for i in range(0, len(button_configs), 4):
214 row_layout = QHBoxLayout()
215 row_layout.setContentsMargins(2, 2, 2, 2)
216 row_layout.setSpacing(2)
218 for j in range(4):
219 if i + j < len(button_configs):
220 name, action, tooltip = button_configs[i + j]
222 button = QPushButton(name)
223 button.setToolTip(tooltip)
224 button.setMinimumHeight(30)
225 # Apply explicit button styling to ensure it works
226 button.setStyleSheet(self.style_generator.generate_button_style())
228 # Connect button to action
229 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a))
231 self.buttons[action] = button
232 row_layout.addWidget(button)
233 else:
234 row_layout.addStretch()
236 layout.addLayout(row_layout)
238 # Set maximum height to constrain the button panel
239 panel.setMaximumHeight(80)
241 return panel
243 def create_status_section(self) -> QWidget:
244 """
245 Create the status section with progress bar and status label.
247 Returns:
248 Widget containing status information
249 """
250 frame = QWidget()
251 # Set consistent background
252 frame.setStyleSheet(f"""
253 QWidget {{
254 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
255 border: none;
256 padding: 2px;
257 }}
258 """)
260 layout = QVBoxLayout(frame)
261 layout.setContentsMargins(2, 2, 2, 2)
262 layout.setSpacing(2)
264 # Status label
265 self.status_label = QLabel("Ready")
266 self.status_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_success)}; font-weight: bold;")
267 layout.addWidget(self.status_label)
269 # Progress bar
270 self.progress_bar = QProgressBar()
271 self.progress_bar.setVisible(False)
272 # Progress bar styling is handled by the main widget stylesheet
273 layout.addWidget(self.progress_bar)
275 return frame
277 def setup_connections(self):
278 """Setup signal/slot connections."""
279 # Plate list selection
280 self.plate_list.itemSelectionChanged.connect(self.on_selection_changed)
281 self.plate_list.itemDoubleClicked.connect(self.on_item_double_clicked)
283 # Internal signals
284 self.status_message.connect(self.update_status)
285 self.orchestrator_state_changed.connect(self.on_orchestrator_state_changed)
287 # Progress signals for thread-safe UI updates
288 self.progress_started.connect(self._on_progress_started)
289 self.progress_updated.connect(self._on_progress_updated)
290 self.progress_finished.connect(self._on_progress_finished)
292 # Error handling signals for thread-safe error reporting
293 self.compilation_error.connect(self._handle_compilation_error)
294 self.initialization_error.connect(self._handle_initialization_error)
296 def handle_button_action(self, action: str):
297 """
298 Handle button actions (extracted from Textual version).
300 Args:
301 action: Action identifier
302 """
303 # Action mapping (preserved from Textual version)
304 action_map = {
305 "add_plate": self.action_add_plate,
306 "del_plate": self.action_delete_plate,
307 "edit_config": self.action_edit_config,
308 "init_plate": self.action_init_plate,
309 "compile_plate": self.action_compile_plate,
310 "code_plate": self.action_code_plate,
311 "save_python_script": self.action_save_python_script,
312 }
314 if action in action_map:
315 action_func = action_map[action]
317 # Handle async actions
318 if inspect.iscoroutinefunction(action_func):
319 self.run_async_action(action_func)
320 else:
321 action_func()
322 elif action == "run_plate":
323 if self.is_any_plate_running():
324 self.run_async_action(self.action_stop_execution)
325 else:
326 self.run_async_action(self.action_run_plate)
327 else:
328 logger.warning(f"Unknown action: {action}")
330 def run_async_action(self, async_func: Callable):
331 """
332 Run async action using service adapter.
334 Args:
335 async_func: Async function to execute
336 """
337 self.service_adapter.execute_async_operation(async_func)
339 def _update_orchestrator_global_config(self, orchestrator, new_global_config):
340 """Update orchestrator's global config reference and rebuild pipeline config if needed."""
341 from openhcs.config_framework.lazy_factory import rebuild_lazy_config_with_new_global_reference
342 from openhcs.core.config import GlobalPipelineConfig
344 # SIMPLIFIED: Update shared global context (dual-axis resolver handles context)
345 from openhcs.config_framework.lazy_factory import ensure_global_config_context
346 ensure_global_config_context(GlobalPipelineConfig, new_global_config)
348 # Rebuild orchestrator-specific config if it exists
349 if orchestrator.pipeline_config is not None:
350 orchestrator.pipeline_config = rebuild_lazy_config_with_new_global_reference(
351 orchestrator.pipeline_config,
352 new_global_config,
353 GlobalPipelineConfig
354 )
355 logger.info(f"Rebuilt orchestrator-specific config for plate: {orchestrator.plate_path}")
357 # Get effective config and emit signal for UI refresh
358 effective_config = orchestrator.get_effective_config()
359 self.orchestrator_config_changed.emit(str(orchestrator.plate_path), effective_config)
361 # ========== Business Logic Methods (Extracted from Textual) ==========
363 def action_add_plate(self):
364 """Handle Add Plate button (adapted from Textual version)."""
365 from openhcs.core.path_cache import PathCacheKey
367 # Use cached directory dialog (mirrors Textual TUI pattern)
368 directory_path = self.service_adapter.show_cached_directory_dialog(
369 cache_key=PathCacheKey.PLATE_IMPORT,
370 title="Select Plate Directory",
371 fallback_path=Path.home()
372 )
374 if directory_path:
375 self.add_plate_callback([directory_path])
377 def add_plate_callback(self, selected_paths: List[Path]):
378 """
379 Handle plate directory selection (extracted from Textual version).
381 Args:
382 selected_paths: List of selected directory paths
383 """
384 if not selected_paths:
385 self.status_message.emit("Plate selection cancelled")
386 return
388 added_plates = []
390 for selected_path in selected_paths:
391 # Check if plate already exists
392 if any(plate['path'] == str(selected_path) for plate in self.plates):
393 continue
395 # Add the plate to the list
396 plate_name = selected_path.name
397 plate_path = str(selected_path)
398 plate_entry = {
399 'name': plate_name,
400 'path': plate_path,
401 }
403 self.plates.append(plate_entry)
404 added_plates.append(plate_name)
406 if added_plates:
407 self.update_plate_list()
408 self.status_message.emit(f"Added {len(added_plates)} plate(s): {', '.join(added_plates)}")
409 else:
410 self.status_message.emit("No new plates added (duplicates skipped)")
412 def action_delete_plate(self):
413 """Handle Delete Plate button (extracted from Textual version)."""
414 selected_items = self.get_selected_plates()
415 if not selected_items:
416 self.service_adapter.show_error_dialog("No plate selected to delete.")
417 return
419 paths_to_delete = {p['path'] for p in selected_items}
420 self.plates = [p for p in self.plates if p['path'] not in paths_to_delete]
422 # Clean up orchestrators for deleted plates
423 for path in paths_to_delete:
424 if path in self.orchestrators:
425 del self.orchestrators[path]
427 if self.selected_plate_path in paths_to_delete:
428 self.selected_plate_path = ""
429 # Notify pipeline editor that no plate is selected (mirrors Textual TUI)
430 self.plate_selected.emit("")
432 self.update_plate_list()
433 self.status_message.emit(f"Deleted {len(paths_to_delete)} plate(s)")
435 def _validate_plates_for_operation(self, plates, operation_type):
436 """Unified functional validator for all plate operations."""
437 # Functional validation mapping
438 validators = {
439 'init': lambda p: True, # Init can work on any plates
440 'compile': lambda p: (
441 self.orchestrators.get(p['path']) and
442 self._get_current_pipeline_definition(p['path'])
443 ),
444 'run': lambda p: (
445 self.orchestrators.get(p['path']) and
446 self.orchestrators[p['path']].state in ['COMPILED', 'COMPLETED']
447 )
448 }
450 # Functional pattern: filter invalid plates in one pass
451 validator = validators.get(operation_type, lambda p: True)
452 return [p for p in plates if not validator(p)]
454 async def action_init_plate(self):
455 """Handle Initialize Plate button with unified validation."""
456 # CRITICAL: Set up global context in worker thread
457 # The service adapter runs this entire function in a worker thread,
458 # so we need to establish the global context here
459 from openhcs.config_framework.lazy_factory import ensure_global_config_context
460 from openhcs.core.config import GlobalPipelineConfig
461 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
463 selected_items = self.get_selected_plates()
465 # Unified validation - let it fail if no plates
466 invalid_plates = self._validate_plates_for_operation(selected_items, 'init')
468 self.progress_started.emit(len(selected_items))
470 # Functional pattern: async map with enumerate
471 async def init_single_plate(i, plate):
472 plate_path = plate['path']
473 # Create orchestrator in main thread (has access to global context)
474 orchestrator = PipelineOrchestrator(
475 plate_path=plate_path,
476 storage_registry=self.file_manager.registry
477 )
478 # Only run heavy initialization in worker thread
479 # Need to set up context in worker thread too since initialize() runs there
480 def initialize_with_context():
481 from openhcs.config_framework.lazy_factory import ensure_global_config_context
482 from openhcs.core.config import GlobalPipelineConfig
483 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
484 return orchestrator.initialize()
486 await asyncio.get_event_loop().run_in_executor(
487 None,
488 initialize_with_context
489 )
491 self.orchestrators[plate_path] = orchestrator
492 self.orchestrator_state_changed.emit(plate_path, "READY")
494 if not self.selected_plate_path:
495 self.selected_plate_path = plate_path
496 self.plate_selected.emit(plate_path)
498 self.progress_updated.emit(i + 1)
500 # Process all plates functionally
501 await asyncio.gather(*[
502 init_single_plate(i, plate)
503 for i, plate in enumerate(selected_items)
504 ])
506 self.progress_finished.emit()
507 self.status_message.emit(f"Initialized {len(selected_items)} plate(s)")
509 # Additional action methods would be implemented here following the same pattern...
510 # (compile_plate, run_plate, code_plate, save_python_script, edit_config)
512 def action_edit_config(self):
513 """
514 Handle Edit Config button - create per-orchestrator PipelineConfig instances.
516 This enables per-orchestrator configuration without affecting global configuration.
517 Shows resolved defaults from GlobalPipelineConfig with "Pipeline default: {value}" placeholders.
518 """
519 selected_items = self.get_selected_plates()
521 if not selected_items:
522 self.service_adapter.show_error_dialog("No plates selected for configuration.")
523 return
525 # Get selected orchestrators
526 selected_orchestrators = [
527 self.orchestrators[item['path']] for item in selected_items
528 if item['path'] in self.orchestrators
529 ]
531 if not selected_orchestrators:
532 self.service_adapter.show_error_dialog("No initialized orchestrators selected.")
533 return
535 # Load existing config or create new one for editing
536 representative_orchestrator = selected_orchestrators[0]
538 # CRITICAL FIX: Don't change thread-local context - preserve orchestrator context
539 # The config window should work with the current orchestrator context
540 # Reset behavior will be handled differently to avoid corrupting step editor context
542 # CRITICAL FIX: Create PipelineConfig that preserves user-set values but shows placeholders for inherited fields
543 # The orchestrator's pipeline_config has concrete values filled in from global config inheritance,
544 # but we need to distinguish between user-set values (keep concrete) and inherited values (show as placeholders)
545 from openhcs.config_framework.lazy_factory import create_dataclass_for_editing
546 from dataclasses import fields
548 # CRITICAL FIX: Create config for editing that preserves user values while showing placeholders for inherited fields
549 if representative_orchestrator.pipeline_config is not None:
550 # Orchestrator has existing config - preserve explicitly set fields, reset others to None for placeholders
551 existing_config = representative_orchestrator.pipeline_config
552 explicitly_set_fields = getattr(existing_config, '_explicitly_set_fields', set())
554 # Create field values: keep explicitly set values, use None for inherited fields
555 field_values = {}
556 for field in fields(PipelineConfig):
557 if field.name in explicitly_set_fields:
558 # User explicitly set this field - preserve the concrete value
559 field_values[field.name] = object.__getattribute__(existing_config, field.name)
560 else:
561 # Field was inherited from global config - use None to show placeholder
562 field_values[field.name] = None
564 # Create config with preserved user values and None for inherited fields
565 current_plate_config = PipelineConfig(**field_values)
566 # Preserve the explicitly set fields tracking (bypass frozen restriction)
567 object.__setattr__(current_plate_config, '_explicitly_set_fields', explicitly_set_fields.copy())
568 else:
569 # No existing config - create fresh config with all None values (all show as placeholders)
570 current_plate_config = create_dataclass_for_editing(PipelineConfig, self.global_config)
572 def handle_config_save(new_config: PipelineConfig) -> None:
573 """Apply per-orchestrator configuration without global side effects."""
574 # SIMPLIFIED: Debug logging without thread-local context
575 from dataclasses import fields
576 logger.debug(f"🔍 CONFIG SAVE - new_config type: {type(new_config)}")
577 for field in fields(new_config):
578 raw_value = object.__getattribute__(new_config, field.name)
579 logger.debug(f"🔍 CONFIG SAVE - new_config.{field.name} = {raw_value}")
581 for orchestrator in selected_orchestrators:
582 # Direct synchronous call - no async needed
583 orchestrator.apply_pipeline_config(new_config)
584 # Emit signal for UI components to refresh
585 effective_config = orchestrator.get_effective_config()
586 self.orchestrator_config_changed.emit(str(orchestrator.plate_path), effective_config)
588 # Auto-sync handles context restoration automatically when pipeline_config is accessed
589 if self.selected_plate_path and self.selected_plate_path in self.orchestrators:
590 logger.debug(f"Orchestrator context automatically maintained after config save: {self.selected_plate_path}")
592 count = len(selected_orchestrators)
593 # Success message dialog removed for test automation compatibility
595 # Open configuration window using PipelineConfig (not GlobalPipelineConfig)
596 # PipelineConfig already imported from openhcs.core.config
597 self._open_config_window(
598 config_class=PipelineConfig,
599 current_config=current_plate_config,
600 on_save_callback=handle_config_save,
601 orchestrator=representative_orchestrator # Pass orchestrator for context persistence
602 )
604 def _open_config_window(self, config_class, current_config, on_save_callback, orchestrator=None):
605 """
606 Open configuration window with specified config class and current config.
608 Args:
609 config_class: Configuration class type (PipelineConfig or GlobalPipelineConfig)
610 current_config: Current configuration instance
611 on_save_callback: Function to call when config is saved
612 orchestrator: Optional orchestrator reference for context persistence
613 """
614 from openhcs.pyqt_gui.windows.config_window import ConfigWindow
615 from openhcs.config_framework.context_manager import config_context
618 # SIMPLIFIED: ConfigWindow now uses the dataclass instance directly for context
619 # No need for external context management - the form manager handles it automatically
620 with config_context(orchestrator.pipeline_config):
621 config_window = ConfigWindow(
622 config_class, # config_class
623 current_config, # current_config
624 on_save_callback, # on_save_callback
625 self.color_scheme, # color_scheme
626 self, # parent
627 )
629 # CRITICAL: Connect to orchestrator config changes for automatic refresh
630 # This ensures the config window stays in sync when tier 3 edits change the underlying config
631 if orchestrator and hasattr(config_window, 'refresh_config'):
632 def handle_orchestrator_config_change(plate_path: str, effective_config):
633 # Only refresh if this is for the same orchestrator
634 if plate_path == str(orchestrator.plate_path):
635 # Get the updated pipeline config from the orchestrator
636 updated_pipeline_config = orchestrator.pipeline_config
637 if updated_pipeline_config:
638 config_window.refresh_config(updated_pipeline_config)
639 logger.debug(f"Auto-refreshed config window for orchestrator: {plate_path}")
641 # Connect the signal
642 self.orchestrator_config_changed.connect(handle_orchestrator_config_change)
644 # Store the connection so we can disconnect it when the window closes
645 config_window._orchestrator_signal_connection = handle_orchestrator_config_change
647 # Show as non-modal window (like main window configuration)
648 config_window.show()
649 config_window.raise_()
650 config_window.activateWindow()
652 def action_edit_global_config(self):
653 """
654 Handle global configuration editing - affects all orchestrators.
656 Uses concrete GlobalPipelineConfig for direct editing with static placeholder defaults.
657 """
658 from openhcs.core.config import GlobalPipelineConfig
660 # Get current global config from service adapter or use default
661 current_global_config = self.service_adapter.get_global_config() or GlobalPipelineConfig()
663 def handle_global_config_save(new_config: GlobalPipelineConfig) -> None:
664 """Apply global configuration to all orchestrators and save to cache."""
665 self.service_adapter.set_global_config(new_config) # Update app-level config
667 # Update thread-local storage for MaterializationPathConfig defaults
668 from openhcs.core.config import GlobalPipelineConfig
669 from openhcs.config_framework.global_config import set_global_config_for_editing
670 set_global_config_for_editing(GlobalPipelineConfig, new_config)
672 # Save to cache for persistence between sessions
673 self._save_global_config_to_cache(new_config)
675 for orchestrator in self.orchestrators.values():
676 self._update_orchestrator_global_config(orchestrator, new_config)
678 # SIMPLIFIED: Dual-axis resolver handles context discovery automatically
679 if self.selected_plate_path and self.selected_plate_path in self.orchestrators:
680 logger.debug(f"Global config applied to selected orchestrator: {self.selected_plate_path}")
682 self.service_adapter.show_info_dialog("Global configuration applied to all orchestrators")
684 # Open configuration window using concrete GlobalPipelineConfig
685 self._open_config_window(
686 config_class=GlobalPipelineConfig,
687 current_config=current_global_config,
688 on_save_callback=handle_global_config_save
689 )
691 def _save_global_config_to_cache(self, config: GlobalPipelineConfig):
692 """Save global config to cache for persistence between sessions."""
693 try:
694 # Use synchronous saving to ensure it completes
695 from openhcs.core.config_cache import _sync_save_config
696 from openhcs.core.xdg_paths import get_config_file_path
698 cache_file = get_config_file_path("global_config.config")
699 success = _sync_save_config(config, cache_file)
701 if success:
702 logger.info("Global config saved to cache for session persistence")
703 else:
704 logger.error("Failed to save global config to cache - sync save returned False")
705 except Exception as e:
706 logger.error(f"Failed to save global config to cache: {e}")
707 # Don't show error dialog as this is not critical for immediate functionality
709 async def action_compile_plate(self):
710 """Handle Compile Plate button - compile pipelines for selected plates."""
711 selected_items = self.get_selected_plates()
713 if not selected_items:
714 logger.warning("No plates available for compilation")
715 return
717 # Unified validation using functional validator
718 invalid_plates = self._validate_plates_for_operation(selected_items, 'compile')
720 # Let validation failures bubble up as status messages
721 if invalid_plates:
722 invalid_names = [p['name'] for p in invalid_plates]
723 self.status_message.emit(f"Cannot compile invalid plates: {', '.join(invalid_names)}")
724 return
726 # Start async compilation
727 await self._compile_plates_worker(selected_items)
729 async def _compile_plates_worker(self, selected_items: List[Dict]) -> None:
730 """Background worker for plate compilation."""
731 # CRITICAL: Set up global context in worker thread
732 # The service adapter runs this entire function in a worker thread,
733 # so we need to establish the global context here
734 from openhcs.config_framework.lazy_factory import ensure_global_config_context
735 from openhcs.core.config import GlobalPipelineConfig
736 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
738 # Use signals for thread-safe UI updates
739 self.progress_started.emit(len(selected_items))
741 for i, plate_data in enumerate(selected_items):
742 plate_path = plate_data['path']
744 # Get definition pipeline and make fresh copy
745 definition_pipeline = self._get_current_pipeline_definition(plate_path)
746 if not definition_pipeline:
747 logger.warning(f"No pipeline defined for {plate_data['name']}, using empty pipeline")
748 definition_pipeline = []
750 try:
751 # Get or create orchestrator for compilation
752 if plate_path in self.orchestrators:
753 orchestrator = self.orchestrators[plate_path]
754 if not orchestrator.is_initialized():
755 # Only run heavy initialization in worker thread
756 # Need to set up context in worker thread too since initialize() runs there
757 def initialize_with_context():
758 from openhcs.config_framework.lazy_factory import ensure_global_config_context
759 from openhcs.core.config import GlobalPipelineConfig
760 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
761 return orchestrator.initialize()
763 import asyncio
764 loop = asyncio.get_event_loop()
765 await loop.run_in_executor(None, initialize_with_context)
766 else:
767 # Create orchestrator in main thread (has access to global context)
768 orchestrator = PipelineOrchestrator(
769 plate_path=plate_path,
770 storage_registry=self.file_manager.registry
771 )
772 # Only run heavy initialization in worker thread
773 # Need to set up context in worker thread too since initialize() runs there
774 def initialize_with_context():
775 from openhcs.config_framework.lazy_factory import ensure_global_config_context
776 from openhcs.core.config import GlobalPipelineConfig
777 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
778 return orchestrator.initialize()
780 import asyncio
781 loop = asyncio.get_event_loop()
782 await loop.run_in_executor(None, initialize_with_context)
783 self.orchestrators[plate_path] = orchestrator
784 self.orchestrators[plate_path] = orchestrator
786 # Make fresh copy for compilation
787 execution_pipeline = copy.deepcopy(definition_pipeline)
789 # Fix step IDs after deep copy to match new object IDs
790 for step in execution_pipeline:
791 step.step_id = str(id(step))
792 # Ensure variable_components is never None - use FunctionStep default
793 if step.variable_components is None:
794 logger.warning(f"Step '{step.name}' has None variable_components, setting FunctionStep default")
795 step.variable_components = [VariableComponents.SITE]
796 # Also ensure it's not an empty list
797 elif not step.variable_components:
798 logger.warning(f"Step '{step.name}' has empty variable_components, setting FunctionStep default")
799 step.variable_components = [VariableComponents.SITE]
801 # Get wells and compile (async - run in executor to avoid blocking UI)
802 # Wrap in Pipeline object like test_main.py does
803 pipeline_obj = Pipeline(steps=execution_pipeline)
805 # Run heavy operations in executor to avoid blocking UI (works in Qt thread)
806 import asyncio
807 loop = asyncio.get_event_loop()
808 # Get wells using multiprocessing axis (WELL in default config)
809 from openhcs.constants import MULTIPROCESSING_AXIS
810 wells = await loop.run_in_executor(None, lambda: orchestrator.get_component_keys(MULTIPROCESSING_AXIS))
812 # Wrap compilation with context setup for worker thread
813 def compile_with_context():
814 from openhcs.config_framework.lazy_factory import ensure_global_config_context
815 from openhcs.core.config import GlobalPipelineConfig
816 ensure_global_config_context(GlobalPipelineConfig, self.global_config)
817 return orchestrator.compile_pipelines(pipeline_obj.steps, wells)
819 compiled_contexts = await loop.run_in_executor(None, compile_with_context)
821 # Store compiled data
822 self.plate_compiled_data[plate_path] = (execution_pipeline, compiled_contexts)
823 logger.info(f"Successfully compiled {plate_path}")
825 # Update orchestrator state change signal
826 self.orchestrator_state_changed.emit(plate_path, "COMPILED")
828 except Exception as e:
829 logger.error(f"COMPILATION ERROR: Pipeline compilation failed for {plate_path}: {e}", exc_info=True)
830 plate_data['error'] = str(e)
831 # Don't store anything in plate_compiled_data on failure
832 self.orchestrator_state_changed.emit(plate_path, "COMPILE_FAILED")
833 # Use signal for thread-safe error reporting instead of direct dialog call
834 self.compilation_error.emit(plate_data['name'], str(e))
836 # Use signal for thread-safe progress update
837 self.progress_updated.emit(i + 1)
839 # Use signal for thread-safe progress completion
840 self.progress_finished.emit()
841 self.status_message.emit(f"Compilation completed for {len(selected_items)} plate(s)")
842 self.update_button_states()
844 async def action_run_plate(self):
845 """Handle Run Plate button - execute compiled plates."""
846 selected_items = self.get_selected_plates()
847 if not selected_items:
848 self.service_adapter.show_error_dialog("No plates selected to run.")
849 return
851 ready_items = [item for item in selected_items if item.get('path') in self.plate_compiled_data]
852 if not ready_items:
853 self.service_adapter.show_error_dialog("Selected plates are not compiled. Please compile first.")
854 return
856 try:
857 # Use subprocess approach like Textual TUI
858 logger.debug("Using subprocess approach for clean isolation")
860 plate_paths_to_run = [item['path'] for item in ready_items]
862 # Pass both pipeline definition and pre-compiled contexts to subprocess
863 pipeline_data = {}
864 effective_configs = {}
865 for plate_path in plate_paths_to_run:
866 execution_pipeline, compiled_contexts = self.plate_compiled_data[plate_path]
867 pipeline_data[plate_path] = {
868 'pipeline_definition': execution_pipeline, # Use execution pipeline (stripped)
869 'compiled_contexts': compiled_contexts # Pre-compiled contexts
870 }
872 # Get effective config for this plate (includes pipeline config if set)
873 if plate_path in self.orchestrators:
874 effective_configs[plate_path] = self.orchestrators[plate_path].get_effective_config()
875 else:
876 effective_configs[plate_path] = self.global_config
878 logger.info(f"Starting subprocess for {len(plate_paths_to_run)} plates")
880 # Clear subprocess logs before starting new execution
881 self.clear_subprocess_logs.emit()
883 # Create data file for subprocess
884 data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.pkl')
886 # Generate unique ID for this subprocess
887 import time
888 subprocess_timestamp = int(time.time())
889 plate_names = [Path(path).name for path in plate_paths_to_run]
890 unique_id = f"plates_{'_'.join(plate_names[:2])}_{subprocess_timestamp}"
892 # Build subprocess log name using log utilities
893 from openhcs.core.log_utils import get_current_log_file_path
894 try:
895 tui_log_path = get_current_log_file_path()
896 if tui_log_path.endswith('.log'):
897 tui_base = tui_log_path[:-4] # Remove .log extension
898 else:
899 tui_base = tui_log_path
900 log_file_base = f"{tui_base}_subprocess_{subprocess_timestamp}"
901 except RuntimeError:
902 # Fallback if no main log found
903 log_dir = Path.home() / ".local" / "share" / "openhcs" / "logs"
904 log_dir.mkdir(parents=True, exist_ok=True)
905 log_file_base = str(log_dir / f"pyqt_gui_subprocess_{subprocess_timestamp}")
907 # Pickle data for subprocess
908 subprocess_data = {
909 'plate_paths': plate_paths_to_run,
910 'pipeline_data': pipeline_data,
911 'global_config': self.global_config, # Fallback global config
912 'effective_configs': effective_configs # Per-plate effective configs (includes pipeline config)
913 }
915 # Resolve all lazy configurations to concrete values before pickling
916 from openhcs.config_framework.lazy_factory import resolve_lazy_configurations_for_serialization
917 resolved_subprocess_data = resolve_lazy_configurations_for_serialization(subprocess_data)
919 # Write pickle data
920 def _write_pickle_data():
921 import dill as pickle
922 with open(data_file.name, 'wb') as f:
923 pickle.dump(resolved_subprocess_data, f)
924 data_file.close()
926 # Write pickle data in executor (works in Qt thread)
927 import asyncio
928 loop = asyncio.get_event_loop()
929 await loop.run_in_executor(None, _write_pickle_data)
931 logger.debug(f"Created data file: {data_file.name}")
933 # Create subprocess
934 subprocess_script = Path(__file__).parent.parent.parent / "textual_tui" / "subprocess_runner.py"
936 # Generate actual log file path that subprocess will create
937 actual_log_file_path = f"{log_file_base}_{unique_id}.log"
938 logger.debug(f"Log file base: {log_file_base}")
939 logger.debug(f"Unique ID: {unique_id}")
940 logger.debug(f"Actual log file: {actual_log_file_path}")
942 # Store log file path for monitoring
943 self.log_file_path = actual_log_file_path
944 self.log_file_position = 0
946 logger.debug(f"Subprocess command: {sys.executable} {subprocess_script} {data_file.name} {log_file_base} {unique_id}")
948 # Create subprocess
949 def _create_subprocess():
950 return subprocess.Popen([
951 sys.executable, str(subprocess_script),
952 data_file.name, log_file_base, unique_id
953 ],
954 stdout=subprocess.DEVNULL,
955 stderr=subprocess.DEVNULL,
956 text=True,
957 )
959 # Create subprocess in executor (works in Qt thread)
960 import asyncio
961 loop = asyncio.get_event_loop()
962 self.current_process = await loop.run_in_executor(None, _create_subprocess)
964 logger.info(f"Subprocess started with PID: {self.current_process.pid}")
966 # Emit signal for log viewer to start monitoring
967 self.subprocess_log_started.emit(log_file_base)
969 # Update orchestrator states to show running state
970 for plate in ready_items:
971 plate_path = plate['path']
972 if plate_path in self.orchestrators:
973 self.orchestrators[plate_path]._state = OrchestratorState.EXECUTING
975 self.execution_state = "running"
976 self.status_message.emit(f"Running {len(ready_items)} plate(s) in subprocess...")
977 self.update_button_states()
979 # Start monitoring
980 await self._start_monitoring()
982 except Exception as e:
983 logger.error(f"Failed to start plate execution: {e}", exc_info=True)
984 self.service_adapter.show_error_dialog(f"Failed to start execution: {e}")
985 self.execution_state = "idle"
986 self.update_button_states()
988 async def action_stop_execution(self):
989 """Handle Stop Execution - terminate running subprocess (matches TUI implementation)."""
990 logger.info("🛑 Stop button pressed. Terminating subprocess.")
991 self.status_message.emit("Terminating execution...")
993 if self.current_process and self.current_process.poll() is None: # Still running
994 try:
995 # Kill the entire process group, not just the parent process (matches TUI)
996 # The subprocess creates its own process group, so we need to kill that group
997 logger.info(f"🛑 Killing process group for PID {self.current_process.pid}...")
999 # Get the process group ID (should be same as PID since subprocess calls os.setpgrp())
1000 process_group_id = self.current_process.pid
1002 # Kill entire process group (negative PID kills process group)
1003 import os
1004 import signal
1005 os.killpg(process_group_id, signal.SIGTERM)
1007 # Give processes time to exit gracefully
1008 import asyncio
1009 await asyncio.sleep(1)
1011 # Force kill if still alive
1012 try:
1013 os.killpg(process_group_id, signal.SIGKILL)
1014 logger.info(f"🛑 Force killed process group {process_group_id}")
1015 except ProcessLookupError:
1016 logger.info(f"🛑 Process group {process_group_id} already terminated")
1018 # Reset execution state
1019 self.execution_state = "idle"
1020 self.current_process = None
1022 # Update orchestrator states
1023 for orchestrator in self.orchestrators.values():
1024 if orchestrator.state == OrchestratorState.EXECUTING:
1025 orchestrator._state = OrchestratorState.COMPILED
1027 self.status_message.emit("Execution terminated by user")
1028 self.update_button_states()
1030 # Emit signal for log viewer
1031 self.subprocess_log_stopped.emit()
1033 except Exception as e:
1034 logger.warning(f"🛑 Error killing process group: {e}, falling back to single process kill")
1035 # Fallback to killing just the main process (original behavior)
1036 self.current_process.terminate()
1037 try:
1038 self.current_process.wait(timeout=5)
1039 except subprocess.TimeoutExpired:
1040 self.current_process.kill()
1041 self.current_process.wait()
1043 # Reset state even on fallback
1044 self.execution_state = "idle"
1045 self.current_process = None
1046 self.status_message.emit("Execution terminated by user")
1047 self.update_button_states()
1048 self.subprocess_log_stopped.emit()
1049 else:
1050 self.service_adapter.show_info_dialog("No execution is currently running.")
1052 def action_code_plate(self):
1053 """Generate Python code for selected plates and their pipelines (Tier 3)."""
1054 logger.debug("Code button pressed - generating Python code for plates")
1056 selected_items = self.get_selected_plates()
1057 if not selected_items:
1058 self.service_adapter.show_error_dialog("No plates selected for code generation")
1059 return
1061 try:
1062 # Collect plate paths, pipeline data, and pipeline config (same logic as Textual TUI)
1063 plate_paths = []
1064 pipeline_data = {}
1066 # Get pipeline config from the first selected orchestrator (they should all have the same config)
1067 representative_orchestrator = None
1068 for plate_data in selected_items:
1069 plate_path = plate_data['path']
1070 if plate_path in self.orchestrators:
1071 representative_orchestrator = self.orchestrators[plate_path]
1072 break
1074 for plate_data in selected_items:
1075 plate_path = plate_data['path']
1076 plate_paths.append(plate_path)
1078 # Get pipeline definition for this plate
1079 definition_pipeline = self._get_current_pipeline_definition(plate_path)
1080 if not definition_pipeline:
1081 logger.warning(f"No pipeline defined for {plate_data['name']}, using empty pipeline")
1082 definition_pipeline = []
1084 pipeline_data[plate_path] = definition_pipeline
1086 # Get the actual pipeline config from the orchestrator
1087 actual_pipeline_config = None
1088 if representative_orchestrator and representative_orchestrator.pipeline_config:
1089 actual_pipeline_config = representative_orchestrator.pipeline_config
1091 # Generate complete orchestrator code using existing function
1092 from openhcs.debug.pickle_to_python import generate_complete_orchestrator_code
1094 python_code = generate_complete_orchestrator_code(
1095 plate_paths=plate_paths,
1096 pipeline_data=pipeline_data,
1097 global_config=self.global_config,
1098 pipeline_config=actual_pipeline_config,
1099 clean_mode=True # Default to clean mode - only show non-default values
1100 )
1102 # Create simple code editor service (same pattern as tiers 1 & 2)
1103 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService
1104 editor_service = SimpleCodeEditorService(self)
1106 # Check if user wants external editor (check environment variable)
1107 import os
1108 use_external = os.environ.get('OPENHCS_USE_EXTERNAL_EDITOR', '').lower() in ('1', 'true', 'yes')
1110 # Launch editor with callback
1111 editor_service.edit_code(
1112 initial_content=python_code,
1113 title="Edit Orchestrator Configuration",
1114 callback=self._handle_edited_orchestrator_code,
1115 use_external=use_external
1116 )
1118 except Exception as e:
1119 logger.error(f"Failed to generate plate code: {e}")
1120 self.service_adapter.show_error_dialog(f"Failed to generate code: {str(e)}")
1122 def _patch_lazy_constructors(self):
1123 """Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction."""
1124 from contextlib import contextmanager
1125 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
1126 import dataclasses
1128 @contextmanager
1129 def patch_context():
1130 # Store original constructors
1131 original_constructors = {}
1133 # Find all lazy dataclass types that need patching
1134 from openhcs.core.config import LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig
1135 lazy_types = [LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig]
1137 # Add any other lazy types that might be used
1138 for lazy_type in lazy_types:
1139 if LazyDefaultPlaceholderService.has_lazy_resolution(lazy_type):
1140 # Store original constructor
1141 original_constructors[lazy_type] = lazy_type.__init__
1143 # Create patched constructor that uses raw values
1144 def create_patched_init(original_init, dataclass_type):
1145 def patched_init(self, **kwargs):
1146 # Use raw value approach instead of calling original constructor
1147 # This prevents lazy resolution during code execution
1148 for field in dataclasses.fields(dataclass_type):
1149 value = kwargs.get(field.name, None)
1150 object.__setattr__(self, field.name, value)
1152 # Initialize any required lazy dataclass attributes
1153 if hasattr(dataclass_type, '_is_lazy_dataclass'):
1154 object.__setattr__(self, '_is_lazy_dataclass', True)
1156 return patched_init
1158 # Apply the patch
1159 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type)
1161 try:
1162 yield
1163 finally:
1164 # Restore original constructors
1165 for lazy_type, original_init in original_constructors.items():
1166 lazy_type.__init__ = original_init
1168 return patch_context()
1170 def _handle_edited_orchestrator_code(self, edited_code: str):
1171 """Handle edited orchestrator code and update UI state (same logic as Textual TUI)."""
1172 logger.debug("Orchestrator code edited, processing changes...")
1173 try:
1174 # CRITICAL FIX: Execute code with lazy dataclass constructor patching to preserve None vs concrete distinction
1175 namespace = {}
1176 with self._patch_lazy_constructors():
1177 exec(edited_code, namespace)
1179 # Extract variables from executed code (same logic as Textual TUI)
1180 if 'plate_paths' in namespace and 'pipeline_data' in namespace:
1181 new_plate_paths = namespace['plate_paths']
1182 new_pipeline_data = namespace['pipeline_data']
1184 # Update global config if present
1185 if 'global_config' in namespace:
1186 new_global_config = namespace['global_config']
1187 # Update the global config (trigger UI refresh)
1188 self.global_config = new_global_config
1190 # CRITICAL: Apply new global config to all orchestrators (was missing!)
1191 # This ensures orchestrators use the updated global config from tier 3 edits
1192 for orchestrator in self.orchestrators.values():
1193 self._update_orchestrator_global_config(orchestrator, new_global_config)
1195 # SIMPLIFIED: Update service adapter (dual-axis resolver handles context)
1196 self.service_adapter.set_global_config(new_global_config)
1198 self.global_config_changed.emit()
1200 # Update pipeline config if present (CRITICAL: This was missing!)
1201 if 'pipeline_config' in namespace:
1202 new_pipeline_config = namespace['pipeline_config']
1203 # Apply the new pipeline config to all affected orchestrators
1204 for plate_path in new_plate_paths:
1205 if plate_path in self.orchestrators:
1206 orchestrator = self.orchestrators[plate_path]
1207 orchestrator.apply_pipeline_config(new_pipeline_config)
1208 # Emit signal for UI components to refresh (including config windows)
1209 effective_config = orchestrator.get_effective_config()
1210 self.orchestrator_config_changed.emit(str(plate_path), effective_config)
1211 logger.debug(f"Applied tier 3 pipeline config to orchestrator: {plate_path}")
1213 # Update pipeline data for ALL affected plates with proper state invalidation
1214 if self.pipeline_editor and hasattr(self.pipeline_editor, 'plate_pipelines'):
1215 current_plate = getattr(self.pipeline_editor, 'current_plate', None)
1217 for plate_path, new_steps in new_pipeline_data.items():
1218 # Update pipeline data in the pipeline editor
1219 self.pipeline_editor.plate_pipelines[plate_path] = new_steps
1220 logger.debug(f"Updated pipeline for {plate_path} with {len(new_steps)} steps")
1222 # CRITICAL: Invalidate orchestrator state for ALL affected plates
1223 self._invalidate_orchestrator_compilation_state(plate_path)
1225 # If this is the currently displayed plate, trigger UI cascade
1226 if plate_path == current_plate:
1227 # Update the current pipeline steps to trigger cascade
1228 self.pipeline_editor.pipeline_steps = new_steps
1229 # Trigger UI refresh for the current plate
1230 self.pipeline_editor.update_step_list()
1231 # Emit pipeline changed signal to cascade to step editors
1232 self.pipeline_editor.pipeline_changed.emit(new_steps)
1233 logger.debug(f"Triggered UI cascade refresh for current plate: {plate_path}")
1234 else:
1235 logger.warning("No pipeline editor available to update pipeline data")
1237 # Trigger UI refresh
1238 self.pipeline_data_changed.emit()
1239 self.service_adapter.show_info_dialog("Orchestrator configuration updated successfully")
1241 else:
1242 self.service_adapter.show_error_dialog("No valid assignments found in edited code")
1244 except SyntaxError as e:
1245 self.service_adapter.show_error_dialog(f"Invalid Python syntax: {e}")
1246 except Exception as e:
1247 import traceback
1248 full_traceback = traceback.format_exc()
1249 logger.error(f"Failed to parse edited orchestrator code: {e}\nFull traceback:\n{full_traceback}")
1250 self.service_adapter.show_error_dialog(f"Failed to parse orchestrator code: {str(e)}\n\nFull traceback:\n{full_traceback}")
1252 def _invalidate_orchestrator_compilation_state(self, plate_path: str):
1253 """Invalidate compilation state for an orchestrator when its pipeline changes.
1255 This ensures that tier 3 changes properly invalidate ALL affected orchestrators,
1256 not just the currently visible one.
1258 Args:
1259 plate_path: Path of the plate whose orchestrator state should be invalidated
1260 """
1261 # Clear compiled data from simple state
1262 if plate_path in self.plate_compiled_data:
1263 del self.plate_compiled_data[plate_path]
1264 logger.debug(f"Cleared compiled data for {plate_path}")
1266 # Reset orchestrator state to READY (initialized) if it was compiled
1267 orchestrator = self.orchestrators.get(plate_path)
1268 if orchestrator:
1269 from openhcs.constants.constants import OrchestratorState
1270 if orchestrator.state == OrchestratorState.COMPILED:
1271 orchestrator._state = OrchestratorState.READY
1272 logger.debug(f"Reset orchestrator state to READY for {plate_path}")
1274 # Emit state change signal for UI refresh
1275 self.orchestrator_state_changed.emit(plate_path, "READY")
1277 logger.debug(f"Invalidated compilation state for orchestrator: {plate_path}")
1279 def action_save_python_script(self):
1280 """Handle Save Python Script button (placeholder)."""
1281 self.service_adapter.show_info_dialog("Script saving not yet implemented in PyQt6 version.")
1283 # ========== UI Helper Methods ==========
1285 def update_plate_list(self):
1286 """Update the plate list widget using selection preservation mixin."""
1287 def format_plate_item(plate):
1288 """Format plate item for display."""
1289 display_text = f"{plate['name']} ({plate['path']})"
1291 # Add status indicators
1292 status_indicators = []
1293 if plate['path'] in self.orchestrators:
1294 orchestrator = self.orchestrators[plate['path']]
1295 if orchestrator.state == OrchestratorState.READY:
1296 status_indicators.append("✓ Init")
1297 elif orchestrator.state == OrchestratorState.COMPILED:
1298 status_indicators.append("✓ Compiled")
1299 elif orchestrator.state == OrchestratorState.EXECUTING:
1300 status_indicators.append("🔄 Running")
1301 elif orchestrator.state == OrchestratorState.COMPLETED:
1302 status_indicators.append("✅ Complete")
1303 elif orchestrator.state == OrchestratorState.COMPILE_FAILED:
1304 status_indicators.append("❌ Compile Failed")
1305 elif orchestrator.state == OrchestratorState.EXEC_FAILED:
1306 status_indicators.append("❌ Exec Failed")
1308 if status_indicators:
1309 display_text = f"[{', '.join(status_indicators)}] {display_text}"
1311 return display_text, plate
1313 def update_func():
1314 """Update function that clears and rebuilds the list."""
1315 self.plate_list.clear()
1317 for plate in self.plates:
1318 display_text, plate_data = format_plate_item(plate)
1319 item = QListWidgetItem(display_text)
1320 item.setData(Qt.ItemDataRole.UserRole, plate_data)
1322 # Add tooltip
1323 if plate['path'] in self.orchestrators:
1324 orchestrator = self.orchestrators[plate['path']]
1325 item.setToolTip(f"Status: {orchestrator.state.value}")
1327 self.plate_list.addItem(item)
1329 # Auto-select first plate if no selection and plates exist
1330 if self.plates and not self.selected_plate_path:
1331 self.plate_list.setCurrentRow(0)
1333 # Use utility to preserve selection during update
1334 preserve_selection_during_update(
1335 self.plate_list,
1336 lambda item_data: item_data['path'] if isinstance(item_data, dict) and 'path' in item_data else str(item_data),
1337 lambda: bool(self.orchestrators),
1338 update_func
1339 )
1340 self.update_button_states()
1342 def get_selected_plates(self) -> List[Dict]:
1343 """
1344 Get currently selected plates.
1346 Returns:
1347 List of selected plate dictionaries
1348 """
1349 selected_items = []
1350 for item in self.plate_list.selectedItems():
1351 plate_data = item.data(Qt.ItemDataRole.UserRole)
1352 if plate_data:
1353 selected_items.append(plate_data)
1354 return selected_items
1356 def update_button_states(self):
1357 """Update button enabled/disabled states based on selection."""
1358 selected_plates = self.get_selected_plates()
1359 has_selection = len(selected_plates) > 0
1360 has_initialized = any(plate['path'] in self.orchestrators for plate in selected_plates)
1361 has_compiled = any(plate['path'] in self.plate_compiled_data for plate in selected_plates)
1362 is_running = self.is_any_plate_running()
1364 # Update button states (logic extracted from Textual version)
1365 self.buttons["del_plate"].setEnabled(has_selection and not is_running)
1366 self.buttons["edit_config"].setEnabled(has_initialized and not is_running)
1367 self.buttons["init_plate"].setEnabled(has_selection and not is_running)
1368 self.buttons["compile_plate"].setEnabled(has_initialized and not is_running)
1369 self.buttons["code_plate"].setEnabled(has_initialized and not is_running)
1370 self.buttons["save_python_script"].setEnabled(has_initialized and not is_running)
1372 # Run button - enabled if plates are compiled or if currently running (for stop)
1373 if is_running:
1374 self.buttons["run_plate"].setEnabled(True)
1375 self.buttons["run_plate"].setText("Stop")
1376 else:
1377 self.buttons["run_plate"].setEnabled(has_compiled)
1378 self.buttons["run_plate"].setText("Run")
1380 def is_any_plate_running(self) -> bool:
1381 """
1382 Check if any plate is currently running.
1384 Returns:
1385 True if any plate is running, False otherwise
1386 """
1387 return self.execution_state == "running"
1389 def update_status(self, message: str):
1390 """
1391 Update status label.
1393 Args:
1394 message: Status message to display
1395 """
1396 self.status_label.setText(message)
1398 def on_selection_changed(self):
1399 """Handle plate list selection changes using utility."""
1400 def on_selected(selected_plates):
1401 self.selected_plate_path = selected_plates[0]['path']
1402 self.plate_selected.emit(self.selected_plate_path)
1404 # SIMPLIFIED: Dual-axis resolver handles context discovery automatically
1405 if self.selected_plate_path in self.orchestrators:
1406 logger.debug(f"Selected orchestrator: {self.selected_plate_path}")
1408 def on_cleared():
1409 self.selected_plate_path = ""
1411 # Use utility to handle selection with prevention
1412 handle_selection_change_with_prevention(
1413 self.plate_list,
1414 self.get_selected_plates,
1415 lambda item_data: item_data['path'] if isinstance(item_data, dict) and 'path' in item_data else str(item_data),
1416 lambda: bool(self.orchestrators),
1417 lambda: self.selected_plate_path,
1418 on_selected,
1419 on_cleared
1420 )
1422 self.update_button_states()
1428 def on_item_double_clicked(self, item: QListWidgetItem):
1429 """Handle double-click on plate item."""
1430 plate_data = item.data(Qt.ItemDataRole.UserRole)
1431 if plate_data:
1432 # Double-click could trigger initialization or configuration
1433 if plate_data['path'] not in self.orchestrators:
1434 self.run_async_action(self.action_init_plate)
1436 def on_orchestrator_state_changed(self, plate_path: str, state: str):
1437 """
1438 Handle orchestrator state changes.
1440 Args:
1441 plate_path: Path of the plate
1442 state: New orchestrator state
1443 """
1444 self.update_plate_list()
1445 logger.debug(f"Orchestrator state changed: {plate_path} -> {state}")
1447 def on_config_changed(self, new_config: GlobalPipelineConfig):
1448 """
1449 Handle global configuration changes.
1451 Args:
1452 new_config: New global configuration
1453 """
1454 self.global_config = new_config
1456 # Apply new global config to all existing orchestrators
1457 # This rebuilds their pipeline configs preserving concrete values
1458 for orchestrator in self.orchestrators.values():
1459 self._update_orchestrator_global_config(orchestrator, new_config)
1461 # REMOVED: Thread-local modification - dual-axis resolver handles orchestrator context automatically
1463 logger.info(f"Applied new global config to {len(self.orchestrators)} orchestrators")
1465 # SIMPLIFIED: Dual-axis resolver handles placeholder updates automatically
1467 # REMOVED: _refresh_all_parameter_form_placeholders and _refresh_widget_parameter_forms
1468 # SIMPLIFIED: Dual-axis resolver handles placeholder updates automatically
1470 # ========== Helper Methods ==========
1472 def _get_current_pipeline_definition(self, plate_path: str) -> List:
1473 """
1474 Get the current pipeline definition for a plate.
1476 Args:
1477 plate_path: Path to the plate
1479 Returns:
1480 List of pipeline steps or empty list if no pipeline
1481 """
1482 if not self.pipeline_editor:
1483 logger.warning("No pipeline editor reference - using empty pipeline")
1484 return []
1486 # Get pipeline for specific plate (same logic as Textual TUI)
1487 if hasattr(self.pipeline_editor, 'plate_pipelines') and plate_path in self.pipeline_editor.plate_pipelines:
1488 pipeline_steps = self.pipeline_editor.plate_pipelines[plate_path]
1489 logger.debug(f"Found pipeline for plate {plate_path} with {len(pipeline_steps)} steps")
1490 return pipeline_steps
1491 else:
1492 logger.debug(f"No pipeline found for plate {plate_path}, using empty pipeline")
1493 return []
1495 def set_pipeline_editor(self, pipeline_editor):
1496 """
1497 Set the pipeline editor reference.
1499 Args:
1500 pipeline_editor: Pipeline editor widget instance
1501 """
1502 self.pipeline_editor = pipeline_editor
1503 logger.debug("Pipeline editor reference set in plate manager")
1505 async def _start_monitoring(self):
1506 """Start monitoring subprocess execution."""
1507 if not self.current_process:
1508 return
1510 # Simple monitoring - check if process is still running
1511 def check_process():
1512 if self.current_process and self.current_process.poll() is not None:
1513 # Process has finished
1514 return_code = self.current_process.returncode
1515 logger.info(f"Subprocess finished with return code: {return_code}")
1517 # Reset execution state
1518 self.execution_state = "idle"
1519 self.current_process = None
1521 # Update orchestrator states based on return code
1522 for orchestrator in self.orchestrators.values():
1523 if orchestrator.state == OrchestratorState.EXECUTING:
1524 if return_code == 0:
1525 orchestrator._state = OrchestratorState.COMPLETED
1526 else:
1527 orchestrator._state = OrchestratorState.EXEC_FAILED
1529 if return_code == 0:
1530 self.status_message.emit("Execution completed successfully")
1531 else:
1532 self.status_message.emit(f"Execution failed with code {return_code}")
1534 self.update_button_states()
1536 # Emit signal for log viewer
1537 self.subprocess_log_stopped.emit()
1539 return False # Stop monitoring
1540 return True # Continue monitoring
1542 # Monitor process in background
1543 while check_process():
1544 await asyncio.sleep(1) # Check every second
1546 def _on_progress_started(self, max_value: int):
1547 """Handle progress started signal (main thread)."""
1548 self.progress_bar.setVisible(True)
1549 self.progress_bar.setMaximum(max_value)
1550 self.progress_bar.setValue(0)
1552 def _on_progress_updated(self, value: int):
1553 """Handle progress updated signal (main thread)."""
1554 self.progress_bar.setValue(value)
1556 def _on_progress_finished(self):
1557 """Handle progress finished signal (main thread)."""
1558 self.progress_bar.setVisible(False)
1560 def _handle_compilation_error(self, plate_name: str, error_message: str):
1561 """Handle compilation error on main thread (slot)."""
1562 self.service_adapter.show_error_dialog(f"Compilation failed for {plate_name}: {error_message}")
1564 def _handle_initialization_error(self, plate_name: str, error_message: str):
1565 """Handle initialization error on main thread (slot)."""
1566 self.service_adapter.show_error_dialog(f"Failed to initialize {plate_name}: {error_message}")