griptape-nodes 0.43.1__py3-none-any.whl → 0.45.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 (134) hide show
  1. griptape_nodes/__init__.py +46 -52
  2. griptape_nodes/app/.python-version +0 -0
  3. griptape_nodes/app/__init__.py +0 -0
  4. griptape_nodes/app/api.py +37 -41
  5. griptape_nodes/app/app.py +70 -3
  6. griptape_nodes/app/watch.py +5 -2
  7. griptape_nodes/bootstrap/__init__.py +0 -0
  8. griptape_nodes/bootstrap/workflow_executors/__init__.py +0 -0
  9. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +7 -1
  10. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +90 -0
  11. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +7 -1
  12. griptape_nodes/drivers/__init__.py +0 -0
  13. griptape_nodes/drivers/storage/__init__.py +0 -0
  14. griptape_nodes/drivers/storage/base_storage_driver.py +90 -0
  15. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +48 -0
  16. griptape_nodes/drivers/storage/local_storage_driver.py +37 -0
  17. griptape_nodes/drivers/storage/storage_backend.py +0 -0
  18. griptape_nodes/exe_types/__init__.py +0 -0
  19. griptape_nodes/exe_types/connections.py +0 -0
  20. griptape_nodes/exe_types/core_types.py +222 -17
  21. griptape_nodes/exe_types/flow.py +0 -0
  22. griptape_nodes/exe_types/node_types.py +20 -5
  23. griptape_nodes/exe_types/type_validator.py +0 -0
  24. griptape_nodes/machines/__init__.py +0 -0
  25. griptape_nodes/machines/control_flow.py +5 -4
  26. griptape_nodes/machines/fsm.py +0 -0
  27. griptape_nodes/machines/node_resolution.py +110 -74
  28. griptape_nodes/mcp_server/__init__.py +0 -0
  29. griptape_nodes/mcp_server/server.py +16 -8
  30. griptape_nodes/mcp_server/ws_request_manager.py +0 -0
  31. griptape_nodes/node_library/__init__.py +0 -0
  32. griptape_nodes/node_library/advanced_node_library.py +0 -0
  33. griptape_nodes/node_library/library_registry.py +0 -0
  34. griptape_nodes/node_library/workflow_registry.py +29 -0
  35. griptape_nodes/py.typed +0 -0
  36. griptape_nodes/retained_mode/__init__.py +0 -0
  37. griptape_nodes/retained_mode/events/__init__.py +0 -0
  38. griptape_nodes/retained_mode/events/agent_events.py +0 -0
  39. griptape_nodes/retained_mode/events/app_events.py +3 -8
  40. griptape_nodes/retained_mode/events/arbitrary_python_events.py +0 -0
  41. griptape_nodes/retained_mode/events/base_events.py +15 -7
  42. griptape_nodes/retained_mode/events/config_events.py +0 -0
  43. griptape_nodes/retained_mode/events/connection_events.py +0 -0
  44. griptape_nodes/retained_mode/events/context_events.py +0 -0
  45. griptape_nodes/retained_mode/events/execution_events.py +0 -0
  46. griptape_nodes/retained_mode/events/flow_events.py +2 -1
  47. griptape_nodes/retained_mode/events/generate_request_payload_schemas.py +0 -0
  48. griptape_nodes/retained_mode/events/library_events.py +0 -0
  49. griptape_nodes/retained_mode/events/logger_events.py +0 -0
  50. griptape_nodes/retained_mode/events/node_events.py +36 -0
  51. griptape_nodes/retained_mode/events/object_events.py +0 -0
  52. griptape_nodes/retained_mode/events/os_events.py +98 -6
  53. griptape_nodes/retained_mode/events/parameter_events.py +0 -0
  54. griptape_nodes/retained_mode/events/payload_registry.py +0 -0
  55. griptape_nodes/retained_mode/events/secrets_events.py +0 -0
  56. griptape_nodes/retained_mode/events/static_file_events.py +0 -0
  57. griptape_nodes/retained_mode/events/sync_events.py +60 -0
  58. griptape_nodes/retained_mode/events/validation_events.py +0 -0
  59. griptape_nodes/retained_mode/events/workflow_events.py +231 -0
  60. griptape_nodes/retained_mode/griptape_nodes.py +9 -4
  61. griptape_nodes/retained_mode/managers/__init__.py +0 -0
  62. griptape_nodes/retained_mode/managers/agent_manager.py +0 -0
  63. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +0 -0
  64. griptape_nodes/retained_mode/managers/config_manager.py +1 -1
  65. griptape_nodes/retained_mode/managers/context_manager.py +0 -0
  66. griptape_nodes/retained_mode/managers/engine_identity_manager.py +0 -0
  67. griptape_nodes/retained_mode/managers/event_manager.py +0 -0
  68. griptape_nodes/retained_mode/managers/flow_manager.py +6 -0
  69. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +0 -0
  70. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +0 -0
  71. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +0 -0
  72. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +0 -0
  73. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +0 -0
  74. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +0 -0
  75. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +0 -0
  76. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +0 -0
  77. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +0 -0
  78. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +0 -0
  79. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +0 -0
  80. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +0 -0
  81. griptape_nodes/retained_mode/managers/library_manager.py +8 -26
  82. griptape_nodes/retained_mode/managers/node_manager.py +78 -7
  83. griptape_nodes/retained_mode/managers/object_manager.py +0 -0
  84. griptape_nodes/retained_mode/managers/operation_manager.py +7 -0
  85. griptape_nodes/retained_mode/managers/os_manager.py +133 -8
  86. griptape_nodes/retained_mode/managers/secrets_manager.py +0 -0
  87. griptape_nodes/retained_mode/managers/session_manager.py +0 -0
  88. griptape_nodes/retained_mode/managers/settings.py +5 -0
  89. griptape_nodes/retained_mode/managers/static_files_manager.py +0 -0
  90. griptape_nodes/retained_mode/managers/sync_manager.py +498 -0
  91. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +0 -0
  92. griptape_nodes/retained_mode/managers/workflow_manager.py +736 -33
  93. griptape_nodes/retained_mode/retained_mode.py +23 -0
  94. griptape_nodes/retained_mode/utils/__init__.py +0 -0
  95. griptape_nodes/retained_mode/utils/engine_identity.py +0 -0
  96. griptape_nodes/retained_mode/utils/name_generator.py +0 -0
  97. griptape_nodes/traits/__init__.py +0 -0
  98. griptape_nodes/traits/add_param_button.py +0 -0
  99. griptape_nodes/traits/button.py +0 -0
  100. griptape_nodes/traits/clamp.py +0 -0
  101. griptape_nodes/traits/compare.py +0 -0
  102. griptape_nodes/traits/compare_images.py +0 -0
  103. griptape_nodes/traits/file_system_picker.py +18 -0
  104. griptape_nodes/traits/minmax.py +0 -0
  105. griptape_nodes/traits/options.py +0 -0
  106. griptape_nodes/traits/slider.py +0 -0
  107. griptape_nodes/traits/trait_registry.py +0 -0
  108. griptape_nodes/traits/traits.json +0 -0
  109. griptape_nodes/updater/__init__.py +4 -2
  110. griptape_nodes/updater/__main__.py +0 -0
  111. griptape_nodes/utils/__init__.py +0 -0
  112. griptape_nodes/utils/dict_utils.py +0 -0
  113. griptape_nodes/utils/image_preview.py +0 -0
  114. griptape_nodes/utils/metaclasses.py +0 -0
  115. griptape_nodes/utils/uv_utils.py +18 -0
  116. griptape_nodes/utils/version_utils.py +51 -0
  117. griptape_nodes/version_compatibility/__init__.py +0 -0
  118. griptape_nodes/version_compatibility/versions/__init__.py +0 -0
  119. griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +0 -0
  120. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +0 -0
  121. {griptape_nodes-0.43.1.dist-info → griptape_nodes-0.45.0.dist-info}/METADATA +2 -1
  122. {griptape_nodes-0.43.1.dist-info → griptape_nodes-0.45.0.dist-info}/RECORD +42 -47
  123. {griptape_nodes-0.43.1.dist-info → griptape_nodes-0.45.0.dist-info}/WHEEL +1 -1
  124. griptape_nodes/bootstrap/bootstrap_script.py +0 -54
  125. griptape_nodes/bootstrap/post_build_install_script.sh +0 -3
  126. griptape_nodes/bootstrap/pre_build_install_script.sh +0 -4
  127. griptape_nodes/bootstrap/register_libraries_script.py +0 -32
  128. griptape_nodes/bootstrap/structure_config.yaml +0 -15
  129. griptape_nodes/bootstrap/workflow_runners/__init__.py +0 -1
  130. griptape_nodes/bootstrap/workflow_runners/bootstrap_workflow_runner.py +0 -28
  131. griptape_nodes/bootstrap/workflow_runners/local_workflow_runner.py +0 -237
  132. griptape_nodes/bootstrap/workflow_runners/subprocess_workflow_runner.py +0 -62
  133. griptape_nodes/bootstrap/workflow_runners/workflow_runner.py +0 -11
  134. {griptape_nodes-0.43.1.dist-info → griptape_nodes-0.45.0.dist-info}/entry_points.txt +0 -0
@@ -45,6 +45,12 @@ from griptape_nodes.retained_mode.events.library_events import (
45
45
  )
46
46
  from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStateRequest
47
47
  from griptape_nodes.retained_mode.events.workflow_events import (
48
+ BranchWorkflowRequest,
49
+ BranchWorkflowResultFailure,
50
+ BranchWorkflowResultSuccess,
51
+ CompareWorkflowsRequest,
52
+ CompareWorkflowsResultFailure,
53
+ CompareWorkflowsResultSuccess,
48
54
  DeleteWorkflowRequest,
49
55
  DeleteWorkflowResultFailure,
50
56
  DeleteWorkflowResultSuccess,
@@ -57,14 +63,27 @@ from griptape_nodes.retained_mode.events.workflow_events import (
57
63
  LoadWorkflowMetadata,
58
64
  LoadWorkflowMetadataResultFailure,
59
65
  LoadWorkflowMetadataResultSuccess,
66
+ MergeWorkflowBranchRequest,
67
+ MergeWorkflowBranchResultFailure,
68
+ MergeWorkflowBranchResultSuccess,
69
+ MoveWorkflowRequest,
70
+ MoveWorkflowResultFailure,
71
+ MoveWorkflowResultSuccess,
60
72
  PublishWorkflowRequest,
61
73
  PublishWorkflowResultFailure,
74
+ PublishWorkflowResultSuccess,
62
75
  RegisterWorkflowRequest,
63
76
  RegisterWorkflowResultFailure,
64
77
  RegisterWorkflowResultSuccess,
78
+ RegisterWorkflowsFromConfigRequest,
79
+ RegisterWorkflowsFromConfigResultFailure,
80
+ RegisterWorkflowsFromConfigResultSuccess,
65
81
  RenameWorkflowRequest,
66
82
  RenameWorkflowResultFailure,
67
83
  RenameWorkflowResultSuccess,
84
+ ResetWorkflowBranchRequest,
85
+ ResetWorkflowBranchResultFailure,
86
+ ResetWorkflowBranchResultSuccess,
68
87
  RunWorkflowFromRegistryRequest,
69
88
  RunWorkflowFromRegistryResultFailure,
70
89
  RunWorkflowFromRegistryResultSuccess,
@@ -91,6 +110,7 @@ if TYPE_CHECKING:
91
110
  from griptape_nodes.exe_types.core_types import Parameter
92
111
  from griptape_nodes.node_library.library_registry import LibraryNameAndVersion
93
112
  from griptape_nodes.retained_mode.events.base_events import ResultPayload
113
+ from griptape_nodes.retained_mode.events.node_events import SetLockNodeStateRequest
94
114
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
95
115
 
96
116
 
@@ -100,6 +120,13 @@ T = TypeVar("T")
100
120
  logger = logging.getLogger("griptape_nodes")
101
121
 
102
122
 
123
+ class WorkflowRegistrationResult(NamedTuple):
124
+ """Result of processing workflows for registration."""
125
+
126
+ succeeded: list[str]
127
+ failed: list[str]
128
+
129
+
103
130
  class WorkflowManager:
104
131
  WORKFLOW_METADATA_HEADER: ClassVar[str] = "script"
105
132
  MAX_MINOR_VERSION_DEVIATION: ClassVar[int] = (
@@ -226,6 +253,10 @@ class WorkflowManager:
226
253
  RenameWorkflowRequest,
227
254
  self.on_rename_workflow_request,
228
255
  )
256
+ event_manager.assign_manager_to_request_type(
257
+ MoveWorkflowRequest,
258
+ self.on_move_workflow_request,
259
+ )
229
260
 
230
261
  event_manager.assign_manager_to_request_type(
231
262
  SaveWorkflowRequest,
@@ -240,6 +271,26 @@ class WorkflowManager:
240
271
  ImportWorkflowAsReferencedSubFlowRequest,
241
272
  self.on_import_workflow_as_referenced_sub_flow_request,
242
273
  )
274
+ event_manager.assign_manager_to_request_type(
275
+ BranchWorkflowRequest,
276
+ self.on_branch_workflow_request,
277
+ )
278
+ event_manager.assign_manager_to_request_type(
279
+ MergeWorkflowBranchRequest,
280
+ self.on_merge_workflow_branch_request,
281
+ )
282
+ event_manager.assign_manager_to_request_type(
283
+ ResetWorkflowBranchRequest,
284
+ self.on_reset_workflow_branch_request,
285
+ )
286
+ event_manager.assign_manager_to_request_type(
287
+ CompareWorkflowsRequest,
288
+ self.on_compare_workflows_request,
289
+ )
290
+ event_manager.assign_manager_to_request_type(
291
+ RegisterWorkflowsFromConfigRequest,
292
+ self.on_register_workflows_from_config_request,
293
+ )
243
294
 
244
295
  def has_current_referenced_workflow(self) -> bool:
245
296
  """Check if there is currently a referenced workflow context active."""
@@ -257,7 +308,15 @@ class WorkflowManager:
257
308
  # All of the libraries have loaded, and any workflows they came with have been registered.
258
309
  # See if there are USER workflow JSONs to load.
259
310
  default_workflow_section = "app_events.on_app_initialization_complete.workflows_to_register"
260
- self.register_workflows_from_config(config_section=default_workflow_section)
311
+
312
+ # Use the request/response pattern for workflow registration
313
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
314
+
315
+ register_request = RegisterWorkflowsFromConfigRequest(config_section=default_workflow_section)
316
+ register_result = GriptapeNodes.handle_request(register_request)
317
+
318
+ if not isinstance(register_result, RegisterWorkflowsFromConfigResultSuccess):
319
+ logger.warning("Failed to register workflows from configuration during library initialization")
261
320
 
262
321
  # Print it all out nicely.
263
322
  self.print_workflow_load_status()
@@ -610,6 +669,82 @@ class WorkflowManager:
610
669
 
611
670
  return RenameWorkflowResultSuccess()
612
671
 
672
+ def on_move_workflow_request(self, request: MoveWorkflowRequest) -> ResultPayload: # noqa: PLR0911
673
+ try:
674
+ # Validate source workflow exists
675
+ workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
676
+ except KeyError:
677
+ logger.error("Failed to move workflow '%s' because it does not exist", request.workflow_name)
678
+ return MoveWorkflowResultFailure()
679
+
680
+ config_manager = GriptapeNodes.ConfigManager()
681
+
682
+ # Get current file path
683
+ current_file_path = WorkflowRegistry.get_complete_file_path(workflow.file_path)
684
+ if not Path(current_file_path).exists():
685
+ logger.error(
686
+ "Failed to move workflow '%s': File path '%s' does not exist", request.workflow_name, current_file_path
687
+ )
688
+ return MoveWorkflowResultFailure()
689
+
690
+ # Clean and validate target directory
691
+ target_directory = request.target_directory.strip().replace("\\", "/")
692
+ target_directory = target_directory.removeprefix("/") # Remove leading slash
693
+
694
+ # Create target directory path
695
+ target_dir_path = config_manager.workspace_path / target_directory
696
+
697
+ try:
698
+ # Create target directory if it doesn't exist
699
+ target_dir_path.mkdir(parents=True, exist_ok=True)
700
+ except OSError as e:
701
+ logger.error("Failed to create target directory '%s': %s", target_dir_path, str(e))
702
+ return MoveWorkflowResultFailure()
703
+
704
+ # Create new file path
705
+ workflow_filename = Path(workflow.file_path).name
706
+ new_relative_path = str(Path(target_directory) / workflow_filename)
707
+ new_absolute_path = config_manager.workspace_path / new_relative_path
708
+
709
+ # Check if target file already exists
710
+ if new_absolute_path.exists():
711
+ logger.error(
712
+ "Failed to move workflow '%s': Target file '%s' already exists",
713
+ request.workflow_name,
714
+ new_absolute_path,
715
+ )
716
+ return MoveWorkflowResultFailure()
717
+
718
+ try:
719
+ # Move the file
720
+ Path(current_file_path).rename(new_absolute_path)
721
+
722
+ # Update workflow registry with new file path
723
+ workflow.file_path = new_relative_path
724
+
725
+ # Update configuration - remove old path and add new path
726
+ config_manager.delete_user_workflow(workflow.file_path)
727
+ config_manager.save_user_workflow_json(str(new_absolute_path))
728
+
729
+ except OSError as e:
730
+ logger.error("Failed to move workflow file '%s' to '%s': %s", current_file_path, new_absolute_path, str(e))
731
+
732
+ # Attempt to rollback if file was moved but registry update failed
733
+ if new_absolute_path.exists() and not Path(current_file_path).exists():
734
+ try:
735
+ new_absolute_path.rename(current_file_path)
736
+ logger.info("Rolled back file move for workflow '%s'", request.workflow_name)
737
+ except OSError:
738
+ logger.error("Failed to rollback file move for workflow '%s'", request.workflow_name)
739
+
740
+ return MoveWorkflowResultFailure()
741
+ except Exception as e:
742
+ logger.error("Failed to move workflow '%s': %s", request.workflow_name, str(e))
743
+ return MoveWorkflowResultFailure()
744
+ else:
745
+ logger.info("Successfully moved workflow '%s' to '%s'", request.workflow_name, new_relative_path)
746
+ return MoveWorkflowResultSuccess(moved_file_path=new_relative_path)
747
+
613
748
  def on_load_workflow_metadata_request( # noqa: C901, PLR0912, PLR0915
614
749
  self, request: LoadWorkflowMetadata
615
750
  ) -> ResultPayload:
@@ -932,7 +1067,12 @@ class WorkflowManager:
932
1067
  return import_statements
933
1068
 
934
1069
  def _generate_workflow_file_contents_and_metadata( # noqa: C901, PLR0912, PLR0915
935
- self, file_name: str, creation_date: datetime, image_path: str | None = None
1070
+ self,
1071
+ file_name: str,
1072
+ creation_date: datetime,
1073
+ image_path: str | None = None,
1074
+ prior_workflow: Workflow | None = None,
1075
+ custom_metadata: WorkflowMetadata | None = None,
936
1076
  ) -> tuple[str, WorkflowMetadata]:
937
1077
  """Generate the contents of a workflow file.
938
1078
 
@@ -940,6 +1080,10 @@ class WorkflowManager:
940
1080
  file_name: The name of the workflow file
941
1081
  creation_date: The creation date for the workflow
942
1082
  image_path: Optional; the path to an image to include in the workflow metadata
1083
+ prior_workflow: Optional; existing workflow to preserve branch info from
1084
+ custom_metadata: Optional; pre-constructed metadata to use instead of generating it
1085
+ from the current workflow state. When provided, this metadata will be
1086
+ used directly, allowing branch/merge operations to pass specific metadata.
943
1087
 
944
1088
  Returns:
945
1089
  A tuple of (workflow_file_contents, workflow_metadata)
@@ -988,22 +1132,27 @@ class WorkflowManager:
988
1132
  raise TypeError(details)
989
1133
  serialized_flow_commands = serialized_flow_result.serialized_flow_commands
990
1134
 
991
- # Create the Workflow Metadata header.
992
- workflows_referenced = None
993
- if serialized_flow_commands.referenced_workflows:
994
- workflows_referenced = list(serialized_flow_commands.referenced_workflows)
995
-
996
- workflow_metadata = self._generate_workflow_metadata(
997
- file_name=file_name,
998
- engine_version=engine_version,
999
- creation_date=creation_date,
1000
- node_libraries_referenced=list(serialized_flow_commands.node_libraries_used),
1001
- workflows_referenced=workflows_referenced,
1002
- )
1003
- if workflow_metadata is None:
1004
- details = f"Failed to generate metadata for workflow '{file_name}'."
1005
- logger.error(details)
1006
- raise ValueError(details)
1135
+ # Use custom metadata if provided, otherwise generate it
1136
+ if custom_metadata is not None:
1137
+ workflow_metadata = custom_metadata
1138
+ else:
1139
+ # Create the Workflow Metadata header.
1140
+ workflows_referenced = None
1141
+ if serialized_flow_commands.referenced_workflows:
1142
+ workflows_referenced = list(serialized_flow_commands.referenced_workflows)
1143
+
1144
+ workflow_metadata = self._generate_workflow_metadata(
1145
+ file_name=file_name,
1146
+ engine_version=engine_version,
1147
+ creation_date=creation_date,
1148
+ node_libraries_referenced=list(serialized_flow_commands.node_libraries_used),
1149
+ workflows_referenced=workflows_referenced,
1150
+ prior_workflow=prior_workflow,
1151
+ )
1152
+ if workflow_metadata is None:
1153
+ details = f"Failed to generate metadata for workflow '{file_name}'."
1154
+ logger.error(details)
1155
+ raise ValueError(details)
1007
1156
 
1008
1157
  # Set the image if provided
1009
1158
  if image_path:
@@ -1064,6 +1213,7 @@ class WorkflowManager:
1064
1213
  or len(serialized_flow_commands.serialized_connections) > 0
1065
1214
  or len(serialized_flow_commands.set_parameter_value_commands) > 0
1066
1215
  or len(serialized_flow_commands.sub_flows_commands) > 0
1216
+ or len(serialized_flow_commands.set_lock_commands_per_node) > 0
1067
1217
  )
1068
1218
 
1069
1219
  if not is_referenced_workflow and has_content_to_serialize:
@@ -1110,6 +1260,7 @@ class WorkflowManager:
1110
1260
  # Now generate all the set parameter value code and add it to the flow context.
1111
1261
  set_parameter_value_asts = self._generate_set_parameter_value_code(
1112
1262
  set_parameter_value_commands=serialized_flow_commands.set_parameter_value_commands,
1263
+ lock_commands=serialized_flow_commands.set_lock_commands_per_node,
1113
1264
  node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
1114
1265
  unique_values_dict_name="top_level_unique_values_dict",
1115
1266
  import_recorder=import_recorder,
@@ -1140,8 +1291,6 @@ class WorkflowManager:
1140
1291
  return final_code_output, workflow_metadata
1141
1292
 
1142
1293
  def on_save_workflow_request(self, request: SaveWorkflowRequest) -> ResultPayload: # noqa: C901, PLR0912, PLR0915
1143
- local_tz = datetime.now().astimezone().tzinfo
1144
-
1145
1294
  # Start with the file name provided; we may change it.
1146
1295
  file_name = request.file_name
1147
1296
 
@@ -1153,10 +1302,19 @@ class WorkflowManager:
1153
1302
  prior_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1154
1303
  # We'll use its creation date.
1155
1304
  creation_date = prior_workflow.metadata.creation_date
1305
+ elif file_name:
1306
+ # If no prior workflow exists for the new name, check if there's a current workflow
1307
+ # context (e.g., during rename operations) to preserve metadata from
1308
+ context_manager = GriptapeNodes.ContextManager()
1309
+ if context_manager.has_current_workflow():
1310
+ current_workflow_name = context_manager.get_current_workflow_name()
1311
+ if current_workflow_name and WorkflowRegistry.has_workflow_with_name(current_workflow_name):
1312
+ prior_workflow = WorkflowRegistry.get_workflow_by_name(current_workflow_name)
1313
+ creation_date = prior_workflow.metadata.creation_date
1156
1314
 
1157
1315
  if (creation_date is None) or (creation_date == WorkflowManager.EPOCH_START):
1158
1316
  # Either a new workflow, or a backcompat situation.
1159
- creation_date = datetime.now(tz=local_tz)
1317
+ creation_date = datetime.now(tz=UTC)
1160
1318
 
1161
1319
  # Let's see if this is a template file; if so, re-route it as a copy in the customer's workflow directory.
1162
1320
  if prior_workflow and prior_workflow.metadata.is_template:
@@ -1178,14 +1336,17 @@ class WorkflowManager:
1178
1336
 
1179
1337
  # Get file name stuff prepped.
1180
1338
  if not file_name:
1181
- file_name = datetime.now(tz=local_tz).strftime("%d.%m_%H.%M")
1339
+ file_name = datetime.now(tz=UTC).strftime("%d.%m_%H.%M")
1182
1340
  relative_file_path = f"{file_name}.py"
1183
1341
  file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1184
1342
 
1185
1343
  # Generate the workflow file contents
1186
1344
  try:
1187
1345
  final_code_output, workflow_metadata = self._generate_workflow_file_contents_and_metadata(
1188
- file_name=file_name, creation_date=creation_date, image_path=request.image_path
1346
+ file_name=file_name,
1347
+ creation_date=creation_date,
1348
+ image_path=request.image_path,
1349
+ prior_workflow=prior_workflow,
1189
1350
  )
1190
1351
  except Exception as err:
1191
1352
  details = f"Attempted to save workflow '{relative_file_path}', but {err}"
@@ -1228,19 +1389,27 @@ class WorkflowManager:
1228
1389
  logger.error("Attempted to save workflow '%s'. Failed when saving configuration: %s", file_name, str(e))
1229
1390
  return SaveWorkflowResultFailure()
1230
1391
  WorkflowRegistry.generate_new_workflow(metadata=workflow_metadata, file_path=relative_file_path)
1392
+ # Update existing workflow's metadata in the registry
1393
+ existing_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1394
+ existing_workflow.metadata = workflow_metadata
1231
1395
  details = f"Successfully saved workflow to: {serialized_file_path}"
1232
1396
  logger.info(details)
1233
1397
  return SaveWorkflowResultSuccess(file_path=str(serialized_file_path))
1234
1398
 
1235
- def _generate_workflow_metadata(
1399
+ def _generate_workflow_metadata( # noqa: PLR0913
1236
1400
  self,
1237
1401
  file_name: str,
1238
1402
  engine_version: str,
1239
1403
  creation_date: datetime,
1240
1404
  node_libraries_referenced: list[LibraryNameAndVersion],
1241
1405
  workflows_referenced: list[str] | None = None,
1406
+ prior_workflow: Workflow | None = None,
1242
1407
  ) -> WorkflowMetadata | None:
1243
- local_tz = datetime.now().astimezone().tzinfo
1408
+ # Preserve branched workflow information if it exists
1409
+ branched_from = None
1410
+ if prior_workflow and prior_workflow.metadata.branched_from:
1411
+ branched_from = prior_workflow.metadata.branched_from
1412
+
1244
1413
  workflow_metadata = WorkflowMetadata(
1245
1414
  name=str(file_name),
1246
1415
  schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION,
@@ -1248,11 +1417,35 @@ class WorkflowManager:
1248
1417
  node_libraries_referenced=node_libraries_referenced,
1249
1418
  workflows_referenced=workflows_referenced,
1250
1419
  creation_date=creation_date,
1251
- last_modified_date=datetime.now(tz=local_tz),
1420
+ last_modified_date=datetime.now(tz=UTC),
1421
+ branched_from=branched_from,
1252
1422
  )
1253
1423
 
1254
1424
  return workflow_metadata
1255
1425
 
1426
+ def _replace_workflow_metadata_header(self, workflow_content: str, new_metadata: WorkflowMetadata) -> str | None:
1427
+ """Replace the metadata header in a workflow file with new metadata.
1428
+
1429
+ Args:
1430
+ workflow_content: The full content of the workflow file
1431
+ new_metadata: The new metadata to replace the existing header with
1432
+
1433
+ Returns:
1434
+ The workflow content with updated metadata header, or None if replacement failed
1435
+ """
1436
+ import re
1437
+
1438
+ # Generate the new metadata header
1439
+ new_metadata_header = self._generate_workflow_metadata_header(new_metadata)
1440
+ if new_metadata_header is None:
1441
+ return None
1442
+
1443
+ # Replace the metadata block using regex
1444
+ metadata_pattern = r"(# /// script\n)(.*?)(# ///)"
1445
+ updated_content = re.sub(metadata_pattern, new_metadata_header, workflow_content, flags=re.DOTALL)
1446
+
1447
+ return updated_content
1448
+
1256
1449
  def _generate_workflow_metadata_header(self, workflow_metadata: WorkflowMetadata) -> str | None:
1257
1450
  try:
1258
1451
  toml_doc = tomlkit.document()
@@ -2503,6 +2696,7 @@ class WorkflowManager:
2503
2696
  set_parameter_value_commands: dict[
2504
2697
  SerializedNodeCommands.NodeUUID, list[SerializedNodeCommands.IndirectSetParameterValueCommand]
2505
2698
  ],
2699
+ lock_commands: dict[SerializedNodeCommands.NodeUUID, SetLockNodeStateRequest],
2506
2700
  node_uuid_to_node_variable_name: dict[SerializedNodeCommands.NodeUUID, str],
2507
2701
  unique_values_dict_name: str,
2508
2702
  import_recorder: ImportRecorder,
@@ -2510,9 +2704,14 @@ class WorkflowManager:
2510
2704
  parameter_value_asts = []
2511
2705
  for node_uuid, indirect_set_parameter_value_commands in set_parameter_value_commands.items():
2512
2706
  node_variable_name = node_uuid_to_node_variable_name[node_uuid]
2707
+ lock_node_command = lock_commands.get(node_uuid)
2513
2708
  parameter_value_asts.extend(
2514
2709
  self._generate_set_parameter_value_for_node(
2515
- node_variable_name, indirect_set_parameter_value_commands, unique_values_dict_name, import_recorder
2710
+ node_variable_name,
2711
+ indirect_set_parameter_value_commands,
2712
+ unique_values_dict_name,
2713
+ import_recorder,
2714
+ lock_node_command,
2516
2715
  )
2517
2716
  )
2518
2717
  return parameter_value_asts
@@ -2523,13 +2722,15 @@ class WorkflowManager:
2523
2722
  indirect_set_parameter_value_commands: list[SerializedNodeCommands.IndirectSetParameterValueCommand],
2524
2723
  unique_values_dict_name: str,
2525
2724
  import_recorder: ImportRecorder,
2725
+ lock_node_command: SetLockNodeStateRequest | None = None,
2526
2726
  ) -> list[ast.stmt]:
2527
- if not indirect_set_parameter_value_commands:
2727
+ if not indirect_set_parameter_value_commands and lock_node_command is None:
2528
2728
  return []
2529
2729
 
2530
- import_recorder.add_from_import(
2531
- "griptape_nodes.retained_mode.events.parameter_events", "SetParameterValueRequest"
2532
- )
2730
+ if indirect_set_parameter_value_commands:
2731
+ import_recorder.add_from_import(
2732
+ "griptape_nodes.retained_mode.events.parameter_events", "SetParameterValueRequest"
2733
+ )
2533
2734
 
2534
2735
  set_parameter_value_asts = []
2535
2736
  with_node_context = ast.With(
@@ -2613,6 +2814,44 @@ class WorkflowManager:
2613
2814
  )
2614
2815
  with_node_context.body.append(set_parameter_value_request_call)
2615
2816
 
2817
+ # Add lock command as the LAST command in the with context
2818
+ if lock_node_command is not None:
2819
+ import_recorder.add_from_import(
2820
+ "griptape_nodes.retained_mode.events.node_events", "SetLockNodeStateRequest"
2821
+ )
2822
+
2823
+ lock_node_call_ast = ast.Expr(
2824
+ value=ast.Call(
2825
+ func=ast.Attribute(
2826
+ value=ast.Name(id="GriptapeNodes", ctx=ast.Load(), lineno=1, col_offset=0),
2827
+ attr="handle_request",
2828
+ ctx=ast.Load(),
2829
+ lineno=1,
2830
+ col_offset=0,
2831
+ ),
2832
+ args=[
2833
+ ast.Call(
2834
+ func=ast.Name(id="SetLockNodeStateRequest", ctx=ast.Load(), lineno=1, col_offset=0),
2835
+ args=[],
2836
+ keywords=[
2837
+ ast.keyword(arg="node_name", value=ast.Constant(value=None, lineno=1, col_offset=0)),
2838
+ ast.keyword(
2839
+ arg="lock", value=ast.Constant(value=lock_node_command.lock, lineno=1, col_offset=0)
2840
+ ),
2841
+ ],
2842
+ lineno=1,
2843
+ col_offset=0,
2844
+ )
2845
+ ],
2846
+ keywords=[],
2847
+ lineno=1,
2848
+ col_offset=0,
2849
+ ),
2850
+ lineno=1,
2851
+ col_offset=0,
2852
+ )
2853
+ with_node_context.body.append(lock_node_call_ast)
2854
+
2616
2855
  set_parameter_value_asts.append(with_node_context)
2617
2856
  return set_parameter_value_asts
2618
2857
 
@@ -2727,13 +2966,47 @@ class WorkflowManager:
2727
2966
  msg = f"No publishing handler found for '{publisher_name}' in request type '{type(request).__name__}'."
2728
2967
  raise ValueError(msg) # noqa: TRY301
2729
2968
 
2730
- return publishing_handler.handler(request)
2731
-
2969
+ result = publishing_handler.handler(request)
2970
+ if isinstance(result, PublishWorkflowResultSuccess):
2971
+ file = Path(result.published_workflow_file_path)
2972
+ self._register_published_workflow_file(file)
2973
+ return result # noqa: TRY300
2732
2974
  except Exception as e:
2733
2975
  details = f"Failed to publish workflow '{request.workflow_name}': {e!s}"
2734
2976
  logger.exception(details)
2735
2977
  return PublishWorkflowResultFailure(exception=e)
2736
2978
 
2979
+ def _register_published_workflow_file(self, workflow_file: Path) -> None:
2980
+ """Register a published workflow file in the workflow registry."""
2981
+ if workflow_file.exists() and workflow_file.is_file():
2982
+ load_workflow_metadata_request = LoadWorkflowMetadata(
2983
+ file_name=workflow_file.name,
2984
+ )
2985
+ load_metadata_result = self.on_load_workflow_metadata_request(load_workflow_metadata_request)
2986
+ if isinstance(load_metadata_result, LoadWorkflowMetadataResultSuccess):
2987
+ register_workflow_result = self.on_register_workflow_request(
2988
+ RegisterWorkflowRequest(
2989
+ metadata=load_metadata_result.metadata,
2990
+ file_name=workflow_file.name,
2991
+ )
2992
+ )
2993
+ if isinstance(register_workflow_result, RegisterWorkflowResultSuccess):
2994
+ logger.info(
2995
+ "Successfully registered new workflow with file '%s'.",
2996
+ workflow_file.name,
2997
+ )
2998
+ else:
2999
+ logger.warning(
3000
+ "Failed to register workflow with file '%s': %s",
3001
+ workflow_file.name,
3002
+ cast("RegisterWorkflowResultFailure", register_workflow_result).exception,
3003
+ )
3004
+ else:
3005
+ logger.warning(
3006
+ "Failed to load metadata for workflow file '%s'. Not registering workflow.",
3007
+ workflow_file.name,
3008
+ )
3009
+
2737
3010
  def on_import_workflow_as_referenced_sub_flow_request(
2738
3011
  self, request: ImportWorkflowAsReferencedSubFlowRequest
2739
3012
  ) -> ResultPayload:
@@ -2875,6 +3148,341 @@ class WorkflowManager:
2875
3148
  )
2876
3149
  return ImportWorkflowAsReferencedSubFlowResultSuccess(created_flow_name=created_flow_name)
2877
3150
 
3151
+ def on_branch_workflow_request(self, request: BranchWorkflowRequest) -> ResultPayload:
3152
+ """Create a branch (copy) of an existing workflow with branch tracking."""
3153
+ try:
3154
+ # Validate source workflow exists
3155
+ source_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3156
+ except KeyError:
3157
+ logger.error("Failed to branch workflow '%s' because it does not exist", request.workflow_name)
3158
+ return BranchWorkflowResultFailure()
3159
+
3160
+ # Generate branch name if not provided
3161
+ branch_name = request.branched_workflow_name
3162
+ if branch_name is None:
3163
+ base_name = request.workflow_name
3164
+ counter = 1
3165
+ branch_name = f"{base_name}_branch_{counter}"
3166
+ while WorkflowRegistry.has_workflow_with_name(branch_name):
3167
+ counter += 1
3168
+ branch_name = f"{base_name}_branch_{counter}"
3169
+
3170
+ # Check if branch name already exists
3171
+ if WorkflowRegistry.has_workflow_with_name(branch_name):
3172
+ logger.error(
3173
+ "Failed to branch workflow '%s' because branch name '%s' already exists",
3174
+ request.workflow_name,
3175
+ branch_name,
3176
+ )
3177
+ return BranchWorkflowResultFailure()
3178
+
3179
+ try:
3180
+ # Create branch metadata by copying source metadata
3181
+ branch_metadata = WorkflowMetadata(
3182
+ name=branch_name,
3183
+ schema_version=source_workflow.metadata.schema_version,
3184
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3185
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3186
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3187
+ if source_workflow.metadata.workflows_referenced
3188
+ else None,
3189
+ description=source_workflow.metadata.description,
3190
+ image=source_workflow.metadata.image,
3191
+ is_griptape_provided=False, # Branches are always user-created
3192
+ is_template=False,
3193
+ creation_date=datetime.now(tz=UTC),
3194
+ last_modified_date=source_workflow.metadata.last_modified_date,
3195
+ branched_from=request.workflow_name,
3196
+ )
3197
+
3198
+ # Prepare branch file path
3199
+ branch_file_path = f"{branch_name}.py"
3200
+
3201
+ # Read source workflow content and replace metadata header
3202
+ source_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3203
+ if not Path(source_file_path).exists():
3204
+ logger.error(
3205
+ "Failed to branch workflow '%s': File path '%s' does not exist. "
3206
+ "The workflow may have been moved or the workspace configuration may have changed.",
3207
+ request.workflow_name,
3208
+ source_file_path,
3209
+ )
3210
+ return BranchWorkflowResultFailure()
3211
+
3212
+ source_content = Path(source_file_path).read_text(encoding="utf-8")
3213
+
3214
+ # Replace the metadata header with branch metadata
3215
+ branch_content = self._replace_workflow_metadata_header(source_content, branch_metadata)
3216
+ if branch_content is None:
3217
+ logger.error("Failed to replace metadata header for branch workflow '%s'", branch_name)
3218
+ return BranchWorkflowResultFailure()
3219
+
3220
+ # Write branch workflow file to disk BEFORE registering in registry
3221
+ branch_full_path = WorkflowRegistry.get_complete_file_path(branch_file_path)
3222
+ Path(branch_full_path).write_text(branch_content, encoding="utf-8")
3223
+
3224
+ # Now create the branch workflow in registry (file must exist on disk first)
3225
+ WorkflowRegistry.generate_new_workflow(metadata=branch_metadata, file_path=branch_file_path)
3226
+
3227
+ # Register the branch in user configuration
3228
+ config_manager = GriptapeNodes.ConfigManager()
3229
+ config_manager.save_user_workflow_json(branch_full_path)
3230
+
3231
+ logger.info("Successfully branched workflow '%s' as '%s'", request.workflow_name, branch_name)
3232
+ return BranchWorkflowResultSuccess(
3233
+ branched_workflow_name=branch_name, original_workflow_name=request.workflow_name
3234
+ )
3235
+
3236
+ except Exception as e:
3237
+ logger.error("Failed to branch workflow '%s': %s", request.workflow_name, str(e))
3238
+ import traceback
3239
+
3240
+ traceback.print_exc()
3241
+ return BranchWorkflowResultFailure()
3242
+
3243
+ def on_merge_workflow_branch_request(self, request: MergeWorkflowBranchRequest) -> ResultPayload:
3244
+ """Merge a branch back into its source workflow, removing the branch when complete."""
3245
+ try:
3246
+ # Validate branch workflow exists
3247
+ branch_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3248
+ except KeyError as e:
3249
+ logger.error("Failed to merge workflow branch because it does not exist: %s", str(e))
3250
+ return MergeWorkflowBranchResultFailure()
3251
+
3252
+ # Get source workflow name from branch metadata
3253
+ source_workflow_name = branch_workflow.metadata.branched_from
3254
+ if not source_workflow_name:
3255
+ logger.error(
3256
+ "Failed to merge workflow branch '%s' because it has no source workflow",
3257
+ request.workflow_name,
3258
+ )
3259
+ return MergeWorkflowBranchResultFailure()
3260
+
3261
+ # Validate source workflow exists
3262
+ try:
3263
+ source_workflow = WorkflowRegistry.get_workflow_by_name(source_workflow_name)
3264
+ except KeyError:
3265
+ logger.error(
3266
+ "Failed to merge workflow branch '%s' because source workflow '%s' does not exist",
3267
+ request.workflow_name,
3268
+ source_workflow_name,
3269
+ )
3270
+ return MergeWorkflowBranchResultFailure()
3271
+
3272
+ try:
3273
+ # Create updated metadata for source workflow - update timestamp
3274
+ merged_metadata = WorkflowMetadata(
3275
+ name=source_workflow_name,
3276
+ schema_version=source_workflow.metadata.schema_version,
3277
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3278
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3279
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3280
+ if source_workflow.metadata.workflows_referenced
3281
+ else None,
3282
+ description=source_workflow.metadata.description,
3283
+ image=source_workflow.metadata.image,
3284
+ is_griptape_provided=source_workflow.metadata.is_griptape_provided,
3285
+ is_template=source_workflow.metadata.is_template,
3286
+ creation_date=source_workflow.metadata.creation_date,
3287
+ last_modified_date=datetime.now(tz=UTC),
3288
+ branched_from=source_workflow.metadata.branched_from, # Preserve original source chain
3289
+ )
3290
+
3291
+ # Read branch content and replace metadata header with merged metadata
3292
+ branch_content_file_path = WorkflowRegistry.get_complete_file_path(branch_workflow.file_path)
3293
+ branch_content = Path(branch_content_file_path).read_text(encoding="utf-8")
3294
+
3295
+ # Replace the metadata header with merged metadata
3296
+ merged_content = self._replace_workflow_metadata_header(branch_content, merged_metadata)
3297
+ if merged_content is None:
3298
+ logger.error("Failed to replace metadata header for merged workflow '%s'", source_workflow_name)
3299
+ return MergeWorkflowBranchResultFailure()
3300
+
3301
+ # Write the updated content to the source workflow file
3302
+ source_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3303
+ Path(source_file_path).write_text(merged_content, encoding="utf-8")
3304
+
3305
+ # Update the registry with new metadata for the source workflow
3306
+ source_workflow.metadata = merged_metadata
3307
+
3308
+ # Remove the branch workflow from registry and delete file
3309
+ try:
3310
+ WorkflowRegistry.delete_workflow_by_name(request.workflow_name)
3311
+ Path(branch_content_file_path).unlink()
3312
+ logger.info("Deleted branch workflow file and registry entry for '%s'", request.workflow_name)
3313
+ except Exception as delete_error:
3314
+ logger.warning(
3315
+ "Failed to fully clean up branch workflow '%s': %s",
3316
+ request.workflow_name,
3317
+ str(delete_error),
3318
+ )
3319
+ # Continue anyway - the merge was successful even if cleanup failed
3320
+
3321
+ logger.info(
3322
+ "Successfully merged branch workflow '%s' into source workflow '%s'",
3323
+ request.workflow_name,
3324
+ source_workflow_name,
3325
+ )
3326
+ return MergeWorkflowBranchResultSuccess(merged_workflow_name=source_workflow_name)
3327
+
3328
+ except Exception as e:
3329
+ logger.error(
3330
+ "Failed to merge branch workflow '%s' into source workflow '%s': %s",
3331
+ request.workflow_name,
3332
+ source_workflow_name,
3333
+ str(e),
3334
+ )
3335
+ return MergeWorkflowBranchResultFailure()
3336
+
3337
+ def on_reset_workflow_branch_request(self, request: ResetWorkflowBranchRequest) -> ResultPayload:
3338
+ """Reset a branch to match its source workflow, discarding branch changes."""
3339
+ try:
3340
+ # Validate branch workflow exists
3341
+ branch_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3342
+ except KeyError as e:
3343
+ logger.error("Failed to reset workflow branch because it does not exist: %s", str(e))
3344
+ return ResetWorkflowBranchResultFailure()
3345
+
3346
+ # Get source workflow name from branch metadata
3347
+ source_workflow_name = branch_workflow.metadata.branched_from
3348
+ if not source_workflow_name:
3349
+ logger.error(
3350
+ "Failed to reset workflow branch '%s' because it has no source workflow",
3351
+ request.workflow_name,
3352
+ )
3353
+ return ResetWorkflowBranchResultFailure()
3354
+
3355
+ # Validate source workflow exists
3356
+ try:
3357
+ source_workflow = WorkflowRegistry.get_workflow_by_name(source_workflow_name)
3358
+ except KeyError:
3359
+ logger.error(
3360
+ "Failed to reset workflow branch '%s' because source workflow '%s' does not exist",
3361
+ request.workflow_name,
3362
+ source_workflow_name,
3363
+ )
3364
+ return ResetWorkflowBranchResultFailure()
3365
+
3366
+ try:
3367
+ # Read content from the source workflow (what we're resetting the branch to)
3368
+ source_content_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3369
+ source_content = Path(source_content_file_path).read_text(encoding="utf-8")
3370
+
3371
+ # Create updated metadata for branch workflow - preserve branch relationship and source timestamp
3372
+ reset_metadata = WorkflowMetadata(
3373
+ name=request.workflow_name,
3374
+ schema_version=source_workflow.metadata.schema_version,
3375
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3376
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3377
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3378
+ if source_workflow.metadata.workflows_referenced
3379
+ else None,
3380
+ description=source_workflow.metadata.description,
3381
+ image=source_workflow.metadata.image,
3382
+ is_griptape_provided=branch_workflow.metadata.is_griptape_provided,
3383
+ is_template=branch_workflow.metadata.is_template,
3384
+ creation_date=branch_workflow.metadata.creation_date,
3385
+ last_modified_date=source_workflow.metadata.last_modified_date,
3386
+ branched_from=source_workflow_name, # Preserve branch relationship
3387
+ )
3388
+
3389
+ # Replace the metadata header with reset metadata
3390
+ reset_content = self._replace_workflow_metadata_header(source_content, reset_metadata)
3391
+ if reset_content is None:
3392
+ logger.error("Failed to replace metadata header for reset branch workflow '%s'", request.workflow_name)
3393
+ return ResetWorkflowBranchResultFailure()
3394
+
3395
+ # Write the updated content to the branch workflow file
3396
+ branch_content_file_path = WorkflowRegistry.get_complete_file_path(branch_workflow.file_path)
3397
+ Path(branch_content_file_path).write_text(reset_content, encoding="utf-8")
3398
+
3399
+ # Update the registry with new metadata for the branch workflow
3400
+ branch_workflow.metadata = reset_metadata
3401
+
3402
+ except Exception as e:
3403
+ logger.error(
3404
+ "Failed to reset branch workflow '%s' to source workflow '%s': %s",
3405
+ request.workflow_name,
3406
+ source_workflow_name,
3407
+ str(e),
3408
+ )
3409
+ return ResetWorkflowBranchResultFailure()
3410
+ else:
3411
+ logger.info(
3412
+ "Successfully reset branch workflow '%s' to match source workflow '%s'",
3413
+ request.workflow_name,
3414
+ source_workflow_name,
3415
+ )
3416
+ return ResetWorkflowBranchResultSuccess(reset_workflow_name=request.workflow_name)
3417
+
3418
+ def on_compare_workflows_request(self, request: CompareWorkflowsRequest) -> ResultPayload:
3419
+ """Compare two workflows to determine if one is ahead, behind, or up-to-date relative to the other."""
3420
+ try:
3421
+ # Get the workflow to evaluate
3422
+ workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3423
+ except KeyError:
3424
+ logger.error("Failed to compare workflow '%s' because it does not exist", request.workflow_name)
3425
+ return CompareWorkflowsResultFailure()
3426
+
3427
+ # Use the provided compare_workflow_name
3428
+ compare_workflow_name = request.compare_workflow_name
3429
+
3430
+ # Try to get the source workflow
3431
+ try:
3432
+ source_workflow = WorkflowRegistry.get_workflow_by_name(compare_workflow_name)
3433
+ except KeyError:
3434
+ # Source workflow no longer exists
3435
+ details = f"Source workflow '{compare_workflow_name}' for '{request.workflow_name}' no longer exists"
3436
+ return CompareWorkflowsResultSuccess(
3437
+ workflow_name=request.workflow_name,
3438
+ compare_workflow_name=compare_workflow_name,
3439
+ status="no_source",
3440
+ workflow_last_modified=workflow.metadata.last_modified_date.isoformat()
3441
+ if workflow.metadata.last_modified_date
3442
+ else None,
3443
+ source_last_modified=None,
3444
+ details=details,
3445
+ )
3446
+
3447
+ # Compare last modified dates
3448
+ workflow_last_modified = workflow.metadata.last_modified_date
3449
+ source_last_modified = source_workflow.metadata.last_modified_date
3450
+
3451
+ # Handle missing timestamps
3452
+ if workflow_last_modified is None or source_last_modified is None:
3453
+ details = f"Cannot compare timestamps - workflow: {workflow_last_modified}, source: {source_last_modified}"
3454
+ logger.warning(details)
3455
+ return CompareWorkflowsResultSuccess(
3456
+ workflow_name=request.workflow_name,
3457
+ compare_workflow_name=compare_workflow_name,
3458
+ status="diverged",
3459
+ workflow_last_modified=workflow_last_modified.isoformat() if workflow_last_modified else None,
3460
+ source_last_modified=source_last_modified.isoformat() if source_last_modified else None,
3461
+ details=details,
3462
+ )
3463
+
3464
+ # Compare timestamps to determine status
3465
+ if workflow_last_modified == source_last_modified:
3466
+ status = "up_to_date"
3467
+ details = f"Workflow '{request.workflow_name}' is up-to-date with source '{compare_workflow_name}'"
3468
+ elif workflow_last_modified > source_last_modified:
3469
+ status = "ahead"
3470
+ details = f"Workflow '{request.workflow_name}' is ahead of source '{compare_workflow_name}' (local changes)"
3471
+ else:
3472
+ status = "behind"
3473
+ details = (
3474
+ f"Workflow '{request.workflow_name}' is behind source '{compare_workflow_name}' (source has updates)"
3475
+ )
3476
+
3477
+ return CompareWorkflowsResultSuccess(
3478
+ workflow_name=request.workflow_name,
3479
+ compare_workflow_name=compare_workflow_name,
3480
+ status=status,
3481
+ workflow_last_modified=workflow_last_modified.isoformat(),
3482
+ source_last_modified=source_last_modified.isoformat(),
3483
+ details=details,
3484
+ )
3485
+
2878
3486
  def _walk_object_tree(
2879
3487
  self, obj: Any, process_class_fn: Callable[[type, Any], None], visited: set[int] | None = None
2880
3488
  ) -> None:
@@ -3027,6 +3635,101 @@ class WorkflowManager:
3027
3635
 
3028
3636
  self._walk_object_tree(obj, collect_class_import)
3029
3637
 
3638
+ def on_register_workflows_from_config_request(self, request: RegisterWorkflowsFromConfigRequest) -> ResultPayload:
3639
+ """Register workflows from a configuration section."""
3640
+ try:
3641
+ workflows_to_register = GriptapeNodes.ConfigManager().get_config_value(request.config_section)
3642
+ if not workflows_to_register:
3643
+ logger.info("No workflows found in configuration section '%s'", request.config_section)
3644
+ return RegisterWorkflowsFromConfigResultSuccess(succeeded_workflows=[], failed_workflows=[])
3645
+
3646
+ # Process all workflows and track results
3647
+ succeeded, failed = self._process_workflows_for_registration(workflows_to_register)
3648
+
3649
+ except Exception as e:
3650
+ logger.error(
3651
+ "Failed to register workflows from configuration section '%s': %s", request.config_section, str(e)
3652
+ )
3653
+ return RegisterWorkflowsFromConfigResultFailure()
3654
+ else:
3655
+ return RegisterWorkflowsFromConfigResultSuccess(succeeded_workflows=succeeded, failed_workflows=failed)
3656
+
3657
+ def _process_workflows_for_registration(self, workflows_to_register: list[str]) -> WorkflowRegistrationResult:
3658
+ """Process a list of workflow paths for registration.
3659
+
3660
+ Returns:
3661
+ WorkflowRegistrationResult with succeeded and failed workflow names
3662
+ """
3663
+ succeeded = []
3664
+ failed = []
3665
+
3666
+ for workflow_to_register in workflows_to_register:
3667
+ path = Path(workflow_to_register)
3668
+
3669
+ if path.is_dir():
3670
+ dir_result = self._process_workflow_directory(path)
3671
+ succeeded.extend(dir_result.succeeded)
3672
+ failed.extend(dir_result.failed)
3673
+ elif path.suffix == ".py":
3674
+ workflow_name = self._process_single_workflow_file(path)
3675
+ if workflow_name:
3676
+ succeeded.append(workflow_name)
3677
+ else:
3678
+ failed.append(str(path))
3679
+
3680
+ return WorkflowRegistrationResult(succeeded=succeeded, failed=failed)
3681
+
3682
+ def _process_workflow_directory(self, directory_path: Path) -> WorkflowRegistrationResult:
3683
+ """Process all workflow files in a directory.
3684
+
3685
+ Returns:
3686
+ WorkflowRegistrationResult with succeeded and failed workflow names
3687
+ """
3688
+ succeeded = []
3689
+ failed = []
3690
+
3691
+ for workflow_file in directory_path.glob("*.py"):
3692
+ # Check that the python file has script metadata
3693
+ metadata_blocks = self.get_workflow_metadata(
3694
+ workflow_file, block_name=WorkflowManager.WORKFLOW_METADATA_HEADER
3695
+ )
3696
+ if len(metadata_blocks) == 1:
3697
+ workflow_name = self._process_single_workflow_file(workflow_file)
3698
+ if workflow_name:
3699
+ succeeded.append(workflow_name)
3700
+ else:
3701
+ failed.append(str(workflow_file))
3702
+
3703
+ return WorkflowRegistrationResult(succeeded=succeeded, failed=failed)
3704
+
3705
+ def _process_single_workflow_file(self, workflow_file: Path) -> str | None:
3706
+ """Process a single workflow file for registration.
3707
+
3708
+ Returns:
3709
+ Workflow name if registered successfully, None if failed or skipped
3710
+ """
3711
+ # Parse metadata once and use it for both registration check and actual registration
3712
+ load_metadata_request = LoadWorkflowMetadata(file_name=str(workflow_file))
3713
+ load_metadata_result = self.on_load_workflow_metadata_request(load_metadata_request)
3714
+
3715
+ if not isinstance(load_metadata_result, LoadWorkflowMetadataResultSuccess):
3716
+ logger.debug("Skipping workflow with invalid metadata: %s", workflow_file)
3717
+ return None
3718
+
3719
+ workflow_metadata = load_metadata_result.metadata
3720
+
3721
+ # Check if workflow is already registered using the parsed metadata
3722
+ if WorkflowRegistry.has_workflow_with_name(workflow_metadata.name):
3723
+ logger.debug("Skipping already registered workflow: %s", workflow_file)
3724
+ return None
3725
+
3726
+ # Register workflow using existing method with parsed metadata available
3727
+ # The _register_workflow method will re-parse metadata, but this is acceptable
3728
+ # since we've already validated it's parseable and the duplicate work is minimal
3729
+ if self._register_workflow(str(workflow_file)):
3730
+ return workflow_metadata.name
3731
+ return None
3732
+
3030
3733
 
3031
3734
  class ASTContainer:
3032
3735
  """ASTContainer is a helper class to keep track of AST nodes and generate final code from them."""