griptape-nodes 0.38.1__py3-none-any.whl → 0.41.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 (38) hide show
  1. griptape_nodes/__init__.py +13 -9
  2. griptape_nodes/app/__init__.py +10 -1
  3. griptape_nodes/app/app.py +2 -3
  4. griptape_nodes/app/app_sessions.py +458 -0
  5. griptape_nodes/bootstrap/workflow_executors/__init__.py +1 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +213 -0
  7. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +13 -0
  8. griptape_nodes/bootstrap/workflow_runners/local_workflow_runner.py +1 -1
  9. griptape_nodes/drivers/storage/__init__.py +4 -0
  10. griptape_nodes/drivers/storage/storage_backend.py +10 -0
  11. griptape_nodes/exe_types/core_types.py +5 -1
  12. griptape_nodes/exe_types/node_types.py +20 -24
  13. griptape_nodes/machines/node_resolution.py +5 -1
  14. griptape_nodes/node_library/advanced_node_library.py +51 -0
  15. griptape_nodes/node_library/library_registry.py +28 -2
  16. griptape_nodes/node_library/workflow_registry.py +1 -1
  17. griptape_nodes/retained_mode/events/agent_events.py +15 -2
  18. griptape_nodes/retained_mode/events/app_events.py +113 -2
  19. griptape_nodes/retained_mode/events/base_events.py +28 -1
  20. griptape_nodes/retained_mode/events/library_events.py +111 -1
  21. griptape_nodes/retained_mode/events/node_events.py +1 -0
  22. griptape_nodes/retained_mode/events/workflow_events.py +1 -0
  23. griptape_nodes/retained_mode/griptape_nodes.py +240 -18
  24. griptape_nodes/retained_mode/managers/agent_manager.py +123 -17
  25. griptape_nodes/retained_mode/managers/flow_manager.py +16 -48
  26. griptape_nodes/retained_mode/managers/library_manager.py +642 -121
  27. griptape_nodes/retained_mode/managers/node_manager.py +2 -2
  28. griptape_nodes/retained_mode/managers/static_files_manager.py +4 -3
  29. griptape_nodes/retained_mode/managers/workflow_manager.py +666 -37
  30. griptape_nodes/retained_mode/utils/__init__.py +1 -0
  31. griptape_nodes/retained_mode/utils/engine_identity.py +131 -0
  32. griptape_nodes/retained_mode/utils/name_generator.py +162 -0
  33. griptape_nodes/retained_mode/utils/session_persistence.py +105 -0
  34. {griptape_nodes-0.38.1.dist-info → griptape_nodes-0.41.0.dist-info}/METADATA +1 -1
  35. {griptape_nodes-0.38.1.dist-info → griptape_nodes-0.41.0.dist-info}/RECORD +38 -28
  36. {griptape_nodes-0.38.1.dist-info → griptape_nodes-0.41.0.dist-info}/WHEEL +0 -0
  37. {griptape_nodes-0.38.1.dist-info → griptape_nodes-0.41.0.dist-info}/entry_points.txt +0 -0
  38. {griptape_nodes-0.38.1.dist-info → griptape_nodes-0.41.0.dist-info}/licenses/LICENSE +0 -0
@@ -13,7 +13,8 @@ from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
13
13
  @dataclass
14
14
  @PayloadRegistry.register
15
15
  class AppStartSessionRequest(RequestPayload):
16
- session_id: str
16
+ # TODO: https://github.com/griptape-ai/griptape-nodes/issues/1600
17
+ session_id: str | None = None
17
18
 
18
19
 
19
20
  @dataclass
@@ -68,5 +69,115 @@ class GetEngineVersionResultSuccess(ResultPayloadSuccess):
68
69
 
69
70
  @dataclass
70
71
  @PayloadRegistry.register
71
- class GetEngineVersionResultFailure(WorkflowNotAlteredMixin, ResultPayloadSuccess):
72
+ class GetEngineVersionResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
73
+ pass
74
+
75
+
76
+ @dataclass
77
+ @PayloadRegistry.register
78
+ class AppEndSessionRequest(RequestPayload):
79
+ pass
80
+
81
+
82
+ @dataclass
83
+ @PayloadRegistry.register
84
+ class AppEndSessionResultSuccess(ResultPayloadSuccess):
85
+ session_id: str | None
86
+
87
+
88
+ @dataclass
89
+ @PayloadRegistry.register
90
+ class AppEndSessionResultFailure(ResultPayloadFailure):
91
+ pass
92
+
93
+
94
+ @dataclass
95
+ @PayloadRegistry.register
96
+ class SessionHeartbeatRequest(RequestPayload):
97
+ """Request clients can use ensure the engine session is still active."""
98
+
99
+
100
+ @dataclass
101
+ @PayloadRegistry.register
102
+ class SessionHeartbeatResultSuccess(ResultPayloadSuccess):
103
+ pass
104
+
105
+
106
+ @dataclass
107
+ @PayloadRegistry.register
108
+ class SessionHeartbeatResultFailure(ResultPayloadFailure):
72
109
  pass
110
+
111
+
112
+ @dataclass
113
+ @PayloadRegistry.register
114
+ class EngineHeartbeatRequest(RequestPayload):
115
+ """Request clients can use to discover active engines and their status.
116
+
117
+ Attributes:
118
+ heartbeat_id: Unique identifier for the heartbeat request, used to correlate requests and responses.
119
+
120
+ """
121
+
122
+ heartbeat_id: str
123
+
124
+
125
+ @dataclass
126
+ @PayloadRegistry.register
127
+ class EngineHeartbeatResultSuccess(ResultPayloadSuccess):
128
+ heartbeat_id: str
129
+ engine_version: str
130
+ engine_id: str | None
131
+ session_id: str | None
132
+ timestamp: str
133
+ instance_type: str | None
134
+ instance_region: str | None
135
+ instance_provider: str | None
136
+ deployment_type: str | None
137
+ public_ip: str | None
138
+ current_workflow: str | None
139
+ workflow_file_path: str | None
140
+ has_active_flow: bool
141
+ engine_name: str
142
+
143
+
144
+ @dataclass
145
+ @PayloadRegistry.register
146
+ class EngineHeartbeatResultFailure(ResultPayloadFailure):
147
+ heartbeat_id: str
148
+
149
+
150
+ @dataclass
151
+ @PayloadRegistry.register
152
+ class SetEngineNameRequest(RequestPayload):
153
+ engine_name: str
154
+
155
+
156
+ @dataclass
157
+ @PayloadRegistry.register
158
+ class SetEngineNameResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess):
159
+ engine_name: str
160
+
161
+
162
+ @dataclass
163
+ @PayloadRegistry.register
164
+ class SetEngineNameResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
165
+ error_message: str
166
+
167
+
168
+ @dataclass
169
+ @PayloadRegistry.register
170
+ class GetEngineNameRequest(RequestPayload):
171
+ pass
172
+
173
+
174
+ @dataclass
175
+ @PayloadRegistry.register
176
+ class GetEngineNameResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess):
177
+ engine_name: str
178
+
179
+
180
+ @dataclass
181
+ @PayloadRegistry.register
182
+ class GetEngineNameResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
183
+ error_message: str
@@ -12,6 +12,9 @@ from griptape.structures import Structure
12
12
  from griptape.tools import BaseTool
13
13
  from pydantic import BaseModel, Field
14
14
 
15
+ from griptape_nodes.retained_mode.utils.engine_identity import EngineIdentity
16
+ from griptape_nodes.retained_mode.utils.session_persistence import SessionPersistence
17
+
15
18
  if TYPE_CHECKING:
16
19
  import builtins
17
20
 
@@ -28,6 +31,7 @@ class RequestPayload(Payload, ABC):
28
31
 
29
32
 
30
33
  # Result payload base class with abstract succeeded/failed methods, and indicator whether the current workflow was altered.
34
+ @dataclass(kw_only=True)
31
35
  class ResultPayload(Payload, ABC):
32
36
  """Base class for all result payloads."""
33
37
 
@@ -62,6 +66,7 @@ class WorkflowNotAlteredMixin:
62
66
 
63
67
 
64
68
  # Success result payload abstract base class
69
+ @dataclass(kw_only=True)
65
70
  class ResultPayloadSuccess(ResultPayload, ABC):
66
71
  """Abstract base class for success result payloads."""
67
72
 
@@ -75,9 +80,12 @@ class ResultPayloadSuccess(ResultPayload, ABC):
75
80
 
76
81
 
77
82
  # Failure result payload abstract base class
83
+ @dataclass(kw_only=True)
78
84
  class ResultPayloadFailure(ResultPayload, ABC):
79
85
  """Abstract base class for failure result payloads."""
80
86
 
87
+ exception: Exception | None = None
88
+
81
89
  def succeeded(self) -> bool:
82
90
  """Returns False as this is a failure result.
83
91
 
@@ -108,10 +116,29 @@ class BaseEvent(BaseModel, ABC):
108
116
  # Keeping here instead of in GriptapeNodes to avoid circular import hell.
109
117
  # TODO: https://github.com/griptape-ai/griptape-nodes/issues/848
110
118
  _session_id: ClassVar[str | None] = None
119
+ _engine_id: ClassVar[str | None] = None
111
120
 
112
- # Instance variable with a default_factory that references the class variable
121
+ # Instance variables with a default_factory that references the class variable
122
+ engine_id: str | None = Field(default_factory=lambda: BaseEvent._engine_id)
113
123
  session_id: str | None = Field(default_factory=lambda: BaseEvent._session_id)
114
124
 
125
+ @classmethod
126
+ def initialize_engine_id(cls) -> None:
127
+ """Initialize the engine ID if not already set."""
128
+ if cls._engine_id is None:
129
+ persisted_engine_id = cls._engine_id = EngineIdentity.get_engine_id()
130
+ if persisted_engine_id:
131
+ cls._engine_id = persisted_engine_id
132
+
133
+ @classmethod
134
+ def initialize_session_id(cls) -> None:
135
+ """Initialize the session ID from persisted storage if available."""
136
+ if cls._session_id is None:
137
+ # Check if there's a persisted session ID
138
+ persisted_session_id = SessionPersistence.get_persisted_session_id()
139
+ if persisted_session_id:
140
+ cls._session_id = persisted_session_id
141
+
115
142
  # Custom JSON encoder for the payload
116
143
  class Config:
117
144
  """Pydantic configuration for the BaseEvent class."""
@@ -1,6 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
2
5
 
3
- from griptape_nodes.node_library.library_registry import LibraryMetadata, NodeMetadata
4
6
  from griptape_nodes.retained_mode.events.base_events import (
5
7
  RequestPayload,
6
8
  ResultPayloadFailure,
@@ -10,6 +12,10 @@ from griptape_nodes.retained_mode.events.base_events import (
10
12
  )
11
13
  from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
12
14
 
15
+ if TYPE_CHECKING:
16
+ from griptape_nodes.node_library.library_registry import LibraryMetadata, LibrarySchema, NodeMetadata
17
+ from griptape_nodes.retained_mode.managers.library_manager import LibraryManager
18
+
13
19
 
14
20
  @dataclass
15
21
  @PayloadRegistry.register
@@ -66,6 +72,110 @@ class GetNodeMetadataFromLibraryResultFailure(WorkflowNotAlteredMixin, ResultPay
66
72
  pass
67
73
 
68
74
 
75
+ @dataclass
76
+ @PayloadRegistry.register
77
+ class LoadLibraryMetadataFromFileRequest(RequestPayload):
78
+ """Request to load library metadata from a JSON file without loading node modules.
79
+
80
+ This provides a lightweight way to get library schema information without the overhead
81
+ of dynamically importing Python modules. Useful for metadata queries, validation,
82
+ and library discovery operations.
83
+
84
+ Args:
85
+ file_path: Absolute path to the library JSON schema file to load.
86
+ """
87
+
88
+ file_path: str
89
+
90
+
91
+ @dataclass
92
+ @PayloadRegistry.register
93
+ class LoadLibraryMetadataFromFileResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess):
94
+ """Successful result from loading library metadata.
95
+
96
+ Contains the validated library schema that can be used for metadata queries,
97
+ node type discovery, and other operations that don't require the actual
98
+ node classes to be loaded.
99
+
100
+ Args:
101
+ library_schema: The validated LibrarySchema object containing all metadata
102
+ about the library including nodes, categories, and settings.
103
+ file_path: The file path from which the library metadata was loaded.
104
+ """
105
+
106
+ library_schema: LibrarySchema
107
+ file_path: str
108
+
109
+
110
+ @dataclass
111
+ @PayloadRegistry.register
112
+ class LoadLibraryMetadataFromFileResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
113
+ """Failed result from loading library metadata with detailed error information.
114
+
115
+ Provides comprehensive error details including the specific failure type and
116
+ a list of problems encountered during loading. This allows callers to understand
117
+ exactly what went wrong and take appropriate action.
118
+
119
+ Args:
120
+ library_path: Path to the library file that failed to load.
121
+ library_name: Name of the library if it could be extracted from the JSON,
122
+ None if the name couldn't be determined.
123
+ status: The LibraryStatus enum indicating the type of failure
124
+ (MISSING, UNUSABLE, etc.).
125
+ problems: List of specific error messages describing what went wrong
126
+ during loading (JSON parse errors, validation failures, etc.).
127
+ """
128
+
129
+ library_path: str
130
+ library_name: str | None
131
+ status: LibraryManager.LibraryStatus
132
+ problems: list[str]
133
+
134
+
135
+ @dataclass
136
+ @PayloadRegistry.register
137
+ class LoadMetadataForAllLibrariesRequest(RequestPayload):
138
+ """Request to load metadata for all libraries from configuration without loading node modules.
139
+
140
+ This loads metadata from both:
141
+ 1. Library JSON files specified in configuration
142
+ 2. Sandbox library (dynamically generated from Python files)
143
+
144
+ Provides a lightweight way to discover all available libraries and their schemas
145
+ without the overhead of importing Python modules or registering them in the system.
146
+ """
147
+
148
+
149
+ @dataclass
150
+ @PayloadRegistry.register
151
+ class LoadMetadataForAllLibrariesResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess):
152
+ """Successful result from loading metadata for all libraries.
153
+
154
+ Contains metadata for all discoverable libraries from both configuration files
155
+ and sandbox directory, with clear separation between successful loads and failures.
156
+
157
+ Args:
158
+ successful_libraries: List of successful library metadata loading results,
159
+ including both config-based libraries and sandbox library if applicable.
160
+ failed_libraries: List of detailed failure results for libraries that couldn't be loaded,
161
+ including both config-based libraries and sandbox library if applicable.
162
+ """
163
+
164
+ successful_libraries: list[LoadLibraryMetadataFromFileResultSuccess]
165
+ failed_libraries: list[LoadLibraryMetadataFromFileResultFailure]
166
+
167
+
168
+ @dataclass
169
+ @PayloadRegistry.register
170
+ class LoadMetadataForAllLibrariesResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure):
171
+ """Failed result from loading metadata for all libraries.
172
+
173
+ This indicates a systemic failure (e.g., configuration access issues)
174
+ rather than individual library loading failures, which are captured
175
+ in the success result's failed_libraries list.
176
+ """
177
+
178
+
69
179
  @dataclass
70
180
  @PayloadRegistry.register
71
181
  class RegisterLibraryFromFileRequest(RequestPayload):
@@ -408,6 +408,7 @@ class DeserializeNodeFromCommandsResultFailure(ResultPayloadFailure):
408
408
  @PayloadRegistry.register
409
409
  class DuplicateSelectedNodesRequest(WorkflowNotAlteredMixin, RequestPayload):
410
410
  nodes_to_duplicate: list[list[str]]
411
+ positions: list[NewPosition] | None = None
411
412
 
412
413
 
413
414
  @dataclass
@@ -144,6 +144,7 @@ class RenameWorkflowResultFailure(ResultPayloadFailure):
144
144
  @PayloadRegistry.register
145
145
  class SaveWorkflowRequest(RequestPayload):
146
146
  file_name: str | None = None
147
+ image_path: str | None = None
147
148
 
148
149
 
149
150
  @dataclass
@@ -2,27 +2,48 @@ from __future__ import annotations
2
2
 
3
3
  import importlib.metadata
4
4
  import logging
5
+ import os
5
6
  import re
7
+ import uuid
6
8
  from dataclasses import dataclass
7
9
  from datetime import UTC, datetime
8
10
  from typing import IO, TYPE_CHECKING, Any, TextIO
9
11
 
12
+ import httpx
13
+
10
14
  from griptape_nodes.exe_types.core_types import BaseNodeElement, Parameter, ParameterContainer, ParameterGroup
11
15
  from griptape_nodes.exe_types.flow import ControlFlow
16
+ from griptape_nodes.node_library.workflow_registry import WorkflowRegistry
12
17
  from griptape_nodes.retained_mode.events.app_events import (
18
+ AppEndSessionRequest,
19
+ AppEndSessionResultFailure,
20
+ AppEndSessionResultSuccess,
13
21
  AppGetSessionRequest,
14
22
  AppGetSessionResultSuccess,
15
23
  AppStartSessionRequest,
16
24
  AppStartSessionResultSuccess,
25
+ EngineHeartbeatRequest,
26
+ EngineHeartbeatResultFailure,
27
+ EngineHeartbeatResultSuccess,
28
+ GetEngineNameRequest,
29
+ GetEngineNameResultFailure,
30
+ GetEngineNameResultSuccess,
17
31
  GetEngineVersionRequest,
18
32
  GetEngineVersionResultFailure,
19
33
  GetEngineVersionResultSuccess,
34
+ SessionHeartbeatRequest,
35
+ SessionHeartbeatResultFailure,
36
+ SessionHeartbeatResultSuccess,
37
+ SetEngineNameRequest,
38
+ SetEngineNameResultFailure,
39
+ SetEngineNameResultSuccess,
20
40
  )
21
41
  from griptape_nodes.retained_mode.events.base_events import (
22
42
  AppPayload,
23
43
  BaseEvent,
24
44
  RequestPayload,
25
45
  ResultPayload,
46
+ ResultPayloadFailure,
26
47
  )
27
48
  from griptape_nodes.retained_mode.events.connection_events import (
28
49
  CreateConnectionRequest,
@@ -35,6 +56,8 @@ from griptape_nodes.retained_mode.events.parameter_events import (
35
56
  AddParameterToNodeRequest,
36
57
  AlterParameterDetailsRequest,
37
58
  )
59
+ from griptape_nodes.retained_mode.utils.engine_identity import EngineIdentity
60
+ from griptape_nodes.retained_mode.utils.session_persistence import SessionPersistence
38
61
  from griptape_nodes.utils.metaclasses import SingletonMeta
39
62
 
40
63
  if TYPE_CHECKING:
@@ -169,7 +192,7 @@ class GriptapeNodes(metaclass=SingletonMeta):
169
192
  self._static_files_manager = StaticFilesManager(
170
193
  self._config_manager, self._secrets_manager, self._event_manager
171
194
  )
172
- self._agent_manager = AgentManager(self._event_manager)
195
+ self._agent_manager = AgentManager(self._static_files_manager, self._event_manager)
173
196
  self._version_compatibility_manager = VersionCompatibilityManager(self._event_manager)
174
197
 
175
198
  # Assign handlers now that these are created.
@@ -179,7 +202,20 @@ class GriptapeNodes(metaclass=SingletonMeta):
179
202
  self._event_manager.assign_manager_to_request_type(
180
203
  AppStartSessionRequest, self.handle_session_start_request
181
204
  )
205
+ self._event_manager.assign_manager_to_request_type(AppEndSessionRequest, self.handle_session_end_request)
182
206
  self._event_manager.assign_manager_to_request_type(AppGetSessionRequest, self.handle_get_session_request)
207
+ self._event_manager.assign_manager_to_request_type(
208
+ SessionHeartbeatRequest, self.handle_session_heartbeat_request
209
+ )
210
+ self._event_manager.assign_manager_to_request_type(
211
+ EngineHeartbeatRequest, self.handle_engine_heartbeat_request
212
+ )
213
+ self._event_manager.assign_manager_to_request_type(
214
+ GetEngineNameRequest, self.handle_get_engine_name_request
215
+ )
216
+ self._event_manager.assign_manager_to_request_type(
217
+ SetEngineNameRequest, self.handle_set_engine_name_request
218
+ )
183
219
 
184
220
  @classmethod
185
221
  def get_instance(cls) -> GriptapeNodes:
@@ -191,11 +227,20 @@ class GriptapeNodes(metaclass=SingletonMeta):
191
227
  event_mgr = GriptapeNodes.EventManager()
192
228
  obj_depth_mgr = GriptapeNodes.OperationDepthManager()
193
229
  workflow_mgr = GriptapeNodes.WorkflowManager()
194
- return event_mgr.handle_request(
195
- request=request,
196
- operation_depth_mgr=obj_depth_mgr,
197
- workflow_mgr=workflow_mgr,
198
- )
230
+
231
+ try:
232
+ return event_mgr.handle_request(
233
+ request=request,
234
+ operation_depth_mgr=obj_depth_mgr,
235
+ workflow_mgr=workflow_mgr,
236
+ )
237
+ except Exception as e:
238
+ logger.exception(
239
+ "Unhandled exception while processing request of type %s. "
240
+ "Consider saving your work and restarting the engine if issues persist.",
241
+ type(request).__name__,
242
+ )
243
+ return ResultPayloadFailure(exception=e)
199
244
 
200
245
  @classmethod
201
246
  def broadcast_app_event(cls, app_event: AppPayload) -> None:
@@ -302,26 +347,203 @@ class GriptapeNodes(metaclass=SingletonMeta):
302
347
  logger.error(details)
303
348
  return GetEngineVersionResultFailure()
304
349
 
305
- def handle_session_start_request(self, request: AppStartSessionRequest) -> ResultPayload:
306
- if BaseEvent._session_id is None:
307
- details = f"Session '{request.session_id}' started at {datetime.now(tz=UTC)}."
308
- else:
309
- if BaseEvent._session_id == request.session_id:
310
- details = f"Session '{request.session_id}' already in place. No action taken."
311
- else:
312
- details = f"Attempted to start a session with ID '{request.session_id}' but this engine instance already had a session ID `{BaseEvent._session_id}' in place. Replacing it."
313
-
350
+ def handle_session_start_request(self, request: AppStartSessionRequest) -> ResultPayload: # noqa: ARG002
351
+ current_session_id = BaseEvent._session_id
352
+ if current_session_id is None:
353
+ # Client wants a new session
354
+ current_session_id = uuid.uuid4().hex
355
+ BaseEvent._session_id = current_session_id
356
+ # Persist the session ID to XDG state directory
357
+ SessionPersistence.persist_session(current_session_id)
358
+ details = f"New session '{current_session_id}' started at {datetime.now(tz=UTC)}."
314
359
  logger.info(details)
360
+ else:
361
+ details = f"Session '{current_session_id}' already active. Joining..."
315
362
 
316
- BaseEvent._session_id = request.session_id
363
+ return AppStartSessionResultSuccess(current_session_id)
317
364
 
318
- # TODO: https://github.com/griptape-ai/griptape-nodes/issues/855
365
+ def handle_session_end_request(self, _: AppEndSessionRequest) -> ResultPayload:
366
+ try:
367
+ previous_session_id = BaseEvent._session_id
368
+ if BaseEvent._session_id is None:
369
+ details = "No active session to end."
370
+ logger.info(details)
371
+ else:
372
+ details = f"Session '{BaseEvent._session_id}' ended at {datetime.now(tz=UTC)}."
373
+ logger.info(details)
374
+ BaseEvent._session_id = None
375
+ # Clear the persisted session ID from XDG state directory
376
+ SessionPersistence.clear_persisted_session()
319
377
 
320
- return AppStartSessionResultSuccess(request.session_id)
378
+ return AppEndSessionResultSuccess(session_id=previous_session_id)
379
+ except Exception as err:
380
+ details = f"Failed to end session due to '{err}'."
381
+ logger.error(details)
382
+ return AppEndSessionResultFailure()
321
383
 
322
384
  def handle_get_session_request(self, _: AppGetSessionRequest) -> ResultPayload:
323
385
  return AppGetSessionResultSuccess(session_id=BaseEvent._session_id)
324
386
 
387
+ def handle_session_heartbeat_request(self, request: SessionHeartbeatRequest) -> ResultPayload: # noqa: ARG002
388
+ """Handle session heartbeat requests.
389
+
390
+ Simply verifies that the session is active and responds with success.
391
+ """
392
+ try:
393
+ if BaseEvent._session_id is None:
394
+ logger.warning("Session heartbeat received but no active session found")
395
+ return SessionHeartbeatResultFailure()
396
+
397
+ logger.debug("Session heartbeat successful for session: %s", BaseEvent._session_id)
398
+ return SessionHeartbeatResultSuccess()
399
+ except Exception as err:
400
+ logger.error("Failed to handle session heartbeat: %s", err)
401
+ return SessionHeartbeatResultFailure()
402
+
403
+ def handle_engine_heartbeat_request(self, request: EngineHeartbeatRequest) -> ResultPayload:
404
+ """Handle engine heartbeat requests.
405
+
406
+ Returns engine status information including version, session state, and system metrics.
407
+ """
408
+ try:
409
+ # Get instance information based on environment variables
410
+ instance_info = self._get_instance_info()
411
+
412
+ # Get current workflow information
413
+ workflow_info = self._get_current_workflow_info()
414
+
415
+ # Get engine name
416
+ engine_name = EngineIdentity.get_engine_name()
417
+
418
+ logger.debug("Engine heartbeat successful")
419
+ return EngineHeartbeatResultSuccess(
420
+ heartbeat_id=request.heartbeat_id,
421
+ engine_version=engine_version,
422
+ engine_name=engine_name,
423
+ engine_id=BaseEvent._engine_id,
424
+ session_id=BaseEvent._session_id,
425
+ timestamp=datetime.now(tz=UTC).isoformat(),
426
+ **instance_info,
427
+ **workflow_info,
428
+ )
429
+ except Exception as err:
430
+ logger.error("Failed to handle engine heartbeat: %s", err)
431
+ return EngineHeartbeatResultFailure(heartbeat_id=request.heartbeat_id)
432
+
433
+ def handle_get_engine_name_request(self, request: GetEngineNameRequest) -> ResultPayload: # noqa: ARG002
434
+ """Handle requests to get the current engine name."""
435
+ try:
436
+ engine_name = EngineIdentity.get_engine_name()
437
+ logger.debug("Retrieved engine name: %s", engine_name)
438
+ return GetEngineNameResultSuccess(engine_name=engine_name)
439
+ except Exception as err:
440
+ error_message = f"Failed to get engine name: {err}"
441
+ logger.error(error_message)
442
+ return GetEngineNameResultFailure(error_message=error_message)
443
+
444
+ def handle_set_engine_name_request(self, request: SetEngineNameRequest) -> ResultPayload:
445
+ """Handle requests to set a new engine name."""
446
+ try:
447
+ # Validate engine name (basic validation)
448
+ if not request.engine_name or not request.engine_name.strip():
449
+ error_message = "Engine name cannot be empty"
450
+ logger.warning(error_message)
451
+ return SetEngineNameResultFailure(error_message=error_message)
452
+
453
+ # Set the new engine name
454
+ EngineIdentity.set_engine_name(request.engine_name.strip())
455
+ logger.info("Engine name set to: %s", request.engine_name.strip())
456
+ return SetEngineNameResultSuccess(engine_name=request.engine_name.strip())
457
+
458
+ except Exception as err:
459
+ error_message = f"Failed to set engine name: {err}"
460
+ logger.error(error_message)
461
+ return SetEngineNameResultFailure(error_message=error_message)
462
+
463
+ def _get_instance_info(self) -> dict[str, str | None]:
464
+ """Get instance information from environment variables.
465
+
466
+ Returns instance type, region, provider, and public IP information if available.
467
+ """
468
+ instance_info: dict[str, str | None] = {
469
+ "instance_type": os.getenv("GTN_INSTANCE_TYPE"),
470
+ "instance_region": os.getenv("GTN_INSTANCE_REGION"),
471
+ "instance_provider": os.getenv("GTN_INSTANCE_PROVIDER"),
472
+ }
473
+
474
+ # Determine deployment type based on presence of instance environment variables
475
+ instance_info["deployment_type"] = "griptape_hosted" if any(instance_info.values()) else "local"
476
+
477
+ # Get public IP address
478
+ public_ip = self._get_public_ip()
479
+ if public_ip:
480
+ instance_info["public_ip"] = public_ip
481
+
482
+ return instance_info
483
+
484
+ def _get_public_ip(self) -> str | None:
485
+ """Get the public IP address of this device.
486
+
487
+ Returns the public IP address if available, None otherwise.
488
+ """
489
+ try:
490
+ # Try multiple services in case one is down
491
+ services = [
492
+ "https://api.ipify.org",
493
+ "https://ipinfo.io/ip",
494
+ "https://icanhazip.com",
495
+ ]
496
+
497
+ for service in services:
498
+ try:
499
+ with httpx.Client(timeout=5.0) as client:
500
+ response = client.get(service)
501
+ response.raise_for_status()
502
+ public_ip = response.text.strip()
503
+ if public_ip:
504
+ logger.debug("Retrieved public IP from %s: %s", service, public_ip)
505
+ return public_ip
506
+ except Exception as err:
507
+ logger.debug("Failed to get public IP from %s: %s", service, err)
508
+ continue
509
+ logger.warning("Unable to retrieve public IP from any service")
510
+ except Exception as err:
511
+ logger.warning("Failed to get public IP: %s", err)
512
+ return None
513
+ else:
514
+ return None
515
+
516
+ def _get_current_workflow_info(self) -> dict[str, Any]:
517
+ """Get information about the currently loaded workflow.
518
+
519
+ Returns workflow name, file path, and status information if available.
520
+ """
521
+ workflow_info = {
522
+ "current_workflow": None,
523
+ "workflow_file_path": None,
524
+ "has_active_flow": False,
525
+ }
526
+
527
+ try:
528
+ context_manager = self._context_manager
529
+
530
+ # Check if there's an active workflow
531
+ if context_manager.has_current_workflow():
532
+ workflow_name = context_manager.get_current_workflow_name()
533
+ workflow_info["current_workflow"] = workflow_name
534
+ workflow_info["has_active_flow"] = context_manager.has_current_flow()
535
+
536
+ # Get workflow file path from registry
537
+ if WorkflowRegistry.has_workflow_with_name(workflow_name):
538
+ workflow = WorkflowRegistry.get_workflow_by_name(workflow_name)
539
+ absolute_path = WorkflowRegistry.get_complete_file_path(workflow.file_path)
540
+ workflow_info["workflow_file_path"] = absolute_path
541
+
542
+ except Exception as err:
543
+ logger.warning("Failed to get current workflow info: %s", err)
544
+
545
+ return workflow_info
546
+
325
547
 
326
548
  def create_flows_in_order(flow_name: str, flow_manager: FlowManager, created_flows: list, file: IO) -> list | None:
327
549
  """Creates flows in the correct order based on their dependencies."""