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

1""" 

2Plate Manager Widget for PyQt6 

3 

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""" 

7 

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 

17 

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 

25 

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 

38 

39logger = logging.getLogger(__name__) 

40 

41 

42class PlateManagerWidget(QWidget): 

43 """ 

44 PyQt6 Plate Manager Widget. 

45  

46 Manages plate selection, initialization, compilation, and execution. 

47 Preserves all business logic from Textual version with clean PyQt6 UI. 

48 """ 

49 

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 

55 

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 

59 

60 # Log viewer integration signals 

61 subprocess_log_started = pyqtSignal(str) # base_log_path 

62 subprocess_log_stopped = pyqtSignal() 

63 clear_subprocess_logs = pyqtSignal() 

64 

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() 

69 

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 

73 

74 def __init__(self, file_manager: FileManager, service_adapter, 

75 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

76 """ 

77 Initialize the plate manager widget. 

78 

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) 

86 

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 

92 

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) 

96 

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 

107 

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 

113 

114 # Setup UI 

115 self.setup_ui() 

116 self.setup_connections() 

117 self.update_button_states() 

118 

119 logger.debug("Plate manager widget initialized") 

120 

121 # ========== UI Setup ========== 

122 

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) 

128 

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) 

134 

135 # Main content splitter 

136 splitter = QSplitter(Qt.Orientation.Vertical) 

137 layout.addWidget(splitter) 

138 

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) 

167 

168 # Button panel 

169 button_panel = self.create_button_panel() 

170 splitter.addWidget(button_panel) 

171 

172 # Status section 

173 status_frame = self.create_status_section() 

174 layout.addWidget(status_frame) 

175 

176 # Set splitter proportions - make button panel much smaller 

177 splitter.setSizes([400, 80]) 

178 

179 def create_button_panel(self) -> QWidget: 

180 """ 

181 Create the button panel with all plate management actions. 

182 

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 """) 

195 

196 layout = QVBoxLayout(panel) 

197 layout.setContentsMargins(0, 0, 0, 0) 

198 layout.setSpacing(2) 

199 

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 ] 

211 

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) 

217 

218 for j in range(4): 

219 if i + j < len(button_configs): 

220 name, action, tooltip = button_configs[i + j] 

221 

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()) 

227 

228 # Connect button to action 

229 button.clicked.connect(lambda checked, a=action: self.handle_button_action(a)) 

230 

231 self.buttons[action] = button 

232 row_layout.addWidget(button) 

233 else: 

234 row_layout.addStretch() 

235 

236 layout.addLayout(row_layout) 

237 

238 # Set maximum height to constrain the button panel 

239 panel.setMaximumHeight(80) 

240 

241 return panel 

242 

243 def create_status_section(self) -> QWidget: 

244 """ 

245 Create the status section with progress bar and status label. 

246 

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 """) 

259 

260 layout = QVBoxLayout(frame) 

261 layout.setContentsMargins(2, 2, 2, 2) 

262 layout.setSpacing(2) 

263 

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) 

268 

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) 

274 

275 return frame 

276 

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) 

282 

283 # Internal signals 

284 self.status_message.connect(self.update_status) 

285 self.orchestrator_state_changed.connect(self.on_orchestrator_state_changed) 

286 

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) 

291 

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) 

295 

296 def handle_button_action(self, action: str): 

297 """ 

298 Handle button actions (extracted from Textual version). 

299 

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 } 

313 

314 if action in action_map: 

315 action_func = action_map[action] 

316 

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}") 

329 

330 def run_async_action(self, async_func: Callable): 

331 """ 

332 Run async action using service adapter. 

333 

334 Args: 

335 async_func: Async function to execute 

336 """ 

337 self.service_adapter.execute_async_operation(async_func) 

338 

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 

343 

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) 

347 

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}") 

356 

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) 

360 

361 # ========== Business Logic Methods (Extracted from Textual) ========== 

362 

363 def action_add_plate(self): 

364 """Handle Add Plate button (adapted from Textual version).""" 

365 from openhcs.core.path_cache import PathCacheKey 

366 

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 ) 

373 

374 if directory_path: 

375 self.add_plate_callback([directory_path]) 

376 

377 def add_plate_callback(self, selected_paths: List[Path]): 

378 """ 

379 Handle plate directory selection (extracted from Textual version). 

380  

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 

387 

388 added_plates = [] 

389 

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 

394 

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 } 

402 

403 self.plates.append(plate_entry) 

404 added_plates.append(plate_name) 

405 

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)") 

411 

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 

418 

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] 

421 

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] 

426 

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("") 

431 

432 self.update_plate_list() 

433 self.status_message.emit(f"Deleted {len(paths_to_delete)} plate(s)") 

434 

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 } 

449 

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)] 

453 

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) 

462 

463 selected_items = self.get_selected_plates() 

464 

465 # Unified validation - let it fail if no plates 

466 invalid_plates = self._validate_plates_for_operation(selected_items, 'init') 

467 

468 self.progress_started.emit(len(selected_items)) 

469 

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() 

485 

486 await asyncio.get_event_loop().run_in_executor( 

487 None, 

488 initialize_with_context 

489 ) 

490 

491 self.orchestrators[plate_path] = orchestrator 

492 self.orchestrator_state_changed.emit(plate_path, "READY") 

493 

494 if not self.selected_plate_path: 

495 self.selected_plate_path = plate_path 

496 self.plate_selected.emit(plate_path) 

497 

498 self.progress_updated.emit(i + 1) 

499 

500 # Process all plates functionally 

501 await asyncio.gather(*[ 

502 init_single_plate(i, plate) 

503 for i, plate in enumerate(selected_items) 

504 ]) 

505 

506 self.progress_finished.emit() 

507 self.status_message.emit(f"Initialized {len(selected_items)} plate(s)") 

508 

509 # Additional action methods would be implemented here following the same pattern... 

510 # (compile_plate, run_plate, code_plate, save_python_script, edit_config) 

511 

512 def action_edit_config(self): 

513 """ 

514 Handle Edit Config button - create per-orchestrator PipelineConfig instances. 

515 

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() 

520 

521 if not selected_items: 

522 self.service_adapter.show_error_dialog("No plates selected for configuration.") 

523 return 

524 

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 ] 

530 

531 if not selected_orchestrators: 

532 self.service_adapter.show_error_dialog("No initialized orchestrators selected.") 

533 return 

534 

535 # Load existing config or create new one for editing 

536 representative_orchestrator = selected_orchestrators[0] 

537 

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 

541 

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 

547 

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()) 

553 

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 

563 

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) 

571 

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}") 

580 

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) 

587 

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}") 

591 

592 count = len(selected_orchestrators) 

593 # Success message dialog removed for test automation compatibility 

594 

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 ) 

603 

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. 

607 

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 

616 

617 

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 ) 

628 

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}") 

640 

641 # Connect the signal 

642 self.orchestrator_config_changed.connect(handle_orchestrator_config_change) 

643 

644 # Store the connection so we can disconnect it when the window closes 

645 config_window._orchestrator_signal_connection = handle_orchestrator_config_change 

646 

647 # Show as non-modal window (like main window configuration) 

648 config_window.show() 

649 config_window.raise_() 

650 config_window.activateWindow() 

651 

652 def action_edit_global_config(self): 

653 """ 

654 Handle global configuration editing - affects all orchestrators. 

655 

656 Uses concrete GlobalPipelineConfig for direct editing with static placeholder defaults. 

657 """ 

658 from openhcs.core.config import GlobalPipelineConfig 

659 

660 # Get current global config from service adapter or use default 

661 current_global_config = self.service_adapter.get_global_config() or GlobalPipelineConfig() 

662 

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 

666 

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) 

671 

672 # Save to cache for persistence between sessions 

673 self._save_global_config_to_cache(new_config) 

674 

675 for orchestrator in self.orchestrators.values(): 

676 self._update_orchestrator_global_config(orchestrator, new_config) 

677 

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}") 

681 

682 self.service_adapter.show_info_dialog("Global configuration applied to all orchestrators") 

683 

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 ) 

690 

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 

697 

698 cache_file = get_config_file_path("global_config.config") 

699 success = _sync_save_config(config, cache_file) 

700 

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 

708 

709 async def action_compile_plate(self): 

710 """Handle Compile Plate button - compile pipelines for selected plates.""" 

711 selected_items = self.get_selected_plates() 

712 

713 if not selected_items: 

714 logger.warning("No plates available for compilation") 

715 return 

716 

717 # Unified validation using functional validator 

718 invalid_plates = self._validate_plates_for_operation(selected_items, 'compile') 

719 

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 

725 

726 # Start async compilation 

727 await self._compile_plates_worker(selected_items) 

728 

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) 

737 

738 # Use signals for thread-safe UI updates 

739 self.progress_started.emit(len(selected_items)) 

740 

741 for i, plate_data in enumerate(selected_items): 

742 plate_path = plate_data['path'] 

743 

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 = [] 

749 

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() 

762 

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() 

779 

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 

785 

786 # Make fresh copy for compilation 

787 execution_pipeline = copy.deepcopy(definition_pipeline) 

788 

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] 

800 

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) 

804 

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)) 

811 

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) 

818 

819 compiled_contexts = await loop.run_in_executor(None, compile_with_context) 

820 

821 # Store compiled data 

822 self.plate_compiled_data[plate_path] = (execution_pipeline, compiled_contexts) 

823 logger.info(f"Successfully compiled {plate_path}") 

824 

825 # Update orchestrator state change signal 

826 self.orchestrator_state_changed.emit(plate_path, "COMPILED") 

827 

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)) 

835 

836 # Use signal for thread-safe progress update 

837 self.progress_updated.emit(i + 1) 

838 

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() 

843 

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 

850 

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 

855 

856 try: 

857 # Use subprocess approach like Textual TUI 

858 logger.debug("Using subprocess approach for clean isolation") 

859 

860 plate_paths_to_run = [item['path'] for item in ready_items] 

861 

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 } 

871 

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 

877 

878 logger.info(f"Starting subprocess for {len(plate_paths_to_run)} plates") 

879 

880 # Clear subprocess logs before starting new execution 

881 self.clear_subprocess_logs.emit() 

882 

883 # Create data file for subprocess 

884 data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.pkl') 

885 

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}" 

891 

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}") 

906 

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 } 

914 

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) 

918 

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() 

925 

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) 

930 

931 logger.debug(f"Created data file: {data_file.name}") 

932 

933 # Create subprocess 

934 subprocess_script = Path(__file__).parent.parent.parent / "textual_tui" / "subprocess_runner.py" 

935 

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}") 

941 

942 # Store log file path for monitoring 

943 self.log_file_path = actual_log_file_path 

944 self.log_file_position = 0 

945 

946 logger.debug(f"Subprocess command: {sys.executable} {subprocess_script} {data_file.name} {log_file_base} {unique_id}") 

947 

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 ) 

958 

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) 

963 

964 logger.info(f"Subprocess started with PID: {self.current_process.pid}") 

965 

966 # Emit signal for log viewer to start monitoring 

967 self.subprocess_log_started.emit(log_file_base) 

968 

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 

974 

975 self.execution_state = "running" 

976 self.status_message.emit(f"Running {len(ready_items)} plate(s) in subprocess...") 

977 self.update_button_states() 

978 

979 # Start monitoring 

980 await self._start_monitoring() 

981 

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() 

987 

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...") 

992 

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}...") 

998 

999 # Get the process group ID (should be same as PID since subprocess calls os.setpgrp()) 

1000 process_group_id = self.current_process.pid 

1001 

1002 # Kill entire process group (negative PID kills process group) 

1003 import os 

1004 import signal 

1005 os.killpg(process_group_id, signal.SIGTERM) 

1006 

1007 # Give processes time to exit gracefully 

1008 import asyncio 

1009 await asyncio.sleep(1) 

1010 

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") 

1017 

1018 # Reset execution state 

1019 self.execution_state = "idle" 

1020 self.current_process = None 

1021 

1022 # Update orchestrator states 

1023 for orchestrator in self.orchestrators.values(): 

1024 if orchestrator.state == OrchestratorState.EXECUTING: 

1025 orchestrator._state = OrchestratorState.COMPILED 

1026 

1027 self.status_message.emit("Execution terminated by user") 

1028 self.update_button_states() 

1029 

1030 # Emit signal for log viewer 

1031 self.subprocess_log_stopped.emit() 

1032 

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() 

1042 

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.") 

1051 

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") 

1055 

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 

1060 

1061 try: 

1062 # Collect plate paths, pipeline data, and pipeline config (same logic as Textual TUI) 

1063 plate_paths = [] 

1064 pipeline_data = {} 

1065 

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 

1073 

1074 for plate_data in selected_items: 

1075 plate_path = plate_data['path'] 

1076 plate_paths.append(plate_path) 

1077 

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 = [] 

1083 

1084 pipeline_data[plate_path] = definition_pipeline 

1085 

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 

1090 

1091 # Generate complete orchestrator code using existing function 

1092 from openhcs.debug.pickle_to_python import generate_complete_orchestrator_code 

1093 

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 ) 

1101 

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) 

1105 

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') 

1109 

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 ) 

1117 

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)}") 

1121 

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 

1127 

1128 @contextmanager 

1129 def patch_context(): 

1130 # Store original constructors 

1131 original_constructors = {} 

1132 

1133 # Find all lazy dataclass types that need patching 

1134 from openhcs.core.config import LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig 

1135 lazy_types = [LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig] 

1136 

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__ 

1142 

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) 

1151 

1152 # Initialize any required lazy dataclass attributes 

1153 if hasattr(dataclass_type, '_is_lazy_dataclass'): 

1154 object.__setattr__(self, '_is_lazy_dataclass', True) 

1155 

1156 return patched_init 

1157 

1158 # Apply the patch 

1159 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type) 

1160 

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 

1167 

1168 return patch_context() 

1169 

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) 

1178 

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'] 

1183 

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 

1189 

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) 

1194 

1195 # SIMPLIFIED: Update service adapter (dual-axis resolver handles context) 

1196 self.service_adapter.set_global_config(new_global_config) 

1197 

1198 self.global_config_changed.emit() 

1199 

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}") 

1212 

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) 

1216 

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") 

1221 

1222 # CRITICAL: Invalidate orchestrator state for ALL affected plates 

1223 self._invalidate_orchestrator_compilation_state(plate_path) 

1224 

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") 

1236 

1237 # Trigger UI refresh 

1238 self.pipeline_data_changed.emit() 

1239 self.service_adapter.show_info_dialog("Orchestrator configuration updated successfully") 

1240 

1241 else: 

1242 self.service_adapter.show_error_dialog("No valid assignments found in edited code") 

1243 

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}") 

1251 

1252 def _invalidate_orchestrator_compilation_state(self, plate_path: str): 

1253 """Invalidate compilation state for an orchestrator when its pipeline changes. 

1254 

1255 This ensures that tier 3 changes properly invalidate ALL affected orchestrators, 

1256 not just the currently visible one. 

1257 

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}") 

1265 

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}") 

1273 

1274 # Emit state change signal for UI refresh 

1275 self.orchestrator_state_changed.emit(plate_path, "READY") 

1276 

1277 logger.debug(f"Invalidated compilation state for orchestrator: {plate_path}") 

1278 

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.") 

1282 

1283 # ========== UI Helper Methods ========== 

1284 

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']})" 

1290 

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") 

1307 

1308 if status_indicators: 

1309 display_text = f"[{', '.join(status_indicators)}] {display_text}" 

1310 

1311 return display_text, plate 

1312 

1313 def update_func(): 

1314 """Update function that clears and rebuilds the list.""" 

1315 self.plate_list.clear() 

1316 

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) 

1321 

1322 # Add tooltip 

1323 if plate['path'] in self.orchestrators: 

1324 orchestrator = self.orchestrators[plate['path']] 

1325 item.setToolTip(f"Status: {orchestrator.state.value}") 

1326 

1327 self.plate_list.addItem(item) 

1328 

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) 

1332 

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() 

1341 

1342 def get_selected_plates(self) -> List[Dict]: 

1343 """ 

1344 Get currently selected plates. 

1345  

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 

1355 

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() 

1363 

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) 

1371 

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") 

1379 

1380 def is_any_plate_running(self) -> bool: 

1381 """ 

1382 Check if any plate is currently running. 

1383  

1384 Returns: 

1385 True if any plate is running, False otherwise 

1386 """ 

1387 return self.execution_state == "running" 

1388 

1389 def update_status(self, message: str): 

1390 """ 

1391 Update status label. 

1392  

1393 Args: 

1394 message: Status message to display 

1395 """ 

1396 self.status_label.setText(message) 

1397 

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) 

1403 

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}") 

1407 

1408 def on_cleared(): 

1409 self.selected_plate_path = "" 

1410 

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 ) 

1421 

1422 self.update_button_states() 

1423 

1424 

1425 

1426 

1427 

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) 

1435 

1436 def on_orchestrator_state_changed(self, plate_path: str, state: str): 

1437 """ 

1438 Handle orchestrator state changes. 

1439  

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}") 

1446 

1447 def on_config_changed(self, new_config: GlobalPipelineConfig): 

1448 """ 

1449 Handle global configuration changes. 

1450 

1451 Args: 

1452 new_config: New global configuration 

1453 """ 

1454 self.global_config = new_config 

1455 

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) 

1460 

1461 # REMOVED: Thread-local modification - dual-axis resolver handles orchestrator context automatically 

1462 

1463 logger.info(f"Applied new global config to {len(self.orchestrators)} orchestrators") 

1464 

1465 # SIMPLIFIED: Dual-axis resolver handles placeholder updates automatically 

1466 

1467 # REMOVED: _refresh_all_parameter_form_placeholders and _refresh_widget_parameter_forms 

1468 # SIMPLIFIED: Dual-axis resolver handles placeholder updates automatically 

1469 

1470 # ========== Helper Methods ========== 

1471 

1472 def _get_current_pipeline_definition(self, plate_path: str) -> List: 

1473 """ 

1474 Get the current pipeline definition for a plate. 

1475 

1476 Args: 

1477 plate_path: Path to the plate 

1478 

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 [] 

1485 

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 [] 

1494 

1495 def set_pipeline_editor(self, pipeline_editor): 

1496 """ 

1497 Set the pipeline editor reference. 

1498 

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") 

1504 

1505 async def _start_monitoring(self): 

1506 """Start monitoring subprocess execution.""" 

1507 if not self.current_process: 

1508 return 

1509 

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}") 

1516 

1517 # Reset execution state 

1518 self.execution_state = "idle" 

1519 self.current_process = None 

1520 

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 

1528 

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}") 

1533 

1534 self.update_button_states() 

1535 

1536 # Emit signal for log viewer 

1537 self.subprocess_log_stopped.emit() 

1538 

1539 return False # Stop monitoring 

1540 return True # Continue monitoring 

1541 

1542 # Monitor process in background 

1543 while check_process(): 

1544 await asyncio.sleep(1) # Check every second 

1545 

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) 

1551 

1552 def _on_progress_updated(self, value: int): 

1553 """Handle progress updated signal (main thread).""" 

1554 self.progress_bar.setValue(value) 

1555 

1556 def _on_progress_finished(self): 

1557 """Handle progress finished signal (main thread).""" 

1558 self.progress_bar.setVisible(False) 

1559 

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}") 

1563 

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}")