griptape-nodes 0.64.11__py3-none-any.whl → 0.65.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 (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +77 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1134 -138
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/entry_points.txt +0 -0
@@ -14,7 +14,6 @@ from pathlib import Path
14
14
  from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypeVar, cast
15
15
 
16
16
  import aiofiles
17
- import portalocker
18
17
  import semver
19
18
  import tomlkit
20
19
  from rich.box import HEAVY_EDGE
@@ -59,7 +58,14 @@ from griptape_nodes.retained_mode.events.library_events import (
59
58
  ListRegisteredLibrariesRequest,
60
59
  ListRegisteredLibrariesResultSuccess,
61
60
  )
61
+ from griptape_nodes.retained_mode.events.node_events import CreateNodeGroupRequest
62
62
  from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStateRequest
63
+ from griptape_nodes.retained_mode.events.os_events import (
64
+ ExistingFilePolicy,
65
+ FileIOFailureReason,
66
+ WriteFileRequest,
67
+ WriteFileResultFailure,
68
+ )
63
69
  from griptape_nodes.retained_mode.events.workflow_events import (
64
70
  BranchWorkflowRequest,
65
71
  BranchWorkflowResultFailure,
@@ -70,6 +76,9 @@ from griptape_nodes.retained_mode.events.workflow_events import (
70
76
  DeleteWorkflowRequest,
71
77
  DeleteWorkflowResultFailure,
72
78
  DeleteWorkflowResultSuccess,
79
+ GetWorkflowMetadataRequest,
80
+ GetWorkflowMetadataResultFailure,
81
+ GetWorkflowMetadataResultSuccess,
73
82
  ImportWorkflowAsReferencedSubFlowRequest,
74
83
  ImportWorkflowAsReferencedSubFlowResultFailure,
75
84
  ImportWorkflowAsReferencedSubFlowResultSuccess,
@@ -118,6 +127,9 @@ from griptape_nodes.retained_mode.events.workflow_events import (
118
127
  SaveWorkflowRequest,
119
128
  SaveWorkflowResultFailure,
120
129
  SaveWorkflowResultSuccess,
130
+ SetWorkflowMetadataRequest,
131
+ SetWorkflowMetadataResultFailure,
132
+ SetWorkflowMetadataResultSuccess,
121
133
  )
122
134
  from griptape_nodes.retained_mode.griptape_nodes import (
123
135
  GriptapeNodes,
@@ -332,6 +344,14 @@ class WorkflowManager:
332
344
  PublishWorkflowRequest,
333
345
  self.on_publish_workflow_request,
334
346
  )
347
+ event_manager.assign_manager_to_request_type(
348
+ SetWorkflowMetadataRequest,
349
+ self.on_set_workflow_metadata_request,
350
+ )
351
+ event_manager.assign_manager_to_request_type(
352
+ GetWorkflowMetadataRequest,
353
+ self.on_get_workflow_metadata_request,
354
+ )
335
355
  event_manager.assign_manager_to_request_type(
336
356
  ImportWorkflowAsReferencedSubFlowRequest,
337
357
  self.on_import_workflow_as_referenced_sub_flow_request,
@@ -833,10 +853,17 @@ class WorkflowManager:
833
853
  details = f"Attempted to rename workflow '{request.workflow_name}' to '{request.requested_name}'. Failed while attempting to save."
834
854
  return RenameWorkflowResultFailure(result_details=details)
835
855
 
836
- delete_workflow_result = await GriptapeNodes.ahandle_request(DeleteWorkflowRequest(name=request.workflow_name))
837
- if isinstance(delete_workflow_result, DeleteWorkflowResultFailure):
838
- details = f"Attempted to rename workflow '{request.workflow_name}' to '{request.requested_name}'. Failed while attempting to remove the original file name from the registry."
839
- return RenameWorkflowResultFailure(result_details=details)
856
+ # If the original workflow isn't registered, treat this as a Save As and skip deletion
857
+ if WorkflowRegistry.has_workflow_with_name(request.workflow_name):
858
+ delete_workflow_result = await GriptapeNodes.ahandle_request(
859
+ DeleteWorkflowRequest(name=request.workflow_name)
860
+ )
861
+ if isinstance(delete_workflow_result, DeleteWorkflowResultFailure):
862
+ details = (
863
+ f"Attempted to rename workflow '{request.workflow_name}' to '{request.requested_name}'. "
864
+ "Failed while attempting to remove the original file name from the registry."
865
+ )
866
+ return RenameWorkflowResultFailure(result_details=details)
840
867
 
841
868
  return RenameWorkflowResultSuccess(
842
869
  result_details=ResultDetails(
@@ -844,6 +871,96 @@ class WorkflowManager:
844
871
  )
845
872
  )
846
873
 
874
+ def on_get_workflow_metadata_request(self, request: GetWorkflowMetadataRequest) -> ResultPayload:
875
+ try:
876
+ workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
877
+ except KeyError:
878
+ details = f"Failed to get metadata. Workflow '{request.workflow_name}' not found."
879
+ return GetWorkflowMetadataResultFailure(result_details=details)
880
+
881
+ return GetWorkflowMetadataResultSuccess(
882
+ workflow_metadata=workflow.metadata,
883
+ result_details="Successfully retrieved workflow metadata.",
884
+ )
885
+
886
+ class WorkflowPathResolution(NamedTuple):
887
+ """Resolution result for workflow and its corresponding file path."""
888
+
889
+ workflow: Workflow | None
890
+ file_path: Path | None
891
+ error: str | None
892
+
893
+ def _get_workflow_and_path(self, workflow_name: str) -> WorkflowPathResolution:
894
+ """Resolve workflow from registry and return absolute file path."""
895
+ try:
896
+ workflow = WorkflowRegistry.get_workflow_by_name(workflow_name)
897
+ except KeyError:
898
+ return WorkflowManager.WorkflowPathResolution(
899
+ workflow=None, file_path=None, error=f"Failed to set metadata. Workflow '{workflow_name}' not found."
900
+ )
901
+
902
+ complete_file_path = WorkflowRegistry.get_complete_file_path(workflow.file_path)
903
+ file_path_obj = Path(complete_file_path)
904
+ if not file_path_obj.is_file():
905
+ return WorkflowManager.WorkflowPathResolution(
906
+ workflow=workflow,
907
+ file_path=None,
908
+ error=f"Failed to set metadata. File path '{complete_file_path}' does not exist.",
909
+ )
910
+
911
+ return WorkflowManager.WorkflowPathResolution(workflow=workflow, file_path=file_path_obj, error=None)
912
+
913
+ async def _write_metadata_header(self, file_path: Path, workflow_metadata: WorkflowMetadata) -> str | None:
914
+ """Replace the workflow header and persist changes to disk."""
915
+ try:
916
+ existing_content = file_path.read_text(encoding="utf-8")
917
+ except OSError as e:
918
+ return f"Failed to read workflow file '{file_path}': {e!s}"
919
+
920
+ updated_content = self._replace_workflow_metadata_header(existing_content, workflow_metadata)
921
+ if updated_content is None:
922
+ return "Failed to update metadata header."
923
+
924
+ write_result = self._write_workflow_file(
925
+ file_path=file_path, content=updated_content, file_name=workflow_metadata.name
926
+ )
927
+ if not write_result.success:
928
+ return write_result.error_details
929
+ return None
930
+
931
+ async def on_set_workflow_metadata_request(self, request: SetWorkflowMetadataRequest) -> ResultPayload:
932
+ await self._workflows_loading_complete.wait()
933
+
934
+ # Resolve workflow and file path
935
+ resolution = self._get_workflow_and_path(request.workflow_name)
936
+ if resolution.error is not None or resolution.workflow is None or resolution.file_path is None:
937
+ return SetWorkflowMetadataResultFailure(result_details=resolution.error or "Failed to resolve workflow.")
938
+
939
+ # Use provided metadata object as the new metadata
940
+ new_metadata = request.workflow_metadata
941
+ # Allow JSON dicts from frontend by coercing to WorkflowMetadata
942
+ if isinstance(new_metadata, dict):
943
+ try:
944
+ new_metadata = WorkflowMetadata.model_validate(new_metadata)
945
+ except Exception as e:
946
+ return SetWorkflowMetadataResultFailure(result_details=f"Invalid workflow_metadata: {e!s}")
947
+ # Refresh last_modified_date to reflect this change
948
+ new_metadata.last_modified_date = datetime.now(tz=UTC)
949
+
950
+ # Persist header
951
+ write_error = await self._write_metadata_header(file_path=resolution.file_path, workflow_metadata=new_metadata)
952
+ if write_error is not None:
953
+ return SetWorkflowMetadataResultFailure(result_details=write_error)
954
+
955
+ # Update registry
956
+ resolution.workflow.metadata = new_metadata
957
+
958
+ return SetWorkflowMetadataResultSuccess(
959
+ result_details=ResultDetails(
960
+ message=f"Successfully updated metadata for workflow '{request.workflow_name}'.", level=logging.INFO
961
+ )
962
+ )
963
+
847
964
  def on_move_workflow_request(self, request: MoveWorkflowRequest) -> ResultPayload: # noqa: PLR0911
848
965
  try:
849
966
  # Validate source workflow exists
@@ -1273,7 +1390,7 @@ class WorkflowManager:
1273
1390
  def _write_workflow_file(self, file_path: Path, content: str, file_name: str) -> WriteWorkflowFileResult:
1274
1391
  """Write workflow content to file with proper validation and error handling.
1275
1392
 
1276
- Uses portalocker for exclusive file locking to prevent concurrent writes.
1393
+ Uses OSManager's WriteFileRequest for file writing with exclusive locking.
1277
1394
  First write wins - if another process is writing, this call fails immediately.
1278
1395
 
1279
1396
  Args:
@@ -1292,41 +1409,36 @@ class WorkflowManager:
1292
1409
  details = f"Attempted to save workflow '{file_name}' (requires {min_space_gb:.1f} GB). Failed due to insufficient disk space: {error_msg}"
1293
1410
  return self.WriteWorkflowFileResult(success=False, error_details=details)
1294
1411
 
1295
- # Create directory structure
1296
- try:
1297
- file_path.parent.mkdir(parents=True, exist_ok=True)
1298
- except OSError as e:
1299
- details = f"Attempted to save workflow '{file_name}'. Failed when creating directory structure: {e}"
1300
- return self.WriteWorkflowFileResult(success=False, error_details=details)
1301
-
1302
- # Write the file content with exclusive lock (non-blocking)
1303
- error_details = None
1304
- try:
1305
- with portalocker.Lock(
1306
- file_path,
1307
- mode="w",
1308
- encoding="utf-8",
1309
- timeout=0, # Non-blocking: fail immediately if locked
1310
- flags=portalocker.LockFlags.EXCLUSIVE,
1311
- ) as fh:
1312
- fh.write(content)
1313
- except portalocker.LockException:
1314
- error_details = (
1315
- f"Attempted to save workflow '{file_name}'. Another process is currently writing to this workflow file"
1316
- )
1317
- except PermissionError as e:
1318
- error_details = f"Attempted to save workflow '{file_name}'. Permission denied: {e}"
1319
- except IsADirectoryError:
1320
- error_details = f"Attempted to save workflow '{file_name}'. Path is a directory, not a file"
1321
- except UnicodeEncodeError as e:
1322
- error_details = f"Attempted to save workflow '{file_name}'. Content encoding error: {e}"
1323
- except OSError as e:
1324
- error_details = f"Attempted to save workflow '{file_name}'. OS error: {e}"
1325
- except Exception as e:
1326
- error_details = f"Attempted to save workflow '{file_name}'. Unexpected error: {type(e).__name__}: {e}"
1412
+ # Write file using OSManager's centralized file writing API
1413
+ os_manager = GriptapeNodes.OSManager()
1414
+ write_request = WriteFileRequest(
1415
+ file_path=str(file_path),
1416
+ content=content,
1417
+ encoding="utf-8",
1418
+ append=False,
1419
+ existing_file_policy=ExistingFilePolicy.OVERWRITE,
1420
+ create_parents=True,
1421
+ )
1327
1422
 
1328
- if error_details:
1329
- return self.WriteWorkflowFileResult(success=False, error_details=error_details)
1423
+ result = os_manager.on_write_file_request(write_request)
1424
+
1425
+ if isinstance(result, WriteFileResultFailure):
1426
+ # Map failure reasons to workflow-specific error messages
1427
+ match result.failure_reason:
1428
+ case FileIOFailureReason.IO_ERROR:
1429
+ # Could be lock exception or other I/O error
1430
+ error_msg = str(result.result_details)
1431
+ case FileIOFailureReason.PERMISSION_DENIED:
1432
+ error_msg = f"Permission denied: {result.result_details}"
1433
+ case FileIOFailureReason.IS_DIRECTORY:
1434
+ error_msg = "Path is a directory, not a file"
1435
+ case FileIOFailureReason.ENCODING_ERROR:
1436
+ error_msg = f"Content encoding error: {result.result_details}"
1437
+ case _:
1438
+ error_msg = str(result.result_details)
1439
+
1440
+ details = f"Attempted to save workflow '{file_name}'. {error_msg}"
1441
+ return self.WriteWorkflowFileResult(success=False, error_details=details)
1330
1442
 
1331
1443
  return self.WriteWorkflowFileResult(success=True, error_details="")
1332
1444
 
@@ -1336,7 +1448,6 @@ class WorkflowManager:
1336
1448
  current_workflow_name = (
1337
1449
  context_manager.get_current_workflow_name() if context_manager.has_current_workflow() else None
1338
1450
  )
1339
-
1340
1451
  try:
1341
1452
  save_target = self._determine_save_target(
1342
1453
  requested_file_name=request.file_name,
@@ -1360,70 +1471,68 @@ class WorkflowManager:
1360
1471
  branched_from if branched_from else "None",
1361
1472
  )
1362
1473
 
1363
- # Serialize the current workflow state
1364
- top_level_flow_request = GetTopLevelFlowRequest()
1365
- top_level_flow_result = await GriptapeNodes.ahandle_request(top_level_flow_request)
1474
+ # Serialize current flow and get shape
1475
+ top_level_flow_result = await GriptapeNodes.ahandle_request(GetTopLevelFlowRequest())
1366
1476
  if not isinstance(top_level_flow_result, GetTopLevelFlowResultSuccess):
1367
1477
  details = f"Attempted to save workflow '{relative_file_path}'. Failed when requesting top level flow."
1368
1478
  return SaveWorkflowResultFailure(result_details=details)
1369
-
1370
1479
  top_level_flow_name = top_level_flow_result.flow_name
1371
- serialized_flow_request = SerializeFlowToCommandsRequest(
1372
- flow_name=top_level_flow_name, include_create_flow_command=True
1480
+
1481
+ serialized_flow_result = await GriptapeNodes.ahandle_request(
1482
+ SerializeFlowToCommandsRequest(flow_name=top_level_flow_name, include_create_flow_command=True)
1373
1483
  )
1374
- serialized_flow_result = await GriptapeNodes.ahandle_request(serialized_flow_request)
1375
1484
  if not isinstance(serialized_flow_result, SerializeFlowToCommandsResultSuccess):
1376
1485
  details = f"Attempted to save workflow '{relative_file_path}'. Failed when serializing flow."
1377
1486
  return SaveWorkflowResultFailure(result_details=details)
1487
+ commands = serialized_flow_result.serialized_flow_commands
1378
1488
 
1379
- serialized_flow_commands = serialized_flow_result.serialized_flow_commands
1380
-
1381
- # Extract workflow shape if possible
1382
- workflow_shape = None
1489
+ # Extract workflow shape if available; ignore failures
1383
1490
  try:
1384
1491
  workflow_shape_dict = self.extract_workflow_shape(workflow_name=file_name)
1385
- workflow_shape = WorkflowShape(inputs=workflow_shape_dict["input"], outputs=workflow_shape_dict["output"])
1492
+ workflow_shape = WorkflowShape(
1493
+ inputs=workflow_shape_dict["input"],
1494
+ outputs=workflow_shape_dict["output"],
1495
+ )
1386
1496
  except ValueError:
1387
- # If we can't extract workflow shape, continue without it
1388
- pass
1497
+ workflow_shape = None
1498
+
1499
+ # Build save request inline (preserve existing description/image/is_template if present)
1500
+ existing_description, existing_image, existing_is_template = self._get_existing_metadata(file_name)
1389
1501
 
1390
- # Use the standalone request to save the workflow file
1391
- # Use pickle_control_flow_result from request if provided, otherwise use False (default)
1392
- pickle_control_flow_result = (
1393
- request.pickle_control_flow_result if request.pickle_control_flow_result is not None else False
1394
- )
1395
1502
  save_file_request = SaveWorkflowFileFromSerializedFlowRequest(
1396
- serialized_flow_commands=serialized_flow_commands,
1503
+ serialized_flow_commands=commands,
1397
1504
  file_name=file_name,
1398
1505
  creation_date=creation_date,
1399
- image_path=request.image_path,
1506
+ image_path=request.image_path if request.image_path is not None else existing_image,
1507
+ description=existing_description,
1508
+ is_template=existing_is_template,
1400
1509
  execution_flow_name=top_level_flow_name,
1401
1510
  branched_from=branched_from,
1402
1511
  workflow_shape=workflow_shape,
1403
1512
  file_path=str(file_path),
1404
- pickle_control_flow_result=pickle_control_flow_result,
1513
+ pickle_control_flow_result=(
1514
+ request.pickle_control_flow_result if request.pickle_control_flow_result is not None else False
1515
+ ),
1405
1516
  )
1406
- save_file_result = await self.on_save_workflow_file_from_serialized_flow_request(save_file_request)
1407
1517
 
1518
+ # Execute save and update registry inline
1519
+ save_file_result = await self.on_save_workflow_file_from_serialized_flow_request(save_file_request)
1408
1520
  if not isinstance(save_file_result, SaveWorkflowFileFromSerializedFlowResultSuccess):
1409
- details = f"Attempted to save workflow '{relative_file_path}'. Failed during file generation: {save_file_result.result_details}"
1521
+ details = (
1522
+ f"Attempted to save workflow '{relative_file_path}'. "
1523
+ f"Failed during file generation: {save_file_result.result_details}"
1524
+ )
1410
1525
  return SaveWorkflowResultFailure(result_details=details)
1411
1526
 
1412
- # Use the metadata returned by the save operation
1413
1527
  workflow_metadata = save_file_result.workflow_metadata
1414
1528
 
1415
- # save the created workflow as an entry in the JSON config file.
1416
1529
  registered_workflows = WorkflowRegistry.list_workflows()
1417
1530
  if file_name not in registered_workflows:
1418
- # Only add to config if it's in the workspace directory (not an external file)
1419
- if not Path(relative_file_path).is_absolute():
1420
- try:
1421
- GriptapeNodes.ConfigManager().save_user_workflow_json(str(file_path))
1422
- except OSError as e:
1423
- details = f"Attempted to save workflow '{file_name}'. Failed when saving configuration: {e}"
1424
- return SaveWorkflowResultFailure(result_details=details)
1531
+ error_details = self._ensure_user_workflow_json_saved(relative_file_path, file_path, file_name)
1532
+ if error_details:
1533
+ return SaveWorkflowResultFailure(result_details=error_details)
1425
1534
  WorkflowRegistry.generate_new_workflow(metadata=workflow_metadata, file_path=relative_file_path)
1426
- # Update existing workflow's metadata in the registry
1535
+
1427
1536
  existing_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1428
1537
  existing_workflow.metadata = workflow_metadata
1429
1538
  details = f"Successfully saved workflow to: {save_file_result.file_path}"
@@ -1431,6 +1540,29 @@ class WorkflowManager:
1431
1540
  file_path=save_file_result.file_path, result_details=ResultDetails(message=details, level=logging.INFO)
1432
1541
  )
1433
1542
 
1543
+ def _get_existing_metadata(self, file_name: str) -> tuple[str | None, str | None, bool | None]:
1544
+ """Return (description, image, is_template) for existing workflow if present; otherwise Nones."""
1545
+ if not WorkflowRegistry.has_workflow_with_name(file_name):
1546
+ return None, None, None
1547
+ try:
1548
+ existing = WorkflowRegistry.get_workflow_by_name(file_name)
1549
+ except Exception as err:
1550
+ logger.debug("Preserving existing metadata failed for workflow '%s': %s", file_name, err)
1551
+ return None, None, None
1552
+ else:
1553
+ return existing.metadata.description, existing.metadata.image, existing.metadata.is_template
1554
+
1555
+ def _ensure_user_workflow_json_saved(self, relative_file_path: str, file_path: Path, file_name: str) -> str | None:
1556
+ """Save user workflow JSON when needed; return error details on failure, else None."""
1557
+ if Path(relative_file_path).is_absolute():
1558
+ return None
1559
+ try:
1560
+ GriptapeNodes.ConfigManager().save_user_workflow_json(str(file_path))
1561
+ except OSError as e:
1562
+ return f"Attempted to save workflow '{file_name}'. Failed when saving configuration: {e}"
1563
+ else:
1564
+ return None
1565
+
1434
1566
  def _determine_save_target(
1435
1567
  self, requested_file_name: str | None, current_workflow_name: str | None
1436
1568
  ) -> SaveWorkflowTargetInfo:
@@ -1497,7 +1629,8 @@ class WorkflowManager:
1497
1629
  file_name = requested_file_name
1498
1630
  creation_date = current_workflow.metadata.creation_date
1499
1631
  branched_from = current_workflow.metadata.branched_from
1500
- relative_file_path = f"{file_name}.py"
1632
+ current_dir = Path(current_workflow.file_path).parent
1633
+ relative_file_path = str(current_dir / f"{file_name}.py")
1501
1634
  file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1502
1635
 
1503
1636
  else:
@@ -1547,6 +1680,8 @@ class WorkflowManager:
1547
1680
  file_name=request.file_name,
1548
1681
  creation_date=creation_date,
1549
1682
  image_path=request.image_path,
1683
+ description=request.description,
1684
+ is_template=request.is_template,
1550
1685
  branched_from=request.branched_from,
1551
1686
  workflow_shape=request.workflow_shape,
1552
1687
  )
@@ -1571,9 +1706,6 @@ class WorkflowManager:
1571
1706
  return SaveWorkflowFileFromSerializedFlowResultFailure(result_details=details)
1572
1707
 
1573
1708
  # Write the workflow file
1574
- # TODO: https://github.com/griptape-ai/griptape-nodes/issues/3169
1575
- # This is a synchronous call within an async context and may block the event loop.
1576
- # Consider using asyncio.to_thread() to run in a thread pool for better async performance.
1577
1709
  write_result = self._write_workflow_file(file_path, final_code_output, request.file_name)
1578
1710
  if not write_result.success:
1579
1711
  return SaveWorkflowFileFromSerializedFlowResultFailure(result_details=write_result.error_details)
@@ -1590,7 +1722,10 @@ class WorkflowManager:
1590
1722
  serialized_flow_commands: SerializedFlowCommands,
1591
1723
  file_name: str,
1592
1724
  creation_date: datetime,
1725
+ *,
1593
1726
  image_path: str | None = None,
1727
+ description: str | None = None,
1728
+ is_template: bool | None = None,
1594
1729
  branched_from: str | None = None,
1595
1730
  workflow_shape: WorkflowShape | None = None,
1596
1731
  ) -> WorkflowMetadata:
@@ -1622,6 +1757,8 @@ class WorkflowManager:
1622
1757
  branched_from=branched_from,
1623
1758
  workflow_shape=workflow_shape,
1624
1759
  image=image_path,
1760
+ description=description,
1761
+ is_template=is_template,
1625
1762
  )
1626
1763
 
1627
1764
  def _generate_workflow_file_content( # noqa: PLR0912, PLR0915, C901
@@ -1708,15 +1845,33 @@ class WorkflowManager:
1708
1845
  flow_initialization_command=flow_initialization_command, flow_creation_index=flow_creation_index
1709
1846
  )
1710
1847
 
1711
- # Generate nodes in flow AST node. This will create the node and apply all element modifiers
1712
- nodes_in_flow = self._generate_nodes_in_flow(
1713
- serialized_flow_commands, import_recorder, node_uuid_to_node_variable_name
1714
- )
1848
+ # Separate regular nodes from NodeGroup nodes in main flow
1849
+ from griptape_nodes.retained_mode.events.node_events import CreateNodeGroupRequest
1850
+
1851
+ regular_node_commands = []
1852
+ node_group_commands = []
1853
+ for serialized_node_command in serialized_flow_commands.serialized_node_commands:
1854
+ create_cmd = serialized_node_command.create_node_command
1855
+ if isinstance(create_cmd, CreateNodeGroupRequest):
1856
+ node_group_commands.append(serialized_node_command)
1857
+ else:
1858
+ regular_node_commands.append(serialized_node_command)
1715
1859
 
1716
- # Add the nodes to the body of the Current Context flow's "with" statement
1717
- assign_flow_context_node.body.extend(nodes_in_flow)
1860
+ # Track the running node index across all flows to ensure unique variable names
1861
+ current_node_index = 0
1718
1862
 
1719
- # Process sub-flows - for each sub-flow, generate its initialization command
1863
+ # Generate regular nodes in main flow first (NOT NodeGroups yet)
1864
+ for serialized_node_command in regular_node_commands:
1865
+ node_creation_ast = self._generate_node_creation_code(
1866
+ serialized_node_command,
1867
+ current_node_index,
1868
+ import_recorder,
1869
+ node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
1870
+ )
1871
+ assign_flow_context_node.body.extend(node_creation_ast)
1872
+ current_node_index += 1
1873
+
1874
+ # Process sub-flows - for each sub-flow, generate its nodes
1720
1875
  for sub_flow_index, sub_flow_commands in enumerate(serialized_flow_commands.sub_flows_commands):
1721
1876
  sub_flow_creation_index = flow_creation_index + 1 + sub_flow_index
1722
1877
 
@@ -1726,7 +1881,10 @@ class WorkflowManager:
1726
1881
  match sub_flow_initialization_command:
1727
1882
  case CreateFlowRequest():
1728
1883
  sub_flow_create_node = self._generate_create_flow(
1729
- sub_flow_initialization_command, import_recorder, sub_flow_creation_index
1884
+ sub_flow_initialization_command,
1885
+ import_recorder,
1886
+ sub_flow_creation_index,
1887
+ parent_flow_creation_index=flow_creation_index,
1730
1888
  )
1731
1889
  assign_flow_context_node.body.append(cast("ast.stmt", sub_flow_create_node))
1732
1890
  case ImportWorkflowAsReferencedSubFlowRequest():
@@ -1735,6 +1893,50 @@ class WorkflowManager:
1735
1893
  )
1736
1894
  assign_flow_context_node.body.append(cast("ast.stmt", sub_flow_import_node))
1737
1895
 
1896
+ # Generate the nodes in this subflow (just like we do for main flow)
1897
+ if sub_flow_commands.serialized_node_commands:
1898
+ # Create "with" statement for subflow
1899
+ subflow_context_node = self._generate_assign_flow_context(
1900
+ flow_initialization_command=sub_flow_initialization_command,
1901
+ flow_creation_index=sub_flow_creation_index,
1902
+ )
1903
+ # Generate nodes in subflow, passing current index and getting next available
1904
+ subflow_nodes, current_node_index = self._generate_nodes_in_flow(
1905
+ sub_flow_commands, import_recorder, node_uuid_to_node_variable_name, current_node_index
1906
+ )
1907
+ subflow_context_node.body.extend(subflow_nodes)
1908
+
1909
+ # Generate connections for nodes in this subflow (must be in subflow context)
1910
+ subflow_connection_asts = self._generate_connections_code(
1911
+ serialized_connections=sub_flow_commands.serialized_connections,
1912
+ node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
1913
+ import_recorder=import_recorder,
1914
+ )
1915
+ subflow_context_node.body.extend(subflow_connection_asts)
1916
+
1917
+ # Generate parameter values for nodes in this subflow (must be in subflow context)
1918
+ subflow_parameter_value_asts = self._generate_set_parameter_value_code(
1919
+ set_parameter_value_commands=sub_flow_commands.set_parameter_value_commands,
1920
+ lock_commands=sub_flow_commands.set_lock_commands_per_node,
1921
+ node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
1922
+ unique_values_dict_name="top_level_unique_values_dict",
1923
+ import_recorder=import_recorder,
1924
+ )
1925
+ subflow_context_node.body.extend(subflow_parameter_value_asts)
1926
+
1927
+ assign_flow_context_node.body.append(subflow_context_node)
1928
+
1929
+ # Generate NodeGroup nodes LAST (after subflows, so child nodes exist)
1930
+ for serialized_node_command in node_group_commands:
1931
+ node_creation_ast = self._generate_node_creation_code(
1932
+ serialized_node_command,
1933
+ current_node_index,
1934
+ import_recorder,
1935
+ node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
1936
+ )
1937
+ assign_flow_context_node.body.extend(node_creation_ast)
1938
+ current_node_index += 1
1939
+
1738
1940
  # Now generate the connection code and add it to the flow context
1739
1941
  connection_asts = self._generate_connections_code(
1740
1942
  serialized_connections=serialized_flow_commands.serialized_connections,
@@ -1743,7 +1945,7 @@ class WorkflowManager:
1743
1945
  )
1744
1946
  assign_flow_context_node.body.extend(connection_asts)
1745
1947
 
1746
- # Generate parameter values
1948
+ # Generate parameter values for main flow only (subflow parameter values generated inside their contexts)
1747
1949
  set_parameter_value_asts = self._generate_set_parameter_value_code(
1748
1950
  set_parameter_value_commands=serialized_flow_commands.set_parameter_value_commands,
1749
1951
  lock_commands=serialized_flow_commands.set_lock_commands_per_node,
@@ -2679,7 +2881,11 @@ class WorkflowManager:
2679
2881
  return full_ast
2680
2882
 
2681
2883
  def _generate_create_flow(
2682
- self, create_flow_command: CreateFlowRequest, import_recorder: ImportRecorder, flow_creation_index: int
2884
+ self,
2885
+ create_flow_command: CreateFlowRequest,
2886
+ import_recorder: ImportRecorder,
2887
+ flow_creation_index: int,
2888
+ parent_flow_creation_index: int | None = None,
2683
2889
  ) -> ast.Module:
2684
2890
  import_recorder.add_from_import("griptape_nodes.retained_mode.events.flow_events", "CreateFlowRequest")
2685
2891
 
@@ -2691,9 +2897,19 @@ class WorkflowManager:
2691
2897
  for field in fields(create_flow_command):
2692
2898
  field_value = getattr(create_flow_command, field.name)
2693
2899
  if field_value != field.default:
2694
- create_flow_request_args.append(
2695
- ast.keyword(arg=field.name, value=ast.Constant(value=field_value, lineno=1, col_offset=0))
2696
- )
2900
+ # Special handling for parent_flow_name - use variable reference if parent index provided
2901
+ if field.name == "parent_flow_name" and parent_flow_creation_index is not None:
2902
+ parent_flow_variable = f"flow{parent_flow_creation_index}_name"
2903
+ create_flow_request_args.append(
2904
+ ast.keyword(
2905
+ arg=field.name,
2906
+ value=ast.Name(id=parent_flow_variable, ctx=ast.Load(), lineno=1, col_offset=0),
2907
+ )
2908
+ )
2909
+ else:
2910
+ create_flow_request_args.append(
2911
+ ast.keyword(arg=field.name, value=ast.Constant(value=field_value, lineno=1, col_offset=0))
2912
+ )
2697
2913
 
2698
2914
  # Create a comment explaining the behavior
2699
2915
  comment_ast = ast.Expr(
@@ -2904,20 +3120,33 @@ class WorkflowManager:
2904
3120
  serialized_flow_commands: SerializedFlowCommands,
2905
3121
  import_recorder: ImportRecorder,
2906
3122
  node_uuid_to_node_variable_name: dict[SerializedNodeCommands.NodeUUID, str],
2907
- ) -> list[ast.stmt]:
2908
- # Generate node creation code and add it to the flow context
3123
+ starting_node_index: int,
3124
+ ) -> tuple[list[ast.stmt], int]:
3125
+ """Generate node creation code for nodes in a flow.
3126
+
3127
+ Args:
3128
+ serialized_flow_commands: Commands for the flow
3129
+ import_recorder: Import recorder for tracking imports
3130
+ node_uuid_to_node_variable_name: Mapping from node UUIDs to variable names
3131
+ starting_node_index: The starting index for node variable names
3132
+
3133
+ Returns:
3134
+ Tuple of (list of AST statements, next available node index)
3135
+ """
2909
3136
  node_creation_asts = []
2910
- for node_index, serialized_node_command in enumerate(serialized_flow_commands.serialized_node_commands):
3137
+ current_index = starting_node_index
3138
+ for serialized_node_command in serialized_flow_commands.serialized_node_commands:
2911
3139
  node_creation_ast = self._generate_node_creation_code(
2912
3140
  serialized_node_command,
2913
- node_index,
3141
+ current_index,
2914
3142
  import_recorder,
2915
3143
  node_uuid_to_node_variable_name=node_uuid_to_node_variable_name,
2916
3144
  )
2917
3145
  node_creation_asts.extend(node_creation_ast)
2918
- return node_creation_asts
3146
+ current_index += 1
3147
+ return node_creation_asts, current_index
2919
3148
 
2920
- def _generate_node_creation_code(
3149
+ def _generate_node_creation_code( # noqa: C901, PLR0912
2921
3150
  self,
2922
3151
  serialized_node_command: SerializedNodeCommands,
2923
3152
  node_index: int,
@@ -2951,9 +3180,34 @@ class WorkflowManager:
2951
3180
  for field in fields(create_node_request):
2952
3181
  field_value = getattr(create_node_request, field.name)
2953
3182
  if field_value != field.default:
2954
- create_node_request_args.append(
2955
- ast.keyword(arg=field.name, value=ast.Constant(value=field_value, lineno=1, col_offset=0))
2956
- )
3183
+ # Special handling for CreateNodeGroupRequest.node_names_to_add - these are now UUIDs, convert to variable references
3184
+ if isinstance(create_node_request, CreateNodeGroupRequest) and field.name == "node_names_to_add":
3185
+ # field_value is now a list of UUIDs (converted in _serialize_package_nodes_for_local_execution)
3186
+ # Convert each UUID to an AST Name node referencing the generated variable
3187
+ node_var_ast_list = []
3188
+ for node_uuid in field_value:
3189
+ if node_uuid in node_uuid_to_node_variable_name:
3190
+ variable_name = node_uuid_to_node_variable_name[node_uuid]
3191
+ node_var_ast_list.append(ast.Name(id=variable_name, ctx=ast.Load()))
3192
+ else:
3193
+ logger.info(
3194
+ "NodeGroup child UUID '%s' not found in node_uuid_to_node_variable_name. Available UUIDs: %s...",
3195
+ node_uuid,
3196
+ list(node_uuid_to_node_variable_name.keys())[:5],
3197
+ )
3198
+ if node_var_ast_list:
3199
+ create_node_request_args.append(
3200
+ ast.keyword(arg=field.name, value=ast.List(elts=node_var_ast_list, ctx=ast.Load()))
3201
+ )
3202
+ else:
3203
+ logger.info(
3204
+ "NodeGroup node_names_to_add resulted in empty variable list. Original UUIDs: %s",
3205
+ field_value,
3206
+ )
3207
+ else:
3208
+ create_node_request_args.append(
3209
+ ast.keyword(arg=field.name, value=ast.Constant(value=field_value, lineno=1, col_offset=0))
3210
+ )
2957
3211
  # Get the actual request class name (CreateNodeRequest or CreateNodeGroupRequest)
2958
3212
  request_class_name = type(create_node_request).__name__
2959
3213
  # Handle the create node command and assign to node name
@@ -4158,7 +4412,7 @@ class WorkflowManager:
4158
4412
  failed = []
4159
4413
 
4160
4414
  # First pass: collect all workflow files to determine total count
4161
- all_workflow_files: list[Path] = []
4415
+ all_workflow_files: set[Path] = set()
4162
4416
 
4163
4417
  def collect_workflow_files(path: Path) -> None:
4164
4418
  """Collect workflow files from a path."""
@@ -4174,7 +4428,7 @@ class WorkflowManager:
4174
4428
  workflow_file, block_name=WorkflowManager.WORKFLOW_METADATA_HEADER
4175
4429
  )
4176
4430
  if len(metadata_blocks) == 1:
4177
- all_workflow_files.append(workflow_file)
4431
+ all_workflow_files.add(workflow_file)
4178
4432
  except Exception as e:
4179
4433
  # Skip files that can't be read or parsed
4180
4434
  logger.debug("Skipping workflow file %s due to error: %s", workflow_file, e)
@@ -4185,7 +4439,7 @@ class WorkflowManager:
4185
4439
  path, block_name=WorkflowManager.WORKFLOW_METADATA_HEADER
4186
4440
  )
4187
4441
  if len(metadata_blocks) == 1:
4188
- all_workflow_files.append(path)
4442
+ all_workflow_files.add(path)
4189
4443
  except Exception as e:
4190
4444
  logger.debug("Skipping workflow file %s due to error: %s", path, e)
4191
4445