griptape-nodes 0.53.0__py3-none-any.whl → 0.54.1__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 (56) hide show
  1. griptape_nodes/__init__.py +5 -2
  2. griptape_nodes/app/app.py +4 -26
  3. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +35 -5
  4. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +15 -1
  5. griptape_nodes/cli/commands/config.py +4 -1
  6. griptape_nodes/cli/commands/init.py +5 -3
  7. griptape_nodes/cli/commands/libraries.py +14 -8
  8. griptape_nodes/cli/commands/models.py +504 -0
  9. griptape_nodes/cli/commands/self.py +5 -2
  10. griptape_nodes/cli/main.py +11 -1
  11. griptape_nodes/cli/shared.py +0 -9
  12. griptape_nodes/common/directed_graph.py +17 -1
  13. griptape_nodes/drivers/storage/base_storage_driver.py +40 -20
  14. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +24 -29
  15. griptape_nodes/drivers/storage/local_storage_driver.py +17 -13
  16. griptape_nodes/exe_types/node_types.py +219 -14
  17. griptape_nodes/exe_types/param_components/__init__.py +1 -0
  18. griptape_nodes/exe_types/param_components/execution_status_component.py +138 -0
  19. griptape_nodes/machines/control_flow.py +129 -92
  20. griptape_nodes/machines/dag_builder.py +207 -0
  21. griptape_nodes/machines/parallel_resolution.py +264 -276
  22. griptape_nodes/machines/sequential_resolution.py +9 -7
  23. griptape_nodes/node_library/library_registry.py +34 -1
  24. griptape_nodes/retained_mode/events/app_events.py +5 -1
  25. griptape_nodes/retained_mode/events/base_events.py +7 -7
  26. griptape_nodes/retained_mode/events/config_events.py +30 -0
  27. griptape_nodes/retained_mode/events/execution_events.py +2 -2
  28. griptape_nodes/retained_mode/events/model_events.py +296 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +10 -1
  30. griptape_nodes/retained_mode/managers/agent_manager.py +14 -0
  31. griptape_nodes/retained_mode/managers/config_manager.py +44 -3
  32. griptape_nodes/retained_mode/managers/event_manager.py +8 -2
  33. griptape_nodes/retained_mode/managers/flow_manager.py +45 -14
  34. griptape_nodes/retained_mode/managers/library_manager.py +3 -3
  35. griptape_nodes/retained_mode/managers/model_manager.py +1107 -0
  36. griptape_nodes/retained_mode/managers/node_manager.py +26 -26
  37. griptape_nodes/retained_mode/managers/object_manager.py +1 -1
  38. griptape_nodes/retained_mode/managers/os_manager.py +6 -6
  39. griptape_nodes/retained_mode/managers/settings.py +87 -9
  40. griptape_nodes/retained_mode/managers/static_files_manager.py +77 -9
  41. griptape_nodes/retained_mode/managers/sync_manager.py +10 -5
  42. griptape_nodes/retained_mode/managers/workflow_manager.py +101 -92
  43. griptape_nodes/retained_mode/retained_mode.py +19 -0
  44. griptape_nodes/servers/__init__.py +1 -0
  45. griptape_nodes/{mcp_server/server.py → servers/mcp.py} +1 -1
  46. griptape_nodes/{app/api.py → servers/static.py} +43 -40
  47. griptape_nodes/traits/button.py +124 -6
  48. griptape_nodes/traits/multi_options.py +188 -0
  49. griptape_nodes/traits/numbers_selector.py +77 -0
  50. griptape_nodes/traits/options.py +93 -2
  51. griptape_nodes/utils/async_utils.py +31 -0
  52. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/METADATA +3 -1
  53. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/RECORD +56 -47
  54. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/WHEEL +1 -1
  55. /griptape_nodes/{mcp_server → servers}/ws_request_manager.py +0 -0
  56. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  from abc import ABC, abstractmethod
3
+ from pathlib import Path
3
4
  from typing import TypedDict
4
5
 
5
6
  import httpx
@@ -18,12 +19,31 @@ class CreateSignedUploadUrlResponse(TypedDict):
18
19
  class BaseStorageDriver(ABC):
19
20
  """Base class for storage drivers."""
20
21
 
22
+ def __init__(self, workspace_directory: Path) -> None:
23
+ """Initialize the storage driver with a workspace directory.
24
+
25
+ Args:
26
+ workspace_directory: The base workspace directory path.
27
+ """
28
+ self.workspace_directory = workspace_directory
29
+
30
+ def _get_full_path(self, path: Path) -> Path:
31
+ """Get the full path by joining workspace directory with the given path.
32
+
33
+ Args:
34
+ path: The relative path to join with workspace directory.
35
+
36
+ Returns:
37
+ The full path as workspace_directory / path.
38
+ """
39
+ return self.workspace_directory / path
40
+
21
41
  @abstractmethod
22
- def create_signed_upload_url(self, file_name: str) -> CreateSignedUploadUrlResponse:
23
- """Create a signed upload URL for the given file name.
42
+ def create_signed_upload_url(self, path: Path) -> CreateSignedUploadUrlResponse:
43
+ """Create a signed upload URL for the given path.
24
44
 
25
45
  Args:
26
- file_name: The name of the file to create a signed URL for.
46
+ path: The path of the file to create a signed URL for.
27
47
 
28
48
  Returns:
29
49
  CreateSignedUploadUrlResponse: A dictionary containing the signed URL, headers, and operation type.
@@ -31,11 +51,11 @@ class BaseStorageDriver(ABC):
31
51
  ...
32
52
 
33
53
  @abstractmethod
34
- def create_signed_download_url(self, file_name: str) -> str:
35
- """Create a signed download URL for the given file name.
54
+ def create_signed_download_url(self, path: Path) -> str:
55
+ """Create a signed download URL for the given path.
36
56
 
37
57
  Args:
38
- file_name: The name of the file to create a signed URL for.
58
+ path: The path of the file to create a signed URL for.
39
59
 
40
60
  Returns:
41
61
  str: The signed URL for downloading the file.
@@ -43,11 +63,11 @@ class BaseStorageDriver(ABC):
43
63
  ...
44
64
 
45
65
  @abstractmethod
46
- def delete_file(self, file_name: str) -> None:
66
+ def delete_file(self, path: Path) -> None:
47
67
  """Delete a file from storage.
48
68
 
49
69
  Args:
50
- file_name: The name of the file to delete.
70
+ path: The path of the file to delete.
51
71
  """
52
72
  ...
53
73
 
@@ -60,11 +80,11 @@ class BaseStorageDriver(ABC):
60
80
  """
61
81
  ...
62
82
 
63
- def upload_file(self, file_name: str, file_content: bytes) -> str:
83
+ def upload_file(self, path: Path, file_content: bytes) -> str:
64
84
  """Upload a file to storage.
65
85
 
66
86
  Args:
67
- file_name: The name of the file to upload.
87
+ path: The path of the file to upload.
68
88
  file_content: The file content as bytes.
69
89
 
70
90
  Returns:
@@ -75,7 +95,7 @@ class BaseStorageDriver(ABC):
75
95
  """
76
96
  try:
77
97
  # Get signed upload URL
78
- upload_response = self.create_signed_upload_url(file_name)
98
+ upload_response = self.create_signed_upload_url(path)
79
99
 
80
100
  # Upload the file using the signed URL
81
101
  response = httpx.request(
@@ -87,21 +107,21 @@ class BaseStorageDriver(ABC):
87
107
  response.raise_for_status()
88
108
 
89
109
  # Return the download URL
90
- return self.create_signed_download_url(file_name)
110
+ return self.create_signed_download_url(path)
91
111
  except httpx.HTTPStatusError as e:
92
- msg = f"Failed to upload file {file_name}: {e}"
112
+ msg = f"Failed to upload file {path}: {e}"
93
113
  logger.error(msg)
94
114
  raise RuntimeError(msg) from e
95
115
  except Exception as e:
96
- msg = f"Unexpected error uploading file {file_name}: {e}"
116
+ msg = f"Unexpected error uploading file {path}: {e}"
97
117
  logger.error(msg)
98
118
  raise RuntimeError(msg) from e
99
119
 
100
- def download_file(self, file_name: str) -> bytes:
101
- """Download a file from the bucket.
120
+ def download_file(self, path: Path) -> bytes:
121
+ """Download a file from storage.
102
122
 
103
123
  Args:
104
- file_name: The name of the file to download.
124
+ path: The path of the file to download.
105
125
 
106
126
  Returns:
107
127
  The file content as bytes.
@@ -111,17 +131,17 @@ class BaseStorageDriver(ABC):
111
131
  """
112
132
  try:
113
133
  # Get signed download URL
114
- download_url = self.create_signed_download_url(file_name)
134
+ download_url = self.create_signed_download_url(path)
115
135
 
116
136
  # Download the file
117
137
  response = httpx.get(download_url)
118
138
  response.raise_for_status()
119
139
  except httpx.HTTPStatusError as e:
120
- msg = f"Failed to download file {file_name}: {e}"
140
+ msg = f"Failed to download file {path}: {e}"
121
141
  logger.error(msg)
122
142
  raise RuntimeError(msg) from e
123
143
  except Exception as e:
124
- msg = f"Unexpected error downloading file {file_name}: {e}"
144
+ msg = f"Unexpected error downloading file {path}: {e}"
125
145
  logger.error(msg)
126
146
  raise RuntimeError(msg) from e
127
147
  else:
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ from pathlib import Path
3
4
  from urllib.parse import urljoin
4
5
 
5
6
  import httpx
@@ -14,52 +15,46 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
14
15
 
15
16
  def __init__(
16
17
  self,
18
+ workspace_directory: Path,
17
19
  *,
18
20
  bucket_id: str,
19
- base_url: str | None = None,
20
21
  api_key: str | None = None,
21
- headers: dict | None = None,
22
22
  static_files_directory: str | None = None,
23
+ **kwargs,
23
24
  ) -> None:
24
25
  """Initialize the GriptapeCloudStorageDriver.
25
26
 
26
27
  Args:
28
+ workspace_directory: The base workspace directory path.
27
29
  bucket_id: The ID of the bucket to use. Required.
28
- base_url: The base URL for the Griptape Cloud API. If not provided, it will be retrieved from the environment variable "GT_CLOUD_BASE_URL" or default to "https://cloud.griptape.ai".
29
30
  api_key: The API key for authentication. If not provided, it will be retrieved from the environment variable "GT_CLOUD_API_KEY".
30
- headers: Additional headers to include in the requests. If not provided, the default headers will be used.
31
31
  static_files_directory: The directory path prefix for static files. If provided, file names will be prefixed with this path.
32
+ **kwargs: Additional keyword arguments including base_url and headers.
32
33
  """
33
- self.base_url = (
34
- base_url if base_url is not None else os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
35
- )
34
+ super().__init__(workspace_directory)
35
+
36
+ self.base_url = kwargs.get("base_url") or os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
36
37
  self.api_key = api_key if api_key is not None else os.environ.get("GT_CLOUD_API_KEY")
37
- self.headers = (
38
- headers
39
- if headers is not None
40
- else {
41
- "Authorization": f"Bearer {self.api_key}",
42
- }
43
- )
38
+ self.headers = kwargs.get("headers") or {"Authorization": f"Bearer {self.api_key}"}
44
39
 
45
40
  self.bucket_id = bucket_id
46
41
  self.static_files_directory = static_files_directory
47
42
 
48
- def _get_full_file_path(self, file_name: str) -> str:
49
- """Get the full file path including the static files directory prefix.
43
+ def _get_full_file_path(self, path: Path) -> str:
44
+ """Get the full file path including workspace directory and static files directory prefix.
50
45
 
51
46
  Args:
52
- file_name: The base file name.
47
+ path: The relative path from the workspace directory.
53
48
 
54
49
  Returns:
55
50
  The full file path with static files directory prefix if configured.
56
51
  """
57
52
  if self.static_files_directory:
58
- return f"{self.static_files_directory}/{file_name}"
59
- return file_name
53
+ return f"{self.static_files_directory}/{path}"
54
+ return str(path)
60
55
 
61
- def create_signed_upload_url(self, file_name: str) -> CreateSignedUploadUrlResponse:
62
- full_file_path = self._get_full_file_path(file_name)
56
+ def create_signed_upload_url(self, path: Path) -> CreateSignedUploadUrlResponse:
57
+ full_file_path = self._get_full_file_path(path)
63
58
  self._create_asset(full_file_path)
64
59
 
65
60
  url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
@@ -67,7 +62,7 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
67
62
  response = httpx.post(url, json={"operation": "PUT"}, headers=self.headers)
68
63
  response.raise_for_status()
69
64
  except httpx.HTTPStatusError as e:
70
- msg = f"Failed to create presigned URL for file {file_name}: {e}"
65
+ msg = f"Failed to create presigned URL for file {path}: {e}"
71
66
  logger.error(msg)
72
67
  raise RuntimeError(msg) from e
73
68
 
@@ -75,14 +70,14 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
75
70
 
76
71
  return {"url": response_data["url"], "headers": response_data.get("headers", {}), "method": "PUT"}
77
72
 
78
- def create_signed_download_url(self, file_name: str) -> str:
79
- full_file_path = self._get_full_file_path(file_name)
73
+ def create_signed_download_url(self, path: Path) -> str:
74
+ full_file_path = self._get_full_file_path(path)
80
75
  url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
81
76
  try:
82
77
  response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
83
78
  response.raise_for_status()
84
79
  except httpx.HTTPStatusError as e:
85
- msg = f"Failed to create presigned URL for file {file_name}: {e}"
80
+ msg = f"Failed to create presigned URL for file {path}: {e}"
86
81
  logger.error(msg)
87
82
  raise RuntimeError(msg) from e
88
83
 
@@ -190,19 +185,19 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
190
185
 
191
186
  return response.json().get("buckets", [])
192
187
 
193
- def delete_file(self, file_name: str) -> None:
188
+ def delete_file(self, path: Path) -> None:
194
189
  """Delete a file from the bucket.
195
190
 
196
191
  Args:
197
- file_name: The name of the file to delete.
192
+ path: The path of the file to delete.
198
193
  """
199
- full_file_path = self._get_full_file_path(file_name)
194
+ full_file_path = self._get_full_file_path(path)
200
195
  url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets/{full_file_path}")
201
196
 
202
197
  try:
203
198
  response = httpx.delete(url, headers=self.headers)
204
199
  response.raise_for_status()
205
200
  except httpx.HTTPStatusError as e:
206
- msg = f"Failed to delete file {file_name}: {e}"
201
+ msg = f"Failed to delete file {path}: {e}"
207
202
  logger.error(msg)
208
203
  raise RuntimeError(msg) from e
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import time
3
+ from pathlib import Path
3
4
  from urllib.parse import urljoin
4
5
 
5
6
  import httpx
@@ -12,13 +13,16 @@ logger = logging.getLogger("griptape_nodes")
12
13
  class LocalStorageDriver(BaseStorageDriver):
13
14
  """Stores files using the engine's local static server."""
14
15
 
15
- def __init__(self, base_url: str | None = None) -> None:
16
+ def __init__(self, workspace_directory: Path, base_url: str | None = None) -> None:
16
17
  """Initialize the LocalStorageDriver.
17
18
 
18
19
  Args:
20
+ workspace_directory: The base workspace directory path.
19
21
  base_url: The base URL for the static file server. If not provided, it will be constructed
20
22
  """
21
- from griptape_nodes.app.api import (
23
+ super().__init__(workspace_directory)
24
+
25
+ from griptape_nodes.servers.static import (
22
26
  STATIC_SERVER_ENABLED,
23
27
  STATIC_SERVER_HOST,
24
28
  STATIC_SERVER_PORT,
@@ -33,46 +37,46 @@ class LocalStorageDriver(BaseStorageDriver):
33
37
  else:
34
38
  self.base_url = base_url
35
39
 
36
- def create_signed_upload_url(self, file_name: str) -> CreateSignedUploadUrlResponse:
40
+ def create_signed_upload_url(self, path: Path) -> CreateSignedUploadUrlResponse:
37
41
  static_url = urljoin(self.base_url, "/static-upload-urls")
38
42
  try:
39
- response = httpx.post(static_url, json={"file_name": file_name})
43
+ response = httpx.post(static_url, json={"file_path": str(path)})
40
44
  response.raise_for_status()
41
45
  except httpx.HTTPStatusError as e:
42
- msg = f"Failed to create presigned URL for file {file_name}: {e}"
46
+ msg = f"Failed to create presigned URL for file {path}: {e}"
43
47
  logger.error(msg)
44
48
  raise RuntimeError(msg) from e
45
49
 
46
50
  response_data = response.json()
47
51
  url = response_data.get("url")
48
52
  if url is None:
49
- msg = f"Failed to create presigned URL for file {file_name}: {response_data}"
53
+ msg = f"Failed to create presigned URL for file {path}: {response_data}"
50
54
  logger.error(msg)
51
55
  raise ValueError(msg)
52
56
 
53
57
  return {"url": url, "headers": response_data.get("headers", {}), "method": "PUT"}
54
58
 
55
- def create_signed_download_url(self, file_name: str) -> str:
56
- # The base_url already includes the /static path, so just append the filename
57
- url = f"{self.base_url}/{file_name}"
59
+ def create_signed_download_url(self, path: Path) -> str:
60
+ # The base_url already includes the /static path, so just append the path
61
+ url = f"{self.base_url}/{path}"
58
62
  # Add a cache-busting query parameter to the URL so that the browser always reloads the file
59
63
  cache_busted_url = f"{url}?t={int(time.time())}"
60
64
  return cache_busted_url
61
65
 
62
- def delete_file(self, file_name: str) -> None:
66
+ def delete_file(self, path: Path) -> None:
63
67
  """Delete a file from local storage.
64
68
 
65
69
  Args:
66
- file_name: The name of the file to delete.
70
+ path: The path of the file to delete.
67
71
  """
68
72
  # Use the static server's delete endpoint
69
- delete_url = urljoin(self.base_url, f"/static-files/{file_name}")
73
+ delete_url = urljoin(self.base_url, f"/static-files/{path}")
70
74
 
71
75
  try:
72
76
  response = httpx.delete(delete_url)
73
77
  response.raise_for_status()
74
78
  except httpx.HTTPStatusError as e:
75
- msg = f"Failed to delete file {file_name}: {e}"
79
+ msg = f"Failed to delete file {path}: {e}"
76
80
  logger.error(msg)
77
81
  raise RuntimeError(msg) from e
78
82
 
@@ -22,6 +22,7 @@ from griptape_nodes.exe_types.core_types import (
22
22
  ParameterMode,
23
23
  ParameterTypeBuiltin,
24
24
  )
25
+ from griptape_nodes.exe_types.param_components.execution_status_component import ExecutionStatusComponent
25
26
  from griptape_nodes.exe_types.type_validator import TypeValidator
26
27
  from griptape_nodes.retained_mode.events.base_events import (
27
28
  ExecutionEvent,
@@ -715,6 +716,9 @@ class BaseNode(ABC):
715
716
  raise KeyError(err)
716
717
 
717
718
  def get_next_control_output(self) -> Parameter | None:
719
+ # The default behavior for nodes is to find the first control output found.
720
+ # Advanced nodes can override this behavior (e.g., nodes that have multiple possible
721
+ # control paths).
718
722
  for param in self.parameters:
719
723
  if (
720
724
  ParameterTypeBuiltin.CONTROL_TYPE.value == param.output_type
@@ -1150,28 +1154,185 @@ class TrackedParameterOutputValues(dict[str, Any]):
1150
1154
 
1151
1155
  class ControlNode(BaseNode):
1152
1156
  # Control Nodes may have one Control Input Port and at least one Control Output Port
1157
+ def __init__(
1158
+ self,
1159
+ name: str,
1160
+ metadata: dict[Any, Any] | None = None,
1161
+ input_control_name: str | None = None,
1162
+ output_control_name: str | None = None,
1163
+ ) -> None:
1164
+ super().__init__(name, metadata=metadata)
1165
+ self.control_parameter_in = ControlParameterInput(
1166
+ display_name=input_control_name if input_control_name is not None else "Flow In"
1167
+ )
1168
+ self.control_parameter_out = ControlParameterOutput(
1169
+ display_name=output_control_name if output_control_name is not None else "Flow Out"
1170
+ )
1171
+
1172
+ self.add_parameter(self.control_parameter_in)
1173
+ self.add_parameter(self.control_parameter_out)
1174
+
1175
+
1176
+ class DataNode(BaseNode):
1153
1177
  def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
1154
1178
  super().__init__(name, metadata=metadata)
1155
- control_parameter_in = ControlParameterInput()
1156
- control_parameter_out = ControlParameterOutput()
1157
1179
 
1158
- self.add_parameter(control_parameter_in)
1159
- self.add_parameter(control_parameter_out)
1180
+ # Create control parameters like ControlNode, but initialize them as hidden
1181
+ # This allows the user to turn a DataNode "into" a Control Node; useful when
1182
+ # in situations like within a For Loop.
1183
+ self.control_parameter_in = ControlParameterInput()
1184
+ self.control_parameter_out = ControlParameterOutput()
1160
1185
 
1161
- def get_next_control_output(self) -> Parameter | None:
1162
- for param in self.parameters:
1163
- if (
1164
- ParameterTypeBuiltin.CONTROL_TYPE.value == param.output_type
1165
- and ParameterMode.OUTPUT in param.allowed_modes
1166
- ):
1167
- return param
1168
- return None
1186
+ # Hide the control parameters by default
1187
+ self.control_parameter_in.ui_options["hide"] = True
1188
+ self.control_parameter_out.ui_options["hide"] = True
1169
1189
 
1190
+ self.add_parameter(self.control_parameter_in)
1191
+ self.add_parameter(self.control_parameter_out)
1192
+
1193
+
1194
+ class SuccessFailureNode(BaseNode):
1195
+ """Base class for nodes that have success/failure branching with control outputs.
1196
+
1197
+ This class provides:
1198
+ - Control input parameter
1199
+ - Two control outputs: success ("exec_out") and failure ("failure")
1200
+ - Execution state tracking for control flow routing
1201
+ - Helper method to check outgoing connections
1202
+ - Helper method to create standard status output parameters
1203
+ """
1170
1204
 
1171
- class DataNode(BaseNode):
1172
1205
  def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
1173
1206
  super().__init__(name, metadata=metadata)
1174
1207
 
1208
+ # Track execution state for control flow routing
1209
+ self._execution_succeeded: bool | None = None
1210
+
1211
+ # Add control input parameter
1212
+ self.control_parameter_in = ControlParameterInput()
1213
+ self.add_parameter(self.control_parameter_in)
1214
+
1215
+ # Add success control output (uses default "exec_out" name)
1216
+ self.control_parameter_out = ControlParameterOutput(
1217
+ display_name="Succeeded", tooltip="Control path when the operation succeeds"
1218
+ )
1219
+ self.add_parameter(self.control_parameter_out)
1220
+
1221
+ # Add failure control output
1222
+ self.failure_output = ControlParameterOutput(
1223
+ name="failure",
1224
+ display_name="Failed",
1225
+ tooltip="Control path when the operation fails",
1226
+ )
1227
+ self.add_parameter(self.failure_output)
1228
+
1229
+ def get_next_control_output(self) -> Parameter | None:
1230
+ """Determine which control output to follow based on execution result."""
1231
+ if self._execution_succeeded is None:
1232
+ # Execution hasn't completed yet
1233
+ self.stop_flow = True
1234
+ return None
1235
+
1236
+ if self._execution_succeeded:
1237
+ return self.control_parameter_out
1238
+ return self.failure_output
1239
+
1240
+ def _has_outgoing_connections(self, parameter: Parameter) -> bool:
1241
+ """Check if a specific parameter has outgoing connections."""
1242
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
1243
+
1244
+ connections = GriptapeNodes.FlowManager().get_connections()
1245
+
1246
+ # Check if node has any outgoing connections
1247
+ node_connections = connections.outgoing_index.get(self.name)
1248
+ if node_connections is None:
1249
+ return False
1250
+
1251
+ # Check if this specific parameter has any outgoing connections
1252
+ param_connections = node_connections.get(parameter.name, [])
1253
+ return len(param_connections) > 0
1254
+
1255
+ def _create_status_parameters(
1256
+ self,
1257
+ result_details_tooltip: str = "Details about the operation result",
1258
+ result_details_placeholder: str = "Details on the operation will be presented here.",
1259
+ ) -> None:
1260
+ """Create and add standard status output parameters in a collapsible group.
1261
+
1262
+ This method creates a "Status" ParameterGroup and immediately adds it to the node.
1263
+ Nodes that use this are responsible for calling this at their desired location
1264
+ in their class constructor.
1265
+
1266
+ Creates and adds:
1267
+ - was_successful: Boolean parameter indicating success/failure
1268
+ - result_details: String parameter with operation details
1269
+
1270
+ Args:
1271
+ result_details_tooltip: Custom tooltip for result_details parameter
1272
+ result_details_placeholder: Custom placeholder text for result_details parameter
1273
+ """
1274
+ # Create status component with OUTPUT modes for SuccessFailureNode
1275
+ self.status_component = ExecutionStatusComponent(
1276
+ self,
1277
+ was_successful_modes={ParameterMode.OUTPUT},
1278
+ result_details_modes={ParameterMode.OUTPUT},
1279
+ parameter_group_initially_collapsed=True,
1280
+ result_details_tooltip=result_details_tooltip,
1281
+ result_details_placeholder=result_details_placeholder,
1282
+ )
1283
+
1284
+ def _clear_execution_status(self) -> None:
1285
+ """Clear execution status and reset status parameters.
1286
+
1287
+ This method should be called at the start of process() to reset the node state.
1288
+ """
1289
+ self._execution_succeeded = None
1290
+ self.status_component.clear_execution_status("Beginning execution...")
1291
+
1292
+ def _set_status_results(self, *, was_successful: bool, result_details: str) -> None:
1293
+ """Set status results and update execution state.
1294
+
1295
+ This method should be called from the process() method to communicate success or failure.
1296
+ It sets the execution state for control flow routing and updates the status output parameters.
1297
+
1298
+ Args:
1299
+ was_successful: Whether the operation succeeded
1300
+ result_details: Details about the operation result
1301
+ """
1302
+ self._execution_succeeded = was_successful
1303
+ self.status_component.set_execution_result(was_successful=was_successful, result_details=result_details)
1304
+
1305
+ def _handle_failure_exception(self, exception: Exception) -> None:
1306
+ """Handle failure exceptions based on whether failure output is connected.
1307
+
1308
+ If the failure output has outgoing connections, logs the error and continues execution
1309
+ to allow graceful failure handling. If no connections exist, raises the exception
1310
+ to crash the flow and provide immediate feedback.
1311
+
1312
+ Args:
1313
+ exception: The exception that caused the failure
1314
+ """
1315
+ if self._has_outgoing_connections(self.failure_output):
1316
+ # User has connected something to Failed output, they want to handle errors gracefully
1317
+ logger.error(
1318
+ "Error in node '%s': %s. Continuing execution since failure output is connected for graceful handling.",
1319
+ self.name,
1320
+ exception,
1321
+ )
1322
+ else:
1323
+ # No graceful handling, raise the exception to crash the flow
1324
+ raise exception
1325
+
1326
+ def validate_before_workflow_run(self) -> list[Exception] | None:
1327
+ """Clear result details before workflow runs to avoid confusion from previous sessions."""
1328
+ self._set_status_results(was_successful=False, result_details="<Results will appear when the node executes>")
1329
+ return super().validate_before_workflow_run()
1330
+
1331
+ def validate_before_node_run(self) -> list[Exception] | None:
1332
+ """Clear result details before node runs to avoid confusion from previous sessions."""
1333
+ self._set_status_results(was_successful=False, result_details="<Results will appear when the node executes>")
1334
+ return super().validate_before_node_run()
1335
+
1175
1336
 
1176
1337
  class StartNode(BaseNode):
1177
1338
  def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
@@ -1183,7 +1344,51 @@ class EndNode(BaseNode):
1183
1344
  # TODO: https://github.com/griptape-ai/griptape-nodes/issues/854
1184
1345
  def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
1185
1346
  super().__init__(name, metadata)
1186
- self.add_parameter(ControlParameterInput())
1347
+
1348
+ # Add dual control inputs
1349
+ self.succeeded_control = ControlParameterInput(
1350
+ display_name="Succeeded", tooltip="Control path when the flow completed successfully"
1351
+ )
1352
+ self.failed_control = ControlParameterInput(
1353
+ name="failed", display_name="Failed", tooltip="Control path when the flow failed"
1354
+ )
1355
+
1356
+ self.add_parameter(self.succeeded_control)
1357
+ self.add_parameter(self.failed_control)
1358
+
1359
+ # Create status component with INPUT and PROPERTY modes
1360
+ self.status_component = ExecutionStatusComponent(
1361
+ self,
1362
+ was_successful_modes={ParameterMode.PROPERTY},
1363
+ result_details_modes={ParameterMode.INPUT},
1364
+ parameter_group_initially_collapsed=False,
1365
+ result_details_placeholder="Details about the completion or failure will be shown here.",
1366
+ )
1367
+
1368
+ def process(self) -> None:
1369
+ # Detect which control input was used to enter this node and determine success status
1370
+ match self._entry_control_parameter:
1371
+ case self.succeeded_control:
1372
+ was_successful = True
1373
+ status_prefix = "[SUCCEEDED]"
1374
+ case self.failed_control:
1375
+ was_successful = False
1376
+ status_prefix = "[FAILED]"
1377
+ case _:
1378
+ # No specific success/failure connection provided, assume success
1379
+ was_successful = True
1380
+ status_prefix = "[SUCCEEDED] No connection provided for success or failure, assuming successful"
1381
+
1382
+ # Get result details and format the final message
1383
+ result_details_value = self.get_parameter_value("result_details")
1384
+ if result_details_value and self._entry_control_parameter in (self.succeeded_control, self.failed_control):
1385
+ details = f"{status_prefix}\n{result_details_value}"
1386
+ elif self._entry_control_parameter in (self.succeeded_control, self.failed_control):
1387
+ details = f"{status_prefix}\nNo details supplied by flow"
1388
+ else:
1389
+ details = status_prefix
1390
+
1391
+ self.status_component.set_execution_result(was_successful=was_successful, result_details=details)
1187
1392
 
1188
1393
 
1189
1394
  class StartLoopNode(BaseNode):
@@ -0,0 +1 @@
1
+ """Parameter components for reusable node functionality."""