experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (152) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +239 -126
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +217 -50
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +629 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +732 -167
  36. experimaestro/scheduler/interfaces.py +316 -101
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  39. experimaestro/scheduler/remote/client.py +171 -117
  40. experimaestro/scheduler/remote/protocol.py +8 -193
  41. experimaestro/scheduler/remote/server.py +95 -71
  42. experimaestro/scheduler/services.py +53 -28
  43. experimaestro/scheduler/state_provider.py +663 -2430
  44. experimaestro/scheduler/state_status.py +1247 -0
  45. experimaestro/scheduler/transient.py +31 -0
  46. experimaestro/scheduler/workspace.py +1 -1
  47. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  48. experimaestro/scriptbuilder.py +4 -4
  49. experimaestro/settings.py +36 -0
  50. experimaestro/tests/conftest.py +33 -5
  51. experimaestro/tests/connectors/bin/executable.py +1 -1
  52. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  53. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  54. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  55. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  56. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  58. experimaestro/tests/launchers/bin/test.py +1 -0
  59. experimaestro/tests/launchers/test_slurm.py +9 -9
  60. experimaestro/tests/partial_reschedule.py +46 -0
  61. experimaestro/tests/restart.py +3 -3
  62. experimaestro/tests/restart_main.py +1 -0
  63. experimaestro/tests/scripts/notifyandwait.py +1 -0
  64. experimaestro/tests/task_partial.py +38 -0
  65. experimaestro/tests/task_tokens.py +2 -2
  66. experimaestro/tests/tasks/test_dynamic.py +6 -6
  67. experimaestro/tests/test_dependencies.py +3 -3
  68. experimaestro/tests/test_deprecated.py +15 -15
  69. experimaestro/tests/test_dynamic_locking.py +317 -0
  70. experimaestro/tests/test_environment.py +24 -14
  71. experimaestro/tests/test_experiment.py +171 -36
  72. experimaestro/tests/test_identifier.py +25 -25
  73. experimaestro/tests/test_identifier_stability.py +3 -5
  74. experimaestro/tests/test_multitoken.py +2 -4
  75. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  76. experimaestro/tests/test_partial_paths.py +81 -138
  77. experimaestro/tests/test_pre_experiment.py +219 -0
  78. experimaestro/tests/test_progress.py +2 -8
  79. experimaestro/tests/test_remote_state.py +560 -99
  80. experimaestro/tests/test_stray_jobs.py +261 -0
  81. experimaestro/tests/test_tasks.py +1 -2
  82. experimaestro/tests/test_token_locking.py +52 -67
  83. experimaestro/tests/test_tokens.py +5 -6
  84. experimaestro/tests/test_transient.py +225 -0
  85. experimaestro/tests/test_workspace_state_provider.py +768 -0
  86. experimaestro/tests/token_reschedule.py +1 -3
  87. experimaestro/tests/utils.py +2 -7
  88. experimaestro/tokens.py +227 -372
  89. experimaestro/tools/diff.py +1 -0
  90. experimaestro/tools/documentation.py +4 -5
  91. experimaestro/tools/jobs.py +1 -2
  92. experimaestro/tui/app.py +438 -1966
  93. experimaestro/tui/app.tcss +162 -0
  94. experimaestro/tui/dialogs.py +172 -0
  95. experimaestro/tui/log_viewer.py +253 -3
  96. experimaestro/tui/messages.py +137 -0
  97. experimaestro/tui/utils.py +54 -0
  98. experimaestro/tui/widgets/__init__.py +23 -0
  99. experimaestro/tui/widgets/experiments.py +468 -0
  100. experimaestro/tui/widgets/global_services.py +238 -0
  101. experimaestro/tui/widgets/jobs.py +972 -0
  102. experimaestro/tui/widgets/log.py +156 -0
  103. experimaestro/tui/widgets/orphans.py +363 -0
  104. experimaestro/tui/widgets/runs.py +185 -0
  105. experimaestro/tui/widgets/services.py +314 -0
  106. experimaestro/tui/widgets/stray_jobs.py +528 -0
  107. experimaestro/utils/__init__.py +1 -1
  108. experimaestro/utils/environment.py +105 -22
  109. experimaestro/utils/fswatcher.py +124 -0
  110. experimaestro/utils/jobs.py +1 -2
  111. experimaestro/utils/jupyter.py +1 -2
  112. experimaestro/utils/logging.py +72 -0
  113. experimaestro/version.py +2 -2
  114. experimaestro/webui/__init__.py +9 -0
  115. experimaestro/webui/app.py +117 -0
  116. experimaestro/{server → webui}/data/index.css +66 -11
  117. experimaestro/webui/data/index.css.map +1 -0
  118. experimaestro/{server → webui}/data/index.js +82763 -87217
  119. experimaestro/webui/data/index.js.map +1 -0
  120. experimaestro/webui/routes/__init__.py +5 -0
  121. experimaestro/webui/routes/auth.py +53 -0
  122. experimaestro/webui/routes/proxy.py +117 -0
  123. experimaestro/webui/server.py +200 -0
  124. experimaestro/webui/state_bridge.py +152 -0
  125. experimaestro/webui/websocket.py +413 -0
  126. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
  127. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  128. experimaestro/cli/progress.py +0 -269
  129. experimaestro/scheduler/state.py +0 -75
  130. experimaestro/scheduler/state_db.py +0 -437
  131. experimaestro/scheduler/state_sync.py +0 -891
  132. experimaestro/server/__init__.py +0 -467
  133. experimaestro/server/data/index.css.map +0 -1
  134. experimaestro/server/data/index.js.map +0 -1
  135. experimaestro/tests/test_cli_jobs.py +0 -615
  136. experimaestro/tests/test_file_progress.py +0 -425
  137. experimaestro/tests/test_file_progress_integration.py +0 -477
  138. experimaestro/tests/test_state_db.py +0 -434
  139. experimaestro-2.0.0b8.dist-info/RECORD +0 -187
  140. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  141. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  142. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  143. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  145. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  147. /experimaestro/{server → webui}/data/index.html +0 -0
  148. /experimaestro/{server → webui}/data/login.html +0 -0
  149. /experimaestro/{server → webui}/data/manifest.json +0 -0
  150. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  151. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  152. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,5 @@
1
+ """WebUI route modules"""
2
+
3
+ from experimaestro.webui.routes import auth, proxy
4
+
5
+ __all__ = ["auth", "proxy"]
@@ -0,0 +1,53 @@
1
+ """Authentication routes for WebUI"""
2
+
3
+ import logging
4
+ from typing import Optional
5
+ from fastapi import APIRouter, Request, Response, Query
6
+ from fastapi.responses import RedirectResponse
7
+
8
+ logger = logging.getLogger("xpm.webui.auth")
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/auth")
14
+ async def auth(
15
+ request: Request,
16
+ xpm_token: Optional[str] = Query(None, alias="xpm-token"),
17
+ ):
18
+ """Authenticate with token and set cookie
19
+
20
+ If valid token provided, redirects to index.html with cookie set.
21
+ The token is passed as ?xpm-token=... in the URL.
22
+ """
23
+ server = request.app.state.server
24
+
25
+ if xpm_token and server.token == xpm_token:
26
+ response = RedirectResponse(url="/index.html", status_code=302)
27
+ response.set_cookie(
28
+ key="experimaestro_token",
29
+ value=xpm_token,
30
+ httponly=True,
31
+ samesite="lax",
32
+ )
33
+ return response
34
+
35
+ return RedirectResponse(url="/login.html", status_code=302)
36
+
37
+
38
+ @router.get("/stop")
39
+ async def stop(
40
+ request: Request,
41
+ xpm_token: Optional[str] = Query(None, alias="xpm-token"),
42
+ ):
43
+ """Stop the server (requires authentication)"""
44
+ server = request.app.state.server
45
+
46
+ # Check token from query param or cookie
47
+ token = xpm_token or request.cookies.get("experimaestro_token")
48
+
49
+ if server.token == token:
50
+ server.request_quit()
51
+ return Response(status_code=202)
52
+
53
+ return Response(status_code=401)
@@ -0,0 +1,117 @@
1
+ """Service proxy routes for WebUI"""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter, Request, Response
6
+ from fastapi.responses import RedirectResponse
7
+
8
+ logger = logging.getLogger("xpm.webui.proxy")
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.api_route(
14
+ "/services/{service_id}/{path:path}",
15
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
16
+ )
17
+ async def proxy_service(request: Request, service_id: str, path: str = ""):
18
+ """Proxy requests to a service
19
+
20
+ Routes /services/{service_id}/{path} to the service's URL.
21
+ """
22
+ server = request.app.state.server
23
+
24
+ # Get service from scheduler (only works in active mode)
25
+ from experimaestro.scheduler.base import Scheduler
26
+
27
+ if not isinstance(server.state_provider, Scheduler):
28
+ return Response(
29
+ content="Service proxy only available in active mode",
30
+ status_code=503,
31
+ )
32
+
33
+ scheduler: Scheduler = server.state_provider
34
+
35
+ # Find service in experiments
36
+ service = None
37
+ for xp in scheduler.experiments.values():
38
+ service = xp.services.get(service_id)
39
+ if service:
40
+ break
41
+
42
+ if service is None:
43
+ return Response(
44
+ content=f"Service {service_id} not found",
45
+ status_code=404,
46
+ )
47
+
48
+ # Get service URL
49
+ base_url = service.get_url()
50
+ if not base_url:
51
+ return Response(
52
+ content=f"Service {service_id} has no URL",
53
+ status_code=503,
54
+ )
55
+
56
+ # Proxy the request using httpx
57
+ import httpx
58
+
59
+ # Build target URL
60
+ target_url = f"{base_url}/{path}"
61
+ if request.query_params:
62
+ target_url = f"{target_url}?{request.query_params}"
63
+
64
+ # Forward headers (filter sensitive ones)
65
+ headers = {}
66
+ for key, value in request.headers.items():
67
+ key_lower = key.lower()
68
+ if key_lower not in ("host", "content-length", "transfer-encoding"):
69
+ headers[key] = value
70
+
71
+ # Get request body for POST/PUT/PATCH
72
+ body = None
73
+ if request.method in ("POST", "PUT", "PATCH"):
74
+ body = await request.body()
75
+
76
+ try:
77
+ async with httpx.AsyncClient() as client:
78
+ response = await client.request(
79
+ method=request.method,
80
+ url=target_url,
81
+ headers=headers,
82
+ content=body,
83
+ follow_redirects=False,
84
+ )
85
+
86
+ # Build response
87
+ response_headers = {}
88
+ for key, value in response.headers.items():
89
+ key_lower = key.lower()
90
+ if key_lower not in (
91
+ "content-encoding",
92
+ "transfer-encoding",
93
+ "content-length",
94
+ ):
95
+ response_headers[key] = value
96
+
97
+ return Response(
98
+ content=response.content,
99
+ status_code=response.status_code,
100
+ headers=response_headers,
101
+ media_type=response.headers.get("content-type"),
102
+ )
103
+ except httpx.RequestError as e:
104
+ logger.error("Proxy error for service %s: %s", service_id, e)
105
+ return Response(
106
+ content=f"Proxy error: {e}",
107
+ status_code=502,
108
+ )
109
+
110
+
111
+ @router.get("/services/{service_id}")
112
+ async def redirect_service(service_id: str):
113
+ """Redirect to service with trailing slash"""
114
+ return RedirectResponse(
115
+ url=f"/services/{service_id}/",
116
+ status_code=308, # Permanent redirect, preserves method
117
+ )
@@ -0,0 +1,200 @@
1
+ """WebUI Server implementation
2
+
3
+ Aligned with TUI's ExperimaestroUI pattern, using StateProvider abstraction.
4
+ """
5
+
6
+ import logging
7
+ import platform
8
+ import socket
9
+ import threading
10
+ import uuid
11
+ from typing import ClassVar, Optional
12
+
13
+ from experimaestro.scheduler.state_provider import StateProvider
14
+ from experimaestro.settings import ServerSettings
15
+
16
+ logger = logging.getLogger("xpm.webui")
17
+
18
+
19
+ class WebUIServer:
20
+ """WebUI server for monitoring experiments
21
+
22
+ Aligned with TUI's ExperimaestroUI pattern:
23
+ - Uses StateProvider abstraction for data access
24
+ - Supports both embedded mode (live scheduler) and offline mode (database)
25
+ - Can wait for explicit quit from interface (like TUI)
26
+ """
27
+
28
+ _instance: ClassVar[Optional["WebUIServer"]] = None
29
+ _lock: ClassVar[threading.Lock] = threading.Lock()
30
+
31
+ @staticmethod
32
+ def instance(
33
+ settings: ServerSettings = None,
34
+ state_provider: StateProvider = None,
35
+ wait_for_quit: bool = False,
36
+ ) -> "WebUIServer":
37
+ """Get or create the global server instance
38
+
39
+ Args:
40
+ settings: Server settings (optional, uses defaults if not provided)
41
+ state_provider: StateProvider instance (required)
42
+ wait_for_quit: If True, server waits for explicit quit from UI
43
+ """
44
+ if WebUIServer._instance is None:
45
+ with WebUIServer._lock:
46
+ if WebUIServer._instance is None:
47
+ if settings is None:
48
+ from experimaestro.settings import get_settings
49
+
50
+ settings = get_settings().server
51
+
52
+ if state_provider is None:
53
+ raise ValueError(
54
+ "state_provider parameter is required. "
55
+ "Pass the Scheduler instance or WorkspaceStateProvider."
56
+ )
57
+
58
+ WebUIServer._instance = WebUIServer(
59
+ settings, state_provider, wait_for_quit
60
+ )
61
+ return WebUIServer._instance
62
+
63
+ @staticmethod
64
+ def clear_instance():
65
+ """Clear the singleton instance (for testing)"""
66
+ with WebUIServer._lock:
67
+ WebUIServer._instance = None
68
+
69
+ def __init__(
70
+ self,
71
+ settings: ServerSettings,
72
+ state_provider: StateProvider,
73
+ wait_for_quit: bool = False,
74
+ ):
75
+ """Initialize the WebUI server
76
+
77
+ Args:
78
+ settings: Server settings
79
+ state_provider: StateProvider for accessing experiment/job data
80
+ wait_for_quit: If True, wait for explicit quit from web interface
81
+ """
82
+ # Determine host binding
83
+ if settings.autohost == "fqdn":
84
+ settings.host = socket.getfqdn()
85
+ logger.info("Auto host name (fqdn): %s", settings.host)
86
+ elif settings.autohost == "name":
87
+ settings.host = platform.node()
88
+ logger.info("Auto host name (name): %s", settings.host)
89
+
90
+ if settings.host is None or settings.host == "127.0.0.1":
91
+ self.binding_host = "127.0.0.1"
92
+ else:
93
+ self.binding_host = "0.0.0.0"
94
+
95
+ self.host = settings.host or "127.0.0.1"
96
+ self.port = settings.port
97
+ self.token = settings.token or uuid.uuid4().hex
98
+ self.state_provider = state_provider
99
+ self.wait_for_quit = wait_for_quit
100
+
101
+ # Check if we have an active experiment (scheduler as state provider)
102
+ from experimaestro.scheduler.base import Scheduler
103
+
104
+ self._has_active_experiment = isinstance(state_provider, Scheduler)
105
+
106
+ # Threading state
107
+ self._thread: Optional[threading.Thread] = None
108
+ self._running = False
109
+ self._cv_running = threading.Condition()
110
+ self._quit_event = threading.Event()
111
+
112
+ # Uvicorn server reference
113
+ self._uvicorn_server = None
114
+
115
+ def start(self):
116
+ """Start the web server in a daemon thread"""
117
+ logger.info("Starting the web server")
118
+
119
+ self._thread = threading.Thread(
120
+ target=self._run_server,
121
+ daemon=True,
122
+ name="webui-server",
123
+ )
124
+ self._thread.start()
125
+
126
+ # Wait until server is ready
127
+ with self._cv_running:
128
+ self._cv_running.wait_for(lambda: self._running)
129
+
130
+ logger.info(
131
+ "Web server started on http://%s:%d/auth?xpm-token=%s",
132
+ self.host,
133
+ self.port,
134
+ self.token,
135
+ )
136
+
137
+ def _run_server(self):
138
+ """Run the uvicorn server (called in thread)"""
139
+ import uvicorn
140
+
141
+ from experimaestro.webui.app import create_app
142
+
143
+ # Find available port if needed
144
+ if self.port is None or self.port == 0:
145
+ logger.info("Searching for an available port")
146
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
147
+ sock.bind(("", 0))
148
+ self.port = sock.getsockname()[1]
149
+ sock.close()
150
+
151
+ # Create FastAPI app
152
+ app = create_app(self)
153
+
154
+ # Configure uvicorn
155
+ config = uvicorn.Config(
156
+ app,
157
+ host=self.binding_host,
158
+ port=self.port,
159
+ log_level="warning",
160
+ access_log=False,
161
+ )
162
+ self._uvicorn_server = uvicorn.Server(config)
163
+
164
+ # Signal that we're running
165
+ with self._cv_running:
166
+ self._running = True
167
+ self._cv_running.notify_all()
168
+
169
+ # Run server (blocks until shutdown)
170
+ self._uvicorn_server.run()
171
+ logger.info("Web server stopped")
172
+
173
+ def stop(self):
174
+ """Stop the server gracefully"""
175
+ if self._uvicorn_server:
176
+ self._uvicorn_server.should_exit = True
177
+
178
+ def request_quit(self):
179
+ """Signal quit request from web interface"""
180
+ logger.info("Quit requested from web interface")
181
+ self._quit_event.set()
182
+ self.stop()
183
+
184
+ def wait(self):
185
+ """Wait for explicit quit from web interface
186
+
187
+ Call this after experiment completion when wait_for_quit=True.
188
+ Blocks until user clicks Quit button in the web UI.
189
+ """
190
+ if not self.wait_for_quit:
191
+ return
192
+
193
+ logger.info("Waiting for quit from web interface...")
194
+ self._quit_event.wait()
195
+ logger.info("Quit signal received")
196
+
197
+ @property
198
+ def is_running(self) -> bool:
199
+ """Check if server is running"""
200
+ return self._running
@@ -0,0 +1,152 @@
1
+ """State Bridge - connects StateProvider events to WebSocket broadcasts
2
+
3
+ Mirrors the TUI's STATE_EVENT_HANDLERS pattern for consistent event handling.
4
+ Uses db_state_dict() serialization consistent with SSHStateProviderServer.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import TYPE_CHECKING
10
+
11
+ from experimaestro.scheduler.state_provider import StateProvider
12
+ from experimaestro.scheduler.state_status import (
13
+ EventBase,
14
+ ExperimentUpdatedEvent,
15
+ JobStateChangedEvent,
16
+ JobSubmittedEvent,
17
+ ServiceAddedEvent,
18
+ RunUpdatedEvent,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from experimaestro.webui.websocket import WebSocketHandler
23
+
24
+ logger = logging.getLogger("xpm.webui.state_bridge")
25
+
26
+
27
+ class StateBridge:
28
+ """Bridges StateProvider events to WebSocket broadcasts
29
+
30
+ Similar to TUI's STATE_EVENT_HANDLERS pattern, converts events
31
+ to WebSocket messages and broadcasts to all connected clients.
32
+
33
+ Uses db_state_dict() for consistent serialization with SSHStateProviderServer.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ state_provider: StateProvider,
39
+ ws_handler: "WebSocketHandler",
40
+ ):
41
+ """Initialize state bridge
42
+
43
+ Args:
44
+ state_provider: StateProvider to listen to
45
+ ws_handler: WebSocket handler for broadcasting
46
+ """
47
+ self.state_provider = state_provider
48
+ self.ws_handler = ws_handler
49
+ self._loop: asyncio.AbstractEventLoop = None
50
+
51
+ # Register as listener
52
+ state_provider.add_listener(self._on_state_event)
53
+
54
+ def _on_state_event(self, event: EventBase):
55
+ """Handle state event from provider
56
+
57
+ Called from provider's thread, schedules async broadcast.
58
+ """
59
+ # Get or create event loop for async operations
60
+ try:
61
+ # Try to get running loop (if called from async context)
62
+ loop = asyncio.get_running_loop()
63
+ loop.create_task(self._handle_event_async(event))
64
+ except RuntimeError:
65
+ # No running loop - create new one or use thread-safe call
66
+ asyncio.run(self._handle_event_async(event))
67
+
68
+ async def _handle_event_async(self, event: EventBase):
69
+ """Handle state event asynchronously"""
70
+ handler = self._get_handler(event)
71
+ if handler:
72
+ await handler(event)
73
+
74
+ def _get_handler(self, event: EventBase):
75
+ """Get handler for event type"""
76
+ handlers = {
77
+ ExperimentUpdatedEvent: self._handle_experiment_updated,
78
+ JobStateChangedEvent: self._handle_job_state_changed,
79
+ JobSubmittedEvent: self._handle_job_submitted,
80
+ ServiceAddedEvent: self._handle_service_added,
81
+ RunUpdatedEvent: self._handle_run_updated,
82
+ }
83
+ return handlers.get(type(event))
84
+
85
+ async def _handle_experiment_updated(self, event: ExperimentUpdatedEvent):
86
+ """Handle experiment update - broadcast experiment.add"""
87
+ from experimaestro.webui.websocket import serialize_experiment
88
+
89
+ # Fetch experiment from state provider
90
+ experiment = self.state_provider.get_experiment(event.experiment_id)
91
+ if experiment:
92
+ payload = serialize_experiment(experiment)
93
+ await self.ws_handler.broadcast("experiment.add", payload)
94
+
95
+ async def _handle_job_state_changed(self, event: JobStateChangedEvent):
96
+ """Handle job state change - broadcast job.update"""
97
+ from experimaestro.webui.websocket import job_db_to_frontend
98
+
99
+ # Fetch job from state provider
100
+ # Note: get_all_jobs returns jobs across all experiments
101
+ jobs = self.state_provider.get_all_jobs()
102
+ job = next((j for j in jobs if j.identifier == event.job_id), None)
103
+ if job:
104
+ payload = job_db_to_frontend(job.db_state_dict())
105
+ await self.ws_handler.broadcast("job.update", payload)
106
+
107
+ async def _handle_job_submitted(self, event: JobSubmittedEvent):
108
+ """Handle job added to experiment - broadcast job.add"""
109
+ from experimaestro.webui.websocket import job_db_to_frontend
110
+
111
+ # Fetch the full job data from state provider
112
+ job = self.state_provider.get_job(event.job_id, event.experiment_id)
113
+ if job:
114
+ # Get base dict from db_state_dict
115
+ db_dict = job.db_state_dict()
116
+
117
+ # Add tags and dependencies from event
118
+ db_dict["tags"] = (
119
+ [(tag.key, tag.value) for tag in event.tags] if event.tags else []
120
+ )
121
+ db_dict["depends_on"] = event.depends_on or []
122
+
123
+ payload = job_db_to_frontend(db_dict)
124
+ await self.ws_handler.broadcast("job.add", payload)
125
+
126
+ async def _handle_service_added(self, event: ServiceAddedEvent):
127
+ """Handle service added - broadcast service.add"""
128
+ from experimaestro.webui.websocket import service_db_to_frontend
129
+
130
+ # Fetch service from state provider
131
+ services = self.state_provider.get_services(event.experiment_id, event.run_id)
132
+ service = next((s for s in services if s.id == event.service_id), None)
133
+ if service:
134
+ payload = service_db_to_frontend(service.db_state_dict())
135
+ await self.ws_handler.broadcast("service.add", payload)
136
+
137
+ async def _handle_run_updated(self, event: RunUpdatedEvent):
138
+ """Handle run update - triggers experiment refresh"""
139
+ from experimaestro.webui.websocket import serialize_experiment
140
+
141
+ # Run updates affect experiment stats, so broadcast experiment update
142
+ experiment = self.state_provider.get_experiment(event.experiment_id)
143
+ if experiment:
144
+ payload = serialize_experiment(experiment)
145
+ await self.ws_handler.broadcast("experiment.add", payload)
146
+
147
+ def close(self):
148
+ """Clean up - remove listener"""
149
+ try:
150
+ self.state_provider.remove_listener(self._on_state_event)
151
+ except ValueError:
152
+ pass # Already removed