griptape-nodes 0.41.0__py3-none-any.whl → 0.43.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 (133) hide show
  1. griptape_nodes/__init__.py +0 -0
  2. griptape_nodes/app/.python-version +0 -0
  3. griptape_nodes/app/__init__.py +1 -10
  4. griptape_nodes/app/api.py +199 -0
  5. griptape_nodes/app/app.py +140 -222
  6. griptape_nodes/app/watch.py +4 -2
  7. griptape_nodes/bootstrap/__init__.py +0 -0
  8. griptape_nodes/bootstrap/bootstrap_script.py +0 -0
  9. griptape_nodes/bootstrap/register_libraries_script.py +0 -0
  10. griptape_nodes/bootstrap/structure_config.yaml +0 -0
  11. griptape_nodes/bootstrap/workflow_executors/__init__.py +0 -0
  12. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +0 -0
  13. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +0 -0
  14. griptape_nodes/bootstrap/workflow_runners/__init__.py +0 -0
  15. griptape_nodes/bootstrap/workflow_runners/bootstrap_workflow_runner.py +0 -0
  16. griptape_nodes/bootstrap/workflow_runners/local_workflow_runner.py +0 -0
  17. griptape_nodes/bootstrap/workflow_runners/subprocess_workflow_runner.py +6 -2
  18. griptape_nodes/bootstrap/workflow_runners/workflow_runner.py +0 -0
  19. griptape_nodes/drivers/__init__.py +0 -0
  20. griptape_nodes/drivers/storage/__init__.py +0 -0
  21. griptape_nodes/drivers/storage/base_storage_driver.py +0 -0
  22. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +0 -0
  23. griptape_nodes/drivers/storage/local_storage_driver.py +5 -3
  24. griptape_nodes/drivers/storage/storage_backend.py +0 -0
  25. griptape_nodes/exe_types/__init__.py +0 -0
  26. griptape_nodes/exe_types/connections.py +0 -0
  27. griptape_nodes/exe_types/core_types.py +0 -0
  28. griptape_nodes/exe_types/flow.py +68 -368
  29. griptape_nodes/exe_types/node_types.py +17 -1
  30. griptape_nodes/exe_types/type_validator.py +0 -0
  31. griptape_nodes/machines/__init__.py +0 -0
  32. griptape_nodes/machines/control_flow.py +52 -20
  33. griptape_nodes/machines/fsm.py +16 -2
  34. griptape_nodes/machines/node_resolution.py +16 -14
  35. griptape_nodes/mcp_server/__init__.py +1 -0
  36. griptape_nodes/mcp_server/server.py +126 -0
  37. griptape_nodes/mcp_server/ws_request_manager.py +268 -0
  38. griptape_nodes/node_library/__init__.py +0 -0
  39. griptape_nodes/node_library/advanced_node_library.py +0 -0
  40. griptape_nodes/node_library/library_registry.py +0 -0
  41. griptape_nodes/node_library/workflow_registry.py +2 -2
  42. griptape_nodes/py.typed +0 -0
  43. griptape_nodes/retained_mode/__init__.py +0 -0
  44. griptape_nodes/retained_mode/events/__init__.py +0 -0
  45. griptape_nodes/retained_mode/events/agent_events.py +70 -8
  46. griptape_nodes/retained_mode/events/app_events.py +137 -12
  47. griptape_nodes/retained_mode/events/arbitrary_python_events.py +23 -0
  48. griptape_nodes/retained_mode/events/base_events.py +13 -31
  49. griptape_nodes/retained_mode/events/config_events.py +87 -11
  50. griptape_nodes/retained_mode/events/connection_events.py +56 -5
  51. griptape_nodes/retained_mode/events/context_events.py +27 -4
  52. griptape_nodes/retained_mode/events/execution_events.py +99 -14
  53. griptape_nodes/retained_mode/events/flow_events.py +165 -7
  54. griptape_nodes/retained_mode/events/generate_request_payload_schemas.py +0 -0
  55. griptape_nodes/retained_mode/events/library_events.py +195 -17
  56. griptape_nodes/retained_mode/events/logger_events.py +11 -0
  57. griptape_nodes/retained_mode/events/node_events.py +242 -22
  58. griptape_nodes/retained_mode/events/object_events.py +40 -4
  59. griptape_nodes/retained_mode/events/os_events.py +116 -3
  60. griptape_nodes/retained_mode/events/parameter_events.py +212 -8
  61. griptape_nodes/retained_mode/events/payload_registry.py +0 -0
  62. griptape_nodes/retained_mode/events/secrets_events.py +59 -7
  63. griptape_nodes/retained_mode/events/static_file_events.py +57 -4
  64. griptape_nodes/retained_mode/events/validation_events.py +39 -4
  65. griptape_nodes/retained_mode/events/workflow_events.py +188 -17
  66. griptape_nodes/retained_mode/griptape_nodes.py +89 -363
  67. griptape_nodes/retained_mode/managers/__init__.py +0 -0
  68. griptape_nodes/retained_mode/managers/agent_manager.py +49 -23
  69. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +0 -0
  70. griptape_nodes/retained_mode/managers/config_manager.py +0 -0
  71. griptape_nodes/retained_mode/managers/context_manager.py +0 -0
  72. griptape_nodes/retained_mode/managers/engine_identity_manager.py +146 -0
  73. griptape_nodes/retained_mode/managers/event_manager.py +14 -2
  74. griptape_nodes/retained_mode/managers/flow_manager.py +751 -64
  75. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +45 -0
  76. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +191 -0
  77. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +346 -0
  78. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +439 -0
  79. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +17 -0
  80. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +82 -0
  81. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +116 -0
  82. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +352 -0
  83. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +104 -0
  84. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +155 -0
  85. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +18 -0
  86. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +12 -0
  87. griptape_nodes/retained_mode/managers/library_manager.py +255 -40
  88. griptape_nodes/retained_mode/managers/node_manager.py +120 -103
  89. griptape_nodes/retained_mode/managers/object_manager.py +11 -3
  90. griptape_nodes/retained_mode/managers/operation_manager.py +0 -0
  91. griptape_nodes/retained_mode/managers/os_manager.py +582 -8
  92. griptape_nodes/retained_mode/managers/secrets_manager.py +4 -0
  93. griptape_nodes/retained_mode/managers/session_manager.py +328 -0
  94. griptape_nodes/retained_mode/managers/settings.py +7 -0
  95. griptape_nodes/retained_mode/managers/static_files_manager.py +0 -0
  96. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +2 -2
  97. griptape_nodes/retained_mode/managers/workflow_manager.py +722 -456
  98. griptape_nodes/retained_mode/retained_mode.py +44 -0
  99. griptape_nodes/retained_mode/utils/__init__.py +0 -0
  100. griptape_nodes/retained_mode/utils/engine_identity.py +141 -27
  101. griptape_nodes/retained_mode/utils/name_generator.py +0 -0
  102. griptape_nodes/traits/__init__.py +0 -0
  103. griptape_nodes/traits/add_param_button.py +0 -0
  104. griptape_nodes/traits/button.py +0 -0
  105. griptape_nodes/traits/clamp.py +0 -0
  106. griptape_nodes/traits/compare.py +0 -0
  107. griptape_nodes/traits/compare_images.py +0 -0
  108. griptape_nodes/traits/file_system_picker.py +127 -0
  109. griptape_nodes/traits/minmax.py +0 -0
  110. griptape_nodes/traits/options.py +0 -0
  111. griptape_nodes/traits/slider.py +0 -0
  112. griptape_nodes/traits/trait_registry.py +0 -0
  113. griptape_nodes/traits/traits.json +0 -0
  114. griptape_nodes/updater/__init__.py +2 -2
  115. griptape_nodes/updater/__main__.py +0 -0
  116. griptape_nodes/utils/__init__.py +0 -0
  117. griptape_nodes/utils/dict_utils.py +0 -0
  118. griptape_nodes/utils/image_preview.py +128 -0
  119. griptape_nodes/utils/metaclasses.py +0 -0
  120. griptape_nodes/version_compatibility/__init__.py +0 -0
  121. griptape_nodes/version_compatibility/versions/__init__.py +0 -0
  122. griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +0 -0
  123. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +5 -5
  124. griptape_nodes-0.43.0.dist-info/METADATA +90 -0
  125. griptape_nodes-0.43.0.dist-info/RECORD +129 -0
  126. griptape_nodes-0.43.0.dist-info/WHEEL +4 -0
  127. {griptape_nodes-0.41.0.dist-info → griptape_nodes-0.43.0.dist-info}/entry_points.txt +1 -0
  128. griptape_nodes/app/app_sessions.py +0 -458
  129. griptape_nodes/retained_mode/utils/session_persistence.py +0 -105
  130. griptape_nodes-0.41.0.dist-info/METADATA +0 -78
  131. griptape_nodes-0.41.0.dist-info/RECORD +0 -112
  132. griptape_nodes-0.41.0.dist-info/WHEEL +0 -4
  133. griptape_nodes-0.41.0.dist-info/licenses/LICENSE +0 -201
griptape_nodes/app/app.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import binascii
5
4
  import json
6
5
  import logging
7
6
  import os
@@ -10,13 +9,9 @@ import sys
10
9
  import threading
11
10
  from pathlib import Path
12
11
  from queue import Queue
13
- from typing import Any, cast
12
+ from typing import Any
14
13
  from urllib.parse import urljoin
15
14
 
16
- import uvicorn
17
- from fastapi import FastAPI, HTTPException, Request
18
- from fastapi.middleware.cors import CORSMiddleware
19
- from fastapi.staticfiles import StaticFiles
20
15
  from griptape.events import (
21
16
  EventBus,
22
17
  EventListener,
@@ -28,6 +23,8 @@ from rich.panel import Panel
28
23
  from websockets.asyncio.client import connect
29
24
  from websockets.exceptions import ConnectionClosed, WebSocketException
30
25
 
26
+ from griptape_nodes.mcp_server.server import main as mcp_server
27
+
31
28
  # This import is necessary to register all events, even if not technically used
32
29
  from griptape_nodes.retained_mode.events import app_events, execution_events
33
30
  from griptape_nodes.retained_mode.events.base_events import (
@@ -39,11 +36,12 @@ from griptape_nodes.retained_mode.events.base_events import (
39
36
  ExecutionGriptapeNodeEvent,
40
37
  GriptapeNodeEvent,
41
38
  ProgressEvent,
42
- deserialize_event,
43
39
  )
44
40
  from griptape_nodes.retained_mode.events.logger_events import LogHandlerEvent
45
41
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
46
42
 
43
+ from .api import _process_api_event, start_api
44
+
47
45
  # This is a global event queue that will be used to pass events between threads
48
46
  event_queue = Queue()
49
47
 
@@ -51,16 +49,12 @@ event_queue = Queue()
51
49
  ws_connection_for_sending = None
52
50
  event_loop = None
53
51
 
52
+ # Event to signal when WebSocket connection is ready
53
+ ws_ready_event = threading.Event()
54
+
55
+
54
56
  # Whether to enable the static server
55
57
  STATIC_SERVER_ENABLED = os.getenv("STATIC_SERVER_ENABLED", "true").lower() == "true"
56
- # Host of the static server
57
- STATIC_SERVER_HOST = os.getenv("STATIC_SERVER_HOST", "localhost")
58
- # Port of the static server
59
- STATIC_SERVER_PORT = int(os.getenv("STATIC_SERVER_PORT", "8124"))
60
- # URL path for the static server
61
- STATIC_SERVER_URL = os.getenv("STATIC_SERVER_URL", "/static")
62
- # Log level for the static server
63
- STATIC_SERVER_LOG_LEVEL = os.getenv("STATIC_SERVER_LOG_LEVEL", "info").lower()
64
58
 
65
59
 
66
60
  class EventLogHandler(logging.Handler):
@@ -79,6 +73,13 @@ class EventLogHandler(logging.Handler):
79
73
 
80
74
  # Logger for this module. Important that this is not the same as the griptape_nodes logger or else we'll have infinite log events.
81
75
  logger = logging.getLogger("griptape_nodes_app")
76
+
77
+ griptape_nodes_logger = logging.getLogger("griptape_nodes")
78
+ # When running as an app, we want to forward all log messages to the event queue so they can be sent to the GUI
79
+ griptape_nodes_logger.addHandler(EventLogHandler())
80
+ griptape_nodes_logger.addHandler(RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True))
81
+ griptape_nodes_logger.setLevel(logging.INFO)
82
+
82
83
  console = Console()
83
84
 
84
85
 
@@ -89,103 +90,45 @@ def start_app() -> None:
89
90
  """
90
91
  _init_event_listeners()
91
92
 
92
- griptape_nodes_logger = logging.getLogger("griptape_nodes")
93
- # When running as an app, we want to forward all log messages to the event queue so they can be sent to the GUI
94
- griptape_nodes_logger.addHandler(EventLogHandler())
95
- griptape_nodes_logger.addHandler(RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True))
96
- griptape_nodes_logger.setLevel(logging.INFO)
97
-
98
93
  # Listen for any signals to exit the app
99
94
  for sig in (signal.SIGINT, signal.SIGTERM):
100
95
  signal.signal(sig, lambda *_: sys.exit(0))
101
96
 
102
- # SSE subscription pushes events into event_queue
103
- threading.Thread(target=_listen_for_api_events, daemon=True).start()
97
+ api_key = _ensure_api_key()
98
+ threading.Thread(target=mcp_server, args=(api_key,), daemon=True).start()
99
+ threading.Thread(target=_listen_for_api_events, args=(api_key,), daemon=True).start()
104
100
 
105
101
  if STATIC_SERVER_ENABLED:
106
- threading.Thread(target=_serve_static_server, daemon=True).start()
102
+ static_dir = _build_static_dir()
103
+ threading.Thread(target=start_api, args=(static_dir, event_queue), daemon=True).start()
107
104
 
108
105
  _process_event_queue()
109
106
 
110
107
 
111
- def _serve_static_server() -> None:
112
- """Run FastAPI with Uvicorn in order to serve static files produced by nodes."""
113
- config_manager = GriptapeNodes.ConfigManager()
114
- app = FastAPI()
115
-
116
- static_dir = config_manager.workspace_path / config_manager.merged_config["static_files_directory"]
117
-
118
- if not static_dir.exists():
119
- static_dir.mkdir(parents=True, exist_ok=True)
120
-
121
- app.add_middleware(
122
- CORSMiddleware,
123
- allow_origins=[
124
- os.getenv("GRIPTAPE_NODES_UI_BASE_URL", "https://app.nodes.griptape.ai"),
125
- "https://app.nodes-staging.griptape.ai",
126
- "http://localhost:5173",
127
- ],
128
- allow_credentials=True,
129
- allow_methods=["OPTIONS", "GET", "POST", "PUT"],
130
- allow_headers=["*"],
131
- )
108
+ def _ensure_api_key() -> str:
109
+ secrets_manager = GriptapeNodes.SecretsManager()
110
+ api_key = secrets_manager.get_secret("GT_CLOUD_API_KEY")
111
+ if api_key is None:
112
+ message = Panel(
113
+ Align.center(
114
+ "[bold red]Nodes API key is not set, please run [code]gtn init[/code] with a valid key: [/bold red]"
115
+ "[code]gtn init --api-key <your key>[/code]\n"
116
+ "[bold red]You can generate a new key from [/bold red][bold blue][link=https://nodes.griptape.ai]https://nodes.griptape.ai[/link][/bold blue]",
117
+ ),
118
+ title="🔑 ❌ Missing Nodes API Key",
119
+ border_style="red",
120
+ padding=(1, 4),
121
+ )
122
+ console.print(message)
123
+ sys.exit(1)
132
124
 
133
- app.mount(
134
- STATIC_SERVER_URL,
135
- StaticFiles(directory=static_dir),
136
- name="static",
137
- )
125
+ return api_key
138
126
 
139
- @app.post("/static-upload-urls")
140
- async def create_static_file_upload_url(request: Request) -> dict:
141
- """Create a URL for uploading a static file.
142
-
143
- Similar to a presigned URL, but for uploading files to the static server.
144
- """
145
- base_url = request.base_url
146
- body = await request.json()
147
- file_name = body["file_name"]
148
- url = urljoin(str(base_url), f"/static-uploads/{file_name}")
149
-
150
- return {"url": url}
151
-
152
- @app.put("/static-uploads/{file_name:str}")
153
- async def create_static_file(request: Request, file_name: str) -> dict:
154
- """Upload a static file to the static server."""
155
- if not STATIC_SERVER_ENABLED:
156
- msg = "Static server is not enabled. Please set STATIC_SERVER_ENABLED to True."
157
- raise ValueError(msg)
158
-
159
- if not static_dir.exists():
160
- static_dir.mkdir(parents=True, exist_ok=True)
161
- data = await request.body()
162
- try:
163
- Path(static_dir / file_name).write_bytes(data)
164
- except binascii.Error as e:
165
- msg = f"Invalid base64 encoding for file {file_name}."
166
- logger.error(msg)
167
- raise HTTPException(status_code=400, detail=msg) from e
168
- except (OSError, PermissionError) as e:
169
- msg = f"Failed to write file {file_name} to {config_manager.workspace_path}: {e}"
170
- logger.error(msg)
171
- raise HTTPException(status_code=500, detail=msg) from e
172
-
173
- static_url = f"http://{STATIC_SERVER_HOST}:{STATIC_SERVER_PORT}{STATIC_SERVER_URL}/{file_name}"
174
- return {"url": static_url}
175
-
176
- @app.post("/engines/request")
177
- async def create_event(request: Request) -> None:
178
- body = await request.json()
179
- if "payload" in body:
180
- __process_api_event(body["payload"])
181
-
182
- logging.getLogger("uvicorn").addHandler(
183
- RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True)
184
- )
185
127
 
186
- uvicorn.run(
187
- app, host=STATIC_SERVER_HOST, port=STATIC_SERVER_PORT, log_level=STATIC_SERVER_LOG_LEVEL, log_config=None
188
- )
128
+ def _build_static_dir() -> Path:
129
+ """Build the static directory path based on the workspace configuration."""
130
+ config_manager = GriptapeNodes.ConfigManager()
131
+ return Path(config_manager.workspace_path) / config_manager.merged_config["static_files_directory"]
189
132
 
190
133
 
191
134
  def _init_event_listeners() -> None:
@@ -216,38 +159,31 @@ def _init_event_listeners() -> None:
216
159
  )
217
160
 
218
161
 
219
- async def _alisten_for_api_requests() -> None:
162
+ async def _alisten_for_api_requests(api_key: str) -> None:
220
163
  """Listen for events from the Nodes API and process them asynchronously."""
221
164
  global ws_connection_for_sending, event_loop # noqa: PLW0603
222
165
  event_loop = asyncio.get_running_loop() # Store the event loop reference
223
- nodes_app_url = os.getenv("GRIPTAPE_NODES_UI_BASE_URL", "https://nodes.griptape.ai")
224
166
  logger.info("Listening for events from Nodes API via async WebSocket")
225
167
 
226
168
  # Auto reconnect https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html#opening-a-connection
227
- connection_stream = __create_async_websocket_connection()
169
+ connection_stream = _create_websocket_connection(api_key)
228
170
  initialized = False
229
171
  async for ws_connection in connection_stream:
230
172
  try:
231
173
  ws_connection_for_sending = ws_connection # Store for sending events
174
+ ws_ready_event.set() # Signal that WebSocket is ready for sending
175
+
232
176
  if not initialized:
233
- __broadcast_app_initialization_complete(nodes_app_url)
177
+ event_queue.put(AppEvent(payload=app_events.AppInitializationComplete()))
234
178
  initialized = True
235
179
 
180
+ event_queue.put(AppEvent(payload=app_events.AppConnectionEstablished()))
181
+
236
182
  async for message in ws_connection:
237
183
  try:
238
184
  data = json.loads(message)
239
185
 
240
- payload = data.get("payload", {})
241
- # With heartbeat events, we skip the regular processing and just send the heartbeat
242
- # Technically no longer needed since https://github.com/griptape-ai/griptape-nodes/pull/369
243
- # but we don't have a proper EventRequest for it yet.
244
- if payload.get("request_type") == "Heartbeat":
245
- session_id = GriptapeNodes.get_session_id()
246
- await __send_heartbeat(
247
- session_id=session_id, request=payload["request"], ws_connection=ws_connection
248
- )
249
- else:
250
- __process_api_event(payload)
186
+ _process_api_event(data, event_queue)
251
187
  except Exception:
252
188
  logger.exception("Error processing event, skipping.")
253
189
  except ConnectionClosed:
@@ -257,9 +193,9 @@ async def _alisten_for_api_requests() -> None:
257
193
  await asyncio.sleep(2)
258
194
 
259
195
 
260
- def _listen_for_api_events() -> None:
196
+ def _listen_for_api_events(api_key: str) -> None:
261
197
  """Run the async WebSocket listener in an event loop."""
262
- asyncio.run(_alisten_for_api_requests())
198
+ asyncio.run(_alisten_for_api_requests(api_key))
263
199
 
264
200
 
265
201
  def __process_node_event(event: GriptapeNodeEvent) -> None:
@@ -273,9 +209,8 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
273
209
  else:
274
210
  msg = f"Unknown/unsupported result event type encountered: '{type(result_event)}'."
275
211
  raise TypeError(msg) from None
276
- # Don't send events over the wire that don't have a request_id set (e.g. engine-internal events)
277
- event_json = result_event.json()
278
- __schedule_async_task(__emit_message(dest_socket, event_json))
212
+
213
+ __schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
279
214
 
280
215
 
281
216
  def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
@@ -283,7 +218,6 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
283
218
  result_event = event.wrapped_event
284
219
  if type(result_event.payload).__name__ == "NodeStartProcessEvent":
285
220
  GriptapeNodes.EventManager().current_active_node = result_event.payload.node_name
286
- event_json = result_event.json()
287
221
 
288
222
  if type(result_event.payload).__name__ == "ResumeNodeProcessingEvent":
289
223
  node_name = result_event.payload.node_name
@@ -297,7 +231,7 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
297
231
  msg = "Node start and finish do not match."
298
232
  raise KeyError(msg) from None
299
233
  GriptapeNodes.EventManager().current_active_node = None
300
- __schedule_async_task(__emit_message("execution_event", event_json))
234
+ __schedule_async_task(__emit_message("execution_event", result_event.json()))
301
235
 
302
236
 
303
237
  def __process_progress_event(gt_event: ProgressEvent) -> None:
@@ -325,11 +259,16 @@ def _process_event_queue() -> None:
325
259
 
326
260
  Event queue will be populated by background threads listening for events from the Nodes API.
327
261
  """
262
+ # Wait for WebSocket connection to be established before processing events
263
+ ws_ready_event.wait()
264
+
328
265
  while True:
329
266
  event = event_queue.get(block=True)
330
267
  if isinstance(event, EventRequest):
331
268
  request_payload = event.request
332
- GriptapeNodes.handle_request(request_payload)
269
+ GriptapeNodes.handle_request(
270
+ request_payload, response_topic=event.response_topic, request_id=event.request_id
271
+ )
333
272
  elif isinstance(event, AppEvent):
334
273
  __process_app_event(event)
335
274
  else:
@@ -338,27 +277,11 @@ def _process_event_queue() -> None:
338
277
  event_queue.task_done()
339
278
 
340
279
 
341
- def __create_async_websocket_connection() -> Any:
280
+ def _create_websocket_connection(api_key: str) -> Any:
342
281
  """Create an async WebSocket connection to the Nodes API."""
343
- secrets_manager = GriptapeNodes.SecretsManager()
344
- api_key = secrets_manager.get_secret("GT_CLOUD_API_KEY")
345
- if api_key is None:
346
- message = Panel(
347
- Align.center(
348
- "[bold red]Nodes API key is not set, please run [code]gtn init[/code] with a valid key: [/bold red]"
349
- "[code]gtn init --api-key <your key>[/code]\n"
350
- "[bold red]You can generate a new key from [/bold red][bold blue][link=https://nodes.griptape.ai]https://nodes.griptape.ai[/link][/bold blue]",
351
- ),
352
- title="🔑 ❌ Missing Nodes API Key",
353
- border_style="red",
354
- padding=(1, 4),
355
- )
356
- console.print(message)
357
- sys.exit(1)
358
-
359
282
  endpoint = urljoin(
360
283
  os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
361
- "/ws/engines/events?publish_channel=responses&subscribe_channel=requests",
284
+ "/ws/engines/events?version=v2",
362
285
  )
363
286
 
364
287
  return connect(
@@ -367,7 +290,7 @@ def __create_async_websocket_connection() -> Any:
367
290
  )
368
291
 
369
292
 
370
- async def __emit_message(event_type: str, payload: str) -> None:
293
+ async def __emit_message(event_type: str, payload: str, topic: str | None = None) -> None:
371
294
  """Send a message via WebSocket asynchronously."""
372
295
  global ws_connection_for_sending # noqa: PLW0602
373
296
  if ws_connection_for_sending is None:
@@ -375,7 +298,12 @@ async def __emit_message(event_type: str, payload: str) -> None:
375
298
  return
376
299
 
377
300
  try:
378
- body = {"type": event_type, "payload": json.loads(payload) if payload else {}}
301
+ # Determine topic based on session_id and engine_id in the payload
302
+ if topic is None:
303
+ topic = _determine_response_topic()
304
+
305
+ body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
306
+
379
307
  await ws_connection_for_sending.send(json.dumps(body))
380
308
  except WebSocketException as e:
381
309
  logger.error("Error sending event to Nodes API: %s", e)
@@ -383,97 +311,87 @@ async def __emit_message(event_type: str, payload: str) -> None:
383
311
  logger.error("Unexpected error while sending event to Nodes API: %s", e)
384
312
 
385
313
 
386
- async def __send_heartbeat(*, session_id: str | None, request: dict, ws_connection: Any) -> None:
387
- """Send a heartbeat response via WebSocket."""
388
- heartbeat_response = {
389
- "request": request,
390
- "result": {},
391
- "request_type": "Heartbeat",
392
- "event_type": "EventResultSuccess",
393
- "result_type": "HeartbeatSuccess",
394
- **({"session_id": session_id} if session_id is not None else {}),
395
- }
314
+ def _determine_response_topic() -> str | None:
315
+ """Determine the response topic based on session_id and engine_id in the payload."""
316
+ engine_id = GriptapeNodes.get_engine_id()
317
+ session_id = GriptapeNodes.get_session_id()
396
318
 
397
- body = {"type": "success_result", "payload": heartbeat_response}
398
- try:
399
- await ws_connection.send(json.dumps(body))
400
- logger.debug(
401
- "Responded to heartbeat request with session: %s and request: %s", session_id, request.get("request_id")
402
- )
403
- except WebSocketException as e:
404
- logger.error("Error sending heartbeat response: %s", e)
405
- except Exception as e:
406
- logger.error("Unexpected error while sending heartbeat response: %s", e)
319
+ # Normal topic determination logic
320
+ # Check for session_id first (highest priority)
321
+ if session_id:
322
+ return f"sessions/{session_id}/response"
407
323
 
324
+ # Check for engine_id if no session_id
325
+ if engine_id:
326
+ return f"engines/{engine_id}/response"
408
327
 
409
- def __schedule_async_task(coro: Any) -> None:
410
- """Schedule an async coroutine to run in the event loop from a sync context."""
411
- if event_loop and event_loop.is_running():
412
- asyncio.run_coroutine_threadsafe(coro, event_loop)
413
- else:
414
- logger.warning("Event loop not available for scheduling async task")
328
+ # Default to generic response topic
329
+ return "response"
415
330
 
416
331
 
417
- def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
418
- """Broadcast the AppInitializationComplete event to all listeners.
332
+ def _determine_request_topic() -> str | None:
333
+ """Determine the request topic based on session_id and engine_id in the payload."""
334
+ engine_id = GriptapeNodes.get_engine_id()
335
+ session_id = GriptapeNodes.get_session_id()
419
336
 
420
- This is used to notify the GUI that the app is ready to receive events.
421
- """
422
- # Broadcast this to anybody who wants a callback on "hey, the app's ready to roll"
423
- payload = app_events.AppInitializationComplete()
424
- app_event = AppEvent(payload=payload)
425
- __process_app_event(app_event)
426
-
427
- engine_version_request = app_events.GetEngineVersionRequest()
428
- engine_version_result = GriptapeNodes.get_instance().handle_engine_version_request(engine_version_request)
429
- if isinstance(engine_version_result, app_events.GetEngineVersionResultSuccess):
430
- engine_version = f"v{engine_version_result.major}.{engine_version_result.minor}.{engine_version_result.patch}"
431
- else:
432
- engine_version = "<UNKNOWN ENGINE VERSION>"
433
-
434
- message = Panel(
435
- Align.center(
436
- f"[bold green]Engine is ready to receive events[/bold green]\n"
437
- f"[bold blue]Return to: [link={nodes_app_url}]{nodes_app_url}[/link] to access the Workflow Editor[/bold blue]",
438
- vertical="middle",
439
- ),
440
- title="🚀 Griptape Nodes Engine Started",
441
- subtitle=f"[green]{engine_version}[/green]",
442
- border_style="green",
443
- padding=(1, 4),
444
- )
445
- console.print(message)
337
+ # Normal topic determination logic
338
+ # Check for session_id first (highest priority)
339
+ if session_id:
340
+ return f"sessions/{session_id}/request"
446
341
 
342
+ # Check for engine_id if no session_id
343
+ if engine_id:
344
+ return f"engines/{engine_id}/request"
447
345
 
448
- def __process_api_event(data: dict) -> None:
449
- """Process API events and send them to the event queue."""
450
- try:
451
- data["request"]
452
- except KeyError:
453
- msg = "Error: 'request' was expected but not found."
454
- raise RuntimeError(msg) from None
346
+ # Default to generic request topic
347
+ return "request"
348
+
349
+
350
+ def subscribe_to_topic(topic: str) -> None:
351
+ """Subscribe to a specific topic in the message bus."""
352
+ __schedule_async_task(_asubscribe_to_topic(topic))
353
+
354
+
355
+ def unsubscribe_from_topic(topic: str) -> None:
356
+ """Unsubscribe from a specific topic in the message bus."""
357
+ __schedule_async_task(_aunsubscribe_from_topic(topic))
358
+
359
+
360
+ async def _asubscribe_to_topic(topic: str) -> None:
361
+ """Subscribe to a specific topic in the message bus."""
362
+ if ws_connection_for_sending is None:
363
+ logger.warning("WebSocket connection not available for subscribing to topic")
364
+ return
455
365
 
456
366
  try:
457
- event_type = data["event_type"]
458
- if event_type != "EventRequest":
459
- msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
460
- raise RuntimeError(msg) from None
461
- except KeyError:
462
- msg = "Error: 'event_type' not found in request."
463
- raise RuntimeError(msg) from None
464
-
465
- # Now attempt to convert it into an EventRequest.
466
- try:
467
- request_event: EventRequest = cast("EventRequest", deserialize_event(json_data=data))
367
+ body = {"type": "subscribe", "topic": topic, "payload": {}}
368
+ await ws_connection_for_sending.send(json.dumps(body))
369
+ logger.info("Subscribed to topic: %s", topic)
370
+ except WebSocketException as e:
371
+ logger.error("Error subscribing to topic %s: %s", topic, e)
468
372
  except Exception as e:
469
- msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
470
- raise RuntimeError(msg) from None
373
+ logger.error("Unexpected error while subscribing to topic %s: %s", topic, e)
374
+
375
+
376
+ async def _aunsubscribe_from_topic(topic: str) -> None:
377
+ """Unsubscribe from a specific topic in the message bus."""
378
+ if ws_connection_for_sending is None:
379
+ logger.warning("WebSocket connection not available for unsubscribing from topic")
380
+ return
471
381
 
472
- # Add a request_id to the payload
473
- request_id = request_event.request.request_id
474
- request_event.request.request_id = request_id
382
+ try:
383
+ body = {"type": "unsubscribe", "topic": topic, "payload": {}}
384
+ await ws_connection_for_sending.send(json.dumps(body))
385
+ logger.info("Unsubscribed from topic: %s", topic)
386
+ except WebSocketException as e:
387
+ logger.error("Error unsubscribing from topic %s: %s", topic, e)
388
+ except Exception as e:
389
+ logger.error("Unexpected error while unsubscribing from topic %s: %s", topic, e)
475
390
 
476
- # Add the event to the queue
477
- event_queue.put(request_event)
478
391
 
479
- return request_id
392
+ def __schedule_async_task(coro: Any) -> None:
393
+ """Schedule an async coroutine to run in the event loop from a sync context."""
394
+ if event_loop and event_loop.is_running():
395
+ asyncio.run_coroutine_threadsafe(coro, event_loop)
396
+ else:
397
+ logger.warning("Event loop not available for scheduling async task")
@@ -30,7 +30,7 @@ class ReloadHandler(PatternMatchingEventHandler):
30
30
  def start_process(self) -> None:
31
31
  if self.process:
32
32
  self.process.terminate()
33
- self.process = subprocess.Popen( # noqa: S603
33
+ self.process = subprocess.Popen(
34
34
  ["uv", "run", "gtn"], # noqa: S607
35
35
  stdout=sys.stdout,
36
36
  stderr=sys.stderr,
@@ -52,7 +52,9 @@ if __name__ == "__main__":
52
52
  event_handler = ReloadHandler(patterns=["*.py"], ignore_patterns=["*.pyc", "*.pyo"], ignore_directories=True)
53
53
 
54
54
  observer = Observer()
55
- observer.schedule(event_handler, path=".", recursive=True)
55
+ observer.schedule(event_handler, path="src", recursive=True)
56
+ observer.schedule(event_handler, path="libraries", recursive=True)
57
+ observer.schedule(event_handler, path="tests", recursive=True)
56
58
  observer.start()
57
59
 
58
60
  try:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,10 +1,12 @@
1
1
  import logging
2
2
  import threading
3
3
  from multiprocessing import Process, Queue
4
+ from multiprocessing import Queue as ProcessQueue
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
7
- from griptape_nodes.app.app import _serve_static_server
8
+ from griptape_nodes.app.api import start_api
9
+ from griptape_nodes.app.app import _build_static_dir
8
10
  from griptape_nodes.bootstrap.workflow_runners.local_workflow_runner import LocalWorkflowRunner
9
11
  from griptape_nodes.bootstrap.workflow_runners.workflow_runner import WorkflowRunner
10
12
 
@@ -30,7 +32,9 @@ class SubprocessWorkflowRunner(WorkflowRunner):
30
32
  logger.setLevel(logging.NOTSET)
31
33
 
32
34
  try:
33
- threading.Thread(target=_serve_static_server, daemon=True).start()
35
+ static_dir = _build_static_dir()
36
+ event_queue = ProcessQueue()
37
+ threading.Thread(target=start_api, args=(static_dir, event_queue), daemon=True).start()
34
38
  workflow_runner = LocalWorkflowRunner(libraries)
35
39
  workflow_runner.run(workflow_path, workflow_name, flow_input, "local")
36
40
  except Exception as e:
File without changes
File without changes
File without changes
@@ -4,7 +4,8 @@ from urllib.parse import urljoin
4
4
 
5
5
  import httpx
6
6
 
7
- from griptape_nodes.app.app import STATIC_SERVER_ENABLED, STATIC_SERVER_HOST, STATIC_SERVER_PORT, STATIC_SERVER_URL
7
+ from griptape_nodes.app.api import STATIC_SERVER_HOST, STATIC_SERVER_PORT, STATIC_SERVER_URL
8
+ from griptape_nodes.app.app import STATIC_SERVER_ENABLED
8
9
  from griptape_nodes.drivers.storage.base_storage_driver import BaseStorageDriver, CreateSignedUploadUrlResponse
9
10
 
10
11
  logger = logging.getLogger("griptape_nodes")
@@ -47,7 +48,8 @@ class LocalStorageDriver(BaseStorageDriver):
47
48
  return {"url": url, "headers": response_data.get("headers", {}), "method": "PUT"}
48
49
 
49
50
  def create_signed_download_url(self, file_name: str) -> str:
50
- url = urljoin(self.base_url, f"/static/{file_name}")
51
+ # The base_url already includes the /static path, so just append the filename
52
+ url = f"{self.base_url}/{file_name}"
51
53
  # Add a cache-busting query parameter to the URL so that the browser always reloads the file
52
- cache_busted_url = urljoin(url, f"?t={int(time.time())}")
54
+ cache_busted_url = f"{url}?t={int(time.time())}"
53
55
  return cache_busted_url
File without changes
File without changes
File without changes
File without changes