griptape-nodes 0.55.0__py3-none-any.whl → 0.56.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 (35) hide show
  1. griptape_nodes/cli/commands/init.py +88 -0
  2. griptape_nodes/cli/commands/models.py +2 -0
  3. griptape_nodes/cli/shared.py +1 -0
  4. griptape_nodes/exe_types/core_types.py +104 -5
  5. griptape_nodes/exe_types/node_types.py +9 -12
  6. griptape_nodes/machines/control_flow.py +10 -0
  7. griptape_nodes/machines/dag_builder.py +21 -2
  8. griptape_nodes/machines/parallel_resolution.py +25 -10
  9. griptape_nodes/node_library/workflow_registry.py +73 -3
  10. griptape_nodes/retained_mode/events/execution_events.py +12 -2
  11. griptape_nodes/retained_mode/events/flow_events.py +58 -0
  12. griptape_nodes/retained_mode/events/resource_events.py +290 -0
  13. griptape_nodes/retained_mode/events/workflow_events.py +57 -2
  14. griptape_nodes/retained_mode/griptape_nodes.py +9 -1
  15. griptape_nodes/retained_mode/managers/flow_manager.py +678 -12
  16. griptape_nodes/retained_mode/managers/library_manager.py +13 -19
  17. griptape_nodes/retained_mode/managers/model_manager.py +184 -83
  18. griptape_nodes/retained_mode/managers/node_manager.py +3 -3
  19. griptape_nodes/retained_mode/managers/os_manager.py +118 -1
  20. griptape_nodes/retained_mode/managers/resource_components/__init__.py +1 -0
  21. griptape_nodes/retained_mode/managers/resource_components/capability_field.py +41 -0
  22. griptape_nodes/retained_mode/managers/resource_components/comparator.py +18 -0
  23. griptape_nodes/retained_mode/managers/resource_components/resource_instance.py +236 -0
  24. griptape_nodes/retained_mode/managers/resource_components/resource_type.py +79 -0
  25. griptape_nodes/retained_mode/managers/resource_manager.py +306 -0
  26. griptape_nodes/retained_mode/managers/resource_types/__init__.py +1 -0
  27. griptape_nodes/retained_mode/managers/resource_types/cpu_resource.py +108 -0
  28. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +87 -0
  29. griptape_nodes/retained_mode/managers/settings.py +5 -0
  30. griptape_nodes/retained_mode/managers/sync_manager.py +10 -3
  31. griptape_nodes/retained_mode/managers/workflow_manager.py +359 -261
  32. {griptape_nodes-0.55.0.dist-info → griptape_nodes-0.56.0.dist-info}/METADATA +1 -1
  33. {griptape_nodes-0.55.0.dist-info → griptape_nodes-0.56.0.dist-info}/RECORD +35 -25
  34. {griptape_nodes-0.55.0.dist-info → griptape_nodes-0.56.0.dist-info}/WHEEL +1 -1
  35. {griptape_nodes-0.55.0.dist-info → griptape_nodes-0.56.0.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,
@@ -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,7 +117,6 @@ 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
121
  from griptape_nodes.retained_mode.events.node_events import SetLockNodeStateRequest
121
122
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
@@ -123,6 +124,10 @@ if TYPE_CHECKING:
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,111 +1124,287 @@ 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="")
1178
1168
 
1179
- # Serialize from the top.
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
1212
+
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,
1211
1327
  )
1212
- if workflow_metadata is None:
1213
- details = f"Failed to generate metadata for workflow '{file_name}'."
1214
- raise ValueError(details)
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)
1215
1331
 
1216
- # Set the image if provided
1217
- if image_path:
1218
- workflow_metadata.image = image_path
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
1219
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,
1343
+ )
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}"
1379
+
1380
+ # Create the Workflow Metadata header
1381
+ workflows_referenced = None
1382
+ if serialized_flow_commands.referenced_workflows:
1383
+ workflows_referenced = list(serialized_flow_commands.referenced_workflows)
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_libraries_used),
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, 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()
@@ -1233,7 +1418,7 @@ class WorkflowManager:
1233
1418
  for node in prereq_code:
1234
1419
  ast_container.add_node(node)
1235
1420
 
1236
- # Generate unique values code AST node.
1421
+ # Generate unique values code AST node
1237
1422
  unique_values_node = self._generate_unique_values_code(
1238
1423
  unique_parameter_uuid_to_values=serialized_flow_commands.unique_parameter_uuid_to_values,
1239
1424
  prefix="top_level",
@@ -1241,7 +1426,10 @@ class WorkflowManager:
1241
1426
  )
1242
1427
  ast_container.add_node(unique_values_node)
1243
1428
 
1244
- # See if this serialized flow has a flow initialization command; if it does, we'll need to insert that.
1429
+ # Keep track of each flow and node index we've created
1430
+ flow_creation_index = 0
1431
+
1432
+ # See if this serialized flow has a flow initialization command; if it does, we'll need to insert that
1245
1433
  flow_initialization_command = serialized_flow_commands.flow_initialization_command
1246
1434
 
1247
1435
  match flow_initialization_command:
@@ -1263,7 +1451,7 @@ class WorkflowManager:
1263
1451
  # No initialization command, deserialize into current context
1264
1452
  pass
1265
1453
 
1266
- # Generate assign flow context AST node, if we have any children commands.
1454
+ # Generate assign flow context AST node, if we have any children commands
1267
1455
  # Skip content generation for referenced workflows - they should only have the import command
1268
1456
  is_referenced_workflow = isinstance(flow_initialization_command, ImportWorkflowAsReferencedSubFlowRequest)
1269
1457
  has_content_to_serialize = (
@@ -1275,12 +1463,15 @@ class WorkflowManager:
1275
1463
  )
1276
1464
 
1277
1465
  if not is_referenced_workflow and has_content_to_serialize:
1466
+ # Keep track of all of the nodes we create and the generated variable names for them
1467
+ node_uuid_to_node_variable_name: dict[SerializedNodeCommands.NodeUUID, str] = {}
1468
+
1278
1469
  # Create the "with..." statement
1279
1470
  assign_flow_context_node = self._generate_assign_flow_context(
1280
1471
  flow_initialization_command=flow_initialization_command, flow_creation_index=flow_creation_index
1281
1472
  )
1282
1473
 
1283
- # Generate nodes in flow AST node. This will create the node and apply all element modifiers.
1474
+ # Generate nodes in flow AST node. This will create the node and apply all element modifiers
1284
1475
  nodes_in_flow = self._generate_nodes_in_flow(
1285
1476
  serialized_flow_commands, import_recorder, node_uuid_to_node_variable_name
1286
1477
  )
@@ -1307,7 +1498,7 @@ class WorkflowManager:
1307
1498
  )
1308
1499
  assign_flow_context_node.body.append(cast("ast.stmt", sub_flow_import_node))
1309
1500
 
1310
- # Now generate the connection code and add it to the flow context.
1501
+ # Now generate the connection code and add it to the flow context
1311
1502
  connection_asts = self._generate_connections_code(
1312
1503
  serialized_connections=serialized_flow_commands.serialized_connections,
1313
1504
  node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
@@ -1315,7 +1506,7 @@ class WorkflowManager:
1315
1506
  )
1316
1507
  assign_flow_context_node.body.extend(connection_asts)
1317
1508
 
1318
- # Now generate all the set parameter value code and add it to the flow context.
1509
+ # Generate parameter values
1319
1510
  set_parameter_value_asts = self._generate_set_parameter_value_code(
1320
1511
  set_parameter_value_commands=serialized_flow_commands.set_parameter_value_commands,
1321
1512
  lock_commands=serialized_flow_commands.set_lock_commands_per_node,
@@ -1327,166 +1518,20 @@ class WorkflowManager:
1327
1518
 
1328
1519
  ast_container.add_node(assign_flow_context_node)
1329
1520
 
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
1521
+ # Generate workflow execution code
1522
+ workflow_execution_code = self._generate_workflow_execution(
1523
+ flow_name=execution_flow_name,
1524
+ import_recorder=import_recorder,
1525
+ workflow_metadata=workflow_metadata,
1337
1526
  )
1338
1527
  if workflow_execution_code is not None:
1339
1528
  for node in workflow_execution_code:
1340
1529
  ast_container.add_node(node)
1341
1530
 
1342
- # TODO: https://github.com/griptape-ai/griptape-nodes/issues/1190 do child workflows
1343
-
1344
1531
  # Generate final code from ASTContainer
1345
1532
  ast_output = "\n\n".join([ast.unparse(node) for node in ast_container.get_ast()])
1346
1533
  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
1534
+ return f"{metadata_block}\n\n{import_output}\n\n{ast_output}\n"
1490
1535
 
1491
1536
  def _replace_workflow_metadata_header(self, workflow_content: str, new_metadata: WorkflowMetadata) -> str | None:
1492
1537
  """Replace the metadata header in a workflow file with new metadata.
@@ -1516,11 +1561,13 @@ class WorkflowManager:
1516
1561
  toml_doc = tomlkit.document()
1517
1562
  toml_doc.add("dependencies", tomlkit.item([]))
1518
1563
  griptape_tool_table = tomlkit.table()
1519
- metadata_dict = workflow_metadata.model_dump()
1564
+ # Strip out the Nones since TOML doesn't like those
1565
+ # WorkflowShape is now serialized as JSON string by Pydantic field_serializer;
1566
+ # this preserves the nil/null/None values that we WANT, but for all of the
1567
+ # Python-related Nones, TOML will flip out if they are not stripped.
1568
+ metadata_dict = workflow_metadata.model_dump(exclude_none=True)
1520
1569
  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)
1570
+ griptape_tool_table.add(key=key, value=value)
1524
1571
  toml_doc["tool"] = tomlkit.table()
1525
1572
  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
1573
  except Exception as err:
@@ -1545,14 +1592,20 @@ class WorkflowManager:
1545
1592
  self,
1546
1593
  flow_name: str,
1547
1594
  import_recorder: ImportRecorder,
1595
+ workflow_metadata: WorkflowMetadata,
1548
1596
  ) -> list[ast.AST] | None:
1549
1597
  """Generates execute_workflow(...) and the __main__ guard."""
1550
- try:
1551
- workflow_shape = self.extract_workflow_shape(flow_name)
1552
- except ValueError:
1598
+ # Use workflow shape from metadata if available, otherwise skip execution block
1599
+ if workflow_metadata.workflow_shape is None:
1553
1600
  logger.info("Workflow shape does not have required Start or End Nodes. Skipping local execution block.")
1554
1601
  return None
1555
1602
 
1603
+ # Convert WorkflowShape to dict format expected by the rest of the method
1604
+ workflow_shape = {
1605
+ "input": workflow_metadata.workflow_shape.inputs,
1606
+ "output": workflow_metadata.workflow_shape.outputs,
1607
+ }
1608
+
1556
1609
  # === imports ===
1557
1610
  import_recorder.add_import("argparse")
1558
1611
  import_recorder.add_import("asyncio")
@@ -2608,7 +2661,7 @@ class WorkflowManager:
2608
2661
  )
2609
2662
 
2610
2663
  if flow_initialization_command is None:
2611
- # Construct AST for "GriptapeNodes.ContextManager().flow(GriptapeNodes.ContextManager().get_current_flow_name())"
2664
+ # Construct AST for "GriptapeNodes.ContextManager().flow(GriptapeNodes.ContextManager().get_current_flow().flow_name)"
2612
2665
  flow_call = ast.Call(
2613
2666
  func=ast.Attribute(
2614
2667
  value=ast.Call(func=context_manager, args=[], keywords=[], lineno=1, col_offset=0),
@@ -2618,16 +2671,22 @@ class WorkflowManager:
2618
2671
  col_offset=0,
2619
2672
  ),
2620
2673
  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(),
2674
+ ast.Attribute(
2675
+ value=ast.Call(
2676
+ func=ast.Attribute(
2677
+ value=ast.Call(func=context_manager, args=[], keywords=[], lineno=1, col_offset=0),
2678
+ attr="get_current_flow",
2679
+ ctx=ast.Load(),
2680
+ lineno=1,
2681
+ col_offset=0,
2682
+ ),
2683
+ args=[],
2684
+ keywords=[],
2626
2685
  lineno=1,
2627
2686
  col_offset=0,
2628
2687
  ),
2629
- args=[],
2630
- keywords=[],
2688
+ attr="flow_name",
2689
+ ctx=ast.Load(),
2631
2690
  lineno=1,
2632
2691
  col_offset=0,
2633
2692
  )
@@ -3101,17 +3160,12 @@ class WorkflowManager:
3101
3160
  for node in nodes:
3102
3161
  for param in node.parameters:
3103
3162
  # 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"):
3163
+ param_info = self.extract_parameter_shape_info(param, include_control_params=False)
3164
+ if param_info is not None:
3107
3165
  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
- )
3166
+ cast("dict", workflow_shape[workflow_shape_type][node.name])[param.name] = param_info
3111
3167
  else:
3112
- workflow_shape[workflow_shape_type][node.name] = {
3113
- param.name: self._convert_parameter_to_minimal_dict(param)
3114
- }
3168
+ workflow_shape[workflow_shape_type][node.name] = {param.name: param_info}
3115
3169
  return workflow_shape
3116
3170
 
3117
3171
  def extract_workflow_shape(self, workflow_name: str) -> dict[str, Any]:
@@ -3162,6 +3216,50 @@ class WorkflowManager:
3162
3216
 
3163
3217
  return workflow_shape
3164
3218
 
3219
+ def extract_parameter_shape_info(
3220
+ self, parameter: Parameter, *, include_control_params: bool = False
3221
+ ) -> ParameterShapeInfo | None:
3222
+ """Extract shape information from a parameter for workflow shape building.
3223
+
3224
+ Expose only the parameters that are relevant for workflow input and output.
3225
+
3226
+ Args:
3227
+ parameter: The parameter to extract shape info from
3228
+ include_control_params: Whether to include control type parameters (default: False)
3229
+
3230
+ Returns:
3231
+ Parameter info dict if relevant for workflow shape, None if should be excluded
3232
+ """
3233
+ # TODO (https://github.com/griptape-ai/griptape-nodes/issues/1090): This is a temporary solution until we know how to handle container types.
3234
+ # Always exclude list types until container type handling is implemented
3235
+ if parameter.type.startswith("list"):
3236
+ logger.warning(
3237
+ "Skipping list parameter '%s' of type '%s' in workflow shape - container types not yet supported",
3238
+ parameter.name,
3239
+ parameter.type,
3240
+ )
3241
+ return None
3242
+
3243
+ # Conditionally exclude control types
3244
+ if not include_control_params and parameter.type == ParameterTypeBuiltin.CONTROL_TYPE.value:
3245
+ return None
3246
+
3247
+ return self._convert_parameter_to_minimal_dict(parameter)
3248
+
3249
+ def build_workflow_shape_from_parameter_info(
3250
+ self, input_node_params: WorkflowShapeNodes, output_node_params: WorkflowShapeNodes
3251
+ ) -> WorkflowShape:
3252
+ """Build a WorkflowShape from collected parameter information.
3253
+
3254
+ Args:
3255
+ input_node_params: Mapping of input node names to their parameter info
3256
+ output_node_params: Mapping of output node names to their parameter info
3257
+
3258
+ Returns:
3259
+ WorkflowShape object with inputs and outputs
3260
+ """
3261
+ return WorkflowShape(inputs=input_node_params, outputs=output_node_params)
3262
+
3165
3263
  async def on_publish_workflow_request(self, request: PublishWorkflowRequest) -> ResultPayload:
3166
3264
  try:
3167
3265
  publisher_name = request.publisher_name