griptape-nodes 0.55.1__py3-none-any.whl → 0.56.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 (60) hide show
  1. griptape_nodes/app/app.py +10 -15
  2. griptape_nodes/app/watch.py +35 -67
  3. griptape_nodes/bootstrap/utils/__init__.py +1 -0
  4. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +122 -0
  5. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +418 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +37 -8
  7. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +326 -0
  8. griptape_nodes/bootstrap/workflow_executors/utils/__init__.py +1 -0
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +51 -0
  10. griptape_nodes/bootstrap/workflow_publishers/__init__.py +1 -0
  11. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +43 -0
  12. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +84 -0
  13. griptape_nodes/bootstrap/workflow_publishers/utils/__init__.py +1 -0
  14. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +54 -0
  15. griptape_nodes/cli/commands/engine.py +4 -15
  16. griptape_nodes/cli/commands/init.py +88 -0
  17. griptape_nodes/cli/commands/models.py +2 -0
  18. griptape_nodes/cli/main.py +6 -1
  19. griptape_nodes/cli/shared.py +1 -0
  20. griptape_nodes/exe_types/core_types.py +130 -0
  21. griptape_nodes/exe_types/node_types.py +125 -13
  22. griptape_nodes/machines/control_flow.py +10 -0
  23. griptape_nodes/machines/dag_builder.py +21 -2
  24. griptape_nodes/machines/parallel_resolution.py +25 -10
  25. griptape_nodes/node_library/workflow_registry.py +73 -3
  26. griptape_nodes/retained_mode/events/agent_events.py +2 -0
  27. griptape_nodes/retained_mode/events/base_events.py +18 -17
  28. griptape_nodes/retained_mode/events/execution_events.py +15 -3
  29. griptape_nodes/retained_mode/events/flow_events.py +63 -7
  30. griptape_nodes/retained_mode/events/mcp_events.py +363 -0
  31. griptape_nodes/retained_mode/events/node_events.py +3 -4
  32. griptape_nodes/retained_mode/events/resource_events.py +290 -0
  33. griptape_nodes/retained_mode/events/workflow_events.py +57 -2
  34. griptape_nodes/retained_mode/griptape_nodes.py +17 -1
  35. griptape_nodes/retained_mode/managers/agent_manager.py +67 -4
  36. griptape_nodes/retained_mode/managers/event_manager.py +31 -13
  37. griptape_nodes/retained_mode/managers/flow_manager.py +731 -33
  38. griptape_nodes/retained_mode/managers/library_manager.py +15 -23
  39. griptape_nodes/retained_mode/managers/mcp_manager.py +364 -0
  40. griptape_nodes/retained_mode/managers/model_manager.py +184 -83
  41. griptape_nodes/retained_mode/managers/node_manager.py +15 -4
  42. griptape_nodes/retained_mode/managers/os_manager.py +118 -1
  43. griptape_nodes/retained_mode/managers/resource_components/__init__.py +1 -0
  44. griptape_nodes/retained_mode/managers/resource_components/capability_field.py +41 -0
  45. griptape_nodes/retained_mode/managers/resource_components/comparator.py +18 -0
  46. griptape_nodes/retained_mode/managers/resource_components/resource_instance.py +236 -0
  47. griptape_nodes/retained_mode/managers/resource_components/resource_type.py +79 -0
  48. griptape_nodes/retained_mode/managers/resource_manager.py +306 -0
  49. griptape_nodes/retained_mode/managers/resource_types/__init__.py +1 -0
  50. griptape_nodes/retained_mode/managers/resource_types/cpu_resource.py +108 -0
  51. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +87 -0
  52. griptape_nodes/retained_mode/managers/settings.py +45 -0
  53. griptape_nodes/retained_mode/managers/sync_manager.py +10 -3
  54. griptape_nodes/retained_mode/managers/workflow_manager.py +447 -263
  55. griptape_nodes/traits/multi_options.py +5 -1
  56. griptape_nodes/traits/options.py +10 -2
  57. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/METADATA +2 -2
  58. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/RECORD +60 -37
  59. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/WHEEL +1 -1
  60. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/entry_points.txt +0 -0
@@ -4,7 +4,6 @@ import ast
4
4
  import asyncio
5
5
  import logging
6
6
  import pickle
7
- import pkgutil
8
7
  import re
9
8
  from dataclasses import dataclass, field, fields, is_dataclass
10
9
  from datetime import UTC, datetime
@@ -24,7 +23,7 @@ from griptape_nodes.drivers.storage import StorageBackend
24
23
  from griptape_nodes.exe_types.core_types import ParameterTypeBuiltin
25
24
  from griptape_nodes.exe_types.flow import ControlFlow
26
25
  from griptape_nodes.exe_types.node_types import BaseNode, EndNode, StartNode
27
- from griptape_nodes.node_library.workflow_registry import Workflow, WorkflowMetadata, WorkflowRegistry
26
+ from griptape_nodes.node_library.workflow_registry import Workflow, WorkflowMetadata, WorkflowRegistry, WorkflowShape
28
27
  from griptape_nodes.retained_mode.events.app_events import (
29
28
  GetEngineVersionRequest,
30
29
  GetEngineVersionResultSuccess,
@@ -32,12 +31,12 @@ from griptape_nodes.retained_mode.events.app_events import (
32
31
 
33
32
  # Runtime imports for ResultDetails since it's used at runtime
34
33
  from griptape_nodes.retained_mode.events.base_events import ResultDetail, ResultDetails
34
+ from griptape_nodes.retained_mode.events.execution_events import StartFlowResultFailure, StartFlowResultSuccess
35
35
  from griptape_nodes.retained_mode.events.flow_events import (
36
36
  CreateFlowRequest,
37
37
  GetTopLevelFlowRequest,
38
38
  GetTopLevelFlowResultSuccess,
39
39
  SerializedFlowCommands,
40
- SerializedNodeCommands,
41
40
  SerializeFlowToCommandsRequest,
42
41
  SerializeFlowToCommandsResultSuccess,
43
42
  SetFlowMetadataRequest,
@@ -100,6 +99,9 @@ from griptape_nodes.retained_mode.events.workflow_events import (
100
99
  RunWorkflowWithCurrentStateRequest,
101
100
  RunWorkflowWithCurrentStateResultFailure,
102
101
  RunWorkflowWithCurrentStateResultSuccess,
102
+ SaveWorkflowFileFromSerializedFlowRequest,
103
+ SaveWorkflowFileFromSerializedFlowResultFailure,
104
+ SaveWorkflowFileFromSerializedFlowResultSuccess,
103
105
  SaveWorkflowRequest,
104
106
  SaveWorkflowResultFailure,
105
107
  SaveWorkflowResultSuccess,
@@ -115,14 +117,17 @@ if TYPE_CHECKING:
115
117
  from types import TracebackType
116
118
 
117
119
  from griptape_nodes.exe_types.core_types import Parameter
118
- from griptape_nodes.node_library.library_registry import LibraryNameAndVersion
119
120
  from griptape_nodes.retained_mode.events.base_events import ResultPayload
120
- from griptape_nodes.retained_mode.events.node_events import SetLockNodeStateRequest
121
+ from griptape_nodes.retained_mode.events.node_events import SerializedNodeCommands, SetLockNodeStateRequest
121
122
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
122
123
 
123
124
 
124
125
  T = TypeVar("T")
125
126
 
127
+ # Type aliases for workflow shape building
128
+ ParameterShapeInfo = dict[str, Any] # Parameter metadata dict from _convert_parameter_to_minimal_dict
129
+ NodeParameterMap = dict[str, ParameterShapeInfo] # {param_name: param_info}
130
+ WorkflowShapeNodes = dict[str, NodeParameterMap] # {node_name: {param_name: param_info}}
126
131
 
127
132
  logger = logging.getLogger("griptape_nodes")
128
133
 
@@ -269,6 +274,10 @@ class WorkflowManager:
269
274
  SaveWorkflowRequest,
270
275
  self.on_save_workflow_request,
271
276
  )
277
+ event_manager.assign_manager_to_request_type(
278
+ SaveWorkflowFileFromSerializedFlowRequest,
279
+ self.on_save_workflow_file_from_serialized_flow_request,
280
+ )
272
281
  event_manager.assign_manager_to_request_type(LoadWorkflowMetadata, self.on_load_workflow_metadata_request)
273
282
  event_manager.assign_manager_to_request_type(
274
283
  PublishWorkflowRequest,
@@ -1115,116 +1124,299 @@ class WorkflowManager:
1115
1124
 
1116
1125
  return True
1117
1126
 
1118
- def _gather_workflow_imports(self) -> list[str]:
1119
- """Gathers all the imports for the saved workflow file, specifically for the events."""
1120
- import_template = "from {} import *"
1121
- import_statements = []
1122
-
1123
- from griptape_nodes.retained_mode import events as events_pkg
1127
+ class WriteWorkflowFileResult(NamedTuple):
1128
+ """Result of writing a workflow file."""
1124
1129
 
1125
- # Iterate over all modules in the events package
1126
- for _finder, module_name, _is_pkg in pkgutil.iter_modules(events_pkg.__path__, events_pkg.__name__ + "."):
1127
- if module_name.endswith("generate_request_payload_schemas"):
1128
- continue
1129
- import_statements.append(import_template.format(module_name))
1130
+ success: bool
1131
+ error_details: str
1130
1132
 
1131
- return import_statements
1132
-
1133
- def _generate_workflow_file_contents_and_metadata( # noqa: C901, PLR0912, PLR0915
1134
- self,
1135
- file_name: str,
1136
- creation_date: datetime,
1137
- image_path: str | None = None,
1138
- prior_workflow: Workflow | None = None,
1139
- custom_metadata: WorkflowMetadata | None = None,
1140
- ) -> tuple[str, WorkflowMetadata]:
1141
- """Generate the contents of a workflow file.
1133
+ def _write_workflow_file(self, file_path: Path, content: str, file_name: str) -> WriteWorkflowFileResult:
1134
+ """Write workflow content to file with proper validation and error handling.
1142
1135
 
1143
1136
  Args:
1144
- file_name: The name of the workflow file
1145
- creation_date: The creation date for the workflow
1146
- image_path: Optional; the path to an image to include in the workflow metadata
1147
- prior_workflow: Optional; existing workflow to preserve branch info from
1148
- custom_metadata: Optional; pre-constructed metadata to use instead of generating it
1149
- from the current workflow state. When provided, this metadata will be
1150
- used directly, allowing branch/merge operations to pass specific metadata.
1137
+ file_path: Path where to write the file
1138
+ content: Content to write
1139
+ file_name: Name for error messages
1151
1140
 
1152
1141
  Returns:
1153
- A tuple of (workflow_file_contents, workflow_metadata)
1154
-
1155
- Raises:
1156
- ValueError, TypeError: If workflow generation fails
1142
+ WriteWorkflowFileResult with success status and error details if failed
1157
1143
  """
1158
- # Get the engine version.
1159
- engine_version_request = GetEngineVersionRequest()
1160
- engine_version_result = GriptapeNodes.handle_request(request=engine_version_request)
1161
- if not isinstance(engine_version_result, GetEngineVersionResultSuccess):
1162
- details = f"Failed getting the engine version for workflow '{file_name}'."
1163
- raise TypeError(details)
1144
+ # Check disk space before any file system operations
1145
+ config_manager = GriptapeNodes.ConfigManager()
1146
+ min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_workflows")
1147
+ if not OSManager.check_available_disk_space(file_path.parent, min_space_gb):
1148
+ error_msg = OSManager.format_disk_space_error(file_path.parent)
1149
+ details = f"Attempted to save workflow '{file_name}' (requires {min_space_gb:.1f} GB). Failed due to insufficient disk space: {error_msg}"
1150
+ return self.WriteWorkflowFileResult(success=False, error_details=details)
1151
+
1152
+ # Create directory structure
1164
1153
  try:
1165
- engine_version_success = cast("GetEngineVersionResultSuccess", engine_version_result)
1166
- engine_version = (
1167
- f"{engine_version_success.major}.{engine_version_success.minor}.{engine_version_success.patch}"
1168
- )
1169
- except Exception as err:
1170
- details = f"Failed getting the engine version for workflow '{file_name}': {err}"
1171
- raise ValueError(details) from err
1154
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1155
+ except OSError as e:
1156
+ details = f"Attempted to save workflow '{file_name}'. Failed when creating directory structure: {e}"
1157
+ return self.WriteWorkflowFileResult(success=False, error_details=details)
1172
1158
 
1173
- # Keep track of all of the nodes we create and the generated variable names for them.
1174
- node_uuid_to_node_variable_name: dict[SerializedNodeCommands.NodeUUID, str] = {}
1159
+ # Write the file content
1160
+ try:
1161
+ with file_path.open("w", encoding="utf-8") as file:
1162
+ file.write(content)
1163
+ except OSError as e:
1164
+ details = f"Attempted to save workflow '{file_name}'. Failed when writing file content: {e}"
1165
+ return self.WriteWorkflowFileResult(success=False, error_details=details)
1175
1166
 
1176
- # Keep track of each flow and node index we've created.
1177
- flow_creation_index = 0
1167
+ return self.WriteWorkflowFileResult(success=True, error_details="")
1168
+
1169
+ def on_save_workflow_request(self, request: SaveWorkflowRequest) -> ResultPayload: # noqa: C901, PLR0912, PLR0915
1170
+ # Start with the file name provided; we may change it.
1171
+ file_name = request.file_name
1172
+
1173
+ # See if we had an existing workflow for this.
1174
+ prior_workflow = None
1175
+ creation_date = None
1176
+ if file_name and WorkflowRegistry.has_workflow_with_name(file_name):
1177
+ # Get the metadata.
1178
+ prior_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1179
+ # We'll use its creation date.
1180
+ creation_date = prior_workflow.metadata.creation_date
1181
+ elif file_name:
1182
+ # If no prior workflow exists for the new name, check if there's a current workflow
1183
+ # context (e.g., during rename operations) to preserve metadata from
1184
+ context_manager = GriptapeNodes.ContextManager()
1185
+ if context_manager.has_current_workflow():
1186
+ current_workflow_name = context_manager.get_current_workflow_name()
1187
+ if current_workflow_name and WorkflowRegistry.has_workflow_with_name(current_workflow_name):
1188
+ prior_workflow = WorkflowRegistry.get_workflow_by_name(current_workflow_name)
1189
+ creation_date = prior_workflow.metadata.creation_date
1190
+
1191
+ if (creation_date is None) or (creation_date == WorkflowManager.EPOCH_START):
1192
+ # Either a new workflow, or a backcompat situation.
1193
+ creation_date = datetime.now(tz=UTC)
1194
+
1195
+ # Let's see if this is a template file; if so, re-route it as a copy in the customer's workflow directory.
1196
+ if prior_workflow and prior_workflow.metadata.is_template:
1197
+ # Aha! User is attempting to save a template. Create a differently-named file in their workspace.
1198
+ # Find the first available file name that doesn't conflict.
1199
+ curr_idx = 1
1200
+ free_file_found = False
1201
+ while not free_file_found:
1202
+ # Composite a new candidate file name to test.
1203
+ new_file_name = f"{file_name}_{curr_idx}"
1204
+ new_file_name_with_extension = f"{new_file_name}.py"
1205
+ new_file_full_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(new_file_name_with_extension)
1206
+ if new_file_full_path.exists():
1207
+ # Keep going.
1208
+ curr_idx += 1
1209
+ else:
1210
+ free_file_found = True
1211
+ file_name = new_file_name
1178
1212
 
1179
- # Serialize from the top.
1213
+ # Get file name stuff prepped.
1214
+ # Use the existing registered file path if this is an existing workflow (not a template)
1215
+ if prior_workflow and not prior_workflow.metadata.is_template:
1216
+ # Use the existing registered file path
1217
+ relative_file_path = prior_workflow.file_path
1218
+ file_path = Path(WorkflowRegistry.get_complete_file_path(relative_file_path))
1219
+ # Extract file name from the path for metadata generation
1220
+ if not file_name:
1221
+ file_name = prior_workflow.metadata.name
1222
+ else:
1223
+ # Create new path in workspace for new workflows or templates
1224
+ if not file_name:
1225
+ file_name = datetime.now(tz=UTC).strftime("%d.%m_%H.%M")
1226
+ relative_file_path = f"{file_name}.py"
1227
+ file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1228
+
1229
+ # First, serialize the current workflow state
1180
1230
  top_level_flow_request = GetTopLevelFlowRequest()
1181
1231
  top_level_flow_result = GriptapeNodes.handle_request(top_level_flow_request)
1182
1232
  if not isinstance(top_level_flow_result, GetTopLevelFlowResultSuccess):
1183
- details = f"Failed when requesting to get top level flow for workflow '{file_name}'."
1184
- raise TypeError(details)
1233
+ details = f"Attempted to save workflow '{relative_file_path}'. Failed when requesting top level flow."
1234
+ return SaveWorkflowResultFailure(result_details=details)
1235
+
1185
1236
  top_level_flow_name = top_level_flow_result.flow_name
1186
1237
  serialized_flow_request = SerializeFlowToCommandsRequest(
1187
1238
  flow_name=top_level_flow_name, include_create_flow_command=True
1188
1239
  )
1189
1240
  serialized_flow_result = GriptapeNodes.handle_request(serialized_flow_request)
1190
1241
  if not isinstance(serialized_flow_result, SerializeFlowToCommandsResultSuccess):
1191
- details = f"Failed when serializing flow for workflow '{file_name}'."
1192
- raise TypeError(details)
1242
+ details = f"Attempted to save workflow '{relative_file_path}'. Failed when serializing flow."
1243
+ return SaveWorkflowResultFailure(result_details=details)
1244
+
1193
1245
  serialized_flow_commands = serialized_flow_result.serialized_flow_commands
1194
1246
 
1195
- # Use custom metadata if provided, otherwise generate it
1196
- if custom_metadata is not None:
1197
- workflow_metadata = custom_metadata
1247
+ # Extract branched_from information if it exists
1248
+ branched_from = None
1249
+ if prior_workflow and prior_workflow.metadata.branched_from:
1250
+ branched_from = prior_workflow.metadata.branched_from
1251
+
1252
+ # Extract workflow shape if possible
1253
+ workflow_shape = None
1254
+ try:
1255
+ workflow_shape_dict = self.extract_workflow_shape(workflow_name=file_name)
1256
+ workflow_shape = WorkflowShape(inputs=workflow_shape_dict["input"], outputs=workflow_shape_dict["output"])
1257
+ except ValueError:
1258
+ # If we can't extract workflow shape, continue without it
1259
+ pass
1260
+
1261
+ # Use the standalone request to save the workflow file
1262
+ save_file_request = SaveWorkflowFileFromSerializedFlowRequest(
1263
+ serialized_flow_commands=serialized_flow_commands,
1264
+ file_name=file_name,
1265
+ creation_date=creation_date,
1266
+ image_path=request.image_path,
1267
+ execution_flow_name=top_level_flow_name,
1268
+ branched_from=branched_from,
1269
+ workflow_shape=workflow_shape,
1270
+ file_path=str(file_path),
1271
+ )
1272
+ save_file_result = self.on_save_workflow_file_from_serialized_flow_request(save_file_request)
1273
+
1274
+ if not isinstance(save_file_result, SaveWorkflowFileFromSerializedFlowResultSuccess):
1275
+ details = f"Attempted to save workflow '{relative_file_path}'. Failed during file generation: {save_file_result.result_details}"
1276
+ return SaveWorkflowResultFailure(result_details=details)
1277
+
1278
+ # Use the metadata returned by the save operation
1279
+ workflow_metadata = save_file_result.workflow_metadata
1280
+
1281
+ # save the created workflow as an entry in the JSON config file.
1282
+ registered_workflows = WorkflowRegistry.list_workflows()
1283
+ if file_name not in registered_workflows:
1284
+ # Only add to config if it's in the workspace directory (not an external file)
1285
+ if not Path(relative_file_path).is_absolute():
1286
+ try:
1287
+ GriptapeNodes.ConfigManager().save_user_workflow_json(str(file_path))
1288
+ except OSError as e:
1289
+ details = f"Attempted to save workflow '{file_name}'. Failed when saving configuration: {e}"
1290
+ return SaveWorkflowResultFailure(result_details=details)
1291
+ WorkflowRegistry.generate_new_workflow(metadata=workflow_metadata, file_path=relative_file_path)
1292
+ # Update existing workflow's metadata in the registry
1293
+ existing_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1294
+ existing_workflow.metadata = workflow_metadata
1295
+ details = f"Successfully saved workflow to: {save_file_result.file_path}"
1296
+ return SaveWorkflowResultSuccess(
1297
+ file_path=save_file_result.file_path, result_details=ResultDetails(message=details, level=logging.INFO)
1298
+ )
1299
+
1300
+ def on_save_workflow_file_from_serialized_flow_request(
1301
+ self, request: SaveWorkflowFileFromSerializedFlowRequest
1302
+ ) -> ResultPayload:
1303
+ """Save a workflow file from serialized flow commands without registry overhead."""
1304
+ # Determine file path
1305
+ if request.file_path:
1306
+ # Use provided file path
1307
+ file_path = Path(request.file_path)
1198
1308
  else:
1199
- # Create the Workflow Metadata header.
1200
- workflows_referenced = None
1201
- if serialized_flow_commands.referenced_workflows:
1202
- workflows_referenced = list(serialized_flow_commands.referenced_workflows)
1203
-
1204
- workflow_metadata = self._generate_workflow_metadata(
1205
- file_name=file_name,
1206
- engine_version=engine_version,
1309
+ # Default to workspace path
1310
+ relative_file_path = f"{request.file_name}.py"
1311
+ file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1312
+
1313
+ # Use provided creation date or default to current time
1314
+ creation_date = request.creation_date
1315
+ if creation_date is None:
1316
+ creation_date = datetime.now(tz=UTC)
1317
+
1318
+ # Generate metadata from the serialized commands
1319
+ try:
1320
+ workflow_metadata = self._generate_workflow_metadata_from_commands(
1321
+ serialized_flow_commands=request.serialized_flow_commands,
1322
+ file_name=request.file_name,
1207
1323
  creation_date=creation_date,
1208
- node_libraries_referenced=list(serialized_flow_commands.node_libraries_used),
1209
- workflows_referenced=workflows_referenced,
1210
- prior_workflow=prior_workflow,
1324
+ image_path=request.image_path,
1325
+ branched_from=request.branched_from,
1326
+ workflow_shape=request.workflow_shape,
1327
+ )
1328
+ except Exception as err:
1329
+ details = f"Attempted to save workflow file '{request.file_name}' from serialized flow commands. Failed during metadata generation: {err}"
1330
+ return SaveWorkflowFileFromSerializedFlowResultFailure(result_details=details)
1331
+
1332
+ # Use provided execution flow name or default to file name
1333
+ execution_flow_name = request.execution_flow_name
1334
+ if execution_flow_name is None:
1335
+ execution_flow_name = request.file_name
1336
+
1337
+ # Generate the workflow file content
1338
+ try:
1339
+ final_code_output = self._generate_workflow_file_content(
1340
+ serialized_flow_commands=request.serialized_flow_commands,
1341
+ workflow_metadata=workflow_metadata,
1342
+ execution_flow_name=execution_flow_name,
1211
1343
  )
1212
- if workflow_metadata is None:
1213
- details = f"Failed to generate metadata for workflow '{file_name}'."
1214
- raise ValueError(details)
1344
+ except Exception as err:
1345
+ details = f"Attempted to save workflow file '{request.file_name}' from serialized flow commands. Failed during content generation: {err}"
1346
+ return SaveWorkflowFileFromSerializedFlowResultFailure(result_details=details)
1347
+
1348
+ # Write the workflow file
1349
+ write_result = self._write_workflow_file(file_path, final_code_output, request.file_name)
1350
+ if not write_result.success:
1351
+ return SaveWorkflowFileFromSerializedFlowResultFailure(result_details=write_result.error_details)
1352
+
1353
+ details = f"Successfully saved workflow file at: {file_path}"
1354
+ return SaveWorkflowFileFromSerializedFlowResultSuccess(
1355
+ file_path=str(file_path),
1356
+ workflow_metadata=workflow_metadata,
1357
+ result_details=ResultDetails(message=details, level=logging.INFO),
1358
+ )
1359
+
1360
+ def _generate_workflow_metadata_from_commands( # noqa: PLR0913
1361
+ self,
1362
+ serialized_flow_commands: SerializedFlowCommands,
1363
+ file_name: str,
1364
+ creation_date: datetime,
1365
+ image_path: str | None = None,
1366
+ branched_from: str | None = None,
1367
+ workflow_shape: WorkflowShape | None = None,
1368
+ ) -> WorkflowMetadata:
1369
+ """Generate workflow metadata from serialized commands."""
1370
+ # Get the engine version
1371
+ engine_version_request = GetEngineVersionRequest()
1372
+ engine_version_result = GriptapeNodes.handle_request(request=engine_version_request)
1373
+ if not isinstance(engine_version_result, GetEngineVersionResultSuccess):
1374
+ details = f"Failed getting the engine version for workflow '{file_name}'."
1375
+ raise TypeError(details)
1376
+
1377
+ engine_version_success = cast("GetEngineVersionResultSuccess", engine_version_result)
1378
+ engine_version = f"{engine_version_success.major}.{engine_version_success.minor}.{engine_version_success.patch}"
1215
1379
 
1216
- # Set the image if provided
1217
- if image_path:
1218
- workflow_metadata.image = image_path
1380
+ # Create the Workflow Metadata header
1381
+ workflows_referenced = None
1382
+ if serialized_flow_commands.node_dependencies.referenced_workflows:
1383
+ workflows_referenced = list(serialized_flow_commands.node_dependencies.referenced_workflows)
1219
1384
 
1385
+ return WorkflowMetadata(
1386
+ name=str(file_name),
1387
+ schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION,
1388
+ engine_version_created_with=engine_version,
1389
+ node_libraries_referenced=list(serialized_flow_commands.node_dependencies.libraries),
1390
+ workflows_referenced=workflows_referenced,
1391
+ creation_date=creation_date,
1392
+ last_modified_date=datetime.now(tz=UTC),
1393
+ branched_from=branched_from,
1394
+ workflow_shape=workflow_shape,
1395
+ image=image_path,
1396
+ )
1397
+
1398
+ def _generate_workflow_file_content( # noqa: PLR0912, PLR0915, C901
1399
+ self,
1400
+ serialized_flow_commands: SerializedFlowCommands,
1401
+ workflow_metadata: WorkflowMetadata,
1402
+ execution_flow_name: str,
1403
+ ) -> str:
1404
+ """Generate workflow file content from serialized commands and metadata."""
1220
1405
  metadata_block = self._generate_workflow_metadata_header(workflow_metadata=workflow_metadata)
1221
1406
  if metadata_block is None:
1222
- details = f"Failed to generate metadata block for workflow '{file_name}'."
1407
+ details = f"Failed to generate metadata block for workflow '{workflow_metadata.name}'."
1223
1408
  raise ValueError(details)
1224
1409
 
1225
1410
  import_recorder = ImportRecorder()
1226
1411
  import_recorder.add_from_import("griptape_nodes.retained_mode.griptape_nodes", "GriptapeNodes")
1227
1412
 
1413
+ # Add imports from node dependencies
1414
+ for import_dep in serialized_flow_commands.node_dependencies.imports:
1415
+ if import_dep.class_name:
1416
+ import_recorder.add_from_import(import_dep.module, import_dep.class_name)
1417
+ else:
1418
+ import_recorder.add_import(import_dep.module)
1419
+
1228
1420
  ast_container = ASTContainer()
1229
1421
 
1230
1422
  prereq_code = self._generate_workflow_run_prerequisite_code(
@@ -1233,7 +1425,7 @@ class WorkflowManager:
1233
1425
  for node in prereq_code:
1234
1426
  ast_container.add_node(node)
1235
1427
 
1236
- # Generate unique values code AST node.
1428
+ # Generate unique values code AST node
1237
1429
  unique_values_node = self._generate_unique_values_code(
1238
1430
  unique_parameter_uuid_to_values=serialized_flow_commands.unique_parameter_uuid_to_values,
1239
1431
  prefix="top_level",
@@ -1241,7 +1433,10 @@ class WorkflowManager:
1241
1433
  )
1242
1434
  ast_container.add_node(unique_values_node)
1243
1435
 
1244
- # See if this serialized flow has a flow initialization command; if it does, we'll need to insert that.
1436
+ # Keep track of each flow and node index we've created
1437
+ flow_creation_index = 0
1438
+
1439
+ # See if this serialized flow has a flow initialization command; if it does, we'll need to insert that
1245
1440
  flow_initialization_command = serialized_flow_commands.flow_initialization_command
1246
1441
 
1247
1442
  match flow_initialization_command:
@@ -1263,7 +1458,7 @@ class WorkflowManager:
1263
1458
  # No initialization command, deserialize into current context
1264
1459
  pass
1265
1460
 
1266
- # Generate assign flow context AST node, if we have any children commands.
1461
+ # Generate assign flow context AST node, if we have any children commands
1267
1462
  # Skip content generation for referenced workflows - they should only have the import command
1268
1463
  is_referenced_workflow = isinstance(flow_initialization_command, ImportWorkflowAsReferencedSubFlowRequest)
1269
1464
  has_content_to_serialize = (
@@ -1275,12 +1470,15 @@ class WorkflowManager:
1275
1470
  )
1276
1471
 
1277
1472
  if not is_referenced_workflow and has_content_to_serialize:
1473
+ # Keep track of all of the nodes we create and the generated variable names for them
1474
+ node_uuid_to_node_variable_name: dict[SerializedNodeCommands.NodeUUID, str] = {}
1475
+
1278
1476
  # Create the "with..." statement
1279
1477
  assign_flow_context_node = self._generate_assign_flow_context(
1280
1478
  flow_initialization_command=flow_initialization_command, flow_creation_index=flow_creation_index
1281
1479
  )
1282
1480
 
1283
- # Generate nodes in flow AST node. This will create the node and apply all element modifiers.
1481
+ # Generate nodes in flow AST node. This will create the node and apply all element modifiers
1284
1482
  nodes_in_flow = self._generate_nodes_in_flow(
1285
1483
  serialized_flow_commands, import_recorder, node_uuid_to_node_variable_name
1286
1484
  )
@@ -1307,7 +1505,7 @@ class WorkflowManager:
1307
1505
  )
1308
1506
  assign_flow_context_node.body.append(cast("ast.stmt", sub_flow_import_node))
1309
1507
 
1310
- # Now generate the connection code and add it to the flow context.
1508
+ # Now generate the connection code and add it to the flow context
1311
1509
  connection_asts = self._generate_connections_code(
1312
1510
  serialized_connections=serialized_flow_commands.serialized_connections,
1313
1511
  node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
@@ -1315,7 +1513,7 @@ class WorkflowManager:
1315
1513
  )
1316
1514
  assign_flow_context_node.body.extend(connection_asts)
1317
1515
 
1318
- # Now generate all the set parameter value code and add it to the flow context.
1516
+ # Generate parameter values
1319
1517
  set_parameter_value_asts = self._generate_set_parameter_value_code(
1320
1518
  set_parameter_value_commands=serialized_flow_commands.set_parameter_value_commands,
1321
1519
  lock_commands=serialized_flow_commands.set_lock_commands_per_node,
@@ -1327,166 +1525,20 @@ class WorkflowManager:
1327
1525
 
1328
1526
  ast_container.add_node(assign_flow_context_node)
1329
1527
 
1330
- workflow_execution_code = (
1331
- self._generate_workflow_execution(
1332
- flow_name=top_level_flow_name,
1333
- import_recorder=import_recorder,
1334
- )
1335
- if top_level_flow_name
1336
- else None
1528
+ # Generate workflow execution code
1529
+ workflow_execution_code = self._generate_workflow_execution(
1530
+ flow_name=execution_flow_name,
1531
+ import_recorder=import_recorder,
1532
+ workflow_metadata=workflow_metadata,
1337
1533
  )
1338
1534
  if workflow_execution_code is not None:
1339
1535
  for node in workflow_execution_code:
1340
1536
  ast_container.add_node(node)
1341
1537
 
1342
- # TODO: https://github.com/griptape-ai/griptape-nodes/issues/1190 do child workflows
1343
-
1344
1538
  # Generate final code from ASTContainer
1345
1539
  ast_output = "\n\n".join([ast.unparse(node) for node in ast_container.get_ast()])
1346
1540
  import_output = import_recorder.generate_imports()
1347
- final_code_output = f"{metadata_block}\n\n{import_output}\n\n{ast_output}\n"
1348
-
1349
- return final_code_output, workflow_metadata
1350
-
1351
- def on_save_workflow_request(self, request: SaveWorkflowRequest) -> ResultPayload: # noqa: C901, PLR0912, PLR0915
1352
- # Start with the file name provided; we may change it.
1353
- file_name = request.file_name
1354
-
1355
- # See if we had an existing workflow for this.
1356
- prior_workflow = None
1357
- creation_date = None
1358
- if file_name and WorkflowRegistry.has_workflow_with_name(file_name):
1359
- # Get the metadata.
1360
- prior_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1361
- # We'll use its creation date.
1362
- creation_date = prior_workflow.metadata.creation_date
1363
- elif file_name:
1364
- # If no prior workflow exists for the new name, check if there's a current workflow
1365
- # context (e.g., during rename operations) to preserve metadata from
1366
- context_manager = GriptapeNodes.ContextManager()
1367
- if context_manager.has_current_workflow():
1368
- current_workflow_name = context_manager.get_current_workflow_name()
1369
- if current_workflow_name and WorkflowRegistry.has_workflow_with_name(current_workflow_name):
1370
- prior_workflow = WorkflowRegistry.get_workflow_by_name(current_workflow_name)
1371
- creation_date = prior_workflow.metadata.creation_date
1372
-
1373
- if (creation_date is None) or (creation_date == WorkflowManager.EPOCH_START):
1374
- # Either a new workflow, or a backcompat situation.
1375
- creation_date = datetime.now(tz=UTC)
1376
-
1377
- # Let's see if this is a template file; if so, re-route it as a copy in the customer's workflow directory.
1378
- if prior_workflow and prior_workflow.metadata.is_template:
1379
- # Aha! User is attempting to save a template. Create a differently-named file in their workspace.
1380
- # Find the first available file name that doesn't conflict.
1381
- curr_idx = 1
1382
- free_file_found = False
1383
- while not free_file_found:
1384
- # Composite a new candidate file name to test.
1385
- new_file_name = f"{file_name}_{curr_idx}"
1386
- new_file_name_with_extension = f"{new_file_name}.py"
1387
- new_file_full_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(new_file_name_with_extension)
1388
- if new_file_full_path.exists():
1389
- # Keep going.
1390
- curr_idx += 1
1391
- else:
1392
- free_file_found = True
1393
- file_name = new_file_name
1394
-
1395
- # Get file name stuff prepped.
1396
- # Use the existing registered file path if this is an existing workflow (not a template)
1397
- if prior_workflow and not prior_workflow.metadata.is_template:
1398
- # Use the existing registered file path
1399
- relative_file_path = prior_workflow.file_path
1400
- file_path = Path(WorkflowRegistry.get_complete_file_path(relative_file_path))
1401
- # Extract file name from the path for metadata generation
1402
- if not file_name:
1403
- file_name = prior_workflow.metadata.name
1404
- else:
1405
- # Create new path in workspace for new workflows or templates
1406
- if not file_name:
1407
- file_name = datetime.now(tz=UTC).strftime("%d.%m_%H.%M")
1408
- relative_file_path = f"{file_name}.py"
1409
- file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1410
-
1411
- # Generate the workflow file contents
1412
- try:
1413
- final_code_output, workflow_metadata = self._generate_workflow_file_contents_and_metadata(
1414
- file_name=file_name,
1415
- creation_date=creation_date,
1416
- image_path=request.image_path,
1417
- prior_workflow=prior_workflow,
1418
- )
1419
- except Exception as err:
1420
- details = f"Attempted to save workflow '{relative_file_path}', but {err}"
1421
- return SaveWorkflowResultFailure(result_details=details)
1422
-
1423
- # Create the pathing and write the file
1424
- try:
1425
- file_path.parent.mkdir(parents=True, exist_ok=True)
1426
- except OSError as e:
1427
- details = f"Attempted to save workflow '{file_name}'. Failed when creating directory: {e}"
1428
- return SaveWorkflowResultFailure(result_details=details)
1429
-
1430
- # Check disk space before writing
1431
- config_manager = GriptapeNodes.ConfigManager()
1432
- min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_workflows")
1433
- if not OSManager.check_available_disk_space(file_path.parent, min_space_gb):
1434
- error_msg = OSManager.format_disk_space_error(file_path.parent)
1435
- details = f"Attempted to save workflow '{file_name}' (requires {min_space_gb:.1f} GB). Failed: {error_msg}"
1436
- return SaveWorkflowResultFailure(result_details=details)
1437
-
1438
- try:
1439
- with file_path.open("w", encoding="utf-8") as file:
1440
- file.write(final_code_output)
1441
- except OSError as e:
1442
- details = f"Attempted to save workflow '{file_name}'. Failed when writing file: {e}"
1443
- return SaveWorkflowResultFailure(result_details=details)
1444
-
1445
- # save the created workflow as an entry in the JSON config file.
1446
- registered_workflows = WorkflowRegistry.list_workflows()
1447
- if file_name not in registered_workflows:
1448
- # Only add to config if it's in the workspace directory (not an external file)
1449
- if not Path(relative_file_path).is_absolute():
1450
- try:
1451
- GriptapeNodes.ConfigManager().save_user_workflow_json(str(file_path))
1452
- except OSError as e:
1453
- details = f"Attempted to save workflow '{file_name}'. Failed when saving configuration: {e}"
1454
- return SaveWorkflowResultFailure(result_details=details)
1455
- WorkflowRegistry.generate_new_workflow(metadata=workflow_metadata, file_path=relative_file_path)
1456
- # Update existing workflow's metadata in the registry
1457
- existing_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1458
- existing_workflow.metadata = workflow_metadata
1459
- details = f"Successfully saved workflow to: {file_path}"
1460
- return SaveWorkflowResultSuccess(
1461
- file_path=str(file_path), result_details=ResultDetails(message=details, level=logging.INFO)
1462
- )
1463
-
1464
- def _generate_workflow_metadata( # noqa: PLR0913
1465
- self,
1466
- file_name: str,
1467
- engine_version: str,
1468
- creation_date: datetime,
1469
- node_libraries_referenced: list[LibraryNameAndVersion],
1470
- workflows_referenced: list[str] | None = None,
1471
- prior_workflow: Workflow | None = None,
1472
- ) -> WorkflowMetadata | None:
1473
- # Preserve branched workflow information if it exists
1474
- branched_from = None
1475
- if prior_workflow and prior_workflow.metadata.branched_from:
1476
- branched_from = prior_workflow.metadata.branched_from
1477
-
1478
- workflow_metadata = WorkflowMetadata(
1479
- name=str(file_name),
1480
- schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION,
1481
- engine_version_created_with=engine_version,
1482
- node_libraries_referenced=node_libraries_referenced,
1483
- workflows_referenced=workflows_referenced,
1484
- creation_date=creation_date,
1485
- last_modified_date=datetime.now(tz=UTC),
1486
- branched_from=branched_from,
1487
- )
1488
-
1489
- return workflow_metadata
1541
+ return f"{metadata_block}\n\n{import_output}\n\n{ast_output}\n"
1490
1542
 
1491
1543
  def _replace_workflow_metadata_header(self, workflow_content: str, new_metadata: WorkflowMetadata) -> str | None:
1492
1544
  """Replace the metadata header in a workflow file with new metadata.
@@ -1516,11 +1568,13 @@ class WorkflowManager:
1516
1568
  toml_doc = tomlkit.document()
1517
1569
  toml_doc.add("dependencies", tomlkit.item([]))
1518
1570
  griptape_tool_table = tomlkit.table()
1519
- metadata_dict = workflow_metadata.model_dump()
1571
+ # Strip out the Nones since TOML doesn't like those
1572
+ # WorkflowShape is now serialized as JSON string by Pydantic field_serializer;
1573
+ # this preserves the nil/null/None values that we WANT, but for all of the
1574
+ # Python-related Nones, TOML will flip out if they are not stripped.
1575
+ metadata_dict = workflow_metadata.model_dump(exclude_none=True)
1520
1576
  for key, value in metadata_dict.items():
1521
- # Strip out the Nones since TOML doesn't like those.
1522
- if value is not None:
1523
- griptape_tool_table.add(key=key, value=value)
1577
+ griptape_tool_table.add(key=key, value=value)
1524
1578
  toml_doc["tool"] = tomlkit.table()
1525
1579
  toml_doc["tool"]["griptape-nodes"] = griptape_tool_table # type: ignore (this is the only way I could find to get tomlkit to do the dotted notation correctly)
1526
1580
  except Exception as err:
@@ -1545,14 +1599,20 @@ class WorkflowManager:
1545
1599
  self,
1546
1600
  flow_name: str,
1547
1601
  import_recorder: ImportRecorder,
1602
+ workflow_metadata: WorkflowMetadata,
1548
1603
  ) -> list[ast.AST] | None:
1549
1604
  """Generates execute_workflow(...) and the __main__ guard."""
1550
- try:
1551
- workflow_shape = self.extract_workflow_shape(flow_name)
1552
- except ValueError:
1605
+ # Use workflow shape from metadata if available, otherwise skip execution block
1606
+ if workflow_metadata.workflow_shape is None:
1553
1607
  logger.info("Workflow shape does not have required Start or End Nodes. Skipping local execution block.")
1554
1608
  return None
1555
1609
 
1610
+ # Convert WorkflowShape to dict format expected by the rest of the method
1611
+ workflow_shape = {
1612
+ "input": workflow_metadata.workflow_shape.inputs,
1613
+ "output": workflow_metadata.workflow_shape.outputs,
1614
+ }
1615
+
1556
1616
  # === imports ===
1557
1617
  import_recorder.add_import("argparse")
1558
1618
  import_recorder.add_import("asyncio")
@@ -1936,6 +1996,17 @@ class WorkflowManager:
1936
1996
  ]
1937
1997
  )
1938
1998
 
1999
+ # Ensure body is not empty - add pass statement if no input parameters
2000
+ if not build_flow_input_stmts:
2001
+ build_flow_input_stmts = [
2002
+ ast.Expr(
2003
+ value=ast.Constant(
2004
+ value="This workflow has no input parameters defined, so there's nothing necessary to supply"
2005
+ )
2006
+ ),
2007
+ ast.Pass(),
2008
+ ]
2009
+
1939
2010
  # Wrap the individual argument processing in an else clause
1940
2011
  individual_args_else = ast.If(
1941
2012
  test=ast.Compare(
@@ -2608,7 +2679,7 @@ class WorkflowManager:
2608
2679
  )
2609
2680
 
2610
2681
  if flow_initialization_command is None:
2611
- # Construct AST for "GriptapeNodes.ContextManager().flow(GriptapeNodes.ContextManager().get_current_flow_name())"
2682
+ # Construct AST for "GriptapeNodes.ContextManager().flow(GriptapeNodes.ContextManager().get_current_flow().flow_name)"
2612
2683
  flow_call = ast.Call(
2613
2684
  func=ast.Attribute(
2614
2685
  value=ast.Call(func=context_manager, args=[], keywords=[], lineno=1, col_offset=0),
@@ -2618,16 +2689,22 @@ class WorkflowManager:
2618
2689
  col_offset=0,
2619
2690
  ),
2620
2691
  args=[
2621
- ast.Call(
2622
- func=ast.Attribute(
2623
- value=ast.Call(func=context_manager, args=[], keywords=[], lineno=1, col_offset=0),
2624
- attr="get_current_flow_name",
2625
- ctx=ast.Load(),
2692
+ ast.Attribute(
2693
+ value=ast.Call(
2694
+ func=ast.Attribute(
2695
+ value=ast.Call(func=context_manager, args=[], keywords=[], lineno=1, col_offset=0),
2696
+ attr="get_current_flow",
2697
+ ctx=ast.Load(),
2698
+ lineno=1,
2699
+ col_offset=0,
2700
+ ),
2701
+ args=[],
2702
+ keywords=[],
2626
2703
  lineno=1,
2627
2704
  col_offset=0,
2628
2705
  ),
2629
- args=[],
2630
- keywords=[],
2706
+ attr="flow_name",
2707
+ ctx=ast.Load(),
2631
2708
  lineno=1,
2632
2709
  col_offset=0,
2633
2710
  )
@@ -3101,17 +3178,12 @@ class WorkflowManager:
3101
3178
  for node in nodes:
3102
3179
  for param in node.parameters:
3103
3180
  # Expose only the parameters that are relevant for workflow input and output.
3104
- # Excluding list types as the individual parameters are exposed in the workflow shape.
3105
- # TODO (https://github.com/griptape-ai/griptape-nodes/issues/1090): This is a temporary solution until we know how to handle container types.
3106
- if param.type != ParameterTypeBuiltin.CONTROL_TYPE.value and not param.type.startswith("list"):
3181
+ param_info = self.extract_parameter_shape_info(param, include_control_params=True)
3182
+ if param_info is not None:
3107
3183
  if node.name in workflow_shape[workflow_shape_type]:
3108
- cast("dict", workflow_shape[workflow_shape_type][node.name])[param.name] = (
3109
- self._convert_parameter_to_minimal_dict(param)
3110
- )
3184
+ cast("dict", workflow_shape[workflow_shape_type][node.name])[param.name] = param_info
3111
3185
  else:
3112
- workflow_shape[workflow_shape_type][node.name] = {
3113
- param.name: self._convert_parameter_to_minimal_dict(param)
3114
- }
3186
+ workflow_shape[workflow_shape_type][node.name] = {param.name: param_info}
3115
3187
  return workflow_shape
3116
3188
 
3117
3189
  def extract_workflow_shape(self, workflow_name: str) -> dict[str, Any]:
@@ -3162,6 +3234,50 @@ class WorkflowManager:
3162
3234
 
3163
3235
  return workflow_shape
3164
3236
 
3237
+ def extract_parameter_shape_info(
3238
+ self, parameter: Parameter, *, include_control_params: bool
3239
+ ) -> ParameterShapeInfo | None:
3240
+ """Extract shape information from a parameter for workflow shape building.
3241
+
3242
+ Expose only the parameters that are relevant for workflow input and output.
3243
+
3244
+ Args:
3245
+ parameter: The parameter to extract shape info from
3246
+ include_control_params: Whether to include control type parameters (default: False)
3247
+
3248
+ Returns:
3249
+ Parameter info dict if relevant for workflow shape, None if should be excluded
3250
+ """
3251
+ # TODO (https://github.com/griptape-ai/griptape-nodes/issues/1090): This is a temporary solution until we know how to handle container types.
3252
+ # Always exclude list types until container type handling is implemented
3253
+ if parameter.type.startswith("list"):
3254
+ logger.warning(
3255
+ "Skipping list parameter '%s' of type '%s' in workflow shape - container types not yet supported",
3256
+ parameter.name,
3257
+ parameter.type,
3258
+ )
3259
+ return None
3260
+
3261
+ # Conditionally exclude control types
3262
+ if not include_control_params and parameter.type == ParameterTypeBuiltin.CONTROL_TYPE.value:
3263
+ return None
3264
+
3265
+ return self._convert_parameter_to_minimal_dict(parameter)
3266
+
3267
+ def build_workflow_shape_from_parameter_info(
3268
+ self, input_node_params: WorkflowShapeNodes, output_node_params: WorkflowShapeNodes
3269
+ ) -> WorkflowShape:
3270
+ """Build a WorkflowShape from collected parameter information.
3271
+
3272
+ Args:
3273
+ input_node_params: Mapping of input node names to their parameter info
3274
+ output_node_params: Mapping of output node names to their parameter info
3275
+
3276
+ Returns:
3277
+ WorkflowShape object with inputs and outputs
3278
+ """
3279
+ return WorkflowShape(inputs=input_node_params, outputs=output_node_params)
3280
+
3165
3281
  async def on_publish_workflow_request(self, request: PublishWorkflowRequest) -> ResultPayload:
3166
3282
  try:
3167
3283
  publisher_name = request.publisher_name
@@ -3178,12 +3294,80 @@ class WorkflowManager:
3178
3294
  if isinstance(result, PublishWorkflowResultSuccess):
3179
3295
  workflow_file = Path(result.published_workflow_file_path)
3180
3296
  result = self._register_published_workflow_file(workflow_file, result)
3297
+
3298
+ if request.execute_on_publish:
3299
+ await self._handle_execute_on_publish(request, workflow_file)
3300
+
3181
3301
  return result # noqa: TRY300
3182
3302
  except Exception as e:
3183
3303
  details = f"Failed to publish workflow '{request.workflow_name}': {e!s}"
3184
3304
  logger.exception(details)
3185
3305
  return PublishWorkflowResultFailure(exception=e, result_details=details)
3186
3306
 
3307
+ async def _handle_execute_on_publish(self, request: PublishWorkflowRequest, workflow_file: Path) -> None:
3308
+ class ExecuteOnPublishError(Exception):
3309
+ pass
3310
+
3311
+ # Create an event to track when we get a start workflow result
3312
+ start_result_received = asyncio.Event()
3313
+ exception: ExecuteOnPublishError | None = None
3314
+
3315
+ def invoke_done_callback(future: asyncio.Future) -> None:
3316
+ nonlocal exception
3317
+ try:
3318
+ future.result()
3319
+ except Exception as e:
3320
+ msg = f"Error during invocation of published workflow '{request.workflow_name}': {e!s}"
3321
+ logger.exception(msg)
3322
+ exception = ExecuteOnPublishError(e)
3323
+ start_result_received.set()
3324
+
3325
+ def on_start_flow_result(start_result: ResultPayload) -> None:
3326
+ nonlocal exception
3327
+ if isinstance(start_result, StartFlowResultSuccess):
3328
+ msg = f"Successfully started published workflow '{request.workflow_name}'"
3329
+ logger.info(msg)
3330
+ elif isinstance(start_result, StartFlowResultFailure):
3331
+ msg = f"Failed to invoke published workflow '{request.workflow_name}': {start_result.exception}"
3332
+ logger.exception(msg)
3333
+ exceptions = start_result.validation_exceptions
3334
+ exceptions.append(start_result.exception) if start_result.exception else None
3335
+ exception = ExecuteOnPublishError(exceptions)
3336
+
3337
+ start_result_received.set()
3338
+
3339
+ invoke_task = asyncio.create_task(
3340
+ self._invoke_published_workflow(request.workflow_name, workflow_file, on_start_flow_result)
3341
+ )
3342
+ invoke_task.add_done_callback(invoke_done_callback)
3343
+
3344
+ # Wait for StartFlow result
3345
+ try:
3346
+ await asyncio.wait_for(start_result_received.wait(), timeout=10.0)
3347
+ if exception is not None:
3348
+ raise exception
3349
+ except TimeoutError:
3350
+ # Timeout occurred - don't block on subprocess workflow execution, just log a warning
3351
+ msg = f"Timeout waiting for workflow '{request.workflow_name}' StartFlow result."
3352
+ logger.warning(msg)
3353
+
3354
+ async def _invoke_published_workflow(
3355
+ self, workflow_name: str, workflow_path: Path, on_start_flow_result: Callable[[ResultPayload], None]
3356
+ ) -> None:
3357
+ from griptape_nodes.bootstrap.workflow_executors.subprocess_workflow_executor import SubprocessWorkflowExecutor
3358
+
3359
+ subprocess_executor = SubprocessWorkflowExecutor(
3360
+ workflow_path=str(workflow_path), on_start_flow_result=on_start_flow_result
3361
+ )
3362
+ storage_backend_value = GriptapeNodes.ConfigManager().get_config_value("storage_backend")
3363
+
3364
+ async with subprocess_executor as executor:
3365
+ await executor.arun(
3366
+ workflow_name=workflow_name,
3367
+ flow_input={},
3368
+ storage_backend=StorageBackend(value=storage_backend_value),
3369
+ )
3370
+
3187
3371
  def _register_published_workflow_file(
3188
3372
  self, workflow_file: Path, result: PublishWorkflowResultSuccess
3189
3373
  ) -> ResultPayload: