griptape-nodes 0.70.1__py3-none-any.whl → 0.71.0__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.
Files changed (28) hide show
  1. griptape_nodes/api_client/client.py +8 -5
  2. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +48 -9
  3. griptape_nodes/bootstrap/utils/subprocess_websocket_base.py +88 -0
  4. griptape_nodes/bootstrap/utils/subprocess_websocket_listener.py +126 -0
  5. griptape_nodes/bootstrap/utils/subprocess_websocket_sender.py +121 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +17 -170
  7. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +13 -117
  8. griptape_nodes/bootstrap/workflow_publishers/local_session_workflow_publisher.py +206 -0
  9. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +22 -3
  10. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +45 -25
  11. griptape_nodes/common/node_executor.py +60 -13
  12. griptape_nodes/exe_types/base_iterative_nodes.py +1 -1
  13. griptape_nodes/exe_types/node_groups/subflow_node_group.py +18 -0
  14. griptape_nodes/exe_types/param_components/log_parameter.py +1 -2
  15. griptape_nodes/exe_types/param_components/subflow_execution_component.py +329 -0
  16. griptape_nodes/exe_types/param_types/parameter_audio.py +17 -2
  17. griptape_nodes/exe_types/param_types/parameter_image.py +14 -1
  18. griptape_nodes/exe_types/param_types/parameter_number.py +16 -22
  19. griptape_nodes/exe_types/param_types/parameter_three_d.py +14 -1
  20. griptape_nodes/exe_types/param_types/parameter_video.py +17 -2
  21. griptape_nodes/retained_mode/managers/os_manager.py +1 -1
  22. griptape_nodes/traits/clamp.py +9 -52
  23. griptape_nodes/utils/artifact_normalization.py +245 -0
  24. griptape_nodes/utils/image_preview.py +27 -0
  25. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.71.0.dist-info}/METADATA +1 -1
  26. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.71.0.dist-info}/RECORD +28 -22
  27. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.71.0.dist-info}/WHEEL +1 -1
  28. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.71.0.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import json
5
4
  import logging
6
- import threading
7
5
  from typing import TYPE_CHECKING, Any, Self
8
6
 
9
- from griptape_nodes.api_client import Client
10
- from griptape_nodes.app.app import WebSocketMessage
7
+ from griptape_nodes.bootstrap.utils.subprocess_websocket_sender import SubprocessWebSocketSenderMixin
11
8
  from griptape_nodes.bootstrap.workflow_executors.local_workflow_executor import (
12
9
  LocalExecutorError,
13
10
  LocalWorkflowExecutor,
@@ -20,10 +17,12 @@ from griptape_nodes.retained_mode.events.base_events import (
20
17
  EventResultSuccess,
21
18
  ExecutionEvent,
22
19
  ExecutionGriptapeNodeEvent,
20
+ ProgressEvent,
23
21
  ResultPayload,
24
22
  )
25
23
  from griptape_nodes.retained_mode.events.execution_events import (
26
24
  ControlFlowCancelledEvent,
25
+ GriptapeEvent,
27
26
  StartFlowRequest,
28
27
  StartFlowResultFailure,
29
28
  )
@@ -36,7 +35,7 @@ if TYPE_CHECKING:
36
35
  logger = logging.getLogger(__name__)
37
36
 
38
37
 
39
- class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
38
+ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor, SubprocessWebSocketSenderMixin):
40
39
  def __init__(
41
40
  self,
42
41
  session_id: str,
@@ -44,13 +43,8 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
44
43
  on_start_flow_result: Callable[[ResultPayload], None] | None = None,
45
44
  ):
46
45
  super().__init__(storage_backend=storage_backend)
47
- self._session_id = session_id
46
+ self._init_websocket_sender(session_id)
48
47
  self._on_start_flow_result = on_start_flow_result
49
- self._websocket_thread: threading.Thread | None = None
50
- self._websocket_event_loop: asyncio.AbstractEventLoop | None = None
51
- self._websocket_event_loop_ready = threading.Event()
52
- self._ws_outgoing_queue: asyncio.Queue | None = None
53
- self._shutdown_event: asyncio.Event | None = None
54
48
 
55
49
  async def __aenter__(self) -> Self:
56
50
  """Async context manager entry: initialize queue and broadcast app initialization."""
@@ -71,37 +65,12 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
71
65
  exc_tb: TracebackType | None,
72
66
  ) -> None:
73
67
  """Async context manager exit."""
74
- self._stop_websocket_thread()
68
+ await self._stop_websocket_connection()
75
69
 
76
70
  GriptapeNodes.SessionManager().remove_session(self._session_id)
77
71
 
78
72
  # TODO: Broadcast shutdown https://github.com/griptape-ai/griptape-nodes/issues/2149
79
73
 
80
- def _stop_websocket_thread(self) -> None:
81
- """Stop the websocket thread."""
82
- if self._websocket_thread is None or not self._websocket_thread.is_alive():
83
- logger.debug("No websocket thread to stop")
84
- return
85
-
86
- logger.debug("Stopping websocket thread")
87
- self._websocket_event_loop_ready.clear()
88
-
89
- # Signal shutdown to the websocket tasks
90
- if self._websocket_event_loop and self._websocket_event_loop.is_running() and self._shutdown_event:
91
-
92
- def signal_shutdown() -> None:
93
- if self._shutdown_event:
94
- self._shutdown_event.set()
95
-
96
- self._websocket_event_loop.call_soon_threadsafe(signal_shutdown)
97
-
98
- # Wait for thread to finish
99
- self._websocket_thread.join(timeout=5.0)
100
- if self._websocket_thread.is_alive():
101
- logger.warning("Websocket thread did not stop gracefully")
102
- else:
103
- logger.info("Websocket thread stopped successfully")
104
-
105
74
  async def _process_execution_event_async(self, event: ExecutionGriptapeNodeEvent) -> None:
106
75
  """Process execution events asynchronously for real-time websocket emission."""
107
76
  logger.debug("REAL-TIME: Processing execution event for session %s", self._session_id)
@@ -144,7 +113,7 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
144
113
  await asyncio.sleep(1)
145
114
  raise LocalExecutorError(msg) from e
146
115
  finally:
147
- self._stop_websocket_thread()
116
+ await self._stop_websocket_connection()
148
117
 
149
118
  async def _arun( # noqa: C901, PLR0915
150
119
  self,
@@ -234,6 +203,16 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
234
203
  background_tasks.add(task)
235
204
  task.add_done_callback(_handle_task_done)
236
205
  is_flow_finished, error = await self._handle_execution_event(event, flow_name)
206
+ elif isinstance(event, ProgressEvent):
207
+ # Convert ProgressEvent to GriptapeEvent and emit via WebSocket
208
+ payload = GriptapeEvent(
209
+ node_name=event.node_name,
210
+ parameter_name=event.parameter_name,
211
+ type=type(event).__name__,
212
+ value=event.value,
213
+ )
214
+ execution_event = ExecutionEvent(payload=payload)
215
+ self.send_event("execution_event", execution_event.json())
237
216
 
238
217
  event_queue.task_done()
239
218
 
@@ -251,135 +230,3 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
251
230
 
252
231
  if error is not None:
253
232
  raise error
254
-
255
- async def _start_websocket_connection(self) -> None:
256
- """Start websocket connection in a background thread for event emission."""
257
- logger.info("Starting websocket connection for session %s", self._session_id)
258
- self._websocket_thread = threading.Thread(target=self._start_websocket_thread, daemon=True)
259
- self._websocket_thread.start()
260
-
261
- if self._websocket_event_loop_ready.wait(timeout=10):
262
- logger.info("Websocket thread ready")
263
- await asyncio.sleep(1) # Brief wait for connection to establish
264
- else:
265
- logger.error("Timeout waiting for websocket thread to start")
266
-
267
- def _start_websocket_thread(self) -> None:
268
- """Run WebSocket tasks in a separate thread with its own async loop."""
269
- try:
270
- # Create a new event loop for this thread
271
- loop = asyncio.new_event_loop()
272
- self._websocket_event_loop = loop
273
- asyncio.set_event_loop(loop)
274
-
275
- # Create the outgoing queue and shutdown event
276
- self._ws_outgoing_queue = asyncio.Queue()
277
- self._shutdown_event = asyncio.Event()
278
-
279
- # Signal that websocket_event_loop is ready
280
- self._websocket_event_loop_ready.set()
281
- logger.info("Websocket thread started and ready")
282
-
283
- # Run the async WebSocket tasks
284
- loop.run_until_complete(self._run_websocket_tasks())
285
- except Exception as e:
286
- logger.error("WebSocket thread error: %s", e)
287
- finally:
288
- self._websocket_event_loop = None
289
- self._websocket_event_loop_ready.clear()
290
- self._shutdown_event = None
291
- logger.info("Websocket thread ended")
292
-
293
- async def _run_websocket_tasks(self) -> None:
294
- """Run websocket tasks - establish connection and handle outgoing messages."""
295
- logger.info("Creating Client for session %s", self._session_id)
296
-
297
- async with Client() as client:
298
- logger.info("WebSocket connection established for session %s", self._session_id)
299
-
300
- try:
301
- await self._send_outgoing_messages(client)
302
- except Exception:
303
- logger.exception("WebSocket tasks failed")
304
- finally:
305
- logger.info("WebSocket connection loop ended")
306
-
307
- async def _send_outgoing_messages(self, client: Client) -> None:
308
- """Send outgoing WebSocket messages from queue - matches app.py pattern exactly."""
309
- if self._ws_outgoing_queue is None:
310
- logger.error("No outgoing queue available")
311
- return
312
-
313
- logger.debug("Starting outgoing WebSocket request sender")
314
-
315
- while True:
316
- # Check if shutdown was requested
317
- if self._shutdown_event and self._shutdown_event.is_set():
318
- logger.info("Shutdown requested, ending message sender")
319
- break
320
-
321
- try:
322
- # Get message from outgoing queue with timeout to allow shutdown checks
323
- message = await asyncio.wait_for(self._ws_outgoing_queue.get(), timeout=1.0)
324
- except TimeoutError:
325
- # No message in queue, continue to check for shutdown
326
- continue
327
-
328
- try:
329
- if isinstance(message, WebSocketMessage):
330
- topic = message.topic if message.topic else f"sessions/{self._session_id}/response"
331
- payload_dict = json.loads(message.payload)
332
- await client.publish(message.event_type, payload_dict, topic)
333
- logger.debug("DELIVERED: %s event", message.event_type)
334
- else:
335
- logger.warning("Unknown outgoing message type: %s", type(message))
336
- except Exception as e:
337
- logger.error("Error sending outgoing WebSocket request: %s", e)
338
- finally:
339
- self._ws_outgoing_queue.task_done()
340
-
341
- def send_event(self, event_type: str, payload: str) -> None:
342
- """Send an event via websocket if connected - thread-safe version."""
343
- # Wait for websocket event loop to be ready
344
- if not self._websocket_event_loop_ready.wait(timeout=1.0):
345
- logger.debug("Websocket not ready, event not sent: %s", event_type)
346
- return
347
-
348
- # Use run_coroutine_threadsafe to put message into WebSocket background thread queue
349
- if self._websocket_event_loop is None:
350
- logger.debug("WebSocket event loop not available for message: %s", event_type)
351
- return
352
-
353
- topic = f"sessions/{self._session_id}/response"
354
- message = WebSocketMessage(event_type, payload, topic)
355
-
356
- if self._ws_outgoing_queue is None:
357
- logger.debug("No websocket queue available for event: %s", event_type)
358
- return
359
-
360
- try:
361
- asyncio.run_coroutine_threadsafe(self._ws_outgoing_queue.put(message), self._websocket_event_loop)
362
- logger.debug("SENT: %s event via websocket", event_type)
363
- except Exception as e:
364
- logger.error("Failed to queue event %s: %s", event_type, e)
365
-
366
- async def _wait_for_websocket_queue_flush(self, timeout_seconds: float = 5.0) -> None:
367
- """Wait for all websocket messages to be sent."""
368
- if self._ws_outgoing_queue is None or self._websocket_event_loop is None:
369
- return
370
-
371
- async def _check_queue_empty() -> bool:
372
- return self._ws_outgoing_queue.empty() if self._ws_outgoing_queue else True
373
-
374
- start_time = asyncio.get_event_loop().time()
375
- while asyncio.get_event_loop().time() - start_time < timeout_seconds:
376
- future = asyncio.run_coroutine_threadsafe(_check_queue_empty(), self._websocket_event_loop)
377
- try:
378
- is_empty = future.result(timeout=0.1)
379
- if is_empty:
380
- return
381
- except Exception as e:
382
- logger.debug("Error checking queue status: %s", e)
383
- await asyncio.sleep(0.1)
384
-
385
- logger.warning("Timeout waiting for websocket queue to flush")
@@ -1,19 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import json
5
4
  import logging
6
5
  import tempfile
7
- import threading
8
- import uuid
9
6
  from pathlib import Path
10
7
  from typing import TYPE_CHECKING, Any, Self
11
8
 
12
9
  import anyio
13
10
 
14
- from griptape_nodes.api_client import Client
15
11
  from griptape_nodes.bootstrap.utils.python_subprocess_executor import PythonSubprocessExecutor
16
- from griptape_nodes.bootstrap.workflow_executors.local_session_workflow_executor import LocalSessionWorkflowExecutor
12
+ from griptape_nodes.bootstrap.utils.subprocess_websocket_listener import SubprocessWebSocketListenerMixin
13
+ from griptape_nodes.bootstrap.workflow_executors.workflow_executor import WorkflowExecutor
17
14
  from griptape_nodes.drivers.storage import StorageBackend
18
15
  from griptape_nodes.retained_mode.events.base_events import (
19
16
  EventResultFailure,
@@ -39,28 +36,23 @@ class SubprocessWorkflowExecutorError(Exception):
39
36
  """Exception raised during subprocess workflow execution."""
40
37
 
41
38
 
42
- class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessExecutor):
39
+ class SubprocessWorkflowExecutor(WorkflowExecutor, PythonSubprocessExecutor, SubprocessWebSocketListenerMixin):
43
40
  def __init__(
44
41
  self,
45
42
  workflow_path: str,
46
43
  on_start_flow_result: Callable[[ResultPayload], None] | None = None,
44
+ on_event: Callable[[dict], None] | None = None,
47
45
  session_id: str | None = None,
48
46
  ) -> None:
47
+ WorkflowExecutor.__init__(self)
49
48
  PythonSubprocessExecutor.__init__(self)
49
+ self._init_websocket_listener(session_id=session_id, on_event=on_event)
50
50
  self._workflow_path = workflow_path
51
51
  self._on_start_flow_result = on_start_flow_result
52
- # Generate a unique session ID for this execution
53
- self._session_id = session_id or uuid.uuid4().hex
54
- self._websocket_thread: threading.Thread | None = None
55
- self._websocket_event_loop: asyncio.AbstractEventLoop | None = None
56
- self._websocket_event_loop_ready = threading.Event()
57
- self._event_handlers: dict[str, list] = {}
58
- self._shutdown_event: asyncio.Event | None = None
59
52
  self._stored_exception: SubprocessWorkflowExecutorError | None = None
60
53
 
61
54
  async def __aenter__(self) -> Self:
62
- """Async context manager entry: start WebSocket connection."""
63
- logger.info("Starting WebSocket listener for session %s", self._session_id)
55
+ """Async context manager entry: start WebSocket listener."""
64
56
  await self._start_websocket_listener()
65
57
  return self
66
58
 
@@ -70,9 +62,8 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
70
62
  exc_val: BaseException | None,
71
63
  exc_tb: TracebackType | None,
72
64
  ) -> None:
73
- """Async context manager exit: stop WebSocket connection."""
74
- logger.info("Stopping WebSocket listener for session %s", self._session_id)
75
- self._stop_websocket_listener()
65
+ """Async context manager exit: stop WebSocket listener."""
66
+ await self._stop_websocket_listener()
76
67
 
77
68
  async def arun(
78
69
  self,
@@ -138,106 +129,11 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
138
129
  if self._stored_exception:
139
130
  raise self._stored_exception
140
131
 
141
- async def _start_websocket_listener(self) -> None:
142
- """Start WebSocket connection to listen for events from the subprocess."""
143
- logger.info("Starting WebSocket listener for session %s", self._session_id)
144
- self._websocket_thread = threading.Thread(target=self._start_websocket_thread, daemon=True)
145
- self._websocket_thread.start()
132
+ async def _handle_subprocess_event(self, event: dict) -> None:
133
+ """Handle executor-specific events from the subprocess.
146
134
 
147
- if self._websocket_event_loop_ready.wait(timeout=10):
148
- logger.info("WebSocket listener thread ready")
149
- else:
150
- logger.error("Timeout waiting for WebSocket listener thread to start")
151
-
152
- def _stop_websocket_listener(self) -> None:
153
- """Stop the WebSocket listener thread."""
154
- if self._websocket_thread is None or not self._websocket_thread.is_alive():
155
- return
156
-
157
- logger.info("Stopping WebSocket listener thread")
158
- self._websocket_event_loop_ready.clear()
159
-
160
- # Signal shutdown to the websocket tasks
161
- if self._websocket_event_loop and self._websocket_event_loop.is_running() and self._shutdown_event:
162
-
163
- def signal_shutdown() -> None:
164
- if self._shutdown_event:
165
- self._shutdown_event.set()
166
-
167
- self._websocket_event_loop.call_soon_threadsafe(signal_shutdown)
168
-
169
- # Wait for thread to finish
170
- self._websocket_thread.join(timeout=5.0)
171
- if self._websocket_thread.is_alive():
172
- logger.warning("WebSocket listener thread did not stop gracefully")
173
- else:
174
- logger.info("WebSocket listener thread stopped successfully")
175
-
176
- def _start_websocket_thread(self) -> None:
177
- """Run WebSocket tasks in a separate thread with its own async loop."""
178
- try:
179
- # Create a new event loop for this thread
180
- loop = asyncio.new_event_loop()
181
- self._websocket_event_loop = loop
182
- asyncio.set_event_loop(loop)
183
-
184
- # Create shutdown event
185
- self._shutdown_event = asyncio.Event()
186
-
187
- # Signal that websocket_event_loop is ready
188
- self._websocket_event_loop_ready.set()
189
- logger.info("WebSocket listener thread started and ready")
190
-
191
- # Run the async WebSocket listener
192
- loop.run_until_complete(self._run_websocket_listener())
193
- except Exception as e:
194
- logger.error("WebSocket listener thread error: %s", e)
195
- finally:
196
- self._websocket_event_loop = None
197
- self._websocket_event_loop_ready.clear()
198
- self._shutdown_event = None
199
- logger.info("WebSocket listener thread ended")
200
-
201
- async def _run_websocket_listener(self) -> None:
202
- """Run WebSocket listener - establish connection and handle incoming messages."""
203
- logger.info("Creating Client for listening on session %s", self._session_id)
204
-
205
- async with Client() as client:
206
- logger.info("WebSocket connection established for session %s", self._session_id)
207
-
208
- try:
209
- await self._listen_for_messages(client)
210
- except Exception:
211
- logger.exception("WebSocket listener failed")
212
- finally:
213
- logger.info("WebSocket listener connection loop ended")
214
-
215
- async def _listen_for_messages(self, client: Client) -> None:
216
- """Listen for incoming WebSocket messages from the subprocess."""
217
- logger.info("Starting to listen for WebSocket messages")
218
-
219
- topic = f"sessions/{self._session_id}/response"
220
- await client.subscribe(topic)
221
-
222
- try:
223
- async for message in client.messages:
224
- if self._shutdown_event and self._shutdown_event.is_set():
225
- logger.info("Shutdown requested, ending message listener")
226
- break
227
-
228
- try:
229
- logger.debug("Received WebSocket message: %s", message.get("type"))
230
- await self._process_event(message)
231
-
232
- except Exception:
233
- logger.exception("Error processing WebSocket message")
234
-
235
- except Exception as e:
236
- logger.error("Error in WebSocket message listener: %s", e)
237
- raise
238
-
239
- async def _process_event(self, event: dict) -> None:
240
- """Process events received from the subprocess via WebSocket."""
135
+ Processes execution events and result events.
136
+ """
241
137
  event_type = event.get("type", "unknown")
242
138
  if event_type == "execution_event":
243
139
  await self._process_execution_event(event)
@@ -0,0 +1,206 @@
1
+ """Local session workflow publisher with WebSocket event emission support.
2
+
3
+ This module provides a workflow publisher that emits events over WebSocket
4
+ for real-time progress updates during the publishing process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any, Self
12
+
13
+ from griptape_nodes.bootstrap.utils.subprocess_websocket_sender import SubprocessWebSocketSenderMixin
14
+ from griptape_nodes.bootstrap.workflow_publishers.local_workflow_publisher import (
15
+ LocalPublisherError,
16
+ LocalWorkflowPublisher,
17
+ )
18
+ from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
19
+ from griptape_nodes.retained_mode.events.base_events import (
20
+ EventResultFailure,
21
+ EventResultSuccess,
22
+ ExecutionEvent,
23
+ ExecutionGriptapeNodeEvent,
24
+ ResultPayload,
25
+ )
26
+ from griptape_nodes.retained_mode.events.workflow_events import (
27
+ PublishWorkflowProgressEvent,
28
+ PublishWorkflowRequest,
29
+ PublishWorkflowResultFailure,
30
+ )
31
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
32
+
33
+ if TYPE_CHECKING:
34
+ from types import TracebackType
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class LocalSessionWorkflowPublisher(LocalWorkflowPublisher, SubprocessWebSocketSenderMixin):
40
+ """Publisher with WebSocket support for sending events to parent process.
41
+
42
+ This publisher is used inside subprocesses to emit publishing progress events
43
+ over WebSocket back to the parent process.
44
+ """
45
+
46
+ def __init__(self, session_id: str) -> None:
47
+ super().__init__()
48
+ self._init_websocket_sender(session_id)
49
+
50
+ async def __aenter__(self) -> Self:
51
+ """Async context manager entry: initialize queue and start WebSocket connection."""
52
+ GriptapeNodes.EventManager().initialize_queue()
53
+ await GriptapeNodes.EventManager().broadcast_app_event(AppInitializationComplete())
54
+
55
+ logger.info("Setting up publishing session %s", self._session_id)
56
+ GriptapeNodes.SessionManager().save_session(self._session_id)
57
+ GriptapeNodes.SessionManager().active_session_id = self._session_id
58
+ await self._start_websocket_connection()
59
+
60
+ return self
61
+
62
+ async def __aexit__(
63
+ self,
64
+ exc_type: type[BaseException] | None,
65
+ exc_val: BaseException | None,
66
+ exc_tb: TracebackType | None,
67
+ ) -> None:
68
+ """Async context manager exit."""
69
+ await self._stop_websocket_connection()
70
+ GriptapeNodes.SessionManager().remove_session(self._session_id)
71
+
72
+ async def arun(
73
+ self,
74
+ workflow_name: str,
75
+ workflow_path: str,
76
+ publisher_name: str,
77
+ published_workflow_file_name: str,
78
+ **kwargs: Any,
79
+ ) -> None:
80
+ """Run the publish operation with WebSocket event emission enabled.
81
+
82
+ Progress events are emitted by the Library handling the publishing process,
83
+ which uses the GriptapeNodes event infrastructure to emit events that will
84
+ be sent over WebSocket to the parent process.
85
+ """
86
+ try:
87
+ await self._arun(
88
+ workflow_name=workflow_name,
89
+ workflow_path=workflow_path,
90
+ publisher_name=publisher_name,
91
+ published_workflow_file_name=published_workflow_file_name,
92
+ **kwargs,
93
+ )
94
+ except Exception as e:
95
+ msg = f"Unexpected error during publish: {e}"
96
+ logger.exception(msg)
97
+ raise LocalPublisherError(msg) from e
98
+ finally:
99
+ await self._stop_websocket_connection()
100
+
101
+ async def _arun( # noqa: C901, PLR0915
102
+ self,
103
+ workflow_name: str,
104
+ workflow_path: str,
105
+ publisher_name: str,
106
+ published_workflow_file_name: str,
107
+ **kwargs: Any,
108
+ ) -> None:
109
+ """Internal async run method with event queue monitoring and websocket integration."""
110
+ # Load the workflow into memory
111
+ await self.aprepare_workflow_for_run(flow_input={}, workflow_path=workflow_path)
112
+
113
+ pickle_control_flow_result = kwargs.get("pickle_control_flow_result", False)
114
+ publish_workflow_request = PublishWorkflowRequest(
115
+ workflow_name=workflow_name,
116
+ publisher_name=publisher_name,
117
+ published_workflow_file_name=published_workflow_file_name,
118
+ pickle_control_flow_result=pickle_control_flow_result,
119
+ )
120
+
121
+ # Send the publish request async (fire and forget pattern)
122
+ publish_task = asyncio.create_task(GriptapeNodes.ahandle_request(publish_workflow_request))
123
+
124
+ is_publish_finished = False
125
+ error: Exception | None = None
126
+ background_tasks: set[asyncio.Task] = set()
127
+
128
+ def _handle_publish_result(task: asyncio.Task[ResultPayload]) -> None:
129
+ nonlocal is_publish_finished, error
130
+ try:
131
+ publish_result = task.result()
132
+
133
+ if isinstance(publish_result, PublishWorkflowResultFailure):
134
+ msg = f"Failed to publish workflow: {publish_result.result_details}"
135
+ logger.error(msg)
136
+ event_result_failure = EventResultFailure(request=publish_workflow_request, result=publish_result)
137
+ self.send_event("failure_result", event_result_failure.json())
138
+ is_publish_finished = True
139
+ error = LocalPublisherError(msg)
140
+ else:
141
+ logger.info("Published workflow successfully")
142
+ event_result_success = EventResultSuccess(request=publish_workflow_request, result=publish_result)
143
+ self.send_event("success_result", event_result_success.json())
144
+ is_publish_finished = True
145
+
146
+ except Exception as e:
147
+ msg = "Error during publish workflow"
148
+ logger.exception(msg)
149
+ is_publish_finished = True
150
+ error = e
151
+ # Add a dummy event to wake up the loop in failure cases
152
+ event_queue = GriptapeNodes.EventManager().event_queue
153
+ queue_event_task = asyncio.create_task(event_queue.put(None))
154
+ background_tasks.add(queue_event_task)
155
+ queue_event_task.add_done_callback(background_tasks.discard)
156
+
157
+ publish_task.add_done_callback(_handle_publish_result)
158
+
159
+ logger.info("Publish workflow request sent! Processing events...")
160
+
161
+ def _handle_task_done(task: asyncio.Task) -> None:
162
+ background_tasks.discard(task)
163
+ if task.exception() and not task.cancelled():
164
+ logger.exception("Background task failed", exc_info=task.exception())
165
+
166
+ event_queue = GriptapeNodes.EventManager().event_queue
167
+ while not is_publish_finished:
168
+ try:
169
+ event = await event_queue.get()
170
+
171
+ # Handle the dummy wake up event (None)
172
+ if event is None:
173
+ event_queue.task_done()
174
+ continue
175
+
176
+ logger.debug("Processing publish event: %s", type(event).__name__)
177
+
178
+ if isinstance(event, ExecutionGriptapeNodeEvent):
179
+ # Unwrap the event and check if it contains a PublishWorkflowProgressEvent
180
+ wrapped_event = event.wrapped_event
181
+ if isinstance(wrapped_event, ExecutionEvent) and isinstance(
182
+ wrapped_event.payload, PublishWorkflowProgressEvent
183
+ ):
184
+ self.send_event("execution_event", wrapped_event.json())
185
+ logger.debug(
186
+ "Emitted progress event: %.1f%% - %s",
187
+ wrapped_event.payload.progress,
188
+ wrapped_event.payload.message,
189
+ )
190
+
191
+ event_queue.task_done()
192
+
193
+ except Exception as e:
194
+ msg = f"Error handling queue event: {e}"
195
+ logger.exception(msg)
196
+ error = LocalPublisherError(msg)
197
+ break
198
+
199
+ if background_tasks:
200
+ logger.info("Waiting for %d background tasks to complete", len(background_tasks))
201
+ await asyncio.gather(*background_tasks, return_exceptions=True)
202
+
203
+ await self._wait_for_websocket_queue_flush()
204
+
205
+ if error is not None:
206
+ raise error