diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fcd5a990c1..6f2aa41345 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -79,6 +79,7 @@ jobs: external/metaclass-registry/docs/source external/PolyStore/README.md external/PolyStore/docs/source + external/formbridge/README.md external/pyqt-reactive/README.md external/pyqt-reactive/docs external/python-introspect/README.md diff --git a/.gitmodules b/.gitmodules index 64a89a88a5..4dd880c610 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,3 +30,7 @@ path = external/pyqt-reactive url = https://github.com/OpenHCSDev/PyQT-reactive.git branch = main +[submodule "external/formbridge"] + path = external/formbridge + url = https://github.com/OpenHCSDev/formbridge.git + branch = main diff --git a/README.md b/README.md index dcb09fd10f..2ac6abf59f 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,9 @@ graph TD OH --> AB["ArrayBridge
(arrays)"] OH --> PS["PolyStore
(I/O + streaming)"] OH --> ZR["ZMQRuntime
(exec)"] - OH --> QR["PyQT-reactive
(forms)"] + OH --> QR["PyQT-reactive
(Qt forms)"] + OH --> FB["formbridge
(form model + UI bridge)"] + QR --> FB OS --> PI["python-introspect
(signatures)"] OH --> MR["metaclass-registry
(plugins)"] @@ -197,6 +199,7 @@ graph TD | [**PolyStore**](https://github.com/OpenHCSDev/PolyStore) | Unified I/O & stream payloads | Generic storage and streaming payload primitives, backend lifecycle, virtual workspaces, atomic writes, format detection, and ROI extraction | | [**ZMQRuntime**](https://github.com/OpenHCSDev/ZMQRuntime) | Process & transport runtime | Generic request, status, progress, cancellation, process-lifecycle, and viewer-control transport protocols | | [**PyQT-reactive**](https://github.com/OpenHCSDev/PyQT-reactive) | UI form generation | React-style reactive forms from dataclasses with cross-window sync and flash animations | +| [**formbridge**](https://github.com/OpenHCSDev/formbridge) | Toolkit-neutral forms and UI bridge | The form model, help and code documents, and the running-UI bridge contracts, client and server; imports no GUI toolkit, so headless MCP uses it without Qt | | [**pycodify**](https://github.com/OpenHCSDev/pycodify) | Code ↔ object conversion | Python source as serialization format — type-preserving, diffable, editable, with collision handling | | [**python-introspect**](https://github.com/OpenHCSDev/python-introspect) | Signature analysis | Pure-Python function/dataclass introspection for automatic UI generation and contract analysis | | [**metaclass-registry**](https://github.com/OpenHCSDev/metaclass-registry) | Plugin discovery | Zero-boilerplate registry system powering microscope handler and storage backend auto-discovery | diff --git a/benchmark/agent_validation/mcp_attempt_recorder.py b/benchmark/agent_validation/mcp_attempt_recorder.py index f8f5efc98d..4789160e87 100644 --- a/benchmark/agent_validation/mcp_attempt_recorder.py +++ b/benchmark/agent_validation/mcp_attempt_recorder.py @@ -54,7 +54,7 @@ CustomFunctionRegistrationResult, FunctionDetail, ) -from openhcs.agent.dto.ui_bridge import UiCodeDocument +from formbridge.bridge.dto import UiCodeDocument from openhcs.core.function_patterns import normalize_function_pattern from openhcs.core.function_reference import FunctionReference from openhcs.core.pipeline_document import PipelineDocument, PipelineDocumentCodec diff --git a/docs/refactor/consolidation-20261009/U4-formbridge.md b/docs/refactor/consolidation-20261009/U4-formbridge.md new file mode 100644 index 0000000000..ceab6c7ade --- /dev/null +++ b/docs/refactor/consolidation-20261009/U4-formbridge.md @@ -0,0 +1,102 @@ +# U4: formbridge, the toolkit-neutral tier + +**Head audited:** `openhcs` at `c7a953030` (PR #1203, E1, on `main` `eb64b3c13`); `pyqt-reactive` `main` at `1bce7de` (0.5.0); `formbridge` `main` at `7463390` (0.0.1 placeholder). **Rules:** [00-RULES.md](00-RULES.md). **Step 4** ([04-ARCHITECTURE.md](04-ARCHITECTURE.md#the-ui-one-session-interface-thin-renderers-surfaces-u1-to-u5), row U4). +**Shared abstractions** ([02-SHARED-ABSTRACTIONS.md](02-SHARED-ABSTRACTIONS.md)). *Builds:* the `formbridge` library (repository `OpenHCSDev/formbridge`); `MainThreadDispatcher` with a queued and a Qt delivery; the bridge application declaration that replaces OpenHCS's bridge constants. *Uses:* `AutoRegisterMeta`, U2's `UiBridgeOperation` family, U3's `ScopeKey` and `FieldInspection`. + +This branch is stacked on E1 (#1203): E1 edits 342 files, including every UI-bridge module this surface moves, so building on `main` would leave each move in conflict with E1. Merge E1 first. + +## What is wrong + +**Headless MCP imports PyQt6, because toolkit-neutral code lives inside the Qt library and the generic UI-bridge DTOs, contracts and server live inside OpenHCS's GUI and agent packages.** + +Re-measured at `c7a953030`: + +- **Two B5 failures (owner U4).** `test_mcp_cold_build_does_not_import_pyqt6` and `test_hosted_mcp_cold_build_does_not_import_pyqt6` (`tests/unit/agent/test_mcp_distribution_runtime.py:165,194`) fail. `build_server()` loads `PyQt6.QtCore`, `QtGui`, `QtWidgets` and `sip` through two chains: + - `mcp/execution.py:12-18` imports `QCoreApplication`/`QEventLoop`/`QThread` and pyqt-reactive's `FutureCompletion` (a `QObject`) and `UiThreadDispatcher` (a Qt signal proxy). The headless server builds a `QCoreApplication` only to use its event loop as a main-thread queue. + - `authoring/session/pipelines.py:20` → `pyqt_reactive.services.function_pattern_code_document` → `pyqt_reactive.protocols` (package initializer) → `protocols/log_providers.py` → `pyqt_reactive.core` (package initializer) → `debounce_timer.py` (`QTimer`) and `reorderable_list_widget.py` (`QListWidget`). The code document and the provider registries are Qt-free; package initializers bundle them with widget adapters. +- **pyqt-reactive's Qt-free tier is 10.8k lines in 64 modules** (0.5.0, measured by a static import graph: no module-level Qt import in the module or anything it imports, after dropping package initializers). It holds the form model (`forms/parameter_form_service.py` 924, `parameter_info_types.py` 350, `parameter_type_utils.py` 224, …), help documents and parameter help, the function-pattern code document (1,300), scope tokens, the pattern data manager, the provider registries in `protocols/`, the preview formatters and strategies, path cache, log discovery and streaming, the system-metrics sampler, ZMQ server scanning, and the Qt-free manager hooks in `widgets/shared/`. `animation/flash_trace.py` is an unused re-export shim of `flash_trace.py`. +- **OpenHCS's headless code imports 24 of those modules from the Qt package** (`agent/dto/ui_bridge.py`, `agent/dto/viewer.py`, `agent/services/function_catalog_service.py`, `authoring/session/{pipelines,progress,session,step_scopes}.py`, `core/function_patterns.py`, `mcp/server.py`, `processing/custom_functions/events.py`, …). +- **The generic UI-bridge tier is about 9k lines inside OpenHCS** (classified symbol by symbol from imports): + - `agent/dto/ui_bridge.py` (2,844): about 2.55k lines of identities, connection and descriptor DTOs, catalog pages, action, window, widget-tree, snapshot, ObjectState-field, code-document, branch and operation DTOs import nothing from OpenHCS's domain; about 230 lines (plate-manager, pipeline-editor, debug and live-measurement states, the selected-plate workflow) are OpenHCS's. + - `agent/services/ui_bridge_service.py` (1,832): the `UiBridgeOperation` family, `serves`/`UiBridgeOperationServer`, gateway ABC and errors, descriptor reading and resolution, and `UiBridgeService` are generic (about 1.73k); two operations (`selected_plate_workflow`, `session_events`), `UiBridgeEnvironment` and `viewer_launch_context` are OpenHCS's. + - `agent/services/ui_bridge_transport.py` (164), `agent/services/object_state_field_projection.py` (161), `agent/dto/common.py` (the `AgentError`/`AgentResultEnvelope`/`AgentCliRequest` family, 180), `core/selection.py` (99) and `agent/runtime_platform.py` (364) import no domain code. + - `pyqt_gui/services/ui_bridge_server.py` (623), `ui_bridge_contracts.py` (629), `ui_bridge_registry.py` (263) and most of `ui_agent_bridge.py` (about 1.2k of 1,916) and `ui_bridge_object_state.py` (about 780 of 1,163) import no Qt at all. They sit in the GUI package only because the DTOs did. + - `pyqt_gui/services/ui_bridge_windows.py` (2,894): window introspection, about 2.7k lines of generic Qt. +- **OpenHCS constants stand in for an application declaration.** The protocol version `"openhcs.ui_bridge.v3"` (`ui_bridge_service.py:114`, read in five modules), `OPENHCS_ENDPOINT_APPLICATION`, `OPENHCS_ZMQ_CONFIG`, the `"OpenHCS", "ui-bridge"` runtime directory, the `OPENHCS_UI_BRIDGE_*` variable names, `"OpenHCSUiBridgeServer"`, `DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR`, the hidden `"__plates__"` scope (`ui_bridge_object_state_scope_policy.py:17`) and `OpenHCSUiWindowId` aliases are read directly by generic code. +- **Identity enums restate the identity registry** (`agent/dto/ui_bridge.py:124-154`: `_identity_enum` builds `UiCodeDocumentId`, `UiStateSurfaceId`, `UiWidgetId` from the declaration registries; only tests read them; rule 1a). +- **Reassigned to U4 by U3 and U5:** the UI-bridge server and window introspection; the bridge widget id `plate_manager` and the dev client's `WorkflowTerminalStateCriterion`; `PyQtServiceAdapter.get_current_color_scheme` as a library protocol (called by pyqt-reactive `abstract_manager_widget.py:685`); the `QApplication` teardown segfault (about one run in five; the offscreen smoke test exits with `os._exit` to avoid it, `tests/pyqt_gui/test_main_window_offscreen_smoke.py:198`). +- **`DualEditorWindow._current_function_spec`** (`pyqt_gui/windows/dual_editor_window.py:925`), "compatibility wrapper for tests", has no caller; neither has the `DualEditorSession.current_function_spec` it forwards to. + +## Target + +**formbridge 0.1.0** (no Qt, no OpenHCS; depends on python-introspect, objectstate, metaclass-registry, zmqruntime): + +- The Qt-free tier, keeping its module paths under `formbridge.` (`formbridge.forms.parameter_form_service`, `formbridge.services.help_document`, `formbridge.protocols.form_config`, …); the Qt-free manager hooks become `formbridge.managers.*`, the scope colour strategy `formbridge.theming.*`. Package initializers export nothing; every import names the defining module. +- `formbridge.services.main_thread_dispatch`: `MainThreadDispatcher` (owned calls, atomic pre-start cancellation, close) with `QueuedMainThreadDispatcher`, which a headless process drains with `run_until(future)`. pyqt-reactive's `UiThreadDispatcher` subclasses it and supplies only Qt delivery. +- `formbridge.bridge`: the generic UI-bridge tier listed above (DTOs, operation family, gateway, descriptors, transport, service, contracts, provider registry, in-process bridge and server). One `UiBridgeApplication` class declares what OpenHCS's constants restate (protocol version, endpoint application, transport config, runtime directory name, environment variable names, snapshot directory, server name); OpenHCS declares `OpenHCSUiBridgeApplication` once. Hidden scopes are a registered family; window identity aliases are declared on the identity classes. The identity enums are deleted. +- The colour-scheme protocol is a formbridge ABC that pyqt-reactive's manager widgets read and OpenHCS's service adapter implements. + +**pyqt-reactive 0.6.0** depends on `formbridge>=0.1,<0.2`, deletes the moved modules (no re-exports) and receives the Qt half of the bridge: window introspection, the ObjectState code-document window providers, and the host-window declaration the introspection reads. + +**OpenHCS** keeps its declarations: its bridge application, widget, document and state-surface identities, the plate-manager, pipeline-editor, debug and live-measurement surfaces, the session operations, the selected-plate workflow and session-events operations, `OpenHCSUiAgentBridgeService`, and the MCP capabilities. `mcp/execution.py` runs on `QueuedMainThreadDispatcher`; no `QCoreApplication` exists in a headless process. + +Deleted: `pyqt_reactive.animation.flash_trace` (shim); `_identity_enum`, `UiCodeDocumentId`, `UiStateSurfaceId`, `UiWidgetId`; `DEFAULT_UI_BRIDGE_DESCRIPTOR_RESOLVER` and the module constants the application class replaces; `DualEditorWindow._current_function_spec` and `DualEditorSession.current_function_spec` (both unused). + +## Persisted state + +All runtime state, reset on upgrade (rule 2): formbridge's default data directories and log prefixes are named `formbridge` (OpenHCS configures its own); the pattern scope-token metadata key becomes `__formbridge_scope_token__`; UI-bridge descriptor files carry the application's protocol version. + +## Guards + +- `tests/unit/agent/test_mcp_distribution_runtime.py`: the two cold-build tests pass (stdio and hosted MCP import no PyQt6; hosted imports no `openhcs.pyqt_gui`). +- `tests/unit/test_formbridge_guards.py`: importing the MCP server (stdio and hosted) or any formbridge module loads no PyQt6, PyQt5, PySide, qtpy or pyqt-reactive module; no module under `openhcs/` defines a moved name; no OpenHCS module imports a moved `pyqt_reactive` path. +- formbridge `tests/test_no_toolkit_imports.py`: every formbridge module imports without a toolkit, and no formbridge source names one. + +## Tests + +- The pyqt-reactive tests of moved modules move to formbridge unchanged (14 files); the two that tested the flash-trace shim and a moved static gate are deleted or moved. +- New: `formbridge/tests/test_main_thread_dispatch.py` (owner-thread execution, pre-start cancellation, callback-owned outcome, close). +- The MCP affinity tests assert main-thread execution without Qt; the "no Qt application" case becomes "a worker call never runs on the worker". + +## New-case experiments + +- A second headless consumer of the form model or help documents (a web form renderer): today it imports a Qt package and loads PyQt6. After: `import formbridge.forms.parameter_form_service`. +- A second application with a running-UI bridge: today it copies 9k lines out of OpenHCS. After: one `UiBridgeApplication` subclass, its identity declarations and its state-surface providers. + +## Done when + +The guards pass; the formbridge and pyqt-reactive PRs are open with CI on Linux, macOS and Windows for Python 3.11–3.13 plus ruff; OpenHCS points `external/formbridge` and `external/pyqt-reactive` at their branch heads with `formbridge>=0.1,<0.2` and `pyqt-reactive>=0.6,<0.7`; the agent, UI-bridge and pyqt_gui tests pass offscreen; the structural ratchet and NRA show no regression. + +## Outcome and corrections + +Measured against the E1 head `c7a953030`: OpenHCS production −13,730 +736; tests −520 +578. formbridge receives 10.8k lines from pyqt-reactive and about 9k from OpenHCS (more after `ruff format`); pyqt-reactive source −11,325 +3,378 (the Qt bridge half arrives, the Qt-free tier leaves). Guards: 5/5 in `tests/unit/test_formbridge_guards.py`, plus formbridge `test_no_toolkit_imports.py` and pyqt-reactive `test_only_qt_code.py`. + +- **The Qt-free UI-bridge server and most of the in-process bridge went to formbridge, not pyqt-reactive.** U3's reassignment expected the server beside the Qt code. It imports no Qt; only window introspection (`bridge.windows`) and the ObjectState code-document window providers (`bridge.object_state_documents`) are Qt, and they went to pyqt-reactive. +- **Headless dispatch without Qt.** `mcp/execution.py` built a `QCoreApplication` only to use its event loop as a main-thread queue. formbridge's `QueuedMainThreadDispatcher` replaces it (`run_until(future)`); pyqt-reactive's `UiThreadDispatcher` is the Qt delivery of the same `MainThreadDispatcher`. +- **One application declaration.** `UiBridgeApplication.current()` returns the one declared application (OpenHCS: `OpenHCSUiBridgeApplication`, imported by the OpenHCS bridge modules). Bridge envelopes carry formbridge's `SCHEMA_VERSION` (`formbridge.v1`); the bridge protocol is `openhcs.ui_bridge.v4`. Descriptor files are runtime state. +- **Wire and MCP changes (rule 1).** `openhcs_ui_get_state_surface` now requires `surface_id` (the generic request cannot name OpenHCS's default surface); the MCP contract fixture records it. The bridge widget id is `datasets` (was `plate_manager`); its document and surfaces are `datasets.orchestrator_config`, `datasets.state`, `datasets.live_measurements`. Bridge error hints name operations instead of OpenHCS tool names. +- **Rule 1a.** Window-snapshot capture scopes and frame conditions stored Qt callables in enum members; formbridge keeps the wire enums with Qt-free attributes, and each scope and observation kind has a registered Qt implementation. The dev client's `WorkflowTerminalStateCriterion` family restated `DatasetWorkflowOperation`; the terminal and failed row states now live on the operations. +- **QApplication teardown segfault: not reproducible.** The offscreen smoke driver with normal interpreter teardown exited cleanly 18 of 18 times on this branch (6 after deleting the application, 12 with it alive) and 8 of 8 on the E1 head. The smoke test no longer exits with `os._exit`, so a teardown crash fails it. +- **Found outside the brief:** `openhcs/utils/performance_monitor.py` was an unused copy of the library module (deleted); `parameter_ops_service` in pyqt-reactive had an `ImportError` stub fallback (deleted). +- **Not done here:** `openhcs/core/log_utils.py` restates parts of formbridge's `core.log_utils` (log discovery and classification); owner U5 (dead and misplaced UI code). The GUI's own "plate manager" names (`PlateManagerWidget`, `OpenHCSUiWindowId.plate_manager`) are U1's container-manager rename. + +## Deliberate contract changes + +Merged with `main` at `44980dfe2` (E1, K2, K3, N1). Against the published MCP snapshot (`tests/fixtures/mcp/mcp_tool_contract.json` on `main`) exactly one tool record differs; surfaces and resources are identical. + +| Change | Where it shows | In the MCP snapshot | +|---|---|---| +| `openhcs_ui_get_state_surface` requires `surface_id` (the default `plate_manager.state` is gone) | MCP tool input schema | yes: the only snapshot change (3 lines added, 1 removed) | +| Bridge protocol `openhcs.ui_bridge.v4` (was `v3`) | descriptor files and bridge request/response envelopes between an MCP client and a running GUI | no | +| Bridge envelopes and bridge DTO results built by formbridge carry `schema_version: "formbridge.v1"`; OpenHCS's own DTOs keep `openhcs.agent.v1` | field value in bridge tool results | no (value, not schema) | +| Bridge widget id `datasets` (was `plate_manager`); document `datasets.orchestrator_config`; surfaces `datasets.state`, `datasets.live_measurements` | identity values in bridge catalogs and requests | no (values) | +| Bridge error hints name bridge operations (`get_operation_status`, `navigate_window`, `get_document`) instead of OpenHCS tool names | `hint` text of bridge errors | no | + +A GUI and an MCP client from different sides of this change refuse each other at the protocol check. + +## After central verification + +- **formbridge is declared wherever the other first-party libraries are:** `.gitmodules`, `pyproject.toml`, the README library table, `scripts/validate_docs.py`, the docs workflow's validation targets, and the test rosters that scan first-party library sources (configuration consumer coverage, configuration mirror deletion, U3's generic-Qt guards, help projection, the desktop update worker's source path). `openhcs/_source_dependencies.py`, `install_ci_candidate` and `validate_local_release_floors` discover `external/*` and needed no entry. The headless guard's subprocess imports `openhcs` first, so it resolves formbridge through the source-checkout activation and passes without formbridge installed. +- **Gallery:** the scenario id is derived from the bridge identity, so the published asset and its two records were renamed together (`ui-plate-manager` → `ui-datasets`, `window_id` `datasets`). It is a documentation asset, not an external URL contract. +- **Config leaves:** the consumers of `agent_bridge.*`, `list_previews.*` and `performance_monitor.*` now live in formbridge; the coverage scan and the mirror-deletion scans follow them there (formbridge added beside pyqt-reactive, paths of the five moved files updated). No scope was removed. +- **nra-r1:** the local run used `--budget-seconds 600`; CI uses 160. The job timed out (no finding): the base scan took 141 s on that runner and the head scan re-parses the two library trees this surface changes. diff --git a/external/ObjectState b/external/ObjectState index 4eb8de1398..1fcdf1297f 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 4eb8de1398428c539e1e14360c847ccea2fe4b34 +Subproject commit 1fcdf1297f98217eba76f2e23423fcb0e488dc4a diff --git a/external/PolyStore b/external/PolyStore index 7ac9d01110..d367c60b8a 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 7ac9d011100b18f4ad1a54635ee94f52a47efb30 +Subproject commit d367c60b8afecc4c489580a31a865140b2c2f23a diff --git a/external/formbridge b/external/formbridge new file mode 160000 index 0000000000..b4d155842a --- /dev/null +++ b/external/formbridge @@ -0,0 +1 @@ +Subproject commit b4d155842af281553bdf24cd1200c0856c1a62a1 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 1bce7de2b3..4991fc732a 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 1bce7de2b33edd5b054f596da2b321c9b7cf93fd +Subproject commit 4991fc732a9807ae54ada2b2ea9c9228f13bf9f8 diff --git a/external/zmqruntime b/external/zmqruntime index dc327e6ca2..4826549299 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit dc327e6ca2496aa4ad405479832f4e9e219750e4 +Subproject commit 4826549299f69ac56e3dfc858d15ba92c8574f95 diff --git a/openhcs/agent/capabilities.py b/openhcs/agent/capabilities.py index 27a03c7018..163a5fe99a 100644 --- a/openhcs/agent/capabilities.py +++ b/openhcs/agent/capabilities.py @@ -35,12 +35,8 @@ AuthoringContext, AuthoringContextRequest, ) -from openhcs.agent.dto.common import ( - AGENT_PARAMETER_DESCRIPTION_METADATA_KEY, - SCHEMA_VERSION, - AgentDataclassCliRequest, - RenderedSource, -) +from formbridge.contracts import AGENT_PARAMETER_DESCRIPTION_METADATA_KEY, AgentDataclassCliRequest +from openhcs.agent.dto.common import SCHEMA_VERSION, RenderedSource from openhcs.agent.dto.config import ( ConfigPatch, ConfigRef, @@ -81,7 +77,7 @@ ) from openhcs.authoring.session.operations import HeadlessOperation, SessionOperation from openhcs.authoring.session.views import DatasetListView, PipelineStepsView -from openhcs.agent.services.ui_bridge_service import ( +from formbridge.bridge.service import ( UiBridgeApplyDocumentOperation, UiBridgeCloseWindowOperation, UiBridgeFocusWindowOperation, @@ -103,8 +99,6 @@ UiBridgeOperation, UiBridgeRequestlessOperation, UiBridgeRestoreSnapshotOperation, - UiBridgeSelectedPlateWorkflowOperation, - UiBridgeSessionEventsOperation, UiBridgeSnapshotWindowOperation, UiBridgeStatusOperation, UiBridgeSwitchBranchOperation, @@ -113,6 +107,10 @@ UiBridgeWaitForOperationReceiptOperation, UiBridgeWidgetTreeOperation, ) +from openhcs.agent.services.ui_bridge_service import ( + UiBridgeSelectedPlateWorkflowOperation, + UiBridgeSessionEventsOperation, +) from openhcs.agent.dto.functions import ( CustomFunctionRegistrationRequest, CustomFunctionRegistrationResult, @@ -162,7 +160,7 @@ SyntheticPlateGenerationRequest, SyntheticPlateGenerationResult, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiBridgeCatalog, UiObjectStateFieldHelpQuery, UiObjectStateFieldHelpResult, diff --git a/openhcs/agent/dto/__init__.py b/openhcs/agent/dto/__init__.py index af4711388a..d0033d795e 100644 --- a/openhcs/agent/dto/__init__.py +++ b/openhcs/agent/dto/__init__.py @@ -11,18 +11,11 @@ { "openhcs.agent.dto.common": ( "SCHEMA_VERSION", - "AgentError", - "AgentResourceRef", - "AgentWarning", "RenderedSource", ), "openhcs.agent.dto.mcp": ( "McpServerHealthResult", ), - "pyqt_reactive.services.window_snapshot": ( - "WindowSnapshotCaptureScope", - "WindowSnapshotCaptureSpec", - ), "openhcs.agent.dto.authoring": ( "AuthoringContext", "AuthoringContextRequest", @@ -178,115 +171,10 @@ "ViewerWindowValidationSummaryResult", ), "openhcs.agent.dto.ui_bridge": ( - "UiActionCatalog", - "UiActionIdentity", - "UiActionInvocationStatus", - "UiActionInvokeRequest", - "UiActionInvokeResult", - "UiActionSummary", - "UiBranchCatalog", - "UiBranchRef", - "UiBranchSwitchRequest", - "UiBridgeConfirmationRequirement", - "UiBridgeConfirmationRequirementCarrier", - "UiBridgeConnectionSpec", - "UiBridgeCatalog", - "UiBridgeDescriptorEnvelope", - "UiBridgeDescriptorFile", - "UiBridgeDescriptorSummary", - "UiBridgeDescriptorWirePayload", - "UiBridgeOperationIdentity", - "UiBridgeOperationRef", - "UiBridgeOperationStatusRequest", - "UiBridgeOperationWaitRequest", - "UiBridgeRequestEnvelope", - "UiBridgeResponseEnvelope", - "UiBridgeStatus", - "UiCodeDocument", - "UiCodeDocumentApplyRequest", - "UiCodeDocumentApplyResult", - "UiCodeDocumentCatalog", - "UiCodeDocumentBaseRevision", - "UiCodeDocumentCurrentRevision", - "UiCodeDocumentId", - "UiCodeDocumentOptionalBaseRevision", - "UiCodeDocumentRequest", - "UiCodeDocumentSummary", - "UiCodeDocumentValidationRequest", - "UiCodeDocumentValidationResult", - "UiMutationReceipt", - "UiMutationRequestToken", - "UiMutationRequestTokenCarrier", - "UiLiveOverviewItem", - "UiLiveOverviewMetric", - "UiLiveOverviewSection", - "UiLiveOverviewSeverity", - "UiLiveOverviewState", - "UiObjectStateFieldListQuery", - "UiObjectStateFieldListOptions", - "UiObjectStateFieldListResult", - "UiObjectStateFieldPathIndex", - "UiObjectStateFieldProjection", - "UiObjectStateFieldProvenance", - "UiObjectStateFieldScopeProjection", - "UiObjectStateFieldHelpQuery", - "UiObjectStateFieldHelpRequest", - "UiObjectStateFieldHelpResult", - "UiObjectStateFieldMutationRequest", - "UiObjectStateFieldMutationResult", - "UiObjectStateFieldSummary", - "UiObjectStateValuePreview", - "UiObjectStateScopeCatalog", - "UiObjectStateScopeIdentity", - "UiObjectStateScopeListRequest", - "UiObjectStateScopeSummary", - "UiObjectStateScopeVisibility", "UiPlateManagerState", "UiSelectedPlateWorkflowKind", "UiSelectedPlateWorkflowRequest", "UiSelectedPlateWorkflowResult", - "UiStateSurfaceDocument", - "UiStateSurfaceEnvelope", - "UiStateSurfaceCatalog", - "UiStateSurfaceId", - "UiStateSurfaceIdentity", - "UiStateSurfaceRequest", - "UiStateSurfaceSummary", - "UiSemanticAddress", - "UiSnapshotCatalog", - "UiSnapshotListRequest", - "UiSnapshotRef", - "UiSnapshotRestoreRequest", - "UiSnapshotRestoreResult", - "UiTimeTravelRuntimeState", - "UiTimeTravelHeadRequest", - "UiWidgetIdentity", - "UiWidgetActionInvokeRequest", - "UiWidgetActionInvokeResult", - "UiWidgetActionIssueCode", - "UiWidgetActionSummary", - "UiWidgetRect", - "UiWidgetTreeNode", - "UiWidgetTreeRequest", - "UiWidgetTreeResult", - "UiWindowCatalog", - "UiWindowCloseRequest", - "UiWindowCloseResult", - "UiWindowFocusRequest", - "UiWindowFocusResult", - "UiWindowIdentity", - "UiWindowManagerScope", - "UiWindowNavigateRequest", - "UiWindowNavigateResult", - "UiWindowOpenPolicy", - "UiWindowOperationRequest", - "UiWindowSnapshotRequest", - "UiWindowSnapshotResult", - "UiWindowSummary", - ), - "openhcs.agent.ui_bridge_identities": ( - "MainWindowWidgetIdentity", - "ManagedWindowWidgetIdentity", ), }, ) diff --git a/openhcs/agent/dto/common.py b/openhcs/agent/dto/common.py index 1cb84a95c4..101a44f0fb 100644 --- a/openhcs/agent/dto/common.py +++ b/openhcs/agent/dto/common.py @@ -2,201 +2,10 @@ from __future__ import annotations -import argparse -import inspect -from abc import ABC, abstractmethod -from collections.abc import Mapping -from dataclasses import MISSING, dataclass, fields -from functools import cache, wraps -from typing import Self, cast, get_type_hints - -from python_introspect import ( - JsonObject, - JsonValue, - dataclass_from_mapping, - to_jsonable, -) +from dataclasses import dataclass SCHEMA_VERSION = "openhcs.agent.v1" -AGENT_PARAMETER_DESCRIPTION_METADATA_KEY = "agent_parameter_description" -AGENT_PARAMETER_PRODUCER_OUTPUT_CONTRACT_METADATA_KEY = ( - "agent_parameter_producer_output_contract" -) - - -@dataclass(frozen=True, slots=True) -class AgentCliArgumentSpec: - """CLI argument shape declared by agent request DTOs.""" - - field_name: str - flags: tuple[str, ...] = () - positional: bool = False - nargs: str | int | None = None - action: str | type[argparse.Action] | None = None - help: str | None = None - - -class AgentCliRequest(ABC): - """Nominal request DTO that declares generated CLI argument projection.""" - - @classmethod - @abstractmethod - def from_fields(cls, **kwargs) -> Self: - raise NotImplementedError - - @classmethod - def agent_cli_factory(cls): - return cls.from_fields - - @classmethod - def agent_cli_argument_specs(cls) -> tuple[AgentCliArgumentSpec, ...]: - return () - - @abstractmethod - def as_tool_arguments(self) -> JsonObject: - raise NotImplementedError - - -class AgentDataclassCliRequest(AgentCliRequest): - """CLI capability of a DTO whose declared fields are its tool input shape. - - Constructor reflection owns the signature; existing boundary codecs own - nested reconstruction, constraints and JSON projection. Custom flattened - request factories remain on their actual request owners. - """ - - @classmethod - def from_fields(cls, **kwargs) -> Self: - return dataclass_from_mapping(cls, kwargs) - - @classmethod - def agent_cli_factory(cls): - parameters = inspect.signature(cls).parameters - - @wraps(cls, updated=()) - def from_cli_fields(**kwargs): - # Keep Python's constructor marker out of the typed wire codec. - # Omitting that field lets its actual declared factory run once. - for declared_field in fields(cls): - if ( - declared_field.init - and declared_field.default_factory is not MISSING - and kwargs.get(declared_field.name) - is parameters[declared_field.name].default - ): - kwargs.pop(declared_field.name) - return cls.from_fields(**kwargs) - - return from_cli_fields - - @classmethod - def agent_cli_argument_specs(cls) -> tuple[AgentCliArgumentSpec, ...]: - annotations = get_type_hints(cls) - return ( - *super().agent_cli_argument_specs(), - *( - AgentCliArgumentSpec( - field_name=declared_field.name, - action=argparse.BooleanOptionalAction, - ) - for declared_field in fields(cls) - if declared_field.init and annotations[declared_field.name] is bool - ), - ) - - def as_tool_arguments(self) -> JsonObject: - return cast(JsonObject, to_jsonable(self)) - - -@dataclass(frozen=True, slots=True) -class AgentWarning: - code: str - message: str - hint: str | None = None - - -@dataclass(frozen=True, slots=True) -class AgentError: - code: str - message: str - hint: str | None = None - exception_type: str | None = None - path: str | None = None - - @classmethod - def code_from_serialized_mapping(cls, value: Mapping[str, object]) -> str | None: - """Project the error code through this DTO's declared field descriptor.""" - - code = value.get(cls.code.__name__) - return code if isinstance(code, str) else None - - @classmethod - def from_exception( - cls, - code: str, - exception: Exception, - *, - hint: str | None = None, - path: str | None = None, - ) -> "AgentError": - return cls( - code=code, - message=str(exception), - hint=hint, - exception_type=type(exception).__name__, - path=path, - ) - - -@dataclass(frozen=True, kw_only=True) -class AgentResultEnvelope: - schema_version: str - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - @classmethod - @cache - def serialized_errors_field_name(cls) -> str: - """Derive the error-list wire field from the owning dataclass declaration.""" - - type_hints = get_type_hints(cls) - matching_fields = tuple( - field.name - for field in fields(cls) - if type_hints[field.name] == tuple[AgentError, ...] - ) - if len(matching_fields) != 1: - raise TypeError( - f"{cls.__name__} must declare exactly one AgentError tuple field." - ) - return matching_fields[0] - - @classmethod - def error_items_from_serialized_mapping( - cls, - value: Mapping[str, object], - ) -> list[object] | None: - """Return the serialized error list through the declared envelope field.""" - - errors = value.get(cls.serialized_errors_field_name()) - return errors if isinstance(errors, list) else None - - -@dataclass(frozen=True, kw_only=True) -class AgentTimedStatusEnvelope(AgentResultEnvelope): - status: str - started_at_unix: float - - -@dataclass(frozen=True, slots=True) -class AgentResourceRef: - uri: str - title: str - mime_type: str = "application/json" - path: str | None = None - size_bytes: int | None = None - sha256: str | None = None @dataclass(frozen=True, slots=True) diff --git a/openhcs/agent/dto/config.py b/openhcs/agent/dto/config.py index 70de65f39b..b84678ef63 100644 --- a/openhcs/agent/dto/config.py +++ b/openhcs/agent/dto/config.py @@ -5,11 +5,7 @@ from dataclasses import dataclass, field from typing import Self -from openhcs.agent.dto.common import ( - AgentCliArgumentSpec, - AgentCliRequest, - AgentResultEnvelope, -) +from formbridge.contracts import AgentCliArgumentSpec, AgentCliRequest, AgentResultEnvelope from python_introspect import JsonObject diff --git a/openhcs/agent/dto/execution.py b/openhcs/agent/dto/execution.py index ff525b2613..fe6932cc5f 100644 --- a/openhcs/agent/dto/execution.py +++ b/openhcs/agent/dto/execution.py @@ -24,8 +24,8 @@ from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus from zmqruntime.client import EndpointShutdownMode, EndpointShutdownResult -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import ( AgentCliArgumentSpec, AgentCliRequest, AgentError, diff --git a/openhcs/agent/dto/execution_connection.py b/openhcs/agent/dto/execution_connection.py index 8a63ac6ba5..4c7957f6f6 100644 --- a/openhcs/agent/dto/execution_connection.py +++ b/openhcs/agent/dto/execution_connection.py @@ -2,99 +2,27 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field, replace +from dataclasses import dataclass, field from typing import TYPE_CHECKING -from python_introspect import ( - JsonObject, - project_dataclass, - validate_annotated_dataclass, -) -from zmqruntime.config import NonBlankString, SocketPort, TransportMode -from zmqruntime.transport import TransportEndpoint +from formbridge.connection import EndpointConnectionFields, EndpointConnectionSpec +from python_introspect import project_dataclass -from openhcs.agent.dto.common import AgentDataclassCliRequest -from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig +from openhcs.runtime.zmq_config import OpenHCSZMQConfig if TYPE_CHECKING: from openhcs.runtime.zmq_execution_client import ZMQExecutionClient @dataclass(frozen=True, slots=True) -class ExecutionConnectionSpec(AgentDataclassCliRequest): - host: NonBlankString = "localhost" - port: SocketPort | None = None - transport_mode: TransportMode | None = None - persistent: bool = True - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - def require_port(self, purpose: str) -> int: - if self.port is None: - raise ValueError(f"{purpose} requires an explicit port.") - return self.port - - def transport_mode_value(self) -> str | None: - return TransportMode.optional_to_text(self.transport_mode) - - def runtime_arguments(self) -> dict[str, object]: - """Project exact typed constructor arguments for this connection.""" - - return asdict(self.public_connection()) - - def specified_runtime_arguments(self) -> dict[str, object]: - """Project typed constructor arguments while omitting unset optionals.""" - - return { - name: value - for name, value in self.runtime_arguments().items() - if value is not None - } - - def tool_arguments(self) -> JsonObject: - """Project the canonical JSON-compatible tool argument fields.""" - - arguments = self.runtime_arguments() - arguments["transport_mode"] = self.transport_mode_value() - return arguments +class ExecutionConnectionSpec(EndpointConnectionSpec): + """Connection to an OpenHCS execution server.""" def public_connection(self) -> ExecutionConnectionSpec: - """Return the credential-free base connection declaration.""" + """Return the credential-free execution connection declaration.""" return project_dataclass(ExecutionConnectionSpec, self) - def zmq_data_url(self, config: OpenHCSZMQConfig) -> str: - return self.transport_endpoint(config, purpose="ZMQ data URL").data_url(config) - - def zmq_control_port(self, config: OpenHCSZMQConfig) -> int: - return self.transport_endpoint(config, purpose="ZMQ control port").control_port(config) - - def zmq_control_url(self, config: OpenHCSZMQConfig) -> str: - return self.transport_endpoint(config, purpose="ZMQ control URL").control_url(config) - - def transport_endpoint( - self, - config: OpenHCSZMQConfig = OPENHCS_ZMQ_CONFIG, - *, - purpose: str = "ZMQ transport endpoint", - ) -> TransportEndpoint: - """Project the exact route using the supplied execution config owner.""" - - return config.client_endpoint( - self.require_port(purpose), - host=self.host, - transport_mode=self.transport_mode, - ) - - def resolved(self, config: OpenHCSZMQConfig) -> ExecutionConnectionSpec: - """Retain effective routing in the public nominal connection.""" - - return replace( - self.public_connection(), - transport_mode=self.transport_endpoint(config).transport_mode, - ) - def execution_client( self, config: OpenHCSZMQConfig, @@ -113,19 +41,7 @@ def execution_client( @dataclass(frozen=True, kw_only=True) -class ExecutionConnectionFields: +class ExecutionConnectionFields(EndpointConnectionFields): """A request or result that carries one execution connection.""" connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec) - - @property - def host(self) -> str: - return self.connection.host - - @property - def port(self) -> int | None: - return self.connection.port - - @property - def transport_mode(self) -> TransportMode | None: - return self.connection.transport_mode diff --git a/openhcs/agent/dto/functions.py b/openhcs/agent/dto/functions.py index 3ab930d077..ba91801830 100644 --- a/openhcs/agent/dto/functions.py +++ b/openhcs/agent/dto/functions.py @@ -16,12 +16,8 @@ from zmqruntime.messages import MessageFields, ProcessIdentity, ResponseType from zmqruntime.startup import EndpointStartupStatus -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentError, - AgentDataclassCliRequest, - AgentResultEnvelope, -) +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentDataclassCliRequest, AgentResultEnvelope from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec from openhcs.agent.exceptions import AgentFacingErrorMixin from openhcs.agent.path_policy import AgentPathPolicy diff --git a/openhcs/agent/dto/knowledge.py b/openhcs/agent/dto/knowledge.py index 50d65f8578..dbcbf41bab 100644 --- a/openhcs/agent/dto/knowledge.py +++ b/openhcs/agent/dto/knowledge.py @@ -6,11 +6,7 @@ from dataclasses import dataclass, replace from typing import Self -from openhcs.agent.dto.common import ( - AgentCliArgumentSpec, - AgentCliRequest, - AgentResultEnvelope, -) +from formbridge.contracts import AgentCliArgumentSpec, AgentCliRequest, AgentResultEnvelope from python_introspect import JsonObject diff --git a/openhcs/agent/dto/mcp.py b/openhcs/agent/dto/mcp.py index 20eacf4665..126b2c490c 100644 --- a/openhcs/agent/dto/mcp.py +++ b/openhcs/agent/dto/mcp.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from openhcs.agent.dto.common import AgentError, AgentTimedStatusEnvelope +from formbridge.contracts import AgentError, AgentTimedStatusEnvelope @dataclass(frozen=True, slots=True) diff --git a/openhcs/agent/dto/pipeline.py b/openhcs/agent/dto/pipeline.py index ce813cc3d3..958a9e3d80 100644 --- a/openhcs/agent/dto/pipeline.py +++ b/openhcs/agent/dto/pipeline.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from openhcs.agent.dto.common import AgentResultEnvelope +from formbridge.contracts import AgentResultEnvelope from python_introspect import JsonObject, JsonValue from openhcs.agent.dto.config import ConfigPatch from openhcs.agent.dto.functions import FunctionIdentity diff --git a/openhcs/agent/dto/plate.py b/openhcs/agent/dto/plate.py index a4a288d82e..9079b42e4b 100644 --- a/openhcs/agent/dto/plate.py +++ b/openhcs/agent/dto/plate.py @@ -11,7 +11,7 @@ from metaclass_registry import AutoRegisterMeta from zmqruntime.config import TransportMode -from openhcs.agent.dto.common import ( +from formbridge.contracts import ( AGENT_PARAMETER_DESCRIPTION_METADATA_KEY, AgentError, AgentResultEnvelope, diff --git a/openhcs/agent/dto/session.py b/openhcs/agent/dto/session.py index 45e37b84cc..ffb953e16a 100644 --- a/openhcs/agent/dto/session.py +++ b/openhcs/agent/dto/session.py @@ -4,12 +4,8 @@ from dataclasses import dataclass, field -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentDataclassCliRequest, - AgentError, - AgentWarning, -) +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentDataclassCliRequest, AgentError, AgentWarning from enum import Enum from python_introspect import validate_annotated_dataclass @@ -166,6 +162,14 @@ class DatasetRowState: """The dataset's latest finished ordinary execution (its evidence id).""" scope_accent_color: str | None = None + @property + def queued(self) -> bool: + return self.queue_position is not None + + @property + def has_terminal_status(self) -> bool: + return self.terminal_status is not None + @dataclass(frozen=True, slots=True) class DatasetListState: diff --git a/openhcs/agent/dto/ui_bridge.py b/openhcs/agent/dto/ui_bridge.py index 808abe0724..75dcb9eddb 100644 --- a/openhcs/agent/dto/ui_bridge.py +++ b/openhcs/agent/dto/ui_bridge.py @@ -1,139 +1,44 @@ -"""DTOs for the OpenHCS running-UI bridge agent API.""" +"""The OpenHCS UI-bridge state surfaces: datasets, pipeline editor, debug session, live measurements.""" from __future__ import annotations -from abc import ABC, abstractmethod -from collections.abc import Mapping -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from enum import Enum -from math import isfinite -from typing import TYPE_CHECKING, ClassVar, TypeVar - -from metaclass_registry import AutoRegisterMeta -from objectstate import DottedFieldPath -from pyqt_reactive.services.widget_tree_projection_config import ( - COMPACT_FIELD_PROJECTION_METADATA_KEY, - CompactFieldProjection, - WidgetNodeIdentity, - WidgetTreeProjectionControls, - WidgetTreeProjectionPolicy, - always_project_compact_field, - compact_dataclass_projection, -) -from pyqt_reactive.services.window_snapshot import ( - WindowSnapshotCaptureScope, - WindowSnapshotCaptureSpec, - WindowSnapshotFrameCondition, - WindowVisualObservation, -) +from typing import TYPE_CHECKING from python_introspect import ( JsonObject, - JsonValue, - overlay_non_none_dataclass, - project_dataclass, - to_jsonable, - validate_annotated_dataclass, -) -from zmqruntime import EndpointApplication -from zmqruntime.config import ( - NonBlankString, - PositiveInteger, - SocketPort, - TransportMode, ) - -from openhcs.core.selection import AllItems, SelectedItems +import openhcs.agent.ui_bridge_application # noqa: F401 (declares the OpenHCS bridge application) from openhcs.agent.dto.session import DatasetRowState, PipelineStepState -from openhcs.agent.dto.common import ( - AgentDataclassCliRequest, +from formbridge.contracts import ( AGENT_PARAMETER_DESCRIPTION_METADATA_KEY, AGENT_PARAMETER_PRODUCER_OUTPUT_CONTRACT_METADATA_KEY, AgentCliRequest, AgentError, - AgentResourceRef, - AgentResultEnvelope, - AgentTimedStatusEnvelope, AgentWarning, ) -from openhcs.agent.dto.execution_connection import ( - ExecutionConnectionFields, - ExecutionConnectionSpec, -) -from openhcs.agent.path_policy import DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR from openhcs.authoring.session.operations import SessionOperation from openhcs.authoring.session.operations.datasets import DatasetWorkflowOperation -from openhcs.agent.ui_bridge_identities import ( - MainWindowWidgetIdentity as MainWindowWidgetIdentity, -) -from openhcs.agent.ui_bridge_identities import ( - ManagedWindowWidgetIdentity as ManagedWindowWidgetIdentity, -) -from openhcs.agent.ui_bridge_identities import ( - PipelineDebugSessionStateSurfaceIdentityDeclaration as PipelineDebugSessionStateSurfaceIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - PipelineEditorStateSurfaceIdentityDeclaration as PipelineEditorStateSurfaceIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - PipelineEditorWidgetIdentity as PipelineEditorWidgetIdentity, -) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerOrchestratorCodeDocumentIdentity as PlateManagerOrchestratorCodeDocumentIdentity, -) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerStateSurfaceIdentityDeclaration as PlateManagerStateSurfaceIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerWidgetIdentity as PlateManagerWidgetIdentity, -) -from openhcs.agent.ui_bridge_identities import ( - UiBridgeIdentityDeclaration as UiBridgeIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - UiCodeDocumentIdentityDeclaration as UiCodeDocumentIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - UiLiveOverviewStateSurfaceIdentityDeclaration as UiLiveOverviewStateSurfaceIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - UiOwnedByWidgetIdentityDeclaration as UiOwnedByWidgetIdentityDeclaration, -) -from openhcs.agent.ui_bridge_identities import ( - UiStateSurfaceIdentityDeclarationBase as UiStateSurfaceIdentityDeclarationBase, -) -from openhcs.agent.ui_bridge_identities import ( - UiWidgetIdentityDeclaration as UiWidgetIdentityDeclaration, -) -from openhcs.core.selection import ( +from openhcs.agent.ui_bridge_identities import DatasetListStateSurfaceIdentity +from formbridge.selection import ( SelectedScopeIdsArgument, SelectedScopeIdsCarrier, - SelectionModeCarrier, ) - -UI_BRIDGE_UNKNOWN_OPERATION = "unknown" -UI_BRIDGE_UNKNOWN_WIDGET = "unknown" -_UiBridgeOperationSelectionT = TypeVar("_UiBridgeOperationSelectionT") - - -class UiLiveOverviewSeverity(str, Enum): - INFO = "info" - WARNING = "warning" - ERROR = "error" - - -def _identity_enum( - enum_name: str, - identity_type: type[UiBridgeIdentityDeclaration], -) -> type[Enum]: - """Project one public string enum from registered UI identity declarations.""" - members = { - declaration.enum_member_name: declaration.value - for declaration in UiBridgeIdentityDeclaration.__registry__.values() - if issubclass(declaration, identity_type) - and declaration.enum_member_name is not None - and declaration.value is not None - } - return Enum(enum_name, members, type=str) +from formbridge.bridge.dto import ( + UiActionCatalog, + UiActionInvokeResult, + UiBridgeConfirmationRequirement, + UiBridgeConfirmationRequirementCarrier, + UiCodeDocumentCurrentRevision, + UiCurrentSnapshotState, + UiMutationRequestToken, + UiMutationRequestTokenCarrier, + UiStateSurfaceEnvelope, +) +if TYPE_CHECKING: + from openhcs.core.progress.live_measurements import ( + LiveMeasurementTablePreview, + ) def _dataset_workflow_enum() -> type[Enum]: @@ -146,2395 +51,161 @@ def _dataset_workflow_enum() -> type[Enum]: return Enum("UiSelectedPlateWorkflowKind", members, type=str) -UiCodeDocumentId = _identity_enum("UiCodeDocumentId", UiCodeDocumentIdentityDeclaration) -UiStateSurfaceId = _identity_enum( - "UiStateSurfaceId", - UiStateSurfaceIdentityDeclarationBase, -) -UiWidgetId = _identity_enum("UiWidgetId", UiWidgetIdentityDeclaration) UiSelectedPlateWorkflowKind = _dataset_workflow_enum() -class UiBridgeOperationStatus(ABC, metaclass=AutoRegisterMeta): - """State of one bridge operation; each state is a subclass. - - ``name`` is the state's spelling in ``UiBridgeOperationRef.status``. A - state that is not running or completed failed: it is terminal, reported as - an error, and selects the caller's failed output. - """ - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - is_terminal: ClassVar[bool] = True - live_overview_severity: ClassVar[str] = UiLiveOverviewSeverity.ERROR.value - - @staticmethod - def named(name: str) -> type[UiBridgeOperationStatus]: - """Return the state spelled ``name``; ``ValueError`` when unknown.""" - - status = UiBridgeOperationStatus.__registry__.get(name) - if status is None: - raise ValueError(f"Unknown UI bridge operation status {name!r}.") - return status - - @classmethod - def select_completion( - cls, - *, - active: _UiBridgeOperationSelectionT, - succeeded: _UiBridgeOperationSelectionT, - failed: _UiBridgeOperationSelectionT, - ) -> _UiBridgeOperationSelectionT: - """Select caller-owned output for this state.""" - - return failed - - -class OperationRunning(UiBridgeOperationStatus): - name = "running" - is_terminal = False - live_overview_severity = UiLiveOverviewSeverity.INFO.value - - @classmethod - def select_completion(cls, *, active, succeeded, failed): - return active - - -class OperationCompleted(UiBridgeOperationStatus): - name = "completed" - live_overview_severity = UiLiveOverviewSeverity.INFO.value - - @classmethod - def select_completion(cls, *, active, succeeded, failed): - return succeeded - - -class OperationFailed(UiBridgeOperationStatus): - name = "failed" - - -class OperationNotFound(UiBridgeOperationStatus): - name = "not_found" - - -class OperationUnavailable(UiBridgeOperationStatus): - name = "unavailable" - - -if TYPE_CHECKING: - from openhcs.core.progress.live_measurements import ( - LiveMeasurementTablePreview, - ) - - -@dataclass(frozen=True, kw_only=True) -class UiBridgeInstanceIdentity: - bridge_instance_id: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiBridgeApplicationIdentity: - """Application identity observed at a UI bridge boundary.""" - - application: EndpointApplication | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiBridgeDescriptorFileRef: - descriptor_file_path: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiCodeDocumentIdentity: - document_id: str - - def as_document_identity(self) -> "UiCodeDocumentIdentity": - if type(self) is UiCodeDocumentIdentity: - return self - return UiCodeDocumentIdentity(document_id=self.document_id) - - -@dataclass(frozen=True, kw_only=True) -class UiStateSurfaceIdentity: - surface_id: str - - def as_surface_identity(self) -> "UiStateSurfaceIdentity": - if type(self) is UiStateSurfaceIdentity: - return self - return UiStateSurfaceIdentity(surface_id=self.surface_id) - - -@dataclass(frozen=True, kw_only=True) -class UiWidgetIdentity: - widget_id: str - - -@dataclass(frozen=True, slots=True) -class UiSemanticAddress: - """Stable semantic address shared by ObjectState, windows, and code surfaces.""" - - object_state_scope_id: str - field_path: str - window_id: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiActionIdentity(UiWidgetIdentity): - action_id: str - - -@dataclass(frozen=True, kw_only=True) -class UiWindowIdentity: - window_id: str - - def as_identity(self) -> "UiWindowIdentity": - if type(self) is UiWindowIdentity: - return self - return UiWindowIdentity(window_id=self.window_id) - - -@dataclass(frozen=True, kw_only=True) -class UiObjectStateScopeIdentity: - object_state_scope_id: str - - -@dataclass(frozen=True, slots=True) -class UiObjectStateScopeVisibility: - include_system_scopes: bool = False - - @classmethod - def from_mapping( - cls, - value: Mapping[str, JsonValue] | None, - ) -> "UiObjectStateScopeVisibility": - if value is None: - return cls() - include_system_scopes = value.get("include_system_scopes") - if isinstance(include_system_scopes, bool): - return cls(include_system_scopes=include_system_scopes) - return cls() - - -class UiObjectStateFieldFilter(ABC, metaclass=AutoRegisterMeta): - """Agent-facing filter over existing ObjectState field semantics. - - Each filter is a subclass; ``name`` is its spelling in tool arguments. - """ - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - - @staticmethod - def named(name: str) -> type[UiObjectStateFieldFilter]: - field_filter = UiObjectStateFieldFilter.__registry__.get(name) - if field_filter is None: - raise ValueError( - f"Unsupported ObjectState field filter {name!r}; expected one of " - f"{tuple(UiObjectStateFieldFilter.__registry__)!r}." - ) - return field_filter - - @classmethod - @abstractmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - """Return whether ``field`` passes this filter.""" - - -class AllFields(UiObjectStateFieldFilter): - name = "all" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return True - - -class DirtyFields(UiObjectStateFieldFilter): - name = "dirty" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return field.dirty - - -class DefaultDiffFields(UiObjectStateFieldFilter): - name = "default_diff" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return field.signature_diff - - -class InheritedFields(UiObjectStateFieldFilter): - name = "inherited" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return field.inherited_value - - -class RawResolvedFields(UiObjectStateFieldFilter): - name = "raw_resolved" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return field.raw_value_is_none != field.resolved_value_is_none - - -class SemanticFields(UiObjectStateFieldFilter): - name = "semantic" - - @classmethod - def matches(cls, field: "UiObjectStateFieldSummary") -> bool: - return any( - part.matches(field) - for part in (DirtyFields, DefaultDiffFields, InheritedFields, RawResolvedFields) - ) - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldListOptions: - include_fields: bool = False - field_limit: int = 200 - field_offset: int = 0 - include_field_values: bool = False - include_field_descriptions: bool = False - field_paths: tuple[str, ...] = () - field_filter: type[UiObjectStateFieldFilter] = AllFields - - -@dataclass(frozen=True, kw_only=True) -class UiSnapshotIdentity: - snapshot_id: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiCurrentSnapshotState: - current_snapshot: "UiSnapshotRef | None" = None - - -@dataclass(frozen=True, kw_only=True) -class UiCodeDocumentCurrentRevision: - current_revision_token: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiCodeDocumentBaseRevision: - base_revision_token: str = field( - metadata={ - AGENT_PARAMETER_DESCRIPTION_METADATA_KEY: ( - "Base revision returned by the corresponding state/code-document " - "read operation; this is not an action selection token." - ) - } - ) - - -@dataclass(frozen=True, kw_only=True) -class UiCodeDocumentOptionalBaseRevision: - base_revision_token: str | None = field( - default=None, - metadata={ - AGENT_PARAMETER_DESCRIPTION_METADATA_KEY: ( - "Optional base state/code-document revision returned by the " - "corresponding read operation; this is not an action selection " - "token." - ) - }, - ) - - -@dataclass(frozen=True, kw_only=True) -class UiTimeTravelRuntimeState: - active: bool = False - - -@dataclass(frozen=True, slots=True) -class UiBridgeConfirmationRequirement: - """Explicit confirmation guard state for UI bridge mutations.""" - - required: bool = True - - @classmethod - def from_flag(cls, required: bool) -> "UiBridgeConfirmationRequirement": - return cls(required=required) - - -@dataclass(frozen=True, kw_only=True) -class UiBridgeConfirmationRequirementCarrier: - """Shared confirmation policy behavior for UI bridge mutations.""" - - confirmation_requirement: UiBridgeConfirmationRequirement = ( - UiBridgeConfirmationRequirement() - ) - - def confirmation_is_required(self) -> bool: - return self.confirmation_requirement.required - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiBridgeConnectionFields( - UiBridgeInstanceIdentity, - UiBridgeDescriptorFileRef, -): - """Sparse connection-field projection for descriptor/env/MCP inputs.""" - - host: NonBlankString | None = None - port: SocketPort | None = None - transport_mode: TransportMode | None = None - persistent: bool | None = None - timeout_ms: PositiveInteger | None = None - auth_token: NonBlankString | None = None - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - -@dataclass(frozen=True, slots=True) -class UiBridgeConnectionRequest(UiBridgeConnectionFields): - """MCP-facing sparse connection request for a running UI bridge.""" - - -@dataclass(frozen=True, slots=True) -class UiBridgeConnectionSpec( - ExecutionConnectionSpec, - UiBridgeInstanceIdentity, - UiBridgeDescriptorFileRef, -): - timeout_ms: PositiveInteger = 5000 - auth_token: NonBlankString | None = None - - @classmethod - def from_fields( - cls, - fields: UiBridgeConnectionFields, - defaults: "UiBridgeConnectionSpec | None" = None, - ) -> "UiBridgeConnectionSpec": - if defaults is None: - defaults = cls() - return overlay_non_none_dataclass(defaults, fields) - - -@dataclass(frozen=True, slots=True) -class UiBridgeEndpointIdentity( - ExecutionConnectionFields, - UiBridgeInstanceIdentity, - UiBridgeDescriptorFileRef, -): - """Exact public identity of one descriptor-backed UI bridge endpoint.""" - - @classmethod - def from_value( - cls, - value: ExecutionConnectionFields, - ) -> UiBridgeEndpointIdentity: - """Project identity from any declared UI bridge endpoint carrier.""" - - return project_dataclass( - cls, - value, - connection=value.connection.public_connection(), - ) - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorEnvelope( - UiBridgeApplicationIdentity, - UiBridgeInstanceIdentity, -): - """Shared token-bearing descriptor metadata for UI bridge descriptor forms.""" - - schema_version: str - bridge_protocol_version: str - pid: int - started_at_unix: float - auth_token: str - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorFile( - ExecutionConnectionFields, - UiBridgeDescriptorEnvelope, - UiBridgeDescriptorFileRef, -): - """Internal token-bearing descriptor-file payload.""" - - def resolve_connection( - self, - defaults: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionSpec: - """Overlay this descriptor's declared endpoint and bridge fields.""" - - return overlay_non_none_dataclass( - overlay_non_none_dataclass(defaults, self.connection), - self, - ) - - def public_summary(self, status: str) -> UiBridgeDescriptorSummary: - """Project this descriptor to its token-free public representation.""" - - return project_dataclass(UiBridgeDescriptorSummary, self, status=status) - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorWirePayload( - ExecutionConnectionFields, - UiBridgeDescriptorEnvelope, -): - """JSON descriptor file payload written by a running UI bridge.""" - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorSummary( - AgentTimedStatusEnvelope, - ExecutionConnectionFields, - UiBridgeApplicationIdentity, - UiBridgeInstanceIdentity, - UiBridgeDescriptorFileRef, -): - pid: int - - -@dataclass(frozen=True, slots=True) -class UiBridgeCatalog: - schema_version: str - bridges: tuple[UiBridgeDescriptorSummary, ...] = () - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - @dataclass(frozen=True, slots=True) -class UiBridgeStatus( - ExecutionConnectionFields, - UiBridgeInstanceIdentity, - UiBridgeDescriptorFileRef, +class UiPlateManagerState( + UiStateSurfaceEnvelope, + UiCodeDocumentCurrentRevision, + UiCurrentSnapshotState, + SelectedScopeIdsCarrier, ): - schema_version: str - reachable: bool - service: str = "openhcs.ui_bridge" - auth_required: bool = True - supported_operations: tuple[str, ...] = () - provider_catalog_schema_versions: tuple[str, ...] = () - bridge_features: tuple[str, ...] = () - descriptor_status: str | None = None - descriptors: tuple[UiBridgeDescriptorSummary, ...] = () - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiSnapshotRef(UiSnapshotIdentity): - schema_version: str - index: int - branch: str - parent_snapshot_id: str | None - timestamp_unix: float - timestamp: str - label: str - num_states: int - is_current: bool - is_head: bool - uri: str - - -@dataclass(frozen=True, slots=True) -class UiBranchRef: - schema_version: str - name: str - head_snapshot_id: str - base_snapshot_id: str - description: str - is_current: bool + object_state_token: int + manager_execution_state: str + rows: tuple[DatasetRowState, ...] @dataclass(frozen=True, slots=True) -class UiCodeDocumentSummary(UiWidgetIdentity): - schema_version: str - identity: UiCodeDocumentIdentity - title: str - readable: bool - writable: bool - supported_selection_modes: tuple[str, ...] = () - current_selection_count: int = 0 - total_scope_count: int = 0 +class UiLiveMeasurementEntryState: + """One retained progress event paired with its authoritative table preview.""" - @property - def document_id(self) -> str: - return self.identity.document_id + sequence_id: int + execution_id: str + subject_id: str + axis_id: str + step_name: str + preview: LiveMeasurementTablePreview + truncated_preview_group: bool -@dataclass(frozen=True, slots=True) -class UiCodeDocumentCatalog: - schema_version: str - documents: tuple[UiCodeDocumentSummary, ...] - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiStateSurfaceSummary(UiWidgetIdentity): - schema_version: str - identity: UiStateSurfaceIdentity - title: str - readable: bool - supported_selection_modes: tuple[str, ...] = () - current_selection_count: int = 0 - total_scope_count: int = 0 - - @property - def surface_id(self) -> str: - return self.identity.surface_id - - -@dataclass(frozen=True, slots=True) -class UiStateSurfaceCatalog: - schema_version: str - surfaces: tuple[UiStateSurfaceSummary, ...] - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiCatalogPageMetadata: - """Bounded catalog page metadata for growing UI bridge listings.""" - - limit: int - offset: int = 0 - returned_count: int = 0 - total_count: int | None = None - truncated: bool = False - next_offset: int | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiCatalogPageCarrier: - """Inherited carrier for catalog responses that expose a nested page object.""" - - page: UiCatalogPageMetadata | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiFieldCatalogPageCarrier: - """Inherited carrier for field-list pagination nested under a scope summary.""" - - field_page: UiCatalogPageMetadata | None = None - - -@dataclass(frozen=True, slots=True) -class UiPageRequest: - """Shared page request for UI bridge catalogs.""" - - limit: int = 100 - offset: int = 0 - - -@dataclass(frozen=True, slots=True) -class UiCodeDocumentRequest( - UiCodeDocumentIdentity, - SelectionModeCarrier, - AgentCliRequest, -): - clean: bool = True - - @classmethod - def from_fields( - cls, - *, - document_id: str, - selection_mode: str = SelectedItems.name, - clean: bool = True, - ) -> "UiCodeDocumentRequest": - return cls( - document_id=document_id, - selection_mode=selection_mode, - clean=clean, - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "document_id": self.document_id, - "selection_mode": self.selection_mode, - "clean": self.clean, - } - - -@dataclass(frozen=True, slots=True) -class UiStateSurfaceRequest( - UiStateSurfaceIdentity, - SelectionModeCarrier, - UiCodeDocumentOptionalBaseRevision, - AgentCliRequest, -): - @classmethod - def from_fields( - cls, - *, - surface_id: str = PlateManagerStateSurfaceIdentityDeclaration.require_value(), - selection_mode: str = AllItems.name, - base_revision_token: str | None = None, - ) -> "UiStateSurfaceRequest": - return cls( - surface_id=surface_id, - selection_mode=selection_mode, - base_revision_token=base_revision_token, - ) - - def as_tool_arguments(self) -> JsonObject: - """Project the declared state-surface factory fields to MCP arguments.""" - - return { - "surface_id": self.surface_id, - "selection_mode": self.selection_mode, - "base_revision_token": self.base_revision_token, - } - - -@dataclass(frozen=True, slots=True) -class UiCodeDocument( - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - schema_version: str - summary: UiCodeDocumentSummary - source: str - mime_type: str - size_bytes: int - sha256: str - warnings: tuple[AgentWarning, ...] = () - errors: tuple[AgentError, ...] = () - - - - - - -@dataclass(frozen=True, kw_only=True) -class UiStateSurfaceEnvelope(AgentResultEnvelope): - """Shared envelope for typed UI state-surface payloads.""" - - summary: UiStateSurfaceSummary - unchanged: bool = False - - -@dataclass(frozen=True, slots=True) -class UiPlateManagerState( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - object_state_token: int - manager_execution_state: str - rows: tuple[DatasetRowState, ...] - - -@dataclass(frozen=True, slots=True) -class UiLiveMeasurementEntryState: - """One retained progress event paired with its authoritative table preview.""" - - sequence_id: int - execution_id: str - subject_id: str - axis_id: str - step_name: str - preview: LiveMeasurementTablePreview - truncated_preview_group: bool - - -@dataclass(frozen=True, slots=True) -class UiLiveMeasurementsState( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - """Bounded live measurement tables retained by the running Plate Manager.""" - - object_state_token: int - retained_entry_count: int - visible_entry_count: int - total_row_count: int - latest_sequence_id: int | None - entries: tuple[UiLiveMeasurementEntryState, ...] - - -@dataclass(frozen=True, slots=True) -class UiPipelineEditorState( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - object_state_token: int - current_plate_scope_id: str | None - pipeline_scope_id: str | None - steps: tuple[PipelineStepState, ...] - - -@dataclass(frozen=True, slots=True) -class UiDebugActionState(SelectedScopeIdsCarrier): - action_id: str - label: str - placement: str - enabled: bool - side_effects: tuple[str, ...] - confirmation_required: bool - requires_active_debug_session: bool - disabled_error: AgentError | None = None - - -@dataclass(frozen=True, slots=True) -class UiDebugCursorState: - step_index: int - step_scope_id: str | None - group_key: str | None - invocation_key: str | None - pattern_group_identity: str | None - dirty: bool = False - - -@dataclass(frozen=True, slots=True) -class UiDebugTerminalSummaryState: - debug_session_id: str - plate_scope_id: str - terminal_status: str - command_type: str | None - axis_id: str | None - snapshot_id: str | None - snapshot_store_ref: str | None - snapshot_store_backend: str | None - step_name: str | None - callable_name: str | None - cursor: UiDebugCursorState | None - completed_at_unix: float | None - - -@dataclass(frozen=True) -class UiDebugSessionIdentityCarrier: - debug_session_id: str - - -@dataclass(frozen=True) -class UiDebugSnapshotStoreRefCarrier: - snapshot_store_ref: str | None - snapshot_store_backend: str | None - - -@dataclass(frozen=True, slots=True) -class UiProgressIdentityState: - execution_id: str - subject_id: str - axis_id: str - step_name: str - - -@dataclass(frozen=True, slots=True) -class UiDebugRuntimeFrameState( - UiDebugSessionIdentityCarrier, - UiDebugSnapshotStoreRefCarrier, -): - progress_identity: UiProgressIdentityState - cursor: UiDebugCursorState - event_type: str - step_name: str - callable_name: str | None - snapshot_id: str | None - timestamp: float - - -@dataclass(frozen=True, slots=True) -class UiPipelineDebugSessionState( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - object_state_token: int - current_plate_scope_id: str | None - pipeline_scope_id: str | None - manager_execution_state: str - initialized: bool - compiled: bool - phase: str - active_session_id: str | None - execution_id: str | None - axis_id: str | None - selected_source_group: str | None - snapshot_store_ref: str | None - snapshot_store_backend: str | None - terminal_status: str | None - cursor: UiDebugCursorState | None - terminal_summary: UiDebugTerminalSummaryState | None - actions: tuple[UiDebugActionState, ...] - current_frame: UiDebugRuntimeFrameState | None = None - last_frame: UiDebugRuntimeFrameState | None = None - - -@dataclass(frozen=True, slots=True) -class UiLiveOverviewMetric: - key: str - label: str - value: str - - -@dataclass(frozen=True, slots=True) -class UiLiveOverviewItem: - label: str - status: str | None = None - detail: str | None = None - severity: str = UiLiveOverviewSeverity.INFO.value - source_surface_id: str | None = None - source_window_id: str | None = None - source_widget_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class UiLiveOverviewSection: - section_id: str - title: str - summary: str - metrics: tuple[UiLiveOverviewMetric, ...] = () - items: tuple[UiLiveOverviewItem, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiLiveOverviewState( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - object_state_token: int - sections: tuple[UiLiveOverviewSection, ...] - - -@dataclass(frozen=True, slots=True) -class UiStateSurfaceDocument( - UiStateSurfaceEnvelope, - UiCodeDocumentCurrentRevision, - UiCurrentSnapshotState, - SelectedScopeIdsCarrier, -): - payload_schema: str - payload: JsonObject - - -class UiActionInvocationStatus(ABC, metaclass=AutoRegisterMeta): - """Whether the UI took a requested action; ``name`` is its wire spelling.""" - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - accepted: ClassVar[bool] = False - - -class ActionAccepted(UiActionInvocationStatus): - name = "accepted" - accepted = True - - -class ActionRejected(UiActionInvocationStatus): - name = "rejected" - - -class ActionUnavailable(UiActionInvocationStatus): - name = "unavailable" - - -class UiWidgetActionInvocationOutcome(str, Enum): - """Closed terminal outcomes for one projected-widget action.""" - - invoked: bool - - INVOKED = ("invoked", True) - NOT_INVOKED = ("not_invoked", False) - - def __new__( - cls, - value: str, - invoked: bool, - ) -> "UiWidgetActionInvocationOutcome": - member = str.__new__(cls, value) - member._value_ = value - member.invoked = invoked - return member - - @classmethod - def from_invoked(cls, invoked: bool) -> "UiWidgetActionInvocationOutcome": - return cls.INVOKED if invoked else cls.NOT_INVOKED - - -@dataclass(frozen=True, slots=True) -class UiMutationRequestToken: - value: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class UiMutationRequestTokenCarrier: - """Shared request-token carrier for bridge mutation requests.""" - - request_token: UiMutationRequestToken = UiMutationRequestToken() - - -@dataclass(frozen=True, slots=True) -class UiMutationReceipt: - request_token: UiMutationRequestToken - bridge_operation_id: str | None = None - accepted: bool = False - - @classmethod - def accepted_for( - cls, - request_token: UiMutationRequestToken, - *, - bridge_operation_id: str | None = None, - ) -> "UiMutationReceipt": - return cls( - request_token=request_token, - bridge_operation_id=bridge_operation_id, - accepted=True, - ) - - @classmethod - def rejected_for( - cls, - request_token: UiMutationRequestToken, - *, - bridge_operation_id: str | None = None, - ) -> "UiMutationReceipt": - return cls( - request_token=request_token, - bridge_operation_id=bridge_operation_id, - accepted=False, - ) - - -@dataclass(frozen=True, slots=True) -class UiActionSummary(SelectionModeCarrier): - schema_version: str - identity: UiActionIdentity - title: str - enabled: bool - invocation_mode: str - side_effects: tuple[str, ...] = () - disabled_error: AgentError | None = None - confirmation_required: bool = False - current_selection_count: int = 0 - target_scope_ids: tuple[str, ...] = () - selection_revision_token: str | None = None - related_state_surface_ids: tuple[str, ...] = () - required_target_count: int | None = None - - def invocation_guard_error( - self, - request: UiActionInvokeRequest, - ) -> AgentError | None: - """Validate an invocation against this action's authoritative summary.""" - if not self.enabled: - return self.disabled_error or AgentError( - code="ui_action_disabled", - message=f"UI action {request.action_id!r} is disabled.", - ) - if self.confirmation_required and request.confirmation_is_required(): - return AgentError( - code="confirmation_required", - message=( - "This UI action mutates state; set require_confirmation=False " - "to dispatch it." - ), - ) - if ( - self.required_target_count is not None - and len(request.selected_scope_ids) != self.required_target_count - ): - return AgentError( - code="ui_action_target_required", - message=( - f"UI action {request.action_id!r} requires exactly " - f"{self.required_target_count} target scope(s)." - ), - ) - if self.required_target_count is not None: - unavailable_targets = set(request.selected_scope_ids) - set( - self.target_scope_ids - ) - if unavailable_targets: - return AgentError( - code="unknown_ui_action_target", - message="Requested target scope is not available for this UI action.", - ) - elif ( - request.selected_scope_ids - and request.selected_scope_ids != self.target_scope_ids - ): - return AgentError( - code="stale_ui_action_selection", - message="Requested target scopes do not match current UI action targets.", - hint=self.selection_bound_hint(request), - ) - if ( - request.observed_selection_revision_token is not None - and self.selection_revision_token is not None - and request.observed_selection_revision_token - != self.selection_revision_token - ): - return AgentError( - code="stale_ui_action_revision", - message="UI action selection changed after the action was planned.", - hint=( - "Call openhcs_ui_list_actions again for this widget and action, " - "then pass its selection_revision_token as " - "observed_selection_revision_token. State-surface " - "base_revision_token values belong to a different token family." - ), - ) - return None - - def selection_bound_hint(self, request: UiActionInvokeRequest) -> str: - """Explain how an invocation relates to the action's current targets.""" - requested_targets = ", ".join(request.selected_scope_ids) - current_targets = ", ".join(self.target_scope_ids) or "" - parts = [ - ( - f"{request.widget_id}/{request.action_id} is selection-bound " - f"(selection_mode={self.selection_mode or ''}); " - "--target-scope-id is a staleness guard for the current UI " - "selection, not direct ObjectState navigation." - ), - f"Current action targets: {current_targets}.", - ] - if self.related_state_surface_ids: - parts.append( - "Read related state surfaces before invoking: " - f"{', '.join(self.related_state_surface_ids)}." - ) - if len(request.selected_scope_ids) == 1: - parts.append( - "For a list-backed manager, select the desired item with " - "openhcs_ui_navigate_window using " - f"window_id={self.identity.widget_id!r} and item_id from its " - "current state surface. " - f"The requested target scope is {requested_targets!r}. " - "Then read the action again for its current selection revision." - ) - else: - parts.append( - "Change the selection in the owning manager, then read the action " - "again for its current targets and selection revision." - ) - return " ".join(parts) - - -@dataclass(frozen=True, slots=True) -class UiActionCatalog(UiCatalogPageCarrier): - schema_version: str - actions: tuple[UiActionSummary, ...] - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiActionInvokeRequest( - UiActionIdentity, - SelectedScopeIdsCarrier, - UiBridgeConfirmationRequirementCarrier, - UiMutationRequestTokenCarrier, - AgentCliRequest, -): - observed_selection_revision_token: str | None = field( - default=None, - metadata={ - AGENT_PARAMETER_DESCRIPTION_METADATA_KEY: ( - "Action-selection revision returned as selection_revision_token by " - "the action-catalog producer for the same widget, action, and target " - "selection; do not pass a state-surface revision token." - ), - AGENT_PARAMETER_PRODUCER_OUTPUT_CONTRACT_METADATA_KEY: UiActionCatalog, - }, - ) - - @classmethod - def from_fields( - cls, - *, - widget_id: str, - action_id: str, - target_scope_ids: list[str] | None = None, - observed_selection_revision_token: str | None = None, - request_token: str | None = None, - require_confirmation: bool = True, - ) -> "UiActionInvokeRequest": - selected_scope_ids = SelectedScopeIdsArgument.from_optional_iterable( - target_scope_ids - ) - return cls( - widget_id=widget_id, - action_id=action_id, - selected_scope_ids=selected_scope_ids.selected_scope_ids, - observed_selection_revision_token=observed_selection_revision_token, - request_token=UiMutationRequestToken(value=request_token), - confirmation_requirement=UiBridgeConfirmationRequirement.from_flag( - require_confirmation - ), - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "widget_id": self.widget_id, - "action_id": self.action_id, - "target_scope_ids": list(self.selected_scope_ids), - "observed_selection_revision_token": ( - self.observed_selection_revision_token - ), - "request_token": self.request_token.value, - "require_confirmation": self.confirmation_is_required(), - } - - -@dataclass(frozen=True, slots=True) -class UiActionInvokeResult: - schema_version: str - identity: UiActionIdentity - status: type[UiActionInvocationStatus] - receipt: UiMutationReceipt - target_scope_ids: tuple[str, ...] = () - selection_revision_token: str | None = None - workflow_status_surface_ids: tuple[str, ...] = () - event_sequence: int | None = field( - default=None, - metadata={ - AGENT_PARAMETER_DESCRIPTION_METADATA_KEY: ( - "Session event sequence the action started at; read the work it " - "started with openhcs_ui_session_events(after_sequence=...). " - "None when the action started no session work." - ), - }, - ) - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiWindowManagerScope: - """WindowManager scope token associated with a UI window identity.""" - - value: str - - @classmethod - def from_identity(cls, identity: UiWindowIdentity) -> "UiWindowManagerScope": - return cls(value=identity.window_id) - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowSemanticCarrier: - """Shared semantic state carried by agent-visible window summaries.""" - - object_state_scope_id: str | None = None - dirty: bool = False - signature_diff: bool = False - dirty_field_count: int = 0 - signature_diff_field_count: int = 0 - semantic_markers: tuple[str, ...] = () - managed_action_ids: tuple[str, ...] = () - - -# Wire values of ``UiWindowSummary.window_kind``, written by the GUI window -# providers and read by agent-side presentations. -EMBEDDED_WINDOW_KIND = "embedded" -MANAGED_WINDOW_KIND = "managed" -DYNAMIC_SCOPE_WINDOW_KIND = "scope" -QT_TOP_LEVEL_WINDOW_KIND = "qt_top_level" - - -@dataclass(frozen=True, slots=True) -class UiWindowSummary(UiWindowSemanticCarrier): - schema_version: str - identity: UiWindowIdentity - title: str - window_kind: str - visible: bool - focusable: bool - manager_scope: UiWindowManagerScope | None = None - error_dialog: bool = False - """The window is a critical message box reporting an error.""" - - @property - def window_id(self) -> str: - return self.identity.window_id - - -@dataclass(frozen=True, slots=True) -class UiWindowCatalog(AgentResultEnvelope, UiCatalogPageCarrier): - windows: tuple[UiWindowSummary, ...] - - -@dataclass(frozen=True, slots=True) -class UiWindowOpenPolicy: - """Policy for materializing a UI window before operating on it.""" - - create_if_missing: bool = True - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowOperationRequest(UiWindowIdentity): - """Shared request contract for operations against a UI window.""" - - open_policy: UiWindowOpenPolicy - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowFocusRequest(UiWindowOperationRequest): - @classmethod - def from_fields( - cls, - *, - window_id: str, - create_if_missing: bool = True, - ) -> "UiWindowFocusRequest": - return cls( - window_id=window_id, - open_policy=UiWindowOpenPolicy(create_if_missing=create_if_missing), - ) - - -@dataclass(frozen=True, slots=True) -class UiWindowFocusResult(AgentResultEnvelope, UiWindowIdentity): - focused: bool - summary: UiWindowSummary | None = None - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowNavigateRequest(UiWindowOperationRequest): - item_id: str | None = None - field_path: str | None = None - - @property - def has_target(self) -> bool: - return self.item_id is not None or self.field_path is not None - - @classmethod - def from_fields( - cls, - *, - window_id: str, - field_path: str | None = None, - item_id: str | None = None, - create_if_missing: bool = True, - ) -> "UiWindowNavigateRequest": - return cls( - window_id=window_id, - field_path=field_path, - item_id=item_id, - open_policy=UiWindowOpenPolicy(create_if_missing=create_if_missing), - ) - - -@dataclass(frozen=True, slots=True) -class UiWindowNavigateResult(AgentResultEnvelope, UiWindowIdentity): - focused: bool - navigated: bool - created: bool - summary: UiWindowSummary | None = None - operation_id: str | None = None - target_exposed: bool | None = None - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowCloseRequest(UiWindowIdentity): - """Request a normal close for one currently open UI window.""" - - @classmethod - def from_fields( - cls, - *, - window_id: str, - ) -> "UiWindowCloseRequest": - return cls(window_id=window_id) - - -@dataclass(frozen=True, slots=True) -class UiWindowCloseResult(AgentResultEnvelope, UiWindowIdentity): - closed: bool - summary: UiWindowSummary | None = None - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWindowSnapshotRequest( - WindowSnapshotCaptureSpec, - UiWindowOperationRequest, - AgentCliRequest, -): - @classmethod - def from_fields( - cls, - *, - window_id: str, - output_dir_path: str | None = None, - capture_scope: str = WindowSnapshotCaptureScope.WIDGET.value, - create_if_missing: bool = False, - frame_condition: str = WindowSnapshotFrameCondition.IMMEDIATE.value, - observation_timeout_s: float = WindowSnapshotCaptureSpec.observation_timeout_s, - ) -> "UiWindowSnapshotRequest": - if output_dir_path is None: - output_dir_path = str(DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR) - return cls( - window_id=window_id, - output_dir_path=output_dir_path, - capture_scope=WindowSnapshotCaptureScope(capture_scope), - frame_condition=WindowSnapshotFrameCondition(frame_condition), - observation_timeout_s=observation_timeout_s, - open_policy=UiWindowOpenPolicy(create_if_missing=create_if_missing), - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "window_id": self.window_id, - **to_jsonable(project_dataclass(WindowSnapshotCaptureSpec, self)), - "create_if_missing": self.open_policy.create_if_missing, - } - - -@dataclass(frozen=True, slots=True) -class UiWindowSnapshotResult( - WindowSnapshotCaptureSpec, - AgentResultEnvelope, - UiWindowIdentity, -): - captured: bool - resource: AgentResourceRef | None = None - summary: UiWindowSummary | None = None - width: int | None = None - height: int | None = None - operation_id: str | None = None - observation: WindowVisualObservation | None = None - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWidgetTreeRequest( - WidgetTreeProjectionControls, - UiWindowOperationRequest, - AgentCliRequest, -): - actionable_only: bool = True - include_tree: bool = False - max_depth: int | None = 8 - max_nodes: int | None = 80 - - @classmethod - def from_fields( - cls, - *, - window_id: str, - create_if_missing: bool = False, - maximum_text_length: int = ( - WidgetTreeProjectionControls.default_maximum_text_length() - ), - maximum_item_model_nodes: int | None = ( - WidgetTreeProjectionControls.default_maximum_item_model_nodes() - ), - truncation_suffix: str = ( - WidgetTreeProjectionControls.default_truncation_suffix() - ), - actionable_only: bool = True, - include_tree: bool = False, - max_depth: int | None = 8, - max_nodes: int | None = 80, - ) -> "UiWidgetTreeRequest": - return cls( - window_id=window_id, - open_policy=UiWindowOpenPolicy(create_if_missing=create_if_missing), - maximum_text_length=maximum_text_length, - maximum_item_model_nodes=maximum_item_model_nodes, - truncation_suffix=truncation_suffix, - actionable_only=actionable_only, - include_tree=include_tree, - max_depth=max_depth, - max_nodes=max_nodes, - ) - - @classmethod - def default_include_tree(cls) -> bool: - return False - - @classmethod - def default_max_depth(cls) -> int: - return 8 - - @classmethod - def default_max_nodes(cls) -> int: - return 80 - - def __post_init__(self) -> None: - WidgetTreeProjectionControls.__post_init__(self) - if self.max_depth is not None and self.max_depth < 0: - raise ValueError("max_depth must be non-negative or None") - if self.max_nodes is not None and self.max_nodes < 1: - raise ValueError("max_nodes must be positive or None") - - def as_projection_policy(self) -> WidgetTreeProjectionPolicy: - return WidgetTreeProjectionControls.as_projection_policy( - self, - maximum_depth=self.max_depth, - maximum_nodes=self.max_nodes, - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "window_id": self.window_id, - "create_if_missing": self.open_policy.create_if_missing, - "maximum_text_length": self.maximum_text_length, - "maximum_item_model_nodes": self.maximum_item_model_nodes, - "truncation_suffix": self.truncation_suffix, - "actionable_only": self.actionable_only, - "include_tree": self.include_tree, - "max_depth": self.max_depth, - "max_nodes": self.max_nodes, - } - - -@dataclass(frozen=True, slots=True) -class UiWidgetRect: - x: int - y: int - width: int - height: int - - -@dataclass(frozen=True, slots=True) -class UiObjectStateValuePreview: - """Bounded display preview for an ObjectState field value.""" - - type_name: str - is_none: bool - text: str - truncated: bool = False - - -@dataclass(frozen=True, kw_only=True) -class UiObjectStateFieldValuePreviewCarrier: - """Shared raw/resolved value previews for ObjectState-aware UI DTOs.""" - - raw_value_is_none: bool = False - resolved_value_is_none: bool = False - raw_value_preview: UiObjectStateValuePreview | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - field_value is not None or owner.raw_value_is_none - ) - ) - }, - ) - resolved_value_preview: UiObjectStateValuePreview | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - field_value is not None or owner.resolved_value_is_none - ) - ) - }, - ) - - -@dataclass(frozen=True, kw_only=True) -class UiObjectStateFieldSemanticCarrier(UiObjectStateFieldValuePreviewCarrier): - """Shared ObjectState field semantics projected into agent-facing DTOs.""" - - dirty: bool = False - signature_diff: bool = False - last_changed: bool = False - semantic_markers: tuple[str, ...] = () - raw_value: JsonValue | None = None - resolved_value: JsonValue | None = None - inherited_value: bool = False - provenance: "UiObjectStateFieldProvenance | None" = None - - -@dataclass(frozen=True, kw_only=True) -class UiWidgetActionSemanticCarrier(UiObjectStateFieldSemanticCarrier): - """ObjectState and action-address semantics for one widget action.""" - - action_role: str | None = None - semantic_address: UiSemanticAddress | None = None - object_state_scope_id: str | None = None - field_path: str | None = None - - -@dataclass(frozen=True, slots=True) -class UiWidgetTreeNode(WidgetNodeIdentity): - visible: bool - enabled: bool - geometry: UiWidgetRect - global_geometry: UiWidgetRect - tool_tip: str - status_tip: str - whats_this: str - window_title: str - text: str | None - text_truncated: bool - title: str | None - action_kinds: tuple[str, ...] - clickable: bool - actionable: bool - checkable: bool | None - checked: bool | None - current_index: int | None - current_text: str | None - item_count: int | None - children: tuple["UiWidgetTreeNode", ...] - item_texts: tuple[str, ...] = () - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWidgetActionSummary(UiWidgetActionSemanticCarrier, WidgetNodeIdentity): - label: str | None = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - visible: bool = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - enabled: bool = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - geometry: UiWidgetRect = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - global_geometry: UiWidgetRect = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - action_kinds: tuple[str, ...] = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - clickable: bool = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - checkable: bool | None - checked: bool | None = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - isinstance(field_value, bool) - and (field_value or owner.checkable is True) - ) - ) - } - ) - current_index: int | None - current_text: str | None - item_count: int | None - tool_tip: str - item_texts: tuple[str, ...] = () - context_label: str | None = None - raw_value: JsonValue | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - owner.raw_value_preview is None - and (field_value is not None or owner.raw_value_is_none) - ) - ) - }, - ) - resolved_value: JsonValue | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - owner.resolved_value_preview is None - and (field_value is not None or owner.resolved_value_is_none) - ) - ) - }, - ) - - def compact_projection(self) -> JsonObject: - """Return the field-declaration-owned compact action projection.""" - - return { - field_name: to_jsonable(field_value) - for field_name, field_value in compact_dataclass_projection(self).items() - } - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiWidgetActionInvokeRequest( - UiWindowOperationRequest, - UiMutationRequestTokenCarrier, - AgentCliRequest, -): - path_id: str - action_kind: str = "auto" - target_index: int | None = None - - @classmethod - def from_fields( - cls, - *, - window_id: str, - path_id: str, - action_kind: str = "button", - target_index: int | None = None, - create_if_missing: bool = False, - request_token: str | None = None, - ) -> "UiWidgetActionInvokeRequest": - return cls( - window_id=window_id, - open_policy=UiWindowOpenPolicy(create_if_missing=create_if_missing), - path_id=path_id, - action_kind=action_kind, - target_index=target_index, - request_token=UiMutationRequestToken(value=request_token), - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "window_id": self.window_id, - "path_id": self.path_id, - "action_kind": self.action_kind, - "target_index": self.target_index, - "create_if_missing": self.open_policy.create_if_missing, - "request_token": self.request_token.value, - } - - -class UiWidgetActionIssueCode(str, Enum): - """Stable issue identities for projected widget action invocation.""" - - RESOLUTION_FAILED = "ui_widget_action_resolution_failed" - WIDGET_UNKNOWN = "unknown_ui_widget" - ACTION_UNSUPPORTED = "ui_widget_action_unsupported" - INDEX_INVALID = "ui_widget_index_invalid" - NOT_VISIBLE = "ui_widget_not_visible" - DISABLED = "ui_widget_disabled" - NOT_CLICKABLE = "ui_widget_not_clickable" - ACTION_KIND_UNAVAILABLE = "ui_widget_action_kind_unavailable" - OPERATION_OUTCOME_INVALID = "ui_widget_action_operation_outcome_invalid" - - -@dataclass(frozen=True, slots=True) -class UiWidgetActionInvokeResult( - AgentResultEnvelope, - UiWindowIdentity, -): - path_id: str - action_kind: str - invoked: bool - receipt: UiMutationReceipt - summary: UiWidgetActionSummary | None = None - - @property - def outcome(self) -> UiWidgetActionInvocationOutcome: - """Project the terminal operation outcome from the invocation fact.""" - - return UiWidgetActionInvocationOutcome.from_invoked(self.invoked) - - def resolve_operation( - self, - operation: "UiBridgeOperationRef", - ) -> "UiWidgetActionInvokeResult": - """Resolve an accepted placeholder from its authoritative operation.""" - - try: - status = UiBridgeOperationStatus.named(operation.status) - except ValueError: - status = OperationUnavailable - try: - outcome = ( - UiWidgetActionInvocationOutcome(operation.outcome) - if operation.outcome is not None - else None - ) - except ValueError: - outcome = None - errors = operation.errors - if status is OperationCompleted and outcome is None: - errors = ( - *errors, - AgentError( - code=UiWidgetActionIssueCode.OPERATION_OUTCOME_INVALID.value, - message=( - "The widget action operation completed without a declared " - f"terminal outcome: {operation.outcome!r}." - ), - ), - ) - invoked = ( - outcome.invoked - if status is OperationCompleted and outcome is not None - else False - ) - return replace( - self, - invoked=invoked, - errors=errors, - warnings=operation.warnings, - ) - - -@dataclass(frozen=True, slots=True) -class UiWidgetTreeResult(AgentResultEnvelope, UiWindowIdentity): - projected: bool - root: UiWidgetTreeNode | None = None - actionable_widgets: tuple[UiWidgetActionSummary, ...] = () - summary: UiWindowSummary | None = None - widget_count: int = 0 - actionable_count: int = 0 - returned_widget_count: int = 0 - returned_actionable_count: int = 0 - tree_truncated: bool = False - actionable_widgets_truncated: bool = False - actionable_only: bool = True - include_tree: bool = False - max_depth: int | None = None - max_nodes: int | None = None - - def as_jsonable(self, *, compact_actions: bool = True) -> JsonObject: - """Serialize the typed result with action compaction owned by its DTO.""" - - payload = to_jsonable(self) - if not isinstance(payload, dict): - raise TypeError("Widget tree serialization did not produce an object.") - if compact_actions: - payload["actionable_widgets"] = [ - action.compact_projection() for action in self.actionable_widgets - ] - return payload - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldProvenance: - """Resolved source address for an inherited ObjectState field.""" - - source_scope_id: str | None - source_type: str | None - source_field_path: str | None = None - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldSummary(UiObjectStateFieldSemanticCarrier): - """Field-level ObjectState semantics and bounded raw/resolved values.""" - - schema_version: str - address: UiSemanticAddress - field_name: str - container_path: str - object_state_path_type: str - raw_value_type: str - resolved_value_type: str | None - parameter_description: str | None = None - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldPathIndex: - """Field-path relationship index for ObjectState field projections.""" - - fields: tuple[UiObjectStateFieldSummary, ...] - - @property - def container_field_paths(self) -> frozenset[str]: - field_paths = tuple(field.address.field_path for field in self.fields) - return frozenset( - field_path - for field_path in field_paths - if any( - DottedFieldPath(field_path).contains_path(other_path) - for other_path in field_paths - if other_path != field_path - ) - ) - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldHelpRequest(UiSemanticAddress): - """Request docs/help for one live ObjectState field address.""" - - max_description_chars: int = 4_000 - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldHelpQuery(AgentCliRequest): - """Public query for field help; scope may be inferred when unique.""" - - field_path: str - object_state_scope_id: str | None = None - window_id: str | None = None - max_description_chars: int = 4_000 - - @classmethod - def from_fields( - cls, - *, - field_path: str, - object_state_scope_id: str | None = None, - window_id: str | None = None, - max_description_chars: int = 4_000, - ) -> "UiObjectStateFieldHelpQuery": - return cls( - field_path=field_path, - object_state_scope_id=object_state_scope_id, - window_id=window_id, - max_description_chars=max_description_chars, - ) - - def __post_init__(self) -> None: - if self.max_description_chars < 0: - raise ValueError("max_description_chars must be nonnegative.") - - def concrete_request( - self, - object_state_scope_id: str, - ) -> UiObjectStateFieldHelpRequest: - return UiObjectStateFieldHelpRequest( - object_state_scope_id=object_state_scope_id, - field_path=self.field_path, - window_id=self.window_id, - max_description_chars=self.max_description_chars, - ) - - def error_address(self) -> UiSemanticAddress: - return UiSemanticAddress( - object_state_scope_id=self.object_state_scope_id or "", - field_path=self.field_path, - window_id=self.window_id, - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "field_path": self.field_path, - "object_state_scope_id": self.object_state_scope_id, - "window_id": self.window_id, - "max_description_chars": self.max_description_chars, - } - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiObjectStateFieldHelpResult(AgentResultEnvelope): - """Display-ready help for one ObjectState field from Python introspection.""" - - address: UiSemanticAddress - field: UiObjectStateFieldSummary | None = None - object_type: str | None = None - help_target_type: str | None = None - parameter_name: str | None = None - target_summary: str | None = None - target_description: str | None = None - summary: str | None = None - description: str | None = None - enum_values: tuple[str, ...] = () - description_truncated: bool = False - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiObjectStateFieldMutationRequest( - UiMutationRequestTokenCarrier, - UiSemanticAddress, - AgentCliRequest, -): - """Request an ObjectState-owned field update or reset.""" - - value: JsonValue | None = None - reset: bool = False - include_field_values: bool = True - - @classmethod - def from_fields( - cls, - *, - object_state_scope_id: str, - field_path: str, - value: dict | list | str | int | float | bool | None = None, - reset: bool = False, - window_id: str | None = None, - include_field_values: bool = True, - request_token: str | None = None, - ) -> "UiObjectStateFieldMutationRequest": - return cls( - object_state_scope_id=object_state_scope_id, - field_path=field_path, - window_id=window_id, - value=value, - reset=reset, - include_field_values=include_field_values, - request_token=UiMutationRequestToken(value=request_token), - ) - - def __post_init__(self) -> None: - if self.reset and self.value is not None: - raise ValueError("reset=True cannot be combined with value.") - - def as_tool_arguments(self) -> JsonObject: - return { - "object_state_scope_id": self.object_state_scope_id, - "field_path": self.field_path, - "value": self.value, - "reset": self.reset, - "window_id": self.window_id, - "include_field_values": self.include_field_values, - "request_token": self.request_token.value, - } - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiObjectStateFieldMutationResult(AgentResultEnvelope): - """Before/after projection for one ObjectState-owned field mutation.""" - - address: UiSemanticAddress - mutated: bool - reset: bool - receipt: UiMutationReceipt - before: UiObjectStateFieldSummary | None = None - after: UiObjectStateFieldSummary | None = None - current_snapshot: "UiSnapshotRef | None" = None - - -@dataclass(frozen=True, slots=True) -class UiObjectStateScopeSummary(UiFieldCatalogPageCarrier): - schema_version: str - identity: UiObjectStateScopeIdentity - object_type: str - parameter_count: int - dirty_field_count: int - signature_diff_field_count: int - has_unsaved_changes: bool = False - has_default_overrides: bool = False - dirty_marker: str = "*" - signature_diff_marker: str = "_" - last_changed_field: str | None = None - registered: bool = True - fields: tuple[UiObjectStateFieldSummary, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiObjectStateScopeCatalog( - AgentResultEnvelope, - UiTimeTravelRuntimeState, - UiCatalogPageCarrier, -): - object_state_token: int - current_branch: str - current_snapshot_index: int - scopes: tuple[UiObjectStateScopeSummary, ...] - - -@dataclass(frozen=True, slots=True) -class UiObjectStateScopeListRequest( - UiObjectStateScopeVisibility, - AgentCliRequest, -): - scope_ids: tuple[str, ...] = () - include_fields: bool = False - field_limit: int = 200 - field_offset: int = 0 - include_field_values: bool = False - include_field_descriptions: bool = False - field_paths: tuple[str, ...] = () - field_filter: type[UiObjectStateFieldFilter] = AllFields - - @classmethod - def from_visibility_options( - cls, - visibility: UiObjectStateScopeVisibility, - field_options: UiObjectStateFieldListOptions, - *, - scope_ids: tuple[str, ...] = (), - ) -> "UiObjectStateScopeListRequest": - return cls( - scope_ids=scope_ids, - include_system_scopes=visibility.include_system_scopes, - include_fields=field_options.include_fields, - field_limit=field_options.field_limit, - field_offset=field_options.field_offset, - include_field_values=field_options.include_field_values, - include_field_descriptions=field_options.include_field_descriptions, - field_paths=field_options.field_paths, - field_filter=field_options.field_filter, - ) - - @classmethod - def from_fields( - cls, - *, - scope_ids: tuple[str, ...] = (), - include_system_scopes: bool = False, - include_fields: bool = False, - include_field_values: bool = False, - field_filter: str = AllFields.name, - field_limit: int = 200, - field_offset: int = 0, - ) -> "UiObjectStateScopeListRequest": - return cls.from_visibility_options( - UiObjectStateScopeVisibility( - include_system_scopes=include_system_scopes, - ), - UiObjectStateFieldListOptions( - include_fields=include_fields, - include_field_values=include_field_values, - field_limit=field_limit, - field_offset=field_offset, - field_filter=UiObjectStateFieldFilter.named(field_filter), - ), - scope_ids=tuple(scope_ids), - ) - - def filtered_catalog( - self, - catalog: UiObjectStateScopeCatalog, - ) -> UiObjectStateScopeCatalog: - if not self.scope_ids: - return catalog - requested_scope_ids = set(self.scope_ids) - return replace( - catalog, - scopes=tuple( - scope - for scope in catalog.scopes - if scope.identity.object_state_scope_id in requested_scope_ids - ), - ) - - def as_tool_arguments(self) -> JsonObject: - return { - "scope_ids": list(self.scope_ids), - "include_system_scopes": self.include_system_scopes, - "include_fields": self.include_fields, - "include_field_values": self.include_field_values, - "field_filter": self.field_filter.name, - "field_limit": self.field_limit, - "field_offset": self.field_offset, - } - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldProjection(UiObjectStateFieldSemanticCarrier): - """Compact ObjectState field row for agent-facing field-list queries.""" - - field_path: str - field_name: str - container_path: str - object_state_path_type: str - raw_value_type: str - resolved_value_type: str | None - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldScopeProjection: - """Compact ObjectState scope row for field-list query results.""" - - scope_id: str - object_type: str - dirty_field_count: int - signature_diff_field_count: int - has_unsaved_changes: bool - has_default_overrides: bool - fields: tuple[UiObjectStateFieldProjection, ...] - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UiObjectStateFieldListResult(AgentResultEnvelope): - """Filtered, paged ObjectState field projection for agent tools.""" - - object_state_token: int - current_branch: str - current_snapshot_index: int - requested_scope_ids: tuple[str, ...] - field_paths: tuple[str, ...] - field_path_contains: tuple[str, ...] - field_filter: type[UiObjectStateFieldFilter] - include_container_fields: bool - matched_scope_count: int - matched_field_count: int - returned_field_count: int - field_limit: int - field_offset: int - next_offset: int | None - truncated: bool - scopes: tuple[UiObjectStateFieldScopeProjection, ...] - - -@dataclass(frozen=True, slots=True) -class UiObjectStateFieldListQuery(AgentCliRequest): - """Public ObjectState field query projected from the scope-list bridge ABI.""" - - source_query_scan_limit: ClassVar[int] = 1_000 - - scope_ids: tuple[str, ...] = () - field_paths: tuple[str, ...] = () - field_path_contains: tuple[str, ...] = () - include_system_scopes: bool = False - include_clean_fields: bool = True - include_container_fields: bool = False - field_filter: type[UiObjectStateFieldFilter] = AllFields - include_field_values: bool = False - field_limit: int = 200 - field_offset: int = 0 - max_fields: int = 100 - max_value_items: int = 20 - max_value_chars: int = 1000 - - @classmethod - def from_fields( - cls, - *, - scope_ids: tuple[str, ...] = (), - field_paths: tuple[str, ...] = (), - field_path_contains: tuple[str, ...] = (), - include_system_scopes: bool = False, - include_clean_fields: bool = True, - include_container_fields: bool = False, - field_filter: str = AllFields.name, - include_field_values: bool = False, - field_limit: int = 200, - field_offset: int = 0, - max_fields: int = 100, - max_value_items: int = 20, - max_value_chars: int = 1000, - ) -> "UiObjectStateFieldListQuery": - return cls( - scope_ids=tuple(scope_ids), - field_paths=tuple(field_paths), - field_path_contains=tuple(field_path_contains), - include_system_scopes=include_system_scopes, - include_clean_fields=include_clean_fields, - include_container_fields=include_container_fields, - field_filter=UiObjectStateFieldFilter.named(field_filter), - include_field_values=include_field_values, - field_limit=field_limit, - field_offset=field_offset, - max_fields=max_fields, - max_value_items=max_value_items, - max_value_chars=max_value_chars, - ) - - def __post_init__(self) -> None: - if self.field_limit < 0 or self.field_offset < 0: - raise ValueError("field_limit and field_offset must be nonnegative.") - if self.max_fields < 0 or self.max_value_items < 0 or self.max_value_chars < 0: - raise ValueError( - "max_fields, max_value_items, and max_value_chars must be nonnegative." - ) - - @property - def contains_terms(self) -> tuple[str, ...]: - return tuple(term.lower() for term in self.field_path_contains) - - @property - def exact_source_field_paths(self) -> tuple[str, ...]: - if ( - self.field_paths - and not self.contains_terms - and not self.include_container_fields - ): - return self.field_paths - return () - - @property - def source_field_limit(self) -> int: - if self.exact_source_field_paths: - return max(1, len(self.exact_source_field_paths)) - return max( - self.source_query_scan_limit, - self.field_limit + self.field_offset, - self.max_fields, - ) - - @property - def return_limit(self) -> int: - if self.max_fields: - return min(self.field_limit, self.max_fields) - return self.field_limit - - def scope_list_request(self) -> UiObjectStateScopeListRequest: - return UiObjectStateScopeListRequest.from_visibility_options( - UiObjectStateScopeVisibility( - include_system_scopes=self.include_system_scopes, - ), - UiObjectStateFieldListOptions( - include_fields=True, - include_field_values=self.include_field_values, - field_limit=self.source_field_limit, - field_offset=0, - field_paths=self.exact_source_field_paths, - field_filter=self.field_filter, - ), - scope_ids=self.scope_ids, - ) +@dataclass(frozen=True, slots=True) +class UiLiveMeasurementsState( + UiStateSurfaceEnvelope, + UiCodeDocumentCurrentRevision, + UiCurrentSnapshotState, + SelectedScopeIdsCarrier, +): + """Bounded live measurement tables retained by the running Plate Manager.""" - def as_tool_arguments(self) -> JsonObject: - return { - "scope_ids": list(self.scope_ids), - "field_paths": list(self.field_paths), - "field_path_contains": list(self.field_path_contains), - "include_system_scopes": self.include_system_scopes, - "include_clean_fields": self.include_clean_fields, - "include_container_fields": self.include_container_fields, - "field_filter": self.field_filter.name, - "include_field_values": self.include_field_values, - "field_limit": self.field_limit, - "field_offset": self.field_offset, - "max_fields": self.max_fields, - "max_value_items": self.max_value_items, - "max_value_chars": self.max_value_chars, - } + object_state_token: int + retained_entry_count: int + visible_entry_count: int + total_row_count: int + latest_sequence_id: int | None + entries: tuple[UiLiveMeasurementEntryState, ...] @dataclass(frozen=True, slots=True) -class UiCodeDocumentValidationRequest( - UiCodeDocumentIdentity, - UiCodeDocumentOptionalBaseRevision, - AgentCliRequest, +class UiPipelineEditorState( + UiStateSurfaceEnvelope, + UiCodeDocumentCurrentRevision, + UiCurrentSnapshotState, + SelectedScopeIdsCarrier, ): - source: str + object_state_token: int + current_plate_scope_id: str | None + pipeline_scope_id: str | None + steps: tuple[PipelineStepState, ...] - @classmethod - def from_fields( - cls, - *, - document_id: str, - source: str, - base_revision_token: str | None = None, - ) -> "UiCodeDocumentValidationRequest": - return cls( - document_id=document_id, - source=source, - base_revision_token=base_revision_token, - ) - def as_tool_arguments(self) -> JsonObject: - return { - "document_id": self.document_id, - "source": self.source, - "base_revision_token": self.base_revision_token, - } +@dataclass(frozen=True, slots=True) +class UiDebugActionState(SelectedScopeIdsCarrier): + action_id: str + label: str + placement: str + enabled: bool + side_effects: tuple[str, ...] + confirmation_required: bool + requires_active_debug_session: bool + disabled_error: AgentError | None = None @dataclass(frozen=True, slots=True) -class UiCodeDocumentValidationResult(UiCodeDocumentIdentity): - schema_version: str - valid: bool - normalized_scope_ids: tuple[str, ...] = () - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () +class UiDebugCursorState: + step_index: int + step_scope_id: str | None + group_key: str | None + invocation_key: str | None + pattern_group_identity: str | None + dirty: bool = False -@dataclass(frozen=True, kw_only=True) -class UiBridgeConfirmationPolicy(UiBridgeConfirmationRequirementCarrier): - """Shared mutation confirmation policy for UI bridge requests.""" +@dataclass(frozen=True, slots=True) +class UiDebugTerminalSummaryState: + debug_session_id: str + plate_scope_id: str + terminal_status: str + command_type: str | None + axis_id: str | None + snapshot_id: str | None + snapshot_store_ref: str | None + snapshot_store_backend: str | None + step_name: str | None + callable_name: str | None + cursor: UiDebugCursorState | None + completed_at_unix: float | None + +@dataclass(frozen=True) +class UiDebugSessionIdentityCarrier: + debug_session_id: str -@dataclass(frozen=True, kw_only=True) -class UiBridgeBranchMutationPolicy(UiBridgeConfirmationPolicy): - """Shared branch creation policy for ObjectState movement requests.""" - allow_auto_branch: bool = False +@dataclass(frozen=True) +class UiDebugSnapshotStoreRefCarrier: + snapshot_store_ref: str | None + snapshot_store_backend: str | None @dataclass(frozen=True, slots=True) -class UiCodeDocumentApplyRequest( - UiCodeDocumentIdentity, - UiCodeDocumentBaseRevision, - SelectedScopeIdsCarrier, - UiBridgeConfirmationPolicy, - UiMutationRequestTokenCarrier, - AgentCliRequest, -): - source: str - snapshot_label: str | None = None - apply_if_time_traveling: bool = False +class UiProgressIdentityState: + execution_id: str + subject_id: str + axis_id: str + step_name: str - @classmethod - def from_fields( - cls, - *, - document_id: str, - source: str, - base_revision_token: str, - selection_mode: str = SelectedItems.name, - selected_scope_ids: tuple[str, ...] = (), - require_confirmation: bool = True, - snapshot_label: str | None = None, - apply_if_time_traveling: bool = False, - request_token: str | None = None, - ) -> "UiCodeDocumentApplyRequest": - return cls( - document_id=document_id, - source=source, - base_revision_token=base_revision_token, - selection_mode=selection_mode, - selected_scope_ids=tuple(selected_scope_ids), - confirmation_requirement=UiBridgeConfirmationRequirement.from_flag( - require_confirmation - ), - request_token=UiMutationRequestToken(value=request_token), - snapshot_label=snapshot_label, - apply_if_time_traveling=apply_if_time_traveling, - ) - def as_tool_arguments(self) -> JsonObject: - return { - "document_id": self.document_id, - "source": self.source, - "base_revision_token": self.base_revision_token, - "selection_mode": self.selection_mode, - "selected_scope_ids": list(self.selected_scope_ids), - "require_confirmation": self.confirmation_is_required(), - "snapshot_label": self.snapshot_label, - "apply_if_time_traveling": self.apply_if_time_traveling, - "request_token": self.request_token.value, - } +@dataclass(frozen=True, slots=True) +class UiDebugRuntimeFrameState( + UiDebugSessionIdentityCarrier, + UiDebugSnapshotStoreRefCarrier, +): + progress_identity: UiProgressIdentityState + cursor: UiDebugCursorState + event_type: str + step_name: str + callable_name: str | None + snapshot_id: str | None + timestamp: float @dataclass(frozen=True, slots=True) -class UiCodeDocumentApplyResult(UiCodeDocumentIdentity): - schema_version: str - applied: bool - base_revision_token: str - receipt: UiMutationReceipt - outcome: str = "not_applied" - operation_id: str | None = None - new_revision_token: str | None = None - current_revision_token: str | None = None - current_snapshot: UiSnapshotRef | None = None - undo_snapshot: UiSnapshotRef | None = None - pre_apply_snapshot: UiSnapshotRef | None = None - post_apply_snapshot: UiSnapshotRef | None = None - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () +class UiPipelineDebugSessionState( + UiStateSurfaceEnvelope, + UiCodeDocumentCurrentRevision, + UiCurrentSnapshotState, + SelectedScopeIdsCarrier, +): + object_state_token: int + current_plate_scope_id: str | None + pipeline_scope_id: str | None + manager_execution_state: str + initialized: bool + compiled: bool + phase: str + active_session_id: str | None + execution_id: str | None + axis_id: str | None + selected_source_group: str | None + snapshot_store_ref: str | None + snapshot_store_backend: str | None + terminal_status: str | None + cursor: UiDebugCursorState | None + terminal_summary: UiDebugTerminalSummaryState | None + actions: tuple[UiDebugActionState, ...] + current_frame: UiDebugRuntimeFrameState | None = None + last_frame: UiDebugRuntimeFrameState | None = None @dataclass(frozen=True, slots=True) @@ -2599,246 +270,6 @@ class UiSelectedPlateWorkflowResult: schema_version: str workflow: UiSelectedPlateWorkflowKind action_result: UiActionInvokeResult - state_surface_id: str = PlateManagerStateSurfaceIdentityDeclaration.require_value() - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiSnapshotCatalog(UiTimeTravelRuntimeState): - schema_version: str - current_branch: str - current_snapshot_index: int - object_state_token: int - snapshots: tuple[UiSnapshotRef, ...] - branches: tuple[UiBranchRef, ...] - warnings: tuple[AgentWarning, ...] = () - errors: tuple[AgentError, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiSnapshotListRequest(UiObjectStateScopeVisibility): - @classmethod - def from_fields( - cls, - *, - scope_visibility: dict | None = None, - ) -> "UiSnapshotListRequest": - visibility = UiObjectStateScopeVisibility.from_mapping(scope_visibility) - return cls(include_system_scopes=visibility.include_system_scopes) - - -@dataclass(frozen=True, slots=True) -class UiSnapshotRestoreRequest( - UiSnapshotIdentity, - UiBridgeBranchMutationPolicy, - UiObjectStateScopeVisibility, -): - index: int | None = None - branch: str | None = None - - @classmethod - def from_fields( - cls, - *, - snapshot_id: str | None = None, - index: int | None = None, - branch: str | None = None, - scope_visibility: dict | None = None, - require_confirmation: bool = True, - allow_auto_branch: bool = False, - ) -> "UiSnapshotRestoreRequest": - visibility = UiObjectStateScopeVisibility.from_mapping(scope_visibility) - return cls( - snapshot_id=snapshot_id, - index=index, - branch=branch, - include_system_scopes=visibility.include_system_scopes, - confirmation_requirement=UiBridgeConfirmationRequirement.from_flag( - require_confirmation - ), - allow_auto_branch=allow_auto_branch, - ) - - @property - def operation_target_id(self) -> str | None: - if self.snapshot_id is not None: - return self.snapshot_id - if self.branch is not None: - return self.branch - if self.index is not None: - return str(self.index) - return None - - -@dataclass(frozen=True, slots=True) -class UiTimeTravelHeadRequest(UiBridgeConfirmationPolicy): - @classmethod - def from_fields( - cls, - *, - require_confirmation: bool = True, - ) -> "UiTimeTravelHeadRequest": - return cls( - confirmation_requirement=UiBridgeConfirmationRequirement.from_flag( - require_confirmation - ) - ) - - -@dataclass(frozen=True, slots=True) -class UiSnapshotRestoreResult(UiCurrentSnapshotState): - schema_version: str - restored: bool - target_snapshot: UiSnapshotRef | None - catalog: UiSnapshotCatalog | None = None - operation_id: str | None = None - receipt: UiMutationReceipt | None = None - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiBranchCatalog: - schema_version: str - current_branch: str - branches: tuple[UiBranchRef, ...] - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - - -@dataclass(frozen=True, slots=True) -class UiBranchSwitchRequest(UiBridgeBranchMutationPolicy): - branch: str - - @classmethod - def from_fields( - cls, - *, - branch: str, - require_confirmation: bool = True, - allow_auto_branch: bool = False, - ) -> "UiBranchSwitchRequest": - return cls( - branch=branch, - confirmation_requirement=UiBridgeConfirmationRequirement.from_flag( - require_confirmation - ), - allow_auto_branch=allow_auto_branch, - ) - - def as_restore_request(self) -> UiSnapshotRestoreRequest: - return UiSnapshotRestoreRequest( - branch=self.branch, - confirmation_requirement=self.confirmation_requirement, - allow_auto_branch=self.allow_auto_branch, - ) - - -@dataclass(frozen=True, slots=True) -class UiBridgeOperationRoute: - operation_name: str - request_id: str | None = None - target_id: str | None = None - - -UNKNOWN_UI_BRIDGE_OPERATION_ROUTE = UiBridgeOperationRoute( - operation_name=UI_BRIDGE_UNKNOWN_OPERATION, -) - - -@dataclass(frozen=True, slots=True) -class UiBridgeOperationIdentity: - operation_id: str - route: UiBridgeOperationRoute - - @property - def operation_name(self) -> str: - return self.route.operation_name - - @property - def request_id(self) -> str | None: - return self.route.request_id - - @property - def target_id(self) -> str | None: - return self.route.target_id - - -@dataclass(frozen=True, slots=True) -class UiBridgeOperationStatusRequest(AgentDataclassCliRequest): - operation_id: str - - -@dataclass(frozen=True, slots=True) -class UiBridgeOperationWaitRequest(AgentCliRequest): - """Bounded terminal wait for one accepted UI bridge operation.""" - - operation_id: str - timeout_seconds: float = 30.0 - poll_interval_seconds: float = 0.5 - - @classmethod - def from_fields( - cls, - operation_id: str, - timeout_seconds: float = 30.0, - poll_interval_seconds: float = 0.5, - ) -> "UiBridgeOperationWaitRequest": - return cls( - operation_id=operation_id, - timeout_seconds=timeout_seconds, - poll_interval_seconds=poll_interval_seconds, - ) - - def __post_init__(self) -> None: - if not self.operation_id: - raise ValueError("operation_id must not be empty.") - if ( - not isfinite(self.timeout_seconds) - or not 0.0 <= self.timeout_seconds <= 120.0 - ): - raise ValueError("timeout_seconds must be between 0 and 120.") - if ( - not isfinite(self.poll_interval_seconds) - or not 0.05 <= self.poll_interval_seconds <= 5.0 - ): - raise ValueError("poll_interval_seconds must be between 0.05 and 5.") - - def as_tool_arguments(self) -> JsonObject: - """Project the declared operation-wait factory fields to MCP arguments.""" - - return { - "operation_id": self.operation_id, - "timeout_seconds": self.timeout_seconds, - "poll_interval_seconds": self.poll_interval_seconds, - } - - -@dataclass(frozen=True, slots=True) -class UiBridgeOperationRef(AgentTimedStatusEnvelope): - identity: UiBridgeOperationIdentity - completed_at_unix: float | None = None - outcome: str | None = None - result_payload: JsonObject | None = None - - -@dataclass(frozen=True, slots=True) -class UiBridgeEnvelopeBase(UiBridgeApplicationIdentity): - schema_version: str - bridge_protocol_version: str - request_id: str - payload: JsonObject - - -@dataclass(frozen=True, slots=True) -class UiBridgeRequestEnvelope(UiBridgeEnvelopeBase): - operation: str - auth_token: str | None - - -@dataclass(frozen=True, slots=True) -class UiBridgeResponseEnvelope(UiBridgeEnvelopeBase): - ok: bool + state_surface_id: str = DatasetListStateSurfaceIdentity.require_value() errors: tuple[AgentError, ...] = () warnings: tuple[AgentWarning, ...] = () diff --git a/openhcs/agent/dto/viewer.py b/openhcs/agent/dto/viewer.py index 91e4f38581..f88b7e9de6 100644 --- a/openhcs/agent/dto/viewer.py +++ b/openhcs/agent/dto/viewer.py @@ -11,7 +11,7 @@ from metaclass_registry import AutoRegisterMeta from polystore.streaming.identity import StreamProducerIdentity from polystore.streaming_constants import StreamingDataType -from pyqt_reactive.services.window_snapshot import ( +from formbridge.services.window_snapshot import ( WindowSnapshotCaptureScope, WindowSnapshotCaptureSpec, WindowSnapshotFrameCondition, @@ -33,8 +33,8 @@ ViewerNativeViewportPresentation, ) -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import ( AgentCliArgumentSpec, AgentCliRequest, AgentError, diff --git a/openhcs/agent/exceptions.py b/openhcs/agent/exceptions.py index 76fb676373..fb2078d483 100644 --- a/openhcs/agent/exceptions.py +++ b/openhcs/agent/exceptions.py @@ -4,7 +4,7 @@ from typing import ClassVar -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError class AgentFacingErrorMixin: diff --git a/openhcs/agent/path_policy.py b/openhcs/agent/path_policy.py index 4be39cebf3..0146803db8 100644 --- a/openhcs/agent/path_policy.py +++ b/openhcs/agent/path_policy.py @@ -8,7 +8,8 @@ from pathlib import Path from openhcs.agent.exceptions import AgentFacingErrorMixin -from openhcs.agent.runtime_platform import AgentRuntimePlatform +from formbridge.bridge.service import UiBridgeOutputPathPolicy, UiBridgeRequestRejectedError +from formbridge.runtime_platform import AgentRuntimePlatform class AgentPathLocationAuthority: @@ -90,7 +91,7 @@ def display(self) -> str: @dataclass(frozen=True, slots=True) -class AgentPathPolicy: +class AgentPathPolicy(UiBridgeOutputPathPolicy): """Own the roots and environment contract for agent-visible paths.""" readable_roots_environment_key = "OPENHCS_AGENT_READ_ROOTS" @@ -189,6 +190,12 @@ def assert_writable(self, path: str | Path) -> Path: ) return candidate + def writable_directory(self, path: str | Path) -> Path: + try: + return self.assert_writable(path) + except AgentPathPolicyError as exc: + raise UiBridgeRequestRejectedError((exc.to_agent_error(),)) from exc + class AgentPathPolicyError(AgentFacingErrorMixin, ValueError): """Raised when a path is outside the agent policy boundary.""" diff --git a/openhcs/agent/runtime_platform.py b/openhcs/agent/runtime_platform.py deleted file mode 100644 index b2386664e9..0000000000 --- a/openhcs/agent/runtime_platform.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Host-platform families for headless agent runtime paths.""" - -from __future__ import annotations - -import os -import sys -import tempfile -from abc import ABC -from collections.abc import Mapping -from pathlib import Path -from typing import ClassVar - -from metaclass_registry import AutoRegisterMeta - - -class AgentRuntimePlatform(ABC, metaclass=AutoRegisterMeta): - """Filesystem, process and session semantics of one host platform. - - Each platform is a subclass; ``name`` is its spelling in reports. - """ - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - - @staticmethod - def current_type() -> type[AgentRuntimePlatform]: - """Return the platform class of the running interpreter.""" - if sys.platform.startswith("win"): - return WindowsRuntimePlatform - if sys.platform == "darwin": - return MacOSRuntimePlatform - if sys.platform.startswith("linux"): - return LinuxRuntimePlatform - if os.name == "posix": - return PosixRuntimePlatform - return OtherRuntimePlatform - - @classmethod - def current(cls) -> "AgentRuntimePlatform": - """Instantiate the platform of the running interpreter.""" - return cls.current_type()() - - @staticmethod - def resolved_path(path: str | Path) -> Path: - """Resolve environment and user markers without requiring existence.""" - expanded = os.path.expandvars(os.fspath(path)) - return Path(expanded).expanduser().resolve(strict=False) - - @staticmethod - def process_started_at_unix(pid: int) -> float | None: - """Return the OS-owned creation time for one local process identity.""" - import psutil - - try: - return psutil.Process(pid).create_time() - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - return None - - def _process_environment(self, pid: int) -> Mapping[str, str] | None: - """Read a local process environment for owner-controlled projection.""" - del pid - return None - - @staticmethod - def temporary_root() -> Path: - """Return the platform temporary directory.""" - return Path(tempfile.gettempdir()).resolve(strict=False) - - def current_user_id(self) -> int | None: - """Return the POSIX user id when the platform defines one.""" - return None - - def supports_posix_permissions(self) -> bool: - """Return whether descriptor ownership and mode checks are meaningful.""" - return False - - def child_process_environment_keys(self) -> tuple[str, ...]: - """Return host variables required by a sanitized child process.""" - return ( - "HOME", - "LOGNAME", - "PATH", - "SHELL", - "TEMP", - "TERM", - "TMP", - "TMPDIR", - "USER", - ) - - def graphical_session_environment_keys(self) -> tuple[str, ...]: - """Return platform-owned variables required by graphical children.""" - return ( - "QT_QPA_PLATFORM", - "QT_PLUGIN_PATH", - "QT_QPA_PLATFORM_PLUGIN_PATH", - ) - - def graphical_session_indicator_keys(self) -> tuple[str, ...]: - """Return variables whose values establish an interactive GUI session.""" - return () - - def graphical_session_available( - self, - environment: Mapping[str, str], - ) -> bool: - """Return whether this platform can establish a GUI from the environment.""" - indicators = self.graphical_session_indicator_keys() - return bool(indicators) and any(environment.get(key) for key in indicators) - - def project_child_process_environment( - self, - environment: Mapping[str, str], - *, - include_graphical_session: bool = False, - additional_keys: tuple[str, ...] = (), - ) -> dict[str, str]: - """Project one source environment through declared child-process keys.""" - graphical_keys = ( - self.graphical_session_environment_keys() - if include_graphical_session - else () - ) - allowed_keys = dict.fromkeys( - ( - *self.child_process_environment_keys(), - *graphical_keys, - *additional_keys, - ) - ) - return {key: environment[key] for key in allowed_keys if key in environment} - - def graphical_process_environment( - self, - pid: int, - *, - additional_keys: tuple[str, ...] = (), - ) -> Mapping[str, str] | None: - """Return a sanitized graphical-child projection of one process.""" - environment = self._process_environment(pid) - if environment is None: - return None - return self.project_child_process_environment( - environment, - include_graphical_session=True, - additional_keys=additional_keys, - ) - - def application_data_root(self, application_name: str) -> Path: - """Return the user-scoped persistent data root for an application.""" - return (Path.home() / ".local" / "share" / application_name.casefold()).resolve( - strict=False - ) - - def _application_runtime_candidates( - self, - application_name: str, - component: Path, - ) -> tuple[Path, ...]: - del application_name, component - return () - - def application_runtime_dirs( - self, - application_name: str, - component: str | Path, - ) -> tuple[Path, ...]: - """Return ordered user-scoped runtime directories for a component.""" - component_path = Path(component) - candidates = list( - self._application_runtime_candidates( - application_name, - component_path, - ) - ) - temporary_name_parts = ( - application_name.casefold(), - *component_path.parts, - ) - temporary_name = "-".join(temporary_name_parts) - user_id = self.current_user_id() - if user_id is not None: - temporary_name = f"{temporary_name}-{user_id}" - candidates.append(self.temporary_root() / temporary_name) - return tuple(dict.fromkeys(candidates)) - - -class OtherRuntimePlatform(AgentRuntimePlatform): - """Portable conservative defaults for unknown host platforms.""" - - name = "other" - - -class PosixAgentRuntimePlatformMixin: - """Reusable POSIX runtime ownership and XDG path semantics.""" - - def current_user_id(self) -> int | None: - return os.getuid() - - def supports_posix_permissions(self) -> bool: - return self.current_user_id() is not None - - def graphical_session_indicator_keys(self) -> tuple[str, ...]: - """Return POSIX display-server variables that establish a GUI session.""" - return ( - "DISPLAY", - "WAYLAND_DISPLAY", - ) - - def graphical_session_environment_keys(self) -> tuple[str, ...]: - """Return POSIX desktop-session and toolkit variables.""" - return ( - *super().graphical_session_environment_keys(), - *self.graphical_session_indicator_keys(), - "XAUTHORITY", - "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", - "SESSION_MANAGER", - "XDG_SESSION_TYPE", - "XDG_SESSION_DESKTOP", - "XDG_DATA_HOME", - "XDG_CACHE_HOME", - "XDG_CONFIG_HOME", - "DESKTOP_SESSION", - ) - - def application_data_root(self, application_name: str) -> Path: - configured = os.environ.get("XDG_DATA_HOME") - data_home = ( - self.resolved_path(configured) - if configured - else (Path.home() / ".local" / "share").resolve(strict=False) - ) - return data_home / application_name.casefold() - - def _application_runtime_candidates( - self, - application_name: str, - component: Path, - ) -> tuple[Path, ...]: - configured = os.environ.get("XDG_RUNTIME_DIR") - if not configured: - return () - return ( - self.resolved_path(configured) / application_name.casefold() / component, - ) - - -class NativeGraphicalSessionMixin: - """Declare platforms whose desktop session needs no environment marker.""" - - def graphical_session_available( - self, - environment: Mapping[str, str], - ) -> bool: - del environment - return True - - -class PosixRuntimePlatform( - PosixAgentRuntimePlatformMixin, - AgentRuntimePlatform, -): - """Registered authority for otherwise-unclassified POSIX hosts.""" - - name = "posix" - - -class LinuxRuntimePlatform( - PosixAgentRuntimePlatformMixin, - AgentRuntimePlatform, -): - """Linux runtime paths, including the standard per-user runtime root.""" - - name = "linux" - proc_root: ClassVar[Path] = Path("/proc") - - def _process_environment(self, pid: int) -> Mapping[str, str] | None: - """Read a process environment for immediate owner-controlled projection.""" - try: - payload = (self.proc_root / str(pid) / "environ").read_bytes() - except (FileNotFoundError, PermissionError, ProcessLookupError, OSError): - return None - - environment: dict[str, str] = {} - for entry in payload.split(b"\0"): - key, separator, value = entry.partition(b"=") - if not separator or not key: - continue - environment[os.fsdecode(key)] = os.fsdecode(value) - return environment - - def _application_runtime_candidates( - self, - application_name: str, - component: Path, - ) -> tuple[Path, ...]: - candidates = list( - super()._application_runtime_candidates(application_name, component) - ) - user_id = self.current_user_id() - if user_id is not None: - candidates.append( - Path("/run/user") - / str(user_id) - / application_name.casefold() - / component - ) - return tuple(candidates) - - -class MacOSRuntimePlatform( - NativeGraphicalSessionMixin, - PosixAgentRuntimePlatformMixin, - AgentRuntimePlatform, -): - """macOS application-data convention plus POSIX runtime ownership.""" - - name = "macos" - - def application_data_root(self, application_name: str) -> Path: - return ( - Path.home() / "Library" / "Application Support" / application_name - ).resolve(strict=False) - - -class WindowsRuntimePlatform( - NativeGraphicalSessionMixin, - AgentRuntimePlatform, -): - """Windows user-data and local runtime path conventions.""" - - name = "windows" - - def child_process_environment_keys(self) -> tuple[str, ...]: - """Preserve native Windows process, home, app-data, and temp roots.""" - return ( - *super().child_process_environment_keys(), - "APPDATA", - "HOMEDRIVE", - "HOMEPATH", - "LOCALAPPDATA", - "SYSTEMROOT", - "USERPROFILE", - "WINDIR", - ) - - def _local_data_home(self) -> Path: - configured = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") - if configured: - return self.resolved_path(configured) - return (Path.home() / "AppData" / "Local").resolve(strict=False) - - def application_data_root(self, application_name: str) -> Path: - return self._local_data_home() / application_name - - def _application_runtime_candidates( - self, - application_name: str, - component: Path, - ) -> tuple[Path, ...]: - return (self._local_data_home() / application_name / "runtime" / component,) diff --git a/openhcs/agent/services/artifact_plan_inspection_service.py b/openhcs/agent/services/artifact_plan_inspection_service.py index dc50f2ba0c..0b8ccf2db3 100644 --- a/openhcs/agent/services/artifact_plan_inspection_service.py +++ b/openhcs/agent/services/artifact_plan_inspection_service.py @@ -9,7 +9,8 @@ from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from python_introspect import JsonObject, to_jsonable from openhcs.agent.dto.execution import ( ArtifactInputPlanSummary, diff --git a/openhcs/agent/services/config_service.py b/openhcs/agent/services/config_service.py index d8c6827132..aa464cbaab 100644 --- a/openhcs/agent/services/config_service.py +++ b/openhcs/agent/services/config_service.py @@ -24,7 +24,7 @@ from metaclass_registry import AutoRegisterMeta from objectstate import get_base_type_for_lazy -from pyqt_reactive.services.parameter_help_service import ( +from formbridge.services.parameter_help_service import ( class_docstring_text, dataclass_parameter_descriptions, parameter_description_from_target, @@ -39,7 +39,8 @@ resolve_optional, ) -from openhcs.agent.dto.common import AgentError, RenderedSource, SCHEMA_VERSION +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import RenderedSource, SCHEMA_VERSION from openhcs.agent.dto.config import ( ConfigFieldSchema, ConfigPatch, diff --git a/openhcs/agent/services/endpoint_function_catalog_service.py b/openhcs/agent/services/endpoint_function_catalog_service.py index befa6b250f..42126f129a 100644 --- a/openhcs/agent/services/endpoint_function_catalog_service.py +++ b/openhcs/agent/services/endpoint_function_catalog_service.py @@ -482,7 +482,7 @@ def register_custom_function( emit_signal=False, ) except Exception as error: - from openhcs.agent.dto.common import AgentError + from formbridge.contracts import AgentError # Native receipt is already confirmed; do not erase it on local failure. return replace(result, errors=(AgentError.from_exception( "custom_function_local_projection_failed", error, diff --git a/openhcs/agent/services/function_catalog_service.py b/openhcs/agent/services/function_catalog_service.py index d8c831a502..efb7c20ea3 100644 --- a/openhcs/agent/services/function_catalog_service.py +++ b/openhcs/agent/services/function_catalog_service.py @@ -13,8 +13,8 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar -from pyqt_reactive.services.help_document import HelpDocument -from pyqt_reactive.services.parameter_help_service import ( +from formbridge.services.help_document import HelpDocument +from formbridge.services.parameter_help_service import ( dataclass_type_from_annotation, docstring_info_for_target, ) @@ -813,7 +813,7 @@ def admit_write(path: Path) -> Path: def observe_custom_function_registration( self, handle: CustomFunctionRegistrationHandle, ) -> CustomFunctionRegistrationObservation: - from openhcs.agent.dto.common import AgentWarning + from formbridge.contracts import AgentWarning from openhcs.processing.custom_functions.runtime_registry import CustomFunctionRuntimeRegistry handle.require_current_owner() diff --git a/openhcs/agent/services/knowledge_base_service.py b/openhcs/agent/services/knowledge_base_service.py index a455885f0e..8de85f30f7 100644 --- a/openhcs/agent/services/knowledge_base_service.py +++ b/openhcs/agent/services/knowledge_base_service.py @@ -20,7 +20,8 @@ python_source_root, ) from openhcs.agent.knowledge_manifest_schema import ComparisonManifestSnapshot -from openhcs.agent.dto.common import AgentError, AgentWarning, SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION from python_introspect import JsonObject, JsonValue from openhcs.agent.dto.knowledge import ( KnowledgeBaseCatalog, diff --git a/openhcs/agent/services/llm_context_service.py b/openhcs/agent/services/llm_context_service.py index 5e179742bb..af3d2864b7 100644 --- a/openhcs/agent/services/llm_context_service.py +++ b/openhcs/agent/services/llm_context_service.py @@ -57,10 +57,10 @@ ) from openhcs.agent.ui_bridge_identities import ( PipelineDebugSessionStateSurfaceIdentityDeclaration, - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - UiLiveOverviewStateSurfaceIdentityDeclaration, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, ) +from formbridge.bridge.identities import UiLiveOverviewStateSurfaceIdentityDeclaration from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerDocumentAssignments, ) @@ -197,8 +197,8 @@ class RuntimeUiCoordinationSection( @classmethod def render(cls, service: "AgentAuthoringContextService") -> str: del service - code_document_id = PlateManagerOrchestratorCodeDocumentIdentity.require_value() - state_surface_id = PlateManagerStateSurfaceIdentityDeclaration.require_value() + code_document_id = DatasetListCodeDocumentIdentity.require_value() + state_surface_id = DatasetListStateSurfaceIdentity.require_value() document_fields = ", ".join(PlateManagerDocumentAssignments.names()) workflow_names = ", ".join( operation.operation_id diff --git a/openhcs/agent/services/object_state_field_help_service.py b/openhcs/agent/services/object_state_field_help_service.py index b99ad03f5b..7d612f800e 100644 --- a/openhcs/agent/services/object_state_field_help_service.py +++ b/openhcs/agent/services/object_state_field_help_service.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from types import ModuleType -from pyqt_reactive.services.parameter_help_service import ( +from formbridge.services.parameter_help_service import ( NO_PARAMETER_DESCRIPTION, docstring_info_for_target, parameter_help_content, @@ -15,14 +15,15 @@ ) from python_introspect import JsonValue, UnifiedParameterAnalyzer, enum_input_values -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.functions import ( FunctionArtifactSpec, FunctionDetail, SuppliedAtRuntime, SuppliedByAgent, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiBridgeConnectionSpec, UiObjectStateFieldHelpQuery, UiObjectStateFieldHelpRequest, @@ -37,10 +38,7 @@ FunctionCatalogService, FunctionCatalogServiceABC, ) -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeListObjectStateScopesOperation, - UiBridgeService, -) +from formbridge.bridge.service import UiBridgeListObjectStateScopesOperation, UiBridgeService @dataclass(frozen=True, slots=True) diff --git a/openhcs/agent/services/object_state_field_projection.py b/openhcs/agent/services/object_state_field_projection.py deleted file mode 100644 index 145ad900ce..0000000000 --- a/openhcs/agent/services/object_state_field_projection.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Headless ObjectState field filtering and list projection policies.""" - -from __future__ import annotations - -from collections.abc import Mapping - -from python_introspect import JsonValue, project_dataclass - -from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( - SemanticFields, - UiObjectStateFieldListQuery, - UiObjectStateFieldListResult, - UiObjectStateFieldPathIndex, - UiObjectStateFieldProjection, - UiObjectStateFieldScopeProjection, - UiObjectStateFieldSummary, - UiObjectStateScopeCatalog, -) - - -class ObjectStateFieldListProjector: - """Project scope catalogs through an ObjectState field query.""" - - @classmethod - def project_catalog( - cls, - query: UiObjectStateFieldListQuery, - catalog: UiObjectStateScopeCatalog, - ) -> UiObjectStateFieldListResult: - matched_scopes: list[UiObjectStateFieldScopeProjection] = [] - matched_field_count = 0 - returned_field_count = 0 - source_truncated = False - remaining_offset = query.field_offset - requested_field_paths = set(query.field_paths) - requested_scope_ids = set(query.scope_ids) - contains_terms = query.contains_terms - - for scope in catalog.scopes: - scope_id = scope.identity.object_state_scope_id - if requested_scope_ids and scope_id not in requested_scope_ids: - continue - if scope.field_page is not None and scope.field_page.truncated: - source_truncated = True - field_index = UiObjectStateFieldPathIndex(tuple(scope.fields)) - container_field_paths = field_index.container_field_paths - fields: list[UiObjectStateFieldProjection] = [] - for field in scope.fields: - field_path = field.address.field_path - exact_field_match = field_path in requested_field_paths - if requested_field_paths and not exact_field_match: - continue - if contains_terms and not any( - term in field_path.lower() for term in contains_terms - ): - continue - if ( - not query.include_container_fields - and not exact_field_match - and field_path in container_field_paths - ): - continue - if not query.include_clean_fields and not SemanticFields.matches( - field - ): - continue - if not query.field_filter.matches(field): - continue - matched_field_count += 1 - if remaining_offset: - remaining_offset -= 1 - continue - if query.return_limit and returned_field_count >= query.return_limit: - continue - fields.append(cls.project_field(query, field)) - returned_field_count += 1 - if fields: - matched_scopes.append( - UiObjectStateFieldScopeProjection( - scope_id=scope_id, - object_type=scope.object_type, - dirty_field_count=scope.dirty_field_count, - signature_diff_field_count=scope.signature_diff_field_count, - has_unsaved_changes=scope.has_unsaved_changes, - has_default_overrides=scope.has_default_overrides, - fields=tuple(fields), - ) - ) - - next_offset = None - if matched_field_count > query.field_offset + returned_field_count: - next_offset = query.field_offset + returned_field_count - - return UiObjectStateFieldListResult( - schema_version=SCHEMA_VERSION, - object_state_token=catalog.object_state_token, - current_branch=catalog.current_branch, - current_snapshot_index=catalog.current_snapshot_index, - requested_scope_ids=query.scope_ids, - field_paths=query.field_paths, - field_path_contains=query.field_path_contains, - field_filter=query.field_filter, - include_container_fields=query.include_container_fields, - matched_scope_count=len(matched_scopes), - matched_field_count=matched_field_count, - returned_field_count=returned_field_count, - field_limit=query.field_limit, - field_offset=query.field_offset, - next_offset=next_offset, - truncated=( - source_truncated - or matched_field_count > query.field_offset + returned_field_count - ), - scopes=tuple(matched_scopes), - errors=catalog.errors, - warnings=catalog.warnings, - ) - - @classmethod - def project_field( - cls, - query: UiObjectStateFieldListQuery, - field: UiObjectStateFieldSummary, - ) -> UiObjectStateFieldProjection: - return project_dataclass( - UiObjectStateFieldProjection, - field, - field_path=field.address.field_path, - raw_value=cls.compact_value(query, field.raw_value), - resolved_value=cls.compact_value(query, field.resolved_value), - ) - - @staticmethod - def compact_value( - query: UiObjectStateFieldListQuery, - value: JsonValue | None, - ) -> JsonValue | None: - if isinstance(value, str) and len(value) > query.max_value_chars: - return { - "type": "str", - "length": len(value), - "truncated": True, - "preview": value[: query.max_value_chars], - } - if isinstance(value, (list, tuple)) and len(value) > query.max_value_items: - return { - "type": "sequence", - "length": len(value), - "truncated": True, - "preview": tuple(value[: query.max_value_items]), - } - if isinstance(value, Mapping) and len(value) > query.max_value_items: - preview = tuple(value.items())[: query.max_value_items] - return { - "type": "mapping", - "length": len(value), - "truncated": True, - "preview": {str(key): item for key, item in preview}, - } - return value diff --git a/openhcs/agent/services/pipeline_authoring_service.py b/openhcs/agent/services/pipeline_authoring_service.py index 4a6359cfc1..859d969f1c 100644 --- a/openhcs/agent/services/pipeline_authoring_service.py +++ b/openhcs/agent/services/pipeline_authoring_service.py @@ -10,12 +10,8 @@ from objectstate import get_base_type_for_lazy -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentError, - AgentWarning, - RenderedSource, -) +from openhcs.agent.dto.common import SCHEMA_VERSION, RenderedSource +from formbridge.contracts import AgentError, AgentWarning from python_introspect import JsonObject, JsonValue from openhcs.agent.dto.config import ConfigPatch from openhcs.agent.dto.pipeline import ( diff --git a/openhcs/agent/services/plate_inspection_service.py b/openhcs/agent/services/plate_inspection_service.py index df564102cf..03510bcee7 100644 --- a/openhcs/agent/services/plate_inspection_service.py +++ b/openhcs/agent/services/plate_inspection_service.py @@ -10,7 +10,8 @@ from polystore.base import ImageSamplingRequest from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus -from openhcs.agent.dto.common import AgentError, AgentWarning, SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION from python_introspect import JsonObject from openhcs.agent.dto.plate import ( PlateFileQueryRecordSummary, @@ -45,9 +46,7 @@ ) from openhcs.agent.path_policy import AgentPathPolicy, AgentPathPolicyError from openhcs.authoring.session.operations.datasets import InitializeDatasets -from openhcs.agent.ui_bridge_identities import ( - PlateManagerOrchestratorCodeDocumentIdentity, -) +from openhcs.agent.ui_bridge_identities import DatasetListCodeDocumentIdentity from openhcs.constants.constants import Backend from openhcs.core.config import GlobalPipelineConfig, PathPlanningConfig from openhcs.core.plate_image_inventory import ( @@ -939,7 +938,7 @@ def _advice( ingestion_owner=ingestion_owner, source_binding_role=source_binding_role, ui_code_document_id=( - PlateManagerOrchestratorCodeDocumentIdentity.require_value() + DatasetListCodeDocumentIdentity.require_value() ), ui_operation=InitializeDatasets.operation_id, knowledge_query=cls.KNOWLEDGE_QUERY, diff --git a/openhcs/agent/services/plate_streaming_service.py b/openhcs/agent/services/plate_streaming_service.py index db62faf29c..353cf5176f 100644 --- a/openhcs/agent/services/plate_streaming_service.py +++ b/openhcs/agent/services/plate_streaming_service.py @@ -15,24 +15,22 @@ ) from polystore.streaming.viewer_transport import ViewerStreamProducer -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from python_introspect import JsonObject from openhcs.agent.dto.plate import ( PlateFileStreamRequest, PlateFileStreamResult, PlatePathInspectionRequest, ) -from openhcs.agent.dto.ui_bridge import UiBridgeConnectionSpec +from formbridge.bridge.dto import UiBridgeConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowStateResult from openhcs.agent.services.plate_inspection_service import ( PlateInspectionContext, PlateInspectionFileQueryProjection, PlateInspectionService, ) -from openhcs.agent.services.ui_bridge_service import ( - DEFAULT_UI_BRIDGE_CONNECTION_SPEC, - UiBridgeService, -) +from formbridge.bridge.service import DEFAULT_UI_BRIDGE_CONNECTION_SPEC, UiBridgeService from openhcs.constants import Backend from openhcs.core.config import StreamingConfig from openhcs.core.plate_image_inventory import ( diff --git a/openhcs/agent/services/runtime_server_service.py b/openhcs/agent/services/runtime_server_service.py index dc3060b7c8..889d7a9a0c 100644 --- a/openhcs/agent/services/runtime_server_service.py +++ b/openhcs/agent/services/runtime_server_service.py @@ -5,7 +5,8 @@ from abc import ABC, abstractmethod from dataclasses import replace -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION from python_introspect import JsonObject, registered_class_from_key, to_jsonable from openhcs.agent.dto.execution import ( RuntimeDebugArtifactExportRequest, diff --git a/openhcs/agent/services/selected_plate_service.py b/openhcs/agent/services/selected_plate_service.py index 46a6d98e64..89f927d0d5 100644 --- a/openhcs/agent/services/selected_plate_service.py +++ b/openhcs/agent/services/selected_plate_service.py @@ -5,8 +5,9 @@ from collections.abc import Mapping from dataclasses import dataclass -from openhcs.core.selection import SelectedItems -from openhcs.agent.dto.common import AgentError, AgentWarning, SCHEMA_VERSION +from formbridge.selection import SelectedItems +from formbridge.contracts import AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION from python_introspect import JsonValue from openhcs.agent.dto.plate import ( OutputPlateRoot, @@ -22,20 +23,15 @@ SelectedPlateImageSampleRequest, SelectedPlateImageSampleResult, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiBridgeConnectionSpec, UiStateSurfaceDocument, UiStateSurfaceRequest, ) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerStateSurfaceIdentityDeclaration, -) +from openhcs.agent.ui_bridge_identities import DatasetListStateSurfaceIdentity from openhcs.agent.services.plate_inspection_service import PlateInspectionService from openhcs.agent.services.plate_streaming_service import PlateStreamingService -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeGetStateSurfaceOperation, - UiBridgeService, -) +from formbridge.bridge.service import UiBridgeGetStateSurfaceOperation, UiBridgeService from openhcs.core.plate_image_inventory import PlateFileKind @@ -290,7 +286,7 @@ def selected_plate_state( state_document = self.ui_bridge_service.invoke( UiBridgeGetStateSurfaceOperation, UiStateSurfaceRequest( - surface_id=PlateManagerStateSurfaceIdentityDeclaration.require_value(), + surface_id=DatasetListStateSurfaceIdentity.require_value(), selection_mode=SelectedItems.name, ), connection, @@ -325,7 +321,7 @@ def selected_plate_target_root( target: type[SelectedPlateFileQueryTarget], source_format: str, ) -> tuple[SelectedPlateTargetRoot | None, AgentError | None]: - state_surface_id = PlateManagerStateSurfaceIdentityDeclaration.require_value() + state_surface_id = DatasetListStateSurfaceIdentity.require_value() plate_root = selected_plate.plate_root if plate_root is None: raise RuntimeError("Selected row plate_root was validated as a string.") @@ -347,7 +343,7 @@ def selected_plate_target_root( def selected_plate_row_from_state_surface( state_document: UiStateSurfaceDocument, ) -> tuple[Mapping[str, JsonValue] | None, AgentError | None]: - state_surface_id = PlateManagerStateSurfaceIdentityDeclaration.require_value() + state_surface_id = DatasetListStateSurfaceIdentity.require_value() selected_scope_ids = state_document.selected_scope_ids if len(selected_scope_ids) != 1: return None, AgentError( diff --git a/openhcs/agent/services/synthetic_plate_service.py b/openhcs/agent/services/synthetic_plate_service.py index b8b9ac1faa..076304cb83 100644 --- a/openhcs/agent/services/synthetic_plate_service.py +++ b/openhcs/agent/services/synthetic_plate_service.py @@ -5,7 +5,8 @@ from enum import Enum from pathlib import Path -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.plate import ( PlatePathInspectionRequest, PlatePathInspectionResult, diff --git a/openhcs/agent/services/ui_bridge_service.py b/openhcs/agent/services/ui_bridge_service.py index 0bc8e9104f..f4c5004857 100644 --- a/openhcs/agent/services/ui_bridge_service.py +++ b/openhcs/agent/services/ui_bridge_service.py @@ -1,661 +1,44 @@ -"""Agent service boundary for a running OpenHCS PyQt UI bridge.""" +"""The OpenHCS UI-bridge operations and its bridge connection environment.""" from __future__ import annotations -import json -import os -import stat -import time -from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass from os import environ -from pathlib import Path -from typing import Annotated, ClassVar - -from metaclass_registry import AutoRegisterMeta -from python_introspect import ( - EnvironmentVariable, - JsonObject, - dataclass_from_mapping, - overlay_dataclass_from_environment, - project_dataclass, -) +from typing import Annotated +from python_introspect import EnvironmentVariable from zmqruntime.config import ( NonBlankString, PositiveInteger, SocketPort, TransportMode, ) - -from openhcs.core.selection import AllItems, SelectedItems -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.session import SessionEventBatch, SessionEventsRequest -from openhcs.agent.dto.ui_bridge import ( - ActionAccepted, +from formbridge.bridge.dto import ( ActionUnavailable, - OperationRunning, - OperationUnavailable, UI_BRIDGE_UNKNOWN_WIDGET, - UNKNOWN_UI_BRIDGE_OPERATION_ROUTE, - UiActionCatalog, UiActionIdentity, - UiActionInvokeRequest, UiActionInvokeResult, - UiBranchCatalog, - UiBranchSwitchRequest, - UiBridgeCatalog, UiBridgeConnectionFields, UiBridgeConnectionSpec, - UiBridgeDescriptorFile, - UiBridgeDescriptorSummary, - UiBridgeDescriptorWirePayload, - UiBridgeEndpointIdentity, - UiBridgeOperationIdentity, - UiBridgeOperationRef, - UiBridgeOperationStatus, - UiBridgeOperationStatusRequest, - UiBridgeOperationWaitRequest, - UiBridgeStatus, - UiCodeDocument, - UiCodeDocumentApplyRequest, - UiCodeDocumentApplyResult, - UiCodeDocumentCatalog, - UiCodeDocumentIdentity, - UiCodeDocumentRequest, - UiCodeDocumentSummary, - UiCodeDocumentValidationRequest, - UiCodeDocumentValidationResult, UiMutationReceipt, - UiMutationRequestToken, - UiObjectStateFieldListQuery, - UiObjectStateFieldListResult, - UiObjectStateFieldMutationRequest, - UiObjectStateFieldMutationResult, - UiObjectStateScopeCatalog, - UiObjectStateScopeListRequest, +) +from openhcs.agent.dto.ui_bridge import ( UiSelectedPlateWorkflowRequest, UiSelectedPlateWorkflowResult, - UiSnapshotCatalog, - UiSnapshotListRequest, - UiSnapshotRestoreRequest, - UiSnapshotRestoreResult, - UiStateSurfaceCatalog, - UiStateSurfaceDocument, - UiStateSurfaceIdentity, - UiStateSurfaceRequest, - UiStateSurfaceSummary, - UiTimeTravelHeadRequest, - UiWidgetActionInvokeRequest, - UiWidgetActionInvokeResult, - UiWidgetTreeRequest, - UiWidgetTreeResult, - UiWindowCatalog, - UiWindowCloseRequest, - UiWindowCloseResult, - UiWindowFocusRequest, - UiWindowFocusResult, - UiWindowNavigateRequest, - UiWindowNavigateResult, - UiWindowSnapshotRequest, - UiWindowSnapshotResult, ) -from openhcs.agent.path_policy import AgentPathPolicy, AgentPathPolicyError -from openhcs.agent.runtime_platform import AgentRuntimePlatform -from openhcs.agent.services.object_state_field_projection import ( - ObjectStateFieldListProjector, +import openhcs.agent.ui_bridge_application # noqa: F401 (declares the OpenHCS bridge application) +from formbridge.bridge.service import ( + DEFAULT_UI_BRIDGE_CONNECTION_SPEC, + UiBridgeOperation, + UiBridgeService, ) -from openhcs.agent.ui_bridge_environment import UiBridgeDescriptorEnvironment +from formbridge.runtime_platform import AgentRuntimePlatform + from openhcs.core.native_threading import native_thread_count_environment_keys from openhcs.runtime.viewer_protocol import ViewerLaunchContext -from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION from openhcs.utils.environment import OpenHCSProcessEnvironment -UI_BRIDGE_PROTOCOL_VERSION = "openhcs.ui_bridge.v3" -DEFAULT_UI_BRIDGE_CONNECTION_SPEC = UiBridgeConnectionSpec() -DEFAULT_UI_BRIDGE_TIMEOUT_MS = DEFAULT_UI_BRIDGE_CONNECTION_SPEC.timeout_ms -UNAVAILABLE_UI_CODE_DOCUMENT_TITLE = "Unavailable UI code document" -UNAVAILABLE_UI_STATE_SURFACE_TITLE = "Unavailable UI state surface" - - -@dataclass(slots=True) -class UiBridgeRequestRejected(Exception): - """A request the client refuses before it reaches the bridge.""" - - errors: tuple[AgentError, ...] - - -class UiBridgeOperation(ABC, metaclass=AutoRegisterMeta): - """One running-UI operation: its request, result and failure answer. - - The class is the operation. Gateways, ``UiBridgeService`` and the MCP - capabilities are generic over it; the running UI serves it with one - ``@serves`` method. Operations with a ``name`` cross the wire and are - registered under it; operations without one are composed on the client. - """ - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - request_type: ClassVar[type] - result_type: ClassVar[type] - requires_auth: ClassVar[bool] = True - bridge_feature: ClassVar[str | None] = None - success_outcome: ClassVar[str] = "completed" - failure_error_code: ClassVar[str] = "ui_bridge_operation_failed" - - @classmethod - def for_name(cls, operation_name: str) -> type["UiBridgeOperation"]: - try: - return cls.__registry__[operation_name] - except KeyError as exc: - raise KeyError(f"Unknown UI bridge operation: {operation_name}") from exc - - @classmethod - def require_name(cls) -> str: - if cls.name is None: - raise ValueError(f"UI bridge operation {cls.__qualname__} has no name.") - return cls.name - - @classmethod - def supported_operation_names(cls) -> tuple[str, ...]: - return tuple(cls.__registry__) - - @classmethod - def supported_bridge_features(cls) -> tuple[str, ...]: - return tuple( - dict.fromkeys( - operation.bridge_feature - for operation in cls.__registry__.values() - if operation.bridge_feature is not None - ) - ) - - # -- request on the wire ------------------------------------------------ - - @classmethod - def decode_request(cls, payload: JsonObject): - return dataclass_from_mapping(cls.request_type, payload) - - @classmethod - def validate_request(cls, request) -> None: - if not isinstance(request, cls.request_type): - raise TypeError( - f"UI bridge operation {cls.name!r} requires " - f"{cls.request_type.__name__} request." - ) - - @classmethod - def serve(cls, handler: Callable, request): - """Run the running UI's implementation of this operation.""" - return handler(request) - - @classmethod - def as_served(cls, result, binding: UiBridgeConnectionSpec): - """The result as the bridge server answers it.""" - del binding - return result - - @classmethod - def accepted(cls, request, bridge_operation: UiBridgeOperationRef): - """The placeholder a queued mutation answers before it completes.""" - raise NotImplementedError(f"{cls.__name__} is not a queued mutation.") - - @classmethod - def outcome(cls, result) -> str: - """The tracker outcome of one completed mutation.""" - del result - return cls.success_outcome - - # -- client side ------------------------------------------------------- - - @classmethod - def failed(cls, request, errors: tuple[AgentError, ...]): - """The result reporting that the operation could not run.""" - raise NotImplementedError(f"{cls.__name__} declares no failure answer.") - - @classmethod - def prepare(cls, service: "UiBridgeService", request): - """Check or rewrite the request before it leaves the client.""" - del service - return request - - @classmethod - def call(cls, service: "UiBridgeService", connection, request): - return service.gateway.invoke(connection, cls, request) - - @classmethod - def respond(cls, service: "UiBridgeService", connection, request): - try: - request = cls.prepare(service, request) - except UiBridgeRequestRejected as rejected: - return cls.failed(request, rejected.errors) - resolution = service.resolve(connection) - if not resolution.ok: - return cls.failed(request, resolution.errors) - try: - return cls.call(service, resolution, request) - except Exception as exc: - return cls.failed( - request, ui_bridge_gateway_errors(exc, "ui_bridge_unavailable") - ) - - -class UiBridgeRequestlessOperation(UiBridgeOperation): - """Operation that takes no request.""" - - request_type: ClassVar[None] = None - - @classmethod - def decode_request(cls, payload: JsonObject) -> None: - if payload: - raise ValueError( - f"UI bridge operation {cls.name!r} does not accept a payload." - ) - return None - - @classmethod - def validate_request(cls, request) -> None: - if request is not None: - raise TypeError( - f"UI bridge operation {cls.name!r} does not accept a request." - ) - - @classmethod - def serve(cls, handler: Callable, request): - del request - return handler() - - -def serves(operation: type[UiBridgeOperation]) -> Callable[[Callable], Callable]: - """Mark the running UI's implementation of one wire operation.""" - - def mark(handler: Callable) -> Callable: - handler.served_operation = operation - return handler - - return mark - - -class UiBridgeOperationServer: - """Implementation side of the family: one ``@serves`` method per operation.""" - - served_operations: ClassVar[dict[type[UiBridgeOperation], Callable]] = {} - - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - cls.served_operations = { - handler.served_operation: handler - for klass in reversed(cls.__mro__) - for handler in vars(klass).values() - if hasattr(handler, "served_operation") - } - - def invoke(self, operation: type[UiBridgeOperation], request=None): - handler = self.served_operations[operation] - return operation.serve(handler.__get__(self), request) - - -# -- operation groups: the status feature tag and shared failure answers ----- - - -class UiCodeDocumentOperation(UiBridgeOperation): - bridge_feature = "ui_code_documents" - - -class UiStateSurfaceOperation(UiBridgeOperation): - bridge_feature = "ui_state_surfaces" - - -class UiActionOperation(UiBridgeOperation): - bridge_feature = "ui_actions" - - -class UiWindowOperation(UiBridgeOperation): - bridge_feature = "ui_windows" - - -class UiObjectStateScopeOperation(UiBridgeOperation): - bridge_feature = "objectstate_scopes" - - -class UiObjectStateSnapshotOperation(UiBridgeOperation): - bridge_feature = "objectstate_snapshots" - - -class UiObjectStateRestoreOperation(UiBridgeOperation): - """Operations that move the running UI to another ObjectState snapshot.""" - - result_type = UiSnapshotRestoreResult - - @classmethod - def failed(cls, request, errors): - del request - return UiSnapshotRestoreResult( - schema_version=SCHEMA_VERSION, - restored=False, - target_snapshot=None, - current_snapshot=None, - errors=errors, - ) - - @classmethod - def accepted(cls, request, bridge_operation): - del request - return UiSnapshotRestoreResult( - schema_version=SCHEMA_VERSION, - restored=False, - target_snapshot=None, - operation_id=bridge_operation.identity.operation_id, - receipt=UiMutationReceipt.accepted_for( - UiMutationRequestToken(), - bridge_operation_id=bridge_operation.identity.operation_id, - ), - ) - - @classmethod - def outcome(cls, result) -> str: - return "restored" if result.restored else "not_restored" - - -class UiBridgeOperationStatusQuery(UiBridgeOperation): - """Operations answering with one bridge operation's status.""" - - result_type = UiBridgeOperationRef - - @classmethod - def failed(cls, request, errors): - return UiBridgeOperationRef( - schema_version=SCHEMA_VERSION, - identity=UiBridgeOperationIdentity( - operation_id=request.operation_id, - route=UNKNOWN_UI_BRIDGE_OPERATION_ROUTE, - ), - status=OperationUnavailable.name, - started_at_unix=0.0, - errors=errors, - ) - - -# -- the operations ---------------------------------------------------------- - - -class UiBridgeStatusOperation(UiBridgeRequestlessOperation): - name = "status" - result_type = UiBridgeStatus - requires_auth = False - - @classmethod - def as_served(cls, result: UiBridgeStatus, binding: UiBridgeConnectionSpec): - return replace( - result, - auth_required=True, - bridge_instance_id=binding.bridge_instance_id, - connection=binding.public_connection(), - descriptor_file_path=binding.descriptor_file_path, - supported_operations=UiBridgeOperation.supported_operation_names(), - provider_catalog_schema_versions=(SCHEMA_VERSION,), - bridge_features=UiBridgeOperation.supported_bridge_features(), - ) - - @classmethod - def failed(cls, request, errors): - del request - return UiBridgeStatus(schema_version=SCHEMA_VERSION, reachable=False, errors=errors) - - @staticmethod - def _unreachable(resolution: "UiBridgeConnectionResolution") -> UiBridgeStatus: - return UiBridgeStatus( - schema_version=SCHEMA_VERSION, - reachable=False, - connection=resolution.public_connection(), - descriptor_file_path=resolution.descriptor_file_path, - descriptor_status=resolution.descriptor.status, - descriptors=resolution.descriptor.summaries, - errors=resolution.errors, - ) - - @classmethod - def respond(cls, service, connection, request): - resolution = service.resolve(connection) - if not resolution.ok: - return cls._unreachable(resolution) - try: - status = cls.call(service, resolution, request) - except Exception as exc: - return cls._unreachable( - UiBridgeConnectionResolution.from_connection( - resolution, - descriptor=resolution.descriptor, - errors=ui_bridge_gateway_errors(exc, "ui_bridge_unreachable"), - ) - ) - validation_errors = resolution.validate_live_status(status) - if validation_errors: - return cls._unreachable( - UiBridgeConnectionResolution.from_connection( - resolution, - descriptor=replace( - resolution.descriptor, - status=validation_errors[0].code, - ), - errors=validation_errors, - ) - ) - return resolution.descriptor.project_status(status, connection=resolution) - - -class UiBridgeListDocumentsOperation( - UiBridgeRequestlessOperation, UiCodeDocumentOperation -): - name = "list_documents" - result_type = UiCodeDocumentCatalog - - @classmethod - def failed(cls, request, errors): - return UiCodeDocumentCatalog(SCHEMA_VERSION, documents=(), errors=errors) - - -class UiBridgeListStateSurfacesOperation( - UiBridgeRequestlessOperation, UiStateSurfaceOperation -): - name = "list_state_surfaces" - result_type = UiStateSurfaceCatalog - - @classmethod - def failed(cls, request, errors): - return UiStateSurfaceCatalog(SCHEMA_VERSION, surfaces=(), errors=errors) - - -class UiBridgeListActionsOperation(UiBridgeRequestlessOperation, UiActionOperation): - name = "list_actions" - result_type = UiActionCatalog - - @classmethod - def failed(cls, request, errors): - return UiActionCatalog(SCHEMA_VERSION, actions=(), errors=errors) - - -class UiBridgeListWindowsOperation(UiBridgeRequestlessOperation, UiWindowOperation): - name = "list_windows" - result_type = UiWindowCatalog - - @classmethod - def failed(cls, request, errors): - return UiWindowCatalog(schema_version=SCHEMA_VERSION, windows=(), errors=errors) - - -class UiBridgeListObjectStateScopesOperation(UiObjectStateScopeOperation): - name = "list_object_state_scopes" - request_type = UiObjectStateScopeListRequest - result_type = UiObjectStateScopeCatalog - - @classmethod - def failed(cls, request, errors): - del request - return UiObjectStateScopeCatalog( - schema_version=SCHEMA_VERSION, - object_state_token=0, - current_branch="", - current_snapshot_index=-1, - active=False, - scopes=(), - errors=errors, - ) - - @classmethod - def respond(cls, service, connection, request): - return request.filtered_catalog(super().respond(service, connection, request)) - - -class UiBridgeGetObjectStateFieldsOperation(UiObjectStateScopeOperation): - """Field rows projected on the client from the scope catalog.""" - - request_type = UiObjectStateFieldListQuery - result_type = UiObjectStateFieldListResult - - @classmethod - def respond(cls, service, connection, request): - catalog = UiBridgeListObjectStateScopesOperation.respond( - service, connection, request.scope_list_request() - ) - return ObjectStateFieldListProjector.project_catalog(request, catalog) - - -class UiBridgeMutateObjectStateFieldOperation(UiBridgeOperation): - name = "mutate_object_state_field" - request_type = UiObjectStateFieldMutationRequest - result_type = UiObjectStateFieldMutationResult - bridge_feature = "objectstate_field_mutation" - - @classmethod - def failed(cls, request, errors): - return UiObjectStateFieldMutationResult( - schema_version=SCHEMA_VERSION, - address=request, - mutated=False, - reset=request.reset, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=errors, - ) - - @classmethod - def accepted(cls, request, bridge_operation): - return UiObjectStateFieldMutationResult( - schema_version=SCHEMA_VERSION, - address=request, - mutated=False, - reset=request.reset, - receipt=UiMutationReceipt.accepted_for( - request.request_token, - bridge_operation_id=bridge_operation.identity.operation_id, - ), - ) - - @classmethod - def outcome(cls, result) -> str: - return "mutated" if result.mutated else "not_mutated" - - -class UiBridgeGetDocumentOperation(UiCodeDocumentOperation): - name = "get_document" - request_type = UiCodeDocumentRequest - result_type = UiCodeDocument - - @classmethod - def failed(cls, request, errors): - return UiCodeDocument( - schema_version=SCHEMA_VERSION, - summary=UiCodeDocumentSummary( - schema_version=SCHEMA_VERSION, - identity=UiCodeDocumentIdentity(document_id=request.document_id), - title=UNAVAILABLE_UI_CODE_DOCUMENT_TITLE, - widget_id=UI_BRIDGE_UNKNOWN_WIDGET, - readable=False, - writable=False, - ), - source="", - mime_type="text/x-python", - size_bytes=0, - sha256="", - current_revision_token=None, - current_snapshot=None, - selection_mode=request.resolved_selection_mode( - SelectedItems - ), - selected_scope_ids=(), - errors=errors, - ) - - -class UiBridgeGetStateSurfaceOperation(UiStateSurfaceOperation): - name = "get_state_surface" - request_type = UiStateSurfaceRequest - result_type = UiStateSurfaceDocument - - @classmethod - def failed(cls, request, errors): - return UiStateSurfaceDocument( - schema_version=SCHEMA_VERSION, - summary=UiStateSurfaceSummary( - schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id=request.surface_id), - title=UNAVAILABLE_UI_STATE_SURFACE_TITLE, - widget_id=UI_BRIDGE_UNKNOWN_WIDGET, - readable=False, - ), - payload_schema="openhcs.ui.unavailable_state_surface.v1", - payload={}, - selection_mode=request.resolved_selection_mode( - AllItems - ), - selected_scope_ids=(), - current_revision_token=None, - current_snapshot=None, - errors=errors, - ) - - -class UiBridgeInvokeActionOperation(UiActionOperation): - name = "invoke_action" - request_type = UiActionInvokeRequest - result_type = UiActionInvokeResult - - @classmethod - def failed(cls, request, errors): - return UiActionInvokeResult( - schema_version=SCHEMA_VERSION, - identity=UiActionIdentity( - widget_id=request.widget_id, - action_id=request.action_id, - ), - status=ActionUnavailable, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=errors, - ) - - @classmethod - def accepted(cls, request, bridge_operation): - return UiActionInvokeResult( - schema_version=SCHEMA_VERSION, - identity=UiActionIdentity( - widget_id=request.widget_id, - action_id=request.action_id, - ), - status=ActionAccepted, - receipt=UiMutationReceipt.accepted_for( - request.request_token, - bridge_operation_id=bridge_operation.identity.operation_id, - ), - target_scope_ids=request.selected_scope_ids, - selection_revision_token=request.observed_selection_revision_token, - ) - - @classmethod - def outcome(cls, result) -> str: - return result.status.name - class UiBridgeSelectedPlateWorkflowOperation(UiBridgeOperation): name = "selected_plate_workflow" @@ -697,1123 +80,42 @@ def failed(cls, request, errors): ) -class UiBridgeFocusWindowOperation(UiWindowOperation): - name = "focus_window" - request_type = UiWindowFocusRequest - result_type = UiWindowFocusResult - - @classmethod - def failed(cls, request, errors): - return UiWindowFocusResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=False, - errors=errors, - ) - - -class UiBridgeNavigateWindowOperation(UiBridgeOperation): - name = "navigate_window" - request_type = UiWindowNavigateRequest - result_type = UiWindowNavigateResult - bridge_feature = "ui_window_navigation" - success_outcome = "navigated" - failure_error_code = "ui_window_navigation_failed" - - @classmethod - def failed(cls, request, errors): - return UiWindowNavigateResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=False, - navigated=False, - created=False, - errors=errors, - ) - +@dataclass(frozen=True, slots=True) +class UiBridgeEnvironment(UiBridgeConnectionFields): + """The environment variables that override a UI bridge client's connection.""" -class UiBridgeCloseWindowOperation(UiWindowOperation): - name = "close_window" - request_type = UiWindowCloseRequest - result_type = UiWindowCloseResult + host: Annotated[ + NonBlankString | None, + EnvironmentVariable("OPENHCS_UI_BRIDGE_HOST"), + ] = None + port: Annotated[ + SocketPort | None, + EnvironmentVariable("OPENHCS_UI_BRIDGE_PORT"), + ] = None + transport_mode: Annotated[ + TransportMode | None, + EnvironmentVariable("OPENHCS_UI_BRIDGE_TRANSPORT_MODE"), + ] = None + timeout_ms: Annotated[ + PositiveInteger | None, + EnvironmentVariable("OPENHCS_UI_BRIDGE_TIMEOUT_MS"), + ] = None + auth_token: Annotated[ + NonBlankString | None, + EnvironmentVariable("OPENHCS_UI_BRIDGE_AUTH_TOKEN"), + ] = None - @classmethod - def failed(cls, request, errors): - return UiWindowCloseResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - closed=False, - errors=errors, - ) - - -class UiBridgeSnapshotWindowOperation(UiBridgeOperation): - name = "snapshot_window" - request_type = UiWindowSnapshotRequest - result_type = UiWindowSnapshotResult - bridge_feature = "ui_window_snapshots" - success_outcome = "captured" - failure_error_code = "ui_window_snapshot_failed" - - @classmethod - def failed(cls, request, errors): - return project_dataclass( - UiWindowSnapshotResult, - request, - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - captured=False, - errors=errors, - ) - - @classmethod - def prepare(cls, service, request): - try: - output_dir = service.path_policy.assert_writable(request.output_dir_path) - except AgentPathPolicyError as exc: - raise UiBridgeRequestRejected((exc.to_agent_error(),)) from exc - return replace(request, output_dir_path=str(output_dir)) - - -class UiBridgeWidgetTreeOperation(UiBridgeOperation): - name = "widget_tree" - request_type = UiWidgetTreeRequest - result_type = UiWidgetTreeResult - bridge_feature = "widget_tree_projection" - - @classmethod - def failed(cls, request, errors): - return UiWidgetTreeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - projected=False, - errors=errors, - ) - - -class UiBridgeGetOperationStatusOperation(UiBridgeOperationStatusQuery): - name = "get_operation_status" - request_type = UiBridgeOperationStatusRequest - bridge_feature = "operation_status" - - -class UiBridgeWaitForOperationReceiptOperation(UiBridgeOperationStatusQuery): - """Wait on the client for an operation to reach a terminal status.""" - - request_type = UiBridgeOperationWaitRequest - - @classmethod - def call(cls, service, connection, request): - deadline = time.monotonic() + request.timeout_seconds - status_request = UiBridgeOperationStatusRequest( - operation_id=request.operation_id - ) - while True: - operation = UiBridgeGetOperationStatusOperation.call( - service, connection, status_request - ) - try: - status = UiBridgeOperationStatus.named(operation.status) - except ValueError: - return replace( - operation, - errors=( - *operation.errors, - AgentError( - code="invalid_ui_bridge_operation_status", - message=( - f"UI bridge operation {request.operation_id!r} returned " - f"unknown status {operation.status!r}." - ), - ), - ), - ) - if status.is_terminal: - return operation - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0.0: - return replace( - operation, - errors=( - *operation.errors, - AgentError( - code="ui_bridge_operation_wait_timeout", - message=( - f"UI bridge operation {request.operation_id!r} did not " - f"reach a terminal status within " - f"{request.timeout_seconds:g} seconds." - ), - hint=( - "The bridge mutation receipt remains active; inspect " - "it with openhcs_ui_get_operation_status or call " - "openhcs_ui_wait_for_operation_receipt again. Receipt " - "completion does not establish domain workflow " - "completion." - ), - ), - ), - ) - time.sleep(min(request.poll_interval_seconds, remaining_seconds)) - - -class UiBridgeInvokeWidgetActionOperation(UiBridgeOperation): - name = "invoke_widget_action" - request_type = UiWidgetActionInvokeRequest - result_type = UiWidgetActionInvokeResult - bridge_feature = "widget_action_invocation" - - @classmethod - def failed(cls, request, errors): - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=request.action_kind, - invoked=False, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=errors, - ) - - @classmethod - def accepted(cls, request, bridge_operation): - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=request.action_kind, - invoked=False, - receipt=UiMutationReceipt.accepted_for( - request.request_token, - bridge_operation_id=bridge_operation.identity.operation_id, - ), - ) - - @classmethod - def outcome(cls, result) -> str: - return result.outcome.value - - @classmethod - def call(cls, service, connection, request): - """Return the terminal fact an accepted action receipt stands for.""" - result = super().call(service, connection, request) - operation_id = result.receipt.bridge_operation_id - if result.invoked or not result.receipt.accepted or operation_id is None: - return result - operation = UiBridgeWaitForOperationReceiptOperation.call( - service, - connection, - UiBridgeOperationWaitRequest( - operation_id=operation_id, - timeout_seconds=min(connection.timeout_ms / 1000.0, 120.0), - poll_interval_seconds=0.05, - ), - ) - return result.resolve_operation(operation) - - -class UiBridgeValidateDocumentOperation(UiCodeDocumentOperation): - name = "validate_document" - request_type = UiCodeDocumentValidationRequest - result_type = UiCodeDocumentValidationResult - - @classmethod - def failed(cls, request, errors): - return UiCodeDocumentValidationResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - valid=False, - errors=errors, - ) - - -class UiBridgeApplyDocumentOperation(UiCodeDocumentOperation): - name = "apply_document" - request_type = UiCodeDocumentApplyRequest - result_type = UiCodeDocumentApplyResult - - @classmethod - def failed(cls, request, errors): - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=False, - base_revision_token=request.base_revision_token, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=errors, - ) - - @classmethod - def accepted(cls, request, bridge_operation): - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=False, - base_revision_token=request.base_revision_token, - outcome=OperationRunning.name, - operation_id=bridge_operation.identity.operation_id, - receipt=UiMutationReceipt.accepted_for( - request.request_token, - bridge_operation_id=bridge_operation.identity.operation_id, - ), - ) - - @classmethod - def outcome(cls, result) -> str: - return result.outcome - - -class UiBridgeListSnapshotsOperation(UiObjectStateSnapshotOperation): - name = "list_snapshots" - request_type = UiSnapshotListRequest - result_type = UiSnapshotCatalog - - @classmethod - def failed(cls, request, errors): - del request - return UiSnapshotCatalog( - schema_version=SCHEMA_VERSION, - current_branch="", - current_snapshot_index=-1, - object_state_token=0, - active=False, - snapshots=(), - branches=(), - errors=errors, - ) - - -class UiBridgeRestoreSnapshotOperation( - UiObjectStateRestoreOperation, UiObjectStateSnapshotOperation -): - name = "restore_snapshot" - request_type = UiSnapshotRestoreRequest - - @classmethod - def prepare(cls, service, request): - del service - selectors = (request.snapshot_id, request.index, request.branch) - if sum(selector is not None for selector in selectors) != 1: - raise UiBridgeRequestRejected( - ( - AgentError( - code="invalid_snapshot_restore_request", - message="Exactly one snapshot restore selector is required.", - ), - ) - ) - return request - - -class UiBridgeTimeTravelHeadOperation( - UiObjectStateRestoreOperation, UiObjectStateSnapshotOperation -): - name = "time_travel_head" - request_type = UiTimeTravelHeadRequest - - -class UiObjectStateBranchOperation(UiBridgeOperation): - bridge_feature = "objectstate_branches" - - -class UiBridgeListBranchesOperation( - UiBridgeRequestlessOperation, UiObjectStateBranchOperation -): - name = "list_branches" - result_type = UiBranchCatalog - - @classmethod - def failed(cls, request, errors): - return UiBranchCatalog( - SCHEMA_VERSION, current_branch="", branches=(), errors=errors - ) - - -class UiBridgeSwitchBranchOperation( - UiObjectStateRestoreOperation, UiObjectStateBranchOperation -): - name = "switch_branch" - request_type = UiBranchSwitchRequest - - -class UiBridgeDescriptorDirectoryAuthority: - """Filesystem location policy for live UI bridge descriptors.""" - - @staticmethod - def default_descriptor_dir() -> Path: - return UiBridgeDescriptorDirectoryAuthority.descriptor_dirs()[0] - - @classmethod - def descriptor_dirs(cls) -> tuple[Path, ...]: - configured = environ.get( - UiBridgeDescriptorEnvironment.descriptor_directory_path_key - ) - if configured: - return (AgentRuntimePlatform.resolved_path(configured),) - - return AgentRuntimePlatform.current().application_runtime_dirs( - "OpenHCS", - "ui-bridge", - ) - - -class UiBridgeGatewayABC(ABC): - """Transport to a running OpenHCS UI bridge, generic over the operation.""" - - @abstractmethod - def invoke( - self, - connection: UiBridgeConnectionSpec, - operation: type[UiBridgeOperation], - request, - ): - raise NotImplementedError - - - -class UiBridgeGatewayErrorABC(ABC): - """Gateway-originated bridge failure that knows its agent-facing errors.""" - - @abstractmethod - def agent_errors(self, fallback_code: str) -> tuple[AgentError, ...]: - raise NotImplementedError - - -@dataclass(slots=True) -class UiBridgeGatewayUnavailableError(ConnectionError, UiBridgeGatewayErrorABC): - def __str__(self) -> str: - return "No running OpenHCS UI bridge gateway is configured." - - def agent_errors(self, fallback_code: str) -> tuple[AgentError, ...]: - del fallback_code - return ( - AgentError.from_exception( - "ui_bridge_unavailable", - self, - hint=self.discovery_hint(), - ), - ) - - @staticmethod - def discovery_hint() -> str: - searched_dirs = ", ".join( - str(path) for path in UiBridgeDescriptorDirectoryAuthority.descriptor_dirs() - ) - return ( - "Pass descriptor_file_path, set OPENHCS_UI_BRIDGE_DESCRIPTOR, set " - "OPENHCS_UI_BRIDGE_DESCRIPTOR_DIR, or restart the UI so its bridge " - f"descriptor is written to one of the searched directories: {searched_dirs}." - ) - - -@dataclass(slots=True) -class UiBridgeGatewayResponseError(RuntimeError, UiBridgeGatewayErrorABC): - errors: tuple[AgentError, ...] - - def __str__(self) -> str: - if not self.errors: - return "UI bridge returned an error response." - return "; ".join(error.message for error in self.errors) - - def agent_errors(self, fallback_code: str) -> tuple[AgentError, ...]: - del fallback_code - return tuple(self._with_restart_hint(error) for error in self.errors) - - @staticmethod - def _with_restart_hint(error: AgentError) -> AgentError: - if error.code != "unsupported_ui_bridge_operation": - return error - if error.hint: - return error - return replace( - error, - hint=( - "The running OpenHCS UI bridge does not expose this operation. " - "Restart the UI or UI bridge process so it imports the current " - "OpenHCS source, then retry the MCP call." - ), - ) - - -@dataclass(slots=True) -class UiBridgeGatewayTimeoutError(TimeoutError, UiBridgeGatewayErrorABC): - agent_error_code: ClassVar[str] = "ui_bridge_timeout" - - operation: str - timeout_ms: int - - def __str__(self) -> str: - return ( - f"UI bridge operation {self.operation!r} timed out after " - f"{self.timeout_ms}ms." - ) - - def agent_errors(self, fallback_code: str) -> tuple[AgentError, ...]: - del fallback_code - return ( - AgentError( - code=self.agent_error_code, - message=str(self), - hint=( - "The running UI may be blocked or busy; retry after the UI " - "event loop is responsive." - ), - exception_type=type(self).__name__, - ), - ) - - -def ui_bridge_gateway_errors( - exception: Exception, - fallback_code: str, -) -> tuple[AgentError, ...]: - """Project a gateway exception into agent-facing errors.""" - - if isinstance(exception, UiBridgeGatewayErrorABC): - return exception.agent_errors(fallback_code) - return (AgentError.from_exception(fallback_code, exception),) - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorResolution: - status: str | None = None - summaries: tuple[UiBridgeDescriptorSummary, ...] = () - - def project_status( - self, - status_result: UiBridgeStatus, - *, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeStatus: - descriptor_status = status_result.descriptor_status - if self.status is not None: - descriptor_status = self.status - descriptors = status_result.descriptors - if self.summaries: - descriptors = self.summaries - return replace( - status_result, - connection=connection.public_connection(), - descriptor_status=descriptor_status, - descriptors=descriptors, - ) - - -@dataclass(frozen=True, slots=True) -class UiBridgeConnectionResolution(UiBridgeConnectionSpec): - descriptor: UiBridgeDescriptorResolution = UiBridgeDescriptorResolution() - errors: tuple[AgentError, ...] = () - - @classmethod - def from_connection( - cls, - connection: UiBridgeConnectionSpec, - *, - descriptor: UiBridgeDescriptorResolution = UiBridgeDescriptorResolution(), - errors: tuple[AgentError, ...] = (), - ) -> "UiBridgeConnectionResolution": - return project_dataclass( - cls, - connection, - descriptor=descriptor, - errors=errors, - ) - - @property - def ok(self) -> bool: - return not self.errors - - @property - def process_id(self) -> int | None: - """Return the sole validated UI process identity, when descriptor-backed.""" - if len(self.descriptor.summaries) != 1: - return None - return self.descriptor.summaries[0].pid - - def validate_live_status( - self, - status_result: UiBridgeStatus, - ) -> tuple[AgentError, ...]: - """Validate that a status response still belongs to the resolved descriptor.""" - if len(self.descriptor.summaries) != 1: - return () - expected = self.descriptor.summaries[0] - if expected.descriptor_file_path is None: - return self._endpoint_identity_errors( - "The resolved UI bridge descriptor has no descriptor-file identity." - ) - reread = UiBridgeDescriptorReader.read(Path(expected.descriptor_file_path)) - if not reread.ok or reread.descriptor is None: - return reread.errors - current = reread.descriptor.public_summary(expected.status) - if current != expected: - return self._endpoint_identity_errors( - "The UI bridge descriptor changed while its status was being checked." - ) - if UiBridgeEndpointIdentity.from_value( - status_result - ) != UiBridgeEndpointIdentity.from_value(expected): - return self._endpoint_identity_errors( - "The responding UI bridge does not own the resolved descriptor endpoint." - ) - return () - - @staticmethod - def _endpoint_identity_errors(message: str) -> tuple[AgentError, ...]: - return ( - AgentError( - code="ui_bridge_endpoint_identity_mismatch", - message=message, - hint="Resolve the live UI bridge again before retrying.", - ), - ) - - -@dataclass(frozen=True, slots=True) -class UiBridgeDescriptorReadResult: - descriptor: UiBridgeDescriptorFile | None - path: Path - errors: tuple[AgentError, ...] = () - stale_process_descriptor: bool = False - - @property - def ok(self) -> bool: - return self.descriptor is not None and not self.errors - - -@dataclass(slots=True) -class UiBridgeDescriptorProcessGoneError(ValueError): - pid: int - - def __str__(self) -> str: - return f"UI bridge process is not running: {self.pid}" - - -@dataclass(slots=True) -class UiBridgeDescriptorProcessIdentityError(ValueError): - pid: int - descriptor_started_at_unix: float - process_started_at_unix: float - - def __str__(self) -> str: - return ( - f"UI bridge descriptor process identity is stale for PID {self.pid}: " - f"the current process started at {self.process_started_at_unix}, " - f"after the descriptor timestamp {self.descriptor_started_at_unix}" - ) - - -@dataclass(frozen=True, slots=True) -class LiveUiBridgeDescriptorSet: - descriptors: tuple[UiBridgeDescriptorFile, ...] - - def resolution_runner(self) -> "DescriptorSetResolutionRunner": - """Return the resolver for this set's size: none, one or many.""" - count = len(self.descriptors) - if count == 0: - return NoDescriptorSetResolutionRunner() - if count == 1: - return SingleDescriptorSetResolutionRunner() - return AmbiguousDescriptorSetResolutionRunner() - - def only_descriptor(self) -> UiBridgeDescriptorFile: - if len(self.descriptors) != 1: - raise ValueError( - "Live UI bridge descriptor set does not contain exactly one descriptor." - ) - return self.descriptors[0] - - -@dataclass(frozen=True, slots=True) -class UiBridgeEnvironment(UiBridgeConnectionFields): - host: Annotated[ - NonBlankString | None, - EnvironmentVariable("OPENHCS_UI_BRIDGE_HOST"), - ] = None - port: Annotated[ - SocketPort | None, - EnvironmentVariable("OPENHCS_UI_BRIDGE_PORT"), - ] = None - transport_mode: Annotated[ - TransportMode | None, - EnvironmentVariable("OPENHCS_UI_BRIDGE_TRANSPORT_MODE"), - ] = None - timeout_ms: Annotated[ - PositiveInteger | None, - EnvironmentVariable("OPENHCS_UI_BRIDGE_TIMEOUT_MS"), - ] = None - auth_token: Annotated[ - NonBlankString | None, - EnvironmentVariable("OPENHCS_UI_BRIDGE_AUTH_TOKEN"), - ] = None - - @classmethod - def current(cls) -> "UiBridgeEnvironment": - return overlay_dataclass_from_environment(cls()) - - def apply(self, connection: UiBridgeConnectionSpec) -> UiBridgeConnectionSpec: - return UiBridgeConnectionSpec.from_fields( - self, - defaults=connection, - ) - - -class DescriptorSetResolutionRunner(ABC): - """Resolver behavior for a live UI bridge descriptor set of one size.""" - - @abstractmethod - def resolve( - self, - resolver: "UiBridgeDescriptorResolver", - descriptor_set: LiveUiBridgeDescriptorSet, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - raise NotImplementedError - - -class NoDescriptorSetResolutionRunner(DescriptorSetResolutionRunner): - def resolve( - self, - resolver: "UiBridgeDescriptorResolver", - descriptor_set: LiveUiBridgeDescriptorSet, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - del resolver, descriptor_set - return UiBridgeConnectionResolution.from_connection( - UiBridgeEnvironment.current().apply(connection), - descriptor=UiBridgeDescriptorResolution(), - ) - - -class SingleDescriptorSetResolutionRunner(DescriptorSetResolutionRunner): - def resolve( - self, - resolver: "UiBridgeDescriptorResolver", - descriptor_set: LiveUiBridgeDescriptorSet, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - return resolver._connection_from_descriptor( - descriptor_set.only_descriptor(), - connection, - "ok", - ) - - -class AmbiguousDescriptorSetResolutionRunner(DescriptorSetResolutionRunner): - def resolve( - self, - resolver: "UiBridgeDescriptorResolver", - descriptor_set: LiveUiBridgeDescriptorSet, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - return UiBridgeConnectionResolution.from_connection( - connection, - descriptor=UiBridgeDescriptorResolution( - status="ambiguous_ui_bridge", - summaries=tuple( - descriptor.public_summary("live") - for descriptor in descriptor_set.descriptors - ), - ), - errors=( - AgentError( - code="ambiguous_ui_bridge", - message="Multiple running OpenHCS UI bridge descriptors were found.", - hint="Provide descriptor_file_path or bridge_instance_id.", - ), - ), - ) - - -class UiBridgeDescriptorReader: - """Read, parse, and validate one UI bridge descriptor file.""" - - @classmethod - def read(cls, path: Path) -> UiBridgeDescriptorReadResult: - resolved_path = path.expanduser().resolve(strict=False) - try: - cls._validate_descriptor_file_path(resolved_path) - payload = json.loads(resolved_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("UI bridge descriptor must be a JSON object.") - descriptor = cls._descriptor_from_payload( - dataclass_from_mapping(UiBridgeDescriptorWirePayload, payload), - resolved_path, - ) - cls._validate_descriptor_process(descriptor) - cls._validate_descriptor_compatibility(descriptor) - except Exception as exc: - return UiBridgeDescriptorReadResult( - descriptor=None, - path=resolved_path, - errors=(AgentError.from_exception("stale_ui_bridge_descriptor", exc),), - stale_process_descriptor=isinstance( - exc, - ( - UiBridgeDescriptorProcessGoneError, - UiBridgeDescriptorProcessIdentityError, - ), - ), - ) - return UiBridgeDescriptorReadResult(descriptor=descriptor, path=resolved_path) - - @classmethod - def _descriptor_from_payload( - cls, - descriptor_payload: UiBridgeDescriptorWirePayload, - descriptor_path: Path, - ) -> UiBridgeDescriptorFile: - del cls - return project_dataclass( - UiBridgeDescriptorFile, - descriptor_payload, - descriptor_file_path=str(descriptor_path), - ) - - @staticmethod - def _validate_descriptor_compatibility( - descriptor: UiBridgeDescriptorFile, - ) -> None: - if descriptor.bridge_protocol_version != UI_BRIDGE_PROTOCOL_VERSION: - raise ValueError( - "Unsupported UI bridge protocol version: " - f"{descriptor.bridge_protocol_version}" - ) - OPENHCS_ENDPOINT_APPLICATION.compatibility_with( - descriptor.application - ).require_match() - - @staticmethod - def _validate_descriptor_file_path(path: Path) -> None: - stat_result = path.stat() - if not stat.S_ISREG(stat_result.st_mode): - raise PermissionError("UI bridge descriptor must be a regular file.") - platform_authority = AgentRuntimePlatform.current() - if not platform_authority.supports_posix_permissions(): - return - uid = platform_authority.current_user_id() - if uid is None: - return - if stat_result.st_uid != uid: - raise PermissionError( - "UI bridge descriptor is not owned by the current user." - ) - if stat_result.st_mode & (stat.S_IRWXG | stat.S_IRWXO): - raise PermissionError( - "UI bridge descriptor must not be group/world accessible." - ) - parent_stat = path.parent.stat() - parent_mode = parent_stat.st_mode - parent_is_sticky = bool(parent_mode & stat.S_ISVTX) - if parent_mode & (stat.S_IWGRP | stat.S_IWOTH) and not parent_is_sticky: - raise PermissionError( - "UI bridge descriptor parent directory is writable by other users." - ) - - @staticmethod - def _validate_descriptor_process(descriptor: UiBridgeDescriptorFile) -> None: - process_started_at_unix = AgentRuntimePlatform.process_started_at_unix( - descriptor.pid - ) - if process_started_at_unix is None: - raise UiBridgeDescriptorProcessGoneError(descriptor.pid) - if process_started_at_unix > descriptor.started_at_unix: - raise UiBridgeDescriptorProcessIdentityError( - pid=descriptor.pid, - descriptor_started_at_unix=descriptor.started_at_unix, - process_started_at_unix=process_started_at_unix, - ) - - -class UiBridgeDescriptorCatalog: - """Discover the descriptor set selected for the current process.""" - - @staticmethod - def selected_descriptor_path() -> Path | None: - """Return the exact descriptor selected by the shared environment contract.""" - - configured = environ.get(UiBridgeDescriptorEnvironment.descriptor_file_path_key) - if not configured: - return None - return AgentRuntimePlatform.resolved_path(configured) - - @classmethod - def live_descriptors(cls) -> tuple[UiBridgeDescriptorFile, ...]: - descriptors: list[UiBridgeDescriptorFile] = [] - for result in cls._read_descriptor_results(): - if result.ok and result.descriptor is not None: - descriptors.append(result.descriptor) - return tuple(descriptors) - - @classmethod - def descriptor_catalog(cls) -> UiBridgeCatalog: - descriptors: list[UiBridgeDescriptorSummary] = [] - errors: list[AgentError] = [] - for result in cls._read_descriptor_results(): - if result.descriptor is not None and not result.errors: - descriptors.append(result.descriptor.public_summary("live")) - continue - errors.extend(result.errors) - return UiBridgeCatalog( - schema_version=SCHEMA_VERSION, - bridges=tuple(descriptors), - errors=tuple(errors), - ) - - @classmethod - def _read_descriptor_results(cls) -> tuple[UiBridgeDescriptorReadResult, ...]: - selected_path = cls.selected_descriptor_path() - if selected_path is not None: - return (UiBridgeDescriptorReader.read(selected_path),) - - descriptor_paths: list[Path] = [] - results: list[UiBridgeDescriptorReadResult] = [] - for directory in UiBridgeDescriptorDirectoryAuthority.descriptor_dirs(): - if not directory.exists(): - continue - for path in sorted(directory.glob("ui_bridge_*.json")): - descriptor_paths.append(path) - descriptor_paths.extend( - UiBridgeProcessAdvertisedDescriptorCatalog.descriptor_paths() - ) - - for path in dict.fromkeys( - AgentRuntimePlatform.resolved_path(path) - for path in descriptor_paths - ): - result = UiBridgeDescriptorReader.read(path) - if result.stale_process_descriptor: - cls._remove_stale_process_descriptor(result.path) - continue - results.append(result) - return tuple(results) - - @staticmethod - def _remove_stale_process_descriptor(path: Path) -> None: - try: - path.unlink() - except OSError: - return - - -class UiBridgeProcessAdvertisedDescriptorCatalog: - """Find descriptor files explicitly advertised by running local UI processes.""" - - proc_root = Path("/proc") - descriptor_environment_name = UiBridgeDescriptorEnvironment.descriptor_file_path_key - - @classmethod - def descriptor_paths(cls) -> tuple[Path, ...]: - paths: list[Path] = [] - for process_dir in cls._process_dirs(): - descriptor_path = cls._descriptor_path_from_process(process_dir) - if descriptor_path is not None: - paths.append(descriptor_path) - return tuple(dict.fromkeys(paths)) - - @classmethod - def _process_dirs(cls) -> tuple[Path, ...]: - try: - return tuple( - path for path in cls.proc_root.iterdir() if path.name.isdigit() - ) - except (FileNotFoundError, PermissionError, OSError): - return () - - @classmethod - def _descriptor_path_from_process(cls, process_dir: Path) -> Path | None: - try: - environment_payload = (process_dir / "environ").read_bytes() - except (FileNotFoundError, PermissionError, ProcessLookupError, OSError): - return None - return cls._descriptor_path_from_environment(environment_payload) - - @classmethod - def _descriptor_path_from_environment(cls, payload: bytes) -> Path | None: - prefix = f"{cls.descriptor_environment_name}=".encode() - for entry in payload.split(b"\0"): - if not entry.startswith(prefix): - continue - value = entry.removeprefix(prefix) - if not value: - return None - return Path(os.fsdecode(value)) - return None - - -class UiBridgeDescriptorResolver: - """Resolve UI bridge descriptors without widening the general path policy.""" - - def resolve( - self, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - if connection.descriptor_file_path is not None: - return self._resolve_explicit_file( - AgentRuntimePlatform.resolved_path( - connection.descriptor_file_path - ), - connection, - ) - - selected_descriptor_path = UiBridgeDescriptorCatalog.selected_descriptor_path() - if selected_descriptor_path is not None: - return self._resolve_explicit_file( - selected_descriptor_path, - connection, - ) - - if connection.bridge_instance_id is not None: - return self._resolve_instance(connection.bridge_instance_id, connection) - - descriptor_set = LiveUiBridgeDescriptorSet( - UiBridgeDescriptorCatalog.live_descriptors() - ) - return descriptor_set.resolution_runner().resolve( - self, descriptor_set, connection - ) - - def _resolve_explicit_file( - self, - path: Path, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - result = UiBridgeDescriptorReader.read(path) - if not result.ok or result.descriptor is None: - return UiBridgeConnectionResolution.from_connection( - replace(connection, descriptor_file_path=str(result.path)), - descriptor=UiBridgeDescriptorResolution( - status="stale_ui_bridge_descriptor", - ), - errors=result.errors, - ) - return self._connection_from_descriptor(result.descriptor, connection, "ok") - - def _resolve_instance( - self, - bridge_instance_id: str, - connection: UiBridgeConnectionSpec, - ) -> UiBridgeConnectionResolution: - live_descriptors = UiBridgeDescriptorCatalog.live_descriptors() - matches = tuple( - descriptor - for descriptor in live_descriptors - if descriptor.bridge_instance_id == bridge_instance_id - ) - if not matches: - return UiBridgeConnectionResolution.from_connection( - connection, - descriptor=UiBridgeDescriptorResolution( - status="ui_bridge_descriptor_not_found", - summaries=tuple( - descriptor.public_summary("live") - for descriptor in live_descriptors - ), - ), - errors=( - AgentError( - code="ui_bridge_descriptor_not_found", - message=f"No live OpenHCS UI bridge descriptor matches {bridge_instance_id!r}.", - hint=( - "Use one of the returned descriptors' bridge_instance_id " - "values, pass descriptor_file_path, or omit both when " - "exactly one live bridge is available." - ), - ), - ), - ) - return self._connection_from_descriptor(matches[0], connection, "ok") - - def _connection_from_descriptor( - self, - descriptor: UiBridgeDescriptorFile, - connection: UiBridgeConnectionSpec, - status: str, - ) -> UiBridgeConnectionResolution: - return UiBridgeConnectionResolution.from_connection( - descriptor.resolve_connection(connection), - descriptor=UiBridgeDescriptorResolution( - status=status, - summaries=(descriptor.public_summary(status),), - ), - ) - - -DEFAULT_UI_BRIDGE_DESCRIPTOR_RESOLVER = UiBridgeDescriptorResolver() - - -class UiBridgeService: - """Run UI-bridge operations against the running UI an agent names.""" - - def __init__( - self, - gateway: UiBridgeGatewayABC | None = None, - descriptor_resolver: UiBridgeDescriptorResolver = DEFAULT_UI_BRIDGE_DESCRIPTOR_RESOLVER, - path_policy: AgentPathPolicy | None = None, - ) -> None: - if gateway is None: - from openhcs.agent.services.ui_bridge_transport import ZMQUiBridgeGateway - - gateway = ZMQUiBridgeGateway() - self.gateway = gateway - self._descriptor_resolver = descriptor_resolver - self.path_policy = path_policy or AgentPathPolicy.from_environment() - - def invoke( - self, - operation: type[UiBridgeOperation], - request=None, - connection: UiBridgeConnectionSpec = DEFAULT_UI_BRIDGE_CONNECTION_SPEC, - ): - """Run one operation; failures come back as the operation's own result.""" - return operation.respond(self, connection, request) - - def resolve(self, connection: UiBridgeConnectionSpec) -> UiBridgeConnectionResolution: - return self._descriptor_resolver.resolve(connection) - - def connection_from_args( - self, - *, - host: str | None = None, - port: int | None = None, - transport_mode: TransportMode | None = None, - timeout_ms: int | None = None, - auth_token: str | None = None, - descriptor_file_path: str | None = None, - bridge_instance_id: str | None = None, - persistent: bool | None = None, - ) -> UiBridgeConnectionSpec: - return self.connection_from_fields( - UiBridgeConnectionFields( - host=host, - port=port, - transport_mode=transport_mode, - persistent=persistent, - timeout_ms=timeout_ms, - auth_token=auth_token, - descriptor_file_path=descriptor_file_path, - bridge_instance_id=bridge_instance_id, - ) - ) - - def connection_from_fields( - self, - fields: UiBridgeConnectionFields, - ) -> UiBridgeConnectionSpec: - return UiBridgeConnectionSpec.from_fields( - fields, - defaults=DEFAULT_UI_BRIDGE_CONNECTION_SPEC, - ) - def list_bridges(self) -> UiBridgeCatalog: - return UiBridgeDescriptorCatalog.descriptor_catalog() +class OpenHCSUiBridgeService(UiBridgeService): + """The UI-bridge client of OpenHCS agents, which also launches viewers beside the UI.""" def viewer_launch_context( self, connection: UiBridgeConnectionSpec = DEFAULT_UI_BRIDGE_CONNECTION_SPEC, ) -> ViewerLaunchContext: - """Resolve one typed detached-viewer context from UI or current process.""" + """The running UI's graphical session when its bridge resolves, else this process's.""" platform_authority = AgentRuntimePlatform.current() - resolution = self._descriptor_resolver.resolve(connection) + resolution = self.resolve(connection) if resolution.ok and resolution.process_id is not None: environment = platform_authority.graphical_process_environment( resolution.process_id, diff --git a/openhcs/agent/services/ui_bridge_transport.py b/openhcs/agent/services/ui_bridge_transport.py deleted file mode 100644 index d0c0b18404..0000000000 --- a/openhcs/agent/services/ui_bridge_transport.py +++ /dev/null @@ -1,164 +0,0 @@ -"""ZMQ transport for the OpenHCS running-UI bridge.""" - -from __future__ import annotations - -import uuid -from collections.abc import Mapping - -from python_introspect import JsonObject, dataclass_from_mapping, to_jsonable - -from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( - UiBridgeConnectionSpec, - UiBridgeRequestEnvelope, - UiBridgeResponseEnvelope, -) -from openhcs.agent.services.ui_bridge_service import ( - DEFAULT_UI_BRIDGE_TIMEOUT_MS, - UI_BRIDGE_PROTOCOL_VERSION, - UiBridgeGatewayABC, - UiBridgeGatewayResponseError, - UiBridgeGatewayTimeoutError, - UiBridgeGatewayUnavailableError, - UiBridgeOperation, -) -from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION -from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG - -UI_BRIDGE_CONTROL_TIMEOUT_MAX_MS = DEFAULT_UI_BRIDGE_TIMEOUT_MS -UI_BRIDGE_CONTROL_TIMEOUT_MIN_MS = 1 - - -class UiBridgeControlClient: - """Small JSON/ZMQ client for the UI bridge control socket.""" - - def request( - self, - connection: UiBridgeConnectionSpec, - operation: type[UiBridgeOperation], - request=None, - ) -> JsonObject: - if connection.port is None: - raise UiBridgeGatewayUnavailableError - operation.validate_request(request) - - request = UiBridgeRequestEnvelope( - schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, - application=OPENHCS_ENDPOINT_APPLICATION, - request_id=str(uuid.uuid4()), - operation=operation.require_name(), - auth_token=connection.auth_token, - payload=self._payload_object(request), - ) - response_payload = self._send(connection, to_jsonable(request)) - response = dataclass_from_mapping( - UiBridgeResponseEnvelope, - response_payload, - ) - self._validate_response(request, response) - if not response.ok: - raise UiBridgeGatewayResponseError(response.errors) - return response.payload - - def _send( - self, - connection: UiBridgeConnectionSpec, - request_payload: JsonObject, - ) -> JsonObject: - import zmq - - context = zmq.Context.instance() - socket = context.socket(zmq.REQ) - timeout_ms = self._socket_timeout_ms(connection) - request_operation = self._request_operation(request_payload) - socket.setsockopt(zmq.LINGER, 0) - socket.setsockopt(zmq.RCVTIMEO, timeout_ms) - socket.setsockopt(zmq.SNDTIMEO, timeout_ms) - try: - socket.connect(connection.zmq_data_url(OPENHCS_ZMQ_CONFIG)) - socket.send_json(request_payload) - response = socket.recv_json() - except zmq.Again as exc: - raise UiBridgeGatewayTimeoutError( - operation=request_operation, - timeout_ms=timeout_ms, - ) from exc - finally: - socket.close(linger=0) - if not isinstance(response, Mapping): - raise TypeError( - f"UI bridge response must be a JSON object, got {type(response).__name__}" - ) - return dict(response) - - @staticmethod - def _socket_timeout_ms(connection: UiBridgeConnectionSpec) -> int: - return min( - max(connection.timeout_ms, UI_BRIDGE_CONTROL_TIMEOUT_MIN_MS), - UI_BRIDGE_CONTROL_TIMEOUT_MAX_MS, - ) - - @staticmethod - def _request_operation(request_payload: JsonObject) -> str: - if "operation" not in request_payload: - raise ValueError( - "UI bridge request payload missing required field 'operation'." - ) - operation = request_payload["operation"] - if not isinstance(operation, str): - raise TypeError( - "UI bridge request payload field 'operation' must be a string." - ) - return operation - - @staticmethod - def _payload_object(payload) -> JsonObject: - if payload is None: - return {} - json_payload = to_jsonable(payload) - if not isinstance(json_payload, Mapping): - raise TypeError( - f"UI bridge request payload must serialize to a JSON object, " - f"got {type(json_payload).__name__}" - ) - return json_payload - - @staticmethod - def _validate_response( - request: UiBridgeRequestEnvelope, - response: UiBridgeResponseEnvelope, - ) -> None: - if response.schema_version != SCHEMA_VERSION: - raise ValueError( - f"Unsupported agent schema version: {response.schema_version}" - ) - if response.bridge_protocol_version != UI_BRIDGE_PROTOCOL_VERSION: - raise ValueError( - f"Unsupported UI bridge protocol version: {response.bridge_protocol_version}" - ) - OPENHCS_ENDPOINT_APPLICATION.compatibility_with( - response.application - ).require_match() - if response.request_id != request.request_id: - raise ValueError("UI bridge response request_id does not match request.") - - -class ZMQUiBridgeGateway(UiBridgeGatewayABC): - """Gateway that connects MCP/agent services to a running PyQt UI bridge.""" - - def __init__(self, client: UiBridgeControlClient | None = None) -> None: - if client is None: - client = UiBridgeControlClient() - self._client = client - - def invoke( - self, - connection: UiBridgeConnectionSpec, - operation: type[UiBridgeOperation], - request, - ): - return dataclass_from_mapping( - operation.result_type, - self._client.request(connection, operation, request), - ) diff --git a/openhcs/agent/services/viewer_window_service.py b/openhcs/agent/services/viewer_window_service.py index fdd33d70ba..8202eff428 100644 --- a/openhcs/agent/services/viewer_window_service.py +++ b/openhcs/agent/services/viewer_window_service.py @@ -20,10 +20,7 @@ ) from python_introspect.validation import validate_annotation_value from polystore.streaming.identity import StreamProducerIdentity -from pyqt_reactive.services.window_snapshot import ( - WindowSnapshotCaptureSpec, - WindowVisualObservation, -) +from formbridge.services.window_snapshot import WindowSnapshotCaptureSpec, WindowVisualObservation from zmqruntime.client import ( EndpointShutdownMode, EndpointShutdownResult, @@ -36,12 +33,8 @@ ) import openhcs.core.plate_image_inventory as core_plate_image_inventory -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentError, - AgentResourceRef, - AgentWarning, -) +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentResourceRef, AgentWarning from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ( ViewerWindowPolylineMeasurementRequest, diff --git a/openhcs/agent/ui_bridge_application.py b/openhcs/agent/ui_bridge_application.py new file mode 100644 index 0000000000..5ff7da12b6 --- /dev/null +++ b/openhcs/agent/ui_bridge_application.py @@ -0,0 +1,46 @@ +"""OpenHCS's declaration of its running-UI bridge.""" + +from __future__ import annotations + +from pathlib import Path + +from formbridge.bridge.application import UiBridgeApplication + +from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION +from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG + + +class OpenHCSUiBridgeApplication(UiBridgeApplication): + protocol_version = "openhcs.ui_bridge.v4" + endpoint_application = OPENHCS_ENDPOINT_APPLICATION + transport_config = OPENHCS_ZMQ_CONFIG + runtime_name = "OpenHCS" + descriptor_file_variable = "OPENHCS_UI_BRIDGE_DESCRIPTOR" + descriptor_directory_variable = "OPENHCS_UI_BRIDGE_DESCRIPTOR_DIR" + server_name = "OpenHCSUiBridgeServer" + service_name = "openhcs.ui_bridge" + + @classmethod + def window_snapshot_dir(cls) -> Path: + from openhcs.agent.path_policy import DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR + + return DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR + + @classmethod + def connection_environment(cls): + from openhcs.agent.services.ui_bridge_service import UiBridgeEnvironment + + return UiBridgeEnvironment() + + @classmethod + def output_path_policy(cls): + from openhcs.agent.path_policy import AgentPathPolicy + + return AgentPathPolicy.from_environment() + + @classmethod + def log_file_path(cls) -> str | None: + from openhcs.core.log_utils import get_current_log_file_path + + return get_current_log_file_path() + diff --git a/openhcs/agent/ui_bridge_environment.py b/openhcs/agent/ui_bridge_environment.py index 66d53414b7..da9c8541f5 100644 --- a/openhcs/agent/ui_bridge_environment.py +++ b/openhcs/agent/ui_bridge_environment.py @@ -38,18 +38,3 @@ def cache_file_path( raise ValueError(f"{cls.cache_file_path_key} must be an absolute path.") return cache_file.resolve(strict=False) - -class UiBridgeDescriptorEnvironment: - """Own the environment selectors for one live UI bridge descriptor.""" - - descriptor_file_path_key = "OPENHCS_UI_BRIDGE_DESCRIPTOR" - descriptor_directory_path_key = "OPENHCS_UI_BRIDGE_DESCRIPTOR_DIR" - - @classmethod - def child_process_environment_keys(cls) -> tuple[str, str]: - """Return every selector required by a descriptor-consuming child.""" - - return ( - cls.descriptor_file_path_key, - cls.descriptor_directory_path_key, - ) diff --git a/openhcs/agent/ui_bridge_identities.py b/openhcs/agent/ui_bridge_identities.py index 30801f00df..f2eb163640 100644 --- a/openhcs/agent/ui_bridge_identities.py +++ b/openhcs/agent/ui_bridge_identities.py @@ -1,102 +1,14 @@ -"""Registered identities for stable agent-facing UI bridge objects.""" +"""OpenHCS's registered UI bridge identities: its windows, widgets, documents and surfaces.""" from __future__ import annotations -from abc import ABC -from typing import ClassVar - -from metaclass_registry import AutoRegisterMeta - - -class UiBridgeIdentityDeclaration(ABC, metaclass=AutoRegisterMeta): - """Registered identity for stable agent-facing UI bridge objects.""" - - __registry_key__ = "value" - __skip_if_no_key__ = True - - value: ClassVar[str | None] = None - enum_member_name: ClassVar[str | None] = None - - @classmethod - def require_value(cls) -> str: - if cls.value is None: - raise ValueError( - f"{cls.__name__} does not declare a bridge identity value." - ) - return cls.value - - -class UiStableWindowIdentityDeclaration(UiBridgeIdentityDeclaration): - """Registered identity and presentation for a stable public UI window.""" - - title: ClassVar[str | None] = None - - @classmethod - def require_title(cls) -> str: - """Return the declaration-owned public window title.""" - - if cls.title is None: - raise ValueError(f"{cls.__name__} does not declare a window title.") - return cls.title - - @classmethod - def has_title(cls) -> bool: - return cls.title is not None - - @classmethod - def declaration_types( - cls, - ) -> tuple[type[UiStableWindowIdentityDeclaration], ...]: - """Project the complete stable-window family from the identity registry.""" - - return tuple( - dict.fromkeys( - declaration - for declaration in cls.__registry__.values() - if issubclass(declaration, cls) - and declaration.value is not None - and declaration.has_title() - ) - ) - - -class UiWidgetIdentityDeclaration(UiBridgeIdentityDeclaration): - """Registered widget identity declaration.""" - - @classmethod - def action_provider_id(cls) -> str: - """Return the stable action-provider id for this widget.""" - return f"{cls.require_value()}.actions" - - -class UiStableWidgetIdentityDeclaration( - UiWidgetIdentityDeclaration, +from formbridge.bridge.identities import ( + UiCodeDocumentIdentityDeclaration, + UiStableWidgetIdentityDeclaration, UiStableWindowIdentityDeclaration, -): - """Stable public window whose content is also a registered UI widget.""" - - -class UiOwnedByWidgetIdentityDeclaration(UiBridgeIdentityDeclaration): - """Identity declaration for a UI bridge object owned by one widget.""" - - widget_identity: ClassVar[type[UiWidgetIdentityDeclaration] | None] = None - - @classmethod - def widget_id(cls) -> str: - if cls.widget_identity is None: - raise ValueError(f"{cls.__name__} does not declare a widget identity.") - return cls.widget_identity.require_value() - - -class UiCodeDocumentIdentityDeclaration(UiOwnedByWidgetIdentityDeclaration): - """Registered code document identity declaration.""" - - -class UiStateSurfaceIdentityDeclarationBase(UiBridgeIdentityDeclaration): - """Registered protocol identity for one UI state surface. - - Widget ownership and presentation metadata live on the owning widget class. - """ + UiStateSurfaceIdentityDeclarationBase, + UiWidgetIdentityDeclaration, +) class UiSessionViewWidgetIdentityDeclaration(UiStableWidgetIdentityDeclaration): @@ -113,32 +25,23 @@ def has_title(cls) -> bool: return True -class PlateManagerWidgetIdentity(UiSessionViewWidgetIdentityDeclaration): - value = "plate_manager" - enum_member_name = "PLATE_MANAGER" +class DatasetListWidgetIdentity(UiSessionViewWidgetIdentityDeclaration): + value = "datasets" class PipelineEditorWidgetIdentity(UiSessionViewWidgetIdentityDeclaration): value = "pipeline_editor" - enum_member_name = "PIPELINE_EDITOR" class PipelineDebugToolbarWidgetIdentity(UiWidgetIdentityDeclaration): value = "pipeline_debug_toolbar" - enum_member_name = "PIPELINE_DEBUG_TOOLBAR" class MainWindowWidgetIdentity(UiStableWidgetIdentityDeclaration): value = "main_window" - enum_member_name = "MAIN_WINDOW" title = "OpenHCS" -class ManagedWindowWidgetIdentity(UiWidgetIdentityDeclaration): - value = "managed_window" - enum_member_name = "MANAGED_WINDOW" - - class SystemMonitorWindowIdentity(UiStableWindowIdentityDeclaration): value = "system_monitor" title = "System Monitor" @@ -162,6 +65,7 @@ class LogViewerWindowIdentity(UiStableWindowIdentityDeclaration): class GlobalConfigWindowIdentity(UiStableWindowIdentityDeclaration): value = "global_config" title = "Configure OpenHCS" + manager_scope_alias = "" class KnowledgeBaseWindowIdentity(UiStableWindowIdentityDeclaration): @@ -174,42 +78,30 @@ class AboutOpenHCSWindowIdentity(UiStableWindowIdentityDeclaration): title = "About OpenHCS" -class PlateManagerOrchestratorCodeDocumentIdentity(UiCodeDocumentIdentityDeclaration): - value = "plate_manager.orchestrator_config" - enum_member_name = "PLATE_MANAGER_ORCHESTRATOR" - widget_identity = PlateManagerWidgetIdentity +class DatasetListCodeDocumentIdentity(UiCodeDocumentIdentityDeclaration): + value = "datasets.orchestrator_config" + widget_identity = DatasetListWidgetIdentity -class PlateManagerStateSurfaceIdentityDeclaration( +class DatasetListStateSurfaceIdentity( UiStateSurfaceIdentityDeclarationBase ): - value = "plate_manager.state" - enum_member_name = "PLATE_MANAGER" + value = "datasets.state" -class PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration( +class DatasetLiveMeasurementsStateSurfaceIdentity( UiStateSurfaceIdentityDeclarationBase ): - value = "plate_manager.live_measurements" - enum_member_name = "PLATE_MANAGER_LIVE_MEASUREMENTS" + value = "datasets.live_measurements" class PipelineEditorStateSurfaceIdentityDeclaration( UiStateSurfaceIdentityDeclarationBase ): value = "pipeline_editor.state" - enum_member_name = "PIPELINE_EDITOR" class PipelineDebugSessionStateSurfaceIdentityDeclaration( UiStateSurfaceIdentityDeclarationBase ): value = "pipeline_debug_toolbar.session" - enum_member_name = "PIPELINE_DEBUG_SESSION" - - -class UiLiveOverviewStateSurfaceIdentityDeclaration( - UiStateSurfaceIdentityDeclarationBase -): - value = "ui_live_overview.state" - enum_member_name = "UI_LIVE_OVERVIEW" diff --git a/openhcs/authoring/session/dataset_document.py b/openhcs/authoring/session/dataset_document.py index 5d8483234f..05c9ad1feb 100644 --- a/openhcs/authoring/session/dataset_document.py +++ b/openhcs/authoring/session/dataset_document.py @@ -15,9 +15,9 @@ from objectstate.object_state import ObjectState, ObjectStateRegistry from objectstate.value_semantics import semantic_values_equal -from openhcs.core.selection import AllItems, SelectedItems, SelectionScope +from formbridge.selection import AllItems, SelectedItems, SelectionScope from openhcs.core.config import GlobalPipelineConfig, PipelineConfig -from openhcs.core.selection import SelectedScopeIdsCarrier +from formbridge.selection import SelectedScopeIdsCarrier from objectstate import retain_only_authored_paths from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerCodeDocumentAuthority, diff --git a/openhcs/authoring/session/datasets.py b/openhcs/authoring/session/datasets.py index 1fe34b784c..2dc003653c 100644 --- a/openhcs/authoring/session/datasets.py +++ b/openhcs/authoring/session/datasets.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from formbridge.bridge.scope_policy import HiddenObjectStateScope from objectstate.collection_containers import RootState from objectstate.object_state import ObjectState, ObjectStateRegistry @@ -14,6 +15,12 @@ DATASET_LIST_PARAMETER = "orchestrator_scope_ids" +class DatasetListScope(HiddenObjectStateScope): + """The dataset list root is a system scope; agents read each dataset's own scope.""" + + scope_id = DATASET_LIST_SCOPE_ID + + @dataclass(frozen=True, slots=True) class DatasetRow: """One visible dataset row.""" diff --git a/openhcs/authoring/session/operations/__init__.py b/openhcs/authoring/session/operations/__init__.py index 4f103322e5..c6016a4ddb 100644 --- a/openhcs/authoring/session/operations/__init__.py +++ b/openhcs/authoring/session/operations/__init__.py @@ -14,7 +14,7 @@ from metaclass_registry import AutoRegisterMeta, LazyDiscoveryDict, RegistryConfig -from openhcs.agent.dto.common import AgentError, AgentWarning +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.session import NoArgumentsRequest, SessionOperationResult if TYPE_CHECKING: diff --git a/openhcs/authoring/session/operations/application.py b/openhcs/authoring/session/operations/application.py index 2e6b555557..447ccdf38f 100644 --- a/openhcs/authoring/session/operations/application.py +++ b/openhcs/authoring/session/operations/application.py @@ -2,7 +2,7 @@ from __future__ import annotations -from openhcs.agent.dto.common import AgentWarning +from formbridge.contracts import AgentWarning from openhcs.authoring.session.operations import RendererOperation diff --git a/openhcs/authoring/session/operations/datasets.py b/openhcs/authoring/session/operations/datasets.py index 615072d580..ca267196bc 100644 --- a/openhcs/authoring/session/operations/datasets.py +++ b/openhcs/authoring/session/operations/datasets.py @@ -8,11 +8,12 @@ from zmqruntime.startup import EndpointStartupPresentationTarget -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec from openhcs.agent.dto.session import ( DatasetPipelineSourceRequest, DatasetRootsRequest, + DatasetRowState, DatasetRunRequest, DatasetTargetsRequest, NoArgumentsRequest, @@ -24,6 +25,13 @@ RendererOperation, SessionOperation, ) +from openhcs.constants.constants import ( + OrchestratorCompileFailed, + OrchestratorExecFailed, + OrchestratorInitFailed, + OrchestratorState, +) +from openhcs.core.execution_state import TerminalExecutionStatus if TYPE_CHECKING: pass @@ -104,9 +112,34 @@ def available(cls, session, request) -> AgentError | None: class DatasetWorkflowOperation(SessionOperation): - """One step of the dataset workflow (initialize, compile, run).""" + """One step of the dataset workflow (initialize, compile, run). + + Each step also says when a dataset row shows it finished or failed. + """ workflow_name: ClassVar[str] + failed_orchestrator_states: ClassVar[tuple[type[OrchestratorState], ...]] = () + terminal_state_is_idempotent: ClassVar[bool] = False + """A row already in the terminal state proves completion without a change.""" + + @classmethod + def terminal_for_row(cls, row: DatasetRowState) -> bool: + """Whether ``row`` shows this step finished.""" + raise NotImplementedError(f"{cls.__name__} declares no terminal row state.") + + @classmethod + def failed_for_row(cls, row: DatasetRowState) -> bool: + """Whether ``row`` shows this step failed.""" + failed_terminal_statuses = { + name + for name, status in TerminalExecutionStatus.__registry__.items() + if status.counts_as_failed + } + failed_states = {state.name for state in cls.failed_orchestrator_states} + return ( + row.terminal_status in failed_terminal_statuses + or row.orchestrator_state in failed_states + ) class AddDatasets(PromptedOperation, HeadlessOperation): @@ -182,6 +215,8 @@ def available(cls, session, request) -> AgentError | None: class InitializeDatasets(IdleTargetsOperation, DatasetWorkflowOperation, HeadlessOperation): operation_id = "initialize_datasets" workflow_name = "INIT" + failed_orchestrator_states = (OrchestratorInitFailed,) + terminal_state_is_idempotent = True label = "Init" tooltip = "Initialize selected datasets" description = ( @@ -190,6 +225,10 @@ class InitializeDatasets(IdleTargetsOperation, DatasetWorkflowOperation, Headles ) side_effects = ("writes_dataset_metadata",) + @classmethod + def terminal_for_row(cls, row: DatasetRowState) -> bool: + return row.init_pending is False and row.initialized is True + @classmethod def run(cls, session, request) -> SessionOperationResult: session.start(session.initialize_datasets, tuple(request.scope_ids)) @@ -221,6 +260,7 @@ class CompileDatasets( ): operation_id = "compile_datasets" workflow_name = "COMPILE" + failed_orchestrator_states = (OrchestratorCompileFailed,) label = "Compile" tooltip = "Compile dataset pipelines" description = ( @@ -229,6 +269,10 @@ class CompileDatasets( ) side_effects = ("submits_compile_jobs",) + @classmethod + def terminal_for_row(cls, row: DatasetRowState) -> bool: + return row.compile_pending is False and row.compiled is True + @classmethod def resolved(cls, session) -> type[SessionOperation]: slot = _CompileSlot() @@ -307,6 +351,7 @@ def run(cls, session, request) -> SessionOperationResult: class RunDatasets(DatasetTargetsOperation, DatasetWorkflowOperation, HeadlessOperation): operation_id = "run_datasets" workflow_name = "RUN" + failed_orchestrator_states = (OrchestratorExecFailed,) label = "Run" tooltip = "Run or stop dataset execution" description = ( @@ -316,6 +361,10 @@ class RunDatasets(DatasetTargetsOperation, DatasetWorkflowOperation, HeadlessOpe request = DatasetRunRequest side_effects = ("submits_execution_jobs",) + @classmethod + def terminal_for_row(cls, row: DatasetRowState) -> bool: + return row.execution_active is False and row.queued is False and row.has_terminal_status + @classmethod def resolved(cls, session) -> type[SessionOperation]: return StopExecution if session.execution_state.busy else cls diff --git a/openhcs/authoring/session/operations/pipelines.py b/openhcs/authoring/session/operations/pipelines.py index 66dd730864..9a608fa868 100644 --- a/openhcs/authoring/session/operations/pipelines.py +++ b/openhcs/authoring/session/operations/pipelines.py @@ -2,7 +2,7 @@ from __future__ import annotations -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.agent.dto.session import ( DatasetRequest, PipelineFileRequest, diff --git a/openhcs/authoring/session/pipelines.py b/openhcs/authoring/session/pipelines.py index 397af7755e..2078828636 100644 --- a/openhcs/authoring/session/pipelines.py +++ b/openhcs/authoring/session/pipelines.py @@ -17,18 +17,13 @@ from openhcs.core.dataset_sources.dataset_scopes import DatasetScope from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from openhcs.authoring.session.step_scopes import FunctionStepScopeToken -from pyqt_reactive.services.function_pattern_code_document import ( +from formbridge.services.function_pattern_code_document import ( FunctionPatternCodeDocumentService, FunctionPatternValue, function_pattern_authority, ) -from pyqt_reactive.services.pattern_data_manager import ( - FUNC_EDITOR_PATTERN_TOKENS_META_KEY, -) -from pyqt_reactive.services.scope_token_service import ( - ScopeTokenService, - reconcile_occurrence_tokens, -) +from formbridge.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY +from formbridge.services.scope_token_service import ScopeTokenService, reconcile_occurrence_tokens PipelineFunctionPattern = FunctionSpec | None FunctionPatternTokenTree = list[str] | dict[str, "FunctionPatternTokenTree"] | None diff --git a/openhcs/authoring/session/progress.py b/openhcs/authoring/session/progress.py index 360bd19e82..7462833bb7 100644 --- a/openhcs/authoring/session/progress.py +++ b/openhcs/authoring/session/progress.py @@ -11,14 +11,11 @@ import threading from typing import TYPE_CHECKING -from pyqt_reactive.services.interval_snapshot_poller import ( +from formbridge.services.interval_snapshot_poller import ( CallbackIntervalSnapshotPollerPolicy, IntervalSnapshotPoller, ) -from pyqt_reactive.services.zmq_server_info import ( - BaseServerInfo, - ExecutionServerInfo, -) +from formbridge.services.zmq_server_info import BaseServerInfo, ExecutionServerInfo from zmqruntime.progress import EventRegistryMutation from openhcs.authoring.session.events import ( diff --git a/openhcs/authoring/session/session.py b/openhcs/authoring/session/session.py index 90db36c345..cd495d063d 100644 --- a/openhcs/authoring/session/session.py +++ b/openhcs/authoring/session/session.py @@ -25,9 +25,9 @@ from objectstate.lazy_factory import ensure_global_config_context from objectstate.object_state import ObjectState, ObjectStateRegistry from polystore.base import _create_storage_registry -from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor -from pyqt_reactive.services.scope_token_service import ScopeTokenService -from pyqt_reactive.services.zmq_server_scan_service import EndpointObservationSnapshot +from formbridge.services.async_operation_executor import AsyncOperationExecutor +from formbridge.services.scope_token_service import ScopeTokenService +from formbridge.services.zmq_server_scan_service import EndpointObservationSnapshot from zmqruntime.messages import ExecutionRecord from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus @@ -152,7 +152,7 @@ ) if TYPE_CHECKING: - from openhcs.agent.dto.common import AgentError + from formbridge.contracts import AgentError from openhcs.authoring.session.operations import SessionOperation from openhcs.authoring.session.views import SessionView from zmqruntime.messages import PongResponse diff --git a/openhcs/authoring/session/step_scopes.py b/openhcs/authoring/session/step_scopes.py index 0c1dc9b3f9..0ccd046cea 100644 --- a/openhcs/authoring/session/step_scopes.py +++ b/openhcs/authoring/session/step_scopes.py @@ -7,12 +7,12 @@ from objectstate import ScopeKey from openhcs.core.steps.function_step import FunctionStep -from pyqt_reactive.services.function_navigation import ( +from formbridge.services.function_navigation import ( FUNCTION_FIELD_ROOT, build_function_token_field_path, is_function_field_path, ) -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.scope_token_service import ScopeTokenService FUNCTION_STEP_SCOPE_TOKEN_PREFIX = f"{FunctionStep.__name__.lower()}_" diff --git a/openhcs/authoring/session/views.py b/openhcs/authoring/session/views.py index a736b56a43..26a967c52a 100644 --- a/openhcs/authoring/session/views.py +++ b/openhcs/authoring/session/views.py @@ -23,7 +23,7 @@ ) from openhcs.agent.ui_bridge_identities import ( PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, UiSessionViewWidgetIdentityDeclaration, ) from openhcs.authoring.session.dataset_status import PlateStatusPresenter @@ -232,7 +232,7 @@ def status_prefix(self) -> str: class DatasetListView(SessionView): view_id = "dataset_list" title = "Datasets" - window = PlateManagerWidgetIdentity + window = DatasetListWidgetIdentity state = DatasetListState operations = ( AddDatasets, diff --git a/openhcs/core/function_patterns.py b/openhcs/core/function_patterns.py index ce8938142e..be0a0d8b30 100644 --- a/openhcs/core/function_patterns.py +++ b/openhcs/core/function_patterns.py @@ -27,7 +27,7 @@ from openhcs.core.steps.function_runtime import FunctionCoreExecutor from arraybridge import MemoryType -from pyqt_reactive.pattern_metadata import PatternScopeToken +from formbridge.pattern_metadata import PatternScopeToken from python_introspect import Enableable from openhcs.core.artifacts.plans import ( diff --git a/openhcs/core/log_utils.py b/openhcs/core/log_utils.py index a218212dd3..0a505d1ae3 100644 --- a/openhcs/core/log_utils.py +++ b/openhcs/core/log_utils.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import List, Optional -from pyqt_reactive.core.log_utils import LogFileInfo, LogType +from formbridge.core.log_utils import LogFileInfo, LogType from zmqruntime.execution.logs import ExecutionWorkerLogIdentity from zmqruntime.messages import ProcessIdentity diff --git a/openhcs/core/selection.py b/openhcs/core/selection.py deleted file mode 100644 index d05eb486a7..0000000000 --- a/openhcs/core/selection.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Shared selection carriers for UI and agent-facing projections.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Iterable -from dataclasses import dataclass -from typing import ClassVar - -from metaclass_registry import AutoRegisterMeta - - -class SelectionScope(ABC, metaclass=AutoRegisterMeta): - """Whether a view or document covers the selected items or all of them. - - ``name`` is the scope's spelling on the wire and in CLI choices. - """ - - __registry_key__ = "name" - __skip_if_no_key__ = True - - name: ClassVar[str | None] = None - - @staticmethod - def named(name: str) -> type[SelectionScope]: - scope = SelectionScope.__registry__.get(name) - if scope is None: - raise ValueError( - f"Unknown selection mode {name!r}; expected one of " - f"{tuple(SelectionScope.__registry__)!r}." - ) - return scope - - @staticmethod - @abstractmethod - def keeps(selected: bool) -> bool: - """Whether an item with this selection state is in scope.""" - - @staticmethod - @abstractmethod - def pick(selected: tuple[str, ...], every: tuple[str, ...]) -> tuple[str, ...]: - """The ids in scope, given the selected ids (in selection order) and all ids.""" - - -class SelectedItems(SelectionScope): - name = "selected" - - @staticmethod - def keeps(selected: bool) -> bool: - return selected - - @staticmethod - def pick(selected: tuple[str, ...], every: tuple[str, ...]) -> tuple[str, ...]: - return selected - - -class AllItems(SelectionScope): - name = "all" - - @staticmethod - def keeps(selected: bool) -> bool: - return True - - @staticmethod - def pick(selected: tuple[str, ...], every: tuple[str, ...]) -> tuple[str, ...]: - return every - - -@dataclass(frozen=True, kw_only=True, slots=True) -class SelectionModeCarrier: - selection_mode: str | None = None - - def resolved_selection_mode(self, default: type[SelectionScope]) -> str: - """The requested mode's wire spelling, or ``default``'s.""" - if self.selection_mode is None: - return default.name - return SelectionScope.named(self.selection_mode).name - - def selection_scope(self, default: type[SelectionScope]) -> type[SelectionScope]: - return SelectionScope.named(self.resolved_selection_mode(default)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class SelectedScopeIdsCarrier(SelectionModeCarrier): - selected_scope_ids: tuple[str, ...] = () - - -@dataclass(frozen=True, slots=True) -class SelectedScopeIdsArgument(SelectedScopeIdsCarrier): - """Nominal adapter for optional external selected-scope collections.""" - - @classmethod - def from_optional_iterable( - cls, - value: Iterable[str] | None, - ) -> "SelectedScopeIdsArgument": - if value is None: - return cls(selected_scope_ids=()) - return cls(selected_scope_ids=tuple(value)) diff --git a/openhcs/desktop/deployment.py b/openhcs/desktop/deployment.py index 93e42c4f94..154636afce 100644 --- a/openhcs/desktop/deployment.py +++ b/openhcs/desktop/deployment.py @@ -24,7 +24,7 @@ from packaging.utils import canonicalize_name from openhcs import __version__ as OPENHCS_VERSION -from openhcs.agent.runtime_platform import ( +from formbridge.runtime_platform import ( AgentRuntimePlatform, MacOSRuntimePlatform, WindowsRuntimePlatform, diff --git a/openhcs/desktop/update.py b/openhcs/desktop/update.py index b9b6bc02bd..5fbcbc5fdd 100644 --- a/openhcs/desktop/update.py +++ b/openhcs/desktop/update.py @@ -27,7 +27,7 @@ from python_introspect import dataclass_from_mapping, to_jsonable from zmqruntime.process_launch import BackgroundProcessLaunchPolicy -from openhcs.core.selection import AllItems +from formbridge.selection import AllItems from openhcs import __version__ as OPENHCS_VERSION from openhcs.desktop.deployment import ( DESKTOP_RESTART_EXECUTABLE_ENVIRONMENT_VARIABLE, diff --git a/openhcs/desktop/zmq_version_restart.py b/openhcs/desktop/zmq_version_restart.py index c21a80fd23..c3b9799934 100644 --- a/openhcs/desktop/zmq_version_restart.py +++ b/openhcs/desktop/zmq_version_restart.py @@ -7,9 +7,7 @@ from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QMessageBox -from pyqt_reactive.services.async_operation_executor import ( - AsyncOperationExecutorClosedError, -) +from formbridge.services.async_operation_executor import AsyncOperationExecutorClosedError from openhcs.core.execution_state import ManagerExecutionState from openhcs.desktop.restart import DesktopSessionRestart diff --git a/openhcs/mcp/client_registration.py b/openhcs/mcp/client_registration.py index f84a9ca6e6..db504cf9fb 100644 --- a/openhcs/mcp/client_registration.py +++ b/openhcs/mcp/client_registration.py @@ -24,7 +24,7 @@ from metaclass_registry import AutoRegisterMeta, RegistryFamily, RegistryKeyAttribute from tomlkit.items import InlineTable -from openhcs.agent.runtime_platform import ( +from formbridge.runtime_platform import ( AgentRuntimePlatform, MacOSRuntimePlatform, WindowsRuntimePlatform, diff --git a/openhcs/mcp/context.py b/openhcs/mcp/context.py index c47a6938aa..233399e5cf 100644 --- a/openhcs/mcp/context.py +++ b/openhcs/mcp/context.py @@ -37,7 +37,7 @@ from openhcs.agent.services.synthetic_plate_service import ( SyntheticPlateGenerationService, ) - from openhcs.agent.services.ui_bridge_service import UiBridgeService + from formbridge.bridge.service import UiBridgeService from openhcs.agent.services.viewer_endpoint_discovery import ( ViewerEndpointDiscoveryService, ) @@ -287,9 +287,9 @@ def runtime_server_service(self) -> "RuntimeServerService": @property def ui_bridge_service(self) -> "UiBridgeService": if self._ui_bridge_service is None: - from openhcs.agent.services.ui_bridge_service import UiBridgeService + from openhcs.agent.services.ui_bridge_service import OpenHCSUiBridgeService - self._ui_bridge_service = UiBridgeService(path_policy=self.path_policy) + self._ui_bridge_service = OpenHCSUiBridgeService(path_policy=self.path_policy) return self._ui_bridge_service @property diff --git a/openhcs/mcp/control_timeout.py b/openhcs/mcp/control_timeout.py index 429cc126f8..d23f019b46 100644 --- a/openhcs/mcp/control_timeout.py +++ b/openhcs/mcp/control_timeout.py @@ -6,7 +6,7 @@ from typing import ClassVar from openhcs.agent.dto.viewer import VIEWER_WINDOW_CONTROL_TIMEOUT_MS_DEFAULT -from openhcs.agent.services.ui_bridge_service import DEFAULT_UI_BRIDGE_TIMEOUT_MS +from formbridge.bridge.service import DEFAULT_UI_BRIDGE_TIMEOUT_MS MIN_MCP_CONTROL_TIMEOUT_MS = 1 diff --git a/openhcs/mcp/dev_client_commanding.py b/openhcs/mcp/dev_client_commanding.py index 2b03995a03..d21bdf3af6 100644 --- a/openhcs/mcp/dev_client_commanding.py +++ b/openhcs/mcp/dev_client_commanding.py @@ -21,7 +21,7 @@ get_capability_registry, require_agent_type_contract, ) -from openhcs.agent.dto.common import AgentCliArgumentSpec, AgentCliRequest +from formbridge.contracts import AgentCliArgumentSpec, AgentCliRequest from python_introspect import JsonValue from openhcs.agent.dto.execution import ( RuntimeServerConnectionToolRequest, diff --git a/openhcs/mcp/dev_client_commands/ui.py b/openhcs/mcp/dev_client_commands/ui.py index 230946bfdb..ca175a426d 100644 --- a/openhcs/mcp/dev_client_commands/ui.py +++ b/openhcs/mcp/dev_client_commands/ui.py @@ -7,19 +7,19 @@ from typing import cast -from pyqt_reactive.services.window_snapshot import ( +from formbridge.services.window_snapshot import ( WindowSnapshotCaptureScope, WindowSnapshotFrameCondition, ) -from openhcs.core.selection import AllItems, SelectedItems, SelectionScope +from formbridge.selection import AllItems, SelectedItems, SelectionScope from openhcs.agent.dto.session import ( DatasetRowState, SessionEventBatch, SessionEventsRequest, ) from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( AllFields, UiActionInvokeRequest, UiCodeDocumentApplyRequest, @@ -30,18 +30,13 @@ UiObjectStateFieldListQuery, UiObjectStateFieldMutationRequest, UiObjectStateScopeListRequest, - UiSelectedPlateWorkflowKind, UiWidgetActionInvokeRequest, UiWidgetTreeRequest, UiWindowSnapshotRequest, ) -from openhcs.agent.services.ui_bridge_service import ( - DEFAULT_UI_BRIDGE_TIMEOUT_MS, - UiBridgeGatewayTimeoutError, -) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerStateSurfaceIdentityDeclaration, -) +from openhcs.agent.dto.ui_bridge import UiSelectedPlateWorkflowKind +from formbridge.bridge.service import DEFAULT_UI_BRIDGE_TIMEOUT_MS, UiBridgeGatewayTimeoutError +from openhcs.agent.ui_bridge_identities import DatasetListStateSurfaceIdentity from openhcs.mcp.dev_client_commanding import ( CapabilityBackedCommandSpec, McpDevCommandSpec, @@ -104,7 +99,7 @@ def configure_parser(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "surface_id", nargs="?", - default=PlateManagerStateSurfaceIdentityDeclaration.require_value(), + default=DatasetListStateSurfaceIdentity.require_value(), ) parser.add_argument( "--selection-mode", @@ -166,7 +161,7 @@ class SelectedWorkflowCommandSpec(CapabilityBackedCommandSpec): capability = agent_capabilities.ui_selected_plate_workflow def configure_parser(self, parser: argparse.ArgumentParser) -> None: - state_surface_id = PlateManagerStateSurfaceIdentityDeclaration.require_value() + state_surface_id = DatasetListStateSurfaceIdentity.require_value() parser.add_argument( "workflow", choices=tuple(workflow.value for workflow in UiSelectedPlateWorkflowKind), @@ -247,7 +242,7 @@ async def run_session( state_surface_tool_arguments( args, surface_id=( - PlateManagerStateSurfaceIdentityDeclaration.require_value() + DatasetListStateSurfaceIdentity.require_value() ), selection_mode=args.poll_selection_mode, ), diff --git a/openhcs/mcp/dev_client_commands/viewer.py b/openhcs/mcp/dev_client_commands/viewer.py index 7e8475f5b6..675d75f746 100644 --- a/openhcs/mcp/dev_client_commands/viewer.py +++ b/openhcs/mcp/dev_client_commands/viewer.py @@ -4,7 +4,7 @@ import argparse -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from openhcs.agent.capabilities import agent_capabilities from python_introspect import JsonValue diff --git a/openhcs/mcp/dev_client_core.py b/openhcs/mcp/dev_client_core.py index 7c82e2cc69..ad08753d4f 100644 --- a/openhcs/mcp/dev_client_core.py +++ b/openhcs/mcp/dev_client_core.py @@ -38,9 +38,9 @@ LocalCapabilitySurfaceProfile, get_agent_capability, ) -from openhcs.agent.dto.common import AgentCliRequest, AgentError, AgentResultEnvelope +from formbridge.contracts import AgentCliRequest, AgentError, AgentResultEnvelope from openhcs.agent.dto.mcp import McpBoundaryFailure, McpToolErrorResult -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( AllFields, DefaultDiffFields, DirtyFields, @@ -51,25 +51,20 @@ UiBridgeOperationStatus, UiBridgeOperationWaitRequest, UiObjectStateFieldFilter, + UiStateSurfaceDocument, + UiStateSurfaceRequest, +) +from openhcs.agent.dto.ui_bridge import ( UiPlateManagerState, UiSelectedPlateWorkflowKind, UiSelectedPlateWorkflowRequest, UiSelectedPlateWorkflowResult, - UiStateSurfaceDocument, - UiStateSurfaceRequest, ) from openhcs.agent.path_policy import AgentPathPolicy -from openhcs.agent.runtime_platform import AgentRuntimePlatform -from openhcs.agent.ui_bridge_environment import UiBridgeDescriptorEnvironment -from openhcs.constants.constants import ( - OrchestratorCompileFailed, - OrchestratorExecFailed, - OrchestratorInitFailed, - OrchestratorState, -) +from formbridge.runtime_platform import AgentRuntimePlatform +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication from openhcs.core.execution_state import ( ManagerExecutionState, - TerminalExecutionStatus, ) from openhcs.core.native_threading import native_thread_count_environment_keys from openhcs.core.plate_file_inventory import ALL_PLATE_FILE_KINDS @@ -83,6 +78,8 @@ from openhcs.runtime.import_authority import OpenHCSRuntimeImportAuthority from openhcs.utils.environment import OpenHCSProcessEnvironment from openhcs.core.axes import AxisFamily +from openhcs.authoring.session.operations import SessionOperation +from openhcs.authoring.session.operations.datasets import DatasetWorkflowOperation if TYPE_CHECKING: from mcp.types import ProgressNotification @@ -341,7 +338,7 @@ def environment(self) -> dict[str, str]: additional_keys=( *self.mcp_environment_keys, *AgentPathPolicy.environment_keys(), - *UiBridgeDescriptorEnvironment.child_process_environment_keys(), + *OpenHCSUiBridgeApplication.descriptor_environment_keys(), *UIConfigCacheEnvironment.child_process_environment_keys(), *OpenHCSProcessEnvironment.child_process_environment_keys(), *native_thread_count_environment_keys(), @@ -552,91 +549,24 @@ def has_only_agent_error_code(self, code: str) -> bool: ) -class WorkflowTerminalStateCriterion(ABC, metaclass=AutoRegisterMeta): - """Registered terminal-state criterion for one selected workflow kind.""" - - __registry__: ClassVar[ - dict[UiSelectedPlateWorkflowKind, type["WorkflowTerminalStateCriterion"]] - ] = {} - __registry_key__ = "workflow" - __skip_if_no_key__ = True - - workflow: ClassVar[UiSelectedPlateWorkflowKind] - failed_orchestrator_states: ClassVar[tuple[type[OrchestratorState], ...]] = () - terminal_state_is_idempotent: ClassVar[bool] = False - - @classmethod - def for_workflow( - cls, - workflow: UiSelectedPlateWorkflowKind, - ) -> "WorkflowTerminalStateCriterion": - return cls.__registry__[workflow]() - - @abstractmethod - def terminal_for_row(self, row: DatasetRowState) -> bool: - """Return whether this workflow has reached its terminal row state.""" - - def failed_for_row(self, row: DatasetRowState) -> bool: - """Return whether this workflow reached a failed terminal row state.""" - terminal_status = terminal_execution_status(row.terminal_status) - if terminal_status is not None and terminal_status.counts_as_failed: - return True - - row_orchestrator_state = parse_orchestrator_state(row.orchestrator_state) - return ( - row_orchestrator_state is not None - and row_orchestrator_state in self.failed_orchestrator_states - ) - - -class InitWorkflowTerminalStateCriterion(WorkflowTerminalStateCriterion): - workflow = UiSelectedPlateWorkflowKind.INIT - failed_orchestrator_states = (OrchestratorInitFailed,) - terminal_state_is_idempotent = True - - def terminal_for_row(self, row: DatasetRowState) -> bool: - return row.init_pending is False and row.initialized is True - - -class CompileWorkflowTerminalStateCriterion(WorkflowTerminalStateCriterion): - workflow = UiSelectedPlateWorkflowKind.COMPILE - failed_orchestrator_states = (OrchestratorCompileFailed,) - - def terminal_for_row(self, row: DatasetRowState) -> bool: - return row.compile_pending is False and row.compiled is True - - -class RunWorkflowTerminalStateCriterion(WorkflowTerminalStateCriterion): - workflow = UiSelectedPlateWorkflowKind.RUN - failed_orchestrator_states = (OrchestratorExecFailed,) - - def terminal_for_row(self, row: DatasetRowState) -> bool: - return ( - row.execution_active is False - and row.queue_position is None - and row.terminal_status is not None - ) - - @dataclass(frozen=True, slots=True) class WorkflowStatePollPolicy: - """Terminal-state policy for one selected PlateManager workflow.""" + """Terminal-state policy for one selected dataset workflow step.""" - criterion: WorkflowTerminalStateCriterion + operation: type[DatasetWorkflowOperation] @classmethod def from_workflow_text(cls, workflow: str) -> "WorkflowStatePollPolicy": - return cls( - criterion=WorkflowTerminalStateCriterion.for_workflow( - UiSelectedPlateWorkflowKind(workflow) - ) - ) + operation = SessionOperation.named(UiSelectedPlateWorkflowKind(workflow).value) + if not issubclass(operation, DatasetWorkflowOperation): + raise TypeError(f"{workflow!r} is not a dataset workflow step.") + return cls(operation=operation) def terminal_for_row(self, row: DatasetRowState) -> bool: - return self.criterion.terminal_for_row(row) + return self.operation.terminal_for_row(row) def failed_for_row(self, row: DatasetRowState) -> bool: - return self.criterion.failed_for_row(row) + return self.operation.failed_for_row(row) def can_evaluate( self, @@ -648,7 +578,7 @@ def can_evaluate( return ( baseline is None or baseline.changed_by(result) - or self.criterion.terminal_state_is_idempotent + or self.operation.terminal_state_is_idempotent ) @@ -2474,25 +2404,6 @@ def workflow_poll_target_rows( return tuple(row for row in state.rows if row.scope_id in target_scope_ids) -def terminal_execution_status( - value: str | None, -) -> type[TerminalExecutionStatus] | None: - if value is None: - return None - try: - return TerminalExecutionStatus.named(value) - except ValueError: - return None - - -def parse_orchestrator_state( - value: str | None, -) -> type[OrchestratorState] | None: - if value is None: - return None - return OrchestratorState.named(value) - - def workflow_poll_summary_result( *, workflow: str | None = None, diff --git a/openhcs/mcp/dev_client_renderers/object_state.py b/openhcs/mcp/dev_client_renderers/object_state.py index 5d986dd53e..c88c746312 100644 --- a/openhcs/mcp/dev_client_renderers/object_state.py +++ b/openhcs/mcp/dev_client_renderers/object_state.py @@ -2,7 +2,7 @@ from __future__ import annotations -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( AllFields, UiCatalogPageMetadata, UiObjectStateFieldHelpResult, diff --git a/openhcs/mcp/dev_client_renderers/pipeline.py b/openhcs/mcp/dev_client_renderers/pipeline.py index cb3f07ba00..9d3c449c2d 100644 --- a/openhcs/mcp/dev_client_renderers/pipeline.py +++ b/openhcs/mcp/dev_client_renderers/pipeline.py @@ -5,7 +5,8 @@ from inspect import getdoc from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.common import AgentError, RenderedSource +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import RenderedSource from openhcs.agent.dto.execution import ( ArtifactInputPlanSummary, ArtifactMaterializationPlanSummary, diff --git a/openhcs/mcp/dev_client_renderers/ui_bridge.py b/openhcs/mcp/dev_client_renderers/ui_bridge.py index df90ed377a..3fb125c6be 100644 --- a/openhcs/mcp/dev_client_renderers/ui_bridge.py +++ b/openhcs/mcp/dev_client_renderers/ui_bridge.py @@ -9,9 +9,9 @@ from openhcs.agent.dto.session import DatasetRowState, PipelineStepState from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.common import AgentWarning +from formbridge.contracts import AgentWarning from openhcs.agent.dto.mcp import McpServerHealthResult -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( QT_TOP_LEVEL_WINDOW_KIND, UiActionCatalog, UiActionInvokeResult, @@ -23,14 +23,8 @@ UiCodeDocumentCatalog, UiCodeDocumentSummary, UiCodeDocumentValidationResult, - UiDebugActionState, - UiDebugRuntimeFrameState, UiLiveOverviewSection, UiLiveOverviewState, - UiPipelineDebugSessionState, - UiPipelineEditorState, - UiPlateManagerState, - UiSelectedPlateWorkflowResult, UiSnapshotRef, UiStateSurfaceCatalog, UiStateSurfaceDocument, @@ -43,11 +37,21 @@ UiWindowCatalog, UiWindowSummary, ) +from openhcs.agent.dto.ui_bridge import ( + UiDebugActionState, + UiDebugRuntimeFrameState, + UiPipelineDebugSessionState, + UiPipelineEditorState, + UiPlateManagerState, + UiSelectedPlateWorkflowResult, +) from openhcs.agent.ui_bridge_identities import ( MainWindowWidgetIdentity, PipelineDebugSessionStateSurfaceIdentityDeclaration, PipelineEditorStateSurfaceIdentityDeclaration, - PlateManagerStateSurfaceIdentityDeclaration, + DatasetListStateSurfaceIdentity, +) +from formbridge.bridge.identities import ( UiLiveOverviewStateSurfaceIdentityDeclaration, UiStateSurfaceIdentityDeclarationBase, ) @@ -316,7 +320,7 @@ def _section_lines(cls, section: UiLiveOverviewSection) -> list[str]: class PlateManagerStateSurfaceRenderer(UiStateSurfaceStateRenderer): """Compact renderer for PlateManager state-surface rows.""" - surface_identity = PlateManagerStateSurfaceIdentityDeclaration + surface_identity = DatasetListStateSurfaceIdentity output_contract = UiPlateManagerState @classmethod diff --git a/openhcs/mcp/dev_client_renderers/viewer.py b/openhcs/mcp/dev_client_renderers/viewer.py index 54255f24f0..bc55326a86 100644 --- a/openhcs/mcp/dev_client_renderers/viewer.py +++ b/openhcs/mcp/dev_client_renderers/viewer.py @@ -16,7 +16,7 @@ RuntimeServerInfo, RuntimeServerScanResult, ) -from openhcs.agent.dto.ui_bridge import UiWindowSnapshotResult +from formbridge.bridge.dto import UiWindowSnapshotResult from openhcs.agent.dto.viewer import ( ViewerWindowImageSampleResult, ViewerWindowLayerIsolationResult, diff --git a/openhcs/mcp/dev_client_rendering.py b/openhcs/mcp/dev_client_rendering.py index d842d9947d..90edee56e8 100644 --- a/openhcs/mcp/dev_client_rendering.py +++ b/openhcs/mcp/dev_client_rendering.py @@ -25,7 +25,7 @@ get_agent_capability, ) from openhcs.agent.dto.authoring import AuthoringContextRequest -from openhcs.agent.dto.common import AgentError, AgentWarning +from formbridge.contracts import AgentError, AgentWarning DEFAULT_CODE_DOCUMENT_MAX_CHARS = 2_000 PresentationValue = TypeVar("PresentationValue") diff --git a/openhcs/mcp/execution.py b/openhcs/mcp/execution.py index 39b179072a..6a2758e019 100644 --- a/openhcs/mcp/execution.py +++ b/openhcs/mcp/execution.py @@ -4,19 +4,16 @@ import asyncio from collections.abc import Callable, Coroutine -from concurrent.futures import Future from contextvars import copy_context import threading from typing import Any, ParamSpec, TypeVar -from PyQt6.QtCore import QCoreApplication, QEventLoop, QThread -from polystore import cleanup_backend_connections -from pyqt_reactive.core.future_completion import FutureCompletion -from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor -from pyqt_reactive.services.ui_thread_dispatch import ( - UiThreadDispatcher, - UiThreadDispatchTimeoutError, +from formbridge.services.async_operation_executor import AsyncOperationExecutor +from formbridge.services.main_thread_dispatch import ( + MainThreadDispatchTimeoutError, + QueuedMainThreadDispatcher, ) +from polystore import cleanup_backend_connections from openhcs.agent.exceptions import AgentFacingErrorMixin @@ -24,31 +21,29 @@ ResultT = TypeVar("ResultT") -class McpQueuedCallNotStartedError(AgentFacingErrorMixin, UiThreadDispatchTimeoutError): - """Original dispatch owner cancelled this callback before it began.""" +class McpQueuedCallNotStartedError(AgentFacingErrorMixin, MainThreadDispatchTimeoutError): + """The dispatcher cancelled this callback before it began.""" agent_error_code = "mcp_call_not_started" agent_error_hint = ( - "The Qt dispatcher cancelled this queued callback before invocation. " + "The main-thread dispatcher cancelled this queued callback before invocation. " "This call did not execute; it created no runtime process or handle. " "This is not an uncertain started operation. Preserve its failed receipt " "and resolve the occupied main-thread work before a separate new attempt." ) -class McpMainThreadDispatcher(UiThreadDispatcher): - """Extend the original Qt dispatch owner with request-context propagation. +class McpMainThreadDispatcher(QueuedMainThreadDispatcher): + """Run MCP application work on the process main thread with request context. - ObjectState operations also require the process main thread in a headless - server. Absence of a Qt application must not authorize a worker thread. + ObjectState operations require the process main thread in a headless server, + so the dispatcher must be built there. """ - @staticmethod - def _is_ui_thread() -> bool: - return ( - threading.current_thread() is threading.main_thread() - and UiThreadDispatcher._is_ui_thread() - ) + def __init__(self) -> None: + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError("The MCP dispatcher must be built on the process main thread.") + super().__init__() def call( self, callback: Callable[[], ResultT], *, timeout_ms: int = 5000 @@ -59,35 +54,28 @@ def call( ) @staticmethod - def _dispatch_timeout_error() -> UiThreadDispatchTimeoutError: - """Project only the original dispatch owner's successful cancellation. + def _dispatch_timeout_error() -> MainThreadDispatchTimeoutError: + """Project only the dispatcher's successful cancellation. - The owner passes this error to its queued call's atomic cancel(). A + The dispatcher passes this error to its queued call's atomic cancel(). A callback's own exception, including a dispatch timeout after a side effect, remains the callback outcome and never passes through this hook. """ - return McpQueuedCallNotStartedError("Timed out waiting for UI thread dispatch.") + return McpQueuedCallNotStartedError("Timed out waiting for main-thread dispatch.") async def invoke(self, callback: Callable[[], ResultT]) -> ResultT: - """Await the original affine call without blocking SDK notifications.""" - if self._is_ui_thread(): + """Await one main-thread call without blocking SDK notifications.""" + if self._is_owner_thread(): return self.call(callback) return await asyncio.to_thread(self.call, callback) class McpTransportExecutor(AsyncOperationExecutor): - """Run the SDK loop off-main while the original Qt dispatcher owns calls. - - QCoreApplication supplies only an event dispatcher, not a GUI or native - execution server. Compiler, catalog and kernel placement remain unchanged. - """ + """Run the SDK loop off-main while the main thread serves dispatched calls.""" def __init__(self) -> None: if threading.current_thread() is not threading.main_thread(): raise RuntimeError("MCP transport must be started on the process main thread.") - self._application = QCoreApplication.instance() or QCoreApplication([]) - if QThread.currentThread() != self._application.thread(): - raise RuntimeError("MCP transport requires the Qt application main thread.") super().__init__(max_workers=1) self.dispatcher = McpMainThreadDispatcher() @@ -97,22 +85,13 @@ def run( *args: ParametersT.args, **kwargs: ParametersT.kwargs, ) -> ResultT: - """Join one transport using event-driven completion, not a new poller.""" - loop = QEventLoop() + """Serve main-thread calls until the transport operation finishes.""" future = self.submit(operation, *args, **kwargs) - - def finished(completed: Future[ResultT]) -> None: - loop.quit() - - completion = FutureCompletion(future, finished) - if not future.done(): - loop.exec() - # Keep the original completion relay alive through the event loop. - del completion + self.dispatcher.run_until(future) return future.result() def close(self) -> None: - """Release process resources on their original main-thread owner. + """Release process resources on the main thread. Both transports reach this boundary after their SDK operation finishes. PolyStore owns the lazy ImageJ context and registered resource cleanup; diff --git a/openhcs/mcp/server.py b/openhcs/mcp/server.py index 1ca97a4b91..e03f3cd404 100644 --- a/openhcs/mcp/server.py +++ b/openhcs/mcp/server.py @@ -36,11 +36,11 @@ from pydantic import WithJsonSchema from pydantic.json_schema import GenerateJsonSchema from metaclass_registry import AutoRegisterMeta -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope from python_introspect import JsonValue, dataclass_from_mapping, to_jsonable from zmqruntime.config import TransportMode from zmqruntime.startup import EndpointStartupStatus +import formbridge as formbridge_package import openhcs as openhcs_package from openhcs.agent.capabilities import ( AgentCapabilityDeclaration, @@ -54,12 +54,12 @@ get_capability_registry, require_agent_type_contract, ) -from openhcs.agent.dto.common import ( +from formbridge.contracts import ( AGENT_PARAMETER_DESCRIPTION_METADATA_KEY, AGENT_PARAMETER_PRODUCER_OUTPUT_CONTRACT_METADATA_KEY, - SCHEMA_VERSION, AgentError, ) +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.execution import ( ExecutionConnectionSpec, ) @@ -68,10 +68,7 @@ McpServerStaleErrorResult, McpToolErrorResult, ) -from openhcs.agent.dto.ui_bridge import ( - UiBridgeConnectionRequest, - UiBridgeConnectionSpec, -) +from formbridge.bridge.dto import UiBridgeConnectionRequest, UiBridgeConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowControlRequest from openhcs.agent.exceptions import AgentFacingErrorMixin from openhcs.agent.knowledge_manifest import knowledge_base_source_paths_from_manifest @@ -243,8 +240,8 @@ def _package_python_source_paths(package) -> tuple[Path, ...]: Path(create_agent_context.__code__.co_filename).resolve(), Path(get_capability_registry.__code__.co_filename).resolve(), Path(to_jsonable.__code__.co_filename).resolve(), - _source_path_for_type(WindowSnapshotCaptureScope), *_package_python_source_paths(openhcs_package), + *_package_python_source_paths(formbridge_package), *MCP_SERVER_PACKAGED_RESOURCE_PATHS, ) ) diff --git a/openhcs/processing/custom_functions/events.py b/openhcs/processing/custom_functions/events.py index 151259b07e..f564aa2bc3 100644 --- a/openhcs/processing/custom_functions/events.py +++ b/openhcs/processing/custom_functions/events.py @@ -1,6 +1,6 @@ """Domain change events for process-local custom-function declarations.""" -from pyqt_reactive.weak_events import WeakCallbackEvent +from formbridge.weak_events import WeakCallbackEvent class CustomFunctionChangedEvent(WeakCallbackEvent[[]]): diff --git a/openhcs/pyqt_gui/config.py b/openhcs/pyqt_gui/config.py index 2f69a9b820..dd2d42aee8 100644 --- a/openhcs/pyqt_gui/config.py +++ b/openhcs/pyqt_gui/config.py @@ -7,21 +7,16 @@ """ import logging -import os -import secrets -import uuid -from collections.abc import Mapping from dataclasses import dataclass, field, replace from enum import Enum from pathlib import Path from typing import Annotated from pyqt_reactive.qt_types import QtKeySequenceText -from pyqt_reactive.services.system_monitor_config import PerformanceMonitorConfig -from pyqt_reactive.strategies.preview_formatting import FormattingConfig +from formbridge.services.system_monitor_config import PerformanceMonitorConfig +from formbridge.strategies.preview_formatting import FormattingConfig from python_introspect import ( EnvironmentVariable, - overlay_dataclass_from_environment, validate_annotated_dataclass, ) from zmqruntime.config import ( @@ -33,12 +28,8 @@ ) from zmqruntime.transport import get_default_transport_mode -from openhcs.agent.dto.execution import ExecutionConnectionSpec -from openhcs.agent.runtime_platform import AgentRuntimePlatform -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeDescriptorDirectoryAuthority, -) -from openhcs.agent.ui_bridge_environment import UiBridgeDescriptorEnvironment +from formbridge.bridge.server import UiBridgeServerConfig +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication from openhcs.core.config import GlobalPipelineConfig from openhcs.core.config_cache import ConfigCacheSpec from openhcs.core.xdg_paths import get_openhcs_log_dir @@ -200,7 +191,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class AgentUiBridgeConfig(ExecutionConnectionSpec): +class AgentUiBridgeConfig(UiBridgeServerConfig): """Publish an authenticated local endpoint for inspecting the live PyQt UI. The bridge exposes ObjectState, window, widget, action, log, and viewer @@ -236,8 +227,6 @@ class AgentUiBridgeConfig(ExecutionConnectionSpec): default selects the platform preference; an explicit ``None`` uses the bridge's TCP fallback. """ - persistent: bool = True - """Advertise bridge connections as reusable across successive client requests.""" enabled: Annotated[ bool, EnvironmentVariable("OPENHCS_ENABLE_UI_BRIDGE"), @@ -250,7 +239,7 @@ class AgentUiBridgeConfig(ExecutionConnectionSpec): descriptor_directory_path: Annotated[ Path | None, EnvironmentVariable( - UiBridgeDescriptorEnvironment.descriptor_directory_path_key, + OpenHCSUiBridgeApplication.descriptor_directory_variable, clear_on_empty=True, ), ] = None @@ -262,7 +251,7 @@ class AgentUiBridgeConfig(ExecutionConnectionSpec): descriptor_file_path: Annotated[ Path | None, EnvironmentVariable( - UiBridgeDescriptorEnvironment.descriptor_file_path_key, + OpenHCSUiBridgeApplication.descriptor_file_variable, clear_on_empty=True, ), ] = None @@ -271,59 +260,6 @@ class AgentUiBridgeConfig(ExecutionConnectionSpec): When set, this overrides ``descriptor_directory_path``. The server removes the descriptor during orderly shutdown. """ - bridge_instance_id: NonBlankString | None = field( - default=None, - metadata={"ui_hidden": True}, - ) - """Stable bridge instance identifier. None generates a fresh ui-prefixed identifier at startup.""" - auth_token: NonBlankString | None = field( - default=None, - metadata={"ui_hidden": True}, - ) - """Authentication token required by bridge clients. None generates a random token at startup.""" - poll_timeout_ms: PositiveInteger = 100 - """Maximum milliseconds the bridge server waits for socket activity per poll. - - Lower values reduce shutdown latency while increasing idle wake-ups. - """ - shutdown_timeout_seconds: PositiveFloat = 2.0 - """Maximum seconds the GUI waits for the bridge thread during shutdown.""" - - @classmethod - def from_environment( - cls, - base: "AgentUiBridgeConfig | None" = None, - ) -> "AgentUiBridgeConfig": - if base is None: - base = cls() - return overlay_dataclass_from_environment(base) - - def resolve_bridge_instance_id(self) -> str: - if self.bridge_instance_id is not None: - return self.bridge_instance_id - return f"ui-{uuid.uuid4()}" - - def resolve_auth_token(self) -> str: - if self.auth_token is not None: - return self.auth_token - return secrets.token_urlsafe(32) - - def descriptor_path_for(self, bridge_instance_id: str) -> Path: - if self.descriptor_file_path is not None: - return AgentRuntimePlatform.resolved_path( - self.descriptor_file_path - ) - return ( - self.descriptor_directory_or_default() - / f"ui_bridge_{bridge_instance_id}.json" - ).resolve(strict=False) - - def descriptor_directory_or_default(self) -> Path: - if self.descriptor_directory_path is not None: - return AgentRuntimePlatform.resolved_path( - self.descriptor_directory_path - ) - return UiBridgeDescriptorDirectoryAuthority.default_descriptor_dir() @dataclass(frozen=True) diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index fc933bcc43..faaac1c680 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -29,12 +29,11 @@ QWidget, ) from pyqt_reactive.animation import WindowFlashOverlay +from pyqt_reactive.bridge.host import UiBridgeHostWindow from pyqt_reactive.services.application_shortcuts import ShortcutLifecycle from pyqt_reactive.services.time_travel_navigation import TimeTravelNavigator from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.services.zmq_server_scan_service import ( - EndpointObservationSnapshot, -) +from formbridge.services.zmq_server_scan_service import EndpointObservationSnapshot from pyqt_reactive.widgets import ( StatusIndicator, StatusState, @@ -44,18 +43,18 @@ from pyqt_reactive.widgets.system_monitor import SystemMonitorWidget from zmqruntime import EndpointConnectionCancelledError -from openhcs.core.selection import AllItems, SelectedItems +from formbridge.selection import AllItems, SelectedItems from openhcs.agent.services.endpoint_function_catalog_service import ( ZMQFunctionCatalogService, ) from openhcs.agent.ui_bridge_identities import ( MainWindowWidgetIdentity, PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, SystemMonitorWindowIdentity, - UiLiveOverviewStateSurfaceIdentityDeclaration, ZmqServerManagerWindowIdentity, ) +from formbridge.bridge.identities import UiLiveOverviewStateSurfaceIdentityDeclaration from openhcs.authoring.session.events import ( ExecutionStateChanged, ProgressAdvanced, @@ -68,7 +67,7 @@ ) from openhcs.authoring.session.session import DispatcherThread, Session from openhcs.core.config import GlobalPipelineConfig -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.authoring.session.operations.application import ( CheckForUpdates, ExitApplication, @@ -103,9 +102,7 @@ build_main_window_specs, ) from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiOwnedStateSurfaceDeclaration, -) +from formbridge.bridge.contracts import UiOwnedStateSurfaceDeclaration from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.pyqt_gui.windows.dual_editor_window import ( select_step_editor_tab_after_time_travel, @@ -175,7 +172,7 @@ def create_pipeline_editor_widget(self, session: Session): ) -class OpenHCSMainWindow(QMainWindow): +class OpenHCSMainWindow(QMainWindow, UiBridgeHostWindow): """ Main OpenHCS PyQt6 application window. @@ -413,7 +410,7 @@ def _create_ui_bridge_server(self, bridge_config, transport_config): from openhcs.pyqt_gui.services.ui_bridge_composition import ( OpenHCSUiBridgeCompositionRoot, ) - from openhcs.pyqt_gui.services.ui_bridge_server import UiBridgeControlServer + from formbridge.bridge.server import UiBridgeControlServer return UiBridgeControlServer( OpenHCSUiBridgeCompositionRoot.for_main_window(self).build_service(), @@ -498,8 +495,8 @@ def setup_ui(self): ) plate_manager_pane = DockPane.create( main_window=self, - window_id=PlateManagerWidgetIdentity.require_value(), - title=PlateManagerWidgetIdentity.require_title(), + window_id=DatasetListWidgetIdentity.require_value(), + title=DatasetListWidgetIdentity.require_title(), widget=self.plate_manager_widget, manager_header=self.plate_manager_widget.manager_header, ) @@ -715,7 +712,7 @@ def setup_menu_bar(self): system_monitor_action.triggered.connect(self.show_system_monitor) view_menu.addAction(system_monitor_action) - plate_action = QAction(f"&{PlateManagerWidgetIdentity.require_title()}", self) + plate_action = QAction(f"&{DatasetListWidgetIdentity.require_title()}", self) plate_action.triggered.connect(self.show_plate_manager) view_menu.addAction(plate_action) self.shortcut_lifecycle.bind_menu_action( diff --git a/openhcs/pyqt_gui/services/code_document_normalization.py b/openhcs/pyqt_gui/services/code_document_normalization.py index e54526e317..9764ef99ce 100644 --- a/openhcs/pyqt_gui/services/code_document_normalization.py +++ b/openhcs/pyqt_gui/services/code_document_normalization.py @@ -7,7 +7,7 @@ from typing import TypeVar from metaclass_registry import AutoRegisterMeta -from pyqt_reactive.services.function_pattern_code_document import FunctionPatternValue +from formbridge.services.function_pattern_code_document import FunctionPatternValue from python_introspect import AnnotatedDataclassValidationMixin from openhcs.core.config_document import ConfigDocumentAuthority @@ -141,7 +141,7 @@ def normalize_source( clean_mode: bool, ) -> str: del declaration_type - from pyqt_reactive.services.function_pattern_code_document import ( + from formbridge.services.function_pattern_code_document import ( FunctionPatternCodeDocumentService, ) diff --git a/openhcs/pyqt_gui/services/embedded_code_documents.py b/openhcs/pyqt_gui/services/embedded_code_documents.py index 6126b9447c..0065c6f5e3 100644 --- a/openhcs/pyqt_gui/services/embedded_code_documents.py +++ b/openhcs/pyqt_gui/services/embedded_code_documents.py @@ -6,7 +6,7 @@ from typing import ClassVar from metaclass_registry import AutoRegisterMeta -from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from formbridge.services.window_code_document import WindowCodeDocumentDriver from pyqt_reactive.services.window_manager import WindowManager from PyQt6.QtWidgets import QWidget diff --git a/openhcs/pyqt_gui/services/function_step_code_document.py b/openhcs/pyqt_gui/services/function_step_code_document.py index 42c6197a1a..cf8a5b7cba 100644 --- a/openhcs/pyqt_gui/services/function_step_code_document.py +++ b/openhcs/pyqt_gui/services/function_step_code_document.py @@ -4,7 +4,7 @@ from collections.abc import Callable -from pyqt_reactive.services.window_code_document import ( +from formbridge.services.window_code_document import ( PYTHON_MIME_TYPE, WindowCodeDocument, WindowCodeDocumentDriver, diff --git a/openhcs/pyqt_gui/services/main_window_workflows.py b/openhcs/pyqt_gui/services/main_window_workflows.py index 2f2e3afcd0..9b15ecc8eb 100644 --- a/openhcs/pyqt_gui/services/main_window_workflows.py +++ b/openhcs/pyqt_gui/services/main_window_workflows.py @@ -24,10 +24,10 @@ KnowledgeBaseWindowIdentity, LogViewerWindowIdentity, PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, - UiStableWindowIdentityDeclaration, + DatasetListWidgetIdentity, ZmqServerManagerWindowIdentity, ) +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration from openhcs.agent.dto.session import PipelineFileRequest from openhcs.authoring.session.operations.pipelines import LoadPipelineFile from openhcs.core.config import GlobalPipelineConfig @@ -42,7 +42,7 @@ if TYPE_CHECKING: from openhcs.pyqt_gui.config import AgentUiBridgeConfig from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter - from openhcs.pyqt_gui.services.ui_bridge_server import UiBridgeControlServer + from formbridge.bridge.server import UiBridgeControlServer from openhcs.pyqt_gui.widgets.pipeline_editor import PipelineEditorWidget from openhcs.runtime.zmq_config import OpenHCSZMQConfig @@ -85,7 +85,7 @@ def build_main_window_specs() -> dict[str, WindowSpec]: definitions = ( MainWindowSpecDefinition( - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, PlateManagerWindow, True, ), diff --git a/openhcs/pyqt_gui/services/reactor_providers.py b/openhcs/pyqt_gui/services/reactor_providers.py index 76887b99b9..42bfc8aaef 100644 --- a/openhcs/pyqt_gui/services/reactor_providers.py +++ b/openhcs/pyqt_gui/services/reactor_providers.py @@ -15,22 +15,22 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar -from pyqt_reactive.core.log_utils import LogFileInfo -from pyqt_reactive.protocols import ( - CodegenProviderABC, +from formbridge.core.log_utils import LogFileInfo +from formbridge.protocols.codegen_provider import CodegenProviderABC, register_codegen_provider +from formbridge.protocols.component_selection import ( ComponentSelectionProviderABC, - FormGenConfig, FunctionSelectionProviderABC, - LogDiscoveryProviderABC, - ServerScanProviderABC, - register_codegen_provider, register_component_selection_provider, register_function_selection_provider, +) +from formbridge.protocols.form_config import FormGenConfig, set_form_config +from formbridge.protocols.log_providers import ( + LogDiscoveryProviderABC, + ServerScanProviderABC, register_log_discovery_provider, - register_preview_formatter, register_server_scan_provider, - set_form_config, ) +from formbridge.protocols.preview_formatter import register_preview_formatter import openhcs.serialization.pycodify_formatters # noqa: F401 from openhcs.agent.services.endpoint_function_catalog_service import ( @@ -138,9 +138,7 @@ def __init__( def scan_for_server_logs(self) -> list[LogFileInfo]: from pathlib import Path - from pyqt_reactive.services.zmq_server_scan_service import ( - ZMQServerScanService, - ) + from formbridge.services.zmq_server_scan_service import ZMQServerScanService from zmqruntime.execution.logs import ExecutionWorkerLogObservation from openhcs.core.streaming_config_factory import get_all_streaming_ports @@ -212,7 +210,7 @@ def _grouping_axis(group_by: Any): return axis def _get_plate_manager(self): - from pyqt_reactive.services.service_registry import ServiceRegistry + from formbridge.services.service_registry import ServiceRegistry from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget diff --git a/openhcs/pyqt_gui/services/service_adapter.py b/openhcs/pyqt_gui/services/service_adapter.py index cde5929286..8b67096b9d 100644 --- a/openhcs/pyqt_gui/services/service_adapter.py +++ b/openhcs/pyqt_gui/services/service_adapter.py @@ -5,8 +5,9 @@ from typing import Optional from PyQt6.QtWidgets import QApplication, QFileDialog, QMessageBox, QWidget -from pyqt_reactive.core.path_cache import PathCacheKey, cache_path, get_initial_path -from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor +from formbridge.core.path_cache import PathCacheKey, cache_path, get_initial_path +from formbridge.services.async_operation_executor import AsyncOperationExecutor +from formbridge.services.host_services import UiHostServices from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher from pyqt_reactive.theming import ColorScheme, ThemeManager @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) -class PyQtServiceAdapter: +class PyQtServiceAdapter(UiHostServices): """Qt dialogs, async execution, theme and config access for GUI widgets.""" def __init__(self, main_window: QWidget): diff --git a/openhcs/pyqt_gui/services/ui_agent_bridge.py b/openhcs/pyqt_gui/services/ui_agent_bridge.py index f24b58248b..7b04cfdaf9 100644 --- a/openhcs/pyqt_gui/services/ui_agent_bridge.py +++ b/openhcs/pyqt_gui/services/ui_agent_bridge.py @@ -1,167 +1,45 @@ -"""In-process PyQt implementation of the OpenHCS UI agent bridge.""" +"""The OpenHCS GUI's in-process UI bridge and its plate-manager code document.""" from __future__ import annotations import ast -import datetime as _datetime -import hashlib -import threading -import time -import uuid -from collections.abc import Callable from dataclasses import dataclass, replace -from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING -from objectstate.object_state import ObjectStateRegistry -from pyqt_reactive.services.ui_thread_dispatch import ( - UiThreadDispatcher, -) +from formbridge.bridge.dto import UiActionInvokeRequest +from formbridge.bridge.in_process import UiAgentBridgeService +from formbridge.bridge.service import serves +from formbridge.contracts import AgentError +from formbridge.services.main_thread_dispatch import MainThreadDispatcher +from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentError, - AgentWarning, - AgentResultEnvelope, -) -from python_introspect import project_dataclass, to_jsonable -from openhcs.agent.services.function_catalog_service import ( - FunctionCatalogServiceABC, - UnknownFunctionIdError, -) -from openhcs.core.function_reference import ( - FunctionReference, - RegistryFunctionReference, -) -from openhcs.agent.dto.ui_bridge import ( - ActionRejected, - OperationCompleted, - OperationFailed, - OperationNotFound, - OperationRunning, - OperationUnavailable, - UNKNOWN_UI_BRIDGE_OPERATION_ROUTE, - UiActionCatalog, - UiActionIdentity, - UiActionInvokeRequest, - UiActionInvokeResult, - UiBranchCatalog, - UiBranchRef, - UiBranchSwitchRequest, - UiBridgeConnectionSpec, - UiBridgeOperationIdentity, - UiBridgeOperationRef, - UiBridgeOperationRoute, - UiBridgeOperationStatus, - UiBridgeOperationStatusRequest, - UiBridgeStatus, - UiCodeDocument, - UiCodeDocumentApplyRequest, - UiCodeDocumentApplyResult, - UiCodeDocumentCatalog, - UiCodeDocumentIdentity, - UiCodeDocumentRequest, - UiCodeDocumentValidationRequest, - UiCodeDocumentValidationResult, - UiLiveOverviewItem, - UiLiveOverviewMetric, - UiLiveOverviewSection, - UiLiveOverviewSeverity, - UiMutationReceipt, - UiMutationRequestToken, - UiObjectStateFieldMutationRequest, - UiObjectStateFieldMutationResult, - UiObjectStateScopeCatalog, - UiObjectStateScopeListRequest, - UiObjectStateScopeVisibility, - UiSelectedPlateWorkflowRequest, - UiSelectedPlateWorkflowResult, - UiSnapshotCatalog, - UiSnapshotListRequest, - UiSnapshotRef, - UiSnapshotRestoreRequest, - UiSnapshotRestoreResult, - UiStateSurfaceCatalog, - UiStateSurfaceDocument, - UiStateSurfaceRequest, - UiTimeTravelHeadRequest, - UiWidgetActionInvokeRequest, - UiWidgetActionInvokeResult, - UiWidgetTreeRequest, - UiWidgetTreeResult, - UiWindowCatalog, - UiWindowCloseRequest, - UiWindowCloseResult, - UiWindowFocusRequest, - UiWindowFocusResult, - UiWindowNavigateRequest, - UiWindowNavigateResult, - UiWindowSnapshotRequest, - UiWindowSnapshotResult, -) +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.session import ( SessionEventBatch, SessionEventSummary, SessionEventsRequest, ) +from openhcs.agent.dto.ui_bridge import ( + UiSelectedPlateWorkflowRequest, + UiSelectedPlateWorkflowResult, +) from openhcs.agent.services.ui_bridge_service import ( - UiBridgeApplyDocumentOperation, - UiBridgeCloseWindowOperation, - UiBridgeFocusWindowOperation, - UiBridgeGatewayABC, - UiBridgeGetDocumentOperation, - UiBridgeGetOperationStatusOperation, - UiBridgeGetStateSurfaceOperation, - UiBridgeInvokeActionOperation, - UiBridgeInvokeWidgetActionOperation, - UiBridgeListActionsOperation, - UiBridgeListBranchesOperation, - UiBridgeListDocumentsOperation, - UiBridgeListObjectStateScopesOperation, - UiBridgeListSnapshotsOperation, - UiBridgeListStateSurfacesOperation, - UiBridgeListWindowsOperation, - UiBridgeMutateObjectStateFieldOperation, - UiBridgeNavigateWindowOperation, - UiBridgeOperation, - UiBridgeOperationServer, - UiBridgeRestoreSnapshotOperation, UiBridgeSelectedPlateWorkflowOperation, UiBridgeSessionEventsOperation, - UiBridgeSnapshotWindowOperation, - UiBridgeStatusOperation, - UiBridgeSwitchBranchOperation, - UiBridgeTimeTravelHeadOperation, - UiBridgeValidateDocumentOperation, - UiBridgeWidgetTreeOperation, - serves, ) -from openhcs.agent.ui_bridge_identities import ( - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, -) -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - CONFIRMATION_REQUIRED_GUARD, - RESTORE_TIME_TRAVEL_OPT_IN_GUARD, - UiActionProviderABC, - UiBridgeGuardPolicy, - UiBridgeSnapshotProviderABC, - UiCodeDocumentProviderABC, - UiLiveOverviewContributorABC, - UiLiveOverviewContributorIdentity, - UiObjectStateScopeProviderABC, - UiStateSurfaceProviderABC, - UiWindowProviderABC, +from openhcs.agent.services.function_catalog_service import ( + FunctionCatalogServiceABC, + UnknownFunctionIdError, ) -from openhcs.pyqt_gui.services.ui_bridge_object_state_scope_policy import ( - ObjectStateScopeVisibility, +from openhcs.core.function_reference import ( + FunctionReference, + RegistryFunctionReference, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( - UiBridgeProviderSetABC, - UiBridgeRegistrationContext, - UiBridgeSurfaceRegistry, +from openhcs.agent.ui_bridge_identities import ( + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerCodeDocumentAuthority, @@ -169,19 +47,20 @@ PlateManagerDocumentAssignments, PlateManagerOrchestratorCodePayload, ) +from formbridge.bridge.in_process import UiCodeDocumentValidationError if TYPE_CHECKING: from openhcs.authoring.session.session import Session -UiBridgeObservedResultT = TypeVar("UiBridgeObservedResultT", bound=AgentResultEnvelope) - _ORCHESTRATOR_CODE_DOCUMENT_EXAMPLE = PlateManagerDocumentAssignments( plate_paths="['/path/to/plate']", global_config="GlobalPipelineConfig(...)", per_plate_configs="{'/path/to/plate': PipelineConfig(...)}", pipeline_data="{'/path/to/plate': [FunctionStep(...), ...]}", ) + + ORCHESTRATOR_CODE_DOCUMENT_PAYLOAD_HINT = ( "Use the plate-manager orchestrator code document shape: " + "; ".join( @@ -189,10 +68,11 @@ for name in PlateManagerDocumentAssignments.names() ) + ". " - f"Read {PlateManagerOrchestratorCodeDocumentIdentity.require_value()} " + f"Read {DatasetListCodeDocumentIdentity.require_value()} " "for the current template." ) + @dataclass(frozen=True, slots=True) class CodeDocumentExecutionResult(PlateManagerOrchestratorCodePayload): """Parsed and normalized payload from one declarative UI code document.""" @@ -649,932 +529,18 @@ def validate_source(self, source: str, operations) -> CodeDocumentExecutionResul return CodeDocumentExecutionResult.from_payload(payload) -@dataclass(slots=True) -class UiCodeDocumentValidationError(ValueError): - errors: tuple[AgentError, ...] - - def __str__(self) -> str: - return "; ".join(error.message for error in self.errors) - - -class UiBridgeBusyError(RuntimeError): - """Raised when a mutating UI bridge operation is already running.""" - - -class UiBridgeMutationGate: - """Serialize mutating bridge operations for one running UI.""" - - def __init__(self, tracker: "UiBridgeOperationTracker") -> None: - self._lock = threading.Lock() - self._tracker = tracker - - def run( - self, - *, - operation_type: type[UiBridgeOperation], - target_id: str | None, - callback: Callable[ - [UiBridgeOperationRef], - AgentResultEnvelope, - ], - ): - operation = self.begin(operation_type=operation_type, target_id=target_id) - try: - result = callback(operation) - self.complete(operation, operation_type, result) - return result - except Exception as exc: - self.fail(operation, exc) - raise - - def begin( - self, - *, - operation_type: type[UiBridgeOperation], - target_id: str | None, - ) -> UiBridgeOperationRef: - """Start one serialized mutation and return its pollable operation ref.""" - if not self._lock.acquire(blocking=False): - raise UiBridgeBusyError( - "A mutating UI bridge operation is already running." - ) - return self._tracker.start(operation_type.require_name(), target_id) - - def complete( - self, - operation: UiBridgeOperationRef, - operation_type: type[UiBridgeOperation], - result: AgentResultEnvelope, - ) -> None: - """Complete a mutation successfully and release the mutation gate.""" - try: - self._tracker.complete( - operation.identity.operation_id, - status=OperationCompleted, - outcome=operation_type.outcome(result), - errors=result.errors, - warnings=result.warnings, - ) - finally: - self._lock.release() - - def fail( - self, - operation: UiBridgeOperationRef, - exception: Exception, - ) -> None: - """Complete a mutation as failed and release the mutation gate.""" - try: - self._tracker.complete( - operation.identity.operation_id, - status=OperationFailed, - outcome="error", - errors=( - AgentError.from_exception("ui_bridge_operation_failed", exception), - ), - ) - finally: - self._lock.release() - - -class UiBridgeOperationTracker(UiLiveOverviewContributorABC): - """In-memory operation status projection for bridge calls.""" - - overview_identity = UiLiveOverviewContributorIdentity( - section_id="ui-bridge-operations", - title="UI bridge operations", - ) - - def __init__(self) -> None: - self._operations: dict[str, UiBridgeOperationRef] = {} - self._lock = threading.Lock() - - def start(self, operation_name: str, target_id: str | None) -> UiBridgeOperationRef: - operation_id = str(uuid.uuid4()) - operation = UiBridgeOperationRef( - schema_version=SCHEMA_VERSION, - identity=UiBridgeOperationIdentity( - operation_id=operation_id, - route=UiBridgeOperationRoute( - operation_name=operation_name, - target_id=target_id, - ), - ), - status=OperationRunning.name, - started_at_unix=time.time(), - ) - with self._lock: - self._operations[operation_id] = operation - return operation - - def complete( - self, - operation_id: str, - *, - status: type[UiBridgeOperationStatus], - outcome: str | None, - errors: tuple[AgentError, ...] = (), - warnings: tuple[AgentWarning, ...] = (), - result: AgentResultEnvelope | None = None, - ) -> UiBridgeOperationRef: - with self._lock: - operation = self._operations[operation_id] - completed = replace( - operation, - status=status.name, - completed_at_unix=time.time(), - outcome=outcome, - errors=errors, - warnings=warnings, - result_payload=None if result is None else to_jsonable(result), - ) - self._operations[operation_id] = completed - return completed - - def complete_result( - self, operation_id: str, result: AgentResultEnvelope - ) -> UiBridgeOperationRef: - """Project one typed completion through its registered declaration owner.""" - operation = self.get(operation_id) - contract = UiBridgeOperation.for_name( - operation.identity.operation_name - ) - if not isinstance(result, contract.result_type): - raise TypeError( - f"{contract.require_name()} requires {contract.result_type.__qualname__}." - ) - return self.complete( - operation_id, - status=( - OperationFailed - if result.errors - else OperationCompleted - ), - outcome="error" if result.errors else contract.success_outcome, - errors=result.errors, - warnings=result.warnings, - result=result, - ) - - def fail_exception( - self, operation_id: str, error: Exception - ) -> UiBridgeOperationRef: - operation = self.get(operation_id) - contract = UiBridgeOperation.for_name( - operation.identity.operation_name - ) - return self.complete( - operation_id, - status=OperationFailed, - outcome="error", - errors=(AgentError.from_exception(contract.failure_error_code, error),), - ) - - def observe( - self, - contract: type[UiBridgeOperation], - target_id: str, - invoke: Callable[ - [Callable[[UiBridgeObservedResultT], None]], UiBridgeObservedResultT - ], - ) -> tuple[UiBridgeOperationRef, UiBridgeObservedResultT]: - """Use this tracker for one asynchronously completed typed operation.""" - operation = self.start(contract.require_name(), target_id) - try: - result = invoke( - partial(self.complete_result, operation.identity.operation_id) - ) - if ( - result.errors - and self.get(operation.identity.operation_id).completed_at_unix is None - ): - self.complete_result(operation.identity.operation_id, result) - except Exception as exc: - self.fail_exception(operation.identity.operation_id, exc) - raise - return operation, result - - def get(self, operation_id: str) -> UiBridgeOperationRef: - with self._lock: - if operation_id in self._operations: - return self._operations[operation_id] - return UiBridgeOperationRef( - schema_version=SCHEMA_VERSION, - identity=UiBridgeOperationIdentity( - operation_id=operation_id, - route=UNKNOWN_UI_BRIDGE_OPERATION_ROUTE, - ), - status=OperationNotFound.name, - started_at_unix=0.0, - completed_at_unix=0.0, - outcome="not_found", - errors=( - AgentError( - code="unknown_ui_bridge_operation", - message=f"Unknown UI bridge operation: {operation_id}", - ), - ), - ) - - def operations(self) -> tuple[UiBridgeOperationRef, ...]: - with self._lock: - return tuple(self._operations.values()) - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - operations = self.operations() - status_counts = { - status: sum(1 for operation in operations if operation.status == status.name) - for status in (OperationRunning, OperationFailed, OperationCompleted) - } - recent_operations = tuple(reversed(operations[-5:])) - return ( - UiLiveOverviewSection( - section_id=self.overview_identity.section_id, - title=self.overview_identity.title, - summary=( - f"{status_counts[OperationRunning]} running, " - f"{status_counts[OperationFailed]} failed, " - f"{status_counts[OperationCompleted]} completed" - ), - metrics=( - UiLiveOverviewMetric( - key=OperationRunning.name, - label="Running", - value=str(status_counts[OperationRunning]), - ), - UiLiveOverviewMetric( - key=OperationFailed.name, - label="Failed", - value=str(status_counts[OperationFailed]), - ), - UiLiveOverviewMetric( - key=OperationCompleted.name, - label="Completed", - value=str(status_counts[OperationCompleted]), - ), - ), - items=tuple( - UiLiveOverviewItem( - label=operation.identity.operation_name, - status=operation.status, - detail=self._operation_detail(operation), - severity=self._operation_severity(operation), - ) - for operation in recent_operations - ), - ), - ) - - @staticmethod - def _operation_status( - operation: UiBridgeOperationRef, - ) -> type[UiBridgeOperationStatus]: - try: - return UiBridgeOperationStatus.named(operation.status) - except ValueError: - return OperationUnavailable - - @classmethod - def _operation_severity(cls, operation: UiBridgeOperationRef) -> str: - if operation.errors: - return UiLiveOverviewSeverity.ERROR.value - if operation.warnings: - return UiLiveOverviewSeverity.WARNING.value - return cls._operation_status(operation).live_overview_severity - - @staticmethod - def _operation_detail(operation: UiBridgeOperationRef) -> str: - route = operation.identity.route - if route.target_id is None: - return operation.identity.operation_id - return f"{route.target_id} ({operation.identity.operation_id})" - - -@dataclass(slots=True) -class UiBridgePostedMutation: - """Completion box for a mutation that may run inline or on the Qt event loop.""" - - result: AgentResultEnvelope | None = None - error: Exception | None = None - - @property - def completed(self) -> bool: - return self.result is not None or self.error is not None - - def complete(self, result: AgentResultEnvelope) -> None: - self.result = result - - def fail(self, error: Exception) -> None: - self.error = error - - def require_result(self) -> AgentResultEnvelope: - if self.error is not None: - raise self.error - if self.result is None: - raise RuntimeError("Queued UI bridge mutation has not completed.") - return self.result - - -class UiObjectStateSnapshotProvider(UiBridgeSnapshotProviderABC): - """Typed projection and restore API for ObjectState history.""" - - def __init__( - self, - before_restore: Callable[[], None] | None = None, - ) -> None: - self._before_restore = before_restore - - def list_snapshots( - self, - request: UiSnapshotListRequest, - ) -> UiSnapshotCatalog: - return self.catalog(request) - - def catalog( - self, - visibility_request: UiObjectStateScopeVisibility | None = None, - ) -> UiSnapshotCatalog: - visibility = ObjectStateScopeVisibility( - visibility_request or UiObjectStateScopeVisibility() - ) - history = ObjectStateRegistry.get_branch_history() - current_branch = ObjectStateRegistry.get_current_branch() - current_index = self.current_snapshot_index() - refs = tuple( - self._snapshot_ref(snapshot, index, history, visibility) - for index, snapshot in enumerate(history) - if visibility.includes_system_scopes() - or self._visible_state_count(snapshot) > 0 - ) - return UiSnapshotCatalog( - schema_version=SCHEMA_VERSION, - current_branch=current_branch, - current_snapshot_index=current_index, - object_state_token=ObjectStateRegistry.get_token(), - active=ObjectStateRegistry.is_time_traveling(), - snapshots=refs, - branches=self.branch_refs(), - ) - - def branch_refs(self) -> tuple[UiBranchRef, ...]: - branches = [] - for branch in ObjectStateRegistry.list_branches(): - branches.append( - UiBranchRef( - schema_version=SCHEMA_VERSION, - name=str(branch["name"]), - head_snapshot_id=str(branch["head_id"]), - base_snapshot_id=str(branch["base_id"]), - description=str(branch["description"]), - is_current=bool(branch["is_current"]), - ) - ) - return tuple(branches) - - def current_snapshot(self) -> UiSnapshotRef | None: - history = ObjectStateRegistry.get_branch_history() - if not history: - return None - current_index = self.current_snapshot_index() - if current_index == -1: - return None - return self._snapshot_ref( - history[current_index], - current_index, - history, - ObjectStateScopeVisibility( - UiObjectStateScopeVisibility(include_system_scopes=True) - ), - ) - - def current_snapshot_index(self) -> int: - current_index = ObjectStateRegistry.get_current_snapshot_index() - if current_index != -1: - return current_index - history = ObjectStateRegistry.get_branch_history() - if not history: - return -1 - return len(history) - 1 - - def current_branch_head_snapshot_id(self) -> str | None: - history = ObjectStateRegistry.get_branch_history() - if not history: - return None - return history[-1].id - - def revision_token(self, document_id: str) -> str: - time_travel_active = ObjectStateRegistry.is_time_traveling() - parts = ( - document_id, - str(ObjectStateRegistry.get_token()), - ObjectStateRegistry.get_current_branch(), - str(self.current_branch_head_snapshot_id()), - str(ObjectStateRegistry.get_current_snapshot_index()), - str(time_travel_active), - ) - return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() - - def restore_snapshot( - self, - request: UiSnapshotRestoreRequest, - ) -> UiSnapshotRestoreResult: - guard_error = self._restore_guard_policy(request).first_error() - if guard_error is not None: - return self._restore_error(guard_error) - - target = self._target_snapshot(request) - if target is None: - return self._restore_error( - AgentError( - code="snapshot_not_found", - message="Snapshot target was not found.", - ) - ) - - if request.snapshot_id is not None: - operation = partial( - ObjectStateRegistry.time_travel_to_snapshot, - request.snapshot_id, - ) - elif request.index is not None: - operation = partial( - ObjectStateRegistry.time_travel_to, - request.index, - ) - elif request.branch is not None: - operation = partial(self._restore_branch_head, request.branch) - else: - operation = bool - restored, restore_error = self._run_restore(operation) - if restore_error is not None: - return self._restore_error(restore_error) - - return UiSnapshotRestoreResult( - schema_version=SCHEMA_VERSION, - restored=restored, - target_snapshot=target, - current_snapshot=self.current_snapshot(), - catalog=self.catalog(request), - ) - - def time_travel_head( - self, - request: UiTimeTravelHeadRequest, - ) -> UiSnapshotRestoreResult: - if request.confirmation_is_required(): - return self._restore_error( - AgentError( - code="confirmation_required", - message="UI confirmation is required.", - ) - ) - target = self.current_snapshot() - restored, restore_error = self._run_restore( - ObjectStateRegistry.time_travel_to_head - ) - if restore_error is not None: - return self._restore_error(restore_error) - return UiSnapshotRestoreResult( - schema_version=SCHEMA_VERSION, - restored=restored, - target_snapshot=target, - current_snapshot=self.current_snapshot(), - catalog=self.catalog(), - ) - - def switch_branch( - self, - request: UiBranchSwitchRequest, - ) -> UiSnapshotRestoreResult: - return self.restore_snapshot(request.as_restore_request()) - - def _run_restore( - self, - operation: Callable[[], bool], - ) -> tuple[bool, AgentError | None]: - """Authorize and execute one ObjectState history mutation.""" - - try: - if self._before_restore is not None: - self._before_restore() - return bool(operation()), None - except Exception as exc: - return False, AgentError.from_exception( - "ui_snapshot_restore_rejected", - exc, - ) - - def _target_snapshot( - self, - request: UiSnapshotRestoreRequest, - ) -> UiSnapshotRef | None: - if request.snapshot_id is not None: - return self._ref_for_snapshot_id(request.snapshot_id) - if request.index is not None: - history = ObjectStateRegistry.get_branch_history() - if request.index < 0: - index = len(history) + request.index - else: - index = request.index - if 0 <= index < len(history): - return self._snapshot_ref( - history[index], - index, - history, - ObjectStateScopeVisibility( - UiObjectStateScopeVisibility(include_system_scopes=True) - ), - ) - return None - if request.branch is not None: - branches = {branch.name: branch for branch in self.branch_refs()} - if request.branch not in branches: - return None - return self._ref_for_snapshot_id( - branches[request.branch].head_snapshot_id, - branch=request.branch, - ) - return None - - def _restore_branch_head(self, branch: str) -> bool: - known_branches = {ref.name for ref in self.branch_refs()} - if branch not in known_branches: - return False - return ObjectStateRegistry.switch_branch(branch) - - def _ref_for_snapshot_id( - self, - snapshot_id: str, - *, - branch: str | None = None, - ) -> UiSnapshotRef | None: - history = ObjectStateRegistry.get_branch_history(branch) - for index, snapshot in enumerate(history): - if snapshot.id == snapshot_id: - return self._snapshot_ref( - snapshot, - index, - history, - ObjectStateScopeVisibility( - UiObjectStateScopeVisibility(include_system_scopes=True) - ), - ) - return None - - def _snapshot_ref( - self, - snapshot, - index: int, - history, - visibility: ObjectStateScopeVisibility, - ) -> UiSnapshotRef: - current_index = ObjectStateRegistry.get_current_snapshot_index() - head_index = len(history) - 1 - is_head = index == head_index - is_current = (current_index == -1 and is_head) or current_index == index - return UiSnapshotRef( - schema_version=SCHEMA_VERSION, - snapshot_id=snapshot.id, - index=index, - branch=ObjectStateRegistry.get_current_branch(), - parent_snapshot_id=snapshot.parent_id, - timestamp_unix=snapshot.timestamp, - timestamp=_datetime.datetime.fromtimestamp(snapshot.timestamp).isoformat( - timespec="milliseconds" - ), - label=snapshot.label or f"Snapshot #{index}", - num_states=( - len(snapshot.all_states) - if visibility.includes_system_scopes() - else self._visible_state_count(snapshot) - ), - is_current=is_current, - is_head=is_head, - uri=f"openhcs://ui/snapshots/{snapshot.id}", - ) - - @staticmethod - def _visible_state_count(snapshot) -> int: - visibility = ObjectStateScopeVisibility() - return sum( - 1 - for scope_id in snapshot.all_states - if visibility.includes_scope_id(scope_id) - ) - - @staticmethod - def _restore_guard_policy( - request: UiSnapshotRestoreRequest, - ) -> UiBridgeGuardPolicy: - return UiBridgeGuardPolicy( - rules=( - CONFIRMATION_REQUIRED_GUARD.bind( - lambda: request.confirmation_is_required(), - ), - RESTORE_TIME_TRAVEL_OPT_IN_GUARD.bind( - lambda: ( - ObjectStateRegistry.is_time_traveling() - and not request.allow_auto_branch - ), - ), - ) - ) - - @staticmethod - def _restore_error(error: AgentError) -> UiSnapshotRestoreResult: - return UiSnapshotRestoreResult( - schema_version=SCHEMA_VERSION, - restored=False, - target_snapshot=None, - current_snapshot=None, - catalog=None, - errors=(error,), - ) - - -@dataclass(frozen=True, slots=True) -class UiCodeDocumentApplyLabel: - """Formal defaulting authority for apply snapshot labels.""" - - value: str - - @classmethod - def resolve( - cls, - request: UiCodeDocumentApplyRequest, - identity: UiCodeDocumentIdentity, - ) -> "UiCodeDocumentApplyLabel": - if request.snapshot_label is not None: - return cls(request.snapshot_label) - return cls(identity.default_edit_label) - - -class UiAgentBridgeService(UiBridgeOperationServer): - """In-process UI bridge owned by the running PyQt main window.""" +class OpenHCSUiAgentBridgeService(UiAgentBridgeService): + """The OpenHCS GUI's in-process bridge: the generic operations plus its session's.""" def __init__( self, *, - provider_set: UiBridgeProviderSetABC | None = None, - registry: UiBridgeSurfaceRegistry | None = None, - dispatcher: UiThreadDispatcher | None = None, - snapshot_provider: UiObjectStateSnapshotProvider | None = None, - operation_tracker: UiBridgeOperationTracker | None = None, - object_state_mutation_authorizer: Callable[[str], None] | None = None, session: "Session | None" = None, + dispatcher: MainThreadDispatcher | None = None, + **kwargs, ) -> None: + super().__init__(dispatcher=dispatcher or UiThreadDispatcher(), **kwargs) self._session = session - self._dispatcher = dispatcher or UiThreadDispatcher() - self._snapshot_provider = snapshot_provider or UiObjectStateSnapshotProvider() - self._operation_tracker = operation_tracker or UiBridgeOperationTracker() - self._mutation_gate = UiBridgeMutationGate(self._operation_tracker) - self._object_state_mutation_authorizer = object_state_mutation_authorizer - self._registry = registry or UiBridgeSurfaceRegistry() - if provider_set is not None: - provider_set.register( - UiBridgeRegistrationContext( - registry=self._registry, - snapshot_provider=self._snapshot_provider, - ) - ) - - def close(self) -> None: - """Fence callbacks queued by this bridge instance.""" - - self._dispatcher.close() - - def register_provider(self, provider: UiCodeDocumentProviderABC) -> None: - self._registry.register_code_document_provider(provider) - - def register_state_provider(self, provider: UiStateSurfaceProviderABC) -> None: - self._registry.register_state_surface_provider(provider) - - def register_action_provider(self, provider: UiActionProviderABC) -> None: - self._registry.register_action_provider(provider) - - def register_window_provider(self, provider: UiWindowProviderABC) -> None: - self._registry.register_window_provider(provider) - - def register_object_state_scope_provider( - self, - provider: UiObjectStateScopeProviderABC, - ) -> None: - self._registry.register_object_state_scope_provider(provider) - - def _submit_mutation( - self, - *, - operation_type: type[UiBridgeOperation], - target_id: str | None, - request: object, - callback: Callable[[UiBridgeOperationRef], AgentResultEnvelope], - ) -> AgentResultEnvelope: - operation = self._mutation_gate.begin( - operation_type=operation_type, - target_id=target_id, - ) - submitted = UiBridgePostedMutation() - - def run() -> None: - try: - result = callback(operation) - self._mutation_gate.complete(operation, operation_type, result) - submitted.complete(result) - except Exception as exc: - self._mutation_gate.fail(operation, exc) - submitted.fail(exc) - - try: - self._dispatcher.post(run) - except Exception as exc: - self._mutation_gate.fail(operation, exc) - raise - - if submitted.completed: - return submitted.require_result() - return operation_type.accepted(request, operation) - - @serves(UiBridgeStatusOperation) - def status(self) -> UiBridgeStatus: - return UiBridgeStatus( - schema_version=SCHEMA_VERSION, - reachable=True, - auth_required=False, - ) - - @serves(UiBridgeListDocumentsOperation) - def list_documents(self) -> UiCodeDocumentCatalog: - return self._dispatcher.call( - lambda: UiCodeDocumentCatalog( - schema_version=SCHEMA_VERSION, - documents=tuple( - summary - for provider in self._registry.code_document_providers() - for summary in provider.summaries() - ), - ) - ) - - @serves(UiBridgeListStateSurfacesOperation) - def list_state_surfaces(self) -> UiStateSurfaceCatalog: - return self._dispatcher.call( - lambda: UiStateSurfaceCatalog( - schema_version=SCHEMA_VERSION, - surfaces=tuple( - provider.summary() - for provider in self._registry.state_surface_providers() - ), - ) - ) - - @serves(UiBridgeListActionsOperation) - def list_actions(self) -> UiActionCatalog: - def catalog() -> UiActionCatalog: - actions = [] - errors = [] - warnings = [] - for provider in self._registry.action_providers(): - provider_catalog = provider.catalog() - actions.extend(provider_catalog.actions) - errors.extend(provider_catalog.errors) - warnings.extend(provider_catalog.warnings) - return UiActionCatalog( - schema_version=SCHEMA_VERSION, - actions=tuple(actions), - errors=tuple(errors), - warnings=tuple(warnings), - ) - - return self._dispatcher.call(catalog) - - @serves(UiBridgeListWindowsOperation) - def list_windows(self) -> UiWindowCatalog: - def catalog() -> UiWindowCatalog: - windows = [] - errors = [] - warnings = [] - for provider in self._registry.window_providers(): - provider_catalog = provider.catalog() - windows.extend(provider_catalog.windows) - errors.extend(provider_catalog.errors) - warnings.extend(provider_catalog.warnings) - return UiWindowCatalog( - schema_version=SCHEMA_VERSION, - windows=tuple(windows), - errors=tuple(errors), - warnings=tuple(warnings), - ) - - return self._dispatcher.call(catalog) - - @serves(UiBridgeListObjectStateScopesOperation) - def list_object_state_scopes( - self, - request: UiObjectStateScopeListRequest, - ) -> UiObjectStateScopeCatalog: - def catalog() -> UiObjectStateScopeCatalog: - scopes = [] - errors = [] - warnings = [] - for provider in self._registry.object_state_scope_providers(): - provider_catalog = provider.catalog(request) - scopes.extend(provider_catalog.scopes) - errors.extend(provider_catalog.errors) - warnings.extend(provider_catalog.warnings) - return UiObjectStateScopeCatalog( - schema_version=SCHEMA_VERSION, - object_state_token=ObjectStateRegistry.get_token(), - current_branch=ObjectStateRegistry.get_current_branch(), - current_snapshot_index=self._snapshot_provider.current_snapshot_index(), - active=ObjectStateRegistry.is_time_traveling(), - scopes=tuple(scopes), - errors=tuple(errors), - warnings=tuple(warnings), - ) - - return self._dispatcher.call(catalog) - - @serves(UiBridgeMutateObjectStateFieldOperation) - def mutate_object_state_field( - self, - request: UiObjectStateFieldMutationRequest, - ) -> UiObjectStateFieldMutationResult: - def run(operation: UiBridgeOperationRef) -> UiObjectStateFieldMutationResult: - from openhcs.pyqt_gui.services.ui_bridge_object_state import ( - ObjectStateFieldMutationService, - ) - - result = ObjectStateFieldMutationService( - before_mutation=self._object_state_mutation_authorizer, - ).mutate(request) - return replace( - result, - receipt=replace( - result.receipt, - bridge_operation_id=operation.identity.operation_id, - ), - ) - - try: - return self._dispatcher.call( - lambda: self._mutation_gate.run( - operation_type=UiBridgeMutateObjectStateFieldOperation, - target_id=(f"{request.object_state_scope_id}:{request.field_path}"), - callback=run, - ) - ) - except UiBridgeBusyError as exc: - return UiObjectStateFieldMutationResult( - schema_version=SCHEMA_VERSION, - address=request, - mutated=False, - reset=request.reset, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=(AgentError.from_exception("ui_bridge_busy", exc),), - ) - - @serves(UiBridgeGetDocumentOperation) - def get_document(self, request: UiCodeDocumentRequest) -> UiCodeDocument: - return self._dispatcher.call( - lambda: self._registry.code_document_provider(request.document_id).read( - request - ) - ) - - @serves(UiBridgeGetStateSurfaceOperation) - def get_state_surface( - self, request: UiStateSurfaceRequest - ) -> UiStateSurfaceDocument: - return self._dispatcher.call( - lambda: self._registry.state_surface_provider(request.surface_id).read( - request - ) - ) - - @serves(UiBridgeInvokeActionOperation) - def invoke_action(self, request: UiActionInvokeRequest) -> UiActionInvokeResult: - def run(operation: UiBridgeOperationRef) -> UiActionInvokeResult: - result = self._registry.action_provider(request.widget_id).invoke(request) - return replace( - result, - receipt=replace( - result.receipt, - bridge_operation_id=operation.identity.operation_id, - ), - ) - - try: - return self._submit_mutation( - operation_type=UiBridgeInvokeActionOperation, - target_id=request.action_id, - request=request, - callback=run, - ) - except UiBridgeBusyError as exc: - return self._action_error( - request, - AgentError.from_exception("ui_bridge_busy", exc), - ) @serves(UiBridgeSelectedPlateWorkflowOperation) def selected_plate_workflow( @@ -1583,7 +549,7 @@ def selected_plate_workflow( ) -> UiSelectedPlateWorkflowResult: action_result = self.invoke_action( UiActionInvokeRequest( - widget_id=PlateManagerWidgetIdentity.require_value(), + widget_id=DatasetListWidgetIdentity.require_value(), action_id=request.workflow.value, selected_scope_ids=request.selected_scope_ids, observed_selection_revision_token=( @@ -1597,7 +563,7 @@ def selected_plate_workflow( action_result = replace( action_result, workflow_status_surface_ids=( - PlateManagerStateSurfaceIdentityDeclaration.require_value(), + DatasetListStateSurfaceIdentity.require_value(), ), ) return UiSelectedPlateWorkflowResult( @@ -1608,234 +574,6 @@ def selected_plate_workflow( warnings=action_result.warnings, ) - @serves(UiBridgeFocusWindowOperation) - def focus_window(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: - return self._dispatcher.call( - lambda: self._registry.window_provider(request.window_id).focus(request) - ) - - @serves(UiBridgeNavigateWindowOperation) - def navigate_window( - self, - request: UiWindowNavigateRequest, - ) -> UiWindowNavigateResult: - def navigate() -> UiWindowNavigateResult: - provider = self._registry.window_provider(request.window_id) - if not request.has_target: - return provider.navigate(request) - operation, result = self._operation_tracker.observe( - UiBridgeNavigateWindowOperation, - request.window_id, - partial(provider.navigate, request), - ) - # Accepted target dispatch is not terminal execution or exposure. - return replace( - result, - operation_id=operation.identity.operation_id, - navigated=False, - target_exposed=None, - ) - - return self._dispatcher.call(navigate) - - @serves(UiBridgeCloseWindowOperation) - def close_window(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: - return self._dispatcher.call( - lambda: self._registry.window_provider(request.window_id).close(request) - ) - - @serves(UiBridgeSnapshotWindowOperation) - def snapshot_window( - self, - request: UiWindowSnapshotRequest, - ) -> UiWindowSnapshotResult: - def capture() -> UiWindowSnapshotResult: - provider = self._registry.window_provider(request.window_id) - if not request.frame_condition.observes: - return provider.snapshot(request) - operation, result = self._operation_tracker.observe( - UiBridgeSnapshotWindowOperation, - request.window_id, - partial(provider.snapshot, request), - ) - return project_dataclass( - UiWindowSnapshotResult, - result, - operation_id=operation.identity.operation_id, - ) - - return self._dispatcher.call(capture) - - @serves(UiBridgeWidgetTreeOperation) - def widget_tree( - self, - request: UiWidgetTreeRequest, - ) -> UiWidgetTreeResult: - return self._dispatcher.call( - lambda: self._registry.window_provider(request.window_id).widget_tree( - request - ) - ) - - @serves(UiBridgeInvokeWidgetActionOperation) - def invoke_widget_action( - self, - request: UiWidgetActionInvokeRequest, - ) -> UiWidgetActionInvokeResult: - def run(operation: UiBridgeOperationRef) -> UiWidgetActionInvokeResult: - provider = self._registry.window_provider(request.window_id) - result = provider.invoke_widget_action(request) - return replace( - result, - receipt=replace( - result.receipt, - bridge_operation_id=operation.identity.operation_id, - ), - ) - - try: - return self._submit_mutation( - operation_type=UiBridgeInvokeWidgetActionOperation, - target_id=f"{request.window_id}:{request.path_id}", - request=request, - callback=run, - ) - except UiBridgeBusyError as exc: - return self._widget_action_error( - request, - AgentError.from_exception("ui_bridge_busy", exc), - ) - - @serves(UiBridgeValidateDocumentOperation) - def validate_document( - self, - request: UiCodeDocumentValidationRequest, - ) -> UiCodeDocumentValidationResult: - return self._dispatcher.call( - lambda: self._registry.code_document_provider(request.document_id).validate( - request - ) - ) - - @serves(UiBridgeApplyDocumentOperation) - def apply_document( - self, - request: UiCodeDocumentApplyRequest, - ) -> UiCodeDocumentApplyResult: - def run(operation: UiBridgeOperationRef) -> UiCodeDocumentApplyResult: - result = self._registry.code_document_provider(request.document_id).apply( - request - ) - return replace( - result, - operation_id=operation.identity.operation_id, - receipt=replace( - result.receipt, - bridge_operation_id=operation.identity.operation_id, - ), - ) - - try: - return self._submit_mutation( - operation_type=UiBridgeApplyDocumentOperation, - target_id=request.document_id, - request=request, - callback=run, - ) - except UiBridgeBusyError as exc: - return self._apply_document_error( - request, - AgentError.from_exception("ui_bridge_busy", exc), - ) - - @serves(UiBridgeListSnapshotsOperation) - def list_snapshots(self, request: UiSnapshotListRequest) -> UiSnapshotCatalog: - return self._dispatcher.call( - lambda: self._snapshot_provider.list_snapshots(request) - ) - - @serves(UiBridgeRestoreSnapshotOperation) - def restore_snapshot( - self, - request: UiSnapshotRestoreRequest, - ) -> UiSnapshotRestoreResult: - def run(operation: UiBridgeOperationRef) -> UiSnapshotRestoreResult: - result = self._snapshot_provider.restore_snapshot(request) - return replace( - result, - operation_id=operation.identity.operation_id, - receipt=UiMutationReceipt.accepted_for( - UiMutationRequestToken(), - bridge_operation_id=operation.identity.operation_id, - ), - ) - - return self._submit_mutation( - operation_type=UiBridgeRestoreSnapshotOperation, - target_id=request.operation_target_id, - request=request, - callback=run, - ) - - @serves(UiBridgeTimeTravelHeadOperation) - def time_travel_head( - self, - request: UiTimeTravelHeadRequest, - ) -> UiSnapshotRestoreResult: - def run(operation: UiBridgeOperationRef) -> UiSnapshotRestoreResult: - result = self._snapshot_provider.time_travel_head(request) - return replace( - result, - operation_id=operation.identity.operation_id, - receipt=UiMutationReceipt.accepted_for( - UiMutationRequestToken(), - bridge_operation_id=operation.identity.operation_id, - ), - ) - - return self._submit_mutation( - operation_type=UiBridgeTimeTravelHeadOperation, - target_id=None, - request=request, - callback=run, - ) - - @serves(UiBridgeListBranchesOperation) - def list_branches(self) -> UiBranchCatalog: - return self._dispatcher.call( - lambda: UiBranchCatalog( - schema_version=SCHEMA_VERSION, - current_branch=ObjectStateRegistry.get_current_branch(), - branches=self._snapshot_provider.branch_refs(), - ) - ) - - @serves(UiBridgeSwitchBranchOperation) - def switch_branch(self, request: UiBranchSwitchRequest) -> UiSnapshotRestoreResult: - def run(operation: UiBridgeOperationRef) -> UiSnapshotRestoreResult: - result = self._snapshot_provider.switch_branch(request) - return replace( - result, - operation_id=operation.identity.operation_id, - receipt=UiMutationReceipt.accepted_for( - UiMutationRequestToken(), - bridge_operation_id=operation.identity.operation_id, - ), - ) - - return self._submit_mutation( - operation_type=UiBridgeSwitchBranchOperation, - target_id=request.branch, - request=request, - callback=run, - ) - - @serves(UiBridgeGetOperationStatusOperation) - def get_operation_status( - self, request: UiBridgeOperationStatusRequest - ) -> UiBridgeOperationRef: - return self._operation_tracker.get(request.operation_id) - @serves(UiBridgeSessionEventsOperation) def session_events(self, request: SessionEventsRequest) -> SessionEventBatch: """The hosting session's events; the wait runs off the UI thread.""" @@ -1857,60 +595,3 @@ def session_events(self, request: SessionEventsRequest) -> SessionEventBatch: last_sequence=self._session.event_log.last_sequence, ) - @staticmethod - def _apply_document_error( - request: UiCodeDocumentApplyRequest, - error: AgentError, - ) -> UiCodeDocumentApplyResult: - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=False, - base_revision_token=request.base_revision_token, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=(error,), - ) - - @staticmethod - def _action_error( - request: UiActionInvokeRequest, - error: AgentError, - ) -> UiActionInvokeResult: - return UiActionInvokeResult( - schema_version=SCHEMA_VERSION, - identity=UiActionIdentity( - widget_id=request.widget_id, - action_id=request.action_id, - ), - status=ActionRejected, - receipt=UiMutationReceipt.rejected_for(request.request_token), - target_scope_ids=request.selected_scope_ids, - selection_revision_token=request.observed_selection_revision_token, - errors=(error,), - ) - - @staticmethod - def _widget_action_error( - request: UiWidgetActionInvokeRequest, - error: AgentError, - ) -> UiWidgetActionInvokeResult: - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=request.action_kind, - invoked=False, - receipt=UiMutationReceipt.rejected_for(request.request_token), - errors=(error,), - ) - - -class InProcessUiBridgeGateway(UiBridgeGatewayABC): - """Gateway adapter for tests or same-process MCP embedding.""" - - def __init__(self, bridge: UiAgentBridgeService) -> None: - self._bridge = bridge - - def invoke(self, connection: UiBridgeConnectionSpec, operation, request): - del connection - return self._bridge.invoke(operation, request) diff --git a/openhcs/pyqt_gui/services/ui_bridge_composition.py b/openhcs/pyqt_gui/services/ui_bridge_composition.py index 3ebbac7430..42bcdfd3f1 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_composition.py +++ b/openhcs/pyqt_gui/services/ui_bridge_composition.py @@ -1,22 +1,28 @@ -"""Composition root for the PyQt UI bridge.""" +"""Composition root for the OpenHCS GUI's UI bridge.""" from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING -from openhcs.pyqt_gui.services.ui_agent_bridge import ( - UiAgentBridgeService, +import openhcs.pyqt_gui.services.ui_bridge_live_overview # noqa: F401 (provider set) +import openhcs.pyqt_gui.services.ui_bridge_pipeline_editor # noqa: F401 (provider set) +import openhcs.pyqt_gui.services.ui_bridge_plate_manager # noqa: F401 (provider set) +import openhcs.pyqt_gui.services.ui_bridge_windows # noqa: F401 (provider set) +import pyqt_reactive.bridge.object_state_documents # noqa: F401 (provider set) +from formbridge.bridge.in_process import ( UiBridgeOperationTracker, UiObjectStateSnapshotProvider, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( +from formbridge.bridge.registry import ( CompositeUiBridgeProviderSet, UiBridgeProviderSetABC, UiBridgeRegistrationContext, UiBridgeSurfaceRegistry, ) +from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService + if TYPE_CHECKING: from openhcs.authoring.session.session import Session @@ -42,7 +48,7 @@ def for_main_window(cls, main_window) -> "OpenHCSUiBridgeCompositionRoot": main_window.session, ) - def build_service(self) -> UiAgentBridgeService: + def build_service(self) -> OpenHCSUiAgentBridgeService: snapshot_provider = UiObjectStateSnapshotProvider( before_restore=self.session.require_definition_mutation_allowed, ) @@ -55,7 +61,7 @@ def build_service(self) -> UiAgentBridgeService: snapshot_provider=snapshot_provider, ) ) - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( registry=registry, snapshot_provider=snapshot_provider, operation_tracker=operation_tracker, diff --git a/openhcs/pyqt_gui/services/ui_bridge_contracts.py b/openhcs/pyqt_gui/services/ui_bridge_contracts.py deleted file mode 100644 index 24c36b3447..0000000000 --- a/openhcs/pyqt_gui/services/ui_bridge_contracts.py +++ /dev/null @@ -1,629 +0,0 @@ -"""Cycle-free contracts for PyQt UI bridge provider registration.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from typing import ClassVar, Self - -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning -from openhcs.agent.dto.ui_bridge import ( - ActionAccepted, - ActionRejected, - UiActionCatalog, - UiActionIdentity, - UiActionInvokeRequest, - UiActionInvokeResult, - UiActionSummary, - UiCodeDocument, - UiCodeDocumentApplyRequest, - UiCodeDocumentApplyResult, - UiCodeDocumentIdentity, - UiCodeDocumentRequest, - UiCodeDocumentSummary, - UiCodeDocumentValidationRequest, - UiCodeDocumentValidationResult, - UiLiveOverviewSection, - UiMutationReceipt, - UiObjectStateScopeCatalog, - UiObjectStateScopeListRequest, - UiSnapshotRef, - UiStateSurfaceDocument, - UiStateSurfaceIdentity, - UiStateSurfaceRequest, - UiStateSurfaceSummary, - UiWidgetActionInvokeRequest, - UiWidgetActionInvokeResult, - UiWidgetIdentity, - UiWidgetTreeRequest, - UiWidgetTreeResult, - UiWindowCatalog, - UiWindowCloseRequest, - UiWindowCloseResult, - UiWindowFocusRequest, - UiWindowFocusResult, - UiWindowNavigateRequest, - UiWindowNavigateResult, - UiWindowSnapshotRequest, - UiWindowSnapshotResult, -) -from openhcs.agent.ui_bridge_identities import ( - UiCodeDocumentIdentityDeclaration, - UiStateSurfaceIdentityDeclarationBase, - UiWidgetIdentityDeclaration, -) - - -class UiBridgeRegistryKeyMixin: - """Shared AutoRegisterMeta registry-key declaration for UI bridge families.""" - - __registry_key__ = "registry_key" - __skip_if_no_key__ = True - - registry_key: ClassVar[str | None] = None - - -@dataclass(frozen=True, slots=True) -class UiLiveOverviewContributorIdentity: - """Stable identity for one live-overview contribution source.""" - - section_id: str - title: str - - -class UiLiveOverviewContributorABC(ABC): - """Nominal capability for providers that contribute to the live overview.""" - - @property - @abstractmethod - def overview_identity(self) -> UiLiveOverviewContributorIdentity: - raise NotImplementedError - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - return () - - -class UiLiveOverviewWidget: - """Nominal widget capability for UI-owned live overview contributions.""" - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - return () - - -class UiCodeDocumentProviderABC(ABC): - """Provider contract for one UI-owned code document.""" - - identity: "UiCodeDocumentProviderIdentity" - - def handles(self, document_id: str) -> bool: - """Return whether this provider owns a document id.""" - return self.identity.document_id == document_id - - @abstractmethod - def summary(self) -> UiCodeDocumentSummary: - raise NotImplementedError - - def summaries(self) -> tuple[UiCodeDocumentSummary, ...]: - """Return catalog summaries owned by this provider.""" - return (self.summary(),) - - @abstractmethod - def read(self, request: UiCodeDocumentRequest) -> UiCodeDocument: - raise NotImplementedError - - @abstractmethod - def validate( - self, - request: UiCodeDocumentValidationRequest, - ) -> UiCodeDocumentValidationResult: - raise NotImplementedError - - @abstractmethod - def apply( - self, - request: UiCodeDocumentApplyRequest, - ) -> UiCodeDocumentApplyResult: - raise NotImplementedError - - -class UiStateSurfaceProviderABC(UiLiveOverviewContributorABC): - """Provider contract for one pollable UI state surface.""" - - identity: "UiStateSurfaceProviderIdentity" - - @property - def overview_identity(self) -> UiLiveOverviewContributorIdentity: - return UiLiveOverviewContributorIdentity( - section_id=self.identity.revision_key, - title=self.identity.title, - ) - - @abstractmethod - def summary(self) -> UiStateSurfaceSummary: - raise NotImplementedError - - @abstractmethod - def read(self, request: UiStateSurfaceRequest) -> UiStateSurfaceDocument: - raise NotImplementedError - - -@dataclass(frozen=True, slots=True) -class UiOwnedStateSurfaceDeclaration: - """State-surface semantics declared by the widget that owns the state.""" - - identity: type[UiStateSurfaceIdentityDeclarationBase] - title: str - payload_schema: str - related_action_ids: tuple[str, ...] = () - - @property - def surface_id(self) -> str: - """Project the stable protocol identity without repeating its value.""" - return self.identity.require_value() - - def __post_init__(self) -> None: - if not self.title: - raise ValueError("UI state-surface title cannot be empty.") - if not self.payload_schema: - raise ValueError("UI state-surface payload schema cannot be empty.") - if any(not action_id for action_id in self.related_action_ids): - raise ValueError("Related UI action ids cannot be empty.") - if len(self.related_action_ids) != len(set(self.related_action_ids)): - raise ValueError("Related UI action ids cannot contain duplicates.") - - -def state_surface_ids_for_action( - declarations: Iterable[UiOwnedStateSurfaceDeclaration], - action_id: str, -) -> tuple[str, ...]: - """Project the surfaces an owner declares relevant to one action.""" - return tuple( - declaration.surface_id - for declaration in declarations - if action_id in declaration.related_action_ids - ) - - -def state_surface_declaration_for_identity( - declarations: Iterable[UiOwnedStateSurfaceDeclaration], - identity: type[UiStateSurfaceIdentityDeclarationBase], -) -> UiOwnedStateSurfaceDeclaration: - """Resolve exactly one owner declaration by its neutral protocol identity.""" - matches = tuple( - declaration for declaration in declarations if declaration.identity is identity - ) - if len(matches) != 1: - raise ValueError( - f"Expected exactly one UI state-surface declaration for " - f"{identity.__name__}, found {len(matches)}." - ) - return matches[0] - - -@dataclass(frozen=True, slots=True) -class UiActionDispatch: - """What dispatching one UI action reported.""" - - errors: tuple[AgentError, ...] = () - warnings: tuple[AgentWarning, ...] = () - event_sequence: int | None = None - - -class UiActionProviderABC(ABC): - """One widget's action catalog. - - A provider declares its action ids, the summary of each, and how to - dispatch one; the catalog, the invocation guards (enabled, confirmation, - targets, selection revision, all read from the summary) and the result - are this base's. - """ - - identity: "UiActionProviderIdentity" - dispatch_error_code: ClassVar[str] = "ui_action_dispatch_failed" - - @abstractmethod - def action_ids(self) -> tuple[str, ...]: - raise NotImplementedError - - @abstractmethod - def summary(self, action_id: str) -> UiActionSummary: - raise NotImplementedError - - @abstractmethod - def dispatch(self, request: UiActionInvokeRequest) -> UiActionDispatch: - raise NotImplementedError - - def catalog_warnings(self) -> tuple[AgentWarning, ...]: - return () - - def workflow_status_surface_ids(self) -> tuple[str, ...]: - return () - - def catalog(self) -> UiActionCatalog: - return UiActionCatalog( - schema_version=SCHEMA_VERSION, - actions=tuple(self.summary(action_id) for action_id in self.action_ids()), - warnings=self.catalog_warnings(), - ) - - def invoke(self, request: UiActionInvokeRequest) -> UiActionInvokeResult: - try: - summary = self.summary(request.action_id) - except Exception as exc: - return self._result( - request, - None, - UiActionDispatch( - errors=(AgentError.from_exception("unknown_ui_action", exc),) - ), - ) - error = summary.invocation_guard_error(request) - if error is not None: - return self._result(request, summary, UiActionDispatch(errors=(error,))) - try: - dispatched = self.dispatch(request) - except Exception as exc: - dispatched = UiActionDispatch( - errors=(AgentError.from_exception(self.dispatch_error_code, exc),) - ) - return self._result(request, summary, dispatched) - - def _result( - self, - request: UiActionInvokeRequest, - summary: UiActionSummary | None, - dispatched: UiActionDispatch, - ) -> UiActionInvokeResult: - accepted = not dispatched.errors - return UiActionInvokeResult( - schema_version=SCHEMA_VERSION, - identity=UiActionIdentity( - widget_id=request.widget_id, - action_id=request.action_id, - ), - status=ActionAccepted if accepted else ActionRejected, - receipt=( - UiMutationReceipt.accepted_for(request.request_token) - if accepted - else UiMutationReceipt.rejected_for(request.request_token) - ), - target_scope_ids=( - request.selected_scope_ids - or (() if summary is None else summary.target_scope_ids) - ), - selection_revision_token=( - None if summary is None else summary.selection_revision_token - ), - workflow_status_surface_ids=( - self.workflow_status_surface_ids() if accepted else () - ), - event_sequence=dispatched.event_sequence, - errors=dispatched.errors, - warnings=tuple(dispatched.warnings), - ) - - -class UiWindowProviderABC(UiLiveOverviewContributorABC): - """Provider contract for a catalog of focusable UI windows.""" - - identity: "UiWindowProviderIdentity" - - @property - def overview_identity(self) -> UiLiveOverviewContributorIdentity: - return UiLiveOverviewContributorIdentity( - section_id=self.identity.revision_key, - title=self.identity.title, - ) - - @abstractmethod - def catalog(self) -> UiWindowCatalog: - raise NotImplementedError - - @abstractmethod - def handles(self, window_id: str) -> bool: - raise NotImplementedError - - @abstractmethod - def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: - raise NotImplementedError - - @abstractmethod - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[UiWindowNavigateResult], None] | None = None, - ) -> UiWindowNavigateResult: - raise NotImplementedError - - @abstractmethod - def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: - raise NotImplementedError - - @abstractmethod - def snapshot( - self, - request: UiWindowSnapshotRequest, - completed: Callable[[UiWindowSnapshotResult], None] | None = None, - ) -> UiWindowSnapshotResult: - raise NotImplementedError - - @abstractmethod - def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: - raise NotImplementedError - - @abstractmethod - def invoke_widget_action( - self, - request: UiWidgetActionInvokeRequest, - ) -> UiWidgetActionInvokeResult: - raise NotImplementedError - - -class UiObjectStateScopeProviderABC(ABC): - """Provider contract for ObjectState registry scope projection.""" - - identity: "UiObjectStateScopeProviderIdentity" - - @abstractmethod - def catalog( - self, - request: UiObjectStateScopeListRequest, - ) -> UiObjectStateScopeCatalog: - raise NotImplementedError - - -class UiBridgeSnapshotProviderABC(ABC): - """Snapshot projection behavior required by UI bridge providers.""" - - @abstractmethod - def current_snapshot(self) -> UiSnapshotRef | None: - raise NotImplementedError - - @abstractmethod - def current_branch_head_snapshot_id(self) -> str | None: - raise NotImplementedError - - @abstractmethod - def revision_token(self, document_id: str) -> str: - raise NotImplementedError - - -class SnapshotBackedUiCodeDocumentProviderABC(UiCodeDocumentProviderABC): - """Shared apply rejection behavior for snapshot-backed code documents.""" - - _snapshot_provider: UiBridgeSnapshotProviderABC - - def revision_key_for_document_id(self, document_id: str) -> str: - del document_id - return self.identity.revision_key - - def _apply_error( - self, - request: UiCodeDocumentApplyRequest, - error: AgentError, - ) -> UiCodeDocumentApplyResult: - return self._apply_errors(request, (error,)) - - def _apply_errors( - self, - request: UiCodeDocumentApplyRequest, - errors: tuple[AgentError, ...], - ) -> UiCodeDocumentApplyResult: - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=False, - base_revision_token=request.base_revision_token, - receipt=UiMutationReceipt.rejected_for(request.request_token), - current_revision_token=self._current_revision_token_for_apply_result( - request - ), - current_snapshot=self._current_snapshot_for_apply_result(), - errors=errors, - ) - - def _apply_unchanged( - self, - request: UiCodeDocumentApplyRequest, - ) -> UiCodeDocumentApplyResult: - current_revision = self._current_revision_token_for_apply_result(request) - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=False, - base_revision_token=request.base_revision_token, - receipt=UiMutationReceipt.accepted_for(request.request_token), - outcome="unchanged", - new_revision_token=current_revision, - current_revision_token=current_revision, - current_snapshot=self._current_snapshot_for_apply_result(), - ) - - def _current_revision_token_for_apply_result( - self, - request: UiCodeDocumentApplyRequest, - ) -> str | None: - try: - return self._snapshot_provider.revision_token( - self.revision_key_for_document_id(request.document_id) - ) - except Exception: - return None - - def _current_snapshot_for_apply_result(self) -> UiSnapshotRef | None: - try: - return self._snapshot_provider.current_snapshot() - except Exception: - return None - - -@dataclass(frozen=True, slots=True) -class UiCodeDocumentProviderIdentity(UiCodeDocumentIdentity, UiWidgetIdentity): - """Stable identity and derived names for one UI code document.""" - - title: str - - @classmethod - def from_declaration( - cls, - declaration: type[UiCodeDocumentIdentityDeclaration], - *, - title: str, - ) -> Self: - return cls( - document_id=declaration.require_value(), - widget_id=declaration.widget_id(), - title=title, - ) - - @property - def revision_key(self) -> str: - return f"ui-code-document:{self.document_id}" - - @property - def default_edit_label(self) -> str: - return f"edit {self.document_id} via MCP" - - -@dataclass(frozen=True, slots=True) -class UiStateSurfaceProviderIdentity(UiStateSurfaceIdentity, UiWidgetIdentity): - """Stable identity and derived names for one UI state surface.""" - - title: str - - @classmethod - def from_owner( - cls, - declaration: UiOwnedStateSurfaceDeclaration, - *, - widget_declaration: type[UiWidgetIdentityDeclaration], - ) -> Self: - """Project provider identity from the widget-owned declaration.""" - return cls( - surface_id=declaration.surface_id, - widget_id=widget_declaration.require_value(), - title=declaration.title, - ) - - @property - def revision_key(self) -> str: - return f"ui-state-surface:{self.surface_id}" - - -@dataclass(frozen=True, slots=True) -class UiActionProviderIdentity(UiActionIdentity): - """Stable identity and derived names for one UI action provider.""" - - title: str - - @classmethod - def from_widget_declaration( - cls, - declaration: type[UiWidgetIdentityDeclaration], - *, - title: str, - action_id: str | None = None, - ) -> Self: - return cls( - widget_id=declaration.require_value(), - action_id=( - declaration.action_provider_id() if action_id is None else action_id - ), - title=title, - ) - - @property - def revision_key(self) -> str: - return f"ui-action:{self.widget_id}:{self.action_id}" - - -@dataclass(frozen=True, slots=True) -class UiBridgeProviderIdentity: - """Stable identity shared by dynamic UI bridge provider catalogs.""" - - provider_id: str - title: str - - -@dataclass(frozen=True, slots=True) -class UiWindowProviderIdentity(UiBridgeProviderIdentity): - """Stable identity for one dynamic UI window catalog provider.""" - - @property - def revision_key(self) -> str: - return f"ui-window-provider:{self.provider_id}" - - -@dataclass(frozen=True, slots=True) -class UiObjectStateScopeProviderIdentity(UiBridgeProviderIdentity): - """Stable identity for one ObjectState scope catalog provider.""" - - @property - def revision_key(self) -> str: - return f"ui-object-state-provider:{self.provider_id}" - - -@dataclass(frozen=True, slots=True) -class UiBridgeGuardRule: - """One fail-loud precondition for a mutating bridge request.""" - - code: str - message: str - predicate: Callable[[], bool] - - def error_if_violated(self) -> AgentError | None: - if self.predicate(): - return AgentError(code=self.code, message=self.message) - return None - - -@dataclass(frozen=True, slots=True) -class UiBridgeGuardTemplate: - """Declared guard identity that binds to a runtime predicate.""" - - code: str - message: str - - def bind(self, predicate: Callable[[], bool]) -> UiBridgeGuardRule: - return UiBridgeGuardRule( - code=self.code, - message=self.message, - predicate=predicate, - ) - - -@dataclass(frozen=True, slots=True) -class UiBridgeGuardPolicy: - """Ordered guard authority for bridge mutation preconditions.""" - - rules: tuple[UiBridgeGuardRule, ...] - - def first_error(self) -> AgentError | None: - for rule in self.rules: - error = rule.error_if_violated() - if error is not None: - return error - return None - - -CONFIRMATION_REQUIRED_GUARD = UiBridgeGuardTemplate( - code="confirmation_required", - message="UI confirmation is required.", -) -RESTORE_TIME_TRAVEL_OPT_IN_GUARD = UiBridgeGuardTemplate( - code="time_travel_branch_switch_requires_confirmation", - message="Restoring while time-traveled requires allow_auto_branch=True.", -) -APPLY_TIME_TRAVEL_OPT_IN_GUARD = UiBridgeGuardTemplate( - code="stale_time_travel_state", - message="Applying while time-traveled requires apply_if_time_traveling=True.", -) -STALE_CODE_DOCUMENT_REVISION_ERROR = AgentError( - code="stale_revision_token", - message="The UI document changed after it was read.", - hint=( - "Read the document again with openhcs_ui_get_code_document and retry " - "with its current_revision_token." - ), -) diff --git a/openhcs/pyqt_gui/services/ui_bridge_live_overview.py b/openhcs/pyqt_gui/services/ui_bridge_live_overview.py index 99813cdcd1..98f5b17ec0 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_live_overview.py +++ b/openhcs/pyqt_gui/services/ui_bridge_live_overview.py @@ -1,45 +1,31 @@ -"""Live UI overview state-surface provider.""" +"""OpenHCS contributions to the UI live overview: viewer sessions and the overview surface.""" from __future__ import annotations -import hashlib -from dataclasses import replace - from zmqruntime.viewer_state import ViewerInstance, ViewerStateManager - -from openhcs.core.selection import AllItems -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiLiveOverviewItem, UiLiveOverviewMetric, UiLiveOverviewSection, UiLiveOverviewSeverity, - UiLiveOverviewState, - UiStateSurfaceDocument, - UiStateSurfaceRequest, - UiStateSurfaceSummary, -) -from python_introspect import to_jsonable -from openhcs.agent.ui_bridge_identities import ( - UiLiveOverviewStateSurfaceIdentityDeclaration, ) from openhcs.core.streaming_config_declarations import ViewerFamily -from objectstate.object_state import ObjectStateRegistry -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiBridgeSnapshotProviderABC, +from formbridge.bridge.contracts import ( UiLiveOverviewContributorABC, UiLiveOverviewContributorIdentity, +) +from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId +from formbridge.bridge.identities import UiLiveOverviewStateSurfaceIdentityDeclaration +from formbridge.bridge.contracts import ( UiOwnedStateSurfaceDeclaration, - UiStateSurfaceProviderABC, UiStateSurfaceProviderIdentity, state_surface_declaration_for_identity, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( +from formbridge.bridge.registry import ( UiBridgeProviderSetABC, UiBridgeRegistrationContext, - UiBridgeSurfaceRegistry, ) -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId +from formbridge.bridge.live_overview import UiLiveOverviewStateSurfaceProvider class ViewerSessionLiveOverviewContributor(UiLiveOverviewContributorABC): @@ -159,154 +145,3 @@ def register(self, context: UiBridgeRegistrationContext) -> None: identity=self._identity, ) ) - - -class UiLiveOverviewStateSurfaceProvider(UiStateSurfaceProviderABC): - """Pollable UI overview assembled from registered provider contributions.""" - - def __init__( - self, - *, - registry: UiBridgeSurfaceRegistry, - snapshot_provider: UiBridgeSnapshotProviderABC, - declaration: UiOwnedStateSurfaceDeclaration, - identity: UiStateSurfaceProviderIdentity, - ) -> None: - self._registry = registry - self._snapshot_provider = snapshot_provider - self._declaration = declaration - self.identity = identity - - def summary(self) -> UiStateSurfaceSummary: - return UiStateSurfaceSummary( - schema_version=SCHEMA_VERSION, - identity=self.identity.as_surface_identity(), - title=self.identity.title, - widget_id=self.identity.widget_id, - readable=True, - supported_selection_modes=(AllItems.name,), - current_selection_count=0, - total_scope_count=1, - ) - - def read(self, request: UiStateSurfaceRequest) -> UiStateSurfaceDocument: - selection_mode = request.resolved_selection_mode( - AllItems - ) - try: - state = UiLiveOverviewState( - schema_version=SCHEMA_VERSION, - summary=self.summary(), - object_state_token=ObjectStateRegistry.get_token(), - sections=self._sections(), - selected_scope_ids=(), - current_revision_token=self._snapshot_provider.revision_token( - self.identity.revision_key - ), - current_snapshot=self._snapshot_provider.current_snapshot(), - ) - except Exception as exc: - return self._state_error( - request, - (AgentError.from_exception("ui_state_surface_read_failed", exc),), - ) - revision_token = self._revision_token(state, selection_mode=selection_mode) - state = replace( - state, - current_revision_token=revision_token, - current_snapshot=self._snapshot_provider.current_snapshot(), - unchanged=request.base_revision_token == revision_token, - ) - return self._document_from_state(state, selection_mode=selection_mode) - - def _sections(self) -> tuple[UiLiveOverviewSection, ...]: - sections: list[UiLiveOverviewSection] = [] - for provider in self._registry.overview_contributors(): - try: - sections.extend(provider.overview_sections()) - except Exception as exc: - sections.append( - self._provider_error_section(provider.overview_identity, exc) - ) - return tuple(sections) - - @staticmethod - def _provider_error_section( - identity: UiLiveOverviewContributorIdentity, - exc: Exception, - ) -> UiLiveOverviewSection: - error = AgentError.from_exception("ui_live_overview_provider_failed", exc) - return UiLiveOverviewSection( - section_id=identity.section_id, - title=identity.title, - summary="overview unavailable", - items=( - UiLiveOverviewItem( - label=error.code, - status=error.exception_type, - detail=error.message, - severity=UiLiveOverviewSeverity.ERROR.value, - ), - ), - ) - - def _revision_token( - self, - state: UiLiveOverviewState, - *, - selection_mode: str, - ) -> str: - parts = ( - self.identity.revision_key, - str(state.object_state_token), - self._snapshot_provider.current_branch_head_snapshot_id(), - str(ObjectStateRegistry.get_current_snapshot_index()), - selection_mode, - state.sections, - ) - return hashlib.sha256(repr(parts).encode("utf-8")).hexdigest() - - def _state_error( - self, - request: UiStateSurfaceRequest, - errors: tuple[AgentError, ...], - ) -> UiStateSurfaceDocument: - selection_mode = request.resolved_selection_mode( - AllItems - ) - state = UiLiveOverviewState( - schema_version=SCHEMA_VERSION, - summary=self.summary(), - object_state_token=ObjectStateRegistry.get_token(), - sections=(), - selected_scope_ids=(), - current_revision_token=self._snapshot_provider.revision_token( - self.identity.revision_key - ), - current_snapshot=self._snapshot_provider.current_snapshot(), - errors=errors, - ) - return self._document_from_state(state, selection_mode=selection_mode) - - def _document_from_state( - self, - state: UiLiveOverviewState, - *, - selection_mode: str, - ) -> UiStateSurfaceDocument: - payload = to_jsonable(state) - if not isinstance(payload, dict): - raise TypeError("Live overview payload did not serialize to an object.") - return UiStateSurfaceDocument( - schema_version=state.schema_version, - summary=state.summary, - payload_schema=self._declaration.payload_schema, - payload=payload, - current_revision_token=state.current_revision_token, - current_snapshot=state.current_snapshot, - selection_mode=selection_mode, - selected_scope_ids=state.selected_scope_ids, - unchanged=state.unchanged, - warnings=state.warnings, - errors=state.errors, - ) diff --git a/openhcs/pyqt_gui/services/ui_bridge_object_state.py b/openhcs/pyqt_gui/services/ui_bridge_object_state.py deleted file mode 100644 index 178d982182..0000000000 --- a/openhcs/pyqt_gui/services/ui_bridge_object_state.py +++ /dev/null @@ -1,1163 +0,0 @@ -"""ObjectState scope projection provider for the PyQt UI bridge.""" - -from __future__ import annotations - -import hashlib -import logging -from collections.abc import Callable, Mapping, Sequence, Set -from dataclasses import dataclass -from dataclasses import is_dataclass -from enum import Enum -from typing import ClassVar - -from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService -from pyqt_reactive.services.function_pattern_code_document import ( - FunctionPatternScopeCodeDocumentDriver, -) -from pyqt_reactive.forms.parameter_form_service import ParameterFormService -from pyqt_reactive.services.window_code_document import ( - PYTHON_MIME_TYPE, -) -from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.services.window_navigation import WindowNavigationRequest -from python_introspect import ( - JsonValue, - UnifiedParameterAnalyzer, - project_dataclass, - to_jsonable, -) - -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( - AllFields, - UiCatalogPageMetadata, - UiCodeDocument, - UiCodeDocumentApplyRequest, - UiCodeDocumentApplyResult, - UiCodeDocumentRequest, - UiCodeDocumentSummary, - UiCodeDocumentValidationRequest, - UiCodeDocumentValidationResult, - UiMutationReceipt, - UiObjectStateFieldMutationRequest, - UiObjectStateFieldMutationResult, - UiObjectStateFieldProvenance, - UiObjectStateFieldSemanticCarrier, - UiObjectStateFieldSummary, - UiObjectStateScopeCatalog, - UiObjectStateScopeIdentity, - UiObjectStateScopeListRequest, - UiObjectStateScopeSummary, - UiObjectStateValuePreview, - UiSemanticAddress, -) -from objectstate import ObjectState, ObjectStateRegistry -from objectstate.inspection import FieldInspection, type_name, type_qualname -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - APPLY_TIME_TRAVEL_OPT_IN_GUARD, - CONFIRMATION_REQUIRED_GUARD, - STALE_CODE_DOCUMENT_REVISION_ERROR, - UiBridgeGuardPolicy, - UiBridgeSnapshotProviderABC, - UiCodeDocumentProviderIdentity, - UiObjectStateScopeProviderABC, - UiObjectStateScopeProviderIdentity, - SnapshotBackedUiCodeDocumentProviderABC, -) -from openhcs.pyqt_gui.services.ui_agent_bridge import UiCodeDocumentApplyLabel -from openhcs.pyqt_gui.services.ui_bridge_object_state_scope_policy import ( - ObjectStateScopeVisibility, -) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( - UiBridgeProviderSetABC, - UiBridgeRegistrationContext, -) -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId - -OBJECT_STATE_SCOPE_PROVIDER_ID = "object_state.scopes" -OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID = "object_state_scope" -OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX = f"{OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID}:" -WINDOW_CODE_DOCUMENT_ID = "window_code_document" -WINDOW_CODE_DOCUMENT_PREFIX = f"{WINDOW_CODE_DOCUMENT_ID}:" -OBJECT_STATE_FIELD_PAGE_LIMIT_MAX = 1000 -OBJECT_STATE_FIELD_VALUE_REPR_LIMIT = 500 -OBJECT_STATE_FIELD_VALUE_PREVIEW_LIMIT = 160 -OBJECT_STATE_FIELD_VALUE_PREVIEW_ITEMS = 4 - -logger = logging.getLogger(__name__) - - -def agent_object_state_scope_id(scope_id: str | None) -> str | None: - """Return the stable agent-facing id for an ObjectState registry scope.""" - - if scope_id is None: - return None - return OpenHCSUiWindowId.agent_window_id_for_manager_scope(scope_id) - - -@dataclass(frozen=True, slots=True) -class ObjectStateScopeCodeDocumentAddress: - """Nominal code-document address for one ObjectState scope.""" - - scope_id: str - - prefix: ClassVar[str] = OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX - minimum_scope_id_length: ClassVar[int] = 1 - - @classmethod - def parse(cls, document_id: str) -> "ObjectStateScopeCodeDocumentAddress": - if not document_id.startswith(cls.prefix): - raise ValueError( - "ObjectState scope code document ids must start with " - f"{cls.prefix!r}; got {document_id!r}." - ) - scope_id = document_id[len(cls.prefix) :] - if len(scope_id) < cls.minimum_scope_id_length: - raise ValueError( - "ObjectState scope code document id is missing a scope id." - ) - return cls(scope_id=scope_id) - - @property - def document_id(self) -> str: - return f"{self.prefix}{self.scope_id}" - - @property - def revision_key(self) -> str: - return f"ui-code-document:{self.document_id}" - - def provider_identity(self, title: str) -> UiCodeDocumentProviderIdentity: - return UiCodeDocumentProviderIdentity( - document_id=self.document_id, - widget_id=OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, - title=title, - ) - - -@dataclass(frozen=True, slots=True) -class WindowCodeDocumentAddress: - """Nominal code-document address for one open WindowManager code mode.""" - - window_id: str - - prefix: ClassVar[str] = WINDOW_CODE_DOCUMENT_PREFIX - - @classmethod - def parse(cls, document_id: str) -> "WindowCodeDocumentAddress": - if not document_id.startswith(cls.prefix): - raise ValueError( - "Window code-document ids must start with " - f"{cls.prefix!r}; got {document_id!r}." - ) - window_id = document_id[len(cls.prefix) :] - if not window_id: - raise ValueError("Window code-document id is missing a window id.") - return cls(window_id=window_id) - - @classmethod - def from_scope_id(cls, scope_id: str) -> "WindowCodeDocumentAddress": - return cls( - window_id=OpenHCSUiWindowId.agent_window_id_for_manager_scope(scope_id) - ) - - @property - def document_id(self) -> str: - return f"{self.prefix}{self.window_id}" - - @property - def revision_key(self) -> str: - return f"ui-code-document:{self.document_id}" - - def provider_identity(self, title: str) -> UiCodeDocumentProviderIdentity: - return UiCodeDocumentProviderIdentity( - document_id=self.document_id, - widget_id=self.window_id, - title=title, - ) - - -@dataclass(frozen=True, slots=True) -class ObjectStateFieldSemanticProjection(FieldInspection): - """One inspected ObjectState field projected onto the bridge's DTOs.""" - - @property - def raw_json_value(self) -> JsonValue: - return self.json_value(self.raw_value) - - @property - def resolved_json_value(self) -> JsonValue | None: - if not self.resolved_value_available: - return None - return self.json_value(self.resolved_value) - - @property - def raw_value_preview(self) -> UiObjectStateValuePreview: - return self.value_preview(self.raw_value) - - @property - def resolved_value_preview(self) -> UiObjectStateValuePreview | None: - if not self.resolved_value_available: - return None - return self.value_preview(self.resolved_value) - - def semantic_address(self, *, window_id: str | None = None) -> UiSemanticAddress: - return UiSemanticAddress( - object_state_scope_id=self.agent_scope_id, - field_path=self.field_path, - window_id=window_id, - ) - - @property - def agent_scope_id(self) -> str: - return agent_object_state_scope_id(self.state.scope_id) or self.state.scope_id - - def to_semantic_carrier( - self, - *, - include_values: bool = False, - ) -> UiObjectStateFieldSemanticCarrier: - """Project the shared DTO semantics owned by this live field.""" - - return UiObjectStateFieldSemanticCarrier( - dirty=self.dirty, - signature_diff=self.signature_diff, - last_changed=self.last_changed, - semantic_markers=self.semantic_markers, - raw_value=self.raw_json_value if include_values else None, - resolved_value=self.resolved_json_value if include_values else None, - raw_value_preview=self.raw_value_preview, - resolved_value_preview=self.resolved_value_preview, - raw_value_is_none=self.raw_value_is_none, - resolved_value_is_none=self.resolved_value_is_none, - inherited_value=self.inherited_value, - provenance=self.provenance_dto, - ) - - def to_field_summary( - self, - *, - include_values: bool = False, - include_description: bool = False, - ) -> UiObjectStateFieldSummary: - parameter_description = None - if include_description: - parameter_description = self.state.parameter_descriptions.get( - self.field_path - ) - return project_dataclass( - UiObjectStateFieldSummary, - self.to_semantic_carrier(include_values=include_values), - schema_version=SCHEMA_VERSION, - address=self.semantic_address( - window_id=OpenHCSUiWindowId.agent_window_id_for_manager_scope( - self.state.scope_id - ), - ), - field_name=self.field_name, - container_path=self.container_path, - object_state_path_type=self.declaring_type, - raw_value_type=self.raw_value_type, - resolved_value_type=self.resolved_value_type, - parameter_description=parameter_description, - ) - - @property - def provenance_dto(self) -> UiObjectStateFieldProvenance | None: - if self.provenance is None: - return None - return UiObjectStateFieldProvenance( - source_scope_id=agent_object_state_scope_id(self.provenance.source_scope_id), - source_type=type_qualname(self.provenance.source_type), - source_field_path=self.provenance.source_field_path, - ) - - @staticmethod - def json_value(value) -> JsonValue: - try: - return to_jsonable(value) - except TypeError: - value_repr = repr(value) - if len(value_repr) > OBJECT_STATE_FIELD_VALUE_REPR_LIMIT: - value_repr = ( - f"{value_repr[:OBJECT_STATE_FIELD_VALUE_REPR_LIMIT]}..." - ) - return value_repr - - @classmethod - def value_preview(cls, value) -> UiObjectStateValuePreview: - text, truncated = cls.preview_text(value) - return UiObjectStateValuePreview( - type_name=type_name(value), - is_none=value is None, - text=text, - truncated=truncated, - ) - - @classmethod - def preview_text(cls, value) -> tuple[str, bool]: - text = cls._preview_text_unbounded(value) - truncated = len(text) > OBJECT_STATE_FIELD_VALUE_PREVIEW_LIMIT - if truncated: - text = f"{text[:OBJECT_STATE_FIELD_VALUE_PREVIEW_LIMIT]}..." - return text, truncated - - @classmethod - def _preview_text_unbounded(cls, value) -> str: - if value is None: - return "None" - if callable(value): - return cls._callable_preview_text(value) - if isinstance(value, Enum): - return f"{type(value).__name__}.{value.name}" - if isinstance(value, str): - return repr(value) - if isinstance(value, bytes): - return f"bytes(len={len(value)})" - if isinstance(value, Mapping): - keys = tuple( - cls._preview_item_text(key) for key in cls._first_items(value.keys()) - ) - return f"{type(value).__name__}(len={len(value)}, keys={keys!r})" - if isinstance(value, (Sequence, Set)) and not isinstance( - value, - (str, bytes, bytearray), - ): - items = tuple( - cls._preview_item_text(item) for item in cls._first_items(value) - ) - return f"{type(value).__name__}(len={len(value)}, items={items!r})" - if is_dataclass(value) and not isinstance(value, type): - return repr(value) - return repr(value) - - @staticmethod - def _first_items(values) -> tuple[object, ...]: - items = [] - for item in values: - items.append(item) - if len(items) >= OBJECT_STATE_FIELD_VALUE_PREVIEW_ITEMS: - break - return tuple(items) - - @staticmethod - def _preview_item_text(value) -> str: - if callable(value): - return ObjectStateFieldSemanticProjection._callable_preview_text(value) - if isinstance(value, Enum): - return f"{type(value).__name__}.{value.name}" - return repr(value) - - @staticmethod - def _callable_preview_text(value: Callable[..., object]) -> str: - try: - payload = to_jsonable(value) - except TypeError: - return repr(value) - if not isinstance(payload, Mapping): - return repr(value) - import_path = payload.get("import_path") - if not isinstance(import_path, str): - return repr(value) - return import_path - - -class ObjectStateScopeProjectionService: - """Project ObjectState registry entries into bounded agent DTOs.""" - - def catalog( - self, - request: UiObjectStateScopeListRequest, - ) -> UiObjectStateScopeCatalog: - visibility = ObjectStateScopeVisibility(request) - scopes = tuple( - self.summary(state, request) - for state in ObjectStateRegistry.get_all() - if visibility.includes_scope_id(state.scope_id) - ) - return UiObjectStateScopeCatalog( - schema_version=SCHEMA_VERSION, - object_state_token=ObjectStateRegistry.get_token(), - current_branch=ObjectStateRegistry.get_current_branch(), - current_snapshot_index=ObjectStateRegistry.get_current_snapshot_index(), - active=ObjectStateRegistry.is_time_traveling(), - scopes=scopes, - ) - - def summary( - self, - state: ObjectState, - request: UiObjectStateScopeListRequest, - ) -> UiObjectStateScopeSummary: - fields, field_page = self._field_page(state, request) - return UiObjectStateScopeSummary( - schema_version=SCHEMA_VERSION, - identity=UiObjectStateScopeIdentity( - object_state_scope_id=agent_object_state_scope_id(state.scope_id) - or state.scope_id, - ), - object_type=type(state.object_instance).__name__, - parameter_count=len(state.parameters), - dirty_field_count=len(state.dirty_fields), - signature_diff_field_count=len(state.signature_diff_fields), - has_unsaved_changes=bool(state.dirty_fields), - has_default_overrides=bool(state.signature_diff_fields), - last_changed_field=state.last_changed_field, - fields=fields, - field_page=field_page, - ) - - def _field_page( - self, - state: ObjectState, - request: UiObjectStateScopeListRequest, - ) -> tuple[tuple[UiObjectStateFieldSummary, ...], UiCatalogPageMetadata | None]: - if not request.include_fields: - return (), None - - if request.field_paths: - all_field_paths = tuple( - field_path - for field_path in request.field_paths - if field_path in state.parameters - ) - else: - all_field_paths = tuple(sorted(state.parameters.keys())) - if request.field_filter is not AllFields: - all_field_paths = tuple( - field_path - for field_path in all_field_paths - if request.field_filter.matches( - self._field_summary( - state, - field_path, - include_values=False, - include_description=False, - ), - ) - ) - offset = max(0, request.field_offset) - limit = min(max(0, request.field_limit), OBJECT_STATE_FIELD_PAGE_LIMIT_MAX) - selected_paths = all_field_paths[offset : offset + limit] - next_offset = offset + len(selected_paths) - truncated = next_offset < len(all_field_paths) - next_page_offset = None - if truncated: - next_page_offset = next_offset - page = UiCatalogPageMetadata( - limit=limit, - offset=offset, - returned_count=len(selected_paths), - total_count=len(all_field_paths), - truncated=truncated, - next_offset=next_page_offset, - ) - fields = tuple( - self._field_summary( - state, - field_path, - include_values=request.include_field_values, - include_description=request.include_field_descriptions, - ) - for field_path in selected_paths - ) - return fields, page - - def _field_summary( - self, - state: ObjectState, - field_path: str, - *, - include_values: bool, - include_description: bool, - ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.of( - state, - field_path, - ).to_field_summary( - include_values=include_values, - include_description=include_description, - ) - - -def object_state_for_agent_scope_id(scope_id: str) -> ObjectState | None: - """The ObjectState an agent-facing scope or window id names.""" - for candidate_scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - scope_id - ): - state = ObjectStateRegistry.get_by_scope(candidate_scope_id) - if state is not None: - return state - return None - - -class ObjectStateFieldMutationService: - """Mutate one ObjectState field through ObjectState's own update/reset API.""" - - def __init__( - self, - before_mutation: Callable[[str], None] | None = None, - ) -> None: - self._before_mutation = before_mutation - - def mutate( - self, - request: UiObjectStateFieldMutationRequest, - ) -> UiObjectStateFieldMutationResult: - state = object_state_for_agent_scope_id( - request.object_state_scope_id - ) - if state is None: - return self._error_result( - request, - AgentError( - code="ui_object_state_scope_not_found", - message=( - "ObjectState scope is not registered in the running UI: " - f"{request.object_state_scope_id!r}" - ), - ), - ) - if request.field_path not in state.parameters: - return self._error_result( - request, - AgentError( - code="ui_object_state_field_not_found", - message=( - "ObjectState field is not registered on this scope: " - f"{request.field_path!r}" - ), - ), - ) - - before = self._field_summary(state, request) - try: - if self._before_mutation is not None: - self._before_mutation(request.object_state_scope_id) - if request.reset: - state.reset_parameter(request.field_path) - else: - state.update_parameter( - request.field_path, - self._converted_value(state, request), - ) - after = self._field_summary(state, request) - return UiObjectStateFieldMutationResult( - schema_version=SCHEMA_VERSION, - address=after.address, - mutated=True, - reset=request.reset, - receipt=UiMutationReceipt.accepted_for(request.request_token), - before=before, - after=after, - ) - except Exception as exc: - return self._error_result( - request, - AgentError.from_exception("ui_object_state_field_mutation_failed", exc), - before=before, - ) - - @staticmethod - def _field_summary( - state: ObjectState, - request: UiObjectStateFieldMutationRequest, - ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.of( - state, - request.field_path, - ).to_field_summary(include_values=request.include_field_values) - - @staticmethod - def _converted_value( - state: ObjectState, - request: UiObjectStateFieldMutationRequest, - ) -> object: - help_target = state.type_for_path(request.field_path) - parameter_name = request.field_path.rsplit(".", 1)[-1] - parameter_info = UnifiedParameterAnalyzer.analyze(help_target).get( - parameter_name - ) - if parameter_info is None: - return request.value - return ParameterFormService().convert_value_to_type( - request.value, - parameter_info.param_type, - parameter_name, - help_target, - ) - - @staticmethod - def _error_result( - request: UiObjectStateFieldMutationRequest, - error: AgentError, - *, - before: UiObjectStateFieldSummary | None = None, - ) -> UiObjectStateFieldMutationResult: - return UiObjectStateFieldMutationResult( - schema_version=SCHEMA_VERSION, - address=UiSemanticAddress( - object_state_scope_id=request.object_state_scope_id, - field_path=request.field_path, - window_id=request.window_id, - ), - mutated=False, - reset=request.reset, - receipt=UiMutationReceipt.rejected_for(request.request_token), - before=before, - errors=(error,), - ) - - -class ObjectStateScopeProvider(UiObjectStateScopeProviderABC): - """ObjectState scope catalog provider.""" - - identity = UiObjectStateScopeProviderIdentity( - provider_id=OBJECT_STATE_SCOPE_PROVIDER_ID, - title="ObjectState scopes", - ) - - def __init__(self) -> None: - self._projection = ObjectStateScopeProjectionService() - - def catalog( - self, - request: UiObjectStateScopeListRequest, - ) -> UiObjectStateScopeCatalog: - return self._projection.catalog(request) - - -class WindowCodeDocumentDriverBackedProvider(SnapshotBackedUiCodeDocumentProviderABC): - """Shared provider flow for WindowCodeDocumentDriver-backed documents.""" - - def __init__(self, snapshot_provider: UiBridgeSnapshotProviderABC) -> None: - self._snapshot_provider = snapshot_provider - - def read(self, request: UiCodeDocumentRequest) -> UiCodeDocument: - try: - address = self._address(request.document_id) - driver = self._driver(address) - document = driver.read_document(clean=request.clean) - source_bytes = document.source.encode("utf-8") - summary = self._summary_for_address(address, document.title) - return UiCodeDocument( - schema_version=SCHEMA_VERSION, - summary=summary, - source=document.source, - mime_type=document.mime_type, - size_bytes=len(source_bytes), - sha256=hashlib.sha256(source_bytes).hexdigest(), - current_revision_token=self._current_revision_token(address), - current_snapshot=self._snapshot_provider.current_snapshot(), - selection_mode=request.selection_mode, - selected_scope_ids=self._selected_scope_ids(address), - ) - except Exception as exc: - return self._document_error( - request, - AgentError.from_exception("ui_code_document_read_failed", exc), - ) - - def validate( - self, - request: UiCodeDocumentValidationRequest, - ) -> UiCodeDocumentValidationResult: - try: - address = self._address(request.document_id) - self._driver(address).validate_source(request.source) - return UiCodeDocumentValidationResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - valid=True, - normalized_scope_ids=self._selected_scope_ids(address), - ) - except Exception as exc: - return UiCodeDocumentValidationResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - valid=False, - errors=( - AgentError.from_exception( - "ui_code_document_validation_failed", exc - ), - ), - ) - - def apply(self, request: UiCodeDocumentApplyRequest) -> UiCodeDocumentApplyResult: - guard_error = self._apply_guard_policy(request).first_error() - if guard_error is not None: - return self._apply_error(request, guard_error) - - try: - address = self._address(request.document_id) - current_revision = self._current_revision_token(address) - if request.base_revision_token != current_revision: - return self._apply_error( - request, - STALE_CODE_DOCUMENT_REVISION_ERROR, - ) - - driver = self._driver(address) - if not driver.writable(): - return self._apply_error( - request, - AgentError( - code="ui_code_document_read_only", - message="The UI code document is currently read-only.", - ), - ) - current_document = driver.read_document(clean=True) - current_source = current_document.source - if driver.source_is_current( - request.source, - current_document=current_document, - ): - return self._apply_unchanged(request) - driver.validate_source(request.source) - ObjectStateRegistry.ensure_baseline_snapshot() - pre_snapshot = self._snapshot_provider.current_snapshot() - pre_head_id = self._snapshot_provider.current_branch_head_snapshot_id() - label = UiCodeDocumentApplyLabel.resolve( - request, - self._provider_identity_for_label(address), - ).value - with ObjectStateRegistry.atomic_success( - label, - self._mutation_scope_id(address), - ): - self._before_apply(address) - driver.apply_source(request.source) - - post_head_id = self._snapshot_provider.current_branch_head_snapshot_id() - if ( - driver.records_object_state_snapshot_on_apply() - and post_head_id == pre_head_id - ): - if driver.read_document(clean=True).source == current_source: - return self._apply_unchanged(request) - return self._apply_error( - request, - AgentError( - code="snapshot_not_recorded", - message=( - "Applying the UI document did not record a new " - "ObjectState snapshot." - ), - ), - ) - - post_snapshot = self._snapshot_provider.current_snapshot() - new_revision_token = self._current_revision_token(address) - return UiCodeDocumentApplyResult( - schema_version=SCHEMA_VERSION, - document_id=request.document_id, - applied=True, - base_revision_token=request.base_revision_token, - receipt=UiMutationReceipt.accepted_for(request.request_token), - outcome="applied", - new_revision_token=new_revision_token, - current_revision_token=new_revision_token, - current_snapshot=post_snapshot, - undo_snapshot=pre_snapshot, - pre_apply_snapshot=pre_snapshot, - post_apply_snapshot=post_snapshot, - ) - except Exception as exc: - logger.exception( - "Failed to apply UI code document %s", - request.document_id, - ) - return self._apply_error( - request, - AgentError.from_exception("ui_code_document_apply_failed", exc), - ) - - @staticmethod - def _apply_guard_policy( - request: UiCodeDocumentApplyRequest, - ) -> UiBridgeGuardPolicy: - return UiBridgeGuardPolicy( - rules=( - CONFIRMATION_REQUIRED_GUARD.bind( - lambda: request.confirmation_is_required(), - ), - APPLY_TIME_TRAVEL_OPT_IN_GUARD.bind( - lambda: ( - ObjectStateRegistry.is_time_traveling() - and not request.apply_if_time_traveling - ), - ), - ) - ) - - def _address(self, document_id: str): - raise NotImplementedError - - def _before_apply(self, address) -> None: - del address - - def _driver(self, address): - raise NotImplementedError - - def _summary_for_address(self, address, title: str) -> UiCodeDocumentSummary: - raise NotImplementedError - - def _selected_scope_ids(self, address) -> tuple[str, ...]: - raise NotImplementedError - - def _revision_key_for_address(self, address) -> str: - raise NotImplementedError - - def _provider_identity_for_label(self, address) -> UiCodeDocumentProviderIdentity: - raise NotImplementedError - - def _mutation_scope_id(self, address) -> str: - raise NotImplementedError - - def _current_revision_token_for_apply_result( - self, - request: UiCodeDocumentApplyRequest, - ) -> str | None: - try: - address = self._address(request.document_id) - return self._current_revision_token(address) - except Exception: - return super()._current_revision_token_for_apply_result(request) - - def _current_revision_token( - self, - address, - ) -> str: - return self._snapshot_provider.revision_token( - self._revision_key_for_address(address) - ) - - def _document_error( - self, - request: UiCodeDocumentRequest, - error: AgentError, - ) -> UiCodeDocument: - source_bytes = b"" - return UiCodeDocument( - schema_version=SCHEMA_VERSION, - summary=self.summary(), - source="", - mime_type=PYTHON_MIME_TYPE, - size_bytes=0, - sha256=hashlib.sha256(source_bytes).hexdigest(), - current_revision_token=self._snapshot_provider.revision_token( - self.identity.revision_key - ), - current_snapshot=self._snapshot_provider.current_snapshot(), - selection_mode=request.selection_mode, - selected_scope_ids=(), - errors=(error,), - ) - - -class ObjectStateScopeCodeDocumentProvider(WindowCodeDocumentDriverBackedProvider): - """Dynamic code-document provider for WindowManager-backed ObjectState scopes.""" - - identity = UiCodeDocumentProviderIdentity( - document_id=OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, - widget_id=OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, - title="ObjectState scope code documents", - ) - - def __init__( - self, - snapshot_provider: UiBridgeSnapshotProviderABC, - before_mutation: Callable[[str], None] | None = None, - ) -> None: - super().__init__(snapshot_provider) - self._before_mutation = before_mutation - - def _before_apply(self, address: ObjectStateScopeCodeDocumentAddress) -> None: - if self._before_mutation is not None: - self._before_mutation(self._scope_id(address)) - - def handles(self, document_id: str) -> bool: - return document_id == self.identity.document_id or document_id.startswith( - OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX - ) - - def summaries(self) -> tuple[UiCodeDocumentSummary, ...]: - summaries = tuple( - self._summary_for_scope_id(scope_id) - for scope_id in self._readable_scope_ids() - ) - if not summaries: - return (self.summary(),) - return summaries - - def revision_key_for_document_id(self, document_id: str) -> str: - return self._revision_key_for_address(self._address(document_id)) - - def summary(self) -> UiCodeDocumentSummary: - return UiCodeDocumentSummary( - schema_version=SCHEMA_VERSION, - identity=self.identity.as_document_identity(), - widget_id=self.identity.widget_id, - title=( - "ObjectState scope code documents " - f"({OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX})" - ), - readable=False, - writable=False, - total_scope_count=len(ObjectStateRegistry.get_all()), - ) - - def _readable_scope_ids(self) -> tuple[str, ...]: - return tuple( - agent_object_state_scope_id(state.scope_id) or state.scope_id - for state in ObjectStateRegistry.get_all() - if self._has_code_document_driver(state.scope_id) - ) - - def _has_code_document_driver(self, scope_id: str) -> bool: - if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): - return True - code_document_scopes = WindowManager.get_code_document_scopes() - return any( - candidate_scope_id in code_document_scopes - for candidate_scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - agent_object_state_scope_id(scope_id) or scope_id - ) - ) - - def _summary_for_scope_id(self, scope_id: str) -> UiCodeDocumentSummary: - address = ObjectStateScopeCodeDocumentAddress(scope_id=scope_id) - title = self._title_for_scope_id(scope_id) - return self._summary_for_address(address, title) - - def _title_for_scope_id(self, scope_id: str) -> str: - for candidate_scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - scope_id - ): - window = WindowManager.get_window(candidate_scope_id) - if window is not None: - return f"Code mode - {window.windowTitle()}" - return f"Code mode - {scope_id}" - - def _address(self, document_id: str) -> ObjectStateScopeCodeDocumentAddress: - return ObjectStateScopeCodeDocumentAddress.parse(document_id) - - def _driver(self, address: ObjectStateScopeCodeDocumentAddress): - scope_id = self._scope_id(address) - if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): - return FunctionPatternScopeCodeDocumentDriver(scope_id) - return WindowManager.require_code_document_driver(scope_id) - - def _scope_id(self, address: ObjectStateScopeCodeDocumentAddress) -> str: - for scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - address.scope_id - ): - if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): - return scope_id - try: - WindowManager.require_code_document_driver(scope_id) - return scope_id - except KeyError: - continue - for scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - address.scope_id - ): - result = ScopeWindowNavigationService.navigate( - WindowNavigationRequest( - scope_id=scope_id, - create_if_missing=True, - ) - ) - if result.window is not None: - return scope_id - raise KeyError( - f"ObjectState scope window is not available: {address.scope_id!r}" - ) - - def _summary_for_address( - self, - address: ObjectStateScopeCodeDocumentAddress, - title: str, - ) -> UiCodeDocumentSummary: - identity = address.provider_identity(title) - writable = self._driver(address).writable() - return UiCodeDocumentSummary( - schema_version=SCHEMA_VERSION, - identity=identity.as_document_identity(), - widget_id=identity.widget_id, - title=title, - readable=True, - writable=writable, - current_selection_count=1, - total_scope_count=len(ObjectStateRegistry.get_all()), - ) - - def _selected_scope_ids( - self, - address: ObjectStateScopeCodeDocumentAddress, - ) -> tuple[str, ...]: - return (address.scope_id,) - - def _revision_key_for_address( - self, - address: ObjectStateScopeCodeDocumentAddress, - ) -> str: - return address.revision_key - - def _provider_identity_for_label( - self, - address: ObjectStateScopeCodeDocumentAddress, - ) -> UiCodeDocumentProviderIdentity: - return address.provider_identity(self.identity.title) - - def _mutation_scope_id(self, address: ObjectStateScopeCodeDocumentAddress) -> str: - return self._scope_id(address) - - -class WindowManagerCodeDocumentProvider(WindowCodeDocumentDriverBackedProvider): - """Discover and route live WindowManager code-mode documents.""" - - identity = UiCodeDocumentProviderIdentity( - document_id=WINDOW_CODE_DOCUMENT_ID, - widget_id=WINDOW_CODE_DOCUMENT_ID, - title="Open window code-mode documents", - ) - - def handles(self, document_id: str) -> bool: - return document_id.startswith(WINDOW_CODE_DOCUMENT_PREFIX) - - def revision_key_for_document_id(self, document_id: str) -> str: - return self._revision_key_for_address(self._address(document_id)) - - def summary(self) -> UiCodeDocumentSummary: - return UiCodeDocumentSummary( - schema_version=SCHEMA_VERSION, - identity=self.identity.as_document_identity(), - widget_id=self.identity.widget_id, - title=( - "Open window code-mode documents " - f"({WINDOW_CODE_DOCUMENT_PREFIX})" - ), - readable=False, - writable=False, - total_scope_count=len(WindowManager.get_code_document_scopes()), - ) - - def summaries(self) -> tuple[UiCodeDocumentSummary, ...]: - scopes = WindowManager.get_code_document_scopes() - if not scopes: - return (self.summary(),) - return tuple(self._summary_for_open_scope(scope_id) for scope_id in scopes) - - def _summary_for_open_scope(self, scope_id: str) -> UiCodeDocumentSummary: - address = WindowCodeDocumentAddress.from_scope_id(scope_id) - window = WindowManager.get_window(scope_id) - title = self._open_scope_title(scope_id, address, window) - return self._summary_for_address(address, title) - - def _open_scope_title( - self, - scope_id: str, - address: WindowCodeDocumentAddress, - window, - ) -> str: - if window is not None and window.windowTitle(): - return f"Code mode - {window.windowTitle()}" - try: - document_title = ( - WindowManager.require_code_document_driver(scope_id) - .read_document(clean=True) - .title - ) - except Exception: - document_title = address.window_id - return f"Code mode - {document_title}" - - def _address(self, document_id: str) -> WindowCodeDocumentAddress: - return WindowCodeDocumentAddress.parse(document_id) - - def _driver(self, address: WindowCodeDocumentAddress): - return WindowManager.require_code_document_driver(self._scope_id(address)) - - def _scope_id(self, address: WindowCodeDocumentAddress) -> str: - for scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - address.window_id - ): - try: - WindowManager.require_code_document_driver(scope_id) - return scope_id - except KeyError: - continue - raise KeyError( - f"Open window has no code-document driver registered: {address.window_id!r}" - ) - - def _summary_for_address( - self, - address: WindowCodeDocumentAddress, - title: str, - ) -> UiCodeDocumentSummary: - identity = address.provider_identity(title) - writable = self._driver(address).writable() - return UiCodeDocumentSummary( - schema_version=SCHEMA_VERSION, - identity=identity.as_document_identity(), - widget_id=identity.widget_id, - title=title, - readable=True, - writable=writable, - current_selection_count=1, - total_scope_count=len(WindowManager.get_code_document_scopes()), - ) - - def _selected_scope_ids( - self, - address: WindowCodeDocumentAddress, - ) -> tuple[str, ...]: - return (self._scope_id(address),) - - def _revision_key_for_address(self, address: WindowCodeDocumentAddress) -> str: - return address.revision_key - - def _current_revision_token(self, address: WindowCodeDocumentAddress) -> str: - return self._driver(address).current_revision_token() - - def _provider_identity_for_label( - self, - address: WindowCodeDocumentAddress, - ) -> UiCodeDocumentProviderIdentity: - return address.provider_identity(self.identity.title) - - def _mutation_scope_id(self, address: WindowCodeDocumentAddress) -> str: - return self._scope_id(address) - - -class ObjectStateBridgeProviderSet(UiBridgeProviderSetABC): - """Provider set for ObjectState registry projections.""" - - registry_key = OBJECT_STATE_SCOPE_PROVIDER_ID - - def __init__( - self, - before_mutation: Callable[[str], None] | None = None, - ) -> None: - self._before_mutation = before_mutation - - @classmethod - def for_main_window(cls, main_window) -> "ObjectStateBridgeProviderSet": - return cls( - main_window.session.require_definition_mutation_allowed_for_object_scope - ) - - def register(self, context: UiBridgeRegistrationContext) -> None: - context.registry.register_object_state_scope_provider( - ObjectStateScopeProvider() - ) - context.registry.register_code_document_provider( - ObjectStateScopeCodeDocumentProvider( - context.snapshot_provider, - before_mutation=self._before_mutation, - ) - ) - context.registry.register_code_document_provider( - WindowManagerCodeDocumentProvider(context.snapshot_provider) - ) diff --git a/openhcs/pyqt_gui/services/ui_bridge_object_state_scope_policy.py b/openhcs/pyqt_gui/services/ui_bridge_object_state_scope_policy.py deleted file mode 100644 index 760a0a1e17..0000000000 --- a/openhcs/pyqt_gui/services/ui_bridge_object_state_scope_policy.py +++ /dev/null @@ -1,34 +0,0 @@ -"""ObjectState scope visibility policy shared by UI bridge projections.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import ClassVar - -from openhcs.agent.dto.ui_bridge import UiObjectStateScopeVisibility -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId - - -@dataclass(frozen=True, slots=True) -class ObjectStateScopeVisibility: - """Agent-facing visibility policy for ObjectState scope ids.""" - - hidden_unaliased_system_scopes: ClassVar[frozenset[str]] = frozenset( - ("__plates__",) - ) - - request: UiObjectStateScopeVisibility = field( - default_factory=UiObjectStateScopeVisibility - ) - - def includes_system_scopes(self) -> bool: - return self.request.include_system_scopes - - def includes_scope_id(self, scope_id: str) -> bool: - if self.includes_system_scopes() or self.has_agent_alias(scope_id): - return True - return scope_id not in self.hidden_unaliased_system_scopes - - @staticmethod - def has_agent_alias(scope_id: str) -> bool: - return OpenHCSUiWindowId.agent_window_id_for_manager_scope(scope_id) != scope_id diff --git a/openhcs/pyqt_gui/services/ui_bridge_pipeline_editor.py b/openhcs/pyqt_gui/services/ui_bridge_pipeline_editor.py index 9377036cf2..f3a2bd61df 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_pipeline_editor.py +++ b/openhcs/pyqt_gui/services/ui_bridge_pipeline_editor.py @@ -5,38 +5,41 @@ import hashlib from dataclasses import replace -from openhcs.core.selection import AllItems, SelectionScope -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( +from formbridge.selection import AllItems, SelectionScope +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.bridge.dto import ( UiActionIdentity, UiActionInvokeRequest, UiActionSummary, - UiDebugActionState, - UiDebugCursorState, - UiDebugRuntimeFrameState, - UiDebugTerminalSummaryState, UiLiveOverviewItem, UiLiveOverviewMetric, UiLiveOverviewSection, UiLiveOverviewSeverity, - UiPipelineDebugSessionState, - UiPipelineEditorState, - UiProgressIdentityState, UiStateSurfaceDocument, UiStateSurfaceRequest, UiStateSurfaceSummary, ) +from openhcs.agent.dto.ui_bridge import ( + UiDebugActionState, + UiDebugCursorState, + UiDebugRuntimeFrameState, + UiDebugTerminalSummaryState, + UiPipelineDebugSessionState, + UiPipelineEditorState, + UiProgressIdentityState, +) from openhcs.agent.ui_bridge_identities import ( PipelineDebugSessionStateSurfaceIdentityDeclaration, PipelineDebugToolbarWidgetIdentity, PipelineEditorStateSurfaceIdentityDeclaration, PipelineEditorWidgetIdentity, - PlateManagerStateSurfaceIdentityDeclaration, + DatasetListStateSurfaceIdentity, ) from python_introspect import to_jsonable from objectstate.object_state import ObjectStateRegistry from openhcs.core.progress.debug_projection import DebugRuntimeFrame -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( +from formbridge.bridge.contracts import ( UiActionDispatch, UiActionProviderABC, UiActionProviderIdentity, @@ -46,10 +49,7 @@ state_surface_declaration_for_identity, state_surface_ids_for_action, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( - UiBridgeProviderSetABC, - UiBridgeRegistrationContext, -) +from formbridge.bridge.registry import UiBridgeProviderSetABC, UiBridgeRegistrationContext from openhcs.authoring.session.views import PipelineStepsView from openhcs.pyqt_gui.services.ui_bridge_session_actions import ( SessionOperationActionProvider, @@ -64,7 +64,7 @@ PIPELINE_EDITOR_ACTIONS_TITLE = "Pipeline editor actions" PIPELINE_DEBUG_TOOLBAR_ACTIONS_TITLE = "Pipeline debug toolbar actions" PLATE_MANAGER_STATE_SURFACE_ID = ( - PlateManagerStateSurfaceIdentityDeclaration.require_value() + DatasetListStateSurfaceIdentity.require_value() ) PIPELINE_EDITOR_WIDGET_ID = PipelineEditorWidgetIdentity.require_value() PIPELINE_DEBUG_TOOLBAR_WIDGET_ID = PipelineDebugToolbarWidgetIdentity.require_value() diff --git a/openhcs/pyqt_gui/services/ui_bridge_plate_manager.py b/openhcs/pyqt_gui/services/ui_bridge_plate_manager.py index 54a7b937ca..d61aa85702 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_plate_manager.py +++ b/openhcs/pyqt_gui/services/ui_bridge_plate_manager.py @@ -5,9 +5,10 @@ import hashlib from dataclasses import replace -from openhcs.core.selection import AllItems, SelectedItems, SelectionScope -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( +from formbridge.selection import AllItems, SelectedItems, SelectionScope +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.bridge.dto import ( UiCodeDocument, UiCodeDocumentApplyRequest, UiCodeDocumentApplyResult, @@ -15,9 +16,6 @@ UiCodeDocumentSummary, UiCodeDocumentValidationRequest, UiCodeDocumentValidationResult, - UiLiveMeasurementEntryState, - UiLiveMeasurementsState, - UiPlateManagerState, UiLiveOverviewItem, UiLiveOverviewMetric, UiLiveOverviewSection, @@ -27,11 +25,16 @@ UiStateSurfaceSummary, UiMutationReceipt, ) +from openhcs.agent.dto.ui_bridge import ( + UiLiveMeasurementEntryState, + UiLiveMeasurementsState, + UiPlateManagerState, +) from openhcs.agent.ui_bridge_identities import ( - PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, + DatasetLiveMeasurementsStateSurfaceIdentity, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from python_introspect import to_jsonable from objectstate.object_state import ObjectStateRegistry @@ -42,13 +45,12 @@ ) from openhcs.authoring.session.views import DatasetListView from pyqt_reactive.services.scope_color_service import ScopeColorService +from formbridge.bridge.in_process import UiCodeDocumentApplyLabel, UiCodeDocumentValidationError from openhcs.pyqt_gui.services.ui_agent_bridge import ( - UiCodeDocumentApplyLabel, UiCodeDocumentExecutionService, UiCodeDocumentSourcePolicy, - UiCodeDocumentValidationError, ) -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( +from formbridge.bridge.contracts import ( APPLY_TIME_TRAVEL_OPT_IN_GUARD, CONFIRMATION_REQUIRED_GUARD, STALE_CODE_DOCUMENT_REVISION_ERROR, @@ -63,10 +65,7 @@ state_surface_declaration_for_identity, state_surface_ids_for_action, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( - UiBridgeProviderSetABC, - UiBridgeRegistrationContext, -) +from formbridge.bridge.registry import UiBridgeProviderSetABC, UiBridgeRegistrationContext from openhcs.pyqt_gui.services.ui_bridge_session_actions import ( SessionOperationActionProvider, ) @@ -80,13 +79,13 @@ PYTHON_MIME_TYPE = "text/x-python" PLATE_MANAGER_ORCHESTRATOR_IDENTITY = UiCodeDocumentProviderIdentity.from_declaration( - PlateManagerOrchestratorCodeDocumentIdentity, + DatasetListCodeDocumentIdentity, title=ORCHESTRATOR_DOCUMENT_TITLE, ) PLATE_MANAGER_STATE_DECLARATION: UiOwnedStateSurfaceDeclaration = ( state_surface_declaration_for_identity( PlateManagerWidget.UI_STATE_SURFACE_DECLARATIONS, - PlateManagerStateSurfaceIdentityDeclaration, + DatasetListStateSurfaceIdentity, ) ) PLATE_MANAGER_STATE_IDENTITY = UiStateSurfaceProviderIdentity.from_owner( @@ -96,7 +95,7 @@ LIVE_MEASUREMENTS_STATE_DECLARATION: UiOwnedStateSurfaceDeclaration = ( state_surface_declaration_for_identity( PlateManagerWidget.UI_STATE_SURFACE_DECLARATIONS, - PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, + DatasetLiveMeasurementsStateSurfaceIdentity, ) ) LIVE_MEASUREMENTS_STATE_IDENTITY = UiStateSurfaceProviderIdentity.from_owner( @@ -105,20 +104,20 @@ ) PLATE_MANAGER_ACTION_PROVIDER_IDENTITY = ( UiActionProviderIdentity.from_widget_declaration( - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, title=PLATE_MANAGER_ACTIONS_TITLE, ) ) PLATE_MANAGER_STATE_SURFACE_ID = PLATE_MANAGER_STATE_DECLARATION.surface_id PLATE_MANAGER_ACTION_STATE_SURFACES = (PLATE_MANAGER_STATE_SURFACE_ID,) PLATE_MANAGER_CODE_DOCUMENT_ID = ( - PlateManagerOrchestratorCodeDocumentIdentity.require_value() + DatasetListCodeDocumentIdentity.require_value() ) class PlateManagerBridgeProviderSet(UiBridgeProviderSetABC): """Register all PlateManager surfaces with a UI bridge registry.""" - registry_key = PlateManagerWidgetIdentity.require_value() + registry_key = DatasetListWidgetIdentity.require_value() def __init__(self, manager, *, execution_service=None) -> None: self._manager = manager @@ -529,7 +528,7 @@ def _overview_row_item(cls, row: DatasetRowState) -> UiLiveOverviewItem: detail=cls._overview_row_detail(row), severity=cls._overview_row_severity(row).value, source_surface_id=PLATE_MANAGER_STATE_SURFACE_ID, - source_widget_id=PlateManagerWidgetIdentity.require_value(), + source_widget_id=DatasetListWidgetIdentity.require_value(), ) @staticmethod diff --git a/openhcs/pyqt_gui/services/ui_bridge_registry.py b/openhcs/pyqt_gui/services/ui_bridge_registry.py deleted file mode 100644 index 8f4feca7bb..0000000000 --- a/openhcs/pyqt_gui/services/ui_bridge_registry.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Provider registry for the PyQt UI bridge surface.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from enum import Enum -from typing import ClassVar, TypeAlias, TypeVar - -from metaclass_registry import AutoRegisterMeta, LazyDiscoveryDict, RegistryConfig -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiBridgeRegistryKeyMixin, - UiLiveOverviewContributorABC, - UiBridgeSnapshotProviderABC, - UiActionProviderABC, - UiCodeDocumentProviderABC, - UiObjectStateScopeProviderABC, - UiStateSurfaceProviderABC, - UiWindowProviderABC, -) - -UiBridgeProvider: TypeAlias = ( - UiCodeDocumentProviderABC - | UiStateSurfaceProviderABC - | UiActionProviderABC - | UiWindowProviderABC - | UiObjectStateScopeProviderABC -) -DynamicProviderT = TypeVar( - "DynamicProviderT", - UiCodeDocumentProviderABC, - UiWindowProviderABC, -) - - -class UiRegisteredSurfaceKind(str, Enum): - """Kinds of UI surfaces currently exposed through the bridge registry.""" - - CODE_DOCUMENT = "code_document" - STATE_SURFACE = "state_surface" - ACTION = "action" - WINDOW = "window" - OBJECT_STATE_SCOPE = "object_state_scope" - - -@dataclass(frozen=True, slots=True) -class UiBridgeRegistrationContext: - """Context passed to provider sets during bridge composition.""" - - registry: "UiBridgeSurfaceRegistry" - snapshot_provider: UiBridgeSnapshotProviderABC - - -_UI_BRIDGE_PROVIDER_SET_REGISTRY = LazyDiscoveryDict(enable_cache=False) - - -class UiBridgeProviderSetABC( - UiBridgeRegistryKeyMixin, - ABC, - metaclass=AutoRegisterMeta, - registry_config=RegistryConfig( - registry_dict=_UI_BRIDGE_PROVIDER_SET_REGISTRY, - key_attribute="registry_key", - skip_if_no_key=True, - registry_name="PyQt UI bridge provider set", - discovery_package="openhcs.pyqt_gui.services", - ), -): - """Composable collection of providers for one widget/domain.""" - - __registry__ = _UI_BRIDGE_PROVIDER_SET_REGISTRY - compose_for_main_window: ClassVar[bool] = True - - @classmethod - @abstractmethod - def for_main_window(cls, main_window) -> "UiBridgeProviderSetABC": - """Construct this declaration's provider set for the owning main window.""" - raise NotImplementedError - - @abstractmethod - def register(self, context: UiBridgeRegistrationContext) -> None: - raise NotImplementedError - - -class CompositeUiBridgeProviderSet(UiBridgeProviderSetABC): - """Register several provider sets through one composition stage.""" - - registry_key = "composite" - compose_for_main_window = False - - def __init__(self, provider_sets: tuple[UiBridgeProviderSetABC, ...]) -> None: - self._provider_sets = provider_sets - - @classmethod - def for_main_window(cls, main_window) -> "UiBridgeProviderSetABC": - del main_window - raise TypeError( - "Composite provider sets are assembled by the composition root." - ) - - def register(self, context: UiBridgeRegistrationContext) -> None: - for provider_set in self._provider_sets: - provider_set.register(context) - - -class UiBridgeSurfaceRegistry: - """Nominal registry for UI bridge provider discovery and routing.""" - - def __init__(self) -> None: - self._code_document_providers: dict[str, UiCodeDocumentProviderABC] = {} - self._state_surface_providers: dict[str, UiStateSurfaceProviderABC] = {} - self._action_providers: dict[str, UiActionProviderABC] = {} - self._window_providers: dict[str, UiWindowProviderABC] = {} - self._object_state_scope_providers: dict[str, UiObjectStateScopeProviderABC] = ( - {} - ) - self._live_overview_contributors: list[UiLiveOverviewContributorABC] = [] - - def register_code_document_provider( - self, - provider: UiCodeDocumentProviderABC, - ) -> None: - document_id = provider.identity.document_id - self._register_unique( - self._code_document_providers, - document_id, - provider, - UiRegisteredSurfaceKind.CODE_DOCUMENT, - ) - - def register_state_surface_provider( - self, - provider: UiStateSurfaceProviderABC, - ) -> None: - surface_id = provider.identity.surface_id - self._register_unique( - self._state_surface_providers, - surface_id, - provider, - UiRegisteredSurfaceKind.STATE_SURFACE, - ) - - def register_action_provider( - self, - provider: UiActionProviderABC, - ) -> None: - provider_id = provider.identity.widget_id - self._register_unique( - self._action_providers, - provider_id, - provider, - UiRegisteredSurfaceKind.ACTION, - ) - - def register_window_provider( - self, - provider: UiWindowProviderABC, - ) -> None: - provider_id = provider.identity.provider_id - self._register_unique( - self._window_providers, - provider_id, - provider, - UiRegisteredSurfaceKind.WINDOW, - ) - - def register_object_state_scope_provider( - self, - provider: UiObjectStateScopeProviderABC, - ) -> None: - provider_id = provider.identity.provider_id - self._register_unique( - self._object_state_scope_providers, - provider_id, - provider, - UiRegisteredSurfaceKind.OBJECT_STATE_SCOPE, - ) - - def register_live_overview_contributor( - self, - contributor: UiLiveOverviewContributorABC, - ) -> None: - self._live_overview_contributors.append(contributor) - - def code_document_provider(self, document_id: str) -> UiCodeDocumentProviderABC: - if document_id in self._code_document_providers: - return self._code_document_providers[document_id] - return self._dynamic_provider( - self._code_document_providers, - document_id, - unknown_message=f"Unknown UI code document: {document_id}", - ) - - def state_surface_provider(self, surface_id: str) -> UiStateSurfaceProviderABC: - try: - return self._state_surface_providers[surface_id] - except KeyError as exc: - raise KeyError(f"Unknown UI state surface: {surface_id}") from exc - - def action_provider(self, widget_id: str) -> UiActionProviderABC: - try: - return self._action_providers[widget_id] - except KeyError as exc: - raise KeyError(f"Unknown UI action provider: {widget_id}") from exc - - def window_provider(self, window_id: str) -> UiWindowProviderABC: - return self._dynamic_provider( - self._window_providers, - window_id, - unknown_message=f"Unknown UI window: {window_id}", - ) - - def code_document_providers(self) -> tuple[UiCodeDocumentProviderABC, ...]: - return tuple(self._code_document_providers.values()) - - def state_surface_providers(self) -> tuple[UiStateSurfaceProviderABC, ...]: - return tuple(self._state_surface_providers.values()) - - def action_providers(self) -> tuple[UiActionProviderABC, ...]: - return tuple(self._action_providers.values()) - - def window_providers(self) -> tuple[UiWindowProviderABC, ...]: - return tuple(self._window_providers.values()) - - def object_state_scope_providers( - self, - ) -> tuple[UiObjectStateScopeProviderABC, ...]: - return tuple(self._object_state_scope_providers.values()) - - def overview_contributors(self) -> tuple[UiLiveOverviewContributorABC, ...]: - return ( - tuple(self._state_surface_providers.values()) - + tuple(self._window_providers.values()) - + tuple(self._live_overview_contributors) - ) - - @staticmethod - def _dynamic_provider( - providers: dict[str, DynamicProviderT], - surface_id: str, - *, - unknown_message: str, - ) -> DynamicProviderT: - for provider in providers.values(): - if provider.handles(surface_id): - return provider - raise KeyError(unknown_message) - - @staticmethod - def _register_unique( - providers: dict[ - str, - UiBridgeProvider, - ], - provider_id: str, - provider: UiBridgeProvider, - surface_kind: UiRegisteredSurfaceKind, - ) -> None: - if provider_id in providers: - raise ValueError( - f"Duplicate UI bridge {surface_kind.value} provider id: {provider_id}" - ) - providers[provider_id] = provider diff --git a/openhcs/pyqt_gui/services/ui_bridge_server.py b/openhcs/pyqt_gui/services/ui_bridge_server.py deleted file mode 100644 index b57e350fdd..0000000000 --- a/openhcs/pyqt_gui/services/ui_bridge_server.py +++ /dev/null @@ -1,623 +0,0 @@ -"""Process boundary server for the OpenHCS UI agent bridge.""" - -from __future__ import annotations - -import json -import os -import pickle -import threading -import time -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import ClassVar - -from metaclass_registry import AutoRegisterMeta -from python_introspect import ( - JsonObject, - dataclass_from_mapping, - project_dataclass, - to_jsonable, -) -from zmqruntime.messages import ( - ControlErrorResponse, - ControlMessageType, - ControlRequestHeader, - PongResponse, - ServerRole, -) -from zmqruntime.timeouts import OperationDeadline -from zmqruntime.transport import ( - TransportEndpoint, - endpoint_startup_lock, - resolve_transport_mode, -) - -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError -from openhcs.agent.dto.execution import ExecutionConnectionSpec -from openhcs.agent.dto.ui_bridge import ( - UiBridgeConnectionSpec, - UiBridgeDescriptorFile, - UiBridgeDescriptorWirePayload, - UiBridgeRequestEnvelope, - UiBridgeResponseEnvelope, -) -from openhcs.agent.runtime_platform import AgentRuntimePlatform -from openhcs.agent.services.ui_bridge_service import ( - UI_BRIDGE_PROTOCOL_VERSION, - UiBridgeOperation, -) -from openhcs.pyqt_gui.config import AgentUiBridgeConfig -from openhcs.pyqt_gui.services.ui_agent_bridge import UiAgentBridgeService -from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION -from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig - -DEFAULT_UI_BRIDGE_START_TIMEOUT_SECONDS = 5.0 -UI_BRIDGE_BROWSER_SERVER_NAME = "OpenHCSUiBridgeServer" - - -@dataclass(frozen=True, slots=True) -class UiBridgeServerBinding(UiBridgeConnectionSpec): - def __post_init__(self) -> None: - missing_fields = tuple( - field_name - for field_name, value in ( - ("port", self.port), - ("descriptor_file_path", self.descriptor_file_path), - ("bridge_instance_id", self.bridge_instance_id), - ("auth_token", self.auth_token), - ) - if value is None - ) - if missing_fields: - raise ValueError( - "UI bridge binding requires resolved fields: " - + ", ".join(missing_fields) - ) - - @classmethod - def from_runtime( - cls, - *, - connection: ExecutionConnectionSpec, - bridge_instance_id: str, - descriptor_file_path: Path, - auth_token: str, - ) -> "UiBridgeServerBinding": - if connection.port is None: - raise ValueError("UI bridge binding requires a resolved data port.") - return project_dataclass( - cls, - connection, - auth_token=auth_token, - descriptor_file_path=str(descriptor_file_path), - bridge_instance_id=bridge_instance_id, - ) - - @property - def connection(self) -> ExecutionConnectionSpec: - return self.public_connection() - - def token_bearing_connection(self) -> UiBridgeConnectionSpec: - return self - - -@dataclass(slots=True) -class UiBridgeUnsupportedOperationError(LookupError): - """Raised when an agent requests an operation this bridge does not expose.""" - - operation_name: str - - def __str__(self) -> str: - return f"Unsupported UI bridge operation: {self.operation_name}" - - -@dataclass(slots=True) -class UiBridgeEndpointInUseError(RuntimeError): - """Raised before a UI bridge can replace another live endpoint pair.""" - - endpoint: TransportEndpoint - occupied_ports: frozenset[int] - - def __str__(self) -> str: - ports = ", ".join(str(port) for port in sorted(self.occupied_ports)) - return ( - f"UI bridge endpoint {self.endpoint.transport_mode.value}://" - f"{self.endpoint.host}:{self.endpoint.port} is already in use " - f"(occupied ports: {ports})." - ) - - -class UiBridgeRequestExceptionClassifier(metaclass=AutoRegisterMeta): - """Registered classifier for request-dispatch exception codes.""" - - __registry_key__ = "exception_key" - __skip_if_no_key__ = True - - exception_type: ClassVar[type[BaseException] | None] = None - exception_key: ClassVar[str | None] = None - error_code: ClassVar[str] - - @classmethod - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if cls.exception_type is not None and cls.exception_key is None: - cls.exception_key = cls.exception_type_key(cls.exception_type) - - @staticmethod - def exception_type_key(exception_type: type[BaseException]) -> str: - return f"{exception_type.__module__}.{exception_type.__qualname__}" - - @classmethod - def classifier_for_exception( - cls, - exception: Exception, - ) -> type["UiBridgeRequestExceptionClassifier"] | None: - for exception_type in type(exception).__mro__: - classifier = cls.__registry__.get(cls.exception_type_key(exception_type)) - if classifier is not None: - return classifier - return None - - -class UiBridgePermissionErrorClassifier(UiBridgeRequestExceptionClassifier): - exception_type = PermissionError - error_code = "ui_bridge_auth_failed" - - -class UiBridgeUnsupportedOperationErrorClassifier(UiBridgeRequestExceptionClassifier): - exception_type = UiBridgeUnsupportedOperationError - error_code = "unsupported_ui_bridge_operation" - - -class UiBridgeValueErrorClassifier(UiBridgeRequestExceptionClassifier): - exception_type = ValueError - error_code = "invalid_ui_bridge_request" - - -class UiBridgeRequestErrorAuthority: - """Classify request-dispatch exceptions into agent-facing error codes.""" - - DEFAULT_ERROR_CODE = "ui_bridge_request_failed" - - @classmethod - def agent_error(cls, exception: Exception) -> AgentError: - classifier = UiBridgeRequestExceptionClassifier.classifier_for_exception( - exception - ) - if classifier is not None: - return AgentError.from_exception(classifier.error_code, exception) - return AgentError.from_exception(cls.DEFAULT_ERROR_CODE, exception) - - -class UiBridgeRequestDispatcher: - """Route validated UI bridge envelopes to the in-process bridge service.""" - - def __init__( - self, - bridge: UiAgentBridgeService, - *, - auth_token: str, - binding_supplier: Callable[[], UiBridgeServerBinding], - ) -> None: - self._bridge = bridge - self._auth_token = auth_token - self._binding_supplier = binding_supplier - - @property - def bridge(self) -> UiAgentBridgeService: - return self._bridge - - def dispatch(self, payload: JsonObject) -> JsonObject: - try: - request = dataclass_from_mapping( - UiBridgeRequestEnvelope, - payload, - ) - self._validate_request_contract(request) - operation = self._operation(request.operation) - self._validate_auth(request, operation) - result = operation.as_served( - self._bridge.invoke( - operation, operation.decode_request(request.payload) - ), - self._binding_supplier(), - ) - response = UiBridgeResponseEnvelope( - schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, - application=OPENHCS_ENDPOINT_APPLICATION, - request_id=request.request_id, - ok=True, - payload=self._result_payload(result), - ) - except Exception as exc: - response = self._error_response(payload, exc) - return self._response_payload(response) - - def _validate_request_contract(self, request: UiBridgeRequestEnvelope) -> None: - if request.schema_version != SCHEMA_VERSION: - raise ValueError( - f"Unsupported agent schema version: {request.schema_version}" - ) - if request.bridge_protocol_version != UI_BRIDGE_PROTOCOL_VERSION: - raise ValueError( - f"Unsupported UI bridge protocol version: {request.bridge_protocol_version}" - ) - OPENHCS_ENDPOINT_APPLICATION.compatibility_with( - request.application - ).require_match() - - @staticmethod - def _operation(operation_name: str) -> type[UiBridgeOperation]: - try: - return UiBridgeOperation.for_name(operation_name) - except KeyError as exc: - raise UiBridgeUnsupportedOperationError(operation_name) from exc - - def _validate_auth( - self, - request: UiBridgeRequestEnvelope, - operation: type[UiBridgeOperation], - ) -> None: - if operation.requires_auth and request.auth_token != self._auth_token: - raise PermissionError("UI bridge auth token is missing or invalid.") - - @staticmethod - def _result_payload(result) -> JsonObject: - payload = to_jsonable(result) - if not isinstance(payload, dict): - raise TypeError( - f"UI bridge operation result must serialize to a JSON object, " - f"got {type(payload).__name__}" - ) - return payload - - @staticmethod - def _response_payload(response: UiBridgeResponseEnvelope) -> JsonObject: - payload = to_jsonable(response) - if not isinstance(payload, dict): - raise TypeError( - "UI bridge response envelope did not serialize to a JSON object." - ) - return payload - - def _error_response( - self, - payload: JsonObject, - exception: Exception, - ) -> UiBridgeResponseEnvelope: - return UiBridgeResponseEnvelope( - schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, - application=OPENHCS_ENDPOINT_APPLICATION, - request_id=self._request_id_from_payload(payload), - ok=False, - payload={}, - errors=(self._agent_error(exception),), - ) - - @staticmethod - def _request_id_from_payload(payload: JsonObject) -> str: - request_id = payload.get("request_id") - if isinstance(request_id, str) and request_id: - return request_id - return "unknown" - - @staticmethod - def _agent_error(exception: Exception) -> AgentError: - return UiBridgeRequestErrorAuthority.agent_error(exception) - - -class UiBridgeControlServer: - """Background ZMQ REP server exposing one UiAgentBridgeService.""" - - def __init__( - self, - bridge: UiAgentBridgeService, - config: AgentUiBridgeConfig | None = None, - transport_config: OpenHCSZMQConfig = OPENHCS_ZMQ_CONFIG, - ) -> None: - self._bridge = bridge - self._config = config or AgentUiBridgeConfig() - self._transport_config = transport_config - self._bridge_instance_id = self._config.resolve_bridge_instance_id() - self._auth_token = self._config.resolve_auth_token() - self._stop_event = threading.Event() - self._ready_event = threading.Event() - self._thread: threading.Thread | None = None - self._binding: UiBridgeServerBinding | None = None - self._startup_error: Exception | None = None - - @property - def binding(self) -> UiBridgeServerBinding: - if self._binding is None: - raise RuntimeError("UI bridge server is not running.") - return self._binding - - @property - def config(self) -> AgentUiBridgeConfig: - """Return the exact immutable configuration owned by this server.""" - - return self._config - - @property - def transport_config(self) -> OpenHCSZMQConfig: - """Return the exact immutable transport configuration owned by this server.""" - - return self._transport_config - - @property - def is_running(self) -> bool: - thread = self._thread - return ( - not self._stop_event.is_set() - and thread is not None - and thread.is_alive() - and self._binding is not None - ) - - def matches_configuration( - self, - config: AgentUiBridgeConfig, - transport_config: OpenHCSZMQConfig, - ) -> bool: - """Return whether this live server owns the exact declarations.""" - - return ( - self.is_running - and self._config == config - and self._transport_config == transport_config - ) - - def start( - self, - *, - timeout_seconds: float = DEFAULT_UI_BRIDGE_START_TIMEOUT_SECONDS, - ) -> UiBridgeServerBinding: - if self.is_running: - return self.binding - if self._stop_event.is_set(): - raise RuntimeError("A stopped UI bridge server cannot be restarted.") - deadline = OperationDeadline.after_milliseconds( - max(1, int(timeout_seconds * 1000)), - operation="UI bridge endpoint startup", - ) - endpoint = self._requested_endpoint() - if endpoint is None: - return self._start_before_deadline(deadline) - with endpoint_startup_lock( - endpoint.port, - endpoint.transport_mode, - self._transport_config, - operation_deadline=deadline, - ) as acquired: - if not acquired: - raise TimeoutError("UI bridge endpoint startup was cancelled.") - self._require_available_endpoint(endpoint) - return self._start_before_deadline(deadline) - - def _start_before_deadline( - self, - deadline: OperationDeadline, - ) -> UiBridgeServerBinding: - self._ready_event.clear() - self._startup_error = None - self._thread = threading.Thread( - target=self._serve, - name=f"OpenHCSUiBridge-{self._bridge_instance_id}", - daemon=True, - ) - self._thread.start() - if not self._ready_event.wait(deadline.remaining_seconds()): - self.stop() - if self._startup_error is not None: - error = self._startup_error - raise RuntimeError( - f"Failed to start UI bridge server: {error}" - ) from error - raise deadline.timeout_error() - if self._startup_error is not None: - error = self._startup_error - self.stop() - raise RuntimeError(f"Failed to start UI bridge server: {error}") from error - return self.binding - - def stop(self) -> None: - self._stop_event.set() - self._bridge.close() - thread = self._thread - if thread is not None: - thread.join(self._config.shutdown_timeout_seconds) - if thread.is_alive(): - raise TimeoutError("Timed out waiting for UI bridge server to stop.") - self._remove_descriptor_file( - Path(self._binding.descriptor_file_path) - if self._binding is not None - else None - ) - self._thread = None - self._binding = None - - def _serve(self) -> None: - import zmq - - context = zmq.Context() - socket = context.socket(zmq.REP) - browser_control_socket = context.socket(zmq.REP) - socket.setsockopt(zmq.LINGER, 0) - browser_control_socket.setsockopt(zmq.LINGER, 0) - descriptor_file_path: Path | None = None - connection: ExecutionConnectionSpec | None = None - try: - connection = self._bind(socket) - self._bind_browser_control_socket(browser_control_socket, connection) - descriptor_file_path = self._config.descriptor_path_for( - self._bridge_instance_id - ) - self._binding = UiBridgeServerBinding.from_runtime( - connection=connection, - bridge_instance_id=self._bridge_instance_id, - descriptor_file_path=descriptor_file_path, - auth_token=self._auth_token, - ) - self._write_descriptor_file( - UiBridgeDescriptorFile( - schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, - application=OPENHCS_ENDPOINT_APPLICATION, - bridge_instance_id=self._bridge_instance_id, - pid=os.getpid(), - started_at_unix=time.time(), - connection=connection, - auth_token=self._auth_token, - descriptor_file_path=str(descriptor_file_path), - ) - ) - dispatcher = UiBridgeRequestDispatcher( - self._bridge, - auth_token=self._auth_token, - binding_supplier=lambda: self.binding, - ) - poller = zmq.Poller() - poller.register(socket, zmq.POLLIN) - poller.register(browser_control_socket, zmq.POLLIN) - self._ready_event.set() - while not self._stop_event.is_set(): - events = dict(poller.poll(self._config.poll_timeout_ms)) - if socket in events: - request_payload = socket.recv_json() - if not isinstance(request_payload, dict): - request_payload = {} - socket.send_json(dispatcher.dispatch(request_payload)) - if browser_control_socket in events: - browser_control_socket.send( - self._browser_control_response_payload( - browser_control_socket.recv(), - connection, - ) - ) - except Exception as exc: - self._startup_error = exc - self._ready_event.set() - finally: - self._remove_descriptor_file(descriptor_file_path) - browser_control_socket.close(linger=0) - socket.close(linger=0) - context.term() - if connection is not None: - connection.transport_endpoint().cleanup(self._transport_config) - - def _requested_endpoint(self) -> TransportEndpoint | None: - requested_connection = self._config - if requested_connection.port is None: - return None - return TransportEndpoint( - host=requested_connection.host, - port=requested_connection.port, - transport_mode=resolve_transport_mode(requested_connection.transport_mode), - ) - - def _require_available_endpoint(self, endpoint: TransportEndpoint) -> None: - endpoint.cleanup_stale_addresses(self._transport_config) - occupied_ports = endpoint.occupied_ports(self._transport_config) - if occupied_ports: - raise UiBridgeEndpointInUseError(endpoint, occupied_ports) - - def _write_descriptor_file(self, descriptor: UiBridgeDescriptorFile) -> Path: - path = AgentRuntimePlatform.resolved_path( - descriptor.descriptor_file_path - ) - path.parent.mkdir(parents=True, mode=0o700, exist_ok=True) - path.write_text( - json.dumps( - to_jsonable( - project_dataclass(UiBridgeDescriptorWirePayload, descriptor) - ), - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - if AgentRuntimePlatform.current().supports_posix_permissions(): - path.chmod(0o600) - return path - - @staticmethod - def _remove_descriptor_file(path: Path | None) -> None: - if path is None: - return - try: - path.unlink() - except FileNotFoundError: - return - - def _bind(self, socket) -> ExecutionConnectionSpec: - requested_connection = self._config - mode = resolve_transport_mode(requested_connection.transport_mode) - port = mode.declaration.bind_socket( - socket, - requested_connection.host, - requested_connection.port, - self._transport_config, - ) - return ExecutionConnectionSpec( - host=requested_connection.host, - port=port, - transport_mode=mode, - persistent=requested_connection.persistent, - ) - - def _bind_browser_control_socket( - self, - socket, - connection: ExecutionConnectionSpec, - ) -> None: - socket.bind(connection.zmq_control_url(self._transport_config)) - - def _browser_control_response_payload( - self, - request_payload: bytes, - connection: ExecutionConnectionSpec, - ) -> bytes: - try: - request = ControlRequestHeader.from_wire_payload(request_payload) - if request.message_type is ControlMessageType.PING: - return pickle.dumps(self._browser_pong(connection).to_dict()) - raise ValueError( - f"Unsupported UI bridge browser control message: {request.message_type}" - ) - except Exception as exc: - return pickle.dumps(ControlErrorResponse.from_exception(exc).to_dict()) - - def _browser_pong( - self, - connection: ExecutionConnectionSpec, - ) -> PongResponse: - control_port = connection.zmq_control_port(self._transport_config) - return PongResponse( - port=connection.require_port("UI bridge browser heartbeat"), - control_port=control_port, - ready=True, - server=UI_BRIDGE_BROWSER_SERVER_NAME, - server_role=ServerRole.GENERIC, - log_file_path=self._current_log_file_path(), - application=OPENHCS_ENDPOINT_APPLICATION, - ) - - @staticmethod - def _current_log_file_path() -> str | None: - try: - from openhcs.core.log_utils import get_current_log_file_path - - return get_current_log_file_path() - except Exception: - return None - - def __enter__(self) -> "UiBridgeControlServer": - self.start() - return self - - def __exit__(self, exc_type, exc, traceback) -> None: - del exc_type, exc, traceback - self.stop() diff --git a/openhcs/pyqt_gui/services/ui_bridge_session_actions.py b/openhcs/pyqt_gui/services/ui_bridge_session_actions.py index d73f4e997b..221f94133d 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_session_actions.py +++ b/openhcs/pyqt_gui/services/ui_bridge_session_actions.py @@ -12,16 +12,13 @@ from objectstate.object_state import ObjectStateRegistry -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError -from openhcs.agent.dto.ui_bridge import ( - UiActionIdentity, - UiActionInvokeRequest, - UiActionSummary, -) +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError +from formbridge.bridge.dto import UiActionIdentity, UiActionInvokeRequest, UiActionSummary from openhcs.authoring.session.operations import SessionOperation from openhcs.authoring.session.operations.datasets import PromptedOperation from openhcs.authoring.session.session import Session -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( +from formbridge.bridge.contracts import ( UiActionDispatch, UiActionProviderABC, UiActionProviderIdentity, diff --git a/openhcs/pyqt_gui/services/ui_bridge_windows.py b/openhcs/pyqt_gui/services/ui_bridge_windows.py index 0d6dedc5e5..2e00ac9d1f 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_windows.py +++ b/openhcs/pyqt_gui/services/ui_bridge_windows.py @@ -1,108 +1,11 @@ -"""Window projection providers for the PyQt UI bridge.""" +"""OpenHCS main-window providers for the UI bridge.""" from __future__ import annotations -from objectstate.inspection import field_markers, state_markers -from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, ClassVar, TypeAlias +from dataclasses import dataclass +from typing import TYPE_CHECKING -from metaclass_registry import AutoRegisterMeta -from objectstate import ObjectState -from PyQt6.QtCore import QModelIndex, Qt -from PyQt6.QtWidgets import ( - QAbstractItemView, - QMessageBox, - QWidget, -) -from pyqt_reactive.forms.parameter_form_constants import ( - CONSTANTS as PARAMETER_FORM_CONSTANTS, -) -from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager -from pyqt_reactive.services.scope_window_factory import ScopeWindowRegistry -from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService -from pyqt_reactive.services.widget_tree_projection import ( - DEFAULT_WIDGET_DESCRIPTOR_PROJECTOR_REGISTRY, - ROOT_WIDGET_PATH_ID, - WIDGET_PATH_SEPARATOR, - WidgetActionKind, - WidgetActionTargetInvalidError, - WidgetActionUnsupportedError, - WidgetDescriptor, - WidgetRect, - WidgetTreeProjection, - WidgetTreeProjectionService, -) -from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.services.window_navigation import ( - NullWindowNavigationDriver, - WindowNavigationDispatch, - RegisteredWindowNavigationCompletion, - WindowNavigationRequest, -) -from pyqt_reactive.services.window_snapshot import ( - QtWindowSnapshotRequest, - QtWindowSnapshotService, - QtWindowSnapshot, - WindowVisualObservation, -) -from pyqt_reactive.widgets.shared import ( - BaseFormDialog, - BaseManagedWindow, - ManagedWindowAction, - ManagedWindowActionCapabilities, -) -from pyqt_reactive.widgets.shared.abstract_manager_widget import AbstractManagerWidget -from pyqt_reactive.widgets.shared.list_item_delegate import ( - DIRTY_FIELDS_ROLE, - OBJECT_STATE_PATH_ROLE, - SIG_DIFF_FIELDS_ROLE, -) -from python_introspect import overlay_non_none_dataclass, project_dataclass - -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentResourceRef -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeNavigateWindowOperation, - UiBridgeSnapshotWindowOperation, -) -from openhcs.agent.dto.ui_bridge import ( - DYNAMIC_SCOPE_WINDOW_KIND, - EMBEDDED_WINDOW_KIND, - MANAGED_WINDOW_KIND, - QT_TOP_LEVEL_WINDOW_KIND, - UiActionIdentity, - UiActionInvokeRequest, - UiActionSummary, - UiLiveOverviewItem, - UiLiveOverviewMetric, - UiLiveOverviewSection, - UiLiveOverviewSeverity, - UiMutationReceipt, - UiWidgetActionInvokeRequest, - UiWidgetActionInvokeResult, - UiWidgetActionIssueCode, - UiWidgetActionSemanticCarrier, - UiWidgetActionSummary, - UiWidgetRect, - UiWidgetTreeNode, - UiWidgetTreeRequest, - UiWidgetTreeResult, - UiWindowCatalog, - UiWindowCloseRequest, - UiWindowCloseResult, - UiWindowFocusRequest, - UiWindowFocusResult, - UiWindowIdentity, - UiWindowManagerScope, - UiWindowNavigateRequest, - UiWindowNavigateResult, - UiWindowOperationRequest, - UiWindowSemanticCarrier, - UiWindowSnapshotRequest, - UiWindowSnapshotResult, - UiWindowSummary, -) +from formbridge.bridge.contracts import UiActionProviderIdentity from openhcs.authoring.session.operations.application import ( CheckForUpdates, ExitApplication, @@ -111,2763 +14,23 @@ from openhcs.pyqt_gui.services.ui_bridge_session_actions import ( SessionOperationActionProvider, ) -from openhcs.agent.ui_bridge_identities import ( - MainWindowWidgetIdentity, - ManagedWindowWidgetIdentity, -) -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiActionDispatch, - UiActionProviderABC, - UiActionProviderIdentity, - UiLiveOverviewWidget, - UiWindowProviderABC, - UiWindowProviderIdentity, -) -from openhcs.pyqt_gui.services.ui_bridge_object_state import ( - ObjectStateFieldSemanticProjection, -) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( - UiBridgeProviderSetABC, - UiBridgeRegistrationContext, +from formbridge.bridge.registry import UiBridgeProviderSetABC, UiBridgeRegistrationContext +from pyqt_reactive.bridge.windows import ( + MAIN_WINDOW_PROVIDER_ID, + ManagedWindowActionProvider, + WindowCatalogProjectionABC, ) -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId +from openhcs.agent.ui_bridge_identities import MainWindowWidgetIdentity if TYPE_CHECKING: from openhcs.pyqt_gui.main import OpenHCSMainWindow - from pyqt_reactive.widgets.shared.dock_layout import DockPane - -WindowRouteCollection: TypeAlias = ( - tuple["EmbeddedWindowRoute", ...] | tuple["ManagedWindowRoute", ...] -) -MAIN_WINDOW_PROVIDER_ID = "main_window.windows" -QT_TOP_LEVEL_PROVIDER_ID = "qt_top_level.windows" -QT_TOP_LEVEL_WINDOW_ID_PREFIX = "qt_top_level:" -MANAGED_WINDOW_ACTIONS_TITLE = "Managed window actions" -MAIN_WINDOW_ACTIONS_TITLE = "Main window actions" -FIELD_INPUT_ACTION_ROLE = "field_input" -FIELD_RESET_ACTION_ROLE = "field_reset" -ITEM_SELECT_ACTION_ROLE = "item_select" - - -def _agent_object_state_scope_id(scope_id: str | None) -> str | None: - if scope_id is None: - return None - return OpenHCSUiWindowId.agent_window_id_for_manager_scope(scope_id) - - -MANAGED_WINDOW_ACTION_PROVIDER_IDENTITY = ( - UiActionProviderIdentity.from_widget_declaration( - ManagedWindowWidgetIdentity, - title=MANAGED_WINDOW_ACTIONS_TITLE, - ) -) MAIN_WINDOW_ACTION_PROVIDER_IDENTITY = UiActionProviderIdentity.from_widget_declaration( MainWindowWidgetIdentity, - title=MAIN_WINDOW_ACTIONS_TITLE, + title="Main window actions", ) -class ManagedWindowSummaryProjection: - """Project ObjectState-backed managed-window status into window summaries.""" - - @classmethod - def project(cls, window: QWidget | None) -> UiWindowSemanticCarrier: - if not isinstance(window, BaseManagedWindow): - return UiWindowSemanticCarrier() - state = window.state - capabilities = window.managed_window_action_capabilities() - return UiWindowSemanticCarrier( - object_state_scope_id=cls._object_state_scope_id(state), - dirty=bool(state.dirty_fields) if state is not None else False, - signature_diff=( - bool(state.signature_diff_fields) if state is not None else False - ), - dirty_field_count=(len(state.dirty_fields) if state is not None else 0), - signature_diff_field_count=( - len(state.signature_diff_fields) if state is not None else 0 - ), - semantic_markers=cls._semantic_markers(state), - managed_action_ids=cls._managed_action_ids(capabilities), - ) - - @staticmethod - def _object_state_scope_id(state: ObjectState | None) -> str | None: - if state is None: - return None - return _agent_object_state_scope_id(state.scope_id) - - @staticmethod - def _semantic_markers(state: ObjectState | None) -> tuple[str, ...]: - return () if state is None else state_markers(state) - - @staticmethod - def _managed_action_ids( - capabilities: ManagedWindowActionCapabilities, - ) -> tuple[str, ...]: - return tuple( - action.value - for action in ManagedWindowAction - if action.is_supported(capabilities) - ) - - -class EmbeddedManagerSummaryProjection: - """Project shared AbstractManagerWidget row semantics into window summaries.""" - - @classmethod - def project(cls, widget: QWidget | None) -> UiWindowSemanticCarrier: - if not isinstance(widget, AbstractManagerWidget): - return UiWindowSemanticCarrier() - item_list = widget.item_list - if item_list is None: - return UiWindowSemanticCarrier() - model = item_list.model() - if model is None: - return UiWindowSemanticCarrier() - - dirty_field_count = 0 - signature_diff_field_count = 0 - for row_index in range(model.rowCount()): - item_record = _WidgetItemSemanticRecord.from_index( - model.index(row_index, 0) - ) - dirty_field_count += len(item_record.dirty_fields) - signature_diff_field_count += len(item_record.signature_diff_fields) - - return UiWindowSemanticCarrier( - dirty=bool(dirty_field_count), - signature_diff=bool(signature_diff_field_count), - dirty_field_count=dirty_field_count, - signature_diff_field_count=signature_diff_field_count, - semantic_markers=cls._semantic_markers( - dirty_field_count=dirty_field_count, - signature_diff_field_count=signature_diff_field_count, - ), - ) - - @staticmethod - def _semantic_markers( - *, - dirty_field_count: int, - signature_diff_field_count: int, - ) -> tuple[str, ...]: - return field_markers( - dirty=bool(dirty_field_count), - signature_diff=bool(signature_diff_field_count), - ) - - -@dataclass(frozen=True, slots=True) -class WindowProjectionTarget: - """Resolved Qt widget plus its agent-facing summary.""" - - widget: QWidget - summary: UiWindowSummary - - -class WindowProjectionResultAuthority: - """Shared agent-result fragments for UI window projection operations.""" - - @staticmethod - def unknown_window(identity: UiWindowIdentity) -> AgentError: - return AgentError( - code="unknown_ui_window", - message=f"Unknown or closed UI window: {identity.window_id!r}", - ) - - @staticmethod - def unsupported_navigation_target(request: UiWindowNavigateRequest) -> AgentError: - return AgentError( - code="ui_window_navigation_target_unsupported", - message=( - f"UI window {request.window_id!r} does not own the requested " - "item or field target." - ), - path=request.field_path, - ) - - -class WindowCloseResultBoundaryPolicy: - """Single result boundary for UI window close operations.""" - - @staticmethod - def closed_summary(summary: UiWindowSummary) -> UiWindowSummary: - return replace(summary, visible=False, focusable=False) - - @classmethod - def closed( - cls, - request: UiWindowCloseRequest, - summary: UiWindowSummary, - ) -> UiWindowCloseResult: - return UiWindowCloseResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - closed=True, - summary=cls.closed_summary(summary), - ) - - @staticmethod - def error( - request: UiWindowCloseRequest, - code: str, - message: str, - *, - summary: UiWindowSummary | None = None, - ) -> UiWindowCloseResult: - return UiWindowCloseResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - closed=False, - summary=summary, - errors=(AgentError(code=code, message=message),), - ) - - -class MainWindowContextFactoryMixin: - """Construct registry leaves from an optional OpenHCS main-window context.""" - - @classmethod - def create(cls, main_window: "OpenHCSMainWindow | None"): - return cls(main_window) - - -class WindowCatalogProjectionABC( - MainWindowContextFactoryMixin, - UiWindowProviderABC, - ABC, - metaclass=AutoRegisterMeta, -): - """Provider/projection authority for one catalog of UI windows.""" - - __registry_key__ = "projection_id" - __skip_if_no_key__ = True - - projection_id: ClassVar[str | None] = None - identity: ClassVar[UiWindowProviderIdentity] - - @classmethod - def registered_types(cls) -> tuple[type["WindowCatalogProjectionABC"], ...]: - return tuple(cls.__registry__.values()) - - @classmethod - def for_projection_id( - cls, - projection_id: str, - main_window: "OpenHCSMainWindow | None", - ) -> "WindowCatalogProjectionABC": - return cls.__registry__[projection_id].create(main_window) - - def catalog(self) -> UiWindowCatalog: - return UiWindowCatalog( - schema_version=SCHEMA_VERSION, - windows=self.summaries(), - ) - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - windows = self.summaries() - error_dialogs = tuple( - window - for window in windows - if window.error_dialog - ) - return ( - UiLiveOverviewSection( - section_id=self.identity.provider_id, - title=self.identity.title, - summary=f"{len(windows)} windows", - metrics=( - UiLiveOverviewMetric( - key="windows", - label="windows", - value=str(len(windows)), - ), - UiLiveOverviewMetric( - key="visible", - label="visible", - value=str(sum(1 for window in windows if window.visible)), - ), - UiLiveOverviewMetric( - key="error_dialogs", - label="error dialogs", - value=str(len(error_dialogs)), - ), - ), - items=tuple(self._overview_window_item(window) for window in windows), - ), - ) - - @staticmethod - def _overview_window_item(window: UiWindowSummary) -> UiLiveOverviewItem: - is_error_dialog = window.error_dialog - return UiLiveOverviewItem( - label=window.title, - status=window.window_kind, - detail=f"visible={window.visible} focusable={window.focusable}", - severity=( - UiLiveOverviewSeverity.ERROR.value - if is_error_dialog - else UiLiveOverviewSeverity.INFO.value - ), - source_window_id=window.window_id, - ) - - @abstractmethod - def summaries(self) -> tuple[UiWindowSummary, ...]: - """Return the windows currently visible through this projection.""" - raise NotImplementedError - - @abstractmethod - def handles(self, window_id: str) -> bool: - """Return whether this projection owns a window id.""" - raise NotImplementedError - - @abstractmethod - def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: - raise NotImplementedError - - @abstractmethod - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[UiWindowNavigateResult], None] | None = None, - ) -> UiWindowNavigateResult: - raise NotImplementedError - - @abstractmethod - def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: - raise NotImplementedError - - @abstractmethod - def snapshot( - self, - request: UiWindowSnapshotRequest, - completed: Callable[[UiWindowSnapshotResult], None] | None = None, - ) -> UiWindowSnapshotResult: - raise NotImplementedError - - @abstractmethod - def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: - raise NotImplementedError - - @abstractmethod - def invoke_widget_action( - self, - request: UiWidgetActionInvokeRequest, - ) -> UiWidgetActionInvokeResult: - raise NotImplementedError - - -class WindowTargetOperationProjectionMixin(ABC): - """Shared target-backed window operations for projection providers.""" - - @abstractmethod - def target_for_operation( - self, - request: UiWindowOperationRequest, - ) -> WindowProjectionTarget | None: - raise NotImplementedError - - def snapshot( - self, - request: UiWindowSnapshotRequest, - completed: Callable[[UiWindowSnapshotResult], None] | None = None, - ) -> UiWindowSnapshotResult: - target = self.target_for_operation(request) - snapshot_results = UiWindowSnapshotResultFactory() - if target is None: - return snapshot_results.error( - request, - WindowProjectionResultAuthority.unknown_window(request), - ) - return snapshot_results.capture(request, target, completed) - - def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: - target = self.target_for_operation(request) - widget_tree_results = UiWidgetTreeResultFactory() - if target is None: - return widget_tree_results.error( - request, - WindowProjectionResultAuthority.unknown_window(request), - ) - return widget_tree_results.project(request, target) - - def invoke_widget_action( - self, - request: UiWidgetActionInvokeRequest, - ) -> UiWidgetActionInvokeResult: - target = self.target_for_operation(request) - invoke_results = UiWidgetActionInvokeResultFactory() - if target is None: - return invoke_results.error( - request, - WindowProjectionResultAuthority.unknown_window(request), - ) - return invoke_results.invoke(request, target) - - -@dataclass(frozen=True, slots=True) -class FocusableWindowRouteMixin(ABC): - """Shared focus algorithm for static focusable window routes.""" - - focus_action: Callable[[], None] - - @abstractmethod - def summary(self) -> UiWindowSummary: - raise NotImplementedError - - @abstractmethod - def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: - raise NotImplementedError - - def focus(self) -> UiWindowSummary: - self.focus_action() - return self.summary() - - -@dataclass(frozen=True, slots=True) -class EmbeddedWindowRoute: - """Focusable embedded widget route owned by the main window layout.""" - - pane: "DockPane" - - @property - def identity(self) -> UiWindowIdentity: - return UiWindowIdentity(window_id=self.pane.window_id) - - @property - def title(self) -> str: - return self.pane.title - - def widget(self) -> QWidget: - return self.pane.widget - - def focus(self) -> UiWindowSummary: - self.pane.show() - return self.summary() - - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[RegisteredWindowNavigationCompletion], None] | None = None, - ) -> WindowNavigationDispatch: - self.pane.show() - widget = self.widget() - driver = ( - widget.window_navigation_driver() - if isinstance(widget, AbstractManagerWidget) - else NullWindowNavigationDriver() - ) - return WindowManager.dispatch_widget_navigation( - widget, - driver, - requested_scope_id=request.window_id, - item_id=request.item_id, - field_path=request.field_path, - completed=completed, - ) - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - widget = self.widget() - if isinstance(widget, UiLiveOverviewWidget): - return widget.overview_sections() - return () - - def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: - del create_if_missing - return WindowProjectionTarget( - widget=self.widget(), - summary=self.summary(), - ) - - def summary(self) -> UiWindowSummary: - widget = self.widget() - scope = UiWindowManagerScope.from_identity(self.identity) - return project_dataclass( - UiWindowSummary, - EmbeddedManagerSummaryProjection.project(widget), - schema_version=SCHEMA_VERSION, - identity=self.identity, - title=self.title, - window_kind=EMBEDDED_WINDOW_KIND, - visible=widget.isVisible(), - focusable=True, - manager_scope=scope, - ) - - -@dataclass(frozen=True, slots=True) -class ManagedWindowRoute(FocusableWindowRouteMixin): - """Focusable WindowManager-backed static window route.""" - - identity: UiWindowIdentity - title: str - - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[RegisteredWindowNavigationCompletion], None] | None = None, - ) -> WindowNavigationDispatch: - self.focus() - return WindowManager.focus_and_navigate_result( - UiWindowManagerScope.from_identity(self.identity).value, - item_id=request.item_id, - field_path=request.field_path, - requested_scope_id=request.window_id, - completed=completed, - ) - - def widget(self, create_if_missing: bool) -> QWidget | None: - scope = UiWindowManagerScope.from_identity(self.identity) - window = WindowManager.get_window(scope.value) - if window is not None: - return window - if not create_if_missing: - return None - self.focus_action() - return WindowManager.get_window(scope.value) - - def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: - widget = self.widget(create_if_missing=create_if_missing) - if widget is None: - return None - return WindowProjectionTarget( - widget=widget, - summary=self.summary(), - ) - - def summary(self) -> UiWindowSummary: - scope = UiWindowManagerScope.from_identity(self.identity) - window = WindowManager.get_window(scope.value) - visible = False - if window is not None: - visible = window.isVisible() - return project_dataclass( - UiWindowSummary, - ManagedWindowSummaryProjection.project(window), - schema_version=SCHEMA_VERSION, - identity=self.identity, - title=self.title, - window_kind=MANAGED_WINDOW_KIND, - visible=visible, - focusable=True, - manager_scope=scope, - ) - - -@dataclass(frozen=True, slots=True) -class WindowIdentitySet: - """Closed set of window identities for static route filtering.""" - - identities: frozenset[UiWindowIdentity] - - @classmethod - def from_routes( - cls, - routes: WindowRouteCollection, - ) -> "WindowIdentitySet": - return cls(frozenset(route.identity for route in routes)) - - def contains(self, identity: UiWindowIdentity) -> bool: - return identity in self.identities - - def union(self, other: "WindowIdentitySet") -> "WindowIdentitySet": - return WindowIdentitySet(self.identities | other.identities) - - -@dataclass(frozen=True, slots=True) -class WindowRouteIndex: - """Identity-indexed route lookup for static window routes.""" - - embedded_routes: tuple[EmbeddedWindowRoute, ...] - managed_routes: tuple[ManagedWindowRoute, ...] - - @property - def static_identities(self) -> WindowIdentitySet: - return WindowIdentitySet.from_routes(self.embedded_routes).union( - WindowIdentitySet.from_routes(self.managed_routes) - ) - - def embedded_route(self, identity: UiWindowIdentity) -> EmbeddedWindowRoute | None: - return self._route_by_identity(self.embedded_routes, identity) - - def managed_route(self, identity: UiWindowIdentity) -> ManagedWindowRoute | None: - return self._route_by_identity(self.managed_routes, identity) - - @staticmethod - def _route_by_identity( - routes: WindowRouteCollection, - identity: UiWindowIdentity, - ): - for route in routes: - if route.identity == identity: - return route - return None - - -@dataclass(frozen=True, slots=True) -class WindowRouteResolution: - """A static window route resolved for a requested agent-visible identity.""" - - request_identity: UiWindowIdentity - route_identity: UiWindowIdentity - route: EmbeddedWindowRoute | ManagedWindowRoute - - def summary(self, target_summary: UiWindowSummary | None = None) -> UiWindowSummary: - if target_summary is None: - target_summary = self.route.summary() - if self.route_identity == self.request_identity: - return target_summary - return replace(target_summary, identity=self.request_identity) - - def focus(self) -> UiWindowSummary: - return self.summary(self.route.focus()) - - def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: - target = self.route.target(create_if_missing=create_if_missing) - if target is None: - return None - return WindowProjectionTarget( - widget=target.widget, - summary=self.summary(target.summary), - ) - - -class EmbeddedWindowProjection: - """Project embedded main-window widgets.""" - - def __init__(self, main_window: "OpenHCSMainWindow") -> None: - self._main_window = main_window - - def routes(self) -> tuple[EmbeddedWindowRoute, ...]: - embedded = self._main_window.embedded_widgets - return tuple(EmbeddedWindowRoute(pane=pane) for pane in embedded.panes()) - - -class ManagedWindowProjection: - """Project static WindowManager-managed windows.""" - - def __init__(self, main_window: "OpenHCSMainWindow") -> None: - self._main_window = main_window - - def routes( - self, - embedded_routes: tuple[EmbeddedWindowRoute, ...], - ) -> tuple[ManagedWindowRoute, ...]: - embedded_identities = WindowIdentitySet.from_routes(embedded_routes) - return tuple( - ManagedWindowRoute( - identity=UiWindowIdentity(window_id=spec.window_id), - title=spec.title, - focus_action=self._focus_static_window_action(spec.window_id), - ) - for spec in self._main_window.window_specs.values() - if not embedded_identities.contains( - UiWindowIdentity(window_id=spec.window_id) - ) - ) - - def _focus_static_window_action(self, window_id: str) -> Callable[[], None]: - def focus() -> None: - self._main_window.show_window(window_id, hide_if_startup=False) - - return focus - - -class DynamicScopeWindowProjection: - """Project already-open dynamic ObjectState/scope windows.""" - - def summaries( - self, - route_index: WindowRouteIndex, - ) -> tuple[UiWindowSummary, ...]: - return tuple( - self.summary( - self._identity_for_manager_scope(scope_id), - scope_id, - ) - for scope_id in WindowManager.get_open_scopes() - if not route_index.static_identities.contains( - self._identity_for_manager_scope(scope_id) - ) - ) - - def summary( - self, - identity: UiWindowIdentity, - manager_scope_id: str | None = None, - ) -> UiWindowSummary: - manager_identity = identity - if manager_scope_id is not None: - manager_identity = UiWindowIdentity(window_id=manager_scope_id) - scope = UiWindowManagerScope.from_identity(manager_identity) - window = WindowManager.get_window(scope.value) - title = scope.value - visible = False - if window is not None: - title = window.windowTitle() - visible = window.isVisible() - return project_dataclass( - UiWindowSummary, - ManagedWindowSummaryProjection.project(window), - schema_version=SCHEMA_VERSION, - identity=identity, - title=title, - window_kind=DYNAMIC_SCOPE_WINDOW_KIND, - visible=visible, - focusable=window is not None, - manager_scope=scope, - ) - - @staticmethod - def _identity_for_manager_scope(scope_id: str) -> UiWindowIdentity: - return UiWindowIdentity( - window_id=OpenHCSUiWindowId.agent_window_id_for_manager_scope(scope_id) - ) - - -@dataclass(frozen=True, slots=True) -class OpenWindowManagerScope: - """Witness for an agent identity resolved to an open WindowManager scope.""" - - identity: UiWindowIdentity - scope_id: str - widget: QWidget - - def target( - self, - dynamic_projection: DynamicScopeWindowProjection, - ) -> WindowProjectionTarget: - return WindowProjectionTarget( - widget=self.widget, - summary=dynamic_projection.summary(self.identity, self.scope_id), - ) - - -class ScopeWindowTargetOperationProjectionABC( - WindowTargetOperationProjectionMixin, - ABC, -): - """Shared target lookup for WindowManager and scope-registry projections.""" - - @abstractmethod - def _route_index(self) -> WindowRouteIndex: - raise NotImplementedError - - @abstractmethod - def _dynamic_scope_projection(self) -> DynamicScopeWindowProjection: - raise NotImplementedError - - def target_for_operation( - self, - request: UiWindowOperationRequest, - ) -> WindowProjectionTarget | None: - identity = request.as_identity() - return self._target( - identity, - create_if_missing=request.open_policy.create_if_missing, - ) - - def _target( - self, - identity: UiWindowIdentity, - *, - create_if_missing: bool, - ) -> WindowProjectionTarget | None: - route_index = self._route_index() - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if embedded_route is not None: - return embedded_route.target(create_if_missing=create_if_missing) - - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if managed_route is not None: - return managed_route.target(create_if_missing=create_if_missing) - - open_target = self._open_dynamic_target(identity) - if open_target is not None: - return open_target - - embedded_alias = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if embedded_alias is not None: - return embedded_alias.target(create_if_missing=create_if_missing) - - managed_alias = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if managed_alias is not None: - return managed_alias.target(create_if_missing=create_if_missing) - - scope = UiWindowManagerScope.from_identity( - self._resolved_window_identity(identity) - ) - scope_widget = WindowManager.get_window(scope.value) - if scope_widget is not None: - return WindowProjectionTarget( - widget=scope_widget, - summary=self._dynamic_scope_projection().summary( - identity, - scope.value, - ), - ) - - if ( - create_if_missing - and ScopeWindowRegistry.find_handler(identity.window_id) is not None - ): - navigation = ScopeWindowNavigationService.navigate( - WindowNavigationRequest( - scope_id=identity.window_id, - create_if_missing=True, - ) - ) - if navigation.focused: - target_scope_id = navigation.window_scope_id or scope.value - scope_widget = WindowManager.get_window(target_scope_id) - if scope_widget is not None: - return WindowProjectionTarget( - widget=scope_widget, - summary=self._dynamic_scope_projection().summary( - identity, - target_scope_id, - ), - ) - - return None - - def _embedded_route_resolution( - self, - identity: UiWindowIdentity, - route_index: WindowRouteIndex, - *, - resolve_scope_alias: bool, - ) -> WindowRouteResolution | None: - return self._static_route_resolution( - identity, - route_index.embedded_route, - resolve_scope_alias=resolve_scope_alias, - ) - - def _managed_route_resolution( - self, - identity: UiWindowIdentity, - route_index: WindowRouteIndex, - *, - resolve_scope_alias: bool, - ) -> WindowRouteResolution | None: - return self._static_route_resolution( - identity, - route_index.managed_route, - resolve_scope_alias=resolve_scope_alias, - ) - - def _static_route_resolution( - self, - identity: UiWindowIdentity, - route_for_identity: Callable[ - [UiWindowIdentity], - EmbeddedWindowRoute | ManagedWindowRoute | None, - ], - *, - resolve_scope_alias: bool, - ) -> WindowRouteResolution | None: - route_identity = identity - if resolve_scope_alias: - route_identity = self._resolved_window_identity(identity) - if route_identity == identity: - return None - - route = route_for_identity(route_identity) - if route is None: - return None - return WindowRouteResolution( - request_identity=identity, - route_identity=route_identity, - route=route, - ) - - def _open_dynamic_target( - self, - identity: UiWindowIdentity, - ) -> WindowProjectionTarget | None: - open_scope = self._open_window_manager_scope(identity) - if open_scope is None: - return None - return open_scope.target(self._dynamic_scope_projection()) - - @classmethod - def _open_window_manager_scope_id(cls, identity: UiWindowIdentity) -> str | None: - open_scope = cls._open_window_manager_scope(identity) - if open_scope is None: - return None - return open_scope.scope_id - - @staticmethod - def _open_window_manager_scope( - identity: UiWindowIdentity, - ) -> OpenWindowManagerScope | None: - for scope_id in OpenHCSUiWindowId.manager_scopes_for_agent_window_id( - identity.window_id - ): - widget = WindowManager.get_window(scope_id) - if widget is not None: - return OpenWindowManagerScope( - identity=identity, - scope_id=scope_id, - widget=widget, - ) - return None - - @staticmethod - def _resolved_window_identity(identity: UiWindowIdentity) -> UiWindowIdentity: - route = ScopeWindowRegistry.find_handler(identity.window_id) - if route is None: - return identity - target = route.navigation_target(identity.window_id) - return UiWindowIdentity(window_id=target.window_scope_id) - - -class QtTopLevelWindowProjection( - WindowTargetOperationProjectionMixin, - WindowCatalogProjectionABC, -): - """Project visible Qt top-level windows that are not WindowManager scopes.""" - - projection_id = QT_TOP_LEVEL_PROVIDER_ID - identity = UiWindowProviderIdentity( - provider_id=QT_TOP_LEVEL_PROVIDER_ID, - title="Qt top-level windows", - ) - - def __init__( - self, - main_window: "OpenHCSMainWindow | None", - ) -> None: - self._main_window = main_window - - def summaries(self) -> tuple[UiWindowSummary, ...]: - summaries = [] - for widget in self._projected_top_level_widgets(): - summary = WindowManager.project_live_window(widget, self.summary) - if summary is not None: - summaries.append(summary) - return tuple(summaries) - - def handles(self, window_id: str) -> bool: - return self.target(UiWindowIdentity(window_id=window_id)) is not None - - def target(self, identity: UiWindowIdentity) -> WindowProjectionTarget | None: - identity = identity.as_identity() - for widget in self._projected_top_level_widgets(): - target = WindowManager.project_live_window( - widget, - lambda live_widget: self._matching_target(live_widget, identity), - ) - if target is not None: - return target - return None - - def _matching_target( - self, - widget: QWidget, - identity: UiWindowIdentity, - ) -> WindowProjectionTarget | None: - if self._identity(widget) != identity: - return None - return WindowProjectionTarget(widget=widget, summary=self.summary(widget)) - - def target_for_operation( - self, - request: UiWindowOperationRequest, - ) -> WindowProjectionTarget | None: - return self.target(request) - - def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: - target = self.target(request) - if target is None: - return UiWindowFocusResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=False, - errors=(WindowProjectionResultAuthority.unknown_window(request),), - ) - target.widget.show() - target.widget.raise_() - target.widget.activateWindow() - return UiWindowFocusResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=True, - summary=self.summary(target.widget), - ) - - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[UiWindowNavigateResult], None] | None = None, - ) -> UiWindowNavigateResult: - focus_result = self.focus( - UiWindowFocusRequest( - window_id=request.window_id, - open_policy=request.open_policy, - ) - ) - return UiWindowNavigateResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=focus_result.focused, - navigated=False, - created=False, - summary=focus_result.summary, - errors=( - focus_result.errors - or ( - ( - WindowProjectionResultAuthority.unsupported_navigation_target( - request - ), - ) - if request.has_target - else () - ) - ), - warnings=focus_result.warnings, - ) - - def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: - target = self.target(request) - if target is None: - return WindowCloseResultBoundaryPolicy.error( - request, - "unknown_ui_window", - f"Unknown or closed UI window: {request.window_id!r}", - ) - if target.widget is self._main_window: - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_close_unsupported", - "The OpenHCS main window cannot be closed through this UI bridge operation.", - summary=target.summary, - ) - if target.widget.close(): - return WindowCloseResultBoundaryPolicy.closed( - request, - target.summary, - ) - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_close_rejected", - f"Qt rejected the close request for UI window: {request.window_id!r}", - summary=target.summary, - ) - - def summary(self, widget: QWidget) -> UiWindowSummary: - identity = self._identity(widget) - return UiWindowSummary( - schema_version=SCHEMA_VERSION, - identity=identity, - title=self._title(widget), - window_kind=QT_TOP_LEVEL_WINDOW_KIND, - visible=widget.isVisible(), - focusable=True, - manager_scope=None, - error_dialog=self._is_error_dialog(widget), - ) - - def _projected_top_level_widgets(self) -> tuple[QWidget, ...]: - excluded_widgets = self._excluded_widgets() - return tuple( - widget - for widget in WindowManager.visible_top_level_windows() - if widget not in excluded_widgets - ) - - def _excluded_widgets(self) -> frozenset[QWidget]: - widgets: set[QWidget] = set() - for scope_id in WindowManager.get_open_scopes(): - widget = WindowManager.get_window(scope_id) - if widget is not None: - top_level_widget = widget.window() - if top_level_widget is not self._main_window: - widgets.add(top_level_widget) - if self._main_window is not None: - widgets.update( - pane.dock_widget - for pane in self._main_window.embedded_widgets.panes() - if pane.dock_widget.isFloating() - ) - return frozenset(widgets) - - def _identity(self, widget: QWidget) -> UiWindowIdentity: - if widget is self._main_window: - return UiWindowIdentity(window_id=OpenHCSUiWindowId.main_window) - return UiWindowIdentity( - window_id=f"{QT_TOP_LEVEL_WINDOW_ID_PREFIX}{int(widget.winId())}" - ) - - @staticmethod - def _title(widget: QWidget) -> str: - title = widget.windowTitle() - if title: - return title - return type(widget).__name__ - - @staticmethod - def _is_error_dialog(widget: QWidget) -> bool: - return ( - isinstance(widget, QMessageBox) - and widget.icon() is QMessageBox.Icon.Critical - ) - - -class UiWindowSnapshotResultFactory: - """Build UI bridge screenshot results from resolved Qt window targets.""" - - def __init__(self) -> None: - self._snapshotter = QtWindowSnapshotService() - - def capture( - self, - request: UiWindowSnapshotRequest, - target: WindowProjectionTarget, - completed: Callable[[UiWindowSnapshotResult], None] | None = None, - ) -> UiWindowSnapshotResult: - qt_request = QtWindowSnapshotRequest( - widget=target.widget, - capture=request, - subject_id=request.window_id, - title=target.summary.title, - ) - try: - if request.frame_condition.observes: - if completed is None: - raise ValueError( - "Observed snapshots require an operation completion owner." - ) - self._snapshotter.observe( - qt_request, - lambda snapshot: completed( - self.from_snapshot(request, target, snapshot) - ), - lambda failure: completed( - self.error( - request, - AgentError.from_exception( - UiBridgeSnapshotWindowOperation.failure_error_code, - failure.error, - ), - summary=target.summary, - observation=failure.observation, - ) - ), - ) - return project_dataclass( - UiWindowSnapshotResult, - request, - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - captured=False, - summary=target.summary, - ) - snapshot = self._snapshotter.capture(qt_request) - except Exception as exc: - return self.error( - request, - AgentError.from_exception( - UiBridgeSnapshotWindowOperation.failure_error_code, exc - ), - summary=target.summary, - ) - return self.from_snapshot(request, target, snapshot) - - @staticmethod - def from_snapshot( - request: UiWindowSnapshotRequest, - target: WindowProjectionTarget, - snapshot: QtWindowSnapshot, - ) -> UiWindowSnapshotResult: - return project_dataclass( - UiWindowSnapshotResult, - request, - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - captured=True, - resource=AgentResourceRef( - uri=snapshot.uri, - title=snapshot.title, - mime_type=snapshot.mime_type, - path=snapshot.path, - size_bytes=snapshot.size_bytes, - sha256=snapshot.sha256, - ), - summary=target.summary, - width=snapshot.width, - height=snapshot.height, - observation=snapshot.observation, - ) - - @staticmethod - def error( - request: UiWindowSnapshotRequest, - error: AgentError, - *, - summary: UiWindowSummary | None = None, - observation: WindowVisualObservation | None = None, - ) -> UiWindowSnapshotResult: - return project_dataclass( - UiWindowSnapshotResult, - request, - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - captured=False, - summary=summary, - observation=observation, - errors=(error,), - ) - - -@dataclass(frozen=True, slots=True) -class _ItemViewIndexResolution: - view: QAbstractItemView - index: QModelIndex - - -class UiWidgetPathResolver: - """Resolve widget-tree path ids against live Qt widgets and descriptors.""" - - @classmethod - def path_from_id(cls, path_id: str) -> tuple[int, ...]: - if path_id == ROOT_WIDGET_PATH_ID: - return () - if not path_id: - raise ValueError("Widget path_id must not be empty.") - parts = path_id.split(WIDGET_PATH_SEPARATOR) - path: list[int] = [] - for part in parts: - try: - index = int(part) - except ValueError as exc: - raise ValueError(f"Invalid widget path component: {part!r}") from exc - if index < 0: - raise ValueError( - f"Widget path component must be non-negative: {part!r}" - ) - path.append(index) - return tuple(path) - - @classmethod - def widget_at_path(cls, widget: QWidget, path: tuple[int, ...]) -> QWidget | None: - current = widget - for child_index in path: - children = cls.direct_children(current) - if child_index >= len(children): - return None - current = children[child_index] - return current - - @classmethod - def descriptor_at_path( - cls, - descriptor: WidgetDescriptor, - path: tuple[int, ...], - ) -> WidgetDescriptor | None: - current = descriptor - for child_index in path: - child = current.child_at_index(child_index) - if child is None: - return None - current = child - return current - - @staticmethod - def direct_children(widget: QWidget) -> tuple[QWidget, ...]: - return tuple( - widget.findChildren( - QWidget, - options=Qt.FindChildOption.FindDirectChildrenOnly, - ) - ) - - @classmethod - def item_view_index_at_path( - cls, - widget: QWidget, - path: tuple[int, ...], - ) -> "_ItemViewIndexResolution | None": - current_widget = widget - current_index = QModelIndex() - active_view: QAbstractItemView | None = None - - for child_index in path: - if active_view is None: - children = cls.direct_children(current_widget) - if child_index < len(children): - current_widget = children[child_index] - continue - if not isinstance(current_widget, QAbstractItemView): - return None - active_view = current_widget - model_row = child_index - len(children) - else: - model_row = child_index - - model = active_view.model() - if model is None: - return None - current_index = model.index(model_row, 0, current_index) - if not current_index.isValid(): - return None - - if active_view is None: - return None - return _ItemViewIndexResolution(active_view, current_index) - - -class UiWidgetActionInvokeResultFactory: - """Invoke projected widget actions through live Qt widgets.""" - - AUTO_ACTION_KIND = "auto" - - def invoke( - self, - request: UiWidgetActionInvokeRequest, - target: WindowProjectionTarget, - ) -> UiWidgetActionInvokeResult: - try: - projection = WidgetTreeProjectionService.project(target.widget) - path = UiWidgetPathResolver.path_from_id(request.path_id) - except Exception as exc: - return self.error( - request, - AgentError.from_exception( - UiWidgetActionIssueCode.RESOLUTION_FAILED.value, - exc, - ), - summary=target.summary, - ) - - descriptor = UiWidgetPathResolver.descriptor_at_path(projection.root, path) - if descriptor is None: - return self.error( - request, - AgentError( - code=UiWidgetActionIssueCode.WIDGET_UNKNOWN.value, - message=( - f"Widget path_id {request.path_id!r} was not found in " - f"window {request.window_id!r}." - ), - hint="Refresh the widget tree and retry with a current path_id.", - ), - summary=target.summary, - ) - - action_summary = UiWidgetTreeResultFactory.action_summary( - descriptor, - field_semantics=_WidgetFieldSemanticContext.from_target(target), - item_semantics=_WidgetItemSemanticContext.from_target(target), - ) - - action_kind = self._resolve_action_kind(request, descriptor) - if isinstance(action_kind, AgentError): - return self.error(request, action_kind, summary=action_summary) - - if action_kind.item_action is not None: - return self._invoke_item_action( - request, - target, - path, - descriptor, - action_summary, - action_kind=action_kind, - ) - - widget = UiWidgetPathResolver.widget_at_path(target.widget, path) - if widget is None: - return self.error( - request, - AgentError( - code=UiWidgetActionIssueCode.WIDGET_UNKNOWN.value, - message=( - f"Widget path_id {request.path_id!r} was not found in " - f"window {request.window_id!r}." - ), - hint="Refresh the widget tree and retry with a current path_id.", - ), - summary=action_summary, - ) - - guard_error = self._guard_error(request, descriptor, widget, action_kind) - if guard_error is not None: - return self.error( - request, - guard_error, - summary=action_summary, - action_kind=action_kind.value, - ) - - return self._invoke_projector_action( - request, - action_summary, - widget, - action_kind=action_kind, - ) - - def _invoke_projector_action( - self, - request: UiWidgetActionInvokeRequest, - action_summary: UiWidgetActionSummary, - widget: QWidget, - *, - action_kind: WidgetActionKind, - ) -> UiWidgetActionInvokeResult: - try: - DEFAULT_WIDGET_DESCRIPTOR_PROJECTOR_REGISTRY.projector_for( - widget - ).invoke_action( - widget, - action_kind, - target_index=request.target_index, - ) - except WidgetActionUnsupportedError: - return self.error( - request, - AgentError( - code=UiWidgetActionIssueCode.ACTION_UNSUPPORTED.value, - message=( - f"Widget path_id {request.path_id!r} does not support " - f"action kind {action_kind.value!r}." - ), - ), - summary=action_summary, - action_kind=action_kind.value, - ) - except WidgetActionTargetInvalidError as error: - return self.error( - request, - AgentError( - code=UiWidgetActionIssueCode.INDEX_INVALID.value, - message=( - f"Target index {request.target_index!r} is outside the " - f"available range for widget path_id {request.path_id!r}." - ), - hint=( - "Refresh the widget tree and choose target_index from 0 " - f"through {error.item_count - 1}." - ), - ), - summary=action_summary, - action_kind=action_kind.value, - ) - - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=action_kind.value, - invoked=True, - receipt=UiMutationReceipt.accepted_for(request.request_token), - summary=action_summary, - ) - - def _invoke_item_action( - self, - request: UiWidgetActionInvokeRequest, - target: WindowProjectionTarget, - path: tuple[int, ...], - descriptor: WidgetDescriptor, - action_summary: UiWidgetActionSummary, - *, - action_kind: WidgetActionKind, - ) -> UiWidgetActionInvokeResult: - guard_error = self._descriptor_guard_error(request, descriptor, action_kind) - if guard_error is not None: - return self.error( - request, - guard_error, - summary=action_summary, - action_kind=action_kind.value, - ) - - resolution = UiWidgetPathResolver.item_view_index_at_path(target.widget, path) - if resolution is None: - return self.error( - request, - AgentError( - code=UiWidgetActionIssueCode.ACTION_UNSUPPORTED.value, - message=( - f"Widget path_id {request.path_id!r} does not resolve to " - "an actionable item-view row." - ), - ), - summary=action_summary, - action_kind=action_kind.value, - ) - - action_kind.item_action.defer(resolution.view, resolution.index) - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=action_kind.value, - invoked=True, - receipt=UiMutationReceipt.accepted_for(request.request_token), - summary=action_summary, - ) - - def _resolve_action_kind( - self, - request: UiWidgetActionInvokeRequest, - descriptor: WidgetDescriptor, - ) -> WidgetActionKind | AgentError: - if request.action_kind == self.AUTO_ACTION_KIND: - if len(descriptor.action_kinds) == 1: - return next(iter(descriptor.action_kinds)) - default_actions = tuple( - kind - for kind in descriptor.action_kinds - if kind.item_action is not None and kind.item_action.default - ) - if len(default_actions) == 1: - return default_actions[0] - return self._action_kind_unavailable_error(request, descriptor) - try: - action_kind = WidgetActionKind(request.action_kind) - except ValueError: - return self._action_kind_unavailable_error(request, descriptor) - if action_kind not in descriptor.action_kinds: - return self._action_kind_unavailable_error(request, descriptor) - return action_kind - - def _guard_error( - self, - request: UiWidgetActionInvokeRequest, - descriptor: WidgetDescriptor, - widget: QWidget, - action_kind: WidgetActionKind, - ) -> AgentError | None: - if not descriptor.visible or not widget.isVisible(): - return AgentError( - code=UiWidgetActionIssueCode.NOT_VISIBLE.value, - message=f"Widget path_id {request.path_id!r} is not visible.", - ) - if not descriptor.enabled or not widget.isEnabled(): - return AgentError( - code=UiWidgetActionIssueCode.DISABLED.value, - message=f"Widget path_id {request.path_id!r} is disabled.", - ) - if not descriptor.clickable: - return AgentError( - code=UiWidgetActionIssueCode.NOT_CLICKABLE.value, - message=f"Widget path_id {request.path_id!r} is not clickable.", - ) - return None - - def _descriptor_guard_error( - self, - request: UiWidgetActionInvokeRequest, - descriptor: WidgetDescriptor, - action_kind: WidgetActionKind, - ) -> AgentError | None: - if not descriptor.visible: - return AgentError( - code=UiWidgetActionIssueCode.NOT_VISIBLE.value, - message=f"Widget path_id {request.path_id!r} is not visible.", - ) - if not descriptor.enabled: - return AgentError( - code=UiWidgetActionIssueCode.DISABLED.value, - message=f"Widget path_id {request.path_id!r} is disabled.", - ) - if not descriptor.clickable: - return AgentError( - code=UiWidgetActionIssueCode.NOT_CLICKABLE.value, - message=f"Widget path_id {request.path_id!r} is not clickable.", - ) - return None - - @staticmethod - def _action_kind_unavailable_error( - request: UiWidgetActionInvokeRequest, - descriptor: WidgetDescriptor, - ) -> AgentError: - action_kinds = tuple(kind.value for kind in descriptor.action_kinds) - return AgentError( - code=UiWidgetActionIssueCode.ACTION_KIND_UNAVAILABLE.value, - message=( - f"Widget path_id {request.path_id!r} does not expose " - f"action kind {request.action_kind!r}." - ), - hint=f"Available action kinds: {', '.join(action_kinds) or 'none'}.", - ) - - @staticmethod - def error( - request: UiWidgetActionInvokeRequest, - error: AgentError, - *, - summary: UiWindowSummary | UiWidgetActionSummary | None = None, - action_kind: str | None = None, - ) -> UiWidgetActionInvokeResult: - action_summary = summary if isinstance(summary, UiWidgetActionSummary) else None - return UiWidgetActionInvokeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - path_id=request.path_id, - action_kind=action_kind or request.action_kind, - invoked=False, - receipt=UiMutationReceipt.rejected_for(request.request_token), - summary=action_summary, - errors=(error,), - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetGeometryKey: - """Stable enough bridge key for matching live form widgets to descriptors.""" - - x: int - y: int - width: int - height: int - - @classmethod - def from_qwidget(cls, widget: QWidget) -> "_WidgetGeometryKey": - rect = widget.rect() - top_left = widget.mapToGlobal(rect.topLeft()) - return cls( - x=top_left.x(), - y=top_left.y(), - width=rect.width(), - height=rect.height(), - ) - - @classmethod - def from_descriptor(cls, descriptor: WidgetDescriptor) -> "_WidgetGeometryKey": - rect = descriptor.global_geometry - return cls( - x=rect.x, - y=rect.y, - width=rect.width, - height=rect.height, - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetFieldSemanticRecord: - """Field semantics for one live widget action.""" - - action_role: str - projection: ObjectStateFieldSemanticProjection - - def to_action_carrier(self, *, window_id: str) -> UiWidgetActionSemanticCarrier: - return project_dataclass( - UiWidgetActionSemanticCarrier, - self.projection.to_semantic_carrier(include_values=True), - action_role=self.action_role, - semantic_address=self.projection.semantic_address(window_id=window_id), - object_state_scope_id=self.projection.agent_scope_id, - field_path=self.projection.field_path, - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetFieldSemanticContext: - """Form-manager-backed lookup for widget action ObjectState semantics.""" - - window_id: str - records_by_geometry: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord] - - @classmethod - def empty(cls, window_id: str) -> "_WidgetFieldSemanticContext": - return cls(window_id=window_id, records_by_geometry={}) - - @classmethod - def from_target( - cls, - target: WindowProjectionTarget, - ) -> "_WidgetFieldSemanticContext": - if not isinstance(target.widget, BaseManagedWindow): - return cls.empty(target.summary.window_id) - - records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord] = {} - for form_manager in target.widget.form_managers(): - cls._collect_form_manager(records, form_manager) - return cls( - window_id=target.summary.window_id, - records_by_geometry=records, - ) - - @classmethod - def _collect_form_manager( - cls, - records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord], - form_manager: ParameterFormManager, - ) -> None: - state = form_manager.state - if not isinstance(state, ObjectState): - return - - for field_name, widget in form_manager.widgets.items(): - cls._add_field_widget( - records, - form_manager=form_manager, - state=state, - field_name=field_name, - widget=widget, - action_role=FIELD_INPUT_ACTION_ROLE, - ) - for field_name, widget in form_manager.reset_buttons.items(): - cls._add_field_widget( - records, - form_manager=form_manager, - state=state, - field_name=field_name, - widget=widget, - action_role=FIELD_RESET_ACTION_ROLE, - ) - for nested_manager in form_manager.nested_managers.values(): - cls._collect_form_manager(records, nested_manager) - - @classmethod - def _add_field_widget( - cls, - records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord], - *, - form_manager: ParameterFormManager, - state: ObjectState, - field_name: str, - widget: QWidget, - action_role: str, - ) -> None: - field_path = cls._field_path(form_manager, field_name) - if field_path not in state.parameters: - return - records[_WidgetGeometryKey.from_qwidget(widget)] = _WidgetFieldSemanticRecord( - action_role=action_role, - projection=ObjectStateFieldSemanticProjection.of( - state, - field_path, - ), - ) - - @staticmethod - def _field_path(form_manager: ParameterFormManager, field_name: str) -> str: - if form_manager.field_id: - return f"{form_manager.field_id}.{field_name}" - return field_name - - def for_descriptor( - self, - descriptor: WidgetDescriptor, - ) -> _WidgetFieldSemanticRecord | None: - return self.records_by_geometry.get( - _WidgetGeometryKey.from_descriptor(descriptor) - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetItemSemanticRecord: - """ObjectState semantics already attached to one projected item-view row.""" - - agent_scope_id: str | None - dirty_fields: tuple[str, ...] - signature_diff_fields: tuple[str, ...] - - @classmethod - def from_index(cls, index: QModelIndex) -> "_WidgetItemSemanticRecord": - scope_value = index.data(OBJECT_STATE_PATH_ROLE) - agent_scope_id = None - if isinstance(scope_value, str): - agent_scope_id = _agent_object_state_scope_id(scope_value) - return cls( - agent_scope_id=agent_scope_id, - dirty_fields=cls._field_tuple(index.data(DIRTY_FIELDS_ROLE)), - signature_diff_fields=cls._field_tuple(index.data(SIG_DIFF_FIELDS_ROLE)), - ) - - @staticmethod - def _field_tuple(value) -> tuple[str, ...]: - if isinstance(value, str): - return (value,) - if isinstance(value, (frozenset, list, set, tuple)): - return tuple(field for field in value if isinstance(field, str)) - return () - - @property - def has_semantics(self) -> bool: - return bool( - self.agent_scope_id or self.dirty_fields or self.signature_diff_fields - ) - - @property - def dirty(self) -> bool: - return bool(self.dirty_fields) - - @property - def signature_diff(self) -> bool: - return bool(self.signature_diff_fields) - - @property - def semantic_markers(self) -> tuple[str, ...]: - return field_markers(dirty=self.dirty, signature_diff=self.signature_diff) - - def to_action_carrier(self) -> UiWidgetActionSemanticCarrier: - return UiWidgetActionSemanticCarrier( - action_role=ITEM_SELECT_ACTION_ROLE, - object_state_scope_id=self.agent_scope_id, - dirty=self.dirty, - signature_diff=self.signature_diff, - semantic_markers=self.semantic_markers, - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetItemSemanticContext: - """Resolve shared manager-list item roles for projected item rows.""" - - root_widget: QWidget | None - - @classmethod - def empty(cls) -> "_WidgetItemSemanticContext": - return cls(root_widget=None) - - @classmethod - def from_target( - cls, - target: WindowProjectionTarget, - ) -> "_WidgetItemSemanticContext": - return cls(root_widget=target.widget) - - def for_descriptor( - self, - descriptor: WidgetDescriptor, - ) -> _WidgetItemSemanticRecord | None: - if self.root_widget is None: - return None - resolution = UiWidgetPathResolver.item_view_index_at_path( - self.root_widget, - descriptor.path, - ) - if resolution is None: - return None - record = _WidgetItemSemanticRecord.from_index(resolution.index) - if not record.has_semantics: - return None - return record - - -class UiWidgetTreeResultFactory: - """Build UI bridge widget-tree results from resolved Qt window targets.""" - - def project( - self, - request: UiWidgetTreeRequest, - target: WindowProjectionTarget, - ) -> UiWidgetTreeResult: - try: - projection = WidgetTreeProjectionService.project( - target.widget, - policy=request.as_projection_policy(), - ) - except Exception as exc: - return self.error( - request, - AgentError.from_exception("ui_widget_tree_projection_failed", exc), - summary=target.summary, - ) - return self.from_projection( - request, - target.summary, - projection, - field_semantics=_WidgetFieldSemanticContext.from_target(target), - item_semantics=_WidgetItemSemanticContext.from_target(target), - ) - - @classmethod - def from_projection( - cls, - request: UiWidgetTreeRequest, - summary: UiWindowSummary, - projection: WidgetTreeProjection, - field_semantics: _WidgetFieldSemanticContext | None = None, - item_semantics: _WidgetItemSemanticContext | None = None, - ) -> UiWidgetTreeResult: - if field_semantics is None: - field_semantics = _WidgetFieldSemanticContext.empty(summary.window_id) - if item_semantics is None: - item_semantics = _WidgetItemSemanticContext.empty() - action_draft = cls._draft_node( - projection.root, - actionable_only=False, - ) - assert action_draft is not None - draft = cls._draft_node( - projection.root, - actionable_only=request.actionable_only, - ) - actionable_widgets = tuple( - cls.action_summaries_from_draft( - action_draft, - field_semantics=field_semantics, - item_semantics=item_semantics, - ) - ) - root = None - returned_widget_count = 0 - if request.include_tree and draft is not None: - root = cls.node_from_draft(draft) - returned_widget_count = cls.included_node_count(draft) - return UiWidgetTreeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - projected=True, - root=root, - actionable_widgets=actionable_widgets, - summary=summary, - widget_count=projection.widget_count, - actionable_count=len(actionable_widgets), - returned_widget_count=returned_widget_count, - returned_actionable_count=len(actionable_widgets), - tree_truncated=projection.truncated, - actionable_widgets_truncated=projection.truncated, - actionable_only=request.actionable_only, - include_tree=request.include_tree, - max_depth=request.max_depth, - max_nodes=request.max_nodes, - ) - - @classmethod - def _draft_node( - cls, - descriptor: WidgetDescriptor, - *, - actionable_only: bool, - ) -> "_WidgetTreeNodeDraft | None": - children: list[_WidgetTreeNodeDraft] = [] - for child in descriptor.children: - child_draft = cls._draft_node(child, actionable_only=actionable_only) - if child_draft is not None: - children.append(child_draft) - - if ( - descriptor.path - and actionable_only - and not descriptor.actionable - and not children - ): - return None - return _WidgetTreeNodeDraft(descriptor, tuple(children)) - - @classmethod - def node_from_draft( - cls, - draft: "_WidgetTreeNodeDraft", - ) -> UiWidgetTreeNode: - return cls.node( - draft.descriptor, - children=tuple(cls.node_from_draft(child) for child in draft.children), - ) - - @classmethod - def included_node_count(cls, draft: "_WidgetTreeNodeDraft") -> int: - return 1 + sum(cls.included_node_count(child) for child in draft.children) - - @classmethod - def action_summaries_from_draft( - cls, - draft: "_WidgetTreeNodeDraft", - *, - field_semantics: _WidgetFieldSemanticContext, - item_semantics: _WidgetItemSemanticContext, - ancestors: tuple["_WidgetTreeNodeDraft", ...] = (), - ): - if draft.descriptor.actionable: - summary = cls.action_summary( - draft.descriptor, - context_label=cls.action_context_label(draft, ancestors), - field_semantics=field_semantics, - item_semantics=item_semantics, - ) - if UiWidgetActionSummaryPolicy.includes(summary): - yield summary - - for child in draft.children: - yield from cls.action_summaries_from_draft( - child, - field_semantics=field_semantics, - item_semantics=item_semantics, - ancestors=(*ancestors, draft), - ) - - @classmethod - def action_summary( - cls, - descriptor: WidgetDescriptor, - *, - context_label: str | None = None, - field_semantics: _WidgetFieldSemanticContext | None = None, - item_semantics: _WidgetItemSemanticContext | None = None, - ) -> UiWidgetActionSummary: - label = cls.action_label(descriptor) - action_kinds = tuple(kind.value for kind in descriptor.action_kinds) - summary = project_dataclass( - UiWidgetActionSummary, - descriptor, - label=label, - geometry=cls.rect(descriptor.geometry), - global_geometry=cls.rect(descriptor.global_geometry), - action_kinds=action_kinds, - context_label=context_label, - ) - return overlay_non_none_dataclass( - summary, - cls.action_semantic_carrier( - descriptor, - label=label, - action_kinds=action_kinds, - field_semantics=field_semantics, - item_semantics=item_semantics, - ), - ) - - @staticmethod - def action_semantic_carrier( - descriptor: WidgetDescriptor, - *, - label: str | None, - action_kinds: tuple[str, ...], - field_semantics: _WidgetFieldSemanticContext | None, - item_semantics: _WidgetItemSemanticContext | None, - ) -> UiWidgetActionSemanticCarrier: - if field_semantics is not None: - field_record = field_semantics.for_descriptor(descriptor) - if field_record is not None: - return field_record.to_action_carrier( - window_id=field_semantics.window_id - ) - if item_semantics is not None: - item_record = item_semantics.for_descriptor(descriptor) - if item_record is not None: - return item_record.to_action_carrier() - if FieldResetWidgetActionSummary.matches_fields( - class_name=descriptor.class_name, - label=label, - object_name=descriptor.object_name, - action_kinds=action_kinds, - ): - return UiWidgetActionSemanticCarrier(action_role=FIELD_RESET_ACTION_ROLE) - return UiWidgetActionSemanticCarrier() - - @classmethod - def action_context_label( - cls, - draft: "_WidgetTreeNodeDraft", - ancestors: tuple["_WidgetTreeNodeDraft", ...], - ) -> str | None: - branch = draft - for ancestor in reversed(ancestors): - siblings = tuple( - child - for child in ancestor.children - if child.descriptor.path != branch.descriptor.path - ) - if siblings: - label = cls._first_context_label(siblings) - if label: - return label - branch = ancestor - return None - - @classmethod - def _first_context_label( - cls, - drafts: tuple["_WidgetTreeNodeDraft", ...], - ) -> str | None: - for draft in drafts: - descriptor = draft.descriptor - if not descriptor.actionable and not descriptor.action_kinds: - label = cls._descriptor_context_label(descriptor) - if label: - return label - child_label = cls._first_context_label(draft.children) - if child_label: - return child_label - return None - - @staticmethod - def _descriptor_context_label(descriptor: WidgetDescriptor) -> str | None: - for label in ( - descriptor.text, - descriptor.title, - descriptor.accessible_name, - ): - if label: - return label - return None - - @staticmethod - def action_label(descriptor: WidgetDescriptor) -> str | None: - for label in ( - descriptor.text, - descriptor.title, - descriptor.accessible_name, - descriptor.current_text, - descriptor.object_name, - ): - if label: - return label - return None - - @classmethod - def node( - cls, - descriptor: WidgetDescriptor, - *, - children: tuple[UiWidgetTreeNode, ...] | None = None, - ) -> UiWidgetTreeNode: - return UiWidgetTreeNode( - path=descriptor.path, - path_id=descriptor.path_id, - child_index=descriptor.child_index, - class_name=descriptor.class_name, - object_name=descriptor.object_name, - visible=descriptor.visible, - enabled=descriptor.enabled, - geometry=cls.rect(descriptor.geometry), - global_geometry=cls.rect(descriptor.global_geometry), - tool_tip=descriptor.tool_tip, - status_tip=descriptor.status_tip, - whats_this=descriptor.whats_this, - window_title=descriptor.window_title, - accessible_name=descriptor.accessible_name, - accessible_description=descriptor.accessible_description, - text=descriptor.text, - text_truncated=descriptor.text_truncated, - title=descriptor.title, - action_kinds=tuple(kind.value for kind in descriptor.action_kinds), - clickable=descriptor.clickable, - actionable=descriptor.actionable, - checkable=descriptor.checkable, - checked=descriptor.checked, - current_index=descriptor.current_index, - current_text=descriptor.current_text, - item_count=descriptor.item_count, - item_texts=descriptor.item_texts, - children=( - tuple(cls.node(child) for child in descriptor.children) - if children is None - else children - ), - ) - - @staticmethod - def rect(rect: WidgetRect) -> UiWidgetRect: - return UiWidgetRect( - x=rect.x, - y=rect.y, - width=rect.width, - height=rect.height, - ) - - @staticmethod - def error( - request: UiWidgetTreeRequest, - error: AgentError, - *, - summary: UiWindowSummary | None = None, - ) -> UiWidgetTreeResult: - return UiWidgetTreeResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - projected=False, - summary=summary, - errors=(error,), - actionable_only=request.actionable_only, - include_tree=request.include_tree, - max_depth=request.max_depth, - max_nodes=request.max_nodes, - ) - - -@dataclass(frozen=True, slots=True) -class _WidgetTreeNodeDraft: - descriptor: WidgetDescriptor - children: tuple["_WidgetTreeNodeDraft", ...] - - -class FieldResetWidgetActionSummary: - """Action-summary rule for per-field reset buttons.""" - - BUTTON_CLASS_NAME = "QPushButton" - LABEL = PARAMETER_FORM_CONSTANTS.RESET_BUTTON_TEXT - OBJECT_NAME_SUFFIX = ( - f"{PARAMETER_FORM_CONSTANTS.FIELD_ID_SEPARATOR}" - f"{PARAMETER_FORM_CONSTANTS.RESET_BUTTON_TEXT.lower()}" - ) - - @classmethod - def matches(cls, summary: UiWidgetActionSummary) -> bool: - return cls.matches_fields( - class_name=summary.class_name, - label=summary.label, - object_name=summary.object_name, - action_kinds=summary.action_kinds, - ) - - @classmethod - def matches_fields( - cls, - *, - class_name: str, - label: str | None, - object_name: str, - action_kinds: tuple[str, ...], - ) -> bool: - return ( - class_name == cls.BUTTON_CLASS_NAME - and label == cls.LABEL - and object_name.endswith(cls.OBJECT_NAME_SUFFIX) - and action_kinds == ("button",) - ) - - -class UiWidgetActionSummaryPolicy: - """Inclusion policy for action summaries.""" - - @staticmethod - def includes(summary: UiWidgetActionSummary) -> bool: - del summary - return True - - -class UiWindowProjectionService( - ScopeWindowTargetOperationProjectionABC, - WindowCatalogProjectionABC, -): - """Project the main-window/WindowManager window graph into agent DTOs.""" - - projection_id = MAIN_WINDOW_PROVIDER_ID - identity = UiWindowProviderIdentity( - provider_id=MAIN_WINDOW_PROVIDER_ID, - title="Main window windows", - ) - - def __init__(self, main_window: "OpenHCSMainWindow") -> None: - self._embedded = EmbeddedWindowProjection(main_window) - self._managed = ManagedWindowProjection(main_window) - self._dynamic = DynamicScopeWindowProjection() - - def summaries(self) -> tuple[UiWindowSummary, ...]: - route_index = self._route_index() - return ( - tuple(route.summary() for route in route_index.embedded_routes) - + tuple(route.summary() for route in route_index.managed_routes) - + self._dynamic.summaries(route_index) - ) - - def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: - route_index = self._route_index() - return WindowCatalogProjectionABC.overview_sections(self) + tuple( - section - for route in route_index.embedded_routes - for section in route.overview_sections() - ) - - def handles(self, window_id: str) -> bool: - identity = UiWindowIdentity(window_id=window_id) - route_index = self._route_index() - if route_index.embedded_route(identity) is not None: - return True - if route_index.managed_route(identity) is not None: - return True - if identity.window_id in WindowManager.get_open_scopes(): - return True - if self._open_window_manager_scope_id(identity) is not None: - return True - return ScopeWindowRegistry.find_handler(identity.window_id) is not None - - def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: - identity = request.as_identity() - route_index = self._route_index() - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if embedded_route is not None: - return self._focused_result(request, embedded_route.focus()) - - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if managed_route is not None and request.open_policy.create_if_missing: - return self._focused_result(request, managed_route.focus()) - - open_scope_id = self._open_window_manager_scope_id(identity) - if open_scope_id is not None: - if WindowManager.focus_and_navigate(open_scope_id): - return self._focused_result( - request, - self._dynamic_scope_projection().summary( - identity, - open_scope_id, - ), - ) - - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if embedded_route is not None: - return self._focused_result(request, embedded_route.focus()) - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if managed_route is not None and request.open_policy.create_if_missing: - return self._focused_result(request, managed_route.focus()) - - result = ScopeWindowNavigationService.navigate( - WindowNavigationRequest( - scope_id=identity.window_id, - create_if_missing=request.open_policy.create_if_missing, - ) - ) - if result.focused: - return self._focused_result( - request, - self._dynamic_scope_projection().summary( - identity, - result.window_scope_id, - ), - ) - - return UiWindowFocusResult( - schema_version=SCHEMA_VERSION, - window_id=identity.window_id, - focused=False, - errors=(WindowProjectionResultAuthority.unknown_window(identity),), - ) - - def _navigate_static_route( - self, - request: UiWindowNavigateRequest, - resolution: WindowRouteResolution, - completed: Callable[[UiWindowNavigateResult], None] | None = None, - ) -> UiWindowNavigateResult: - dispatch = resolution.route.navigate( - request, - self._completion_for( - request, - completed, - created=False, - summary=resolution.summary, - ), - ) - return self._navigate_result( - request, - focused=dispatch.focused, - created=False, - navigated=dispatch.navigated, - summary=resolution.summary(), - ) - - def navigate( - self, - request: UiWindowNavigateRequest, - completed: Callable[[UiWindowNavigateResult], None] | None = None, - ) -> UiWindowNavigateResult: - identity = request.as_identity() - route_index = self._route_index() - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if embedded_route is not None: - return self._navigate_static_route(request, embedded_route, completed) - - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if managed_route is not None and request.open_policy.create_if_missing: - return self._navigate_static_route(request, managed_route, completed) - - open_scope_id = self._open_window_manager_scope_id(identity) - if open_scope_id is not None: - dispatch = WindowManager.focus_and_navigate_result( - open_scope_id, - item_id=request.item_id, - field_path=request.field_path, - requested_scope_id=identity.window_id, - completed=self._completion_for( - request, - completed, - created=False, - summary=lambda: self._dynamic_scope_projection().summary( - identity, open_scope_id - ), - ), - ) - if dispatch.focused: - return self._navigate_result( - request, - focused=True, - created=False, - navigated=dispatch.navigated, - summary=self._dynamic_scope_projection().summary( - identity, - open_scope_id, - ), - ) - - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if embedded_route is not None: - return self._navigate_static_route(request, embedded_route, completed) - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if managed_route is not None and request.open_policy.create_if_missing: - return self._navigate_static_route(request, managed_route, completed) - - result = ScopeWindowNavigationService.navigate( - WindowNavigationRequest( - scope_id=identity.window_id, - item_id=request.item_id, - field_path=request.field_path, - create_if_missing=request.open_policy.create_if_missing, - ), - completed=self._completion_for( - request, - completed, - created=True, - summary=lambda: self._dynamic_scope_projection().summary( - identity, identity.window_id - ), - ), - ) - if result.focused: - return self._navigate_result( - request, - focused=True, - created=result.created, - navigated=result.navigated, - summary=self._dynamic_scope_projection().summary( - identity, - result.window_scope_id, - ), - ) - - return UiWindowNavigateResult( - schema_version=SCHEMA_VERSION, - window_id=identity.window_id, - focused=False, - navigated=False, - created=result.created, - errors=(WindowProjectionResultAuthority.unknown_window(identity),), - ) - - def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: - identity = request.as_identity() - route_index = self._route_index() - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if embedded_route is not None: - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_close_unsupported", - "Embedded main-window panes cannot be closed through this UI bridge operation.", - summary=embedded_route.summary(), - ) - - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=False, - ) - if managed_route is not None: - return self._close_window_manager_scope( - request, - managed_route.summary(), - ) - - open_scope_id = self._open_window_manager_scope_id(identity) - if open_scope_id is not None: - return self._close_window_manager_scope( - request, - self._dynamic_scope_projection().summary( - identity, - open_scope_id, - ), - ) - - embedded_route = self._embedded_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if embedded_route is not None: - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_close_unsupported", - "Embedded main-window panes cannot be closed through this UI bridge operation.", - summary=embedded_route.summary(), - ) - managed_route = self._managed_route_resolution( - identity, - route_index, - resolve_scope_alias=True, - ) - if managed_route is not None: - return self._close_window_manager_scope( - request, - managed_route.summary(), - ) - - scope = UiWindowManagerScope.from_identity( - self._resolved_window_identity(identity) - ) - scope_widget = WindowManager.get_window(scope.value) - if scope_widget is not None: - return self._close_window_manager_scope( - request, - self._dynamic_scope_projection().summary( - identity, - scope.value, - ), - ) - - if ScopeWindowRegistry.find_handler(identity.window_id) is not None: - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_not_open", - f"UI window scope is not currently open: {identity.window_id!r}", - ) - - return WindowCloseResultBoundaryPolicy.error( - request, - "unknown_ui_window", - f"Unknown or closed UI window: {identity.window_id!r}", - ) - - @staticmethod - def _close_window_manager_scope( - request: UiWindowCloseRequest, - summary: UiWindowSummary, - ) -> UiWindowCloseResult: - scope = summary.manager_scope - if scope is None: - scope = UiWindowManagerScope.from_identity(request.as_identity()) - if WindowManager.close_window(scope.value): - return WindowCloseResultBoundaryPolicy.closed( - request, - summary, - ) - return WindowCloseResultBoundaryPolicy.error( - request, - "ui_window_close_rejected", - f"WindowManager rejected the close request for scope: {scope.value!r}", - summary=summary, - ) - - def _route_index(self) -> WindowRouteIndex: - embedded_routes = self._embedded.routes() - return WindowRouteIndex( - embedded_routes=embedded_routes, - managed_routes=self._managed.routes(embedded_routes), - ) - - def _dynamic_scope_projection(self) -> DynamicScopeWindowProjection: - return self._dynamic - - @staticmethod - def _focused_result( - request: UiWindowFocusRequest, - summary: UiWindowSummary, - ) -> UiWindowFocusResult: - return UiWindowFocusResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=True, - summary=summary, - ) - - @staticmethod - def _completion_for( - request: UiWindowNavigateRequest, - completed: Callable[[UiWindowNavigateResult], None] | None, - *, - created: bool, - summary: Callable[[], UiWindowSummary], - ) -> Callable[[RegisteredWindowNavigationCompletion], None] | None: - if completed is None: - return None - - def finish(facts: RegisteredWindowNavigationCompletion) -> None: - errors = () - if facts.error is not None: - errors = ( - AgentError.from_exception( - UiBridgeNavigateWindowOperation.failure_error_code, facts.error - ), - ) - elif not facts.executed: - errors = ( - AgentError( - code=UiBridgeNavigateWindowOperation.failure_error_code, - message=( - "Window was destroyed before navigation." - if not facts.window_alive - else f"Navigation readiness exhausted: {facts.wait_reason}." - ), - ), - ) - completed( - UiWindowNavigateResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=facts.window_alive, - created=created, - navigated=facts.executed, - target_exposed=facts.target_exposed, - summary=summary() if facts.window_alive else None, - errors=errors, - ) - ) - - return finish - - @staticmethod - def _navigate_result( - request: UiWindowNavigateRequest, - *, - focused: bool, - created: bool, - navigated: bool, - summary: UiWindowSummary, - ) -> UiWindowNavigateResult: - return UiWindowNavigateResult( - schema_version=SCHEMA_VERSION, - window_id=request.window_id, - focused=focused, - created=created, - navigated=navigated, - summary=summary, - errors=( - () - if navigated or not request.has_target - else ( - WindowProjectionResultAuthority.unsupported_navigation_target( - request - ), - ) - ), - ) - - -class ManagedWindowActionProvider(UiActionProviderABC): - """Action provider for generic WindowManager-managed form windows.""" - - identity = MANAGED_WINDOW_ACTION_PROVIDER_IDENTITY - dispatch_error_code = "managed_window_action_failed" - - def action_ids(self) -> tuple[str, ...]: - return tuple(action.value for action in ManagedWindowAction) - - def summary(self, action_id: str) -> UiActionSummary: - action = self._action(action_id) - target_scope_ids = self._target_scope_ids(action) - return UiActionSummary( - schema_version=SCHEMA_VERSION, - identity=UiActionIdentity( - widget_id=self.identity.widget_id, - action_id=action.value, - ), - title=action.title, - enabled=bool(target_scope_ids), - invocation_mode="async", - side_effects=action.side_effects, - confirmation_required=action.confirmation_required, - selection_mode="targeted", - required_target_count=1, - current_selection_count=len(target_scope_ids), - target_scope_ids=target_scope_ids, - ) - - def dispatch(self, request: UiActionInvokeRequest) -> UiActionDispatch: - action = self._action(request.action_id) - (target_scope_id,) = request.selected_scope_ids - action.dispatch(self._target_window(target_scope_id, action)) - return UiActionDispatch() - - @staticmethod - def _action(action_id: str) -> ManagedWindowAction: - return ManagedWindowAction(action_id) - - @staticmethod - def _target_window( - scope_id: str, - action: ManagedWindowAction, - ) -> BaseFormDialog: - window = WindowManager.get_window(scope_id) - if not isinstance(window, BaseFormDialog): - raise ValueError(f"Window scope is not a managed form window: {scope_id!r}") - capabilities = window.managed_window_action_capabilities() - if not action.is_supported(capabilities): - raise ValueError(f"Window does not support {action.value!r}: {scope_id!r}") - return window - - @classmethod - def _target_scope_ids( - cls, - action: ManagedWindowAction, - ) -> tuple[str, ...]: - return tuple( - scope_id - for scope_id in WindowManager.get_open_scopes() - if cls._window_supports_action(WindowManager.get_window(scope_id), action) - ) - - @staticmethod - def _window_supports_action( - window: QWidget | None, - action: ManagedWindowAction, - ) -> bool: - if not isinstance(window, BaseFormDialog): - return False - return action.is_supported(window.managed_window_action_capabilities()) - - - @dataclass(frozen=True, slots=True) class MainWindowBridgeProviderSet(UiBridgeProviderSetABC): """Provider set for main-window generic UI projections.""" diff --git a/openhcs/pyqt_gui/services/ui_window_ids.py b/openhcs/pyqt_gui/services/ui_window_ids.py index 59a60a848e..e6c69969ef 100644 --- a/openhcs/pyqt_gui/services/ui_window_ids.py +++ b/openhcs/pyqt_gui/services/ui_window_ids.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping -from types import MappingProxyType from typing import ClassVar from openhcs.agent.ui_bridge_identities import ( @@ -14,7 +12,7 @@ LogViewerWindowIdentity, MainWindowWidgetIdentity, PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, SystemMonitorWindowIdentity, ZmqServerManagerWindowIdentity, ) @@ -24,7 +22,7 @@ class OpenHCSUiWindowId: """Closed identifiers for stable windows exposed through the UI bridge.""" main_window: ClassVar[str] = MainWindowWidgetIdentity.require_value() - plate_manager: ClassVar[str] = PlateManagerWidgetIdentity.require_value() + plate_manager: ClassVar[str] = DatasetListWidgetIdentity.require_value() pipeline_editor: ClassVar[str] = PipelineEditorWidgetIdentity.require_value() system_monitor: ClassVar[str] = SystemMonitorWindowIdentity.require_value() zmq_server_manager: ClassVar[str] = ZmqServerManagerWindowIdentity.require_value() @@ -33,34 +31,3 @@ class OpenHCSUiWindowId: global_config: ClassVar[str] = GlobalConfigWindowIdentity.require_value() knowledge_base: ClassVar[str] = KnowledgeBaseWindowIdentity.require_value() about: ClassVar[str] = AboutOpenHCSWindowIdentity.require_value() - - manager_scope_aliases: ClassVar[Mapping[str, str]] = MappingProxyType( - { - "": global_config, - } - ) - - @classmethod - def agent_window_id_for_manager_scope(cls, manager_scope_id: str) -> str: - if manager_scope_id in cls.manager_scope_aliases: - return cls.manager_scope_aliases[manager_scope_id] - return manager_scope_id - - @classmethod - def manager_scopes_for_agent_window_id( - cls, agent_window_id: str - ) -> tuple[str, ...]: - aliased_scopes = tuple( - manager_scope_id - for manager_scope_id, aliased_window_id in cls.manager_scope_aliases.items() - if aliased_window_id == agent_window_id - and manager_scope_id != agent_window_id - ) - return (agent_window_id,) + aliased_scopes - - @classmethod - def canonical_manager_scope_for_agent_window_id(cls, agent_window_id: str) -> str: - for manager_scope_id, aliased_window_id in cls.manager_scope_aliases.items(): - if aliased_window_id == agent_window_id: - return manager_scope_id - return agent_window_id diff --git a/openhcs/pyqt_gui/services/window_handlers.py b/openhcs/pyqt_gui/services/window_handlers.py index 09491d9c48..b41575a7ae 100644 --- a/openhcs/pyqt_gui/services/window_handlers.py +++ b/openhcs/pyqt_gui/services/window_handlers.py @@ -19,6 +19,7 @@ from openhcs.authoring.session.step_scopes import StepEditorScope from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration if TYPE_CHECKING: from objectstate.object_state import ObjectState @@ -131,7 +132,7 @@ def handle_ui_save(new_config: UIConfig) -> None: ), ), ), - scope_id=OpenHCSUiWindowId.canonical_manager_scope_for_agent_window_id( + scope_id=UiStableWindowIdentityDeclaration.manager_scope_for_window_id( global_config_window_scope_id(request.scope_id) ), title_text=GlobalConfigWindowIdentity.require_title(), @@ -355,7 +356,7 @@ def _select_step_editor_tab( window.tab_widget.setCurrentIndex(tab_index) def _plate_manager(self): - from pyqt_reactive.services.service_registry import ServiceRegistry + from formbridge.services.service_registry import ServiceRegistry from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget diff --git a/openhcs/pyqt_gui/session_rendering.py b/openhcs/pyqt_gui/session_rendering.py index 76b7be501c..0a7466690c 100644 --- a/openhcs/pyqt_gui/session_rendering.py +++ b/openhcs/pyqt_gui/session_rendering.py @@ -18,7 +18,7 @@ from metaclass_registry import AutoRegisterMeta from PyQt6.QtCore import QObject, pyqtSignal -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.agent.dto.session import SessionOperationResult from openhcs.authoring.session.operations import SessionOperation from openhcs.authoring.session.operations.datasets import PromptedOperation diff --git a/openhcs/pyqt_gui/ui_tab_identities.py b/openhcs/pyqt_gui/ui_tab_identities.py index 6d6a772a10..6d124842ef 100644 --- a/openhcs/pyqt_gui/ui_tab_identities.py +++ b/openhcs/pyqt_gui/ui_tab_identities.py @@ -5,7 +5,7 @@ from enum import Enum from typing import TYPE_CHECKING, Self -from pyqt_reactive.services.tab_identity import TabLabelDeclarationMixin +from formbridge.services.tab_identity import TabLabelDeclarationMixin if TYPE_CHECKING: from pyqt_reactive.widgets.shared import ActionTabbedWindowBody diff --git a/openhcs/pyqt_gui/widgets/config_preview_formatters.py b/openhcs/pyqt_gui/widgets/config_preview_formatters.py index 70c6987cfd..25850e418f 100644 --- a/openhcs/pyqt_gui/widgets/config_preview_formatters.py +++ b/openhcs/pyqt_gui/widgets/config_preview_formatters.py @@ -2,10 +2,7 @@ from typing import TYPE_CHECKING -from pyqt_reactive.utils.preview_formatters import ( - check_enabled_field, - resolve_preview_label, -) +from formbridge.utils.preview_formatters import check_enabled_field, resolve_preview_label if TYPE_CHECKING: from openhcs.core.config import PartitionFilterConfig diff --git a/openhcs/pyqt_gui/widgets/debug_toolbar.py b/openhcs/pyqt_gui/widgets/debug_toolbar.py index 593422391b..9a28cd30cd 100644 --- a/openhcs/pyqt_gui/widgets/debug_toolbar.py +++ b/openhcs/pyqt_gui/widgets/debug_toolbar.py @@ -11,9 +11,7 @@ ) from openhcs.core.debug import DebugCommandType from openhcs.core.execution_state import ManagerExecutionState -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiOwnedStateSurfaceDeclaration, -) +from formbridge.bridge.contracts import UiOwnedStateSurfaceDeclaration from openhcs.pyqt_gui.widgets.shared.services.debug_session_projection import ( DebugActionRenderModel, DebugSessionPanelText, diff --git a/openhcs/pyqt_gui/widgets/pipeline_editor.py b/openhcs/pyqt_gui/widgets/pipeline_editor.py index cb99806d97..be6505e476 100644 --- a/openhcs/pyqt_gui/widgets/pipeline_editor.py +++ b/openhcs/pyqt_gui/widgets/pipeline_editor.py @@ -5,7 +5,7 @@ them on every pipeline event; every change goes through the session. """ -from pyqt_reactive.services.window_code_document import use_external_code_editor +from formbridge.services.window_code_document import use_external_code_editor from objectstate import ScopeKey import copy import logging @@ -17,7 +17,7 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import QSplitter, QVBoxLayout from pyqt_reactive.animation import WindowFlashOverlay -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.scope_token_service import ScopeTokenService from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.widgets.shared.abstract_manager_widget import ( @@ -30,14 +30,11 @@ ListItemLeadingMarker, ) from pyqt_reactive.widgets.shared.manager_action_controller import CodeEditorPayload -from pyqt_reactive.widgets.shared.manager_item_hooks import ( - AttributeItemIdProjection, - ManagerItemHooks, -) +from formbridge.managers.item_hooks import AttributeItemIdProjection, ManagerItemHooks from pyqt_reactive.widgets.shared.manager_selection_controller import ( ItemSelectionPayloadProjection, ) -from pyqt_reactive.widgets.shared.manager_state_binding import ManagerStateBinding +from formbridge.managers.state_binding import ManagerStateBinding from pyqt_reactive.widgets.shared.manager_ui_scaffold import ( create_manager_header, create_manager_list_widget, @@ -87,7 +84,7 @@ from openhcs.pyqt_gui.services.embedded_code_documents import ( EmbeddedCodeDocumentRegistrationABC, ) -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( +from formbridge.bridge.contracts import ( UiOwnedStateSurfaceDeclaration, state_surface_declaration_for_identity, ) diff --git a/openhcs/pyqt_gui/widgets/plate_manager.py b/openhcs/pyqt_gui/widgets/plate_manager.py index 665a69a182..0c5076e0b8 100644 --- a/openhcs/pyqt_gui/widgets/plate_manager.py +++ b/openhcs/pyqt_gui/widgets/plate_manager.py @@ -7,7 +7,7 @@ from __future__ import annotations -from pyqt_reactive.services.window_code_document import use_external_code_editor +from formbridge.services.window_code_document import use_external_code_editor import logging from dataclasses import fields, replace from functools import singledispatchmethod @@ -17,31 +17,28 @@ from objectstate import DataclassFieldAccess from objectstate.object_state import ObjectState, ObjectStateRegistry from PyQt6.QtCore import pyqtSignal -from pyqt_reactive.core.path_cache import PathCacheKey +from formbridge.core.path_cache import PathCacheKey from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.widgets.shared.abstract_manager_widget import ( AbstractManagerWidget, ListItemFormat, ) -from pyqt_reactive.widgets.shared.manager_item_hooks import ( - AttributeItemIdProjection, - ManagerItemHooks, -) +from formbridge.managers.item_hooks import AttributeItemIdProjection, ManagerItemHooks from pyqt_reactive.widgets.shared.manager_selection_controller import ( ItemIdSelectionPayloadProjection, ) -from pyqt_reactive.widgets.shared.manager_state_binding import ManagerStateBinding +from formbridge.managers.state_binding import ManagerStateBinding from pyqt_reactive.widgets.shared.scope_visual_config import ListItemType from typing_extensions import override -from openhcs.core.selection import SelectedItems, SelectionScope +from formbridge.selection import SelectedItems, SelectionScope from openhcs.agent.dto.knowledge import KnowledgeBaseDocumentTarget from openhcs.agent.dto.session import DatasetRootsRequest from openhcs.agent.ui_bridge_identities import ( - PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, + DatasetLiveMeasurementsStateSurfaceIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from openhcs.authoring.session.dataset_document import ( DatasetDocument, @@ -85,9 +82,7 @@ ) from openhcs.core.config import PipelineConfig from openhcs.constants.constants import OrchestratorCreated -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - UiOwnedStateSurfaceDeclaration, -) +from formbridge.bridge.contracts import UiOwnedStateSurfaceDeclaration from openhcs.pyqt_gui.session_rendering import ( GuiRenderer, OperationPresenter, @@ -124,10 +119,10 @@ class PlateManagerWidget( """ SESSION_VIEW = DatasetListView - TITLE = PlateManagerWidgetIdentity.require_title() + TITLE = DatasetListWidgetIdentity.require_title() UI_STATE_SURFACE_DECLARATIONS = ( UiOwnedStateSurfaceDeclaration( - identity=PlateManagerStateSurfaceIdentityDeclaration, + identity=DatasetListStateSurfaceIdentity, title="Plate manager state", payload_schema="openhcs.ui.plate_manager_state.v1", related_action_ids=tuple( @@ -135,13 +130,13 @@ class PlateManagerWidget( ), ), UiOwnedStateSurfaceDeclaration( - identity=PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, + identity=DatasetLiveMeasurementsStateSurfaceIdentity, title="Live measurement results", payload_schema="openhcs.ui.live_measurements_state.v1", related_action_ids=(ShowLiveResults.operation_id,), ), ) - UI_BRIDGE_WIDGET_IDENTITY = PlateManagerWidgetIdentity + UI_BRIDGE_WIDGET_IDENTITY = DatasetListWidgetIdentity HELP_KNOWLEDGE_TARGET = KnowledgeBaseDocumentTarget( document_id="openhcs_basic_interface", section_id="plate-manager", diff --git a/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py b/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py index 852ce265c3..d585c4226b 100644 --- a/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py +++ b/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py @@ -7,9 +7,9 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem -from pyqt_reactive.services.tree_item_key import EndpointPortProviderABC -from pyqt_reactive.services.zmq_server_info import BaseServerInfo -from pyqt_reactive.services.zmq_server_scan_service import StartingEndpointObservation +from formbridge.services.tree_item_key import EndpointPortProviderABC +from formbridge.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_scan_service import StartingEndpointObservation from zmqruntime.viewer_state import ViewerState, ViewerStateManager from openhcs.core.streaming_config_declarations import ViewerType diff --git a/openhcs/pyqt_gui/widgets/shared/server_browser/presentation_models.py b/openhcs/pyqt_gui/widgets/shared/server_browser/presentation_models.py index 049bb9b6e9..c3b3975c92 100644 --- a/openhcs/pyqt_gui/widgets/shared/server_browser/presentation_models.py +++ b/openhcs/pyqt_gui/widgets/shared/server_browser/presentation_models.py @@ -7,7 +7,7 @@ from typing import Callable from PyQt6.QtWidgets import QTreeWidgetItem -from pyqt_reactive.services.zmq_server_info import ( +from formbridge.services.zmq_server_info import ( BaseServerInfo, ExecutionServerInfo, GenericServerInfo, diff --git a/openhcs/pyqt_gui/widgets/shared/server_browser/progress_projection.py b/openhcs/pyqt_gui/widgets/shared/server_browser/progress_projection.py index 95a6bc7da6..a7e763dc43 100644 --- a/openhcs/pyqt_gui/widgets/shared/server_browser/progress_projection.py +++ b/openhcs/pyqt_gui/widgets/shared/server_browser/progress_projection.py @@ -7,7 +7,7 @@ from typing import Dict, List, Sequence from PyQt6.QtWidgets import QTreeWidgetItem -from pyqt_reactive.services.zmq_server_info import ExecutionServerInfo +from formbridge.services.zmq_server_info import ExecutionServerInfo from pyqt_reactive.widgets.shared import TreeStateAdapter, TreeSyncAdapter from openhcs.core.progress import ProgressEvent diff --git a/openhcs/pyqt_gui/widgets/shared/services/debug_session_projection.py b/openhcs/pyqt_gui/widgets/shared/services/debug_session_projection.py index 0ca294149f..430ab201b5 100644 --- a/openhcs/pyqt_gui/widgets/shared/services/debug_session_projection.py +++ b/openhcs/pyqt_gui/widgets/shared/services/debug_session_projection.py @@ -92,7 +92,7 @@ def disabled_reason( code="debug_target_required", message="Debug controls require a selected plate.", hint=( - "Use plate_manager.state to select a plate, initialize it, compile it, " + "Use datasets.state to select a plate, initialize it, compile it, " "then read pipeline_debug_toolbar.session." ), ) @@ -122,7 +122,7 @@ def disabled_reason( "session is available yet." ), hint=( - "Poll pipeline_debug_toolbar.session and plate_manager.state; " + "Poll pipeline_debug_toolbar.session and datasets.state; " "use Stop debug session if the queued execution is stuck." ), ) diff --git a/openhcs/pyqt_gui/widgets/shared/services/pipeline_debug_actions.py b/openhcs/pyqt_gui/widgets/shared/services/pipeline_debug_actions.py index 6c409065c7..a1039dfdf1 100644 --- a/openhcs/pyqt_gui/widgets/shared/services/pipeline_debug_actions.py +++ b/openhcs/pyqt_gui/widgets/shared/services/pipeline_debug_actions.py @@ -8,7 +8,7 @@ from metaclass_registry import AutoRegisterMeta -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from python_introspect import to_jsonable from openhcs.core.debug import DebugCommand, DebugCommandType diff --git a/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py b/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py index 34586108c2..e751bfc8bb 100644 --- a/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py +++ b/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py @@ -10,9 +10,7 @@ from objectstate.object_state import ObjectState from PyQt6.QtWidgets import QFileDialog -from pyqt_reactive.widgets.shared.manager_workflows import ( - ManagerCodeExecutionWorkflow, -) +from formbridge.managers.workflows import ManagerCodeExecutionWorkflow from openhcs.core.callable_contract import CallableContract from openhcs.core.debug import ( diff --git a/openhcs/pyqt_gui/widgets/shared/services/plate_manager_workflows.py b/openhcs/pyqt_gui/widgets/shared/services/plate_manager_workflows.py index f827db6d2b..260798c892 100644 --- a/openhcs/pyqt_gui/widgets/shared/services/plate_manager_workflows.py +++ b/openhcs/pyqt_gui/widgets/shared/services/plate_manager_workflows.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from pyqt_reactive.widgets.shared.manager_workflows import ManagerCodeExecutionWorkflow +from formbridge.managers.workflows import ManagerCodeExecutionWorkflow from openhcs.authoring.session.dataset_document import ( AllDatasetDocumentScope, diff --git a/openhcs/pyqt_gui/widgets/shared/zmq_server_manager.py b/openhcs/pyqt_gui/widgets/shared/zmq_server_manager.py index 3f0f181d3f..e082baed45 100644 --- a/openhcs/pyqt_gui/widgets/shared/zmq_server_manager.py +++ b/openhcs/pyqt_gui/widgets/shared/zmq_server_manager.py @@ -6,11 +6,8 @@ from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot from PyQt6.QtWidgets import QTreeWidgetItem -from pyqt_reactive.services.zmq_server_info import ( - BaseServerInfo, - ExecutionServerInfo, -) -from pyqt_reactive.services.zmq_server_scan_service import ( +from formbridge.services.zmq_server_info import BaseServerInfo, ExecutionServerInfo +from formbridge.services.zmq_server_scan_service import ( EndpointObservationSnapshot, StartingEndpointObservation, ZMQServerScanService, @@ -26,7 +23,7 @@ from zmqruntime.progress import EventRegistryMutation from zmqruntime.viewer_state import ViewerStateManager -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiLiveOverviewItem, UiLiveOverviewMetric, UiLiveOverviewSection, @@ -35,7 +32,7 @@ from openhcs.agent.ui_bridge_identities import ZmqServerManagerWindowIdentity from openhcs.core.progress import ProgressEvent, registry from openhcs.pyqt_gui.config import ProgressUIConfig -from openhcs.pyqt_gui.services.ui_bridge_contracts import UiLiveOverviewWidget +from formbridge.bridge.contracts import UiLiveOverviewWidget from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.pyqt_gui.widgets.shared.server_browser.live_tree_sync import ( LaunchingViewerServerInfo, diff --git a/openhcs/pyqt_gui/widgets/source_bindings_editor.py b/openhcs/pyqt_gui/widgets/source_bindings_editor.py index 4f75b5b3bc..4d7b0478fe 100644 --- a/openhcs/pyqt_gui/widgets/source_bindings_editor.py +++ b/openhcs/pyqt_gui/widgets/source_bindings_editor.py @@ -44,7 +44,7 @@ InlineDataclassChildFieldIdentity, InlineDataclassFormContext, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.forms.widget_creation_config import ( EnabledTitleWidgetMoveAuthority, ) @@ -78,7 +78,7 @@ from pyqt_reactive.widgets.shared.clickable_help_components import ( InlineDataclassGroupBox, ) -from pyqt_reactive.widgets.shared.scope_color_receiver import ScopeColorSchemeReceiver +from formbridge.theming.scope_color_receiver import ScopeColorSchemeReceiver from pyqt_reactive.widgets.shared.scoped_table_widget import ScopedTableWidget from pyqt_reactive.widgets.structural_table import ( EditableTableSemanticBinding, @@ -122,7 +122,7 @@ if TYPE_CHECKING: from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager - from pyqt_reactive.forms.parameter_info_types import InlineDataclassWidgetInfo + from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo SourceBindingsEditorRawValue = SourceBindingsConfig | LazyDataclass @@ -1980,9 +1980,7 @@ def resolved_source_bindings_value( def register_source_bindings_editor_widget() -> None: """Register the typed source-binding editor with pyqt-reactive forms.""" - from pyqt_reactive.forms.parameter_info_types import ( - register_inline_dataclass_widget, - ) + from formbridge.forms.parameter_info_types import register_inline_dataclass_widget register_inline_dataclass_widget( SourceBindingsConfig, diff --git a/openhcs/pyqt_gui/widgets/step_parameter_editor.py b/openhcs/pyqt_gui/widgets/step_parameter_editor.py index c6703d1d8e..bba67f28c1 100644 --- a/openhcs/pyqt_gui/widgets/step_parameter_editor.py +++ b/openhcs/pyqt_gui/widgets/step_parameter_editor.py @@ -47,19 +47,19 @@ ) from pyqt_reactive.widgets.shared.scrollable_form_mixin import ScrollableFormMixin from pyqt_reactive.services.parameter_ops_service import ParameterOpsService -from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater -from pyqt_reactive.services.window_code_document import ( +from formbridge.services.code_editor_form_updater import CodeEditorFormUpdater +from formbridge.services.window_code_document import ( WindowCodeDocumentDriver, use_external_code_editor, ) from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.theming import ColorScheme, WidgetTheme -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from openhcs.pyqt_gui.services.function_step_code_document import ( FunctionStepCodeDocumentDriver, ) -from pyqt_reactive.forms.parameter_type_utils import ParameterTypeUtils +from formbridge.forms.parameter_type_utils import ParameterTypeUtils from objectstate.object_state import ObjectState, ObjectStateRegistry logger = logging.getLogger(__name__) diff --git a/openhcs/pyqt_gui/windows/config_window.py b/openhcs/pyqt_gui/windows/config_window.py index 7c13b6f588..7218f0de85 100644 --- a/openhcs/pyqt_gui/windows/config_window.py +++ b/openhcs/pyqt_gui/windows/config_window.py @@ -7,7 +7,7 @@ from pyqt_reactive.windows.config_window import ConfigDocumentFormat, ConfigWindow from openhcs.core.config_document import ConfigDocumentAuthority -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration class OpenHCSConfigDocument(ConfigDocumentFormat): @@ -35,4 +35,4 @@ class OpenHCSConfigWindow(ConfigWindow): def window_manager_scope_id(self) -> str | None: if self.scope_id is None: return None - return OpenHCSUiWindowId.agent_window_id_for_manager_scope(self.scope_id) + return UiStableWindowIdentityDeclaration.window_id_for_manager_scope(self.scope_id) diff --git a/openhcs/pyqt_gui/windows/dual_editor_session.py b/openhcs/pyqt_gui/windows/dual_editor_session.py index e4f4d83d4f..b9030bf6b4 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_session.py +++ b/openhcs/pyqt_gui/windows/dual_editor_session.py @@ -41,13 +41,6 @@ def object_session(self) -> ObjectStateEditSession[FunctionStep]: expected_type=FunctionStep, ) - def current_function_spec(self) -> FunctionSpec: - """Return the live function spec from ObjectState, falling back to the clone.""" - object_session = self.object_session() - if object_session.has_parameter("func"): - return cast(FunctionSpec, object_session.parameter_value("func")) - return self.editing_step.func - def current_source_bindings(self): """Return live source bindings from ObjectState, falling back to the clone.""" object_session = self.object_session() diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 6ae7a65574..ace2b60c85 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -21,11 +21,11 @@ QVBoxLayout, QWidget, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT -from pyqt_reactive.services.function_navigation import is_function_field_path -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.forms.layout_constants import CURRENT_LAYOUT +from formbridge.services.function_navigation import is_function_field_path +from formbridge.services.scope_token_service import ScopeTokenService from pyqt_reactive.services.time_travel_navigation import FunctionScopeRef, NavigationTarget -from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from formbridge.services.window_code_document import WindowCodeDocumentDriver from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.services.window_navigation import ( FieldWindowNavigationDriver, @@ -60,7 +60,7 @@ from openhcs.core.source_binding_context import SourceBindingContext from openhcs.core.config import SourceBindingsConfig from openhcs.core.steps.abstract import AbstractStep -from openhcs.core.steps.function_step import FunctionSpec, FunctionStep +from openhcs.core.steps.function_step import FunctionStep from python_introspect import SignatureAnalyzer from openhcs.authoring.session.step_scopes import ( StepEditorScope, @@ -922,10 +922,6 @@ def _sync_function_editor_from_step(self): logger.debug("✅ Triggered function editor refresh from context") - def _current_function_spec(self) -> FunctionSpec: - """Compatibility wrapper for tests; authority lives in DualEditorSession.""" - return self._session.current_function_spec() - def _invalidate_artifact_plan(self) -> None: if self.artifact_plan_view is None: return diff --git a/openhcs/pyqt_gui/windows/help_window.py b/openhcs/pyqt_gui/windows/help_window.py index 3e9de46cba..fa57e0e72c 100644 --- a/openhcs/pyqt_gui/windows/help_window.py +++ b/openhcs/pyqt_gui/windows/help_window.py @@ -21,7 +21,7 @@ QVBoxLayout, QWidget, ) -from pyqt_reactive.services.help_document import HelpDocument, HelpDocumentFormat +from formbridge.services.help_document import HelpDocument, HelpDocumentFormat from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.help_document_browser import HelpDocumentBrowser diff --git a/openhcs/pyqt_gui/windows/managed_windows.py b/openhcs/pyqt_gui/windows/managed_windows.py index c66530c6b6..0822af5f0e 100644 --- a/openhcs/pyqt_gui/windows/managed_windows.py +++ b/openhcs/pyqt_gui/windows/managed_windows.py @@ -12,7 +12,7 @@ ImageBrowserWindowIdentity, LogViewerWindowIdentity, PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, ZmqServerManagerWindowIdentity, ) @@ -32,7 +32,7 @@ def __init__(self, main_window, service_adapter): super().__init__(main_window) self.main_window = main_window self.service_adapter = service_adapter - self.setWindowTitle(PlateManagerWidgetIdentity.require_title()) + self.setWindowTitle(DatasetListWidgetIdentity.require_title()) self.setModal(False) self.resize(600, 400) @@ -103,7 +103,7 @@ def _setup_connections(self): plate_widgets = [self.main_window.embedded_widgets.require_plate_manager()] plate_window = WindowManager._scoped_windows.get( - PlateManagerWidgetIdentity.require_value() + DatasetListWidgetIdentity.require_value() ) if plate_window is not None and plate_window.widget not in plate_widgets: plate_widgets.append(plate_window.widget) diff --git a/openhcs/pyqt_gui/windows/synthetic_plate_generator_window.py b/openhcs/pyqt_gui/windows/synthetic_plate_generator_window.py index 70a7ca0f5d..4adcb59416 100644 --- a/openhcs/pyqt_gui/windows/synthetic_plate_generator_window.py +++ b/openhcs/pyqt_gui/windows/synthetic_plate_generator_window.py @@ -23,7 +23,7 @@ from PyQt6.QtGui import QFont from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.shared.reflowing_vertical_scroll_area import ( ReflowingVerticalScrollArea, diff --git a/openhcs/runtime/function_catalog_preparation.py b/openhcs/runtime/function_catalog_preparation.py index 37712b7048..b55ccf94c5 100644 --- a/openhcs/runtime/function_catalog_preparation.py +++ b/openhcs/runtime/function_catalog_preparation.py @@ -193,7 +193,8 @@ def observe( self, handle: FunctionCatalogPreparationHandle ) -> FunctionCatalogPreparationState: """Project this future, never wait, restart or build another job store.""" - from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError + from openhcs.agent.dto.common import SCHEMA_VERSION + from formbridge.contracts import AgentError from openhcs.agent.dto.functions import ( FunctionCatalogPreparationOutcome as Outcome, ) diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index f6a3059aa6..ef4dcdf378 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -6104,7 +6104,7 @@ def validate_request(self, request: NapariAcceptedControlRequest) -> None: @staticmethod def capture_spec(message): from python_introspect import validate_annotated_dataclass - from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureSpec + from formbridge.services.window_snapshot import WindowSnapshotCaptureSpec capture_spec = message.get(ViewerControlResponseField.PAYLOAD.value) if not isinstance(capture_spec, WindowSnapshotCaptureSpec): diff --git a/openhcs/runtime/viewer_snapshot.py b/openhcs/runtime/viewer_snapshot.py index b5a4c48fd4..1ae03bc991 100644 --- a/openhcs/runtime/viewer_snapshot.py +++ b/openhcs/runtime/viewer_snapshot.py @@ -4,15 +4,14 @@ from dataclasses import dataclass, fields -from pyqt_reactive.services.window_snapshot import ( - QtWindowSnapshot, - QtWindowSnapshotService, +from pyqt_reactive.services.window_snapshot import QtWindowSnapshot, QtWindowSnapshotService +from formbridge.services.window_snapshot import ( WindowSnapshotObservationFailure, WindowVisualObservation, ) from zmqruntime.messages import ControlErrorResponse -from openhcs.agent.dto.common import AgentResourceRef +from formbridge.contracts import AgentResourceRef from openhcs.agent.dto.viewer import ViewerWindowDescriptor from python_introspect import to_jsonable diff --git a/openhcs/serialization/pycodify_formatters.py b/openhcs/serialization/pycodify_formatters.py index 7eae46b939..5231509a78 100644 --- a/openhcs/serialization/pycodify_formatters.py +++ b/openhcs/serialization/pycodify_formatters.py @@ -13,7 +13,7 @@ imported_name, to_source, ) -from pyqt_reactive.pattern_metadata import PatternScopeToken +from formbridge.pattern_metadata import PatternScopeToken from python_introspect import callable_declaration_kwargs, parameter_exclusions from openhcs.core.axes import is_grouping_declaration diff --git a/openhcs/utils/environment.py b/openhcs/utils/environment.py index de0b92e4ca..85278b9eb8 100644 --- a/openhcs/utils/environment.py +++ b/openhcs/utils/environment.py @@ -13,7 +13,7 @@ from openhcs.resources.brand import BRAND_PRODUCT_NAME if TYPE_CHECKING: - from openhcs.agent.runtime_platform import AgentRuntimePlatform + from formbridge.runtime_platform import AgentRuntimePlatform class OpenHCSProcessEnvironment: @@ -44,7 +44,7 @@ def numba_cache_path( def current_numba_cache_path(cls) -> Path: """Return the compiled-code cache for the current host platform.""" - from openhcs.agent.runtime_platform import AgentRuntimePlatform + from formbridge.runtime_platform import AgentRuntimePlatform return cls.numba_cache_path(AgentRuntimePlatform.current()) diff --git a/openhcs/utils/performance_monitor.py b/openhcs/utils/performance_monitor.py deleted file mode 100644 index 3156362eee..0000000000 --- a/openhcs/utils/performance_monitor.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Performance monitoring utilities for OpenHCS. - -Provides decorators and context managers for timing operations and -logging performance metrics. -""" - -import time -import functools -import logging -import os -from contextlib import contextmanager -from typing import Optional, Callable -from pathlib import Path - -# Create performance logger -perf_logger = logging.getLogger("openhcs.performance") -perf_logger.setLevel(logging.WARNING) - -# Add file handler for performance logs -perf_log_file = ( - Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) - / "openhcs" - / "logs" - / "performance.log" -) -perf_log_file.parent.mkdir(parents=True, exist_ok=True) - -file_handler = logging.FileHandler(perf_log_file) -file_handler.setLevel(logging.DEBUG) -file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -) -perf_logger.addHandler(file_handler) - -# Also log to console -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.WARNING) -console_handler.setFormatter(logging.Formatter("⏱️ %(message)s")) -perf_logger.addHandler(console_handler) - - -@contextmanager -def timer( - operation_name: str, threshold_ms: float = 0.0, log_args: bool = False, **kwargs -): - """Context manager for timing operations. - - Args: - operation_name: Name of the operation being timed - threshold_ms: Only log if operation takes longer than this (in milliseconds) - log_args: Whether to log kwargs in the message - **kwargs: Additional context to include in log message - - Example: - with timer("Loading config", threshold_ms=10.0, config_type="GlobalPipelineConfig"): - config = load_config() - """ - start = time.perf_counter() - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - - if elapsed_ms >= threshold_ms: - msg = f"{operation_name}: {elapsed_ms:.2f}ms" - if log_args and kwargs: - args_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()) - msg += f" ({args_str})" - - perf_logger.debug(msg) - - -def timed(operation_name: Optional[str] = None, threshold_ms: float = 0.0): - """Decorator for timing function calls. - - Args: - operation_name: Name for the operation (defaults to function name) - threshold_ms: Only log if operation takes longer than this (in milliseconds) - - Example: - @timed("Config loading", threshold_ms=10.0) - def load_config(): - ... - """ - - def decorator(func: Callable) -> Callable: - nonlocal operation_name - if operation_name is None: - operation_name = f"{func.__module__}.{func.__qualname__}" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - start = time.perf_counter() - try: - return func(*args, **kwargs) - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - - if elapsed_ms >= threshold_ms: - perf_logger.debug(f"{operation_name}: {elapsed_ms:.2f}ms") - - return wrapper - - return decorator - - -class PerformanceMonitor: - """Accumulates timing statistics for repeated operations. - - Example: - monitor = PerformanceMonitor("Placeholder resolution") - - for field in fields: - with monitor.measure(): - resolve_placeholder(field) - - monitor.report() # Logs summary statistics - """ - - def __init__(self, operation_name: str): - self.operation_name = operation_name - self.timings = [] - self.current_start = None - - @contextmanager - def measure(self): - """Measure a single operation.""" - start = time.perf_counter() - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - self.timings.append(elapsed_ms) - - def report(self, log_individual: bool = False): - """Log summary statistics. - - Args: - log_individual: Whether to log each individual timing - """ - if not self.timings: - perf_logger.debug(f"{self.operation_name}: No measurements") - return - - count = len(self.timings) - total_ms = sum(self.timings) - avg_ms = total_ms / count - min_ms = min(self.timings) - max_ms = max(self.timings) - - perf_logger.debug( - f"{self.operation_name} - " - f"Count: {count}, " - f"Total: {total_ms:.2f}ms, " - f"Avg: {avg_ms:.2f}ms, " - f"Min: {min_ms:.2f}ms, " - f"Max: {max_ms:.2f}ms" - ) - - if log_individual: - for i, timing in enumerate(self.timings, 1): - perf_logger.debug(f" #{i}: {timing:.2f}ms") - - def reset(self): - """Clear all timings.""" - self.timings.clear() - - -# Global monitors for common operations -_monitors = {} - - -def get_monitor(operation_name: str) -> PerformanceMonitor: - """Get or create a global monitor for an operation. - - Example: - monitor = get_monitor("Placeholder resolution") - with monitor.measure(): - resolve_placeholder(field) - """ - if operation_name not in _monitors: - _monitors[operation_name] = PerformanceMonitor(operation_name) - return _monitors[operation_name] - - -def report_all_monitors(): - """Report statistics for all global monitors.""" - if not _monitors: - perf_logger.debug("No performance monitors active") - return - - perf_logger.debug("=" * 60) - perf_logger.debug("PERFORMANCE SUMMARY") - perf_logger.debug("=" * 60) - - for monitor in _monitors.values(): - monitor.report() - - perf_logger.debug("=" * 60) - - -def reset_all_monitors(): - """Reset all global monitors.""" - for monitor in _monitors.values(): - monitor.reset() - - -# Convenience function to enable/disable performance logging -_enabled = True - - -def enable_performance_logging(): - """Enable performance logging.""" - global _enabled - _enabled = True - perf_logger.setLevel(logging.DEBUG) - - -def disable_performance_logging(): - """Disable performance logging.""" - global _enabled - _enabled = False - perf_logger.setLevel(logging.WARNING) - - -def is_performance_logging_enabled() -> bool: - """Check if performance logging is enabled.""" - return _enabled diff --git a/pyproject.toml b/pyproject.toml index d6f3007b95..27e5d5f075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,14 +99,15 @@ dependencies = [ "psutil>=5.9.0", # External dependencies (PyPI versions for production builds) - "zmqruntime>=0.6.1,<0.7", + "zmqruntime>=0.6.2,<0.7", "pycodify>=0.2.0,<0.3", - "objectstate>=2.1,<3", + "objectstate>=2.1.1,<3", "python-introspect>=0.2.8,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.4.1,<0.5", - "polystore>=0.6,<0.7", - "pyqt-reactive>=0.5,<0.6", + "polystore>=0.6.1,<0.7", + "formbridge>=0.1,<0.2", + "pyqt-reactive>=0.6,<0.7", ] [project.optional-dependencies] diff --git a/scripts/capture_media_gallery.py b/scripts/capture_media_gallery.py index 444196de2b..cca79e48b9 100755 --- a/scripts/capture_media_gallery.py +++ b/scripts/capture_media_gallery.py @@ -32,13 +32,13 @@ from pyqt_reactive.animation import WindowFlashOverlay from pyqt_reactive.animation.flash_overlay_opengl import WindowFlashOverlayGL -from pyqt_reactive.services.tab_identity import TabLabelDeclarationMixin +from formbridge.services.tab_identity import TabLabelDeclarationMixin from pyqt_reactive.services.widget_tree_projection import WidgetActionKind -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from python_introspect import dataclass_from_mapping, to_jsonable from openhcs.agent.dto.execution import ExecutionConnectionSpec -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiActionCatalog, UiActionInvokeRequest, UiActionInvokeResult, @@ -61,7 +61,7 @@ ViewerWindowSnapshotRequest, ViewerWindowSnapshotResult, ) -from openhcs.agent.services.ui_bridge_service import ( +from formbridge.bridge.service import ( UiBridgeCloseWindowOperation, UiBridgeInvokeActionOperation, UiBridgeInvokeWidgetActionOperation, diff --git a/scripts/gallery_catalog.py b/scripts/gallery_catalog.py index c4ffb8e4ac..22e658e824 100644 --- a/scripts/gallery_catalog.py +++ b/scripts/gallery_catalog.py @@ -21,7 +21,7 @@ from pyqt_reactive.services.function_list_editor_actions import ( FunctionListEditorAction, ) -from pyqt_reactive.services.function_navigation import FUNCTION_FIELD_ROOT +from formbridge.services.function_navigation import FUNCTION_FIELD_ROOT from pyqt_reactive.services.system_monitor_actions import SystemMonitorAction from python_introspect import JsonValue, dataclass_from_mapping, to_jsonable @@ -37,9 +37,9 @@ LogViewerWindowIdentity, MainWindowWidgetIdentity, PipelineEditorWidgetIdentity, - PlateManagerWidgetIdentity, - UiStableWindowIdentityDeclaration, + DatasetListWidgetIdentity, ) +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration from openhcs.core.streaming_config_declarations import ViewerType from openhcs.pyqt_gui.ui_tab_identities import DualEditorTab, PlateViewerTab @@ -425,8 +425,8 @@ class PlateManagerActionWindowCaptureTarget(UiContextCaptureTargetABC): action: type[SessionOperation] tab: PlateViewerTab | None = None - capture_widget_identity: ClassVar[type[PlateManagerWidgetIdentity]] = ( - PlateManagerWidgetIdentity + capture_widget_identity: ClassVar[type[DatasetListWidgetIdentity]] = ( + DatasetListWidgetIdentity ) def capture_source( diff --git a/scripts/master_multi_plate_demo.py b/scripts/master_multi_plate_demo.py index f94fbf6a27..9460d75559 100644 --- a/scripts/master_multi_plate_demo.py +++ b/scripts/master_multi_plate_demo.py @@ -32,9 +32,9 @@ from openhcs.agent.dto.ui_bridge import UiSelectedPlateWorkflowKind from openhcs.agent.ui_bridge_identities import ( - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from openhcs.core.aligned_image_payload import AlignedImageSliceContext from openhcs.core.artifacts.kinds import MeasurementsArtifactType @@ -71,11 +71,11 @@ DEFAULT_BASE_PORT = 5900 UI_TIMEOUT_MS = 2_000 DEFAULT_PRESENTATION_DWELL_SECONDS = 1.0 -ORCHESTRATOR_DOCUMENT_ID = PlateManagerOrchestratorCodeDocumentIdentity.require_value() +ORCHESTRATOR_DOCUMENT_ID = DatasetListCodeDocumentIdentity.require_value() PLATE_MANAGER_STATE_SURFACE_ID = ( - PlateManagerStateSurfaceIdentityDeclaration.require_value() + DatasetListStateSurfaceIdentity.require_value() ) -PLATE_MANAGER_WIDGET_ID = PlateManagerWidgetIdentity.require_value() +PLATE_MANAGER_WIDGET_ID = DatasetListWidgetIdentity.require_value() class MasterDemoFailure(RuntimeError): diff --git a/scripts/mcp_assay_showcase.py b/scripts/mcp_assay_showcase.py index b0170d76d3..aeabcf76ee 100644 --- a/scripts/mcp_assay_showcase.py +++ b/scripts/mcp_assay_showcase.py @@ -27,9 +27,9 @@ from polystore.streaming.identity import StreamProducerIdentity from openhcs.agent.ui_bridge_identities import ( - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from openhcs.core.aligned_image_payload import AlignedImageSliceContext from openhcs.core.artifacts.kinds import ( @@ -43,7 +43,7 @@ ROOT = Path(__file__).resolve().parents[1] PYTHON = os.environ.get("PYTHON_BIN", sys.executable) DEFAULT_OUTPUT_ROOT = ROOT / "mcp_outputs" / "assay_showcase" -ORCHESTRATOR_DOCUMENT_ID = PlateManagerOrchestratorCodeDocumentIdentity.require_value() +ORCHESTRATOR_DOCUMENT_ID = DatasetListCodeDocumentIdentity.require_value() WELL = "A01" SHOWCASE_NAPARI_PORT = 5889 UI_TIMEOUT_MS = 2_000 @@ -1150,7 +1150,7 @@ def discover_live_measurement_surface_id( widget_id = action.get("widget_id") action_id = action.get("action_id") if ( - widget_id == PlateManagerWidgetIdentity.require_value() + widget_id == DatasetListWidgetIdentity.require_value() and action_id == ShowLiveResults.operation_id ): raw_related = action.get("related_state_surface_ids") @@ -1160,7 +1160,7 @@ def discover_live_measurement_surface_id( for surface_id in raw_related if isinstance(surface_id, str) and surface_id - != PlateManagerStateSurfaceIdentityDeclaration.require_value() + != DatasetListStateSurfaceIdentity.require_value() ) surfaces = surface_catalog.get("surfaces") @@ -1502,7 +1502,7 @@ def open_human_results_table( "open_human_results_table", [ "invoke-action", - PlateManagerWidgetIdentity.require_value(), + DatasetListWidgetIdentity.require_value(), ShowLiveResults.operation_id, "--json", *_ui_args(ctx), @@ -1651,7 +1651,7 @@ def read_live_measurements() -> tuple[str, dict[str, Any], dict[str, Any]]: "list_plate_manager_actions", [ "actions", - PlateManagerWidgetIdentity.require_value(), + DatasetListWidgetIdentity.require_value(), "--json", *_ui_args(ctx), ], diff --git a/scripts/mcp_thesis_demo_live.py b/scripts/mcp_thesis_demo_live.py index bb9dd643e5..fdb6d2ae81 100755 --- a/scripts/mcp_thesis_demo_live.py +++ b/scripts/mcp_thesis_demo_live.py @@ -29,7 +29,7 @@ # isort: split from pyqt_reactive.dialogs.group_by_selector_dialog import GroupBySelectorDialog -from pyqt_reactive.services.function_navigation import FunctionPatternField +from formbridge.services.function_navigation import FunctionPatternField from pyqt_reactive.widgets.shared import DetachableActionBar, ManagedWindowAction from zmqruntime import ( EndpointShutdownMode, @@ -60,19 +60,19 @@ UiSnapshotWindowCapability, ValidateViewerWindowStateCapability, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiActionIdentity, UiActionSummary, UiCodeDocumentSummary, UiWindowSummary, ) from python_introspect import dataclass_from_mapping +from formbridge.bridge.identities import ManagedWindowWidgetIdentity from openhcs.agent.ui_bridge_identities import ( - ManagedWindowWidgetIdentity, PipelineEditorStateSurfaceIdentityDeclaration, - PlateManagerOrchestratorCodeDocumentIdentity, - PlateManagerStateSurfaceIdentityDeclaration, - PlateManagerWidgetIdentity, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, + DatasetListWidgetIdentity, ) from openhcs.core.config_cache import ConfigCacheSpec, save_config_sync from openhcs.ui.shared.plate_manager_code_document import PlateManagerDocumentAssignments @@ -99,6 +99,7 @@ ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig from openhcs.domains.microscopy.axes import Microscopy +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration ROOT = Path(__file__).resolve().parents[1] DEFAULT_DEMO_ROOT = ROOT / "mcp_outputs" / "thesis_demo" / "live" @@ -111,7 +112,7 @@ / "test_main[ImageXpress]" / "zstack_plate" ) -ORCHESTRATOR_DOCUMENT_ID = PlateManagerOrchestratorCodeDocumentIdentity.require_value() +ORCHESTRATOR_DOCUMENT_ID = DatasetListCodeDocumentIdentity.require_value() PYTHON = os.environ.get("PYTHON_BIN", sys.executable) FINAL_DEMO_STEP_DISPLAY_INDEX = 8 FINAL_DEMO_STEP_ROUTE_INDEX = FINAL_DEMO_STEP_DISPLAY_INDEX - 1 @@ -984,7 +985,7 @@ def managed_window_action_target( ) target_scope_ids = matches[0].target_scope_ids window_scopes = frozenset( - OpenHCSUiWindowId.manager_scopes_for_agent_window_id(window_id) + UiStableWindowIdentityDeclaration.manager_scopes_for_window_id(window_id) ) advertised_targets = [ scope_id @@ -1850,7 +1851,7 @@ def inspect_and_apply_code_document( for doc in documents ): raise RehearsalFailure( - "Writable plate_manager.orchestrator_config document missing." + "Writable datasets.orchestrator_config document missing." ) source_payload = command_json( @@ -2542,7 +2543,7 @@ def selected_plate_state(ctx: RunContext) -> dict[str, Any]: "selected_plate_state", mcp_cmd( UiGetStateSurfaceCapability.cli_command, - PlateManagerStateSurfaceIdentityDeclaration.require_value(), + DatasetListStateSurfaceIdentity.require_value(), "--selection-mode", "selected", "--timeout-seconds", @@ -2760,7 +2761,7 @@ def plate_action_summary( f"list_{action.operation_id}_action", mcp_cmd( UiListActionsCapability.cli_command, - PlateManagerWidgetIdentity.require_value(), + DatasetListWidgetIdentity.require_value(), descriptor=ctx.descriptor_path, ), timeout=30, @@ -2774,7 +2775,7 @@ def plate_action_summary( matches = [ row for row in summaries - if row.identity.widget_id == PlateManagerWidgetIdentity.require_value() + if row.identity.widget_id == DatasetListWidgetIdentity.require_value() and row.identity.action_id == action.operation_id ] if len(matches) != 1: @@ -2794,7 +2795,7 @@ def invoke_plate_action(ctx: RunContext, action: type[SessionOperation]) -> dict mcp_call_tool_cmd( UiInvokeActionCapability.name, { - "widget_id": PlateManagerWidgetIdentity.require_value(), + "widget_id": DatasetListWidgetIdentity.require_value(), "action_id": action.operation_id, "target_scope_ids": list(summary.target_scope_ids), "observed_selection_revision_token": summary.selection_revision_token, @@ -4913,7 +4914,7 @@ def build_objective_evidence(report: Mapping[str, Any]) -> dict[str, Any]: "requirement": "ui_visible_hierarchical_configuration", "public_evidence": [ ORCHESTRATOR_DOCUMENT_ID, - PlateManagerStateSurfaceIdentityDeclaration.require_value(), + DatasetListStateSurfaceIdentity.require_value(), ], "satisfied": True, }, diff --git a/scripts/smoke_installed_gui.py b/scripts/smoke_installed_gui.py index aa543cd014..faf0dce9af 100644 --- a/scripts/smoke_installed_gui.py +++ b/scripts/smoke_installed_gui.py @@ -25,7 +25,7 @@ from zmqruntime import OperationDeadline from openhcs.agent.dto.execution import RuntimeServerInfo - from openhcs.agent.dto.ui_bridge import ( + from formbridge.bridge.dto import ( UiActionInvokeResult, UiWindowCatalog, UiWindowSnapshotResult, @@ -461,7 +461,7 @@ def _typed_tool_payload( def require_ui_action_accepted(action_result: UiActionInvokeResult) -> None: """Fail the smoke unless the live GUI took the requested action.""" - from openhcs.agent.dto.ui_bridge import ActionAccepted + from formbridge.bridge.dto import ActionAccepted if action_result.status is not ActionAccepted: raise AssertionError(f"Installed UI action was not accepted: {action_result}") @@ -481,10 +481,10 @@ def _capture_installed_gui_snapshot( ) -> UiWindowSnapshotResult: """Capture one live window through the production MCP capability.""" - from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope + from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from openhcs.agent.capabilities import UiSnapshotWindowCapability - from openhcs.agent.dto.ui_bridge import UiWindowCatalog, UiWindowSnapshotResult + from formbridge.bridge.dto import UiWindowCatalog, UiWindowSnapshotResult from openhcs.mcp.dev_client_commands.ui import WindowSnapshotCommandSpec execution = client.execute( @@ -566,7 +566,7 @@ def run_installed_live_mcp_smoke( agent_capabilities, ) from openhcs.agent.dto.mcp import McpServerHealthResult - from openhcs.agent.dto.ui_bridge import ( + from formbridge.bridge.dto import ( UiActionInvokeResult, UiBridgeCatalog, UiBridgeStatus, @@ -575,7 +575,7 @@ def run_installed_live_mcp_smoke( from openhcs.agent.services.runtime_server_service import RuntimeServerService from openhcs.agent.ui_bridge_identities import ( MainWindowWidgetIdentity, - PlateManagerWidgetIdentity, + DatasetListWidgetIdentity, ) from openhcs.mcp.dev_client import McpDevClient from openhcs.mcp.dev_client_commanding import CapabilityBackedCommandSpec @@ -675,7 +675,7 @@ def run_installed_live_mcp_smoke( required_window_ids = { MainWindowWidgetIdentity.require_value(), - PlateManagerWidgetIdentity.require_value(), + DatasetListWidgetIdentity.require_value(), } observed_window_ids = _window_ids(windows_before) if not required_window_ids <= observed_window_ids: @@ -689,7 +689,7 @@ def run_installed_live_mcp_smoke( action_execution = client.execute( ( InvokeActionCommandSpec.command, - PlateManagerWidgetIdentity.require_value(), + DatasetListWidgetIdentity.require_value(), ShowDatasetCode.operation_id, "--request-token", "installed-gui-mcp-smoke", @@ -803,7 +803,7 @@ def run_installed_gui_smoke( from zmqruntime import OperationDeadline import openhcs - from openhcs.agent.ui_bridge_environment import UiBridgeDescriptorEnvironment + from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication from openhcs.core.config import GlobalPipelineConfig from openhcs.pyqt_gui.app import OpenHCSPyQtApp from openhcs.pyqt_gui.config import ( @@ -828,7 +828,7 @@ def run_installed_gui_smoke( ), descriptor_path=descriptor_path, ) - os.environ[UiBridgeDescriptorEnvironment.descriptor_file_path_key] = str( + os.environ[OpenHCSUiBridgeApplication.descriptor_file_variable] = str( descriptor_path ) runtime_context = PyQtGuiRuntimeContext( diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 8205bb185d..368955fe5a 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -49,6 +49,7 @@ / "metaclass_registry" ), "polystore": REPOSITORY_ROOT / "external" / "PolyStore" / "src" / "polystore", + "formbridge": REPOSITORY_ROOT / "external" / "formbridge" / "src" / "formbridge", "pyqt_reactive": ( REPOSITORY_ROOT / "external" / "pyqt-reactive" / "src" / "pyqt_reactive" ), diff --git a/tests/diagnostics/check_viewer_feature_measurement_live.py b/tests/diagnostics/check_viewer_feature_measurement_live.py index 15dac4e33e..eb5c44b9ba 100644 --- a/tests/diagnostics/check_viewer_feature_measurement_live.py +++ b/tests/diagnostics/check_viewer_feature_measurement_live.py @@ -201,7 +201,7 @@ def save() -> None: from openhcs.core.streaming_config_declarations import NapariViewer from zmqruntime.config import TransportMode from python_introspect import to_jsonable - from openhcs.agent.runtime_platform import AgentRuntimePlatform + from formbridge.runtime_platform import AgentRuntimePlatform from polystore.imagej_distribution import ( FijiArchiveDistribution, ImageJArchiveDownloadPolicy, diff --git a/tests/fixtures/mcp/mcp_tool_contract.json b/tests/fixtures/mcp/mcp_tool_contract.json index f1a6682793..8de654d693 100644 --- a/tests/fixtures/mcp/mcp_tool_contract.json +++ b/tests/fixtures/mcp/mcp_tool_contract.json @@ -8822,11 +8822,13 @@ "type": "string" }, "surface_id": { - "default": "plate_manager.state", "title": "Surface Id", "type": "string" } }, + "required": [ + "surface_id" + ], "title": "openhcs_ui_get_state_surfaceArguments", "type": "object" }, diff --git a/tests/pyqt_gui/test_main_window_offscreen_smoke.py b/tests/pyqt_gui/test_main_window_offscreen_smoke.py index ececabcde9..af7207d343 100644 --- a/tests/pyqt_gui/test_main_window_offscreen_smoke.py +++ b/tests/pyqt_gui/test_main_window_offscreen_smoke.py @@ -188,11 +188,6 @@ def startup_failed(error: BaseException) -> None: if __name__ == "__main__": exit_code, app = _run_main_window(int(sys.argv[1])) - # Tearing down the QApplication (PyQt's exit handler and interpreter - # finalization) intermittently segfaults with no Python frame, before and - # after U5 alike; recorded in U5-dead-ui-code.md. No exit handler owns a - # process, so leave without teardown: this test checks the application's own - # exit status and the processes it leaves behind. - sys.stdout.flush() - sys.stderr.flush() - os._exit(exit_code) + # Normal interpreter teardown: a crash while the QApplication is torn down + # fails this test through the child's exit status. + sys.exit(exit_code) diff --git a/tests/unit/agent/test_agent_services.py b/tests/unit/agent/test_agent_services.py index 3a1fd07fe4..032bf1c3ef 100644 --- a/tests/unit/agent/test_agent_services.py +++ b/tests/unit/agent/test_agent_services.py @@ -8,10 +8,8 @@ import pytest from polystore.virtual_workspace import SourcePixelRef -from pyqt_reactive.services.parameter_help_service import ( - dataclass_parameter_descriptions, -) -from pyqt_reactive.services.window_snapshot import ( +from formbridge.services.parameter_help_service import dataclass_parameter_descriptions +from formbridge.services.window_snapshot import ( WindowSnapshotCaptureScope, WindowSnapshotFrameCondition, ) diff --git a/tests/unit/agent/test_config_reference_service.py b/tests/unit/agent/test_config_reference_service.py index 1df8df155f..4bffeffefd 100644 --- a/tests/unit/agent/test_config_reference_service.py +++ b/tests/unit/agent/test_config_reference_service.py @@ -1,7 +1,7 @@ from dataclasses import fields from objectstate import ObjectState -from pyqt_reactive.services.parameter_help_service import dataclass_parameter_descriptions +from formbridge.services.parameter_help_service import dataclass_parameter_descriptions from openhcs.agent.dto.knowledge import KnowledgeBaseDocumentRequest from openhcs.agent.services.config_reference_service import ( diff --git a/tests/unit/agent/test_function_declaration_help.py b/tests/unit/agent/test_function_declaration_help.py index 66b73b7698..8adce61fc8 100644 --- a/tests/unit/agent/test_function_declaration_help.py +++ b/tests/unit/agent/test_function_declaration_help.py @@ -7,8 +7,8 @@ import pytest -from pyqt_reactive.services.help_document import HelpDocument -from pyqt_reactive.services.parameter_help_service import docstring_info_for_target +from formbridge.services.help_document import HelpDocument +from formbridge.services.parameter_help_service import docstring_info_for_target from openhcs.agent.services.function_catalog_service import ( FunctionCatalogService, @@ -175,9 +175,7 @@ class AuditedPolicy(AnnotationAuditPolicy, SupplierAuditPolicy): def test_policy_consumer_executes_cooperative_hooks_once_in_declared_mro(): - from pyqt_reactive.services.parameter_help_service import ( - dataclass_type_from_annotation, - ) + from formbridge.services.parameter_help_service import dataclass_type_from_annotation from openhcs.agent.dto.functions import ( SuppliedByAgent, SuppliedFromPrimaryInput, diff --git a/tests/unit/agent/test_mcp_affine_inspection_progress.py b/tests/unit/agent/test_mcp_affine_inspection_progress.py index a1e9a9bf61..fd8303c693 100644 --- a/tests/unit/agent/test_mcp_affine_inspection_progress.py +++ b/tests/unit/agent/test_mcp_affine_inspection_progress.py @@ -6,7 +6,6 @@ import time from types import SimpleNamespace -from PyQt6.QtCore import QCoreApplication, QThread import pytest from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus @@ -16,13 +15,14 @@ InspectPipelineSourceArtifactPlanCapability, MainThreadProgressCapability, ) -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.execution import ArtifactPlanInspection from openhcs.mcp.execution import ( McpMainThreadDispatcher, McpQueuedCallNotStartedError, McpTransportExecutor, ) -from pyqt_reactive.services.ui_thread_dispatch import ( - UiThreadDispatchError, UiThreadDispatcherClosedError, UiThreadDispatchTimeoutError, +from formbridge.services.main_thread_dispatch import ( + MainThreadDispatcherClosedError, MainThreadDispatchTimeoutError, ) from openhcs.mcp.server import build_server from python_introspect import to_jsonable @@ -52,7 +52,6 @@ def __init__(self, request_identity, *, error=None): def inspect(self, request): assert threading.current_thread() is threading.main_thread() - assert QThread.currentThread() == QCoreApplication.instance().thread() self.calls.append((request, self.request_identity.get())) EndpointStartupStatus( EndpointStartupPhase.PREPARING_CAPABILITIES, @@ -236,7 +235,7 @@ async def exercise(): def test_callback_timeout_after_side_effect_is_not_prestart_cancellation(queued): executor = McpTransportExecutor() calls = [] - original = UiThreadDispatchTimeoutError("Callback timed out after its side effect.") + original = MainThreadDispatchTimeoutError("Callback timed out after its side effect.") def callback(): calls.append("side-effect") @@ -246,7 +245,7 @@ async def exercise(): return await asyncio.to_thread(executor.dispatcher.call, callback) try: - with pytest.raises(UiThreadDispatchTimeoutError) as caught: + with pytest.raises(MainThreadDispatchTimeoutError) as caught: if queued: executor.run(exercise) else: @@ -264,7 +263,7 @@ def test_closed_dispatcher_rejects_without_invocation(): executor.dispatcher.close() async def exercise(): - with pytest.raises(UiThreadDispatcherClosedError): + with pytest.raises(MainThreadDispatcherClosedError): await executor.dispatcher.invoke(lambda: calls.append("forbidden")) try: @@ -274,14 +273,15 @@ async def exercise(): executor.close() -def test_headless_absence_does_not_authorize_worker_affinity(monkeypatch): - monkeypatch.setattr(QCoreApplication, "instance", lambda: None) +def test_worker_call_never_runs_on_the_worker_thread(): dispatcher = McpMainThreadDispatcher() calls = [] async def exercise(): - with pytest.raises(UiThreadDispatchError, match="No Qt application"): - await asyncio.to_thread(dispatcher.call, lambda: calls.append("forbidden")) + with pytest.raises(McpQueuedCallNotStartedError): + await asyncio.to_thread( + dispatcher.call, lambda: calls.append("forbidden"), timeout_ms=20 + ) try: asyncio.run(exercise()) @@ -362,7 +362,7 @@ def serve_stdio_inspection_fixture(*, work_seconds=2.4): def test_resident_continuous_connections_keep_affinity_and_wire_progress( unix_socket_dir, work_seconds, idle_seconds, connection_count, ): - from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor + from formbridge.services.async_operation_executor import AsyncOperationExecutor from openhcs.mcp.dev_client_core import McpDevServerSpec, McpDevSocketSession from openhcs.mcp.socket import McpSocketTransport, wait_for_socket import io diff --git a/tests/unit/agent/test_mcp_affine_real_compile.py b/tests/unit/agent/test_mcp_affine_real_compile.py index aed75c5e99..2456133df9 100644 --- a/tests/unit/agent/test_mcp_affine_real_compile.py +++ b/tests/unit/agent/test_mcp_affine_real_compile.py @@ -5,7 +5,6 @@ import threading from types import SimpleNamespace -from PyQt6.QtCore import QCoreApplication, QThread from openhcs.agent.capabilities import InspectPipelineSourceArtifactPlanCapability from openhcs.agent.path_policy import AgentPathPolicy @@ -47,7 +46,6 @@ def forbidden_catalog(*args, **kwargs): class AffineCompileGateway(InProcessCompileInspectionGateway): def compile(self, request): assert threading.current_thread() is threading.main_thread() - assert QThread.currentThread() == QCoreApplication.instance().thread() assert identity.get() == "exact-source-request" calls.append(request) return super().compile(request) diff --git a/tests/unit/agent/test_mcp_capability_discovery_and_widget_projection.py b/tests/unit/agent/test_mcp_capability_discovery_and_widget_projection.py index 395cfbcfd6..4147a8914b 100644 --- a/tests/unit/agent/test_mcp_capability_discovery_and_widget_projection.py +++ b/tests/unit/agent/test_mcp_capability_discovery_and_widget_projection.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import fields -from pyqt_reactive.services.widget_tree_projection_config import ( +from formbridge.services.widget_tree_projection_config import ( COMPACT_FIELD_PROJECTION_METADATA_KEY, CompactFieldProjection, WidgetNodeIdentity, @@ -20,7 +20,7 @@ DesktopLocalCapabilitySurfaceProfile, get_capability_registry, ) -from openhcs.agent.dto.ui_bridge import UiWidgetActionSummary +from formbridge.bridge.dto import UiWidgetActionSummary import openhcs.mcp.server as server diff --git a/tests/unit/agent/test_mcp_client_registration.py b/tests/unit/agent/test_mcp_client_registration.py index 1f4b824f1f..d8956aa02c 100644 --- a/tests/unit/agent/test_mcp_client_registration.py +++ b/tests/unit/agent/test_mcp_client_registration.py @@ -7,7 +7,7 @@ import pytest import tomlkit -from openhcs.agent.runtime_platform import ( +from formbridge.runtime_platform import ( AgentRuntimePlatform, LinuxRuntimePlatform, MacOSRuntimePlatform, diff --git a/tests/unit/agent/test_mcp_dev_client_declared_decoder.py b/tests/unit/agent/test_mcp_dev_client_declared_decoder.py index 27b23fca22..7247b3271e 100644 --- a/tests/unit/agent/test_mcp_dev_client_declared_decoder.py +++ b/tests/unit/agent/test_mcp_dev_client_declared_decoder.py @@ -7,7 +7,8 @@ import pytest from openhcs.agent.capabilities import AgentResultFamilyContract, StartOwnedRuntimeCapability -from openhcs.agent.dto.common import AgentError, AgentResultEnvelope, SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentResultEnvelope +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.execution import RuntimeBootstrapState, RuntimeBootstrapHandle from openhcs.agent.dto.mcp import McpToolErrorResult from openhcs.mcp.dev_client_core import ( diff --git a/tests/unit/agent/test_mcp_dev_client_generated_inputs.py b/tests/unit/agent/test_mcp_dev_client_generated_inputs.py index e97691d575..864c67d969 100644 --- a/tests/unit/agent/test_mcp_dev_client_generated_inputs.py +++ b/tests/unit/agent/test_mcp_dev_client_generated_inputs.py @@ -12,7 +12,7 @@ AgentCapabilityDeclaration, StartFunctionCatalogPreparationCapability, ) -from openhcs.agent.dto.common import AgentDataclassCliRequest +from formbridge.contracts import AgentDataclassCliRequest from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec from openhcs.agent.dto.functions import FunctionCatalogPreparationHandle from openhcs.mcp.dev_client import _build_parser, _calls_from_args @@ -194,7 +194,7 @@ class ExtendedConnection(*bases, ExecutionConnectionSpec): request_tag: str = "new" trace_enabled: bool = True - import openhcs.agent.dto.common as common + import formbridge.contracts as common original_decode = common.dataclass_from_mapping diff --git a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py index 183712084a..9a2d628bc4 100644 --- a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py @@ -14,7 +14,8 @@ InternalApiSymbol, ) from openhcs.agent.dto.authoring import AuthoringContext -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.functions import ( FunctionArtifactSpec, FunctionCatalogEntry, @@ -33,7 +34,7 @@ KnowledgeBaseSectionSummary, KnowledgeBaseSourceSpan, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( DirtyFields, UiCatalogPageMetadata, UiMutationReceipt, diff --git a/tests/unit/agent/test_mcp_dev_client_pipeline_results.py b/tests/unit/agent/test_mcp_dev_client_pipeline_results.py index bb4bb7f9e2..c77a27ee63 100644 --- a/tests/unit/agent/test_mcp_dev_client_pipeline_results.py +++ b/tests/unit/agent/test_mcp_dev_client_pipeline_results.py @@ -13,12 +13,8 @@ from zmqruntime.execution import ExecutionProgressObservation from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.common import ( - SCHEMA_VERSION, - AgentError, - AgentWarning, - RenderedSource, -) +from openhcs.agent.dto.common import SCHEMA_VERSION, RenderedSource +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.execution import ( ArtifactInputPlanSummary, ArtifactMaterializationPathSummary, @@ -671,11 +667,13 @@ def forbidden_serialization(value): def test_generic_call_preserves_pending_workflow_native_receipt(monkeypatch, capsys): - from openhcs.agent.dto.ui_bridge import ( + from formbridge.bridge.dto import ( UiActionIdentity, UiActionInvokeResult, UiMutationReceipt, UiMutationRequestToken, + ) + from openhcs.agent.dto.ui_bridge import ( UiSelectedPlateWorkflowKind, UiSelectedPlateWorkflowResult, ) @@ -685,7 +683,7 @@ def test_generic_call_preserves_pending_workflow_native_receipt(monkeypatch, cap UiSelectedPlateWorkflowKind("run_datasets"), UiActionInvokeResult( SCHEMA_VERSION, - UiActionIdentity(widget_id="plate_manager", action_id="run_datasets"), + UiActionIdentity(widget_id="datasets", action_id="run_datasets"), "accepted", UiMutationReceipt(UiMutationRequestToken(), accepted=True), target_scope_ids=("scope-1",), diff --git a/tests/unit/agent/test_mcp_dev_client_plate_renderers.py b/tests/unit/agent/test_mcp_dev_client_plate_renderers.py index fe3992bbd1..b609021069 100644 --- a/tests/unit/agent/test_mcp_dev_client_plate_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_plate_renderers.py @@ -11,7 +11,8 @@ from zmqruntime.config import TransportMode from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec from openhcs.agent.dto.plate import ( PlateFileQueryRecordSummary, diff --git a/tests/unit/agent/test_mcp_dev_client_renderers.py b/tests/unit/agent/test_mcp_dev_client_renderers.py index 0ec6bdbc6b..0cde3bee5b 100644 --- a/tests/unit/agent/test_mcp_dev_client_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_renderers.py @@ -21,7 +21,7 @@ import pytest from python_introspect import to_jsonable -from openhcs.agent.dto.common import AgentError, AgentWarning +from formbridge.contracts import AgentError, AgentWarning from openhcs.mcp.dev_client_core import ( McpDevServerIdentity, McpDevToolBatchResponse, @@ -176,7 +176,7 @@ def test_every_renderer_reports_a_missing_payload_as_unavailable(renderer): def test_base_appends_payload_warnings_and_declared_errors_once(): - from openhcs.agent.dto.ui_bridge import UiCodeDocumentValidationResult + from formbridge.bridge.dto import UiCodeDocumentValidationResult payload = UiCodeDocumentValidationResult( document_id="doc", diff --git a/tests/unit/agent/test_mcp_dev_client_runtime_debug_renderer.py b/tests/unit/agent/test_mcp_dev_client_runtime_debug_renderer.py index 6ee68e8b88..a87b0fc9b7 100644 --- a/tests/unit/agent/test_mcp_dev_client_runtime_debug_renderer.py +++ b/tests/unit/agent/test_mcp_dev_client_runtime_debug_renderer.py @@ -1,6 +1,6 @@ """Focused compact rendering tests for paused runtime debug inspection.""" -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.agent.dto.execution import ( ExecutionConnectionSpec, RuntimeDebugInspectionResult, diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index ffb7621857..f93b39df87 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -13,7 +13,8 @@ import pytest from openhcs.agent.capabilities import SamplePlateImageCapability, agent_capabilities -from openhcs.agent.dto.common import AgentError, SCHEMA_VERSION +from formbridge.contracts import AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.plate import PlateImageSampleResult, SelectedPlateImageSampleResult from openhcs.agent.dto.viewer import ViewerWindowImageSampleRecord, ViewerWindowImageSampleResult from openhcs.mcp import dev_client diff --git a/tests/unit/agent/test_mcp_dev_client_ui_action_results.py b/tests/unit/agent/test_mcp_dev_client_ui_action_results.py index f7b4c6d5bc..276e213cef 100644 --- a/tests/unit/agent/test_mcp_dev_client_ui_action_results.py +++ b/tests/unit/agent/test_mcp_dev_client_ui_action_results.py @@ -8,7 +8,7 @@ from openhcs.agent.capabilities import agent_capabilities from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiActionIdentity, UiActionInvokeResult, UiBridgeOperationIdentity, @@ -16,9 +16,6 @@ UiBridgeOperationRoute, UiMutationReceipt, UiMutationRequestToken, - UiPlateManagerState, - UiSelectedPlateWorkflowKind, - UiSelectedPlateWorkflowResult, UiStateSurfaceDocument, UiStateSurfaceIdentity, UiStateSurfaceSummary, @@ -26,6 +23,11 @@ UiWindowIdentity, UiWindowSummary, ) +from openhcs.agent.dto.ui_bridge import ( + UiPlateManagerState, + UiSelectedPlateWorkflowKind, + UiSelectedPlateWorkflowResult, +) from openhcs.mcp import dev_client from openhcs.mcp.dev_client_core import ( McpDevServerSpec, McpDevToolBatchResponse, McpDevToolResult, @@ -42,7 +44,7 @@ def action_fixture(): return UiActionInvokeResult( SCHEMA_VERSION, - UiActionIdentity(widget_id="plate_manager", action_id="run_datasets"), + UiActionIdentity(widget_id="datasets", action_id="run_datasets"), "accepted", UiMutationReceipt(UiMutationRequestToken(), accepted=True), target_scope_ids=("scope-1",), @@ -57,8 +59,8 @@ def action_result(capability, value): def state_fixture(compiled, revision): summary = UiStateSurfaceSummary( - SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="plate_manager.state"), - "Plate Manager", True, widget_id="plate_manager", + SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="datasets.state"), + "Plate Manager", True, widget_id="datasets", ) row = DatasetRowState( scope_id="scope-1", name="plate-one", root="/plate-one", @@ -89,7 +91,7 @@ def test_named_cli_wait_preserves_action_receipt_poll_and_final_rows( native = UiSelectedPlateWorkflowResult( SCHEMA_VERSION, UiSelectedPlateWorkflowKind("compile_datasets"), replace(action_fixture(), - identity=UiActionIdentity(widget_id="plate_manager", action_id="compile_datasets"), + identity=UiActionIdentity(widget_id="datasets", action_id="compile_datasets"), receipt=UiMutationReceipt(UiMutationRequestToken(), "operation-one", True)), ) receipt = UiBridgeOperationRef( @@ -200,7 +202,7 @@ def forbidden_serialization(value): "--arguments", '{"workflow":"run_datasets"}', ]) == 0 rendered = capsys.readouterr().out - assert "action=plate_manager/run_datasets status=accepted" in rendered + assert "action=datasets/run_datasets status=accepted" in rendered assert "accepted=True" in rendered and "targets=scope-1" in rendered assert workflow_result_action_status(result) == "accepted" assert workflow_result_target_scope_ids(result) == ("scope-1",) @@ -284,7 +286,7 @@ class ExtendedAction(UiActionInvokeResult): try: assert McpDevOutputRenderer.for_output_contract(ExtendedAction) is extension value = ExtendedAction( - SCHEMA_VERSION, UiActionIdentity(widget_id="plate_manager", action_id="run_datasets"), + SCHEMA_VERSION, UiActionIdentity(widget_id="datasets", action_id="run_datasets"), "accepted", UiMutationReceipt(UiMutationRequestToken(), accepted=True), ) decoded = McpDevToolResult._decode_payload(to_jsonable(value), (ExtendedAction,)) diff --git a/tests/unit/agent/test_mcp_dev_client_ui_renderers.py b/tests/unit/agent/test_mcp_dev_client_ui_renderers.py index 5f8a8a6057..9c6eeb95df 100644 --- a/tests/unit/agent/test_mcp_dev_client_ui_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_ui_renderers.py @@ -7,9 +7,9 @@ from python_introspect import to_jsonable -from openhcs.agent.dto.common import AgentError, AgentWarning +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.mcp import McpServerHealthResult -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( QT_TOP_LEVEL_WINDOW_KIND, UiActionCatalog, UiActionIdentity, @@ -21,10 +21,6 @@ UiCodeDocumentCatalog, UiCodeDocumentIdentity, UiCodeDocumentSummary, - UiDebugRuntimeFrameState, - UiPipelineDebugSessionState, - UiPipelineEditorState, - UiPlateManagerState, UiSnapshotRef, UiStateSurfaceCatalog, UiStateSurfaceDocument, @@ -37,9 +33,15 @@ UiWindowIdentity, UiWindowSummary, ) +from openhcs.agent.dto.ui_bridge import ( + UiDebugRuntimeFrameState, + UiPipelineDebugSessionState, + UiPipelineEditorState, + UiPlateManagerState, +) from openhcs.agent.ui_bridge_identities import ( PipelineEditorStateSurfaceIdentityDeclaration, - PlateManagerStateSurfaceIdentityDeclaration, + DatasetListStateSurfaceIdentity, ) from openhcs.mcp.dev_client_core import ( McpDevServerIdentity, @@ -183,7 +185,7 @@ def test_catalogs_filter_and_truncate_with_contains_and_limit(): surfaces = replace( sample_dto(UiStateSurfaceCatalog), surfaces=( - surface_summary("plate_manager.state", title="Plate Manager"), + surface_summary("datasets.state", title="Plate Manager"), surface_summary("plate_manager.debug", title="Debug"), surface_summary("pipeline_editor.state", title="Pipeline Editor"), ), @@ -191,7 +193,7 @@ def test_catalogs_filter_and_truncate_with_contains_and_limit(): rendered = render(surfaces, CatalogRenderOptions(contains="plate", limit=1)) assert "State surfaces: count=3 matched=2 shown=1" in rendered assert "Filter: contains=plate" in rendered - assert "- plate_manager.state: widget=" in rendered + assert "- datasets.state: widget=" in rendered assert "plate_manager.debug" not in rendered assert "..." in rendered @@ -203,7 +205,7 @@ def document(document_id): documents = replace( sample_dto(UiCodeDocumentCatalog), - documents=(document("plate_manager.orchestrator_config"), document("pipeline")), + documents=(document("datasets.orchestrator_config"), document("pipeline")), ) assert "Code documents: count=2 matched=2 shown=2" in render(documents) @@ -221,7 +223,7 @@ def test_state_surface_documents_render_through_their_surface_state(): state = replace( sample_dto(UiPlateManagerState), summary=surface_summary( - PlateManagerStateSurfaceIdentityDeclaration.require_value(), + DatasetListStateSurfaceIdentity.require_value(), current_selection_count=1, ), manager_execution_state="idle", diff --git a/tests/unit/agent/test_mcp_dev_client_viewer_renderers.py b/tests/unit/agent/test_mcp_dev_client_viewer_renderers.py index 7b8469ba12..bb32c72a39 100644 --- a/tests/unit/agent/test_mcp_dev_client_viewer_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_viewer_renderers.py @@ -6,14 +6,15 @@ from python_introspect import dataclass_from_mapping from openhcs.agent.capabilities import agent_capabilities -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning from openhcs.agent.dto.execution import ( ExecutionConnectionSpec, RuntimeExecutionStatus, RuntimeServerInfo, RuntimeServerScanResult, ) -from openhcs.agent.dto.ui_bridge import UiWindowSnapshotResult +from formbridge.bridge.dto import UiWindowSnapshotResult from openhcs.agent.dto.viewer import ( ViewerWindowDescriptor, ViewerWindowImageSampleResult, diff --git a/tests/unit/agent/test_mcp_distribution_runtime.py b/tests/unit/agent/test_mcp_distribution_runtime.py index 40da11e2fc..0c73e8d11e 100644 --- a/tests/unit/agent/test_mcp_distribution_runtime.py +++ b/tests/unit/agent/test_mcp_distribution_runtime.py @@ -7,19 +7,19 @@ import time from pathlib import Path -from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.runtime_platform import ( +from formbridge.contracts import SCHEMA_VERSION +from formbridge.runtime_platform import ( AgentRuntimePlatform, LinuxRuntimePlatform, WindowsRuntimePlatform, ) -from openhcs.agent.services.ui_bridge_service import ( - UI_BRIDGE_PROTOCOL_VERSION, - UiBridgeDescriptorDirectoryAuthority, +from formbridge.bridge.service import ( + UiBridgeDescriptorDirectories, UiBridgeDescriptorReader, ) from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION from python_introspect import to_jsonable +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication def _write_descriptor(path: Path) -> None: @@ -27,7 +27,7 @@ def _write_descriptor(path: Path) -> None: json.dumps( { "schema_version": SCHEMA_VERSION, - "bridge_protocol_version": UI_BRIDGE_PROTOCOL_VERSION, + "bridge_protocol_version": OpenHCSUiBridgeApplication.protocol_version, "application": to_jsonable(OPENHCS_ENDPOINT_APPLICATION), "bridge_instance_id": "windows-test-bridge", "pid": os.getpid(), @@ -58,7 +58,7 @@ def test_windows_descriptor_default_uses_local_application_data( staticmethod(lambda: WindowsRuntimePlatform), ) - descriptor_dirs = UiBridgeDescriptorDirectoryAuthority.descriptor_dirs() + descriptor_dirs = UiBridgeDescriptorDirectories.descriptor_dirs() assert descriptor_dirs[0] == ( local_application_data / "OpenHCS" / "runtime" / "ui-bridge" diff --git a/tests/unit/agent/test_mcp_plate_stream_progress.py b/tests/unit/agent/test_mcp_plate_stream_progress.py index 87289aa9c1..2dd6eb50a8 100644 --- a/tests/unit/agent/test_mcp_plate_stream_progress.py +++ b/tests/unit/agent/test_mcp_plate_stream_progress.py @@ -10,8 +10,7 @@ import time import pytest -from PyQt6.QtCore import QCoreApplication, QThread -from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor +from formbridge.services.async_operation_executor import AsyncOperationExecutor from polystore.disk import DiskStorageBackend from polystore.filemanager import FileManager from polystore.roi import PointShape, ROI @@ -64,7 +63,6 @@ def saved_roi_stream(tmp_path, monkeypatch): def acquire(**fields): assert threading.current_thread() is threading.main_thread() - assert QThread.currentThread() == QCoreApplication.instance().thread() assert fields["fresh"] is False and fields["ready_timeout"] == 30.0 return viewer diff --git a/tests/unit/agent/test_mcp_process_resource_lifetime.py b/tests/unit/agent/test_mcp_process_resource_lifetime.py index 1c0079f189..75158bdb86 100644 --- a/tests/unit/agent/test_mcp_process_resource_lifetime.py +++ b/tests/unit/agent/test_mcp_process_resource_lifetime.py @@ -207,7 +207,7 @@ def test_resident_connection_close_preserves_resource_until_process_owner_close( monkeypatch, unix_socket_dir ): from mcp.server.fastmcp import FastMCP - from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor + from formbridge.services.async_operation_executor import AsyncOperationExecutor from openhcs.mcp.dev_client_core import McpDevSocketSession from openhcs.mcp.socket import McpSocketTransport, wait_for_socket diff --git a/tests/unit/agent/test_mcp_server.py b/tests/unit/agent/test_mcp_server.py index 0481e1e9b1..da094cfe01 100644 --- a/tests/unit/agent/test_mcp_server.py +++ b/tests/unit/agent/test_mcp_server.py @@ -14,7 +14,7 @@ from types import SimpleNamespace import pytest -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from python_introspect import to_jsonable from zmqruntime.client import EndpointShutdownResult from zmqruntime.config import TransportMode @@ -36,7 +36,8 @@ agent_capabilities, get_capability_registry, ) -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentError from openhcs.agent.dto.config import ConfigPatch from openhcs.agent.dto.functions import ( CustomFunctionRegistrationResult, @@ -54,7 +55,7 @@ PlatePathInspectionResult, SyntheticPlateGenerationResult, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiActionCatalog, UiActionIdentity, UiActionSummary, @@ -100,13 +101,9 @@ from openhcs.agent.services.object_state_field_help_service import ( ObjectStateFieldHelpService, ) -from openhcs.agent.services.object_state_field_projection import ( - ObjectStateFieldListProjector, -) +from formbridge.bridge.field_projection import ObjectStateFieldListProjector from openhcs.agent.services.selected_plate_service import SelectedPlateService -from openhcs.agent.services.ui_bridge_service import ( - DEFAULT_UI_BRIDGE_CONNECTION_SPEC, -) +from formbridge.bridge.service import DEFAULT_UI_BRIDGE_CONNECTION_SPEC from openhcs.agent.services.viewer_window_service import ViewerWindowService from openhcs.core.streaming_config_declarations import ViewerType from openhcs.mcp.context import OpenHCSAgentContext @@ -271,15 +268,6 @@ def test_agent_dto_package_exports_mcp_debugging_contracts(): "UiSelectedPlateWorkflowKind", "UiSelectedPlateWorkflowRequest", "UiSelectedPlateWorkflowResult", - "UiObjectStateFieldHelpRequest", - "UiObjectStateFieldHelpResult", - "UiObjectStateFieldMutationRequest", - "UiObjectStateFieldMutationResult", - "UiWidgetRect", - "UiWidgetTreeNode", - "UiWidgetTreeRequest", - "UiWidgetTreeResult", - "UiWidgetActionSummary", "ViewerWindowLayerPayloads", "ViewerWindowPayloadRecord", "ViewerWindowPayloadRequest", @@ -983,7 +971,7 @@ def test_mcp_tool_descriptions_expose_debugging_result_contracts(): def test_mcp_widget_tree_projection_preserves_required_empty_action_fields(): result = UiWidgetTreeResult( schema_version=SCHEMA_VERSION, - window_id="plate_manager", + window_id="datasets", projected=True, actionable_widgets=( UiWidgetActionSummary( @@ -1145,9 +1133,9 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): UiCodeDocumentSummary( schema_version=SCHEMA_VERSION, identity=UiCodeDocumentIdentity( - document_id="plate_manager.orchestrator_config" + document_id="datasets.orchestrator_config" ), - widget_id="plate_manager", + widget_id="datasets", title="Plate config", readable=True, writable=True, @@ -1164,9 +1152,9 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, identity=UiStateSurfaceIdentity( - surface_id="plate_manager.state" + surface_id="datasets.state" ), - widget_id="plate_manager", + widget_id="datasets", title="Plate state", readable=True, ), @@ -1182,7 +1170,7 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): UiActionSummary( schema_version=SCHEMA_VERSION, identity=UiActionIdentity( - widget_id="plate_manager", action_id="add_datasets" + widget_id="datasets", action_id="add_datasets" ), title="Add plate", enabled=True, @@ -1232,7 +1220,7 @@ def test_mcp_ui_catalog_invocations_preserve_declared_identity( def test_mcp_widget_tree_projection_can_return_full_action_fields(): result = UiWidgetTreeResult( schema_version=SCHEMA_VERSION, - window_id="plate_manager", + window_id="datasets", projected=True, actionable_widgets=( UiWidgetActionSummary( @@ -1366,7 +1354,7 @@ async def call_widget_tree(arguments): compact_result = asyncio.run( call_widget_tree( { - "window_id": "plate_manager", + "window_id": "datasets", "maximum_item_model_nodes": 256, "include_tree": True, "connection": {"timeout_ms": 1234}, @@ -1376,7 +1364,7 @@ async def call_widget_tree(arguments): full_result = asyncio.run( call_widget_tree( { - "window_id": "plate_manager", + "window_id": "datasets", "compact_actions": False, "connection": {"timeout_ms": 1234}, } @@ -1393,7 +1381,7 @@ async def call_widget_tree(arguments): assert full_payload["actionable_widgets"][0]["raw_value"] is None assert ui_bridge_service.connections[0].timeout_ms == 1234 first_request = ui_bridge_service.widget_tree_requests[0] - assert first_request.window_id == "plate_manager" + assert first_request.window_id == "datasets" assert first_request.maximum_item_model_nodes == 256 assert first_request.include_tree is True assert first_request.open_policy.create_if_missing is False @@ -1621,7 +1609,7 @@ def test_mcp_stale_watchlist_includes_agent_contract_sources(): for path in watched_paths ) assert any( - path.endswith("openhcs/agent/dto/ui_bridge.py") for path in watched_paths + path.endswith("formbridge/bridge/dto.py") for path in watched_paths ) assert any(path.endswith("openhcs/agent/dto/viewer.py") for path in watched_paths) assert any( @@ -1629,12 +1617,10 @@ def test_mcp_stale_watchlist_includes_agent_contract_sources(): for path in watched_paths ) assert any( - path.endswith("openhcs/agent/services/ui_bridge_service.py") - for path in watched_paths + path.endswith("formbridge/bridge/service.py") for path in watched_paths ) assert any( - path.endswith("openhcs/agent/services/ui_bridge_transport.py") - for path in watched_paths + path.endswith("formbridge/bridge/transport.py") for path in watched_paths ) assert any( path.endswith("openhcs/agent/services/viewer_window_service.py") @@ -1648,7 +1634,7 @@ def test_mcp_stale_watchlist_includes_agent_contract_sources(): path.endswith("openhcs/runtime/viewer_protocol.py") for path in watched_paths ) assert any( - path.endswith("pyqt_reactive/services/window_snapshot.py") + path.endswith("formbridge/services/window_snapshot.py") for path in watched_paths ) @@ -2924,8 +2910,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, supported_selection_modes=("selected", "all"), @@ -2985,7 +2971,7 @@ async def call_selected_plate_tool(): result = asyncio.run(call_selected_plate_tool()) payload = json.loads(_direct_tool_text(result)) - assert ui_bridge_service.state_surface_request.surface_id == "plate_manager.state" + assert ui_bridge_service.state_surface_request.surface_id == "datasets.state" assert ui_bridge_service.state_surface_request.selection_mode == "selected" assert ui_bridge_service.connection_fields.timeout_ms == 1234 assert plate_inspection_service.request.plate_path == selected_plate_root @@ -3015,8 +3001,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, current_selection_count=1, @@ -3113,8 +3099,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, supported_selection_modes=("selected", "all"), @@ -3176,7 +3162,7 @@ async def call_selected_plate_query_tool(): result = asyncio.run(call_selected_plate_query_tool()) payload = json.loads(_direct_tool_text(result)) - assert ui_bridge_service.state_surface_request.surface_id == "plate_manager.state" + assert ui_bridge_service.state_surface_request.surface_id == "datasets.state" assert ui_bridge_service.state_surface_request.selection_mode == "selected" assert plate_inspection_service.request.plate_path == output_dataset_root assert plate_inspection_service.request.source_format == "auto" @@ -3228,8 +3214,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, current_selection_count=1, @@ -3322,8 +3308,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, supported_selection_modes=("selected", "all"), @@ -3418,7 +3404,7 @@ async def call_selected_plate_sample_tool(): result = asyncio.run(call_selected_plate_sample_tool()) payload = json.loads(_direct_tool_text(result)) - assert ui_bridge_service.state_surface_request.surface_id == "plate_manager.state" + assert ui_bridge_service.state_surface_request.surface_id == "datasets.state" assert ui_bridge_service.state_surface_request.selection_mode == "selected" assert plate_inspection_service.inspect_request is None assert plate_inspection_service.sample_request.plate_path == selected_plate_root @@ -3449,8 +3435,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, current_selection_count=1, @@ -3558,8 +3544,8 @@ def get_state_surface(self, request, connection): schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( schema_version=SCHEMA_VERSION, - identity=UiStateSurfaceIdentity(surface_id="plate_manager.state"), - widget_id="plate_manager", + identity=UiStateSurfaceIdentity(surface_id="datasets.state"), + widget_id="datasets", title="Plate manager state", readable=True, current_selection_count=1, @@ -4876,7 +4862,7 @@ def test_mcp_dev_client_ui_commands_project_connection_arguments(): state_args = parser.parse_args( ( "state-surface", - "plate_manager.state", + "datasets.state", "--selection-mode", "selected", "--base-revision-token", @@ -4891,7 +4877,7 @@ def test_mcp_dev_client_ui_commands_project_connection_arguments(): state_call = dev_client._calls_from_args(state_args)[0] assert state_call.name == "openhcs_ui_get_state_surface" - assert state_call.arguments["surface_id"] == "plate_manager.state" + assert state_call.arguments["surface_id"] == "datasets.state" assert state_call.arguments["selection_mode"] == "selected" assert state_call.arguments["base_revision_token"] == "rev-1" assert state_call.arguments["connection"] == { @@ -5643,7 +5629,7 @@ def _accepted_workflow_dev_result( "action_result": { "schema_version": "test", "identity": { - "widget_id": "plate_manager", + "widget_id": "datasets", "action_id": workflow, }, "status": "accepted", @@ -5654,12 +5640,12 @@ def _accepted_workflow_dev_result( }, "target_scope_ids": list(target_scope_ids), "selection_revision_token": None, - "workflow_status_surface_ids": ["plate_manager.state"], + "workflow_status_surface_ids": ["datasets.state"], "event_sequence": 7, "errors": [], "warnings": [], }, - "state_surface_id": "plate_manager.state", + "state_surface_id": "datasets.state", "errors": [], "warnings": [], }, @@ -5752,7 +5738,7 @@ def _rejected_workflow_dev_result( "action_result": { "schema_version": "test", "identity": { - "widget_id": "plate_manager", + "widget_id": "datasets", "action_id": "compile_datasets", }, "status": "rejected", @@ -5763,12 +5749,12 @@ def _rejected_workflow_dev_result( }, "target_scope_ids": ["scope-a"], "selection_revision_token": None, - "workflow_status_surface_ids": ["plate_manager.state"], + "workflow_status_surface_ids": ["datasets.state"], "event_sequence": 7, "errors": [error], "warnings": [], }, - "state_surface_id": "plate_manager.state", + "state_surface_id": "datasets.state", "errors": [error], "warnings": [], }, @@ -5804,7 +5790,7 @@ def test_mcp_dev_client_selected_workflow_poll_arguments_are_projected(): workflow_call = dev_client._calls_from_args(args)[0] state_arguments = dev_client.state_surface_tool_arguments( args, - surface_id="plate_manager.state", + surface_id="datasets.state", selection_mode=args.poll_selection_mode, ) @@ -5825,7 +5811,7 @@ def test_mcp_dev_client_selected_workflow_poll_arguments_are_projected(): }, } assert state_arguments == { - "surface_id": "plate_manager.state", + "surface_id": "datasets.state", "selection_mode": "all", "base_revision_token": None, "connection": { @@ -5905,8 +5891,8 @@ def _complete_plate_manager_state(sparse): from openhcs.agent.dto.ui_bridge import UiPlateManagerState summary = UiStateSurfaceSummary( - SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="plate_manager.state"), - "Plate Manager", True, widget_id="plate_manager", + SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="datasets.state"), + "Plate Manager", True, widget_id="datasets", ) row = to_jsonable(DatasetRowState( scope_id="scope", name="plate", root="/plate", pipeline_path=None, @@ -5931,8 +5917,8 @@ def _state_surface_dev_wire(dev_client): return to_jsonable(UiStateSurfaceDocument( schema_version=SCHEMA_VERSION, summary=UiStateSurfaceSummary( - SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="plate_manager.state"), - "Plate Manager", True, widget_id="plate_manager", + SCHEMA_VERSION, UiStateSurfaceIdentity(surface_id="datasets.state"), + "Plate Manager", True, widget_id="datasets", ), payload_schema="openhcs.ui.plate_manager_state.v1", payload={}, )) @@ -6175,7 +6161,7 @@ async def fake_call_tool(session, call, timeout_seconds): "openhcs_ui_get_state_surface", ] assert calls[0].arguments == { - "surface_id": "plate_manager.state", + "surface_id": "datasets.state", "selection_mode": "all", "base_revision_token": None, "connection": { @@ -6527,7 +6513,7 @@ def test_mcp_dev_client_selected_workflow_poll_recovers_from_transient_read_time import openhcs.mcp.dev_client as dev_client import openhcs.mcp.dev_client_commands.ui as ui_commands - from openhcs.agent.services.ui_bridge_service import UiBridgeGatewayTimeoutError + from formbridge.bridge.service import UiBridgeGatewayTimeoutError calls: list[dev_client.McpDevToolCall] = [] state_call_count = 0 @@ -6638,7 +6624,7 @@ def test_mcp_dev_client_selected_workflow_poll_recovers_from_transient_baseline_ import openhcs.mcp.dev_client as dev_client import openhcs.mcp.dev_client_commands.ui as ui_commands - from openhcs.agent.services.ui_bridge_service import UiBridgeGatewayTimeoutError + from formbridge.bridge.service import UiBridgeGatewayTimeoutError workflow_call_count = 0 state_call_count = 0 @@ -6746,7 +6732,7 @@ def test_mcp_dev_client_selected_workflow_poll_exhausts_transient_read_timeout( import openhcs.mcp.dev_client as dev_client import openhcs.mcp.dev_client_commands.ui as ui_commands - from openhcs.agent.services.ui_bridge_service import UiBridgeGatewayTimeoutError + from formbridge.bridge.service import UiBridgeGatewayTimeoutError workflow_call_count = 0 state_call_count = 0 @@ -7026,14 +7012,16 @@ def test_mcp_dev_client_selected_workflow_poll_summarizes_rejection( import openhcs.mcp.dev_client as dev_client import openhcs.mcp.dev_client_commands.ui as ui_commands - from openhcs.agent.dto.ui_bridge import ( - UiPlateManagerState, UiStateSurfaceDocument, - UiStateSurfaceIdentity, UiStateSurfaceSummary, + from openhcs.agent.dto.ui_bridge import UiPlateManagerState + from formbridge.bridge.dto import ( + UiStateSurfaceDocument, + UiStateSurfaceIdentity, + UiStateSurfaceSummary, ) surface_summary = UiStateSurfaceSummary( - "test", UiStateSurfaceIdentity(surface_id="plate_manager.state"), - "Plate Manager", True, widget_id="plate_manager", + "test", UiStateSurfaceIdentity(surface_id="datasets.state"), + "Plate Manager", True, widget_id="datasets", ) baseline = UiPlateManagerState( schema_version="test", summary=surface_summary, object_state_token=1, @@ -8485,8 +8473,8 @@ async def call_health_through_dev_client(): def test_mcp_dev_client_server_spec_preserves_gui_session_environment(monkeypatch): import openhcs.mcp.dev_client as dev_client from openhcs.agent.path_policy import AgentPathPolicy - from openhcs.agent.runtime_platform import AgentRuntimePlatform - from openhcs.agent.ui_bridge_environment import UiBridgeDescriptorEnvironment + from formbridge.runtime_platform import AgentRuntimePlatform + from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication from openhcs.core.native_threading import native_thread_count_environment_keys from openhcs.utils.environment import OpenHCSProcessEnvironment @@ -8495,7 +8483,7 @@ def test_mcp_dev_client_server_spec_preserves_gui_session_environment(monkeypatc monkeypatch.delenv(key, raising=False) for key in AgentPathPolicy.environment_keys(): monkeypatch.delenv(key, raising=False) - for key in UiBridgeDescriptorEnvironment.child_process_environment_keys(): + for key in OpenHCSUiBridgeApplication.descriptor_environment_keys(): monkeypatch.setenv(key, f"/tmp/{key.casefold()}") monkeypatch.setenv("DISPLAY", ":0") monkeypatch.setenv("XAUTHORITY", "/tmp/test.Xauthority") @@ -8540,10 +8528,10 @@ def test_mcp_dev_client_server_spec_preserves_gui_session_environment(monkeypatc assert environment[bootstrap.MCP_VERBOSE_ENVIRONMENT_VARIABLE] == "1" assert { key: environment[key] - for key in UiBridgeDescriptorEnvironment.child_process_environment_keys() + for key in OpenHCSUiBridgeApplication.descriptor_environment_keys() } == { key: f"/tmp/{key.casefold()}" - for key in UiBridgeDescriptorEnvironment.child_process_environment_keys() + for key in OpenHCSUiBridgeApplication.descriptor_environment_keys() } assert { key: environment[key] for key in native_thread_count_environment_keys() @@ -8560,10 +8548,7 @@ def test_mcp_dev_client_server_spec_preserves_gui_session_environment(monkeypatc def test_mcp_dev_client_server_spec_preserves_windows_host_environment(monkeypatch): import openhcs.mcp.dev_client as dev_client - from openhcs.agent.runtime_platform import ( - AgentRuntimePlatform, - WindowsRuntimePlatform, - ) + from formbridge.runtime_platform import AgentRuntimePlatform, WindowsRuntimePlatform windows_authority = WindowsRuntimePlatform() expected_keys = { diff --git a/tests/unit/agent/test_mcp_skill_registration.py b/tests/unit/agent/test_mcp_skill_registration.py index eee330b598..2a478171b1 100644 --- a/tests/unit/agent/test_mcp_skill_registration.py +++ b/tests/unit/agent/test_mcp_skill_registration.py @@ -7,7 +7,7 @@ import pytest from openhcs.agent import skill_sync -from openhcs.agent.runtime_platform import LinuxRuntimePlatform +from formbridge.runtime_platform import LinuxRuntimePlatform from openhcs.agent.skill_bundle import AgentSkillBundle from openhcs.agent.skill_sync import SkillSyncReceipt, sync_skills from openhcs.mcp import client_registration diff --git a/tests/unit/agent/test_object_state_field_help_service.py b/tests/unit/agent/test_object_state_field_help_service.py index d13faedb8e..90285be7d1 100644 --- a/tests/unit/agent/test_object_state_field_help_service.py +++ b/tests/unit/agent/test_object_state_field_help_service.py @@ -11,7 +11,7 @@ SuppliedFromArtifactInput, SuppliedFromPrimaryInput, ) -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiObjectStateFieldHelpRequest, UiObjectStateFieldSummary, UiObjectStateScopeCatalog, @@ -19,9 +19,7 @@ UiObjectStateScopeSummary, UiSemanticAddress, ) -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeListObjectStateScopesOperation, -) +from formbridge.bridge.service import UiBridgeListObjectStateScopesOperation from openhcs.agent.services.object_state_field_help_service import ( ObjectStateFieldHelpService, ) diff --git a/tests/unit/agent/test_owned_runtime_bootstrap.py b/tests/unit/agent/test_owned_runtime_bootstrap.py index 6faed35b1a..6b9be0297e 100644 --- a/tests/unit/agent/test_owned_runtime_bootstrap.py +++ b/tests/unit/agent/test_owned_runtime_bootstrap.py @@ -31,6 +31,7 @@ EndpointShutdownMode, EndpointShutdownResult, ) +from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG _PRODUCTION_SPAWN = ZMQExecutionClient._spawn_server_process @@ -279,7 +280,7 @@ def test_effective_connection_matches_configured_native_endpoint( configured_mode if requested_mode is None else requested_mode ) resolved = connection.resolved(config) - assert resolved.transport_endpoint() == endpoint + assert resolved.transport_endpoint(OPENHCS_ZMQ_CONFIG) == endpoint assert resolved.execution_client(config).endpoint == endpoint assert connection.transport_mode is requested_mode # Input stays immutable. assert ZMQExecutionClient(config=config).endpoint == config.client_endpoint() @@ -347,7 +348,7 @@ def close(client, identity, *, mode, operation_deadline): started = service.start_from_request(request) handle = dataclass_from_mapping(RuntimeBootstrapHandle, to_jsonable(started.handle)) assert handle.connection.transport_mode is expected.transport_mode - assert handle.connection.transport_endpoint() == expected + assert handle.connection.transport_endpoint(OPENHCS_ZMQ_CONFIG) == expected assert not handle.launch_plan.startup_status_file.exists() # Retained explicit routing wins even if another observer has a different # configured default. No second transport record or mutable mode cache. diff --git a/tests/unit/agent/test_plate_inspection_service.py b/tests/unit/agent/test_plate_inspection_service.py index 66cb9b206f..0cf9ad7a4a 100644 --- a/tests/unit/agent/test_plate_inspection_service.py +++ b/tests/unit/agent/test_plate_inspection_service.py @@ -264,7 +264,7 @@ def test_plate_inspection_auto_detects_imagexpress_without_mutating(tmp_path: Pa is PlateInspectionSourceBindingRole.NOT_PROJECTED_BY_HANDLER ) assert result.workflow_advice.ui_code_document_id == ( - "plate_manager.orchestrator_config" + "datasets.orchestrator_config" ) assert result.workflow_advice.ui_operation == "initialize_datasets" assert not (plate / "openhcs_metadata.json").exists() diff --git a/tests/unit/agent/test_s1_typed_viewer_rendering.py b/tests/unit/agent/test_s1_typed_viewer_rendering.py index db34f1d168..8b1292a751 100644 --- a/tests/unit/agent/test_s1_typed_viewer_rendering.py +++ b/tests/unit/agent/test_s1_typed_viewer_rendering.py @@ -11,7 +11,8 @@ ProbeViewerWindowCapability, ValidateViewerWindowStateCapability, ) -from openhcs.agent.dto.common import AgentError, AgentWarning, SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentWarning +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ( ViewerWindowDescriptor, diff --git a/tests/unit/agent/test_ui_bridge_operation_family.py b/tests/unit/agent/test_ui_bridge_operation_family.py index c1b3b75623..1bd3cc966f 100644 --- a/tests/unit/agent/test_ui_bridge_operation_family.py +++ b/tests/unit/agent/test_ui_bridge_operation_family.py @@ -16,15 +16,25 @@ UiBridgeCapability, agent_capability_declarations, ) -from openhcs.agent.services.ui_bridge_service import UiBridgeOperation +from formbridge.bridge.service import UiBridgeOperation REPOSITORY = Path(__file__).resolve().parents[3] PRODUCTION = REPOSITORY / "openhcs" +# The generic bridge lives in formbridge (Qt-free) and pyqt-reactive (Qt). +BRIDGE_SOURCES = ( + PRODUCTION, + REPOSITORY / "external" / "formbridge" / "src" / "formbridge", + REPOSITORY / "external" / "pyqt-reactive" / "src" / "pyqt_reactive", +) + + +def _sources() -> tuple[Path, ...]: + return tuple(path for root in BRIDGE_SOURCES for path in root.rglob("*.py")) def _classes() -> dict[str, tuple[Path, ast.ClassDef]]: classes = {} - for path in PRODUCTION.rglob("*.py"): + for path in _sources(): for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): if isinstance(node, ast.ClassDef): classes[node.name] = (path, node) @@ -97,10 +107,10 @@ def test_service_defines_no_per_operation_method() -> None: def test_every_wire_operation_has_one_running_ui_implementation() -> None: - from openhcs.pyqt_gui.services.ui_agent_bridge import UiAgentBridgeService + from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService wire = set(UiBridgeOperation.__registry__.values()) - assert set(UiAgentBridgeService.served_operations) == wire + assert set(OpenHCSUiAgentBridgeService.served_operations) == wire def test_every_operation_answers_its_own_failure() -> None: @@ -171,7 +181,7 @@ def test_deleted_bridge_mechanisms_stay_deleted() -> None: ) offenders = [ f"{path.relative_to(REPOSITORY)}:{number}" - for path in PRODUCTION.rglob("*.py") + for path in _sources() for number, line in enumerate( path.read_text(encoding="utf-8").splitlines(), start=1 ) diff --git a/tests/unit/agent/test_ui_bridge_service.py b/tests/unit/agent/test_ui_bridge_service.py index ea5db8b110..304da17fb9 100644 --- a/tests/unit/agent/test_ui_bridge_service.py +++ b/tests/unit/agent/test_ui_bridge_service.py @@ -1,5 +1,5 @@ from __future__ import annotations -from openhcs.core.selection import AllItems, SelectedItems +from formbridge.selection import AllItems, SelectedItems from openhcs.agent.dto.session import DatasetRowState import inspect @@ -10,12 +10,13 @@ from pathlib import Path import pytest -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from zmqruntime import EndpointApplication, EndpointApplicationCompatibilityError from openhcs.agent.path_policy import AgentPathPolicy -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentError, AgentResourceRef -from openhcs.agent.dto.ui_bridge import ( +from formbridge.contracts import SCHEMA_VERSION +from formbridge.contracts import AgentError, AgentResourceRef +from formbridge.bridge.dto import ( ActionAccepted, OperationCompleted, OperationFailed, @@ -43,7 +44,6 @@ UiCodeDocumentApplyRequest, UiCodeDocumentApplyResult, UiCodeDocumentCatalog, - UiCodeDocumentId, UiCodeDocumentIdentity, UiCodeDocumentRequest, UiCodeDocumentSummary, @@ -60,10 +60,6 @@ UiObjectStateScopeListRequest, UiObjectStateScopeSummary, UiObjectStateValuePreview, - UiPlateManagerState, - UiSelectedPlateWorkflowKind, - UiSelectedPlateWorkflowRequest, - UiSelectedPlateWorkflowResult, UiSemanticAddress, UiSnapshotCatalog, UiSnapshotListRequest, @@ -72,7 +68,6 @@ UiSnapshotRestoreResult, UiStateSurfaceCatalog, UiStateSurfaceDocument, - UiStateSurfaceId, UiStateSurfaceIdentity, UiStateSurfaceRequest, UiStateSurfaceSummary, @@ -97,12 +92,17 @@ UiWindowSnapshotResult, UiWindowSummary, ) -from openhcs.agent.runtime_platform import AgentRuntimePlatform -from openhcs.agent.services.ui_bridge_service import ( - UI_BRIDGE_PROTOCOL_VERSION, +from openhcs.agent.dto.ui_bridge import ( + UiPlateManagerState, + UiSelectedPlateWorkflowKind, + UiSelectedPlateWorkflowRequest, + UiSelectedPlateWorkflowResult, +) +from formbridge.runtime_platform import AgentRuntimePlatform +from formbridge.bridge.service import ( UiBridgeApplyDocumentOperation, UiBridgeCloseWindowOperation, - UiBridgeDescriptorDirectoryAuthority, + UiBridgeDescriptorDirectories, UiBridgeDescriptorReader, UiBridgeGatewayABC, UiBridgeGatewayResponseError, @@ -117,7 +117,6 @@ UiBridgeMutateObjectStateFieldOperation, UiBridgeProcessAdvertisedDescriptorCatalog, UiBridgeRestoreSnapshotOperation, - UiBridgeSelectedPlateWorkflowOperation, UiBridgeService, UiBridgeSnapshotWindowOperation, UiBridgeStatusOperation, @@ -125,14 +124,21 @@ UiBridgeWaitForOperationReceiptOperation, UiBridgeWidgetTreeOperation, ) -from openhcs.agent.services.ui_bridge_transport import UiBridgeControlClient +from openhcs.agent.services.ui_bridge_service import UiBridgeSelectedPlateWorkflowOperation +from formbridge.bridge.transport import UiBridgeControlClient from openhcs.runtime.viewer_protocol import HeadlessSession, ProjectedGraphicalSession from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION from python_introspect import to_jsonable +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication +from openhcs.agent.ui_bridge_identities import ( + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, +) +from openhcs.agent.services.ui_bridge_service import OpenHCSUiBridgeService -DOCUMENT_ID = UiCodeDocumentId.PLATE_MANAGER_ORCHESTRATOR.value -STATE_SURFACE_ID = UiStateSurfaceId.PLATE_MANAGER.value -WIDGET_ID = "plate_manager" +DOCUMENT_ID = DatasetListCodeDocumentIdentity.require_value() +STATE_SURFACE_ID = DatasetListStateSurfaceIdentity.require_value() +WIDGET_ID = "datasets" PLATE_SCOPE_ID = "plate-1" PLATE_NAME = "plate 1" BRIDGE_ID = "bridge-1" @@ -170,7 +176,7 @@ class UiBridgeDescriptorFile: def write(self) -> Path: payload = { "schema_version": SCHEMA_VERSION, - "bridge_protocol_version": UI_BRIDGE_PROTOCOL_VERSION, + "bridge_protocol_version": OpenHCSUiBridgeApplication.protocol_version, "application": to_jsonable(OPENHCS_ENDPOINT_APPLICATION), "bridge_instance_id": self.bridge_id, "pid": os.getpid(), @@ -885,7 +891,7 @@ def test_ui_bridge_service_resolves_projected_graphical_viewer_launch_context( monkeypatch.setenv("OPENHCS_UI_BRIDGE_DESCRIPTOR", str(descriptor)) monkeypatch.setattr( - "openhcs.agent.runtime_platform." + "formbridge.runtime_platform." "LinuxRuntimePlatform._process_environment", lambda self, pid: ( { @@ -900,7 +906,7 @@ def test_ui_bridge_service_resolves_projected_graphical_viewer_launch_context( ), ) - launch_context = UiBridgeService(path_policy=object()).viewer_launch_context() + launch_context = OpenHCSUiBridgeService(path_policy=object()).viewer_launch_context() assert launch_context.session is ProjectedGraphicalSession assert launch_context.environment_overlay == { @@ -993,7 +999,7 @@ def test_descriptor_reader_rejects_mismatched_openhcs_application( def test_ui_bridge_client_rejects_mismatched_server_application() -> None: request = UiBridgeRequestEnvelope( schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, + bridge_protocol_version=OpenHCSUiBridgeApplication.protocol_version, application=OPENHCS_ENDPOINT_APPLICATION, request_id="request-1", operation="status", @@ -1002,7 +1008,7 @@ def test_ui_bridge_client_rejects_mismatched_server_application() -> None: ) response = UiBridgeResponseEnvelope( schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, + bridge_protocol_version=OpenHCSUiBridgeApplication.protocol_version, application=EndpointApplication(identifier="openhcs", version="0.7.22"), request_id=request.request_id, ok=True, @@ -1050,7 +1056,7 @@ def test_descriptor_catalog_removes_dead_process_before_application_compatibilit descriptor_path.write_text(json.dumps(payload), encoding="utf-8") monkeypatch.delenv("OPENHCS_UI_BRIDGE_DESCRIPTOR", raising=False) monkeypatch.setattr( - UiBridgeDescriptorDirectoryAuthority, + UiBridgeDescriptorDirectories, "descriptor_dirs", classmethod(lambda cls: (descriptor_directory,)), ) @@ -1168,7 +1174,7 @@ def test_descriptor_resolver_reports_ambiguous_live_descriptors(monkeypatch, tmp monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) - service = UiBridgeService() + service = OpenHCSUiBridgeService() status = service.invoke(UiBridgeStatusOperation) launch_context = service.viewer_launch_context() @@ -1237,7 +1243,7 @@ def test_descriptor_resolution_uses_process_advertised_descriptor( proc_root, ) monkeypatch.setattr( - UiBridgeDescriptorDirectoryAuthority, + UiBridgeDescriptorDirectories, "descriptor_dirs", classmethod(lambda cls: (tmp_path / "empty-descriptor-dir",)), ) @@ -1303,7 +1309,7 @@ def test_descriptor_catalog_unions_default_and_process_advertised_live_bridges( ).write() monkeypatch.delenv("OPENHCS_UI_BRIDGE_DESCRIPTOR", raising=False) monkeypatch.setattr( - UiBridgeDescriptorDirectoryAuthority, + UiBridgeDescriptorDirectories, "descriptor_dirs", classmethod(lambda cls: (default_directory,)), ) @@ -1343,7 +1349,7 @@ def test_descriptor_catalog_reads_duplicate_resolved_path_once(monkeypatch, tmp_ alias_path = descriptor_directory / ".." / "descriptors" / descriptor_path.name monkeypatch.delenv("OPENHCS_UI_BRIDGE_DESCRIPTOR", raising=False) monkeypatch.setattr( - UiBridgeDescriptorDirectoryAuthority, + UiBridgeDescriptorDirectories, "descriptor_dirs", classmethod(lambda cls: (descriptor_directory,)), ) @@ -1877,7 +1883,7 @@ def close_window( assert error.code == "unsupported_ui_bridge_operation" assert error.hint is not None assert "Restart the UI or UI bridge process" in error.hint - assert "current OpenHCS source" in error.hint + assert "current source" in error.hint def test_restore_request_rejects_multiple_selectors(monkeypatch, tmp_path): @@ -1983,7 +1989,7 @@ def get_operation_status(connection, request): monkeypatch.setattr(gateway, "get_operation_status", get_operation_status) monkeypatch.setattr( - "openhcs.agent.services.ui_bridge_service.time.sleep", + "formbridge.bridge.service.time.sleep", lambda _seconds: None, ) service = UiBridgeService(gateway=gateway) @@ -2031,7 +2037,7 @@ def test_wait_for_operation_receipt_returns_fresh_running_ref_with_timeout_error lambda _connection, _request: running, ) monkeypatch.setattr( - "openhcs.agent.services.ui_bridge_service.time.monotonic", + "formbridge.bridge.service.time.monotonic", lambda: next(monotonic_values), ) service = UiBridgeService(gateway=gateway) @@ -2050,7 +2056,7 @@ def test_wait_for_operation_receipt_returns_fresh_running_ref_with_timeout_error assert result.status == "running" assert result.started_at_unix == 7.0 assert result.errors[0].code == "ui_bridge_operation_wait_timeout" - assert "openhcs_ui_wait_for_operation_receipt" in result.errors[0].hint + assert "get_operation_status" in result.errors[0].hint assert "does not establish domain workflow completion" in result.errors[0].hint assert result.identity is running.identity @@ -2148,7 +2154,7 @@ def test_ui_requests_own_mcp_tool_argument_projection(): base_revision_token="rev-1", ), UiActionInvokeRequest.from_fields( - widget_id="plate_manager", + widget_id="datasets", action_id="compile_datasets", ), UiWidgetActionInvokeRequest.from_fields( diff --git a/tests/unit/agent/test_user_facing_help_projection.py b/tests/unit/agent/test_user_facing_help_projection.py index 7804584e2e..16361297c8 100644 --- a/tests/unit/agent/test_user_facing_help_projection.py +++ b/tests/unit/agent/test_user_facing_help_projection.py @@ -7,7 +7,7 @@ from pathlib import Path from objectstate import ObjectState -from pyqt_reactive.services.parameter_help_service import ( +from formbridge.services.parameter_help_service import ( dataclass_parameter_descriptions, docstring_info_for_target, ) @@ -527,6 +527,7 @@ def test_help_projection_has_no_semantic_mirror_or_name_dispatch() -> None: production_roots = ( REPOSITORY_ROOT / "openhcs", REPOSITORY_ROOT / "external/arraybridge/src", + REPOSITORY_ROOT / "external/formbridge/src", REPOSITORY_ROOT / "external/pyqt-reactive/src", REPOSITORY_ROOT / "external/python-introspect/src", REPOSITORY_ROOT / "scripts", diff --git a/tests/unit/agent/test_viewer_render_snapshot.py b/tests/unit/agent/test_viewer_render_snapshot.py index 64192eac87..5c03d086f9 100644 --- a/tests/unit/agent/test_viewer_render_snapshot.py +++ b/tests/unit/agent/test_viewer_render_snapshot.py @@ -9,15 +9,17 @@ import pytest from pyqt_reactive.services.window_snapshot import ( QtWindowSnapshotRequest, + WindowSnapshotRenderOwner, +) +from formbridge.services.window_snapshot import ( WindowRenderFrame, WindowSnapshotFrameCondition, - WindowSnapshotRenderOwner, WindowVisualObservation, ) from python_introspect import dataclass_from_mapping, to_jsonable from openhcs.agent.capabilities import ViewerSnapshotWindowCapability -from openhcs.agent.dto.common import AgentError +from formbridge.contracts import AgentError from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ( ViewerWindowDescriptor, diff --git a/tests/unit/benchmark/test_agent_validation_attempt_recorder.py b/tests/unit/benchmark/test_agent_validation_attempt_recorder.py index 77037adc67..db758d4843 100644 --- a/tests/unit/benchmark/test_agent_validation_attempt_recorder.py +++ b/tests/unit/benchmark/test_agent_validation_attempt_recorder.py @@ -31,11 +31,7 @@ DatasetRowState, SessionOperationResult, ) -from openhcs.agent.dto.ui_bridge import ( - UiCodeDocument, - UiCodeDocumentIdentity, - UiCodeDocumentSummary, -) +from formbridge.bridge.dto import UiCodeDocument, UiCodeDocumentIdentity, UiCodeDocumentSummary from openhcs.core.config import PipelineConfig from openhcs.core.pipeline_document import PipelineDocumentCodec from openhcs.core.steps.function_step import FunctionStep diff --git a/tests/unit/desktop/test_desktop_deployment.py b/tests/unit/desktop/test_desktop_deployment.py index ab041789b8..e2dd14ff99 100644 --- a/tests/unit/desktop/test_desktop_deployment.py +++ b/tests/unit/desktop/test_desktop_deployment.py @@ -11,10 +11,7 @@ import pytest from openhcs.desktop import deployment as desktop_deployment -from openhcs.agent.runtime_platform import ( - MacOSRuntimePlatform, - WindowsRuntimePlatform, -) +from formbridge.runtime_platform import MacOSRuntimePlatform, WindowsRuntimePlatform from openhcs.desktop.deployment import ( DesktopDeployment, DesktopDeploymentContext, diff --git a/tests/unit/desktop/test_desktop_update.py b/tests/unit/desktop/test_desktop_update.py index 1a6f80d1a2..52a02b4bc8 100644 --- a/tests/unit/desktop/test_desktop_update.py +++ b/tests/unit/desktop/test_desktop_update.py @@ -14,7 +14,7 @@ BackgroundProcessPlatform, WindowsBackgroundProcesses, ) -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.scope_token_service import ScopeTokenService from pyqt_reactive.theming import ColorScheme import openhcs.pyqt_gui.main as main_module diff --git a/tests/unit/desktop/test_desktop_update_worker.py b/tests/unit/desktop/test_desktop_update_worker.py index 3d252f5ea9..31b4a7501a 100644 --- a/tests/unit/desktop/test_desktop_update_worker.py +++ b/tests/unit/desktop/test_desktop_update_worker.py @@ -960,6 +960,7 @@ def test_worker_process_waits_updates_restarts_and_restores_session( "external/arraybridge/src", "external/pycodify/src", "external/PolyStore/src", + "external/formbridge/src", "external/pyqt-reactive/src", "external/zmqruntime/src", ) diff --git a/tests/unit/desktop/test_zmq_version_restart.py b/tests/unit/desktop/test_zmq_version_restart.py index 780df5766f..8e0b300e4a 100644 --- a/tests/unit/desktop/test_zmq_version_restart.py +++ b/tests/unit/desktop/test_zmq_version_restart.py @@ -6,9 +6,7 @@ from types import SimpleNamespace from PyQt6.QtWidgets import QDialog -from pyqt_reactive.services.async_operation_executor import ( - AsyncOperationExecutorClosedError, -) +from formbridge.services.async_operation_executor import AsyncOperationExecutorClosedError from openhcs.core.execution_state import ManagerExecutionState from openhcs.desktop.update import DesktopUpdateError diff --git a/tests/unit/pyqt_gui/test_cellprofiler_parameter_help.py b/tests/unit/pyqt_gui/test_cellprofiler_parameter_help.py index 415bc3f21b..5d7145902c 100644 --- a/tests/unit/pyqt_gui/test_cellprofiler_parameter_help.py +++ b/tests/unit/pyqt_gui/test_cellprofiler_parameter_help.py @@ -14,7 +14,7 @@ def test_parameter_help_content_uses_parameter_window_not_docstring_mirror( import openhcs # noqa: F401 - activates source-checkout externals first from PyQt6.QtWidgets import QApplication - from pyqt_reactive.services.help_document import HelpDocumentFormat + from formbridge.services.help_document import HelpDocumentFormat from pyqt_reactive.widgets.help_document_browser import HelpDocumentBrowser from pyqt_reactive.windows.help_window_manager import ( HELP_WINDOW_DIALOG_MARGIN, @@ -146,9 +146,7 @@ def enableable_probe(image, *, enabled: bool = True): def test_callable_popup_projection_has_no_parameter_name_dispatch() -> None: import openhcs # noqa: F401 - activates source-checkout externals first - from pyqt_reactive.services.parameter_help_service import ( - parameter_description_from_target, - ) + from formbridge.services.parameter_help_service import parameter_description_from_target source = inspect.getsource(parameter_description_from_target) tree = ast.parse(source) @@ -249,7 +247,7 @@ def record_docstring_help(*args, **kwargs): def test_dataclass_help_uses_source_field_docs_instead_of_signature() -> None: import openhcs # noqa: F401 - activates source-checkout externals first from openhcs.core.config import LazyPathPlanningConfig, PathPlanningConfig - from pyqt_reactive.services.parameter_help_service import ( + from formbridge.services.parameter_help_service import ( docstring_info_for_target, resolved_parameter_description, ) @@ -293,7 +291,7 @@ def test_nested_dataclass_form_uses_nested_dataclass_help_target( ParameterFormManager, ) from pyqt_reactive.theming import ColorScheme - from pyqt_reactive.services.parameter_help_service import source_dataclass_type + from formbridge.services.parameter_help_service import source_dataclass_type app = QApplication.instance() or QApplication([]) manager = ParameterFormManager( diff --git a/tests/unit/pyqt_gui/test_code_document_catalog_imports.py b/tests/unit/pyqt_gui/test_code_document_catalog_imports.py index 6cf90472f1..2131918200 100644 --- a/tests/unit/pyqt_gui/test_code_document_catalog_imports.py +++ b/tests/unit/pyqt_gui/test_code_document_catalog_imports.py @@ -38,8 +38,8 @@ from openhcs.pyqt_gui.services.ui_agent_bridge import ( UiCodeDocumentExecutionService, UiCodeDocumentSourcePolicy, - UiCodeDocumentValidationError, ) +from formbridge.bridge.in_process import UiCodeDocumentValidationError from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerCodeDocumentAuthority, ) diff --git a/tests/unit/pyqt_gui/test_config_preview_formatters.py b/tests/unit/pyqt_gui/test_config_preview_formatters.py index 7fc0e83e6c..4a5d4612f2 100644 --- a/tests/unit/pyqt_gui/test_config_preview_formatters.py +++ b/tests/unit/pyqt_gui/test_config_preview_formatters.py @@ -1,6 +1,6 @@ """Declaration-driven OpenHCS config preview projections.""" -from pyqt_reactive.utils.preview_formatters import resolve_preview_label +from formbridge.utils.preview_formatters import resolve_preview_label from openhcs.core.config import ( LazyNapariStreamingConfig, diff --git a/tests/unit/pyqt_gui/test_dual_editor_window_artifact_refresh.py b/tests/unit/pyqt_gui/test_dual_editor_window_artifact_refresh.py index 2919cb0ef2..88e80d7c5f 100644 --- a/tests/unit/pyqt_gui/test_dual_editor_window_artifact_refresh.py +++ b/tests/unit/pyqt_gui/test_dual_editor_window_artifact_refresh.py @@ -4,7 +4,7 @@ from openhcs.core.steps.function_step import FunctionStep from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.scope_token_service import ScopeTokenService from pyqt_reactive.widgets.shared.base_form_dialog import BaseFormDialog from pyqt_reactive.widgets.shared.dirty_window_presenter import DirtyWindowStateTracker diff --git a/tests/unit/pyqt_gui/test_durable_decorated_history.py b/tests/unit/pyqt_gui/test_durable_decorated_history.py index b6af34a318..a64b398dbd 100644 --- a/tests/unit/pyqt_gui/test_durable_decorated_history.py +++ b/tests/unit/pyqt_gui/test_durable_decorated_history.py @@ -11,7 +11,7 @@ from arraybridge.decorators import ThreadGPUContext from arraybridge.types import MemoryType from objectstate import ObjectStateRegistry -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.scope_token_service import ScopeTokenService from openhcs.core.config import PipelineConfig from openhcs.core.memory import numpy diff --git a/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py b/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py index 7839ea6355..3fd20dc1cf 100644 --- a/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py +++ b/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py @@ -12,6 +12,7 @@ PRODUCT = REPO / "openhcs" PYQT_REACTIVE = REPO / "external" / "pyqt-reactive" / "src" / "pyqt_reactive" OBJECTSTATE = REPO / "external" / "ObjectState" / "src" / "objectstate" +FORMBRIDGE = REPO / "external" / "formbridge" / "src" / "formbridge" MOVED_MODULES = ( "core/path_cache.py", @@ -103,7 +104,9 @@ def test_openhcs_defines_no_library_machinery() -> None: assert found == [] -@pytest.mark.parametrize("root", (PRODUCT, PYQT_REACTIVE, OBJECTSTATE), ids=lambda p: p.name) +@pytest.mark.parametrize( + "root", (PRODUCT, PYQT_REACTIVE, FORMBRIDGE, OBJECTSTATE), ids=lambda p: p.name +) def test_scope_ids_are_built_through_scope_key(root: Path) -> None: """No hand-written '::' scope grammar outside objectstate.scope_key.""" @@ -138,7 +141,7 @@ def test_xdg_paths_carry_no_legacy_migration() -> None: def test_library_families_are_classes_not_enums() -> None: - import pyqt_reactive.core.path_cache as path_cache + import formbridge.core.path_cache as path_cache import pyqt_reactive.services.time_travel_navigation as time_travel_navigation import pyqt_reactive.widgets.enhanced_path_widget as enhanced_path_widget import pyqt_reactive.widgets.grid_selector as grid_selector @@ -155,7 +158,11 @@ def test_library_families_are_classes_not_enums() -> None: def test_external_editor_switch_is_named_once() -> None: readers = sorted( str(path.relative_to(REPO)) - for path in (*_python_files(PRODUCT), *_python_files(PYQT_REACTIVE)) + for path in ( + *_python_files(PRODUCT), + *_python_files(PYQT_REACTIVE), + *_python_files(FORMBRIDGE), + ) if "OPENHCS_USE_EXTERNAL_EDITOR" in path.read_text(encoding="utf-8") ) assert readers == ["openhcs/pyqt_gui/services/reactor_providers.py"] diff --git a/tests/unit/pyqt_gui/test_interval_snapshot_poller.py b/tests/unit/pyqt_gui/test_interval_snapshot_poller.py index d3f2790bb3..024b05f1a6 100644 --- a/tests/unit/pyqt_gui/test_interval_snapshot_poller.py +++ b/tests/unit/pyqt_gui/test_interval_snapshot_poller.py @@ -1,7 +1,7 @@ import threading import time -from pyqt_reactive.services.interval_snapshot_poller import ( +from formbridge.services.interval_snapshot_poller import ( CallbackIntervalSnapshotPollerPolicy, IntervalSnapshotPoller, ) diff --git a/tests/unit/pyqt_gui/test_live_server_tree_sync.py b/tests/unit/pyqt_gui/test_live_server_tree_sync.py index d5c22fa038..5c7b545206 100644 --- a/tests/unit/pyqt_gui/test_live_server_tree_sync.py +++ b/tests/unit/pyqt_gui/test_live_server_tree_sync.py @@ -10,11 +10,8 @@ except ImportError: PYQT_AVAILABLE = False -from pyqt_reactive.services.zmq_server_info import ( - BaseServerInfo, - ExecutionServerInfo, -) -from pyqt_reactive.services.zmq_server_scan_service import StartingEndpointObservation +from formbridge.services.zmq_server_info import BaseServerInfo, ExecutionServerInfo +from formbridge.services.zmq_server_scan_service import StartingEndpointObservation from zmqruntime.messages import PongResponse from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus diff --git a/tests/unit/pyqt_gui/test_logging_config.py b/tests/unit/pyqt_gui/test_logging_config.py index b7425ae620..a4296c86f9 100644 --- a/tests/unit/pyqt_gui/test_logging_config.py +++ b/tests/unit/pyqt_gui/test_logging_config.py @@ -6,13 +6,12 @@ from pathlib import Path from unittest.mock import patch -from pyqt_reactive.protocols import ( +from formbridge.protocols.component_selection import ( ComponentSelectionProviderABC, FunctionSelectionProviderABC, - LogDiscoveryProviderABC, - ServerScanProviderABC, ) -from pyqt_reactive.services.zmq_server_scan_service import ( +from formbridge.protocols.log_providers import LogDiscoveryProviderABC, ServerScanProviderABC +from formbridge.services.zmq_server_scan_service import ( EndpointObservationSnapshot, ZMQServerScanService, ) diff --git a/tests/unit/pyqt_gui/test_main_config_propagation.py b/tests/unit/pyqt_gui/test_main_config_propagation.py index db75ee27fb..514d8a05f0 100644 --- a/tests/unit/pyqt_gui/test_main_config_propagation.py +++ b/tests/unit/pyqt_gui/test_main_config_propagation.py @@ -5,9 +5,7 @@ import pytest from PyQt6.QtWidgets import QWidget -from pyqt_reactive.services.async_operation_executor import ( - AsyncOperationExecutorClosedError, -) +from formbridge.services.async_operation_executor import AsyncOperationExecutorClosedError import openhcs.pyqt_gui.main as main_module from openhcs.core.config import GlobalPipelineConfig @@ -337,9 +335,7 @@ def record(self, value=None, *additional_values, **named_values) -> None: def test_list_preview_config_applies_to_both_real_lists(qtbot) -> None: from pyqt_reactive.core import ReorderableListWidget - from pyqt_reactive.strategies.preview_formatting import ( - ObjectStatePreviewFormattingService, - ) + from formbridge.strategies.preview_formatting import ObjectStatePreviewFormattingService from pyqt_reactive.widgets.shared.abstract_manager_widget import ( AbstractManagerWidget, ) diff --git a/tests/unit/pyqt_gui/test_main_status_bar.py b/tests/unit/pyqt_gui/test_main_status_bar.py index f65e455c30..62a60718bc 100644 --- a/tests/unit/pyqt_gui/test_main_status_bar.py +++ b/tests/unit/pyqt_gui/test_main_status_bar.py @@ -7,9 +7,7 @@ from objectstate.object_state import ObjectStateRegistry from PyQt6.QtCore import QCoreApplication, QEvent, pyqtSignal from PyQt6.QtWidgets import QApplication, QMainWindow -from pyqt_reactive.services.zmq_server_scan_service import ( - EndpointObservationSnapshot, -) +from formbridge.services.zmq_server_scan_service import EndpointObservationSnapshot from pyqt_reactive.theming import ColorScheme from zmqruntime.messages import PongResponse, ServerRole diff --git a/tests/unit/pyqt_gui/test_main_window_file_actions.py b/tests/unit/pyqt_gui/test_main_window_file_actions.py index 5cecbf11dd..1c69f81846 100644 --- a/tests/unit/pyqt_gui/test_main_window_file_actions.py +++ b/tests/unit/pyqt_gui/test_main_window_file_actions.py @@ -6,7 +6,7 @@ import pytest -from openhcs.core.selection import AllItems, SelectedItems +from formbridge.selection import AllItems, SelectedItems from openhcs.pyqt_gui.main import OpenHCSMainWindow diff --git a/tests/unit/pyqt_gui/test_main_zmq_window_routing.py b/tests/unit/pyqt_gui/test_main_zmq_window_routing.py index 8c757e11c4..ab4a9cbc8b 100644 --- a/tests/unit/pyqt_gui/test_main_zmq_window_routing.py +++ b/tests/unit/pyqt_gui/test_main_zmq_window_routing.py @@ -9,10 +9,8 @@ from PyQt6.QtGui import QCloseEvent from PyQt6.QtWidgets import QDialog, QTreeWidgetItem from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.services.zmq_server_info import BaseServerInfo -from pyqt_reactive.services.zmq_server_scan_service import ( - EndpointObservationSnapshot, -) +from formbridge.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_scan_service import EndpointObservationSnapshot from pyqt_reactive.widgets import StatusState from pyqt_reactive.widgets.shared.zmq_server_browser_widget import ( ZMQServerBrowserWidgetABC, diff --git a/tests/unit/pyqt_gui/test_object_state_callable_preview.py b/tests/unit/pyqt_gui/test_object_state_callable_preview.py index b9a9fa47ba..a42155c4ca 100644 --- a/tests/unit/pyqt_gui/test_object_state_callable_preview.py +++ b/tests/unit/pyqt_gui/test_object_state_callable_preview.py @@ -2,11 +2,9 @@ import pytest -from openhcs.agent.dto.ui_bridge import UiObjectStateScopeListRequest +from formbridge.bridge.dto import UiObjectStateScopeListRequest from objectstate.object_state import ObjectState, ObjectStateRegistry -from openhcs.pyqt_gui.services.ui_bridge_object_state import ( - ObjectStateScopeProjectionService, -) +from formbridge.bridge.object_state import ObjectStateScopeProjectionService def preview_test_callable(image, sigma: float = 1.0): diff --git a/tests/unit/pyqt_gui/test_one_persistence_format.py b/tests/unit/pyqt_gui/test_one_persistence_format.py index 9eb2b3217d..b344c53136 100644 --- a/tests/unit/pyqt_gui/test_one_persistence_format.py +++ b/tests/unit/pyqt_gui/test_one_persistence_format.py @@ -5,12 +5,10 @@ import ast from pathlib import Path -from pyqt_reactive.protocols import register_codegen_provider -from pyqt_reactive.services.function_pattern_code_document import ( - FunctionPatternCodeDocumentService, -) +from formbridge.protocols.codegen_provider import register_codegen_provider +from formbridge.services.function_pattern_code_document import FunctionPatternCodeDocumentService -from pyqt_reactive.core.path_cache import PathCacheKey +from formbridge.core.path_cache import PathCacheKey import openhcs.pyqt_gui.widgets.plate_manager # noqa: F401 - declares its dialog context from openhcs.core.steps.function_step import FunctionStep diff --git a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py index eee3ef12f2..bc9e1b9af5 100644 --- a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py +++ b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py @@ -13,16 +13,10 @@ FormManagerConfig, ParameterFormManager, ) -from pyqt_reactive.services.function_navigation import ( - build_function_token_field_path, -) -from pyqt_reactive.services.function_pattern_code_document import ( - FunctionPatternCodeDocumentService, -) -from pyqt_reactive.services.pattern_data_manager import ( - FUNC_EDITOR_PATTERN_TOKENS_META_KEY, -) -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.function_navigation import build_function_token_field_path +from formbridge.services.function_pattern_code_document import FunctionPatternCodeDocumentService +from formbridge.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY +from formbridge.services.scope_token_service import ScopeTokenService from pyqt_reactive.theming import ColorScheme from openhcs.authoring.session.compilation import CompiledDataset @@ -59,7 +53,7 @@ stack_percentile_normalize, ) from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow -from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater +from formbridge.services.code_editor_form_updater import CodeEditorFormUpdater from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from tests.unit.pyqt_gui.session_harness import add_datasets, qt_app, session_gui from openhcs.core.debug import StepDebugCommand diff --git a/tests/unit/pyqt_gui/test_pipeline_history_occurrence_restart_regression.py b/tests/unit/pyqt_gui/test_pipeline_history_occurrence_restart_regression.py index 4b7e50aa04..68ef1ec3f3 100644 --- a/tests/unit/pyqt_gui/test_pipeline_history_occurrence_restart_regression.py +++ b/tests/unit/pyqt_gui/test_pipeline_history_occurrence_restart_regression.py @@ -12,14 +12,12 @@ import pytest from objectstate import ObjectStateRegistry -from pyqt_reactive.services.function_pattern_code_document import ( +from formbridge.services.function_pattern_code_document import ( FunctionPatternCodeDocumentService, function_pattern_authority, ) -from pyqt_reactive.services.pattern_data_manager import ( - FUNC_EDITOR_PATTERN_TOKENS_META_KEY, -) -from pyqt_reactive.services.scope_token_service import ScopeTokenService +from formbridge.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY +from formbridge.services.scope_token_service import ScopeTokenService from openhcs.core.config import PipelineConfig from openhcs.core.pipeline_document import PipelineDocumentCodec diff --git a/tests/unit/pyqt_gui/test_plate_manager_widget.py b/tests/unit/pyqt_gui/test_plate_manager_widget.py index e890e9b31c..4c38f7cb55 100644 --- a/tests/unit/pyqt_gui/test_plate_manager_widget.py +++ b/tests/unit/pyqt_gui/test_plate_manager_widget.py @@ -16,13 +16,11 @@ from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus import openhcs.processing.backends.cellprofiler as cellprofiler_backend -from openhcs.core.selection import AllItems, SelectedItems -from openhcs.agent.dto.ui_bridge import ( +from formbridge.selection import AllItems, SelectedItems +from formbridge.bridge.dto import ( UiBridgeConfirmationRequirement, UiCodeDocumentApplyRequest, - UiCodeDocumentId, UiCodeDocumentRequest, - UiStateSurfaceId, UiStateSurfaceRequest, UiWindowNavigateRequest, ) @@ -97,14 +95,12 @@ MainWindowEmbeddedWidgets, MainWindowPipelineActions, ) -from openhcs.pyqt_gui.services.ui_agent_bridge import UiAgentBridgeService -from openhcs.pyqt_gui.services.ui_bridge_object_state import ( - ObjectStateBridgeProviderSet, -) +from formbridge.bridge.in_process import UiAgentBridgeService +from pyqt_reactive.bridge.object_state_documents import ObjectStateBridgeProviderSet from openhcs.pyqt_gui.services.ui_bridge_plate_manager import ( PlateManagerBridgeProviderSet, ) -from openhcs.pyqt_gui.services.ui_bridge_windows import UiWindowProjectionService +from pyqt_reactive.bridge.windows import UiWindowProjectionService from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerCodeDocumentAuthority, @@ -118,6 +114,11 @@ RunningProgressStatus, StepStartedProgressPhase, ) +from openhcs.agent.ui_bridge_identities import ( + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, +) +from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService class InlineUiThreadDispatcher: @@ -165,7 +166,7 @@ def _workspace(steps, pipeline_config: PipelineConfig | None = None): def _bridge(manager) -> UiAgentBridgeService: - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(manager), dispatcher=InlineUiThreadDispatcher(), ) @@ -174,7 +175,7 @@ def _bridge(manager) -> UiAgentBridgeService: def _apply(bridge, source: str, *, selection_mode=AllItems): document = bridge.get_document( UiCodeDocumentRequest( - document_id=UiCodeDocumentId.PLATE_MANAGER_ORCHESTRATOR.value, + document_id=DatasetListCodeDocumentIdentity.require_value(), selection_mode=selection_mode.name, ) ) @@ -1266,7 +1267,7 @@ def test_dataset_document_edits_during_a_run_and_recovers_after_its_failure( result = _apply(bridge, replacement) state = bridge.get_state_surface( UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PLATE_MANAGER.value, + surface_id=DatasetListStateSurfaceIdentity.require_value(), selection_mode=AllItems.name, ) ) @@ -1296,7 +1297,7 @@ def test_a_failure_is_presented_only_after_the_run_finalizes(tmp_path) -> None: WindowManager.register( window_id, editor, code_document_driver=editor.code_document_driver() ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=ObjectStateBridgeProviderSet(), dispatcher=InlineUiThreadDispatcher(), ) diff --git a/tests/unit/pyqt_gui/test_progress_tree_aggregation.py b/tests/unit/pyqt_gui/test_progress_tree_aggregation.py index cf3af33b63..aeff6bad54 100644 --- a/tests/unit/pyqt_gui/test_progress_tree_aggregation.py +++ b/tests/unit/pyqt_gui/test_progress_tree_aggregation.py @@ -1,6 +1,6 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem -from pyqt_reactive.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_info import BaseServerInfo from pyqt_reactive.widgets.shared import TreeStateAdapter, TreeSyncAdapter from zmqruntime.messages import PongResponse diff --git a/tests/unit/pyqt_gui/test_server_row_presenter.py b/tests/unit/pyqt_gui/test_server_row_presenter.py index b6905a600a..81917801c9 100644 --- a/tests/unit/pyqt_gui/test_server_row_presenter.py +++ b/tests/unit/pyqt_gui/test_server_row_presenter.py @@ -7,7 +7,7 @@ except Exception: PYQT_AVAILABLE = False -from pyqt_reactive.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_info import BaseServerInfo from zmqruntime.messages import PongResponse from openhcs.pyqt_gui.widgets.shared.server_browser.presentation_models import ( diff --git a/tests/unit/pyqt_gui/test_source_binding_preview_policy.py b/tests/unit/pyqt_gui/test_source_binding_preview_policy.py index 75ab576226..5aa00988e8 100644 --- a/tests/unit/pyqt_gui/test_source_binding_preview_policy.py +++ b/tests/unit/pyqt_gui/test_source_binding_preview_policy.py @@ -5,13 +5,8 @@ from openhcs.core.config import SourceBindingsConfig from openhcs.core.source_bindings import NamedSourceBinding from openhcs.pyqt_gui.config import UIConfig -from pyqt_reactive.utils.preview_formatters import ( - PreviewFieldFormatRequest, - PreviewValueDetail, -) -from pyqt_reactive.widgets.shared.manager_preview_formatting import ( - ManagerPreviewFieldFormatter, -) +from formbridge.utils.preview_formatters import PreviewFieldFormatRequest, PreviewValueDetail +from formbridge.managers.preview_formatting import ManagerPreviewFieldFormatter def test_source_binding_collection_is_compact_and_expansion_preserves_its_scope(): diff --git a/tests/unit/pyqt_gui/test_source_bindings_editor.py b/tests/unit/pyqt_gui/test_source_bindings_editor.py index abc260a3e4..0ec36f25fc 100644 --- a/tests/unit/pyqt_gui/test_source_bindings_editor.py +++ b/tests/unit/pyqt_gui/test_source_bindings_editor.py @@ -73,10 +73,7 @@ ParameterFormManager, ) from pyqt_reactive.forms.widget_strategies import PlaceholderConfig, PyQt6WidgetEnhancer -from pyqt_reactive.forms.parameter_info_types import ( - InlineDataclassWidgetInfo, - create_parameter_info, -) +from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo, create_parameter_info from pyqt_reactive.widgets.shared.scrollable_form_mixin import ScrollableFormMixin from pyqt_reactive.widgets.shared.scrollable_form_mixin import ScrollViewport from pyqt_reactive.widgets.shared.reflowing_vertical_scroll_area import ( diff --git a/tests/unit/pyqt_gui/test_step_code_widget_roundtrip.py b/tests/unit/pyqt_gui/test_step_code_widget_roundtrip.py index 4c77346bdf..e79f174170 100644 --- a/tests/unit/pyqt_gui/test_step_code_widget_roundtrip.py +++ b/tests/unit/pyqt_gui/test_step_code_widget_roundtrip.py @@ -12,11 +12,8 @@ OpenHCSComponentSelectionProvider, ) from openhcs.pyqt_gui.widgets.step_parameter_editor import StepParameterEditorWidget -from pyqt_reactive.protocols import ( - FunctionSelectionProviderABC, - codegen_provider, - component_selection, -) +from formbridge.protocols.component_selection import FunctionSelectionProviderABC +from formbridge.protocols import codegen_provider, component_selection from pyqt_reactive.widgets.function_list_editor import FunctionListEditorWidget diff --git a/tests/unit/pyqt_gui/test_system_monitor_widget.py b/tests/unit/pyqt_gui/test_system_monitor_widget.py index aceee3c79b..b929db37b1 100644 --- a/tests/unit/pyqt_gui/test_system_monitor_widget.py +++ b/tests/unit/pyqt_gui/test_system_monitor_widget.py @@ -9,16 +9,16 @@ from PyQt6.QtWidgets import QSizePolicy import pyqt_reactive.widgets.system_monitor as system_monitor -from pyqt_reactive.services.system_metrics_sampler import ( +from formbridge.services.system_metrics_sampler import ( CpuMetrics, MemoryMetrics, SystemMetricsSamplerConfig, ) -from pyqt_reactive.services.system_monitor_config import ( +from formbridge.services.system_monitor_config import ( PerformanceMonitorColors, PerformanceMonitorConfig, ) -from pyqt_reactive.services.system_metrics_sampler import SystemMetrics +from formbridge.services.system_metrics_sampler import SystemMetrics from pyqt_reactive.widgets.system_monitor import ( SystemMonitorAction, SystemMonitorGraphLayout, diff --git a/tests/unit/pyqt_gui/test_tree_state_adapter.py b/tests/unit/pyqt_gui/test_tree_state_adapter.py index 6ce25b1d32..21e8c2968a 100644 --- a/tests/unit/pyqt_gui/test_tree_state_adapter.py +++ b/tests/unit/pyqt_gui/test_tree_state_adapter.py @@ -8,7 +8,7 @@ except Exception: PYQT_AVAILABLE = False -from pyqt_reactive.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_info import BaseServerInfo from pyqt_reactive.widgets.shared import TreeNodeIdentity, TreeStateAdapter from zmqruntime.messages import PongResponse, ServerRole diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 9e4df38287..a930690b19 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -23,10 +23,8 @@ QVBoxLayout, QWidget, ) -from pyqt_reactive.protocols import register_codegen_provider -from pyqt_reactive.services.pattern_data_manager import ( - FUNC_EDITOR_PATTERN_TOKENS_META_KEY, -) +from formbridge.protocols.codegen_provider import register_codegen_provider +from formbridge.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher from pyqt_reactive.services.widget_tree_projection import ( WidgetActionKind, @@ -34,12 +32,9 @@ WidgetRect, WidgetTreeProjection, ) -from pyqt_reactive.services.window_code_document import ( - WindowCodeDocument, - WindowCodeDocumentDriver, -) +from formbridge.services.window_code_document import WindowCodeDocument, WindowCodeDocumentDriver from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from pyqt_reactive.widgets.editors import simple_code_editor from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.widgets.shared import ( @@ -56,9 +51,9 @@ from zmqruntime import DataControlPortPairAuthority, EndpointApplication from zmqruntime.config import TransportMode -from openhcs.core.selection import AllItems, SelectedItems -from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( +from formbridge.selection import AllItems, SelectedItems +from formbridge.contracts import SCHEMA_VERSION +from formbridge.bridge.dto import ( ActionAccepted, ActionRejected, DirtyFields, @@ -69,15 +64,11 @@ UiBridgeOperationStatusRequest, UiBridgeRequestEnvelope, UiCodeDocumentApplyRequest, - UiCodeDocumentId, UiCodeDocumentRequest, UiCodeDocumentValidationRequest, UiObjectStateScopeListRequest, - UiSelectedPlateWorkflowKind, - UiSelectedPlateWorkflowRequest, UiSnapshotListRequest, UiSnapshotRestoreRequest, - UiStateSurfaceId, UiStateSurfaceRequest, UiWidgetActionInvokeRequest, UiWidgetActionIssueCode, @@ -91,13 +82,13 @@ UiWindowSnapshotRequest, UiWindowSummary, ) -from openhcs.agent.services.ui_bridge_service import ( +from openhcs.agent.dto.ui_bridge import UiSelectedPlateWorkflowKind, UiSelectedPlateWorkflowRequest +from formbridge.bridge.service import ( UiBridgeGetDocumentOperation, UiBridgeGetStateSurfaceOperation, UiBridgeListDocumentsOperation, UiBridgeListStateSurfacesOperation, UiBridgeStatusOperation, - UI_BRIDGE_PROTOCOL_VERSION, UiBridgeConnectionResolution, UiBridgeService, ) @@ -161,27 +152,25 @@ MainWindowEmbeddedWidgets, ) from openhcs.pyqt_gui.services.reactor_providers import OpenHCSCodegenProvider -from openhcs.pyqt_gui.services.ui_agent_bridge import ( +from formbridge.bridge.in_process import ( UiAgentBridgeService, UiBridgeOperationTracker, - UiCodeDocumentSourcePolicy, UiObjectStateSnapshotProvider, ) -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - state_surface_ids_for_action, -) +from openhcs.pyqt_gui.services.ui_agent_bridge import UiCodeDocumentSourcePolicy +from formbridge.bridge.contracts import state_surface_ids_for_action from openhcs.pyqt_gui.services.ui_bridge_composition import ( OpenHCSUiBridgeCompositionRoot, ) -from openhcs.pyqt_gui.services.ui_bridge_live_overview import ( - LiveOverviewBridgeProviderSet, -) -from openhcs.pyqt_gui.services.ui_bridge_object_state import ( +from openhcs.pyqt_gui.services.ui_bridge_live_overview import LiveOverviewBridgeProviderSet +from formbridge.bridge.object_state import ( OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX, WINDOW_CODE_DOCUMENT_PREFIX, + ObjectStateScopeProjectionService, +) +from pyqt_reactive.bridge.object_state_documents import ( ObjectStateBridgeProviderSet, ObjectStateScopeCodeDocumentProvider, - ObjectStateScopeProjectionService, ) from openhcs.pyqt_gui.services.ui_bridge_pipeline_editor import ( PipelineEditorBridgeProviderSet, @@ -189,18 +178,18 @@ from openhcs.pyqt_gui.services.ui_bridge_plate_manager import ( PlateManagerBridgeProviderSet, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import ( +from formbridge.bridge.registry import ( UiBridgeProviderSetABC, UiBridgeRegistrationContext, UiBridgeSurfaceRegistry, ) -from openhcs.pyqt_gui.services.ui_bridge_server import ( +from formbridge.bridge.server import ( UiBridgeControlServer, UiBridgeRequestDispatcher, UiBridgeServerBinding, ) -from openhcs.pyqt_gui.services.ui_bridge_windows import ( - MainWindowBridgeProviderSet, +from openhcs.pyqt_gui.services.ui_bridge_windows import MainWindowBridgeProviderSet +from pyqt_reactive.bridge.windows import ( QtTopLevelWindowProjection, UiWidgetTreeResultFactory, UiWindowProjectionService, @@ -234,8 +223,19 @@ StepDebugCommand, StopDebugCommand, ) +from openhcs.agent.ui_bridge_identities import ( + PipelineDebugSessionStateSurfaceIdentityDeclaration, + PipelineEditorStateSurfaceIdentityDeclaration, + DatasetListCodeDocumentIdentity, + DatasetListStateSurfaceIdentity, +) +from formbridge.bridge.identities import UiLiveOverviewStateSurfaceIdentityDeclaration +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication +from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService +from pyqt_reactive.bridge.host import UiBridgeHostWindow +from openhcs.agent.ui_bridge_identities import MainWindowWidgetIdentity -DOCUMENT_ID = UiCodeDocumentId.PLATE_MANAGER_ORCHESTRATOR.value +DOCUMENT_ID = DatasetListCodeDocumentIdentity.require_value() PLATE_SCOPE_ID = "plate-1" DATASET_SCOPE_ID = "/tmp/openhcs-ui-bridge-test/plate-1" PLATE_NAME = "plate 1" @@ -676,7 +676,7 @@ def test_atomic_success_does_not_record_snapshot_on_failure() -> None: def test_selected_read_fails_loudly_when_no_plate_is_selected() -> None: - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()) ) @@ -699,7 +699,7 @@ def dataset_document(self, selection_mode): del selection_mode raise RuntimeError("context construction failed") - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FailingPlateManager(selected=())) ) @@ -898,7 +898,7 @@ def test_listed_embedded_window_routes_support_widget_tree_projection( snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -920,7 +920,7 @@ def test_listed_embedded_window_routes_support_widget_tree_projection( widget_tree = bridge.widget_tree( UiWidgetTreeRequest( - window_id="plate_manager", + window_id="datasets", open_policy=UiWindowOpenPolicy(), include_tree=True, ) @@ -964,7 +964,7 @@ def test_embedded_window_focus_uses_authoritative_dock_pane() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -991,9 +991,13 @@ def test_embedded_window_focus_uses_authoritative_dock_pane() -> None: system_pane.dock_widget.close() +class _BridgeHostWindow(QMainWindow, UiBridgeHostWindow): + UI_BRIDGE_WIDGET_IDENTITY = MainWindowWidgetIdentity + + def test_floating_embedded_pane_is_not_duplicated_as_qt_top_level() -> None: app = QtApplicationAuthority.app() - main_window = QMainWindow() + main_window = _BridgeHostWindow() main_window.session = BridgeSessions.get() embedded_widgets = MainWindowEmbeddedWidgets() pipeline_pane = DockPane.create( @@ -1024,7 +1028,7 @@ def test_floating_embedded_pane_is_not_duplicated_as_qt_top_level() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1051,7 +1055,7 @@ def test_embedded_registration_does_not_hide_main_window_from_bridge( tmp_path: Path, ) -> None: app = QtApplicationAuthority.app() - main_window = QMainWindow() + main_window = _BridgeHostWindow() main_window.session = BridgeSessions.get() main_window.setWindowTitle("OpenHCS") embedded_widgets = MainWindowEmbeddedWidgets() @@ -1085,7 +1089,7 @@ def test_embedded_registration_does_not_hide_main_window_from_bridge( snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1172,7 +1176,7 @@ def test_widget_tree_item_rows_carry_shared_object_state_roles() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1580,7 +1584,7 @@ def test_listed_qt_top_level_window_supports_operation_request_resolution() -> N snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1638,7 +1642,7 @@ def test_projected_widget_action_invokes_live_button_by_path_id() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1709,7 +1713,7 @@ def test_projected_widget_action_selects_live_item_view_row_by_path_id() -> None snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1791,7 +1795,7 @@ def test_projected_widget_action_selects_standalone_tab_bar_by_index() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1870,7 +1874,7 @@ def test_projected_widget_action_selects_combo_box_by_index() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -1943,7 +1947,7 @@ def test_legacy_empty_scope_window_catalogs_as_global_config(tmp_path: Path) -> snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2035,7 +2039,7 @@ def test_managed_window_catalog_projects_object_state_status() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2090,7 +2094,7 @@ def test_open_window_code_mode_documents_are_discoverable_by_window_id() -> None snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2143,7 +2147,7 @@ def apply_source(self, source: str) -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2211,7 +2215,7 @@ def apply_source(self, source: str) -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2270,7 +2274,7 @@ def records_object_state_snapshot_on_apply(self) -> bool: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2336,7 +2340,7 @@ def test_open_window_code_mode_summary_uses_driver_title_when_window_title_empty snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2387,7 +2391,7 @@ def test_simple_code_editor_windows_register_code_documents(monkeypatch) -> None snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2461,7 +2465,7 @@ def test_object_state_code_mode_documents_are_discoverable_by_scope_id() -> None snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -2668,7 +2672,7 @@ def test_managed_window_save_action_returns_before_deferred_save_runs() -> None: snapshot_provider=snapshot_provider, ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=dispatcher, snapshot_provider=snapshot_provider, @@ -2721,7 +2725,7 @@ def test_managed_window_save_action_returns_before_deferred_save_runs() -> None: def test_all_read_returns_source_hash_and_revision() -> None: - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()) ) @@ -2740,7 +2744,7 @@ def test_all_read_returns_source_hash_and_revision() -> None: def test_validation_rejects_side_effecting_source_before_execution() -> None: - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()) ) @@ -2757,7 +2761,7 @@ def test_validation_rejects_side_effecting_source_before_execution() -> None: def test_validation_rejects_legacy_pipeline_config_assignment() -> None: - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()) ) @@ -2778,7 +2782,7 @@ def test_validation_rejects_legacy_pipeline_config_assignment() -> None: def test_validation_reports_orchestrator_payload_hint() -> None: - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()) ) @@ -2807,7 +2811,7 @@ def test_apply_rejection_reports_current_revision_and_snapshot() -> None: state = ObjectState(Dummy(), scope_id=PLATE_SCOPE_ID) ObjectStateRegistry.register(state, _skip_snapshot=True) ObjectStateRegistry.record_snapshot("before edit", scope_id=PLATE_SCOPE_ID) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet( FakePlateManager( selected=(PLATE_SCOPE_ID,), @@ -2842,7 +2846,7 @@ def test_confirmation_required_apply_rejection_reports_current_context() -> None state = ObjectState(Dummy(), scope_id=PLATE_SCOPE_ID) ObjectStateRegistry.register(state, _skip_snapshot=True) ObjectStateRegistry.record_snapshot("before edit", scope_id=PLATE_SCOPE_ID) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet( FakePlateManager( selected=(PLATE_SCOPE_ID,), @@ -3038,7 +3042,7 @@ def test_apply_creates_baseline_and_edit_snapshot() -> None: state = ObjectState(Dummy(), scope_id=PLATE_SCOPE_ID) ObjectStateRegistry.register(state, _skip_snapshot=True) operations = FakeOperations(state) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet( FakePlateManager(operations=operations) ), @@ -3091,7 +3095,7 @@ def test_apply_document_returns_running_operation_before_queued_ui_apply_runs() operations = FakeOperations(state) dispatcher = QueuedPostDispatcher() operation_tracker = UiBridgeOperationTracker() - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet( FakePlateManager(operations=operations) ), @@ -3136,7 +3140,7 @@ def test_queued_apply_document_error_updates_operation_status() -> None: operations = FakeOperations(state) dispatcher = QueuedPostDispatcher() operation_tracker = UiBridgeOperationTracker() - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet( FakePlateManager(operations=operations) ), @@ -3187,7 +3191,7 @@ def test_live_overview_reports_running_and_completed_bridge_operations() -> None context ) LiveOverviewBridgeProviderSet().register(context) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( registry=registry, dispatcher=dispatcher, snapshot_provider=snapshot_provider, @@ -3209,7 +3213,7 @@ def test_live_overview_reports_running_and_completed_bridge_operations() -> None ) ) running_overview = bridge.get_state_surface( - UiStateSurfaceRequest(surface_id=UiStateSurfaceId.UI_LIVE_OVERVIEW.value) + UiStateSurfaceRequest(surface_id=UiLiveOverviewStateSurfaceIdentityDeclaration.require_value()) ) running_section = next( @@ -3224,7 +3228,7 @@ def test_live_overview_reports_running_and_completed_bridge_operations() -> None dispatcher.run_next() completed_overview = bridge.get_state_surface( - UiStateSurfaceRequest(surface_id=UiStateSurfaceId.UI_LIVE_OVERVIEW.value) + UiStateSurfaceRequest(surface_id=UiLiveOverviewStateSurfaceIdentityDeclaration.require_value()) ) completed_section = next( section @@ -3246,7 +3250,7 @@ def test_bridge_lists_and_restores_snapshots() -> None: before_id = ObjectStateRegistry.get_branch_history()[-1].id state.update_parameter("x", 2) ObjectStateRegistry.record_snapshot("after", scope_id=PLATE_SCOPE_ID) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()), dispatcher=InlineDispatcher(), ) @@ -3286,7 +3290,7 @@ def test_bridge_switches_to_preserved_future_branch() -> None: for branch in ObjectStateRegistry.list_branches() if branch["name"].startswith("auto-") ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()), dispatcher=InlineDispatcher(), ) @@ -3321,7 +3325,7 @@ def test_snapshot_restore_authorization_rejects_before_object_state_change() -> ) ) ) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(FakePlateManager()), dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -3346,7 +3350,7 @@ def test_ui_bridge_control_server_round_trips_documents_through_descriptor( monkeypatch.setenv("OPENHCS_UI_BRIDGE_DESCRIPTOR_DIR", str(tmp_path)) manager = FakePlateManager(selected=(PLATE_SCOPE_ID,)) manager.session.ensure_datasets((DATASET_SCOPE_ID,)) - bridge = UiAgentBridgeService( + bridge = OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(manager), dispatcher=InlineDispatcher(), ) @@ -3378,7 +3382,7 @@ def test_ui_bridge_control_server_round_trips_documents_through_descriptor( state = service.invoke( UiBridgeGetStateSurfaceOperation, UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PLATE_MANAGER.value, + surface_id=DatasetListStateSurfaceIdentity.require_value(), selection_mode=ALL_SELECTION_MODE, ), connection, @@ -3391,7 +3395,7 @@ def test_ui_bridge_control_server_round_trips_documents_through_descriptor( assert BRIDGE_AUTH_TOKEN not in set(_json_payload_values(to_jsonable(status))) assert catalog.documents[0].document_id == DOCUMENT_ID assert ( - state_catalog.surfaces[0].surface_id == UiStateSurfaceId.PLATE_MANAGER.value + state_catalog.surfaces[0].surface_id == DatasetListStateSurfaceIdentity.require_value() ) assert document.source == VALID_SOURCE assert state.payload["rows"][0]["scope_id"] == DATASET_SCOPE_ID @@ -3416,7 +3420,7 @@ def test_two_ipc_ui_bridges_route_each_descriptor_to_its_exact_instance( ) descriptor_directory = tmp_path / "descriptors" first_server = UiBridgeControlServer( - UiAgentBridgeService(dispatcher=InlineDispatcher()), + OpenHCSUiAgentBridgeService(dispatcher=InlineDispatcher()), AgentUiBridgeConfig( port=first_pair.data_port, transport_mode=TransportMode.IPC, @@ -3426,7 +3430,7 @@ def test_two_ipc_ui_bridges_route_each_descriptor_to_its_exact_instance( ), ) second_server = UiBridgeControlServer( - UiAgentBridgeService(dispatcher=InlineDispatcher()), + OpenHCSUiAgentBridgeService(dispatcher=InlineDispatcher()), AgentUiBridgeConfig( port=second_pair.data_port, transport_mode=TransportMode.IPC, @@ -3467,7 +3471,7 @@ def test_two_ipc_ui_bridges_route_each_descriptor_to_its_exact_instance( def test_ui_bridge_control_server_preserves_bad_auth_error(tmp_path: Path) -> None: - bridge = UiAgentBridgeService(dispatcher=InlineDispatcher()) + bridge = OpenHCSUiAgentBridgeService(dispatcher=InlineDispatcher()) server = UiBridgeControlServer( bridge, _bridge_server_config(tmp_path), @@ -3502,7 +3506,7 @@ def test_ui_bridge_control_server_preserves_bad_auth_error(tmp_path: Path) -> No "Unsupported UI bridge protocol version", ), ( - UI_BRIDGE_PROTOCOL_VERSION, + OpenHCSUiBridgeApplication.protocol_version, EndpointApplication(identifier="openhcs", version="0.7.22"), "Endpoint application mismatch", ), @@ -3517,7 +3521,7 @@ def unreachable_binding() -> UiBridgeServerBinding: raise AssertionError("invalid requests must not reach the bridge") dispatcher = UiBridgeRequestDispatcher( - UiAgentBridgeService(dispatcher=InlineDispatcher()), + OpenHCSUiAgentBridgeService(dispatcher=InlineDispatcher()), auth_token=BRIDGE_AUTH_TOKEN, binding_supplier=unreachable_binding, ) @@ -3534,7 +3538,7 @@ def unreachable_binding() -> UiBridgeServerBinding: response = dispatcher.dispatch(to_jsonable(request)) assert response["ok"] is False - assert response["bridge_protocol_version"] == UI_BRIDGE_PROTOCOL_VERSION + assert response["bridge_protocol_version"] == OpenHCSUiBridgeApplication.protocol_version assert response["application"] == to_jsonable(OPENHCS_ENDPOINT_APPLICATION) assert message in response["errors"][0]["message"] @@ -3697,7 +3701,7 @@ def _pipeline_editor_bridge(editor) -> UiAgentBridgeService: snapshot_provider=snapshot_provider, ) ) - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -3705,7 +3709,7 @@ def _pipeline_editor_bridge(editor) -> UiAgentBridgeService: def _plate_manager_bridge(manager, dispatcher=None) -> UiAgentBridgeService: - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( provider_set=PlateManagerBridgeProviderSet(manager), dispatcher=dispatcher or InlineDispatcher(), ) @@ -3714,7 +3718,7 @@ def _plate_manager_bridge(manager, dispatcher=None) -> UiAgentBridgeService: def _plate_manager_state(bridge, selection_mode=ALL_SELECTION_MODE, **kwargs): return bridge.get_state_surface( UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PLATE_MANAGER.value, + surface_id=DatasetListStateSurfaceIdentity.require_value(), selection_mode=selection_mode, **kwargs, ) @@ -3755,7 +3759,7 @@ def _main_window_bridge(main_window) -> UiAgentBridgeService: snapshot_provider=snapshot_provider, ) ) - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( registry=registry, dispatcher=InlineDispatcher(), snapshot_provider=snapshot_provider, @@ -3951,7 +3955,7 @@ def test_plate_manager_state_surface_projects_runtime_row_status() -> None: bridge, base_revision_token=state.current_revision_token ) - assert state.summary.surface_id == UiStateSurfaceId.PLATE_MANAGER.value + assert state.summary.surface_id == DatasetListStateSurfaceIdentity.require_value() row = state.payload["rows"][0] assert row["scope_id"] == DATASET_SCOPE_ID assert row["status_prefix"] == "⏳ Compile" @@ -3980,8 +3984,8 @@ def test_every_dataset_action_relates_its_declared_state_surfaces() -> None: ) ) assert actions[ShowLiveResults.operation_id].related_state_surface_ids == ( - "plate_manager.state", - "plate_manager.live_measurements", + "datasets.state", + "datasets.live_measurements", ) @@ -4208,7 +4212,7 @@ def test_selected_workflow_returns_before_the_queued_operation_runs( assert result.action_result.status is ActionAccepted assert operation_id is not None assert result.action_result.workflow_status_surface_ids == ( - "plate_manager.state", + "datasets.state", ) assert started == [] assert len(dispatcher.callbacks) == 1 @@ -4311,7 +4315,7 @@ def test_selected_workflow_confirmation_rejection_avoids_ui_preflight( assert result.action_result.status is ActionRejected assert result.errors[0].code == "confirmation_required" assert result.action_result.workflow_status_surface_ids == ( - "plate_manager.state", + "datasets.state", ) assert dispatcher.call_count == 0 assert started == [] @@ -4430,19 +4434,19 @@ def function_reference(function): catalog = bridge.list_state_surfaces() state = bridge.get_state_surface( UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PIPELINE_EDITOR.value, + surface_id=PipelineEditorStateSurfaceIdentityDeclaration.require_value(), selection_mode=ALL_SELECTION_MODE, ) ) selected_state = bridge.get_state_surface( UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PIPELINE_EDITOR.value, + surface_id=PipelineEditorStateSurfaceIdentityDeclaration.require_value(), selection_mode=SELECTED_SELECTION_MODE, base_revision_token=state.current_revision_token, ) ) - assert catalog.surfaces[0].surface_id == UiStateSurfaceId.PIPELINE_EDITOR.value + assert catalog.surfaces[0].surface_id == PipelineEditorStateSurfaceIdentityDeclaration.require_value() assert state.summary.current_selection_count == 1 assert state.summary.total_scope_count == 2 assert state.payload["pipeline_scope_id"] == ( @@ -4591,7 +4595,7 @@ def _debug_cursor() -> DebugCursor: def _debug_state(gui): return _pipeline_editor_bridge(gui.pipeline_editor).get_state_surface( UiStateSurfaceRequest( - surface_id=UiStateSurfaceId.PIPELINE_DEBUG_SESSION.value, + surface_id=PipelineDebugSessionStateSurfaceIdentityDeclaration.require_value(), selection_mode=ALL_SELECTION_MODE, ) ) @@ -4607,7 +4611,7 @@ def test_pipeline_debug_session_state_surface_projects_context_and_actions() -> state = _debug_state(gui) actions = {action["action_id"]: action for action in state.payload["actions"]} - assert state.summary.surface_id == UiStateSurfaceId.PIPELINE_DEBUG_SESSION.value + assert state.summary.surface_id == PipelineDebugSessionStateSurfaceIdentityDeclaration.require_value() assert state.payload["phase"] == "active_session" assert state.payload["current_plate_scope_id"] == DATASET_SCOPE_ID assert state.payload["pipeline_scope_id"] == ( diff --git a/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py b/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py index a97b1da10d..6d10484281 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py @@ -8,7 +8,7 @@ import pytest import zmq -from pyqt_reactive.services.zmq_server_scan_service import ZMQServerScanService +from formbridge.services.zmq_server_scan_service import ZMQServerScanService from zmqruntime import ( ControlMessageType, DataControlPortPairAuthority, @@ -23,12 +23,9 @@ from python_introspect import to_jsonable -from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import UiBridgeRequestEnvelope, UiBridgeStatus -from openhcs.agent.services.ui_bridge_service import ( - UI_BRIDGE_PROTOCOL_VERSION, - UiBridgeStatusOperation, -) +from formbridge.contracts import SCHEMA_VERSION +from formbridge.bridge.dto import UiBridgeRequestEnvelope, UiBridgeStatus +from formbridge.bridge.service import UiBridgeStatusOperation from openhcs.pyqt_gui.config import ( AgentUiBridgeConfig, PyQtGuiRuntimeContext, @@ -39,13 +36,13 @@ from openhcs.pyqt_gui.services.ui_bridge_composition import ( OpenHCSUiBridgeCompositionRoot, ) -from openhcs.pyqt_gui.services.ui_bridge_server import ( - UI_BRIDGE_BROWSER_SERVER_NAME, +from formbridge.bridge.server import ( UiBridgeControlServer, UiBridgeEndpointInUseError, ) from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig +from openhcs.agent.ui_bridge_application import OpenHCSUiBridgeApplication def test_agent_ui_bridge_config_reads_environment(monkeypatch) -> None: @@ -468,7 +465,7 @@ def test_ui_bridge_answers_zmq_browser_control_ping(tmp_path) -> None: context.term() assert response["type"] == ResponseType.PONG.value - assert response["server"] == UI_BRIDGE_BROWSER_SERVER_NAME + assert response["server"] == OpenHCSUiBridgeApplication.server_name assert response["server_role"] == ServerRole.GENERIC.value assert response["port"] == port assert response["control_port"] == (port + OPENHCS_ZMQ_CONFIG.control_port_offset) @@ -531,7 +528,7 @@ def test_second_ipc_ui_bridge_cannot_replace_live_endpoint_pair( to_jsonable( UiBridgeRequestEnvelope( schema_version=SCHEMA_VERSION, - bridge_protocol_version=UI_BRIDGE_PROTOCOL_VERSION, + bridge_protocol_version=OpenHCSUiBridgeApplication.protocol_version, application=OPENHCS_ENDPOINT_APPLICATION, request_id="identity", operation=UiBridgeStatusOperation.name, @@ -553,7 +550,7 @@ def test_second_ipc_ui_bridge_cannot_replace_live_endpoint_pair( context.term() assert ( - original_binding.connection.transport_endpoint().occupied_ports( + original_binding.connection.transport_endpoint(OPENHCS_ZMQ_CONFIG).occupied_ports( transport_config ) == frozenset() @@ -597,7 +594,7 @@ def expiring_startup_lock( return ExpiringStartupLock(operation_deadline) monkeypatch.setattr( - "openhcs.pyqt_gui.services.ui_bridge_server.endpoint_startup_lock", + "formbridge.bridge.server.endpoint_startup_lock", expiring_startup_lock, ) @@ -683,7 +680,7 @@ def test_zmq_browser_scan_service_discovers_ui_bridge_default_transport( ) assert len(snapshot.responses) == 1 - assert snapshot.responses[0].server == UI_BRIDGE_BROWSER_SERVER_NAME + assert snapshot.responses[0].server == OpenHCSUiBridgeApplication.server_name assert snapshot.responses[0].port == port assert snapshot.responses[0].application == OPENHCS_ENDPOINT_APPLICATION assert endpoint.occupied_ports(OPENHCS_ZMQ_CONFIG) == frozenset() diff --git a/tests/unit/pyqt_gui/test_ui_bridge_live_measurements.py b/tests/unit/pyqt_gui/test_ui_bridge_live_measurements.py index 49f3962893..8654844a47 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_live_measurements.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_live_measurements.py @@ -4,14 +4,12 @@ from dataclasses import dataclass from pathlib import Path -from openhcs.core.selection import AllItems, SelectedItems -from openhcs.agent.dto.ui_bridge import ( - UiStateSurfaceRequest, -) +from formbridge.selection import AllItems, SelectedItems +from formbridge.bridge.dto import UiStateSurfaceRequest from openhcs.agent.ui_bridge_identities import ( - PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, - UiStateSurfaceIdentityDeclarationBase, + DatasetLiveMeasurementsStateSurfaceIdentity, ) +from formbridge.bridge.identities import UiStateSurfaceIdentityDeclarationBase from openhcs.core.artifacts.kinds import MeasurementsArtifactType from openhcs.core.component_group_scope import RuntimeExecutionAxisScope from openhcs.core.progress import ProgressEvent @@ -24,17 +22,13 @@ RuntimeArtifactAddress, RuntimeArtifactLocation, ) -from openhcs.pyqt_gui.services.ui_agent_bridge import UiAgentBridgeService -from openhcs.pyqt_gui.services.ui_bridge_contracts import ( - state_surface_declaration_for_identity, -) -from openhcs.pyqt_gui.services.ui_bridge_live_overview import ( - LiveOverviewBridgeProviderSet, -) +from formbridge.bridge.in_process import UiAgentBridgeService +from formbridge.bridge.contracts import state_surface_declaration_for_identity +from openhcs.pyqt_gui.services.ui_bridge_live_overview import LiveOverviewBridgeProviderSet from openhcs.pyqt_gui.services.ui_bridge_plate_manager import ( PlateManagerBridgeProviderSet, ) -from openhcs.pyqt_gui.services.ui_bridge_registry import CompositeUiBridgeProviderSet +from formbridge.bridge.registry import CompositeUiBridgeProviderSet from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget from openhcs.authoring.session.progress_notifications import ( LiveMeasurementAvailableNotification, @@ -45,6 +39,7 @@ StepCompletedProgressPhase, SuccessProgressStatus, ) +from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService @dataclass(frozen=True, slots=True) @@ -126,7 +121,7 @@ def _notification() -> LiveMeasurementAvailableNotification: def _bridge(manager: _Manager) -> UiAgentBridgeService: - return UiAgentBridgeService( + return OpenHCSUiAgentBridgeService( provider_set=CompositeUiBridgeProviderSet( ( PlateManagerBridgeProviderSet(manager), @@ -140,17 +135,17 @@ def _bridge(manager: _Manager) -> UiAgentBridgeService: def _live_measurement_declaration(): return state_surface_declaration_for_identity( PlateManagerWidget.UI_STATE_SURFACE_DECLARATIONS, - PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration, + DatasetLiveMeasurementsStateSurfaceIdentity, ) def test_live_measurement_surface_is_declared_by_owning_widget() -> None: declaration = _live_measurement_declaration() - assert declaration.surface_id == "plate_manager.live_measurements" + assert declaration.surface_id == "datasets.live_measurements" assert ( declaration.identity - is PlateManagerLiveMeasurementsStateSurfaceIdentityDeclaration + is DatasetLiveMeasurementsStateSurfaceIdentity ) assert declaration.payload_schema == "openhcs.ui.live_measurements_state.v1" assert declaration.related_action_ids == (ShowLiveResults.operation_id,) diff --git a/tests/unit/pyqt_gui/test_ui_bridge_live_overview.py b/tests/unit/pyqt_gui/test_ui_bridge_live_overview.py index 083515e28e..d0337e56ea 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_live_overview.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_live_overview.py @@ -2,9 +2,7 @@ from zmqruntime.viewer_state import ViewerInstance, ViewerState -from openhcs.pyqt_gui.services.ui_bridge_live_overview import ( - ViewerSessionLiveOverviewContributor, -) +from openhcs.pyqt_gui.services.ui_bridge_live_overview import ViewerSessionLiveOverviewContributor from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId diff --git a/tests/unit/pyqt_gui/test_ui_bridge_object_state_mutation.py b/tests/unit/pyqt_gui/test_ui_bridge_object_state_mutation.py index 3f2544eb0e..59f9bd2c1b 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_object_state_mutation.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_object_state_mutation.py @@ -3,11 +3,9 @@ from dataclasses import dataclass from enum import Enum -from openhcs.agent.dto.ui_bridge import UiObjectStateFieldMutationRequest +from formbridge.bridge.dto import UiObjectStateFieldMutationRequest from objectstate import ObjectState, ObjectStateRegistry -from openhcs.pyqt_gui.services.ui_bridge_object_state import ( - ObjectStateFieldMutationService, -) +from formbridge.bridge.object_state import ObjectStateFieldMutationService @dataclass diff --git a/tests/unit/pyqt_gui/test_ui_bridge_preview_disclosure.py b/tests/unit/pyqt_gui/test_ui_bridge_preview_disclosure.py index 5c578edc58..784d0e7a70 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_preview_disclosure.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_preview_disclosure.py @@ -20,15 +20,8 @@ from pyqt_reactive.widgets.shared.styled_text_layout import StyledTextLayout, Segment from openhcs.agent.dto.common import SCHEMA_VERSION -from openhcs.agent.dto.ui_bridge import ( - UiWindowSummary, - UiWindowIdentity, - UiWidgetActionInvokeRequest, -) -from openhcs.pyqt_gui.services.ui_bridge_windows import ( - WindowProjectionTarget, - UiWidgetActionInvokeResultFactory, -) +from formbridge.bridge.dto import UiWindowSummary, UiWindowIdentity, UiWidgetActionInvokeRequest +from pyqt_reactive.bridge.windows import WindowProjectionTarget, UiWidgetActionInvokeResultFactory @pytest.fixture diff --git a/tests/unit/pyqt_gui/test_ui_config_lifecycle.py b/tests/unit/pyqt_gui/test_ui_config_lifecycle.py index 36e182df47..f0a41bc57b 100644 --- a/tests/unit/pyqt_gui/test_ui_config_lifecycle.py +++ b/tests/unit/pyqt_gui/test_ui_config_lifecycle.py @@ -261,7 +261,7 @@ def test_removed_ui_config_and_lifecycle_mirrors_do_not_recur() -> None: / "config_cache_adapter.py" ).exists() bridge_source = ( - repository_root / "openhcs" / "pyqt_gui" / "services" / "ui_bridge_server.py" + repository_root / "external" / "formbridge" / "src" / "formbridge" / "bridge" / "server.py" ).read_text() assert "DEFAULT_UI_BRIDGE_HOST" not in bridge_source assert "DEFAULT_UI_BRIDGE_TRANSPORT" not in bridge_source diff --git a/tests/unit/pyqt_gui/test_ui_config_widget_projection.py b/tests/unit/pyqt_gui/test_ui_config_widget_projection.py index 2a01ffd5ea..0ace84179c 100644 --- a/tests/unit/pyqt_gui/test_ui_config_widget_projection.py +++ b/tests/unit/pyqt_gui/test_ui_config_widget_projection.py @@ -29,7 +29,7 @@ def test_nested_ui_config_projects_color_enums_and_shortcut_capture(qapp) -> Non ParameterFormManager, ) from pyqt_reactive.protocols import KeySequenceEditAdapter - from pyqt_reactive.services.system_monitor_config import ( + from formbridge.services.system_monitor_config import ( PerformanceGraphColor, PerformanceMonitorColors, ) diff --git a/tests/unit/pyqt_gui/test_ui_window_flash_snapshot.py b/tests/unit/pyqt_gui/test_ui_window_flash_snapshot.py index a91f70f080..65afec41c4 100644 --- a/tests/unit/pyqt_gui/test_ui_window_flash_snapshot.py +++ b/tests/unit/pyqt_gui/test_ui_window_flash_snapshot.py @@ -6,16 +6,17 @@ from PyQt6.QtCore import QEvent from PyQt6.QtWidgets import QDialog, QVBoxLayout -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( UiBridgeOperationStatusRequest, UiWindowSnapshotRequest, UiWindowSnapshotResult, ) -from openhcs.agent.services.ui_bridge_service import UiBridgeOperation +from formbridge.bridge.service import UiBridgeOperation from python_introspect import dataclass_from_mapping, to_jsonable -from openhcs.pyqt_gui.services.ui_agent_bridge import UiAgentBridgeService -from openhcs.pyqt_gui.services.ui_bridge_windows import QtTopLevelWindowProjection -from pyqt_reactive.services.window_snapshot import WindowSnapshotFrameCondition +from formbridge.bridge.in_process import UiAgentBridgeService +from pyqt_reactive.bridge.windows import QtTopLevelWindowProjection +from formbridge.services.window_snapshot import WindowSnapshotFrameCondition +from openhcs.pyqt_gui.services.ui_agent_bridge import OpenHCSUiAgentBridgeService @pytest.fixture @@ -53,7 +54,7 @@ class Fields: window.show() qapp.processEvents() projection = QtTopLevelWindowProjection(None) - bridge = UiAgentBridgeService() + bridge = OpenHCSUiAgentBridgeService() bridge.register_window_provider(projection) window_id = projection.summary(window).window_id yield bridge, form, window_id @@ -198,8 +199,8 @@ def test_failed_observation_frame_and_existing_trace_decode_through_registered_o native_snapshot_bridge, tmp_path, ): - from pyqt_reactive.flash_trace import FlashTraceRecord - from pyqt_reactive.services.window_snapshot import FlashPaintFrame + from formbridge.flash_trace import FlashTraceRecord + from formbridge.services.window_snapshot import FlashPaintFrame bridge, form, window_id = native_snapshot_bridge accepted = bridge.snapshot_window( @@ -261,14 +262,8 @@ def test_navigation_uses_existing_operation_with_terminal_native_driver_receipt( native_snapshot_bridge, prove_exposure, ): - from openhcs.agent.dto.ui_bridge import ( - UiWindowNavigateRequest, - UiWindowNavigateResult, - ) - from openhcs.pyqt_gui.services.ui_bridge_windows import ( - UiWindowProjectionService, - WindowRouteIndex, - ) + from formbridge.bridge.dto import UiWindowNavigateRequest, UiWindowNavigateResult + from pyqt_reactive.bridge.windows import UiWindowProjectionService, WindowRouteIndex from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.services.window_navigation import FieldWindowNavigationDriver @@ -326,7 +321,7 @@ def _route_index(self): def test_unowned_target_navigation_is_terminal_failed_not_pending( native_snapshot_bridge, ): - from openhcs.agent.dto.ui_bridge import UiWindowNavigateRequest + from formbridge.bridge.dto import UiWindowNavigateRequest bridge, _, window_id = native_snapshot_bridge accepted = bridge.navigate_window( @@ -344,7 +339,7 @@ def test_unowned_target_navigation_is_terminal_failed_not_pending( def test_tracker_observation_failure_uses_declared_operation_error_owner( native_snapshot_bridge, ): - from openhcs.agent.services.ui_bridge_service import UiBridgeNavigateWindowOperation + from formbridge.bridge.service import UiBridgeNavigateWindowOperation bridge, _, _ = native_snapshot_bridge @@ -364,7 +359,7 @@ def rejected(completed): def test_snapshot_tool_arguments_derive_capture_owner_fields(tmp_path): from python_introspect import project_dataclass - from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureSpec + from formbridge.services.window_snapshot import WindowSnapshotCaptureSpec request = UiWindowSnapshotRequest.from_fields( window_id="engineering-form", diff --git a/tests/unit/pyqt_gui/test_user_facing_help_surfaces.py b/tests/unit/pyqt_gui/test_user_facing_help_surfaces.py index 3d4d2c9ff0..730fe8ef45 100644 --- a/tests/unit/pyqt_gui/test_user_facing_help_surfaces.py +++ b/tests/unit/pyqt_gui/test_user_facing_help_surfaces.py @@ -6,7 +6,7 @@ from pathlib import Path from PyQt6.QtWidgets import QApplication -from pyqt_reactive.services.help_document import HelpDocumentFormat +from formbridge.services.help_document import HelpDocumentFormat from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.widgets.shared.clickable_help_components import HelpButton from pyqt_reactive.windows.help_window_manager import HelpWindowManager diff --git a/tests/unit/pyqt_gui/test_zmq_server_info.py b/tests/unit/pyqt_gui/test_zmq_server_info.py index 8afe0a6115..7b3b93ee2a 100644 --- a/tests/unit/pyqt_gui/test_zmq_server_info.py +++ b/tests/unit/pyqt_gui/test_zmq_server_info.py @@ -1,4 +1,4 @@ -from pyqt_reactive.services.zmq_server_info import ( +from formbridge.services.zmq_server_info import ( BaseServerInfo, ExecutionServerInfo, GenericServerInfo, diff --git a/tests/unit/test_build_website.py b/tests/unit/test_build_website.py index 526f7f91f5..98a301ad47 100644 --- a/tests/unit/test_build_website.py +++ b/tests/unit/test_build_website.py @@ -13,7 +13,7 @@ import pytest from openhcs import __version__ as OPENHCS_VERSION -from openhcs.agent.ui_bridge_identities import UiStableWindowIdentityDeclaration +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration from openhcs.resources.brand import ( BRAND_PRODUCT_NAME, BrandAsset, diff --git a/tests/unit/test_capture_media_gallery.py b/tests/unit/test_capture_media_gallery.py index 1cefdf6cf8..8877666c7e 100644 --- a/tests/unit/test_capture_media_gallery.py +++ b/tests/unit/test_capture_media_gallery.py @@ -9,17 +9,16 @@ import pytest -from openhcs.agent.services.ui_bridge_service import ( - UiBridgeSnapshotWindowOperation, -) +from formbridge.bridge.service import UiBridgeSnapshotWindowOperation from pyqt_reactive.animation import WindowFlashOverlay from pyqt_reactive.animation.flash_overlay_opengl import WindowFlashOverlayGL -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from python_introspect import dataclass_from_mapping, to_jsonable -from openhcs.agent.dto.common import SCHEMA_VERSION, AgentResourceRef +from openhcs.agent.dto.common import SCHEMA_VERSION +from formbridge.contracts import AgentResourceRef from openhcs.agent.dto.execution import ExecutionConnectionSpec -from openhcs.agent.dto.ui_bridge import UiWindowSnapshotResult +from formbridge.bridge.dto import UiWindowSnapshotResult from openhcs.agent.dto.viewer import ViewerWindowDescriptor, ViewerWindowSnapshotResult from openhcs.core.streaming_config_declarations import ViewerType from scripts.capture_media_gallery import ( @@ -577,8 +576,8 @@ def test_shared_native_snapshot_ingestion_refuses_invalid_resource_without_movin from pyqt_reactive.services.window_snapshot import ( QtWindowSnapshotRequest, QtWindowSnapshotService, - WindowSnapshotCaptureSpec, ) + from formbridge.services.window_snapshot import WindowSnapshotCaptureSpec source_root = tmp_path / "capture" request = GallerySourceCaptureRequest( diff --git a/tests/unit/test_configuration_consumer_coverage.py b/tests/unit/test_configuration_consumer_coverage.py index 4575214b63..1777b1bd69 100644 --- a/tests/unit/test_configuration_consumer_coverage.py +++ b/tests/unit/test_configuration_consumer_coverage.py @@ -139,6 +139,7 @@ def _production_syntax_units() -> tuple[_SyntaxUnit, ...]: repository_root = Path(__file__).resolve().parents[2] roots = ( repository_root / "openhcs", + repository_root / "external" / "formbridge" / "src", repository_root / "external" / "pyqt-reactive" / "src", repository_root / "external" / "zmqruntime" / "src", repository_root / "external" / "arraybridge" / "src", diff --git a/tests/unit/test_configuration_mirror_deletion.py b/tests/unit/test_configuration_mirror_deletion.py index f182f47458..81bc5bf655 100644 --- a/tests/unit/test_configuration_mirror_deletion.py +++ b/tests/unit/test_configuration_mirror_deletion.py @@ -13,6 +13,7 @@ PROJECT_ROOT / "tests", PROJECT_ROOT / "benchmark", PROJECT_ROOT / "external/ObjectState/src", + PROJECT_ROOT / "external/formbridge/src", PROJECT_ROOT / "external/pyqt-reactive/src", PROJECT_ROOT / "external/python-introspect/src", PROJECT_ROOT / "external/zmqruntime/src", @@ -207,6 +208,7 @@ def test_deleted_lazy_registry_and_name_resolver_symbols_do_not_recur() -> None: scanned_roots = ( PROJECT_ROOT / "openhcs", PROJECT_ROOT / "external/ObjectState/src", + PROJECT_ROOT / "external/formbridge/src", PROJECT_ROOT / "external/pyqt-reactive/src", ) for root in scanned_roots: @@ -320,11 +322,11 @@ def test_typed_transport_boundaries_do_not_regrow_payload_schema_helpers() -> No def test_system_monitor_keeps_metrics_and_fixed_colors_typed() -> None: scanned_paths = ( - PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" + PROJECT_ROOT / "external/formbridge/src/formbridge/services/" "system_metrics_sampler.py", - PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" + PROJECT_ROOT / "external/formbridge/src/formbridge/services/" "system_monitor_config.py", - PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" + PROJECT_ROOT / "external/formbridge/src/formbridge/services/" "system_monitor_core.py", PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" "persistent_system_monitor.py", @@ -365,9 +367,9 @@ def test_zmq_server_browser_keeps_pong_and_tree_identity_typed() -> None: "ServerTree" + "Population", } scanned_paths = ( - PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" + PROJECT_ROOT / "external/formbridge/src/formbridge/services/" "zmq_server_info.py", - PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/services/" + PROJECT_ROOT / "external/formbridge/src/formbridge/services/" "zmq_server_scan_service.py", PROJECT_ROOT / "external/pyqt-reactive/src/pyqt_reactive/widgets/shared/" "zmq_server_browser_widget.py", diff --git a/tests/unit/test_exception_traceback_compatibility.py b/tests/unit/test_exception_traceback_compatibility.py index 198e073877..3cd08bf721 100644 --- a/tests/unit/test_exception_traceback_compatibility.py +++ b/tests/unit/test_exception_traceback_compatibility.py @@ -2,7 +2,7 @@ import pytest -from openhcs.agent.services.ui_bridge_service import ( +from formbridge.bridge.service import ( UiBridgeDescriptorProcessGoneError, UiBridgeDescriptorProcessIdentityError, UiBridgeGatewayResponseError, @@ -10,10 +10,8 @@ UiBridgeGatewayUnavailableError, ) from openhcs.core.streaming_config_declarations import ViewerType -from openhcs.pyqt_gui.services.ui_agent_bridge import UiCodeDocumentValidationError -from openhcs.pyqt_gui.services.ui_bridge_server import ( - UiBridgeUnsupportedOperationError, -) +from formbridge.bridge.in_process import UiCodeDocumentValidationError +from formbridge.bridge.server import UiBridgeUnsupportedOperationError from openhcs.runtime.viewer_protocol import ViewerGraphicalSessionUnavailableError diff --git a/tests/unit/test_formbridge_guards.py b/tests/unit/test_formbridge_guards.py new file mode 100644 index 0000000000..27b38edd1c --- /dev/null +++ b/tests/unit/test_formbridge_guards.py @@ -0,0 +1,129 @@ +"""U4 guards: headless code needs no GUI toolkit, and OpenHCS keeps only its own declarations. + +The toolkit-neutral tier (forms model, help, code documents, the running-UI +bridge DTOs, contracts, client and server) lives in formbridge; pyqt-reactive +holds the Qt window introspection; OpenHCS declares its application, identities, +surfaces and operations. +""" + +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[2] +PRODUCTION = REPOSITORY / "openhcs" +FORMBRIDGE = REPOSITORY / "external" / "formbridge" / "src" / "formbridge" +QT_BRIDGE = REPOSITORY / "external" / "pyqt-reactive" / "src" / "pyqt_reactive" / "bridge" +TOOLKITS = ("PyQt6", "PyQt5", "PySide6", "PySide2", "qtpy") + + +def test_headless_mcp_and_every_formbridge_module_load_no_toolkit() -> None: + script = f""" +import builtins +import importlib +import pkgutil +import sys + +toolkits = {TOOLKITS!r} +original_import = builtins.__import__ + +def reject_toolkit(name, globals=None, locals=None, fromlist=(), level=0): + if level == 0 and name.split(".")[0] in toolkits: + raise AssertionError(f"headless import loaded {{name}}") + return original_import(name, globals, locals, fromlist, level) + +builtins.__import__ = reject_toolkit +import openhcs # a source checkout puts its external/ libraries on sys.path +import formbridge +for module in pkgutil.walk_packages(formbridge.__path__, "formbridge."): + importlib.import_module(module.name) +from openhcs.mcp.server import build_server +import openhcs.mcp.http +build_server() +loaded = sorted(name for name in sys.modules if name.split(".")[0] in toolkits) +assert not loaded, loaded +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=REPOSITORY, + capture_output=True, + text=True, + timeout=300, + ) + assert completed.returncode == 0, completed.stderr[-4000:] + + +def _top_level_classes(root: Path) -> dict[str, Path]: + classes = {} + for path in root.rglob("*.py"): + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.ClassDef): + classes[node.name] = path + return classes + + +def test_openhcs_defines_no_class_the_libraries_own() -> None: + library = {**_top_level_classes(FORMBRIDGE), **_top_level_classes(QT_BRIDGE)} + restated = sorted( + f"{path.relative_to(REPOSITORY)}: {name}" + for name, path in _top_level_classes(PRODUCTION).items() + if name in library + ) + assert restated == [] + + +def test_libraries_name_no_openhcs_module() -> None: + offenders = sorted( + f"{path.relative_to(REPOSITORY)}:{node.lineno}" + for root in (FORMBRIDGE, QT_BRIDGE) + for path in root.rglob("*.py") + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.ImportFrom | ast.Import) + and any( + (alias.name if isinstance(node, ast.Import) else node.module or "") + .split(".")[0] + == "openhcs" + for alias in node.names + ) + ) + assert offenders == [] + + +def test_bridge_constants_and_identity_enums_stay_deleted() -> None: + deleted = ( + "UI_BRIDGE_PROTOCOL_VERSION", + "UiBridgeDescriptorEnvironment", + "_identity_enum", + "UiCodeDocumentId", + "UiStateSurfaceId", + "UiWidgetId", + "enum_member_name", + "_current_function_spec", + ) + offenders = sorted( + f"{path.relative_to(REPOSITORY)}: {name}" + for root in (PRODUCTION, FORMBRIDGE, QT_BRIDGE) + for path in root.rglob("*.py") + for name in deleted + if re.search(rf"\b{name}\b", path.read_text(encoding="utf-8")) + ) + assert offenders == [] + + +def test_moved_modules_do_not_exist_in_openhcs() -> None: + moved = ( + "core/selection.py", + "agent/runtime_platform.py", + "agent/services/ui_bridge_transport.py", + "agent/services/object_state_field_projection.py", + "pyqt_gui/services/ui_bridge_contracts.py", + "pyqt_gui/services/ui_bridge_registry.py", + "pyqt_gui/services/ui_bridge_server.py", + "pyqt_gui/services/ui_bridge_object_state.py", + "pyqt_gui/services/ui_bridge_object_state_scope_policy.py", + ) + assert [path for path in moved if (PRODUCTION / path).exists()] == [] diff --git a/tests/unit/test_function_patterns.py b/tests/unit/test_function_patterns.py index 6905790764..b4f41355e4 100644 --- a/tests/unit/test_function_patterns.py +++ b/tests/unit/test_function_patterns.py @@ -436,7 +436,7 @@ def measure(image): "sigma": 1, "enabled": True, "dtype_config": "inherited", - "__pyqt_reactive_scope_token__": "ui", + "__formbridge_scope_token__": "ui", }, ), measure, diff --git a/tests/unit/test_mcp_assay_showcase.py b/tests/unit/test_mcp_assay_showcase.py index d4acf5aa45..e8bc7d9b3f 100644 --- a/tests/unit/test_mcp_assay_showcase.py +++ b/tests/unit/test_mcp_assay_showcase.py @@ -363,10 +363,10 @@ def test_live_measurement_surface_is_discovered_from_public_catalog(): { "actions": [ { - "widget_id": "plate_manager", + "widget_id": "datasets", "action_id": "show_live_results", "related_state_surface_ids": [ - "plate_manager.state", + "datasets.state", "runtime-owned-id", ], }, @@ -374,7 +374,7 @@ def test_live_measurement_surface_is_discovered_from_public_catalog(): }, { "surfaces": [ - {"surface_id": "plate_manager.state"}, + {"surface_id": "datasets.state"}, {"surface_id": "runtime-owned-id"}, ] }, @@ -671,7 +671,7 @@ def execute(self, argv, *, timeout_seconds): "surfaces": [ { "surface_id": "runtime-owned-id", - "widget_id": "plate_manager", + "widget_id": "datasets", "title": "Live measurement results", } ] @@ -683,10 +683,10 @@ def execute(self, argv, *, timeout_seconds): { "actions": [ { - "widget_id": "plate_manager", + "widget_id": "datasets", "action_id": "show_live_results", "related_state_surface_ids": [ - "plate_manager.state", + "datasets.state", "runtime-owned-id", ], } @@ -867,6 +867,6 @@ def test_showcase_reuses_one_persistent_session_and_writes_timing_report(tmp_pat argv[:2] == ("state-surface", "runtime-owned-id") for argv in fake_client.argv ) assert any( - argv[:3] == ("invoke-action", "plate_manager", "show_live_results") + argv[:3] == ("invoke-action", "datasets", "show_live_results") for argv in fake_client.argv ) diff --git a/tests/unit/test_mcp_thesis_demo_live.py b/tests/unit/test_mcp_thesis_demo_live.py index 741c96b8c9..e122c9d4e3 100644 --- a/tests/unit/test_mcp_thesis_demo_live.py +++ b/tests/unit/test_mcp_thesis_demo_live.py @@ -1052,7 +1052,7 @@ def test_plate_action_waits_for_accepted_receipt(monkeypatch) -> None: lambda _ctx, _action: demo.UiActionSummary( schema_version="openhcs.agent.v1", identity=demo.UiActionIdentity( - widget_id="plate_manager", action_id=action.operation_id + widget_id="datasets", action_id=action.operation_id ), title="Action", enabled=True, diff --git a/tests/unit/test_napari_render_snapshot.py b/tests/unit/test_napari_render_snapshot.py index b5c30fc627..c507e95511 100644 --- a/tests/unit/test_napari_render_snapshot.py +++ b/tests/unit/test_napari_render_snapshot.py @@ -11,7 +11,7 @@ from qtpy.QtCore import QEvent, Signal from qtpy.QtGui import QColor, QImage, QPainter from qtpy.QtWidgets import QApplication, QWidget -from pyqt_reactive.services.window_snapshot import WindowSnapshotFrameCondition +from formbridge.services.window_snapshot import WindowSnapshotFrameCondition from zmqruntime.config import TransportMode from zmqruntime.transport import TransportEndpoint from zmqruntime.timeouts import OperationDeadline diff --git a/tests/unit/test_process_environment.py b/tests/unit/test_process_environment.py index 73a0f47071..3918281ec6 100644 --- a/tests/unit/test_process_environment.py +++ b/tests/unit/test_process_environment.py @@ -5,7 +5,7 @@ ImageJArchiveDownloadPolicy, ) -from openhcs.agent.runtime_platform import WindowsRuntimePlatform +from formbridge.runtime_platform import WindowsRuntimePlatform from openhcs.resources.brand import BRAND_PRODUCT_NAME from openhcs.utils.environment import OpenHCSProcessEnvironment diff --git a/tests/unit/test_smoke_installed_gui.py b/tests/unit/test_smoke_installed_gui.py index e75a869b2d..5576225472 100644 --- a/tests/unit/test_smoke_installed_gui.py +++ b/tests/unit/test_smoke_installed_gui.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import pytest -from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope +from formbridge.services.window_snapshot import WindowSnapshotCaptureScope from zmqruntime import ( DataControlPortPair, DataControlPortPairAuthority, @@ -16,10 +16,10 @@ TransportMode, ) -from openhcs.agent.dto.common import AgentResourceRef +from formbridge.contracts import AgentResourceRef from openhcs.agent.dto.execution import ExecutionConnectionSpec, RuntimeServerInfo from openhcs.agent.dto.functions import FunctionCatalogEntry, FunctionCatalogPage -from openhcs.agent.dto.ui_bridge import ( +from formbridge.bridge.dto import ( ActionAccepted, UiActionInvocationStatus, UiWindowSnapshotResult, diff --git a/tests/unit/test_time_travel_navigation.py b/tests/unit/test_time_travel_navigation.py index f50d36f49f..1a4ad25218 100644 --- a/tests/unit/test_time_travel_navigation.py +++ b/tests/unit/test_time_travel_navigation.py @@ -2,7 +2,7 @@ from objectstate.object_state import ObjectState, ObjectStateRegistry from openhcs.core.steps.function_step import FunctionStep from openhcs.pyqt_gui.ui_tab_identities import DualEditorTab -from pyqt_reactive.services.function_navigation import build_function_token_field_path +from formbridge.services.function_navigation import build_function_token_field_path from pyqt_reactive.services.window_navigation import ( NavigationWaitReason, RegisteredWindowNavigationReadiness, diff --git a/tests/unit/test_viewer_receipt_replay.py b/tests/unit/test_viewer_receipt_replay.py index 4b0efd499b..ac8eb5e41b 100644 --- a/tests/unit/test_viewer_receipt_replay.py +++ b/tests/unit/test_viewer_receipt_replay.py @@ -20,7 +20,8 @@ from polystore.streaming_constants import StreamingDataType from zmqruntime.viewer_protocol import ViewerNativeLayerTransform -from openhcs.agent.dto.common import AgentResourceRef, SCHEMA_VERSION +from formbridge.contracts import AgentResourceRef +from openhcs.agent.dto.common import SCHEMA_VERSION from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.plate import PlateFileStreamRequest from openhcs.agent.dto.plate import PlateFileStreamResult diff --git a/website/assets/gallery/release-media-record.json b/website/assets/gallery/release-media-record.json index acd6bd7225..399b822243 100644 --- a/website/assets/gallery/release-media-record.json +++ b/website/assets/gallery/release-media-record.json @@ -452,7 +452,7 @@ ] }, { - "id": "ui-plate-manager", + "id": "ui-datasets", "publication_targets": [ "documentation" ], @@ -461,7 +461,7 @@ "kind": "ui_bridge_window", "human_review_required": false, "parameters": { - "window_id": "plate_manager", + "window_id": "datasets", "create_if_missing": true } }, @@ -477,7 +477,7 @@ { "role": "image", "media_type": "image/webp", - "path": "ui-plate-manager.webp", + "path": "ui-datasets.webp", "sha256": "09a340b7b6320827429ca67e22b35aa39ed9543424be07bae399de881201d5b3" } ] diff --git a/website/assets/gallery/source-capture-evidence.json b/website/assets/gallery/source-capture-evidence.json index ffd79ddf62..0bacf2e56b 100644 --- a/website/assets/gallery/source-capture-evidence.json +++ b/website/assets/gallery/source-capture-evidence.json @@ -81,7 +81,7 @@ } }, { - "scenario_id": "ui-plate-manager", + "scenario_id": "ui-datasets", "source": { "duration_seconds": null, "format": "png", diff --git a/website/assets/gallery/ui-plate-manager.webp b/website/assets/gallery/ui-datasets.webp similarity index 100% rename from website/assets/gallery/ui-plate-manager.webp rename to website/assets/gallery/ui-datasets.webp