griptape-nodes 0.52.1__py3-none-any.whl → 0.54.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 (71) hide show
  1. griptape_nodes/__init__.py +8 -942
  2. griptape_nodes/__main__.py +6 -0
  3. griptape_nodes/app/app.py +48 -86
  4. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +35 -5
  5. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +15 -1
  6. griptape_nodes/cli/__init__.py +1 -0
  7. griptape_nodes/cli/commands/__init__.py +1 -0
  8. griptape_nodes/cli/commands/config.py +74 -0
  9. griptape_nodes/cli/commands/engine.py +80 -0
  10. griptape_nodes/cli/commands/init.py +550 -0
  11. griptape_nodes/cli/commands/libraries.py +96 -0
  12. griptape_nodes/cli/commands/models.py +504 -0
  13. griptape_nodes/cli/commands/self.py +120 -0
  14. griptape_nodes/cli/main.py +56 -0
  15. griptape_nodes/cli/shared.py +75 -0
  16. griptape_nodes/common/__init__.py +1 -0
  17. griptape_nodes/common/directed_graph.py +71 -0
  18. griptape_nodes/drivers/storage/base_storage_driver.py +40 -20
  19. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +24 -29
  20. griptape_nodes/drivers/storage/local_storage_driver.py +23 -14
  21. griptape_nodes/exe_types/core_types.py +60 -2
  22. griptape_nodes/exe_types/node_types.py +257 -38
  23. griptape_nodes/exe_types/param_components/__init__.py +1 -0
  24. griptape_nodes/exe_types/param_components/execution_status_component.py +138 -0
  25. griptape_nodes/machines/control_flow.py +195 -94
  26. griptape_nodes/machines/dag_builder.py +207 -0
  27. griptape_nodes/machines/fsm.py +10 -1
  28. griptape_nodes/machines/parallel_resolution.py +558 -0
  29. griptape_nodes/machines/{node_resolution.py → sequential_resolution.py} +30 -57
  30. griptape_nodes/node_library/library_registry.py +34 -1
  31. griptape_nodes/retained_mode/events/app_events.py +5 -1
  32. griptape_nodes/retained_mode/events/base_events.py +9 -9
  33. griptape_nodes/retained_mode/events/config_events.py +30 -0
  34. griptape_nodes/retained_mode/events/execution_events.py +2 -2
  35. griptape_nodes/retained_mode/events/model_events.py +296 -0
  36. griptape_nodes/retained_mode/events/node_events.py +4 -3
  37. griptape_nodes/retained_mode/griptape_nodes.py +34 -12
  38. griptape_nodes/retained_mode/managers/agent_manager.py +23 -5
  39. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/config_manager.py +44 -3
  41. griptape_nodes/retained_mode/managers/context_manager.py +6 -5
  42. griptape_nodes/retained_mode/managers/event_manager.py +8 -2
  43. griptape_nodes/retained_mode/managers/flow_manager.py +150 -206
  44. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +1 -1
  45. griptape_nodes/retained_mode/managers/library_manager.py +35 -25
  46. griptape_nodes/retained_mode/managers/model_manager.py +1107 -0
  47. griptape_nodes/retained_mode/managers/node_manager.py +102 -220
  48. griptape_nodes/retained_mode/managers/object_manager.py +11 -5
  49. griptape_nodes/retained_mode/managers/os_manager.py +28 -13
  50. griptape_nodes/retained_mode/managers/secrets_manager.py +8 -4
  51. griptape_nodes/retained_mode/managers/settings.py +116 -7
  52. griptape_nodes/retained_mode/managers/static_files_manager.py +85 -12
  53. griptape_nodes/retained_mode/managers/sync_manager.py +17 -9
  54. griptape_nodes/retained_mode/managers/workflow_manager.py +186 -192
  55. griptape_nodes/retained_mode/retained_mode.py +19 -0
  56. griptape_nodes/servers/__init__.py +1 -0
  57. griptape_nodes/{mcp_server/server.py → servers/mcp.py} +1 -1
  58. griptape_nodes/{app/api.py → servers/static.py} +43 -40
  59. griptape_nodes/traits/add_param_button.py +1 -1
  60. griptape_nodes/traits/button.py +334 -6
  61. griptape_nodes/traits/color_picker.py +66 -0
  62. griptape_nodes/traits/multi_options.py +188 -0
  63. griptape_nodes/traits/numbers_selector.py +77 -0
  64. griptape_nodes/traits/options.py +93 -2
  65. griptape_nodes/traits/traits.json +4 -0
  66. griptape_nodes/utils/async_utils.py +31 -0
  67. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.54.0.dist-info}/METADATA +4 -1
  68. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.54.0.dist-info}/RECORD +71 -48
  69. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.54.0.dist-info}/WHEEL +1 -1
  70. /griptape_nodes/{mcp_server → servers}/ws_request_manager.py +0 -0
  71. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.54.0.dist-info}/entry_points.txt +0 -0
@@ -10,6 +10,7 @@ from griptape_nodes.exe_types.core_types import (
10
10
  from griptape_nodes.exe_types.flow import ControlFlow
11
11
  from griptape_nodes.exe_types.node_types import BaseNode
12
12
  from griptape_nodes.retained_mode.events.base_events import (
13
+ ResultDetails,
13
14
  ResultPayload,
14
15
  )
15
16
  from griptape_nodes.retained_mode.events.execution_events import (
@@ -50,7 +51,10 @@ class ObjectManager:
50
51
  def on_rename_object_request(self, request: RenameObjectRequest) -> ResultPayload:
51
52
  # Does the source object exist?
52
53
  if request.object_name == request.requested_name:
53
- return RenameObjectResultSuccess(final_name=request.requested_name)
54
+ return RenameObjectResultSuccess(
55
+ final_name=request.requested_name,
56
+ result_details=f"Object '{request.requested_name}' already has the requested name",
57
+ )
54
58
  source_obj = self.attempt_get_object_by_name(request.object_name)
55
59
  if source_obj is None:
56
60
  details = f"Attempted to rename object '{request.object_name}', but no object of that name could be found."
@@ -97,8 +101,11 @@ class ObjectManager:
97
101
  if final_name != request.requested_name:
98
102
  details += " WARNING: Originally requested the name '{request.requested_name}', but that was taken."
99
103
  log_level = logging.WARNING
100
- logger.log(level=log_level, msg=details)
101
- return RenameObjectResultSuccess(final_name=final_name)
104
+ if log_level == logging.WARNING:
105
+ result_details = ResultDetails(message=details, level=logging.WARNING)
106
+ else:
107
+ result_details = details
108
+ return RenameObjectResultSuccess(final_name=final_name, result_details=result_details)
102
109
 
103
110
  def on_clear_all_object_state_request(self, request: ClearAllObjectStateRequest) -> ResultPayload: # noqa: C901
104
111
  if not request.i_know_what_im_doing:
@@ -148,8 +155,7 @@ class ObjectManager:
148
155
  GriptapeNodes.VariablesManager().on_clear_object_state()
149
156
 
150
157
  details = "Successfully cleared all object state (deleted everything)."
151
- logger.debug(details)
152
- return ClearAllObjectStateResultSuccess()
158
+ return ClearAllObjectStateResultSuccess(result_details=details)
153
159
 
154
160
  def get_filtered_subset[T](
155
161
  self,
@@ -1,4 +1,5 @@
1
1
  import base64
2
+ import logging
2
3
  import mimetypes
3
4
  import os
4
5
  import shutil
@@ -11,7 +12,7 @@ from typing import Any
11
12
  from binaryornot.check import is_binary
12
13
  from rich.console import Console
13
14
 
14
- from griptape_nodes.retained_mode.events.base_events import ResultPayload
15
+ from griptape_nodes.retained_mode.events.base_events import ResultDetails, ResultPayload
15
16
  from griptape_nodes.retained_mode.events.os_events import (
16
17
  CreateFileRequest,
17
18
  CreateFileResultFailure,
@@ -304,7 +305,7 @@ class OSManager:
304
305
  logger.info(details)
305
306
  return OpenAssociatedFileResultFailure(result_details=details)
306
307
 
307
- return OpenAssociatedFileResultSuccess()
308
+ return OpenAssociatedFileResultSuccess(result_details="File opened successfully in associated application.")
308
309
  except subprocess.CalledProcessError as e:
309
310
  details = (
310
311
  f"Process error when opening file: return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
@@ -399,11 +400,17 @@ class OSManager:
399
400
  if request.workspace_only:
400
401
  # In workspace mode, return relative path if within workspace, absolute if outside
401
402
  return ListDirectoryResultSuccess(
402
- entries=entries, current_path=str(relative_or_abs_path), is_workspace_path=is_workspace_path
403
+ entries=entries,
404
+ current_path=str(relative_or_abs_path),
405
+ is_workspace_path=is_workspace_path,
406
+ result_details="Directory listing retrieved successfully.",
403
407
  )
404
408
  # In system-wide mode, always return the full absolute path
405
409
  return ListDirectoryResultSuccess(
406
- entries=entries, current_path=str(directory), is_workspace_path=is_workspace_path
410
+ entries=entries,
411
+ current_path=str(directory),
412
+ is_workspace_path=is_workspace_path,
413
+ result_details="Directory listing retrieved successfully.",
407
414
  )
408
415
 
409
416
  except Exception as e:
@@ -430,6 +437,7 @@ class OSManager:
430
437
  mime_type=mime_type,
431
438
  encoding=encoding,
432
439
  compression_encoding=compression_encoding,
440
+ result_details="File read successfully.",
433
441
  )
434
442
 
435
443
  except (ValueError, FileNotFoundError) as e:
@@ -509,15 +517,14 @@ class OSManager:
509
517
 
510
518
  # Check if file is already in the static files directory
511
519
  config_manager = GriptapeNodes.ConfigManager()
512
- static_files_directory = config_manager.get_config_value("static_files_directory", default="staticfiles")
513
- static_dir = config_manager.workspace_path / static_files_directory
520
+ static_dir = config_manager.workspace_path
514
521
 
515
522
  try:
516
523
  # Check if file is within the static files directory
517
524
  file_relative_to_static = file_path.relative_to(static_dir)
518
525
  # File is in static directory, construct URL directly
519
- static_url = f"http://localhost:8124/static/{file_relative_to_static}"
520
- msg = f"Image already in static directory, returning URL: {static_url}"
526
+ static_url = f"http://localhost:8124/workspace/{file_relative_to_static}"
527
+ msg = f"Image already in workspace directory, returning URL: {static_url}"
521
528
  logger.debug(msg)
522
529
  except ValueError:
523
530
  # File is not in static directory, create small preview
@@ -759,8 +766,9 @@ class OSManager:
759
766
  # Check if it already exists - warn but treat as success
760
767
  if file_path.exists():
761
768
  msg = f"Path already exists: {file_path}"
762
- logger.warning(msg)
763
- return CreateFileResultSuccess(created_path=str(file_path))
769
+ return CreateFileResultSuccess(
770
+ created_path=str(file_path), result_details=ResultDetails(message=msg, level=logging.WARNING)
771
+ )
764
772
 
765
773
  # Create parent directories if needed
766
774
  file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -777,7 +785,10 @@ class OSManager:
777
785
  file_path.touch()
778
786
  logger.info("Created empty file: %s", file_path)
779
787
 
780
- return CreateFileResultSuccess(created_path=str(file_path))
788
+ return CreateFileResultSuccess(
789
+ created_path=str(file_path),
790
+ result_details=f"{'Directory' if request.is_directory else 'File'} created successfully at {file_path}",
791
+ )
781
792
 
782
793
  except Exception as e:
783
794
  path_info = request.get_full_path() if hasattr(request, "get_full_path") else str(request.path)
@@ -820,9 +831,13 @@ class OSManager:
820
831
 
821
832
  # Perform the rename operation
822
833
  old_path.rename(new_path)
823
- logger.info("Renamed: %s -> %s", old_path, new_path)
834
+ details = f"Renamed: {old_path} -> {new_path}"
824
835
 
825
- return RenameFileResultSuccess(old_path=str(old_path), new_path=str(new_path))
836
+ return RenameFileResultSuccess(
837
+ old_path=str(old_path),
838
+ new_path=str(new_path),
839
+ result_details=ResultDetails(message=details, level=logging.INFO),
840
+ )
826
841
 
827
842
  except Exception as e:
828
843
  msg = f"Failed to rename {request.old_path} to {request.new_path}: {e}"
@@ -60,7 +60,9 @@ class SecretsManager:
60
60
  logger.error(details)
61
61
  return GetSecretValueResultFailure(result_details=details)
62
62
 
63
- return GetSecretValueResultSuccess(value=secret_value)
63
+ return GetSecretValueResultSuccess(
64
+ value=secret_value, result_details=f"Successfully retrieved secret value for key: {secret_key}"
65
+ )
64
66
 
65
67
  def on_handle_set_secret_request(self, request: SetSecretValueRequest) -> ResultPayload:
66
68
  secret_name = SecretsManager._apply_secret_name_compliance(request.key)
@@ -77,12 +79,14 @@ class SecretsManager:
77
79
 
78
80
  self.set_secret(secret_name, secret_value)
79
81
 
80
- return SetSecretValueResultSuccess()
82
+ return SetSecretValueResultSuccess(result_details=f"Successfully set secret value for key: {secret_name}")
81
83
 
82
84
  def on_handle_get_all_secret_values_request(self, request: GetAllSecretValuesRequest) -> ResultPayload: # noqa: ARG002
83
85
  secret_values = dotenv_values(ENV_VAR_PATH)
84
86
 
85
- return GetAllSecretValuesResultSuccess(values=secret_values)
87
+ return GetAllSecretValuesResultSuccess(
88
+ values=secret_values, result_details=f"Successfully retrieved {len(secret_values)} secret values"
89
+ )
86
90
 
87
91
  def on_handle_delete_secret_value_request(self, request: DeleteSecretValueRequest) -> ResultPayload:
88
92
  secret_name = SecretsManager._apply_secret_name_compliance(request.key)
@@ -101,7 +105,7 @@ class SecretsManager:
101
105
 
102
106
  logger.info("Secret '%s' deleted.", secret_name)
103
107
 
104
- return DeleteSecretValueResultSuccess()
108
+ return DeleteSecretValueResultSuccess(result_details=f"Successfully deleted secret: {secret_name}")
105
109
 
106
110
  def get_secret(self, secret_name: str, *, should_error_on_not_found: bool = True) -> str | None:
107
111
  """Return the secret value with the following search precedence (highest to lowest priority).
@@ -1,12 +1,65 @@
1
+ from enum import StrEnum
1
2
  from pathlib import Path
2
3
  from typing import Any, Literal
3
4
 
4
- from pydantic import BaseModel, ConfigDict, Field
5
+ from pydantic import BaseModel, ConfigDict, field_validator
6
+ from pydantic import Field as PydanticField
7
+
8
+
9
+ class Category(BaseModel):
10
+ """A category with name and optional description."""
11
+
12
+ name: str
13
+ description: str | None = None
14
+
15
+ def __str__(self) -> str:
16
+ return self.name
17
+
18
+
19
+ # Predefined categories to avoid repetition
20
+ FILE_SYSTEM = Category(name="File System", description="Directories and file paths for the application")
21
+ APPLICATION_EVENTS = Category(name="Application Events", description="Configuration for application lifecycle events")
22
+ API_KEYS = Category(name="API Keys", description="API keys and authentication credentials")
23
+ EXECUTION = Category(name="Execution", description="Workflow execution and processing settings")
24
+ STORAGE = Category(name="Storage", description="Data storage and persistence configuration")
25
+ SYSTEM_REQUIREMENTS = Category(name="System Requirements", description="System resource requirements and limits")
26
+
27
+
28
+ def Field(category: str | Category = "General", **kwargs) -> Any:
29
+ """Enhanced Field with default category that can be overridden."""
30
+ if "json_schema_extra" not in kwargs:
31
+ # Convert Category to dict or use string directly
32
+ if isinstance(category, Category):
33
+ category_dict = {"name": category.name}
34
+ if category.description:
35
+ category_dict["description"] = category.description
36
+ kwargs["json_schema_extra"] = {"category": category_dict}
37
+ else:
38
+ kwargs["json_schema_extra"] = {"category": category}
39
+ return PydanticField(**kwargs)
40
+
41
+
42
+ class WorkflowExecutionMode(StrEnum):
43
+ """Execution type for node processing."""
44
+
45
+ SEQUENTIAL = "sequential"
46
+ PARALLEL = "parallel"
47
+
48
+
49
+ class LogLevel(StrEnum):
50
+ """Logging level for the application."""
51
+
52
+ CRITICAL = "CRITICAL"
53
+ ERROR = "ERROR"
54
+ WARNING = "WARNING"
55
+ INFO = "INFO"
56
+ DEBUG = "DEBUG"
5
57
 
6
58
 
7
59
  class AppInitializationComplete(BaseModel):
8
60
  libraries_to_register: list[str] = Field(default_factory=list)
9
61
  workflows_to_register: list[str] = Field(default_factory=list)
62
+ models_to_download: list[str] = Field(default_factory=list)
10
63
 
11
64
 
12
65
  class AppEvents(BaseModel):
@@ -41,17 +94,26 @@ class AppEvents(BaseModel):
41
94
  class Settings(BaseModel):
42
95
  model_config = ConfigDict(extra="allow")
43
96
 
44
- workspace_directory: str = Field(default=str(Path().cwd() / "GriptapeNodes"))
97
+ workspace_directory: str = Field(
98
+ category=FILE_SYSTEM,
99
+ default=str(Path().cwd() / "GriptapeNodes"),
100
+ )
45
101
  static_files_directory: str = Field(
102
+ category=FILE_SYSTEM,
46
103
  default="staticfiles",
47
104
  description="Path to the static files directory, relative to the workspace directory.",
48
105
  )
49
106
  sandbox_library_directory: str = Field(
107
+ category=FILE_SYSTEM,
50
108
  default="sandbox_library",
51
109
  description="Path to the sandbox library directory (useful while developing nodes). If presented as just a directory (e.g., 'sandbox_library') it will be interpreted as being relative to the workspace directory.",
52
110
  )
53
- app_events: AppEvents = Field(default_factory=AppEvents)
111
+ app_events: AppEvents = Field(
112
+ category=APPLICATION_EVENTS,
113
+ default_factory=AppEvents,
114
+ )
54
115
  nodes: dict[str, Any] = Field(
116
+ category=API_KEYS,
55
117
  default_factory=lambda: {
56
118
  "Griptape": {"GT_CLOUD_API_KEY": "$GT_CLOUD_API_KEY"},
57
119
  "OpenAI": {"OPENAI_API_KEY": "$OPENAI_API_KEY"},
@@ -86,18 +148,65 @@ class Settings(BaseModel):
86
148
  },
87
149
  "Tavily": {"TAVILY_API_KEY": "$TAVILY_API_KEY"},
88
150
  "Serper": {"SERPER_API_KEY": "$SERPER_API_KEY"},
89
- }
151
+ },
152
+ )
153
+ log_level: LogLevel = Field(category=EXECUTION, default=LogLevel.INFO)
154
+ workflow_execution_mode: WorkflowExecutionMode = Field(
155
+ category=EXECUTION,
156
+ default=WorkflowExecutionMode.SEQUENTIAL,
157
+ description="Workflow execution mode for node processing",
158
+ )
159
+
160
+ @field_validator("workflow_execution_mode", mode="before")
161
+ @classmethod
162
+ def validate_workflow_execution_mode(cls, v: Any) -> WorkflowExecutionMode:
163
+ """Convert string values to WorkflowExecutionMode enum."""
164
+ if isinstance(v, str):
165
+ try:
166
+ return WorkflowExecutionMode(v.lower())
167
+ except ValueError:
168
+ # Return default if invalid string
169
+ return WorkflowExecutionMode.SEQUENTIAL
170
+ elif isinstance(v, WorkflowExecutionMode):
171
+ return v
172
+ else:
173
+ # Return default for any other type
174
+ return WorkflowExecutionMode.SEQUENTIAL
175
+
176
+ @field_validator("log_level", mode="before")
177
+ @classmethod
178
+ def validate_log_level(cls, v: Any) -> LogLevel:
179
+ """Convert string values to LogLevel enum."""
180
+ if isinstance(v, str):
181
+ try:
182
+ return LogLevel(v.upper())
183
+ except ValueError:
184
+ # Return default if invalid string
185
+ return LogLevel.INFO
186
+ elif isinstance(v, LogLevel):
187
+ return v
188
+ else:
189
+ # Return default for any other type
190
+ return LogLevel.INFO
191
+
192
+ max_nodes_in_parallel: int | None = Field(
193
+ category=EXECUTION,
194
+ default=5,
195
+ description="Maximum number of nodes executing at a time for parallel execution.",
90
196
  )
91
- log_level: str = Field(default="INFO")
92
- storage_backend: Literal["local", "gtc"] = Field(default="local")
197
+ storage_backend: Literal["local", "gtc"] = Field(category=STORAGE, default="local")
93
198
  minimum_disk_space_gb_libraries: float = Field(
199
+ category=SYSTEM_REQUIREMENTS,
94
200
  default=10.0,
95
201
  description="Minimum disk space in GB required for library installation and virtual environment operations",
96
202
  )
97
203
  minimum_disk_space_gb_workflows: float = Field(
98
- default=1.0, description="Minimum disk space in GB required for saving workflows"
204
+ category=SYSTEM_REQUIREMENTS,
205
+ default=1.0,
206
+ description="Minimum disk space in GB required for saving workflows",
99
207
  )
100
208
  synced_workflows_directory: str = Field(
209
+ category=FILE_SYSTEM,
101
210
  default="synced_workflows",
102
211
  description="Path to the synced workflows directory, relative to the workspace directory.",
103
212
  )
@@ -1,6 +1,8 @@
1
1
  import base64
2
2
  import binascii
3
3
  import logging
4
+ import threading
5
+ from pathlib import Path
4
6
 
5
7
  import httpx
6
8
  from xdg_base_dirs import xdg_config_home
@@ -8,6 +10,7 @@ from xdg_base_dirs import xdg_config_home
8
10
  from griptape_nodes.drivers.storage import StorageBackend
9
11
  from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
10
12
  from griptape_nodes.drivers.storage.local_storage_driver import LocalStorageDriver
13
+ from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
11
14
  from griptape_nodes.retained_mode.events.static_file_events import (
12
15
  CreateStaticFileDownloadUrlRequest,
13
16
  CreateStaticFileDownloadUrlResultFailure,
@@ -22,6 +25,7 @@ from griptape_nodes.retained_mode.events.static_file_events import (
22
25
  from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
23
26
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
24
27
  from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
28
+ from griptape_nodes.servers.static import start_static_server
25
29
 
26
30
  logger = logging.getLogger("griptape_nodes")
27
31
 
@@ -46,9 +50,10 @@ class StaticFilesManager:
46
50
  """
47
51
  self.config_manager = config_manager
48
52
 
49
- storage_backend = config_manager.get_config_value("storage_backend", default=StorageBackend.LOCAL)
53
+ self.storage_backend = config_manager.get_config_value("storage_backend", default=StorageBackend.LOCAL)
54
+ workspace_directory = Path(config_manager.get_config_value("workspace_directory"))
50
55
 
51
- match storage_backend:
56
+ match self.storage_backend:
52
57
  case StorageBackend.GTC:
53
58
  bucket_id = secrets_manager.get_secret("GT_CLOUD_BUCKET_ID", should_error_on_not_found=False)
54
59
 
@@ -56,20 +61,21 @@ class StaticFilesManager:
56
61
  logger.warning(
57
62
  "GT_CLOUD_BUCKET_ID secret is not available, falling back to local storage. Run `gtn init` to set it up."
58
63
  )
59
- self.storage_driver = LocalStorageDriver()
64
+ self.storage_driver = LocalStorageDriver(workspace_directory)
60
65
  else:
61
66
  static_files_directory = config_manager.get_config_value(
62
67
  "static_files_directory", default="staticfiles"
63
68
  )
64
69
  self.storage_driver = GriptapeCloudStorageDriver(
70
+ workspace_directory,
65
71
  bucket_id=bucket_id,
66
72
  api_key=secrets_manager.get_secret("GT_CLOUD_API_KEY"),
67
73
  static_files_directory=static_files_directory,
68
74
  )
69
75
  case StorageBackend.LOCAL:
70
- self.storage_driver = LocalStorageDriver()
76
+ self.storage_driver = LocalStorageDriver(workspace_directory)
71
77
  case _:
72
- msg = f"Invalid storage backend: {storage_backend}"
78
+ msg = f"Invalid storage backend: {self.storage_backend}"
73
79
  raise ValueError(msg)
74
80
 
75
81
  if event_manager is not None:
@@ -82,6 +88,11 @@ class StaticFilesManager:
82
88
  event_manager.assign_manager_to_request_type(
83
89
  CreateStaticFileDownloadUrlRequest, self.on_handle_create_static_file_download_url_request
84
90
  )
91
+ event_manager.add_listener_to_app_event(
92
+ AppInitializationComplete,
93
+ self.on_app_initialization_complete,
94
+ )
95
+ # TODO: Listen for shutdown event (https://github.com/griptape-ai/griptape-nodes/issues/2149) to stop static server
85
96
 
86
97
  def on_handle_create_static_file_request(
87
98
  self,
@@ -103,7 +114,7 @@ class StaticFilesManager:
103
114
  logger.error(msg)
104
115
  return CreateStaticFileResultFailure(error=msg, result_details=msg)
105
116
 
106
- return CreateStaticFileResultSuccess(url=url)
117
+ return CreateStaticFileResultSuccess(url=url, result_details=f"Successfully created static file: {url}")
107
118
 
108
119
  def on_handle_create_static_file_upload_url_request(
109
120
  self,
@@ -118,15 +129,22 @@ class StaticFilesManager:
118
129
  A result object indicating success or failure.
119
130
  """
120
131
  file_name = request.file_name
132
+
133
+ resolved_directory = self._get_static_files_directory()
134
+ full_file_path = Path(resolved_directory) / file_name
135
+
121
136
  try:
122
- response = self.storage_driver.create_signed_upload_url(file_name)
137
+ response = self.storage_driver.create_signed_upload_url(full_file_path)
123
138
  except ValueError as e:
124
139
  msg = f"Failed to create presigned URL for file {file_name}: {e}"
125
140
  logger.error(msg)
126
141
  return CreateStaticFileUploadUrlResultFailure(error=msg, result_details=msg)
127
142
 
128
143
  return CreateStaticFileUploadUrlResultSuccess(
129
- url=response["url"], headers=response["headers"], method=response["method"]
144
+ url=response["url"],
145
+ headers=response["headers"],
146
+ method=response["method"],
147
+ result_details="Successfully created static file upload URL",
130
148
  )
131
149
 
132
150
  def on_handle_create_static_file_download_url_request(
@@ -142,14 +160,25 @@ class StaticFilesManager:
142
160
  A result object indicating success or failure.
143
161
  """
144
162
  file_name = request.file_name
163
+
164
+ resolved_directory = self._get_static_files_directory()
165
+ full_file_path = Path(resolved_directory) / file_name
166
+
145
167
  try:
146
- url = self.storage_driver.create_signed_download_url(file_name)
168
+ url = self.storage_driver.create_signed_download_url(full_file_path)
147
169
  except ValueError as e:
148
170
  msg = f"Failed to create presigned URL for file {file_name}: {e}"
149
171
  logger.error(msg)
150
172
  return CreateStaticFileDownloadUrlResultFailure(error=msg, result_details=msg)
151
173
 
152
- return CreateStaticFileDownloadUrlResultSuccess(url=url)
174
+ return CreateStaticFileDownloadUrlResultSuccess(
175
+ url=url, result_details="Successfully created static file download URL"
176
+ )
177
+
178
+ def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
179
+ # Start static server in daemon thread if enabled
180
+ if self.storage_backend == StorageBackend.LOCAL:
181
+ threading.Thread(target=start_static_server, daemon=True, name="static-server").start()
153
182
 
154
183
  def save_static_file(self, data: bytes, file_name: str) -> str:
155
184
  """Saves a static file to the workspace directory.
@@ -163,7 +192,10 @@ class StaticFilesManager:
163
192
  Returns:
164
193
  The URL of the saved file.
165
194
  """
166
- response = self.storage_driver.create_signed_upload_url(file_name)
195
+ resolved_directory = self._get_static_files_directory()
196
+ file_path = Path(resolved_directory) / file_name
197
+
198
+ response = self.storage_driver.create_signed_upload_url(file_path)
167
199
 
168
200
  try:
169
201
  response = httpx.request(
@@ -175,6 +207,47 @@ class StaticFilesManager:
175
207
  logger.error(msg)
176
208
  raise ValueError(msg) from e
177
209
 
178
- url = self.storage_driver.create_signed_download_url(file_name)
210
+ url = self.storage_driver.create_signed_download_url(file_path)
179
211
 
180
212
  return url
213
+
214
+ def _get_static_files_directory(self) -> str:
215
+ """Get the appropriate static files directory based on the current workflow context.
216
+
217
+ Returns:
218
+ The directory path to use for static files, relative to the workspace directory.
219
+ If a workflow is active, returns the staticfiles subdirectory within the
220
+ workflow's directory relative to workspace. Otherwise, returns the staticfiles
221
+ subdirectory relative to workspace.
222
+ """
223
+ from griptape_nodes.node_library.workflow_registry import WorkflowRegistry
224
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
225
+
226
+ workspace_path = self.config_manager.workspace_path
227
+ static_files_subdir = self.config_manager.get_config_value("static_files_directory", default="staticfiles")
228
+
229
+ # Check if there's an active workflow context
230
+ context_manager = GriptapeNodes.ContextManager()
231
+ if context_manager.has_current_workflow():
232
+ try:
233
+ # Get the current workflow name and its file path
234
+ workflow_name = context_manager.get_current_workflow_name()
235
+ workflow = WorkflowRegistry.get_workflow_by_name(workflow_name)
236
+
237
+ # Get the directory containing the workflow file
238
+ workflow_file_path = Path(WorkflowRegistry.get_complete_file_path(workflow.file_path))
239
+ workflow_directory = workflow_file_path.parent
240
+
241
+ # Make the workflow directory relative to workspace
242
+ relative_workflow_dir = workflow_directory.relative_to(workspace_path)
243
+ return str(relative_workflow_dir / static_files_subdir)
244
+
245
+ except (KeyError, AttributeError) as e:
246
+ # If anything goes wrong getting workflow info, fall back to workspace-relative
247
+ logger.warning("Failed to get workflow directory for static files, using workspace: %s", e)
248
+ except ValueError as e:
249
+ # If workflow directory is not within workspace, fall back to workspace-relative
250
+ logger.warning("Workflow directory is outside workspace, using workspace-relative static files: %s", e)
251
+
252
+ # If no workflow context or workflow lookup failed, return just the static files subdirectory
253
+ return static_files_subdir
@@ -13,7 +13,7 @@ from watchfiles import Change, PythonFilter, watch
13
13
 
14
14
  from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
15
15
  from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
16
- from griptape_nodes.retained_mode.events.base_events import AppEvent
16
+ from griptape_nodes.retained_mode.events.base_events import AppEvent, ResultDetails
17
17
  from griptape_nodes.retained_mode.events.sync_events import (
18
18
  StartSyncAllCloudWorkflowsRequest,
19
19
  StartSyncAllCloudWorkflowsResultFailure,
@@ -130,8 +130,13 @@ class SyncManager:
130
130
  workflow_files = [file for file in files if file.endswith(".py")]
131
131
 
132
132
  if not workflow_files:
133
- logger.info("No workflow files found in cloud storage")
134
- return StartSyncAllCloudWorkflowsResultSuccess(sync_directory=str(sync_dir), total_workflows=0)
133
+ return StartSyncAllCloudWorkflowsResultSuccess(
134
+ sync_directory=str(sync_dir),
135
+ total_workflows=0,
136
+ result_details=ResultDetails(
137
+ message="No workflow files found in cloud storage.", level=logging.INFO
138
+ ),
139
+ )
135
140
 
136
141
  # Start background sync with unique ID
137
142
  sync_task_id = str(uuid.uuid4())
@@ -149,9 +154,9 @@ class SyncManager:
149
154
  logger.error(details)
150
155
  return StartSyncAllCloudWorkflowsResultFailure(result_details=details)
151
156
  else:
152
- logger.info("Started background sync for %d workflow files", len(workflow_files))
157
+ details = f"Started background sync for {len(workflow_files)} workflow files"
153
158
  return StartSyncAllCloudWorkflowsResultSuccess(
154
- sync_directory=str(sync_dir), total_workflows=len(workflow_files)
159
+ sync_directory=str(sync_dir), total_workflows=len(workflow_files), result_details=details
155
160
  )
156
161
 
157
162
  def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
@@ -206,7 +211,10 @@ class SyncManager:
206
211
  msg = "Cloud storage api_key not configured. Set GT_CLOUD_API_KEY secret."
207
212
  raise RuntimeError(msg)
208
213
 
214
+ workspace_directory = Path(self._config_manager.get_config_value("workspace_directory"))
215
+
209
216
  return GriptapeCloudStorageDriver(
217
+ workspace_directory,
210
218
  bucket_id=bucket_id,
211
219
  base_url=base_url,
212
220
  api_key=api_key,
@@ -229,7 +237,7 @@ class SyncManager:
229
237
  sync_dir = self._sync_dir
230
238
 
231
239
  # Download file content from cloud
232
- file_content = storage_driver.download_file(filename)
240
+ file_content = storage_driver.download_file(Path(filename))
233
241
 
234
242
  # Write to local sync directory
235
243
  local_file_path = sync_dir / filename
@@ -280,7 +288,7 @@ class SyncManager:
280
288
 
281
289
  # Upload to cloud storage using the upload_file method
282
290
  filename = file_path.name
283
- storage_driver.upload_file(filename, file_content)
291
+ storage_driver.upload_file(Path(filename), file_content)
284
292
 
285
293
  logger.info("Successfully uploaded workflow file to cloud: %s", filename)
286
294
 
@@ -298,7 +306,7 @@ class SyncManager:
298
306
  filename = file_path.name
299
307
 
300
308
  # Use the storage driver's delete method
301
- storage_driver.delete_file(filename)
309
+ storage_driver.delete_file(Path(filename))
302
310
  logger.info("Successfully deleted workflow file from cloud: %s", filename)
303
311
 
304
312
  except Exception as e:
@@ -385,7 +393,7 @@ class SyncManager:
385
393
  """
386
394
  try:
387
395
  # Download file content
388
- file_content = storage_driver.download_file(file_name)
396
+ file_content = storage_driver.download_file(Path(file_name))
389
397
 
390
398
  # Extract just the filename (remove any directory prefixes)
391
399
  local_filename = Path(file_name).name