griptape-nodes 0.65.6__py3-none-any.whl → 0.66.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/common/node_executor.py +352 -27
  2. griptape_nodes/drivers/storage/base_storage_driver.py +12 -3
  3. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +18 -2
  4. griptape_nodes/drivers/storage/local_storage_driver.py +42 -5
  5. griptape_nodes/exe_types/base_iterative_nodes.py +0 -1
  6. griptape_nodes/exe_types/connections.py +42 -0
  7. griptape_nodes/exe_types/core_types.py +2 -2
  8. griptape_nodes/exe_types/node_groups/__init__.py +2 -1
  9. griptape_nodes/exe_types/node_groups/base_iterative_node_group.py +177 -0
  10. griptape_nodes/exe_types/node_groups/base_node_group.py +1 -0
  11. griptape_nodes/exe_types/node_groups/subflow_node_group.py +35 -2
  12. griptape_nodes/exe_types/param_types/parameter_audio.py +1 -1
  13. griptape_nodes/exe_types/param_types/parameter_bool.py +1 -1
  14. griptape_nodes/exe_types/param_types/parameter_button.py +1 -1
  15. griptape_nodes/exe_types/param_types/parameter_float.py +1 -1
  16. griptape_nodes/exe_types/param_types/parameter_image.py +1 -1
  17. griptape_nodes/exe_types/param_types/parameter_int.py +1 -1
  18. griptape_nodes/exe_types/param_types/parameter_number.py +1 -1
  19. griptape_nodes/exe_types/param_types/parameter_string.py +1 -1
  20. griptape_nodes/exe_types/param_types/parameter_three_d.py +1 -1
  21. griptape_nodes/exe_types/param_types/parameter_video.py +1 -1
  22. griptape_nodes/machines/control_flow.py +5 -4
  23. griptape_nodes/machines/dag_builder.py +121 -55
  24. griptape_nodes/machines/fsm.py +10 -0
  25. griptape_nodes/machines/parallel_resolution.py +39 -38
  26. griptape_nodes/machines/sequential_resolution.py +29 -3
  27. griptape_nodes/node_library/library_registry.py +41 -2
  28. griptape_nodes/retained_mode/events/library_events.py +147 -8
  29. griptape_nodes/retained_mode/events/os_events.py +12 -4
  30. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  31. griptape_nodes/retained_mode/managers/fitness_problems/libraries/incompatible_requirements_problem.py +34 -0
  32. griptape_nodes/retained_mode/managers/flow_manager.py +133 -20
  33. griptape_nodes/retained_mode/managers/library_manager.py +1324 -564
  34. griptape_nodes/retained_mode/managers/node_manager.py +9 -3
  35. griptape_nodes/retained_mode/managers/os_manager.py +429 -65
  36. griptape_nodes/retained_mode/managers/resource_types/compute_resource.py +82 -0
  37. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +17 -0
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +21 -8
  39. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +3 -3
  40. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +5 -5
  41. griptape_nodes/version_compatibility/versions/v0_65_4/__init__.py +5 -0
  42. griptape_nodes/version_compatibility/versions/v0_65_4/run_in_parallel_to_run_in_order.py +79 -0
  43. griptape_nodes/version_compatibility/versions/v0_65_5/__init__.py +5 -0
  44. griptape_nodes/version_compatibility/versions/v0_65_5/flux_2_removed_parameters.py +85 -0
  45. {griptape_nodes-0.65.6.dist-info → griptape_nodes-0.66.1.dist-info}/METADATA +1 -1
  46. {griptape_nodes-0.65.6.dist-info → griptape_nodes-0.66.1.dist-info}/RECORD +48 -53
  47. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +0 -45
  48. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +0 -191
  49. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +0 -346
  50. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +0 -439
  51. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +0 -17
  52. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +0 -82
  53. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +0 -116
  54. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +0 -367
  55. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +0 -104
  56. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +0 -155
  57. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +0 -18
  58. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +0 -12
  59. {griptape_nodes-0.65.6.dist-info → griptape_nodes-0.66.1.dist-info}/WHEEL +0 -0
  60. {griptape_nodes-0.65.6.dist-info → griptape_nodes-0.66.1.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ import sys
11
11
  import sysconfig
12
12
  from collections import defaultdict
13
13
  from dataclasses import dataclass, field
14
+ from enum import StrEnum
14
15
  from importlib.resources import files
15
16
  from pathlib import Path
16
17
  from typing import TYPE_CHECKING, Any, Generic, NamedTuple, TypeVar, cast
@@ -57,9 +58,16 @@ from griptape_nodes.retained_mode.events.library_events import (
57
58
  CheckLibraryUpdateRequest,
58
59
  CheckLibraryUpdateResultFailure,
59
60
  CheckLibraryUpdateResultSuccess,
61
+ DiscoveredLibrary,
62
+ DiscoverLibrariesRequest,
63
+ DiscoverLibrariesResultFailure,
64
+ DiscoverLibrariesResultSuccess,
60
65
  DownloadLibraryRequest,
61
66
  DownloadLibraryResultFailure,
62
67
  DownloadLibraryResultSuccess,
68
+ EvaluateLibraryFitnessRequest,
69
+ EvaluateLibraryFitnessResultFailure,
70
+ EvaluateLibraryFitnessResultSuccess,
63
71
  GetAllInfoForAllLibrariesRequest,
64
72
  GetAllInfoForAllLibrariesResultFailure,
65
73
  GetAllInfoForAllLibrariesResultSuccess,
@@ -106,6 +114,9 @@ from griptape_nodes.retained_mode.events.library_events import (
106
114
  ReloadAllLibrariesRequest,
107
115
  ReloadAllLibrariesResultFailure,
108
116
  ReloadAllLibrariesResultSuccess,
117
+ ScanSandboxDirectoryRequest,
118
+ ScanSandboxDirectoryResultFailure,
119
+ ScanSandboxDirectoryResultSuccess,
109
120
  SwitchLibraryRefRequest,
110
121
  SwitchLibraryRefResultFailure,
111
122
  SwitchLibraryRefResultSuccess,
@@ -123,8 +134,15 @@ from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStat
123
134
  from griptape_nodes.retained_mode.events.os_events import (
124
135
  DeleteFileRequest,
125
136
  DeleteFileResultFailure,
137
+ WriteFileRequest,
126
138
  )
127
139
  from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
140
+ from griptape_nodes.retained_mode.events.resource_events import (
141
+ GetResourceInstanceStatusRequest,
142
+ GetResourceInstanceStatusResultSuccess,
143
+ ListCompatibleResourceInstancesRequest,
144
+ ListCompatibleResourceInstancesResultSuccess,
145
+ )
128
146
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
129
147
  from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
130
148
  AdvancedLibraryLoadFailureProblem,
@@ -133,6 +151,7 @@ from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
133
151
  CreateConfigCategoryProblem,
134
152
  DuplicateLibraryProblem,
135
153
  EngineVersionErrorProblem,
154
+ IncompatibleRequirementsProblem,
136
155
  InvalidVersionStringProblem,
137
156
  LibraryJsonDecodeProblem,
138
157
  LibraryLoadExceptionProblem,
@@ -147,11 +166,6 @@ from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
147
166
  SandboxDirectoryMissingProblem,
148
167
  UpdateConfigCategoryProblem,
149
168
  )
150
- from griptape_nodes.retained_mode.managers.library_lifecycle.library_directory import LibraryDirectory
151
- from griptape_nodes.retained_mode.managers.library_lifecycle.library_provenance.local_file import (
152
- LibraryProvenanceLocalFile,
153
- )
154
- from griptape_nodes.retained_mode.managers.library_lifecycle.library_status import LibraryStatus
155
169
  from griptape_nodes.retained_mode.managers.os_manager import OSManager
156
170
  from griptape_nodes.retained_mode.managers.settings import LIBRARIES_TO_DOWNLOAD_KEY, LIBRARIES_TO_REGISTER_KEY
157
171
  from griptape_nodes.utils.async_utils import subprocess_run
@@ -230,20 +244,30 @@ class LibraryManager:
230
244
  LIBRARY_CONFIG_FILENAME = "griptape_nodes_library.json"
231
245
  LIBRARY_CONFIG_GLOB_PATTERN = "griptape[_-]nodes[_-]library.json"
232
246
 
233
- @dataclass
234
- class LibraryInfo:
235
- """Information about a library that was attempted to be loaded.
247
+ # Sandbox library constants
248
+ UNRESOLVED_SANDBOX_CLASS_NAME = "<NOT YET RESOLVED>"
249
+ SANDBOX_CATEGORY_NAME = "Griptape Nodes Sandbox"
236
250
 
237
- Includes the status of the library, the file path, and any problems encountered during loading.
238
- """
251
+ _library_file_path_to_info: dict[str, LibraryInfo]
239
252
 
240
- status: LibraryStatus
241
- library_path: str
242
- library_name: str | None = None
243
- library_version: str | None = None
244
- problems: list[LibraryProblem] = field(default_factory=list)
253
+ class LibraryLifecycleState(StrEnum):
254
+ """Lifecycle states for library loading."""
245
255
 
246
- _library_file_path_to_info: dict[str, LibraryInfo]
256
+ FAILURE = "failure"
257
+ DISCOVERED = "discovered"
258
+ METADATA_LOADED = "metadata_loaded"
259
+ EVALUATED = "evaluated"
260
+ DEPENDENCIES_INSTALLED = "dependencies_installed"
261
+ LOADED = "loaded"
262
+
263
+ class LibraryFitness(StrEnum):
264
+ """Fitness of the library that was attempted to be loaded."""
265
+
266
+ GOOD = "GOOD" # No errors detected during loading. Registered.
267
+ FLAWED = "FLAWED" # Some errors detected, but recoverable. Registered.
268
+ UNUSABLE = "UNUSABLE" # Errors detected and not recoverable. Not registered.
269
+ MISSING = "MISSING" # File not found. Not registered.
270
+ NOT_EVALUATED = "NOT_EVALUATED" # Library has not been evaluated yet.
247
271
 
248
272
  @dataclass
249
273
  class RegisteredEventHandler(Generic[TRegisteredEventData]):
@@ -257,6 +281,39 @@ class LibraryManager:
257
281
  library_data: LibrarySchema
258
282
  event_data: TRegisteredEventData | None = None
259
283
 
284
+ @dataclass
285
+ class LibraryInfo:
286
+ """Information about a library that was attempted to be loaded.
287
+
288
+ Tracks the lifecycle state (where we are in the loading process) and fitness (health/quality).
289
+ Includes the file path and any problems encountered during loading.
290
+
291
+ Attributes:
292
+ lifecycle_state: Current phase of the library loading lifecycle (DISCOVERED → METADATA_LOADED →
293
+ EVALUATED → DEPENDENCIES_INSTALLED → LOADED or FAILURE at any phase)
294
+ fitness: Health/quality assessment of the library (GOOD, FLAWED, UNUSABLE, MISSING, NOT_EVALUATED)
295
+ library_path: Absolute path to the library JSON file or sandbox directory
296
+ is_sandbox: True if this is a sandbox library (user-created nodes in workspace), False for regular libraries
297
+ library_name: Name of the library from metadata (None until METADATA_LOADED phase)
298
+ library_version: Schema version from metadata (None until METADATA_LOADED phase)
299
+ problems: List of issues encountered during any phase (version incompatibilities, node load failures, etc.)
300
+ Problems accumulate across lifecycle phases and determine final fitness level.
301
+ """
302
+
303
+ lifecycle_state: LibraryManager.LibraryLifecycleState
304
+ fitness: LibraryManager.LibraryFitness
305
+ library_path: str
306
+ is_sandbox: bool
307
+ library_name: str | None = None
308
+ library_version: str | None = None
309
+ problems: list[LibraryProblem] = field(default_factory=list)
310
+
311
+ class RegisterLibraryPrerequisites(NamedTuple):
312
+ """Prerequisites established for library loading."""
313
+
314
+ library_info: LibraryManager.LibraryInfo
315
+ file_path: str
316
+
260
317
  # Stable module namespace mappings for workflow serialization
261
318
  # These mappings ensure that dynamically loaded modules can be reliably imported
262
319
  # in generated workflow code by providing stable, predictable import paths.
@@ -284,8 +341,6 @@ class LibraryManager:
284
341
  self._library_event_handler_mappings: dict[
285
342
  type[Payload], dict[str, LibraryManager.RegisteredEventHandler[Any]]
286
343
  ] = {}
287
- # LibraryDirectory owns the FSMs and manages library lifecycle
288
- self._library_directory = LibraryDirectory()
289
344
  self._libraries_loading_complete = asyncio.Event()
290
345
 
291
346
  event_manager.assign_manager_to_request_type(
@@ -327,6 +382,7 @@ class LibraryManager:
327
382
  event_manager.assign_manager_to_request_type(
328
383
  LoadMetadataForAllLibrariesRequest, self.load_metadata_for_all_libraries_request
329
384
  )
385
+ event_manager.assign_manager_to_request_type(ScanSandboxDirectoryRequest, self.scan_sandbox_directory_request)
330
386
  event_manager.assign_manager_to_request_type(
331
387
  UnloadLibraryFromRegistryRequest, self.unload_library_from_registry_request
332
388
  )
@@ -371,25 +427,25 @@ class LibraryManager:
371
427
 
372
428
  # Status emojis mapping
373
429
  status_emoji = {
374
- LibraryStatus.GOOD: "[green]OK[/green]",
375
- LibraryStatus.FLAWED: "[yellow]![/yellow]",
376
- LibraryStatus.UNUSABLE: "[red]X[/red]",
377
- LibraryStatus.MISSING: "[red]?[/red]",
430
+ LibraryManager.LibraryFitness.GOOD: "[green]OK[/green]",
431
+ LibraryManager.LibraryFitness.FLAWED: "[yellow]![/yellow]",
432
+ LibraryManager.LibraryFitness.UNUSABLE: "[red]X[/red]",
433
+ LibraryManager.LibraryFitness.MISSING: "[red]?[/red]",
378
434
  }
379
435
 
380
436
  # Status text mapping (colored)
381
437
  status_text = {
382
- LibraryStatus.GOOD: "[green](GOOD)[/green]",
383
- LibraryStatus.FLAWED: "[yellow](FLAWED)[/yellow]",
384
- LibraryStatus.UNUSABLE: "[red](UNUSABLE)[/red]",
385
- LibraryStatus.MISSING: "[red](MISSING)[/red]",
438
+ LibraryManager.LibraryFitness.GOOD: "[green](GOOD)[/green]",
439
+ LibraryManager.LibraryFitness.FLAWED: "[yellow](FLAWED)[/yellow]",
440
+ LibraryManager.LibraryFitness.UNUSABLE: "[red](UNUSABLE)[/red]",
441
+ LibraryManager.LibraryFitness.MISSING: "[red](MISSING)[/red]",
386
442
  }
387
443
 
388
444
  # Add rows for each library info
389
445
  for lib_info in library_infos:
390
446
  # Library column with emoji, name, version, colored status, and file path underneath
391
- emoji = status_emoji.get(lib_info.status, "ERROR: Unknown/Unexpected Library Status")
392
- colored_status = status_text.get(lib_info.status, "(UNKNOWN)")
447
+ emoji = status_emoji.get(lib_info.fitness, "ERROR: Unknown/Unexpected Library Status")
448
+ colored_status = status_text.get(lib_info.fitness, "(UNKNOWN)")
393
449
  name = lib_info.library_name if lib_info.library_name else "*UNKNOWN*"
394
450
 
395
451
  library_version = lib_info.library_version
@@ -536,7 +592,9 @@ class LibraryManager:
536
592
  result = GetLibraryMetadataResultSuccess(metadata=metadata, result_details=details)
537
593
  return result
538
594
 
539
- def load_library_metadata_from_file_request(self, request: LoadLibraryMetadataFromFileRequest) -> ResultPayload:
595
+ def load_library_metadata_from_file_request( # noqa: PLR0911
596
+ self, request: LoadLibraryMetadataFromFileRequest
597
+ ) -> LoadLibraryMetadataFromFileResultSuccess | LoadLibraryMetadataFromFileResultFailure:
540
598
  """Load library metadata from a JSON file without loading the actual node modules.
541
599
 
542
600
  This method provides a lightweight way to get library schema information
@@ -553,7 +611,7 @@ class LibraryManager:
553
611
  return LoadLibraryMetadataFromFileResultFailure(
554
612
  library_path=file_path,
555
613
  library_name=None,
556
- status=LibraryStatus.MISSING,
614
+ status=LibraryManager.LibraryFitness.MISSING,
557
615
  problems=[LibraryNotFoundProblem(library_path=str(json_path))],
558
616
  result_details=details,
559
617
  )
@@ -567,7 +625,7 @@ class LibraryManager:
567
625
  return LoadLibraryMetadataFromFileResultFailure(
568
626
  library_path=file_path,
569
627
  library_name=None,
570
- status=LibraryStatus.UNUSABLE,
628
+ status=LibraryManager.LibraryFitness.UNUSABLE,
571
629
  problems=[LibraryJsonDecodeProblem()],
572
630
  result_details=details,
573
631
  )
@@ -576,7 +634,7 @@ class LibraryManager:
576
634
  return LoadLibraryMetadataFromFileResultFailure(
577
635
  library_path=file_path,
578
636
  library_name=None,
579
- status=LibraryStatus.UNUSABLE,
637
+ status=LibraryManager.LibraryFitness.UNUSABLE,
580
638
  problems=[LibraryLoadExceptionProblem(error_message=str(err))],
581
639
  result_details=details,
582
640
  )
@@ -600,7 +658,7 @@ class LibraryManager:
600
658
  return LoadLibraryMetadataFromFileResultFailure(
601
659
  library_path=file_path,
602
660
  library_name=library_name,
603
- status=LibraryStatus.UNUSABLE,
661
+ status=LibraryManager.LibraryFitness.UNUSABLE,
604
662
  problems=problems,
605
663
  result_details=details,
606
664
  )
@@ -609,11 +667,23 @@ class LibraryManager:
609
667
  return LoadLibraryMetadataFromFileResultFailure(
610
668
  library_path=file_path,
611
669
  library_name=library_name,
612
- status=LibraryStatus.UNUSABLE,
670
+ status=LibraryManager.LibraryFitness.UNUSABLE,
613
671
  problems=[LibrarySchemaExceptionProblem(error_message=str(err))],
614
672
  result_details=details,
615
673
  )
616
674
 
675
+ # Make sure the version string is copacetic.
676
+ library_version = library_data.metadata.library_version
677
+ if library_version is None:
678
+ details = f"Attempted to load Library '{library_data.name}' JSON file from '{json_path}'. Failed because version string '{library_data.metadata.library_version}' wasn't valid. Must be in major.minor.patch format."
679
+ return LoadLibraryMetadataFromFileResultFailure(
680
+ library_path=file_path,
681
+ library_name=library_data.name,
682
+ status=LibraryManager.LibraryFitness.UNUSABLE,
683
+ problems=[InvalidVersionStringProblem(version_string=str(library_data.metadata.library_version))],
684
+ result_details=details,
685
+ )
686
+
617
687
  # Get git remote and ref if this library is in a git repository
618
688
  library_dir = json_path.parent.absolute()
619
689
  try:
@@ -659,13 +729,35 @@ class LibraryManager:
659
729
  else:
660
730
  failed_libraries.append(cast("LoadLibraryMetadataFromFileResultFailure", metadata_result))
661
731
 
662
- # Generate sandbox library metadata
663
- sandbox_result = self._generate_sandbox_library_metadata()
664
- if isinstance(sandbox_result, LoadLibraryMetadataFromFileResultSuccess):
665
- successful_libraries.append(sandbox_result)
666
- elif isinstance(sandbox_result, LoadLibraryMetadataFromFileResultFailure):
667
- failed_libraries.append(sandbox_result)
668
- # If sandbox_result is None, sandbox was not configured or no files found - skip it
732
+ # Generate sandbox library metadata if configured
733
+ sandbox_library_dir = self._get_sandbox_directory()
734
+ if sandbox_library_dir:
735
+ # Try to load existing JSON first - only scan if load fails
736
+ sandbox_json_path = sandbox_library_dir / LibraryManager.LIBRARY_CONFIG_FILENAME
737
+ sandbox_result = self.load_library_metadata_from_file_request(
738
+ LoadLibraryMetadataFromFileRequest(file_path=str(sandbox_json_path))
739
+ )
740
+
741
+ # If load failed, it either didn't exist or was malformed. Try scanning, which will generate a fresh one.
742
+ if isinstance(sandbox_result, LoadLibraryMetadataFromFileResultFailure):
743
+ scan_result = self.scan_sandbox_directory_request(
744
+ ScanSandboxDirectoryRequest(directory_path=str(sandbox_library_dir))
745
+ )
746
+ # Map scan result to load result for consistency
747
+ if isinstance(scan_result, ScanSandboxDirectoryResultSuccess):
748
+ sandbox_result = LoadLibraryMetadataFromFileResultSuccess(
749
+ library_schema=scan_result.library_schema,
750
+ file_path=str(sandbox_json_path),
751
+ git_remote=None,
752
+ git_ref=None,
753
+ result_details=scan_result.result_details,
754
+ )
755
+ # else: Keep the load failure result
756
+
757
+ if isinstance(sandbox_result, LoadLibraryMetadataFromFileResultSuccess):
758
+ successful_libraries.append(sandbox_result)
759
+ else:
760
+ failed_libraries.append(sandbox_result)
669
761
 
670
762
  details = (
671
763
  f"Successfully loaded metadata for {len(successful_libraries)} libraries, {len(failed_libraries)} failed"
@@ -678,130 +770,305 @@ class LibraryManager:
678
770
 
679
771
  def _generate_sandbox_library_metadata(
680
772
  self,
773
+ sandbox_directory: Path,
681
774
  ) -> LoadLibraryMetadataFromFileResultSuccess | LoadLibraryMetadataFromFileResultFailure | None:
682
775
  """Generate sandbox library metadata by scanning Python files without importing them.
683
776
 
684
- Returns None if no sandbox directory is configured or no files are found.
685
- """
686
- config_mgr = GriptapeNodes.ConfigManager()
687
- sandbox_library_subdir = config_mgr.get_config_value("sandbox_library_directory")
688
- if not sandbox_library_subdir:
689
- logger.debug("No sandbox directory specified in config. Skipping sandbox library metadata generation.")
690
- return None
777
+ Args:
778
+ sandbox_directory: Path to sandbox directory to scan.
691
779
 
692
- # Prepend the workflow directory; if the sandbox dir starts with a slash, the workflow dir will be ignored.
693
- sandbox_library_dir = config_mgr.workspace_path / sandbox_library_subdir
694
- sandbox_library_dir_as_posix = sandbox_library_dir.as_posix()
780
+ Returns None if no files are found.
781
+ """
782
+ sandbox_library_dir_as_posix = sandbox_directory.as_posix()
695
783
 
696
- if not sandbox_library_dir.exists():
784
+ if not sandbox_directory.exists():
697
785
  details = "Sandbox directory does not exist. If you wish to create a Sandbox directory to develop custom nodes: in the Griptape Nodes editor, go to Settings -> Libraries and navigate to the Sandbox Settings."
698
786
  return LoadLibraryMetadataFromFileResultFailure(
699
787
  library_path=sandbox_library_dir_as_posix,
700
788
  library_name=LibraryManager.SANDBOX_LIBRARY_NAME,
701
- status=LibraryStatus.MISSING,
789
+ status=LibraryManager.LibraryFitness.MISSING,
702
790
  problems=[SandboxDirectoryMissingProblem()],
703
791
  result_details=ResultDetails(message=details, level=logging.INFO),
704
792
  )
705
793
 
706
- sandbox_node_candidates = self._find_files_in_dir(directory=sandbox_library_dir, extension=".py")
794
+ sandbox_node_candidates = self._find_files_in_dir(directory=sandbox_directory, extension=".py")
707
795
  if not sandbox_node_candidates:
708
796
  logger.debug(
709
- "No candidate files found in sandbox directory '%s'. Skipping sandbox library metadata generation.",
710
- sandbox_library_dir,
797
+ "No candidate files found in sandbox directory '%s'. Creating empty sandbox library metadata.",
798
+ sandbox_directory,
711
799
  )
712
- return None
800
+ # Continue with empty list - create valid schema with 0 nodes
801
+ sandbox_node_candidates = []
802
+
803
+ # Try to load existing library JSON for smart merging
804
+ json_path = sandbox_directory / LibraryManager.LIBRARY_CONFIG_FILENAME
805
+ metadata_result = self.load_library_metadata_from_file_request(
806
+ LoadLibraryMetadataFromFileRequest(file_path=str(json_path))
807
+ )
713
808
 
714
- # For metadata-only generation, we create placeholder node definitions
715
- # based on file names since we can't inspect the classes without importing
716
- node_definitions = []
717
- for candidate in sandbox_node_candidates:
718
- # Use the full file name (with extension) as a placeholder to make it clear this is a file candidate
719
- file_name = candidate.name
720
-
721
- # Create a placeholder node definition - we can't get the actual class metadata
722
- # without importing, so we use defaults
723
- node_metadata = NodeMetadata(
724
- category="Griptape Nodes Sandbox",
725
- description=f"'{file_name}' may contain one or more nodes defined in this candidate file.",
726
- display_name=file_name,
727
- icon="square-dashed",
728
- color=None,
809
+ existing_schema = None
810
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultSuccess):
811
+ existing_schema = metadata_result.library_schema
812
+ logger.debug("Loaded existing sandbox library JSON from '%s'", json_path)
813
+ else:
814
+ logger.debug(
815
+ "No existing sandbox library JSON or failed to load from '%s': %s. Will generate fresh schema.",
816
+ json_path,
817
+ metadata_result.result_details,
729
818
  )
730
- node_definition = NodeDefinition(
731
- class_name=file_name,
732
- file_path=str(candidate),
733
- metadata=node_metadata,
819
+
820
+ if existing_schema is not None:
821
+ # Smart merge: preserve existing customizations, add new files, remove deleted files
822
+ logger.debug(
823
+ "Merging existing sandbox library JSON with discovered files in sandbox directory '%s'",
824
+ sandbox_directory,
825
+ )
826
+ node_definitions = self._merge_sandbox_nodes(
827
+ existing_schema=existing_schema,
828
+ discovered_files=sandbox_node_candidates,
829
+ sandbox_directory=sandbox_directory,
734
830
  )
735
- node_definitions.append(node_definition)
736
831
 
737
- if not node_definitions:
832
+ if not node_definitions:
833
+ logger.debug(
834
+ "No valid node files found after merge in sandbox directory '%s'. Creating empty sandbox library metadata.",
835
+ sandbox_directory,
836
+ )
837
+ # Continue with empty list - create valid schema with 0 nodes
838
+ node_definitions = []
839
+
840
+ # Preserve existing library metadata
841
+ library_name = existing_schema.name
842
+ library_metadata = existing_schema.metadata
843
+ categories = existing_schema.categories
844
+
845
+ # Update schema version to latest
846
+ library_schema_version = LibrarySchema.LATEST_SCHEMA_VERSION
847
+
848
+ else:
849
+ # No existing JSON or it failed to load - generate fresh schema
738
850
  logger.debug(
739
- "No valid node files found in sandbox directory '%s'. Skipping sandbox library metadata generation.",
740
- sandbox_library_dir,
851
+ "Generating fresh sandbox library schema for sandbox directory '%s'",
852
+ sandbox_directory,
741
853
  )
742
- return None
743
854
 
744
- # Create the library schema
745
- sandbox_category = CategoryDefinition(
746
- title="Sandbox",
747
- description=f"Nodes loaded from the {LibraryManager.SANDBOX_LIBRARY_NAME}.",
748
- color="#c7621a",
749
- icon="Folder",
750
- )
855
+ # Create placeholder node definitions (original behavior)
856
+ node_definitions = []
857
+ for candidate in sandbox_node_candidates:
858
+ # Use placeholder class name to make it obvious when discovery hasn't run yet
859
+ class_name = self.UNRESOLVED_SANDBOX_CLASS_NAME
860
+ file_name = candidate.name
861
+
862
+ # Create a placeholder node definition - we can't get the actual class metadata
863
+ # without importing, so we use defaults
864
+ node_metadata = NodeMetadata(
865
+ category=self.SANDBOX_CATEGORY_NAME,
866
+ description=f"'{file_name}' may contain one or more nodes defined in this candidate file.",
867
+ display_name=file_name,
868
+ icon="square-dashed",
869
+ color=None,
870
+ )
871
+ node_definition = NodeDefinition(
872
+ class_name=class_name,
873
+ file_path=str(candidate.relative_to(sandbox_directory)),
874
+ metadata=node_metadata,
875
+ )
876
+ node_definitions.append(node_definition)
751
877
 
752
- engine_version = GriptapeNodes().handle_engine_version_request(request=GetEngineVersionRequest())
753
- if not isinstance(engine_version, GetEngineVersionResultSuccess):
754
- details = "Could not get engine version for sandbox library generation."
755
- return LoadLibraryMetadataFromFileResultFailure(
756
- library_path=sandbox_library_dir_as_posix,
757
- library_name=LibraryManager.SANDBOX_LIBRARY_NAME,
758
- status=LibraryStatus.UNUSABLE,
759
- problems=[EngineVersionErrorProblem()],
760
- result_details=details,
878
+ if not node_definitions:
879
+ logger.debug(
880
+ "No valid node files found in sandbox directory '%s'. Creating empty sandbox library metadata.",
881
+ sandbox_directory,
882
+ )
883
+ # Continue with empty list - create valid schema with 0 nodes
884
+ node_definitions = []
885
+
886
+ # Create default metadata
887
+ sandbox_category = CategoryDefinition(
888
+ title="Sandbox",
889
+ description=f"Nodes loaded from the {LibraryManager.SANDBOX_LIBRARY_NAME}.",
890
+ color="#c7621a",
891
+ icon="Folder",
761
892
  )
762
893
 
763
- engine_version_str = f"{engine_version.major}.{engine_version.minor}.{engine_version.patch}"
764
- library_metadata = LibraryMetadata(
765
- author="Author needs to be specified when library is published.",
766
- description="Nodes loaded from the sandbox library.",
767
- library_version=engine_version_str,
768
- engine_version=engine_version_str,
769
- tags=["sandbox"],
770
- is_griptape_nodes_searchable=False,
771
- )
772
- categories = [
773
- {"Griptape Nodes Sandbox": sandbox_category},
774
- ]
894
+ engine_version = GriptapeNodes().handle_engine_version_request(request=GetEngineVersionRequest())
895
+ if not isinstance(engine_version, GetEngineVersionResultSuccess):
896
+ details = "Could not get engine version for sandbox library generation."
897
+ return LoadLibraryMetadataFromFileResultFailure(
898
+ library_path=sandbox_library_dir_as_posix,
899
+ library_name=LibraryManager.SANDBOX_LIBRARY_NAME,
900
+ status=LibraryManager.LibraryFitness.UNUSABLE,
901
+ problems=[EngineVersionErrorProblem()],
902
+ result_details=details,
903
+ )
904
+
905
+ engine_version_str = f"{engine_version.major}.{engine_version.minor}.{engine_version.patch}"
906
+ library_metadata = LibraryMetadata(
907
+ author="Author needs to be specified when library is published.",
908
+ description="Nodes loaded from the sandbox library.",
909
+ library_version=engine_version_str,
910
+ engine_version=engine_version_str,
911
+ tags=["sandbox"],
912
+ is_griptape_nodes_searchable=False,
913
+ )
914
+ categories = [
915
+ {self.SANDBOX_CATEGORY_NAME: sandbox_category},
916
+ ]
917
+ library_name = LibraryManager.SANDBOX_LIBRARY_NAME
918
+ library_schema_version = LibrarySchema.LATEST_SCHEMA_VERSION
919
+
920
+ # Create the library schema (now using variables set by either path)
775
921
  library_schema = LibrarySchema(
776
- name=LibraryManager.SANDBOX_LIBRARY_NAME,
777
- library_schema_version=LibrarySchema.LATEST_SCHEMA_VERSION,
922
+ name=library_name,
923
+ library_schema_version=library_schema_version,
778
924
  metadata=library_metadata,
779
925
  categories=categories,
780
926
  nodes=node_definitions,
781
927
  )
782
928
 
783
- # Get git remote and ref if the sandbox directory is in a git repository
784
- try:
785
- git_remote = get_git_remote(sandbox_library_dir)
786
- except GitRemoteError as e:
787
- logger.debug("Failed to get git remote for sandbox library %s: %s", sandbox_library_dir, e)
788
- git_remote = None
929
+ # Sandbox libraries are never git repositories - always set to None
930
+ git_remote = None
931
+ git_ref = None
789
932
 
790
- try:
791
- git_ref = get_current_ref(sandbox_library_dir)
792
- except GitRefError as e:
793
- logger.debug("Failed to get git ref for sandbox library %s: %s", sandbox_library_dir, e)
794
- git_ref = None
795
-
796
- details = f"Successfully generated sandbox library metadata with {len(node_definitions)} nodes from {sandbox_library_dir}"
933
+ details = f"Successfully generated sandbox library metadata with {len(node_definitions)} nodes from {sandbox_directory}"
797
934
  return LoadLibraryMetadataFromFileResultSuccess(
798
935
  library_schema=library_schema,
799
- file_path=str(sandbox_library_dir),
936
+ file_path=str(sandbox_directory),
800
937
  git_remote=git_remote,
801
938
  git_ref=git_ref,
802
939
  result_details=details,
803
940
  )
804
941
 
942
+ def _merge_sandbox_nodes(
943
+ self,
944
+ existing_schema: LibrarySchema,
945
+ discovered_files: list[Path],
946
+ sandbox_directory: Path,
947
+ ) -> list[NodeDefinition]:
948
+ """Merge existing node definitions with newly discovered files.
949
+
950
+ Args:
951
+ existing_schema: Previously saved library schema
952
+ discovered_files: List of .py files found in sandbox directory
953
+ sandbox_directory: Path to sandbox directory for computing relative paths
954
+
955
+ Returns:
956
+ Merged list of NodeDefinitions
957
+ """
958
+ # Create mapping of discovered files for quick lookup (use absolute resolved paths)
959
+ discovered_file_paths = {str(f.resolve()): f for f in discovered_files}
960
+
961
+ # Keep existing nodes that still have corresponding files
962
+ merged_nodes = []
963
+ existing_file_paths = set()
964
+
965
+ for existing_node in existing_schema.nodes:
966
+ # Resolve the file path to absolute for comparison
967
+ try:
968
+ existing_file_path = str(Path(existing_node.file_path).resolve())
969
+ except Exception as e:
970
+ logger.warning(
971
+ "Could not resolve path for existing node '%s' at '%s': %s. Skipping.",
972
+ existing_node.class_name,
973
+ existing_node.file_path,
974
+ e,
975
+ )
976
+ continue
977
+
978
+ # Keep node if file still exists
979
+ if existing_file_path in discovered_file_paths:
980
+ merged_nodes.append(existing_node)
981
+ existing_file_paths.add(existing_file_path)
982
+ logger.debug(
983
+ "Preserved existing sandbox node definition: %s (%s)",
984
+ existing_node.class_name,
985
+ existing_node.file_path,
986
+ )
987
+ else:
988
+ logger.debug(
989
+ "Removing sandbox node '%s' - file no longer exists: %s",
990
+ existing_node.class_name,
991
+ existing_node.file_path,
992
+ )
993
+
994
+ # Add new files as placeholder nodes
995
+ for discovered_file in discovered_files:
996
+ discovered_file_path = str(discovered_file.resolve())
997
+
998
+ if discovered_file_path not in existing_file_paths:
999
+ # Create placeholder node definition for new file
1000
+ class_name = self.UNRESOLVED_SANDBOX_CLASS_NAME
1001
+ file_name = discovered_file.name
1002
+
1003
+ node_metadata = NodeMetadata(
1004
+ category=self.SANDBOX_CATEGORY_NAME,
1005
+ description=f"'{file_name}' may contain one or more nodes defined in this candidate file.",
1006
+ display_name=file_name,
1007
+ icon="square-dashed",
1008
+ color=None,
1009
+ )
1010
+ node_definition = NodeDefinition(
1011
+ class_name=class_name,
1012
+ file_path=str(discovered_file.relative_to(sandbox_directory)),
1013
+ metadata=node_metadata,
1014
+ )
1015
+ merged_nodes.append(node_definition)
1016
+ logger.debug(
1017
+ "Added new placeholder sandbox node: %s (%s)",
1018
+ file_name,
1019
+ discovered_file.relative_to(sandbox_directory),
1020
+ )
1021
+
1022
+ return merged_nodes
1023
+
1024
+ def _get_sandbox_directory(self) -> Path | None:
1025
+ """Get the configured sandbox directory path.
1026
+
1027
+ Returns:
1028
+ Path to sandbox directory if configured and exists, None otherwise.
1029
+ """
1030
+ config_mgr = GriptapeNodes.ConfigManager()
1031
+ sandbox_library_subdir = config_mgr.get_config_value("sandbox_library_directory")
1032
+ if not sandbox_library_subdir:
1033
+ return None
1034
+
1035
+ sandbox_library_dir = config_mgr.workspace_path / sandbox_library_subdir
1036
+ if not sandbox_library_dir.exists():
1037
+ return None
1038
+
1039
+ return sandbox_library_dir
1040
+
1041
+ def scan_sandbox_directory_request(
1042
+ self,
1043
+ request: ScanSandboxDirectoryRequest,
1044
+ ) -> ScanSandboxDirectoryResultSuccess | ScanSandboxDirectoryResultFailure:
1045
+ """Handle ScanSandboxDirectoryRequest.
1046
+
1047
+ Scans specified sandbox directory and generates/merges library metadata.
1048
+ """
1049
+ sandbox_directory = Path(request.directory_path)
1050
+
1051
+ # Generate/merge library metadata
1052
+ result = self._generate_sandbox_library_metadata(sandbox_directory=sandbox_directory)
1053
+
1054
+ # Note: result should never be None after Step 1 fix, but handle defensively
1055
+ if result is None:
1056
+ details = f"Internal error: _generate_sandbox_library_metadata returned None for {sandbox_directory}"
1057
+ return ScanSandboxDirectoryResultFailure(result_details=ResultDetails(message=details, level=logging.ERROR))
1058
+
1059
+ if isinstance(result, LoadLibraryMetadataFromFileResultFailure):
1060
+ # Failure during generation
1061
+ return ScanSandboxDirectoryResultFailure(result_details=result.result_details)
1062
+
1063
+ # Success
1064
+ return ScanSandboxDirectoryResultSuccess(
1065
+ library_schema=result.library_schema,
1066
+ result_details=ResultDetails(
1067
+ message=f"Scanned sandbox directory: {len(result.library_schema.nodes)} node definitions",
1068
+ level=logging.INFO,
1069
+ ),
1070
+ )
1071
+
805
1072
  def get_node_metadata_from_library_request(self, request: GetNodeMetadataFromLibraryRequest) -> ResultPayload:
806
1073
  # Does this library exist?
807
1074
  try:
@@ -842,206 +1109,443 @@ class LibraryManager:
842
1109
  )
843
1110
  return result
844
1111
 
845
- async def register_library_from_file_request(self, request: RegisterLibraryFromFileRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912, PLR0915 (complex logic needs branches)
846
- file_path = request.file_path
1112
+ async def register_library_from_file_request(self, request: RegisterLibraryFromFileRequest) -> ResultPayload: # noqa: PLR0911 (result determination needs returns)
1113
+ """Register a library by name or path, progressing through all lifecycle phases.
847
1114
 
848
- # Convert to Path object if it's a string
849
- json_path = Path(file_path)
1115
+ Supports loading by library_name OR file_path (mutually exclusive), with optional
1116
+ discovery integration. Creates LibraryInfo if not already tracked.
850
1117
 
851
- # Check if the file exists
852
- if not json_path.exists():
853
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
854
- library_path=file_path,
855
- library_name=None,
856
- status=LibraryStatus.MISSING,
857
- problems=[LibraryNotFoundProblem(library_path=file_path)],
858
- )
859
- details = f"Attempted to load Library JSON file. Failed because no file could be found at the specified path: {json_path}"
860
- return RegisterLibraryFromFileResultFailure(result_details=details)
861
-
862
- # Use the new metadata loading functionality
863
- metadata_request = LoadLibraryMetadataFromFileRequest(file_path=file_path)
864
- metadata_result = self.load_library_metadata_from_file_request(metadata_request)
1118
+ Args:
1119
+ request: RegisterLibraryFromFileRequest containing library_name OR file_path,
1120
+ perform_discovery_if_not_found, and load_as_default_library
865
1121
 
866
- if not isinstance(metadata_result, LoadLibraryMetadataFromFileResultSuccess):
867
- # Metadata loading failed, use the detailed error information from the failure result
868
- failure_result = cast("LoadLibraryMetadataFromFileResultFailure", metadata_result)
1122
+ Returns:
1123
+ RegisterLibraryFromFileResultSuccess if loaded, RegisterLibraryFromFileResultFailure otherwise
1124
+ """
1125
+ # Phase 1: Establish prerequisites
1126
+ prereq_result = await self._establish_register_library_prerequisites(request)
869
1127
 
870
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
871
- library_path=file_path,
872
- library_name=failure_result.library_name,
873
- status=failure_result.status,
874
- problems=failure_result.problems,
875
- )
876
- return RegisterLibraryFromFileResultFailure(result_details=str(failure_result.result_details))
1128
+ # FAILURE CHECK FIRST
1129
+ if isinstance(prereq_result, RegisterLibraryFromFileResultFailure):
1130
+ return prereq_result
877
1131
 
878
- # Get the validated library data
879
- library_data = metadata_result.library_schema
1132
+ # SUCCESS CHECK (library already loaded)
1133
+ if isinstance(prereq_result, RegisterLibraryFromFileResultSuccess):
1134
+ return prereq_result
880
1135
 
881
- # Make sure the version string is copacetic.
882
- library_version = library_data.metadata.library_version
883
- if library_version is None:
884
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
885
- library_path=file_path,
886
- library_name=library_data.name,
887
- status=LibraryStatus.UNUSABLE,
888
- problems=[InvalidVersionStringProblem(version_string=str(library_data.metadata.library_version))],
889
- )
890
- details = f"Attempted to load Library '{library_data.name}' JSON file from '{json_path}'. Failed because version string '{library_data.metadata.library_version}' wasn't valid. Must be in major.minor.patch format."
891
- return RegisterLibraryFromFileResultFailure(result_details=details)
1136
+ # Extract prerequisites
1137
+ library_info = prereq_result.library_info
1138
+ file_path = prereq_result.file_path
892
1139
 
893
- # Get the directory containing the JSON file to resolve relative paths
894
- base_dir = json_path.parent.absolute()
895
- # Add the directory to the Python path to allow for relative imports
896
- sys.path.insert(0, str(base_dir))
1140
+ # Phase 2: Progress through lifecycle phases
1141
+ progression_result = await self._progress_library_through_lifecycle(
1142
+ library_info=library_info, file_path=file_path, request=request
1143
+ )
897
1144
 
898
- # Install dependencies and add library venv to sys.path if library has dependencies
899
- if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
900
- venv_path = self._get_library_venv_path(library_data.name, file_path)
1145
+ # FAILURE CHECK
1146
+ if isinstance(progression_result, RegisterLibraryFromFileResultFailure):
1147
+ return progression_result
901
1148
 
902
- install_request = InstallLibraryDependenciesRequest(library_file_path=file_path)
903
- install_result = await self.install_library_dependencies_request(install_request)
1149
+ # Phase 3: Return appropriate result based on fitness
1150
+ # At this point, library_name must be set (it's set during METADATA_LOADED phase)
1151
+ if library_info.library_name is None:
1152
+ details = "Library loaded but library_name was not set during metadata loading"
1153
+ return RegisterLibraryFromFileResultFailure(result_details=details)
904
1154
 
905
- if isinstance(install_result, InstallLibraryDependenciesResultFailure):
906
- details = (
907
- f"Failed to install dependencies for library '{library_data.name}': {install_result.result_details}"
1155
+ match library_info.fitness:
1156
+ case LibraryManager.LibraryFitness.GOOD:
1157
+ details = f"Successfully loaded Library '{library_info.library_name}' from JSON file at {file_path}"
1158
+ return RegisterLibraryFromFileResultSuccess(
1159
+ library_name=library_info.library_name,
1160
+ result_details=ResultDetails(message=details, level=logging.INFO),
1161
+ )
1162
+ case LibraryManager.LibraryFitness.FLAWED:
1163
+ details = f"Successfully loaded Library JSON file from '{file_path}', but one or more nodes failed to load. Check the log for more details."
1164
+ return RegisterLibraryFromFileResultSuccess(
1165
+ library_name=library_info.library_name,
1166
+ result_details=ResultDetails(message=details, level=logging.WARNING),
908
1167
  )
1168
+ case LibraryManager.LibraryFitness.UNUSABLE:
1169
+ details = f"Attempted to load Library JSON file from '{file_path}'. Failed because no nodes were loaded. Check the log for more details."
1170
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1171
+ case _:
1172
+ details = f"Attempted to load Library JSON file from '{file_path}'. Failed because an unknown/unexpected fitness '{library_info.fitness}' was returned."
909
1173
  return RegisterLibraryFromFileResultFailure(result_details=details)
910
1174
 
911
- if isinstance(install_result, InstallLibraryDependenciesResultSuccess):
912
- logger.info(
913
- "Installed %d dependencies for library '%s'",
914
- install_result.dependencies_installed,
915
- library_data.name,
1175
+ async def _establish_register_library_prerequisites( # noqa: C901, PLR0911, PLR0912 (prerequisite validation needs branches)
1176
+ self, request: RegisterLibraryFromFileRequest
1177
+ ) -> (
1178
+ LibraryManager.RegisterLibraryPrerequisites
1179
+ | RegisterLibraryFromFileResultSuccess
1180
+ | RegisterLibraryFromFileResultFailure
1181
+ ):
1182
+ """Validate request and establish library identity.
1183
+
1184
+ Returns:
1185
+ RegisterLibraryPrerequisites: Ready for lifecycle progression
1186
+ RegisterLibraryFromFileResultSuccess: Library already loaded (early exit)
1187
+ RegisterLibraryFromFileResultFailure: Validation or lookup failed
1188
+ """
1189
+ # Validate request has either library_name or file_path (but not both)
1190
+ if not request.library_name and not request.file_path:
1191
+ return RegisterLibraryFromFileResultFailure(
1192
+ result_details="Attempted to register a library. Failed because neither library name nor file path were specified."
1193
+ )
1194
+
1195
+ if request.library_name and request.file_path:
1196
+ return RegisterLibraryFromFileResultFailure(
1197
+ result_details="Attempted to register a library. Failed because both library name and file path were specified."
1198
+ )
1199
+
1200
+ library_name = request.library_name
1201
+ file_path = request.file_path
1202
+
1203
+ # If file_path provided but not library_name, load metadata to get the name
1204
+ if file_path and not library_name:
1205
+ lib_info = self._library_file_path_to_info.get(file_path)
1206
+
1207
+ # If we don't have LibraryInfo yet, load metadata to get the name
1208
+ if not lib_info or not lib_info.library_name:
1209
+ metadata_result = self.load_library_metadata_from_file_request(
1210
+ LoadLibraryMetadataFromFileRequest(file_path=file_path)
916
1211
  )
917
1212
 
918
- # Add venv site-packages to sys.path so node imports can find dependencies
919
- if venv_path.exists():
920
- site_packages = str(
921
- Path(
922
- sysconfig.get_path(
923
- "purelib",
924
- vars={"base": str(venv_path), "platbase": str(venv_path)},
925
- )
1213
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultFailure):
1214
+ return RegisterLibraryFromFileResultFailure(result_details=metadata_result.result_details)
1215
+
1216
+ library_name = metadata_result.library_schema.name
1217
+
1218
+ # Update or create LibraryInfo
1219
+ if lib_info:
1220
+ lib_info.library_name = library_name
1221
+ lib_info.library_version = metadata_result.library_schema.metadata.library_version
1222
+ lib_info.lifecycle_state = LibraryManager.LibraryLifecycleState.METADATA_LOADED
1223
+ else:
1224
+ # Create new LibraryInfo since it doesn't exist yet
1225
+ lib_info = LibraryManager.LibraryInfo(
1226
+ lifecycle_state=LibraryManager.LibraryLifecycleState.METADATA_LOADED,
1227
+ library_path=file_path,
1228
+ is_sandbox=False,
1229
+ library_name=library_name,
1230
+ library_version=metadata_result.library_schema.metadata.library_version,
1231
+ fitness=LibraryManager.LibraryFitness.NOT_EVALUATED,
1232
+ problems=[],
926
1233
  )
927
- )
928
- sys.path.insert(0, site_packages)
929
- logger.debug("Added library '%s' venv to sys.path: %s", library_data.name, site_packages)
1234
+ self._library_file_path_to_info[file_path] = lib_info
1235
+ else:
1236
+ library_name = lib_info.library_name
930
1237
 
931
- # Load the advanced library module if specified
932
- advanced_library_instance = None
933
- if library_data.advanced_library_path:
934
- try:
935
- advanced_library_instance = self._load_advanced_library_module(
936
- library_data=library_data,
937
- base_dir=base_dir,
938
- )
939
- except Exception as err:
940
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
941
- library_path=file_path,
942
- library_name=library_data.name,
943
- library_version=library_version,
944
- status=LibraryStatus.UNUSABLE,
945
- problems=[
946
- AdvancedLibraryLoadFailureProblem(
947
- advanced_library_path=library_data.advanced_library_path, error_message=str(err)
948
- )
949
- ],
950
- )
951
- details = f"Attempted to load Library '{library_data.name}' from '{json_path}'. Failed to load Advanced Library module: {err}"
952
- return RegisterLibraryFromFileResultFailure(result_details=details)
1238
+ # At this point, library_name must be set (either from request or from metadata)
1239
+ if not library_name:
1240
+ return RegisterLibraryFromFileResultFailure(result_details="Failed to determine library name")
953
1241
 
954
- # Create or get the library
1242
+ # Check if already loaded in registry
955
1243
  try:
956
- # Try to create a new library
957
- library = LibraryRegistry.generate_new_library(
958
- library_data=library_data,
959
- mark_as_default_library=request.load_as_default_library,
960
- advanced_library=advanced_library_instance,
961
- )
962
-
963
- except KeyError as err:
964
- # Library already exists
965
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
966
- library_path=file_path,
967
- library_name=library_data.name,
968
- library_version=library_version,
969
- status=LibraryStatus.UNUSABLE,
970
- problems=[DuplicateLibraryProblem()],
1244
+ LibraryRegistry.get_library(name=library_name)
1245
+ return RegisterLibraryFromFileResultSuccess(
1246
+ library_name=library_name,
1247
+ result_details=f"Library '{library_name}' already loaded",
971
1248
  )
972
-
973
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed because a Library '{library_data.name}' already exists. Error: {err}."
1249
+ except KeyError:
1250
+ pass # Not loaded, continue
1251
+
1252
+ # Look up LibraryInfo by library_name (supports lazy loading)
1253
+ library_info = self.get_library_info_by_library_name(library_name)
1254
+
1255
+ # If not found and discovery is allowed, try discovery
1256
+ if library_info is None and request.perform_discovery_if_not_found:
1257
+ discover_result = self.discover_libraries_request(DiscoverLibrariesRequest())
1258
+ if isinstance(discover_result, DiscoverLibrariesResultSuccess):
1259
+ library_info = self.get_library_info_by_library_name(library_name)
1260
+
1261
+ # If still not found, fail
1262
+ if library_info is None:
1263
+ details = f"Library '{library_name}' not found"
1264
+ if request.perform_discovery_if_not_found:
1265
+ details += " (discovery was attempted)"
974
1266
  return RegisterLibraryFromFileResultFailure(result_details=details)
975
1267
 
976
- # We are at least potentially viable.
977
- # Record all problems that occurred
978
- problems = []
1268
+ file_path = library_info.library_path
979
1269
 
980
- # Check the library's custom config settings.
981
- if library_data.settings is not None:
982
- # Assign them into the config space.
983
- for library_data_setting in library_data.settings:
984
- # Does the category exist?
985
- get_category_request = GetConfigCategoryRequest(
986
- category=library_data_setting.category,
987
- failure_log_level=logging.DEBUG, # Missing category is expected, suppress error toast
1270
+ # Check if already loaded in registry (by name if we have it)
1271
+ if library_info.library_name:
1272
+ try:
1273
+ LibraryRegistry.get_library(name=library_info.library_name)
1274
+ except KeyError:
1275
+ # Library not in registry, continue with loading
1276
+ pass
1277
+ else:
1278
+ # Already loaded and good to go
1279
+ return RegisterLibraryFromFileResultSuccess(
1280
+ library_name=library_info.library_name,
1281
+ result_details=f"Library '{library_info.library_name}' already loaded",
988
1282
  )
989
- get_category_result = GriptapeNodes.handle_request(get_category_request)
990
- if not isinstance(get_category_result, GetConfigCategoryResultSuccess):
991
- # That's OK, we'll invent it. Or at least we'll try.
992
- create_new_category_request = SetConfigCategoryRequest(
993
- category=library_data_setting.category, contents=library_data_setting.contents
1283
+
1284
+ # Prerequisites established - ready for lifecycle progression
1285
+ return LibraryManager.RegisterLibraryPrerequisites(library_info=library_info, file_path=file_path)
1286
+
1287
+ async def _progress_library_through_lifecycle( # noqa: C901, PLR0911, PLR0912, PLR0915 (lifecycle state machine needs branches/statements/returns)
1288
+ self,
1289
+ library_info: LibraryManager.LibraryInfo,
1290
+ file_path: str,
1291
+ request: RegisterLibraryFromFileRequest,
1292
+ ) -> None | RegisterLibraryFromFileResultFailure:
1293
+ """Progress library through lifecycle states until LOADED.
1294
+
1295
+ Advances library_info through states: DISCOVERED → METADATA_LOADED →
1296
+ EVALUATED → DEPENDENCIES_INSTALLED → LOADED.
1297
+
1298
+ Modifies library_info in place as it progresses through states.
1299
+
1300
+ Returns:
1301
+ None: Successfully progressed to LOADED state
1302
+ RegisterLibraryFromFileResultFailure: Failed during progression
1303
+ """
1304
+ while True:
1305
+ current_state = library_info.lifecycle_state
1306
+
1307
+ match current_state:
1308
+ case LibraryManager.LibraryLifecycleState.LOADED:
1309
+ # Terminal state: inconsistent (marked LOADED but not in registry)
1310
+ details = f"Library '{library_info.library_name}' marked as LOADED but not in registry"
1311
+ self._library_file_path_to_info[library_info.library_path] = library_info
1312
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1313
+
1314
+ case LibraryManager.LibraryLifecycleState.FAILURE:
1315
+ # Terminal state: failure
1316
+ details = f"Library '{library_info.library_name}' is in FAILURE state and cannot be loaded"
1317
+ self._library_file_path_to_info[library_info.library_path] = library_info
1318
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1319
+
1320
+ case LibraryManager.LibraryLifecycleState.DISCOVERED:
1321
+ # DISCOVERED → METADATA_LOADED
1322
+ # All libraries (including sandbox) load metadata from JSON file
1323
+ metadata_result = self.load_library_metadata_from_file_request(
1324
+ LoadLibraryMetadataFromFileRequest(file_path=library_info.library_path)
994
1325
  )
995
- create_new_category_result = GriptapeNodes.handle_request(create_new_category_request)
996
- if not isinstance(create_new_category_result, SetConfigCategoryResultSuccess):
997
- problems.append(CreateConfigCategoryProblem(category_name=library_data_setting.category))
998
- details = f"Failed attempting to create new config category '{library_data_setting.category}' for library '{library_data.name}'."
999
- logger.error(details)
1000
- continue # SKIP IT
1001
- else:
1002
- # We had an existing category. Union our changes into it (not replacing anything that matched).
1003
- existing_category_contents = merge_dicts(
1004
- library_data_setting.contents, get_category_result.contents, add_keys=True, merge_lists=True
1326
+
1327
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultFailure):
1328
+ self._library_file_path_to_info[library_info.library_path] = library_info
1329
+ return RegisterLibraryFromFileResultFailure(result_details=metadata_result.result_details)
1330
+
1331
+ # Update library_info with metadata results
1332
+ library_info.library_name = metadata_result.library_schema.name
1333
+ library_info.library_version = metadata_result.library_schema.metadata.library_version
1334
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.METADATA_LOADED
1335
+
1336
+ case LibraryManager.LibraryLifecycleState.METADATA_LOADED:
1337
+ # METADATA_LOADED → EVALUATED
1338
+ # Need to load schema to pass to evaluate request
1339
+ metadata_result = self.load_library_metadata_from_file_request(
1340
+ LoadLibraryMetadataFromFileRequest(file_path=library_info.library_path)
1341
+ )
1342
+
1343
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultFailure):
1344
+ self._library_file_path_to_info[library_info.library_path] = library_info
1345
+ return RegisterLibraryFromFileResultFailure(result_details=metadata_result.result_details)
1346
+
1347
+ evaluate_result = self.evaluate_library_fitness_request(
1348
+ EvaluateLibraryFitnessRequest(schema=metadata_result.library_schema)
1005
1349
  )
1006
- set_category_request = SetConfigCategoryRequest(
1007
- category=library_data_setting.category, contents=existing_category_contents
1350
+ if isinstance(evaluate_result, EvaluateLibraryFitnessResultFailure):
1351
+ self._library_file_path_to_info[library_info.library_path] = library_info
1352
+ return RegisterLibraryFromFileResultFailure(result_details=evaluate_result.result_details)
1353
+
1354
+ # Update library_info with evaluation results
1355
+ library_info.fitness = evaluate_result.fitness
1356
+ library_info.problems.extend(evaluate_result.problems)
1357
+
1358
+ # Check if library requirements are met by the current system
1359
+ library_data = metadata_result.library_schema
1360
+ library_requirements = (
1361
+ library_data.metadata.resources.required
1362
+ if library_data.metadata.resources is not None
1363
+ else None
1008
1364
  )
1009
- set_category_result = GriptapeNodes.handle_request(set_category_request)
1010
- if not isinstance(set_category_result, SetConfigCategoryResultSuccess):
1011
- problems.append(UpdateConfigCategoryProblem(category_name=library_data_setting.category))
1012
- details = f"Failed attempting to update config category '{library_data_setting.category}' for library '{library_data.name}'."
1013
- logger.error(details)
1014
- continue # SKIP IT
1015
-
1016
- # Attempt to load nodes from the library.
1017
- library_load_results = await asyncio.to_thread(
1018
- self._attempt_load_nodes_from_library,
1019
- library_data=library_data,
1020
- library=library,
1021
- base_dir=base_dir,
1022
- library_file_path=file_path,
1023
- library_version=library_version,
1024
- problems=problems,
1025
- )
1026
- self._library_file_path_to_info[file_path] = library_load_results
1365
+ if library_requirements is not None:
1366
+ requirements_check_result = self._check_library_requirements(
1367
+ library_requirements, library_data.name
1368
+ )
1369
+ if requirements_check_result is not None:
1370
+ library_info.fitness = LibraryManager.LibraryFitness.UNUSABLE
1371
+ library_info.problems.append(requirements_check_result)
1372
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.FAILURE
1373
+ self._library_file_path_to_info[library_info.library_path] = library_info
1374
+ details = f"Library '{library_data.name}' requirements not met: {library_requirements}"
1375
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1376
+
1377
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.EVALUATED
1378
+
1379
+ case LibraryManager.LibraryLifecycleState.EVALUATED:
1380
+ # EVALUATED → DEPENDENCIES_INSTALLED
1381
+ install_result = await self.install_library_dependencies_request(
1382
+ InstallLibraryDependenciesRequest(library_file_path=library_info.library_path)
1383
+ )
1384
+ if isinstance(install_result, InstallLibraryDependenciesResultFailure):
1385
+ self._library_file_path_to_info[library_info.library_path] = library_info
1386
+ return RegisterLibraryFromFileResultFailure(result_details=install_result.result_details)
1027
1387
 
1028
- match library_load_results.status:
1029
- case LibraryStatus.GOOD:
1030
- details = f"Successfully loaded Library '{library_data.name}' from JSON file at {json_path}"
1031
- return RegisterLibraryFromFileResultSuccess(
1032
- library_name=library_data.name, result_details=ResultDetails(message=details, level=logging.INFO)
1033
- )
1034
- case LibraryStatus.FLAWED:
1035
- details = f"Successfully loaded Library JSON file from '{json_path}', but one or more nodes failed to load. Check the log for more details."
1036
- return RegisterLibraryFromFileResultSuccess(
1037
- library_name=library_data.name, result_details=ResultDetails(message=details, level=logging.WARNING)
1038
- )
1039
- case LibraryStatus.UNUSABLE:
1040
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed because no nodes were loaded. Check the log for more details."
1041
- return RegisterLibraryFromFileResultFailure(result_details=details)
1042
- case _:
1043
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed because an unknown/unexpected status '{library_load_results.status}' was returned."
1044
- return RegisterLibraryFromFileResultFailure(result_details=details)
1388
+ # Update library_info
1389
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.DEPENDENCIES_INSTALLED
1390
+
1391
+ case LibraryManager.LibraryLifecycleState.DEPENDENCIES_INSTALLED:
1392
+ # DEPENDENCIES_INSTALLED → LOADED
1393
+
1394
+ if not library_info.is_sandbox:
1395
+ # REGULAR LIBRARIES: Standard registration from JSON file
1396
+ # Load metadata and create library
1397
+ metadata_result = self.load_library_metadata_from_file_request(
1398
+ LoadLibraryMetadataFromFileRequest(file_path=library_info.library_path)
1399
+ )
1400
+
1401
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultFailure):
1402
+ self._library_file_path_to_info[library_info.library_path] = library_info
1403
+ return RegisterLibraryFromFileResultFailure(result_details=metadata_result.result_details)
1404
+
1405
+ library_data = metadata_result.library_schema
1406
+ json_path = Path(file_path)
1407
+ base_dir = json_path.parent.absolute()
1408
+
1409
+ # Add the directory to the Python path to allow for relative imports
1410
+ sys.path.insert(0, str(base_dir))
1411
+
1412
+ # Add venv site-packages to sys.path if library has dependencies
1413
+ if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
1414
+ venv_path = self._get_library_venv_path(library_data.name, file_path)
1415
+ if venv_path.exists():
1416
+ site_packages = str(
1417
+ Path(
1418
+ sysconfig.get_path(
1419
+ "purelib",
1420
+ vars={"base": str(venv_path), "platbase": str(venv_path)},
1421
+ )
1422
+ )
1423
+ )
1424
+ sys.path.insert(0, site_packages)
1425
+ logger.debug(
1426
+ "Added library '%s' venv to sys.path: %s", library_data.name, site_packages
1427
+ )
1428
+
1429
+ # Load the advanced library module if specified
1430
+ advanced_library_instance = None
1431
+ if library_data.advanced_library_path:
1432
+ try:
1433
+ advanced_library_instance = self._load_advanced_library_module(
1434
+ library_data=library_data,
1435
+ base_dir=base_dir,
1436
+ )
1437
+ except Exception as err:
1438
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.FAILURE
1439
+ library_info.fitness = LibraryManager.LibraryFitness.UNUSABLE
1440
+ library_info.problems.append(
1441
+ AdvancedLibraryLoadFailureProblem(
1442
+ advanced_library_path=library_data.advanced_library_path, error_message=str(err)
1443
+ )
1444
+ )
1445
+ self._library_file_path_to_info[file_path] = library_info
1446
+ details = f"Attempted to load Library '{library_data.name}' from '{json_path}'. Failed to load Advanced Library module: {err}"
1447
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1448
+
1449
+ # Create or get the library
1450
+ try:
1451
+ library = LibraryRegistry.generate_new_library(
1452
+ library_data=library_data,
1453
+ mark_as_default_library=request.load_as_default_library,
1454
+ advanced_library=advanced_library_instance,
1455
+ )
1456
+ except KeyError as err:
1457
+ # Library already exists
1458
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.FAILURE
1459
+ library_info.fitness = LibraryManager.LibraryFitness.UNUSABLE
1460
+ library_info.problems.append(DuplicateLibraryProblem())
1461
+ self._library_file_path_to_info[file_path] = library_info
1462
+ details = f"Attempted to load Library JSON file from '{json_path}'. Failed because a Library '{library_data.name}' already exists. Error: {err}."
1463
+ return RegisterLibraryFromFileResultFailure(result_details=details)
1464
+
1465
+ # Check the library's custom config settings
1466
+ if library_data.settings is not None:
1467
+ for library_data_setting in library_data.settings:
1468
+ # Does the category exist?
1469
+ get_category_request = GetConfigCategoryRequest(
1470
+ category=library_data_setting.category,
1471
+ failure_log_level=logging.DEBUG,
1472
+ )
1473
+ get_category_result = GriptapeNodes.handle_request(get_category_request)
1474
+ if not isinstance(get_category_result, GetConfigCategoryResultSuccess):
1475
+ # Create new category
1476
+ create_new_category_request = SetConfigCategoryRequest(
1477
+ category=library_data_setting.category, contents=library_data_setting.contents
1478
+ )
1479
+ create_new_category_result = GriptapeNodes.handle_request(
1480
+ create_new_category_request
1481
+ )
1482
+ if not isinstance(create_new_category_result, SetConfigCategoryResultSuccess):
1483
+ library_info.problems.append(
1484
+ CreateConfigCategoryProblem(category_name=library_data_setting.category)
1485
+ )
1486
+ details = f"Failed attempting to create new config category '{library_data_setting.category}' for library '{library_data.name}'."
1487
+ logger.error(details)
1488
+ continue
1489
+ else:
1490
+ # Merge with existing category
1491
+ existing_category_contents = merge_dicts(
1492
+ library_data_setting.contents,
1493
+ get_category_result.contents,
1494
+ add_keys=True,
1495
+ merge_lists=True,
1496
+ )
1497
+ set_category_request = SetConfigCategoryRequest(
1498
+ category=library_data_setting.category, contents=existing_category_contents
1499
+ )
1500
+ set_category_result = GriptapeNodes.handle_request(set_category_request)
1501
+ if not isinstance(set_category_result, SetConfigCategoryResultSuccess):
1502
+ library_info.problems.append(
1503
+ UpdateConfigCategoryProblem(category_name=library_data_setting.category)
1504
+ )
1505
+ details = f"Failed attempting to update config category '{library_data_setting.category}' for library '{library_data.name}'."
1506
+ logger.error(details)
1507
+ continue
1508
+
1509
+ # Attempt to load nodes from the library (modifies library_info in place)
1510
+ await asyncio.to_thread(
1511
+ self._attempt_load_nodes_from_library,
1512
+ library_data=library_data,
1513
+ library=library,
1514
+ base_dir=base_dir,
1515
+ library_info=library_info,
1516
+ )
1517
+ self._library_file_path_to_info[file_path] = library_info
1518
+ else:
1519
+ # SANDBOX LIBRARIES: Full processing here (discovery + registration)
1520
+ # Load metadata from JSON file (already generated in DISCOVERED → METADATA_LOADED)
1521
+ sandbox_directory = Path(library_info.library_path).parent
1522
+ metadata_result = self.load_library_metadata_from_file_request(
1523
+ LoadLibraryMetadataFromFileRequest(file_path=library_info.library_path)
1524
+ )
1525
+
1526
+ if isinstance(metadata_result, LoadLibraryMetadataFromFileResultFailure):
1527
+ self._library_file_path_to_info[library_info.library_path] = library_info
1528
+ return RegisterLibraryFromFileResultFailure(result_details=metadata_result.result_details)
1529
+
1530
+ # Discover real class names by importing files
1531
+ await self._attempt_generate_sandbox_library_from_schema(
1532
+ library_schema=metadata_result.library_schema,
1533
+ sandbox_directory=str(sandbox_directory),
1534
+ library_info=library_info,
1535
+ )
1536
+ # Function handles registration and updates library_info with problems
1537
+ # lifecycle_state set to LOADED by _attempt_load_nodes_from_library
1538
+
1539
+ # Exit loop after final phase
1540
+ break
1541
+
1542
+ case _:
1543
+ # Unexpected state
1544
+ msg = f"Library '{library_info.library_name}' in unexpected lifecycle state: {current_state}"
1545
+ raise ValueError(msg)
1546
+
1547
+ # Success - progressed to LOADED state
1548
+ return None
1045
1549
 
1046
1550
  async def register_library_from_requirement_specifier_request(
1047
1551
  self, request: RegisterLibraryFromRequirementSpecifierRequest
@@ -1174,6 +1678,106 @@ class LibraryManager:
1174
1678
 
1175
1679
  return library_venv_python_path
1176
1680
 
1681
+ def _check_library_requirements(
1682
+ self, requirements: dict[str, Any], library_name: str
1683
+ ) -> IncompatibleRequirementsProblem | None:
1684
+ """Check if the current system meets the library's resource requirements.
1685
+
1686
+ Args:
1687
+ requirements: Dictionary of requirements in the format used by resource_instance.Requirements
1688
+ library_name: Name of the library being checked (for logging)
1689
+
1690
+ Returns:
1691
+ IncompatibleRequirementsProblem if requirements are not met, None if they are met
1692
+ """
1693
+ logger.info("Checking requirements for library '%s': %s", library_name, requirements)
1694
+
1695
+ os_keys = {"platform", "arch", "version"}
1696
+ compute_keys = {"compute"}
1697
+
1698
+ os_requirements = {k: v for k, v in requirements.items() if k in os_keys}
1699
+ compute_requirements = {k: v for k, v in requirements.items() if k in compute_keys}
1700
+
1701
+ if os_requirements:
1702
+ list_request = ListCompatibleResourceInstancesRequest(
1703
+ resource_type_name="OSResourceType",
1704
+ requirements=os_requirements,
1705
+ include_locked=True,
1706
+ )
1707
+ result = GriptapeNodes.handle_request(list_request)
1708
+
1709
+ if isinstance(result, ListCompatibleResourceInstancesResultSuccess) and not result.instance_ids:
1710
+ system_capabilities = self._get_system_capabilities()
1711
+ logger.warning(
1712
+ "Library '%s' OS requirements not met. Required: %s, System: %s",
1713
+ library_name,
1714
+ os_requirements,
1715
+ system_capabilities,
1716
+ )
1717
+ return IncompatibleRequirementsProblem(
1718
+ requirements=requirements,
1719
+ system_capabilities=system_capabilities,
1720
+ )
1721
+
1722
+ if compute_requirements:
1723
+ list_request = ListCompatibleResourceInstancesRequest(
1724
+ resource_type_name="ComputeResourceType",
1725
+ requirements=compute_requirements,
1726
+ include_locked=True,
1727
+ )
1728
+ result = GriptapeNodes.handle_request(list_request)
1729
+
1730
+ if isinstance(result, ListCompatibleResourceInstancesResultSuccess) and not result.instance_ids:
1731
+ system_capabilities = self._get_system_capabilities()
1732
+ logger.warning(
1733
+ "Library '%s' compute requirements not met. Required: %s, System: %s",
1734
+ library_name,
1735
+ compute_requirements,
1736
+ system_capabilities,
1737
+ )
1738
+ return IncompatibleRequirementsProblem(
1739
+ requirements=requirements,
1740
+ system_capabilities=system_capabilities,
1741
+ )
1742
+
1743
+ return None
1744
+
1745
+ def _get_system_capabilities(self) -> dict[str, Any]:
1746
+ """Get the current system's capabilities for error reporting.
1747
+
1748
+ Returns:
1749
+ Dictionary of combined OS and compute capabilities or empty dict if unavailable
1750
+ """
1751
+ capabilities: dict[str, Any] = {}
1752
+
1753
+ os_list_request = ListCompatibleResourceInstancesRequest(
1754
+ resource_type_name="OSResourceType",
1755
+ requirements=None,
1756
+ include_locked=True,
1757
+ )
1758
+ os_result = GriptapeNodes.handle_request(os_list_request)
1759
+
1760
+ if isinstance(os_result, ListCompatibleResourceInstancesResultSuccess) and os_result.instance_ids:
1761
+ status_request = GetResourceInstanceStatusRequest(instance_id=os_result.instance_ids[0])
1762
+ status_result = GriptapeNodes.handle_request(status_request)
1763
+ if isinstance(status_result, GetResourceInstanceStatusResultSuccess):
1764
+ capabilities.update(status_result.status.capabilities)
1765
+
1766
+ compute_list_request = ListCompatibleResourceInstancesRequest(
1767
+ resource_type_name="ComputeResourceType",
1768
+ requirements=None,
1769
+ include_locked=True,
1770
+ )
1771
+ compute_result = GriptapeNodes.handle_request(compute_list_request)
1772
+
1773
+ if isinstance(compute_result, ListCompatibleResourceInstancesResultSuccess) and compute_result.instance_ids:
1774
+ status_request = GetResourceInstanceStatusRequest(instance_id=compute_result.instance_ids[0])
1775
+ status_result = GriptapeNodes.handle_request(status_request)
1776
+ if isinstance(status_result, GetResourceInstanceStatusResultSuccess):
1777
+ capabilities.update(status_result.status.capabilities)
1778
+
1779
+ return capabilities
1780
+
1177
1781
  def _get_library_venv_path(self, library_name: str, library_file_path: str | None = None) -> Path:
1178
1782
  """Get the path to the virtual environment directory for a library.
1179
1783
 
@@ -1626,113 +2230,89 @@ class LibraryManager:
1626
2230
  async def load_all_libraries_from_config(self) -> None:
1627
2231
  self._libraries_loading_complete.clear()
1628
2232
 
1629
- try:
1630
- # Load metadata for all libraries to determine which ones can be safely loaded
1631
- metadata_request = LoadMetadataForAllLibrariesRequest()
1632
- metadata_result = self.load_metadata_for_all_libraries_request(metadata_request)
1633
-
1634
- # Check if metadata loading succeeded
1635
- if not isinstance(metadata_result, LoadMetadataForAllLibrariesResultSuccess):
1636
- logger.error("Failed to load metadata for all libraries, skipping library registration")
1637
- return
1638
-
1639
- # Record all failed libraries in our tracking immediately
1640
- for failed_library in metadata_result.failed_libraries:
1641
- self._library_file_path_to_info[failed_library.library_path] = LibraryManager.LibraryInfo(
1642
- library_path=failed_library.library_path,
1643
- library_name=failed_library.library_name,
1644
- status=failed_library.status,
1645
- problems=failed_library.problems,
1646
- )
2233
+ # Discover all available libraries (config + sandbox)
2234
+ discover_result = self.discover_libraries_request(DiscoverLibrariesRequest())
2235
+ if isinstance(discover_result, DiscoverLibrariesResultFailure):
2236
+ logger.error("Failed to discover libraries: %s", discover_result.result_details)
2237
+ self._libraries_loading_complete.set()
2238
+ return
2239
+
2240
+ # Build list of library paths to load
2241
+ libraries_to_load = []
2242
+ for discovered_lib in discover_result.libraries_discovered:
2243
+ lib_path = str(discovered_lib.path)
2244
+ lib_info = self._library_file_path_to_info.get(lib_path)
2245
+
2246
+ if lib_info:
2247
+ libraries_to_load.append(lib_path)
1647
2248
 
1648
- # Calculate total libraries for progress tracking
1649
- total_libraries = len(metadata_result.successful_libraries)
2249
+ if not libraries_to_load:
2250
+ logger.info("No libraries found in configuration.")
2251
+ self._libraries_loading_complete.set()
2252
+ return
1650
2253
 
1651
- # Use metadata results to selectively load libraries
1652
- for current_library_index, library_result in enumerate(metadata_result.successful_libraries, start=1):
1653
- library_name = library_result.library_schema.name
2254
+ # Calculate total libraries for progress tracking
2255
+ total_libraries = len(libraries_to_load)
2256
+
2257
+ # Load each discovered library by path (RegisterLibraryFromFileRequest will handle metadata loading)
2258
+ for current_library_index, lib_path in enumerate(libraries_to_load, start=1):
2259
+ # Load the library through unified lifecycle using library_path
2260
+ # RegisterLibraryFromFileRequest will handle metadata loading internally to get library_name
2261
+ load_result = await self.register_library_from_file_request(
2262
+ RegisterLibraryFromFileRequest(
2263
+ file_path=lib_path,
2264
+ load_as_default_library=False,
2265
+ )
2266
+ )
1654
2267
 
1655
- # Emit loading event
2268
+ # Handle failure case first
2269
+ if isinstance(load_result, RegisterLibraryFromFileResultFailure):
2270
+ logger.warning("Failed to load library at '%s': %s", lib_path, load_result.result_details)
2271
+ error_message = (
2272
+ load_result.result_details.result_details[0].message
2273
+ if isinstance(load_result.result_details, ResultDetails)
2274
+ else str(load_result.result_details)
2275
+ )
1656
2276
  GriptapeNodes.EventManager().put_event(
1657
2277
  AppEvent(
1658
2278
  payload=EngineInitializationProgress(
1659
2279
  phase=InitializationPhase.LIBRARIES,
1660
- item_name=library_name,
1661
- status=InitializationStatus.LOADING,
2280
+ item_name=lib_path, # Use path as fallback since we don't have library_name
2281
+ status=InitializationStatus.FAILED,
1662
2282
  current=current_library_index,
1663
2283
  total=total_libraries,
2284
+ error=error_message,
1664
2285
  )
1665
2286
  )
1666
2287
  )
2288
+ continue
1667
2289
 
1668
- # Register the library
1669
- if library_result.library_schema.name == LibraryManager.SANDBOX_LIBRARY_NAME:
1670
- # Handle sandbox library - use the schema we already have
1671
- await self._attempt_generate_sandbox_library_from_schema(
1672
- library_schema=library_result.library_schema, sandbox_directory=library_result.file_path
1673
- )
1674
- # Emit success event for sandbox library
1675
- GriptapeNodes.EventManager().put_event(
1676
- AppEvent(
1677
- payload=EngineInitializationProgress(
1678
- phase=InitializationPhase.LIBRARIES,
1679
- item_name=library_name,
1680
- status=InitializationStatus.COMPLETE,
1681
- current=current_library_index,
1682
- total=total_libraries,
1683
- )
2290
+ # Success case - narrow type and get library_name from result
2291
+ if isinstance(load_result, RegisterLibraryFromFileResultSuccess):
2292
+ library_name = load_result.library_name
2293
+
2294
+ # Emit success event
2295
+ GriptapeNodes.EventManager().put_event(
2296
+ AppEvent(
2297
+ payload=EngineInitializationProgress(
2298
+ phase=InitializationPhase.LIBRARIES,
2299
+ item_name=library_name,
2300
+ status=InitializationStatus.COMPLETE,
2301
+ current=current_library_index,
2302
+ total=total_libraries,
1684
2303
  )
1685
2304
  )
1686
- else:
1687
- # Handle config-based library - register it directly using the file path
1688
- register_request = RegisterLibraryFromFileRequest(
1689
- file_path=library_result.file_path, load_as_default_library=False
1690
- )
1691
- register_result = await self.register_library_from_file_request(register_request)
1692
- if isinstance(register_result, RegisterLibraryFromFileResultFailure):
1693
- # Registration failed - the failure info is already recorded in _library_file_path_to_info
1694
- # by register_library_from_file_request, so we just log it here for visibility
1695
- logger.warning("Failed to register library from %s", library_result.file_path)
1696
- # Emit failure event
1697
- error_message = (
1698
- register_result.result_details.result_details[0].message
1699
- if isinstance(register_result.result_details, ResultDetails)
1700
- else register_result.result_details
1701
- )
1702
- GriptapeNodes.EventManager().put_event(
1703
- AppEvent(
1704
- payload=EngineInitializationProgress(
1705
- phase=InitializationPhase.LIBRARIES,
1706
- item_name=library_name,
1707
- status=InitializationStatus.FAILED,
1708
- current=current_library_index,
1709
- total=total_libraries,
1710
- error=error_message,
1711
- )
1712
- )
1713
- )
1714
- else:
1715
- # Emit success event
1716
- GriptapeNodes.EventManager().put_event(
1717
- AppEvent(
1718
- payload=EngineInitializationProgress(
1719
- phase=InitializationPhase.LIBRARIES,
1720
- item_name=library_name,
1721
- status=InitializationStatus.COMPLETE,
1722
- current=current_library_index,
1723
- total=total_libraries,
1724
- )
1725
- )
1726
- )
2305
+ )
1727
2306
 
1728
- # Print 'em all pretty
1729
- self.print_library_load_status()
2307
+ # Print 'em all pretty
2308
+ self.print_library_load_status()
1730
2309
 
1731
- # Remove any missing libraries AFTER we've printed them for the user.
1732
- user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1733
- self._remove_missing_libraries_from_config(config_category=user_libraries_section)
1734
- finally:
1735
- self._libraries_loading_complete.set()
2310
+ # Remove any missing libraries AFTER we've printed them for the user.
2311
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
2312
+ self._remove_missing_libraries_from_config(config_category=user_libraries_section)
2313
+
2314
+ # Mark libraries loading as complete
2315
+ self._libraries_loading_complete.set()
1736
2316
 
1737
2317
  async def _ensure_libraries_from_config(self) -> None:
1738
2318
  """Ensure libraries from git URLs specified in config are downloaded.
@@ -1923,86 +2503,6 @@ class LibraryManager:
1923
2503
  )
1924
2504
  console.print(message)
1925
2505
 
1926
- async def _load_libraries_from_provenance_system(self) -> None:
1927
- """Load libraries using the new provenance-based system with FSM.
1928
-
1929
- This method converts libraries_to_register entries into LibraryProvenanceLocalFile
1930
- objects and processes them through the LibraryDirectory and LibraryLifecycleFSM systems.
1931
- """
1932
- # Get config manager
1933
- config_mgr = GriptapeNodes.ConfigManager()
1934
-
1935
- # Get the current libraries_to_register list
1936
- user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1937
- libraries_to_register: list[str] = config_mgr.get_config_value(user_libraries_section)
1938
-
1939
- # Filter out empty or whitespace-only entries
1940
- original_count = len(libraries_to_register) if libraries_to_register else 0
1941
- libraries_to_register = [path for path in (libraries_to_register or []) if path and path.strip()]
1942
- filtered_count = original_count - len(libraries_to_register)
1943
- if filtered_count > 0:
1944
- logger.warning("Filtered out %d empty library path entries from configuration", filtered_count)
1945
-
1946
- if not libraries_to_register:
1947
- logger.info("No libraries to register from config")
1948
- return
1949
-
1950
- # Convert string paths to LibraryProvenanceLocalFile objects
1951
- for library_path in libraries_to_register:
1952
- # Skip non-JSON files for now (requirement specifiers will need different handling)
1953
- if not library_path.endswith(".json"):
1954
- logger.debug("Skipping non-JSON library path: %s", library_path)
1955
- continue
1956
-
1957
- # Create provenance object
1958
- provenance = LibraryProvenanceLocalFile(file_path=library_path)
1959
-
1960
- # Add to directory as user candidate (defaults to active=True)
1961
- # This automatically creates FSM and runs evaluation
1962
- await self._library_directory.add_user_candidate(provenance)
1963
-
1964
- logger.debug("Added library provenance: %s", provenance.get_display_name())
1965
-
1966
- # Get all candidates for evaluation
1967
- all_candidates = self._library_directory.get_all_candidates()
1968
-
1969
- logger.info("Evaluated %d library candidates through FSM lifecycle", len(all_candidates))
1970
-
1971
- # Report on conflicts found
1972
- self._report_library_name_conflicts()
1973
-
1974
- # Get candidates that are ready for installation
1975
- installable_candidates = self._library_directory.get_installable_candidates()
1976
-
1977
- # Log any skipped libraries
1978
- active_candidates = self._library_directory.get_active_candidates()
1979
- for candidate in active_candidates:
1980
- if candidate not in installable_candidates:
1981
- blockers = self._library_directory.get_installation_blockers(candidate.provenance)
1982
- if blockers:
1983
- blocker_messages = [blocker.message for blocker in blockers]
1984
- combined_message = "; ".join(blocker_messages)
1985
- logger.info("Skipping library '%s' - %s", candidate.provenance.get_display_name(), combined_message)
1986
-
1987
- logger.info("Installing and loading %d installable library candidates", len(installable_candidates))
1988
-
1989
- # Process installable candidates through installation and loading
1990
- for candidate in installable_candidates:
1991
- if await self._library_directory.install_library(candidate.provenance):
1992
- await self._library_directory.load_library(candidate.provenance)
1993
-
1994
- def _report_library_name_conflicts(self) -> None:
1995
- """Report on library name conflicts found during evaluation."""
1996
- conflicting_names = self._library_directory.get_all_conflicting_library_names()
1997
- for library_name in conflicting_names:
1998
- conflicting_provenances = self._library_directory.get_conflicting_provenances(library_name)
1999
- logger.warning(
2000
- "Library name conflict detected for '%s' across %d libraries: %s",
2001
- library_name,
2002
- len(conflicting_provenances),
2003
- [p.get_display_name() for p in conflicting_provenances],
2004
- )
2005
-
2006
2506
  def _load_advanced_library_module(
2007
2507
  self,
2008
2508
  library_data: LibrarySchema,
@@ -2069,54 +2569,42 @@ class LibraryManager:
2069
2569
 
2070
2570
  return advanced_library_instance
2071
2571
 
2072
- def _attempt_load_nodes_from_library( # noqa: PLR0913, PLR0912, PLR0915, C901
2572
+ def _attempt_load_nodes_from_library( # noqa: PLR0912, PLR0915, C901
2073
2573
  self,
2074
2574
  library_data: LibrarySchema,
2075
2575
  library: Library,
2076
2576
  base_dir: Path,
2077
- library_file_path: str,
2078
- library_version: str | None,
2079
- problems: list[LibraryProblem],
2080
- ) -> LibraryManager.LibraryInfo:
2081
- any_nodes_loaded_successfully = False
2577
+ library_info: LibraryInfo,
2578
+ ) -> None:
2579
+ """Load nodes from library and update library_info in place.
2082
2580
 
2083
- # Check for version-based compatibility issues and add to problems
2084
- version_issues = GriptapeNodes.VersionCompatibilityManager().check_library_version_compatibility(library_data)
2085
- has_disqualifying_issues = False
2086
- for issue in version_issues:
2087
- problems.append(issue.problem)
2088
- if issue.severity == LibraryStatus.UNUSABLE:
2089
- has_disqualifying_issues = True
2581
+ Args:
2582
+ library_data: Library schema with node definitions
2583
+ library: Library instance to register nodes with
2584
+ base_dir: Base directory for resolving relative paths
2585
+ library_info: LibraryInfo to update with problems and fitness
2586
+ """
2587
+ any_nodes_loaded_successfully = False
2090
2588
 
2091
2589
  # Check if library is in old XDG location
2092
2590
  old_xdg_libraries_path = xdg_data_home() / "griptape_nodes" / "libraries"
2093
- library_path_obj = Path(library_file_path)
2591
+ library_path_obj = Path(library_info.library_path)
2094
2592
  try:
2095
2593
  # Check if the library path is relative to the old XDG location
2096
2594
  if library_path_obj.is_relative_to(old_xdg_libraries_path):
2097
- problems.append(OldXdgLocationWarningProblem(old_path=str(library_path_obj)))
2595
+ library_info.problems.append(OldXdgLocationWarningProblem(old_path=str(library_path_obj)))
2098
2596
  logger.warning(
2099
2597
  "Library '%s' is located in old XDG data directory: %s. "
2100
2598
  "Starting with version 0.65.0, libraries are managed in your workspace directory. "
2101
2599
  "To migrate: run 'gtn init' (CLI) or go to App Settings and click 'Re-run Setup Wizard' (desktop app).",
2102
2600
  library_data.name,
2103
- library_file_path,
2601
+ library_info.library_path,
2104
2602
  )
2105
2603
  except ValueError:
2106
2604
  # is_relative_to() raises ValueError if paths are on different drives
2107
2605
  # In this case, library is definitely not in the old XDG location
2108
2606
  pass
2109
2607
 
2110
- # Early exit if any version issues are disqualifying
2111
- if has_disqualifying_issues:
2112
- return LibraryManager.LibraryInfo(
2113
- library_path=library_file_path,
2114
- library_name=library_data.name,
2115
- library_version=library_version,
2116
- status=LibraryStatus.UNUSABLE,
2117
- problems=problems,
2118
- )
2119
-
2120
2608
  # Call the before_library_nodes_loaded callback if available
2121
2609
  advanced_library = library.get_advanced_library()
2122
2610
  if advanced_library:
@@ -2125,7 +2613,7 @@ class LibraryManager:
2125
2613
  details = f"Successfully called before_library_nodes_loaded callback for library '{library_data.name}'"
2126
2614
  logger.debug(details)
2127
2615
  except Exception as err:
2128
- problems.append(BeforeLibraryCallbackProblem(error_message=str(err)))
2616
+ library_info.problems.append(BeforeLibraryCallbackProblem(error_message=str(err)))
2129
2617
  details = (
2130
2618
  f"Failed to call before_library_nodes_loaded callback for library '{library_data.name}': {err}"
2131
2619
  )
@@ -2143,7 +2631,7 @@ class LibraryManager:
2143
2631
  node_class = self._load_class_from_file(node_file_path, node_definition.class_name, library_data.name)
2144
2632
  except ImportError as err:
2145
2633
  root_cause = self._get_root_cause_from_exception(err)
2146
- problems.append(
2634
+ library_info.problems.append(
2147
2635
  NodeModuleImportProblem(
2148
2636
  class_name=node_definition.class_name,
2149
2637
  file_path=str(node_file_path),
@@ -2155,14 +2643,14 @@ class LibraryManager:
2155
2643
  logger.error(details)
2156
2644
  continue # SKIP IT
2157
2645
  except AttributeError:
2158
- problems.append(
2646
+ library_info.problems.append(
2159
2647
  NodeClassNotFoundProblem(class_name=node_definition.class_name, file_path=str(node_file_path))
2160
2648
  )
2161
2649
  details = f"Attempted to load node '{node_definition.class_name}' from '{node_file_path}'. Failed because class not found in module"
2162
2650
  logger.error(details)
2163
2651
  continue # SKIP IT
2164
2652
  except TypeError:
2165
- problems.append(
2653
+ library_info.problems.append(
2166
2654
  NodeClassNotBaseNodeProblem(class_name=node_definition.class_name, file_path=str(node_file_path))
2167
2655
  )
2168
2656
  details = f"Attempted to load node '{node_definition.class_name}' from '{node_file_path}'. Failed because class doesn't inherit from BaseNode"
@@ -2172,7 +2660,7 @@ class LibraryManager:
2172
2660
  # Register the node type with the library
2173
2661
  library_problem = library.register_new_node_type(node_class, metadata=node_definition.metadata)
2174
2662
  if library_problem is not None:
2175
- problems.append(library_problem)
2663
+ library_info.problems.append(library_problem)
2176
2664
 
2177
2665
  # If we got here, at least one node came in.
2178
2666
  any_nodes_loaded_successfully = True
@@ -2184,49 +2672,46 @@ class LibraryManager:
2184
2672
  details = f"Successfully called after_library_nodes_loaded callback for library '{library_data.name}'"
2185
2673
  logger.debug(details)
2186
2674
  except Exception as err:
2187
- problems.append(AfterLibraryCallbackProblem(error_message=str(err)))
2675
+ library_info.problems.append(AfterLibraryCallbackProblem(error_message=str(err)))
2188
2676
  details = f"Failed to call after_library_nodes_loaded callback for library '{library_data.name}': {err}"
2189
2677
  logger.error(details)
2190
2678
 
2191
- # Create a LibraryInfo object based on load successes and problem count.
2679
+ # Update library_info fitness based on load successes and problem count
2192
2680
  if not any_nodes_loaded_successfully:
2193
- status = LibraryStatus.UNUSABLE
2194
- elif problems:
2681
+ library_info.fitness = LibraryManager.LibraryFitness.UNUSABLE
2682
+ elif library_info.problems:
2195
2683
  # Success, but errors.
2196
- status = LibraryStatus.FLAWED
2684
+ library_info.fitness = LibraryManager.LibraryFitness.FLAWED
2197
2685
  else:
2198
2686
  # Flawless victory.
2199
- status = LibraryStatus.GOOD
2200
-
2201
- # Create a LibraryInfo object based on load successes and problem count.
2202
- return LibraryManager.LibraryInfo(
2203
- library_path=library_file_path,
2204
- library_name=library_data.name,
2205
- library_version=library_version,
2206
- status=status,
2207
- problems=problems,
2208
- )
2687
+ library_info.fitness = LibraryManager.LibraryFitness.GOOD
2209
2688
 
2210
- async def _attempt_generate_sandbox_library_from_schema(
2211
- self, library_schema: LibrarySchema, sandbox_directory: str
2689
+ # Update lifecycle state to LOADED
2690
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.LOADED
2691
+
2692
+ async def _attempt_generate_sandbox_library_from_schema( # noqa: C901
2693
+ self,
2694
+ library_schema: LibrarySchema,
2695
+ sandbox_directory: str,
2696
+ library_info: LibraryInfo,
2212
2697
  ) -> None:
2213
2698
  """Generate sandbox library using an existing schema, loading actual node classes."""
2214
2699
  sandbox_library_dir = Path(sandbox_directory)
2215
- sandbox_library_dir_as_posix = sandbox_library_dir.as_posix()
2216
2700
 
2217
2701
  problems = []
2218
2702
 
2219
2703
  # Get the file paths from the schema's node definitions to load actual classes
2220
2704
  actual_node_definitions = []
2221
2705
  for node_def in library_schema.nodes:
2222
- candidate_path = Path(node_def.file_path)
2706
+ # Resolve relative path from schema against sandbox directory
2707
+ candidate_path = sandbox_library_dir / node_def.file_path
2223
2708
  try:
2224
2709
  module = self._load_module_from_file(candidate_path, LibraryManager.SANDBOX_LIBRARY_NAME)
2225
2710
  except Exception as err:
2226
2711
  root_cause = self._get_root_cause_from_exception(err)
2227
2712
  problems.append(
2228
2713
  NodeModuleImportProblem(
2229
- class_name=node_def.class_name,
2714
+ class_name=f"<Sandbox node in '{node_def.file_path}'>",
2230
2715
  file_path=str(candidate_path),
2231
2716
  error_message=str(err),
2232
2717
  root_cause=str(root_cause),
@@ -2247,29 +2732,38 @@ class LibraryManager:
2247
2732
  details = f"Found node '{class_name}' in sandbox library '{candidate_path}'."
2248
2733
  logger.debug(details)
2249
2734
 
2250
- # Get metadata from class attributes if they exist, otherwise use defaults
2251
- node_icon = getattr(obj, "ICON", "square-dashed")
2252
- node_description = getattr(
2253
- obj, "DESCRIPTION", f"'{class_name}' (loaded from the {LibraryManager.SANDBOX_LIBRARY_NAME})."
2254
- )
2255
- node_color = getattr(obj, "COLOR", None)
2256
-
2257
- node_metadata = NodeMetadata(
2258
- category="Griptape Nodes Sandbox",
2259
- description=node_description,
2260
- display_name=class_name,
2261
- icon=node_icon,
2262
- color=node_color,
2263
- )
2735
+ # Look for existing node definition to preserve user-edited metadata
2736
+ existing_node = None
2737
+ for existing_node_def in library_schema.nodes:
2738
+ if (
2739
+ existing_node_def.file_path == str(candidate_path)
2740
+ and existing_node_def.class_name == class_name
2741
+ ):
2742
+ existing_node = existing_node_def
2743
+ break
2744
+
2745
+ if existing_node:
2746
+ # PRESERVE existing metadata - user may have customized it
2747
+ node_metadata = existing_node.metadata
2748
+ logger.debug("Preserving existing metadata for node '%s'", class_name)
2749
+ else:
2750
+ # NEW node - create default metadata
2751
+ node_metadata = NodeMetadata(
2752
+ category=self.SANDBOX_CATEGORY_NAME,
2753
+ description=f"'{class_name}' (loaded from the {LibraryManager.SANDBOX_LIBRARY_NAME}).",
2754
+ display_name=class_name,
2755
+ )
2756
+ logger.debug("Creating new metadata for node '%s'", class_name)
2757
+
2264
2758
  node_definition = NodeDefinition(
2265
2759
  class_name=class_name,
2266
- file_path=str(candidate_path),
2760
+ file_path=node_def.file_path, # Keep original relative path from schema
2267
2761
  metadata=node_metadata,
2268
2762
  )
2269
2763
  actual_node_definitions.append(node_definition)
2270
2764
 
2271
2765
  if not actual_node_definitions:
2272
- logger.info("No nodes found in sandbox library '%s'. Skipping.", sandbox_library_dir)
2766
+ logger.debug("No nodes found in sandbox library '%s'. Skipping.", sandbox_library_dir)
2273
2767
  return
2274
2768
 
2275
2769
  # Use the existing schema but replace nodes with actual discovered ones
@@ -2281,6 +2775,16 @@ class LibraryManager:
2281
2775
  nodes=actual_node_definitions,
2282
2776
  )
2283
2777
 
2778
+ # Save the schema with real class names back to disk
2779
+ json_path = sandbox_library_dir / LibraryManager.LIBRARY_CONFIG_FILENAME
2780
+ write_succeeded = self._write_library_schema_to_json(library_data, json_path)
2781
+ if write_succeeded:
2782
+ logger.debug(
2783
+ "Saved sandbox library schema with %d discovered nodes to '%s'",
2784
+ len(actual_node_definitions),
2785
+ json_path,
2786
+ )
2787
+
2284
2788
  # Register the library.
2285
2789
  # Create or get the library
2286
2790
  try:
@@ -2291,30 +2795,27 @@ class LibraryManager:
2291
2795
  )
2292
2796
 
2293
2797
  except KeyError as err:
2294
- # Library already exists
2295
- self._library_file_path_to_info[sandbox_library_dir_as_posix] = LibraryManager.LibraryInfo(
2296
- library_path=sandbox_library_dir_as_posix,
2297
- library_name=library_data.name,
2298
- library_version=library_data.metadata.library_version,
2299
- status=LibraryStatus.UNUSABLE,
2300
- problems=[DuplicateLibraryProblem()],
2301
- )
2798
+ # Library already exists - update existing library_info
2799
+ library_info.lifecycle_state = LibraryManager.LibraryLifecycleState.FAILURE
2800
+ library_info.fitness = LibraryManager.LibraryFitness.UNUSABLE
2801
+ library_info.problems.append(DuplicateLibraryProblem())
2302
2802
 
2303
2803
  details = f"Attempted to load Library JSON file from '{sandbox_library_dir}'. Failed because a Library '{library_data.name}' already exists. Error: {err}."
2304
2804
  logger.error(details)
2305
2805
  return
2306
2806
 
2307
- # Load nodes into the library
2308
- library_load_results = await asyncio.to_thread(
2807
+ # Add any problems encountered during node discovery to library_info
2808
+ library_info.problems.extend(problems)
2809
+
2810
+ # Load nodes into the library (modifies library_info in place)
2811
+ # Note: library_info is passed as parameter from lifecycle handler
2812
+ await asyncio.to_thread(
2309
2813
  self._attempt_load_nodes_from_library,
2310
2814
  library_data=library_data,
2311
2815
  library=library,
2312
2816
  base_dir=sandbox_library_dir,
2313
- library_file_path=sandbox_library_dir_as_posix,
2314
- library_version=library_data.metadata.library_version,
2315
- problems=problems,
2817
+ library_info=library_info,
2316
2818
  )
2317
- self._library_file_path_to_info[sandbox_library_dir_as_posix] = library_load_results
2318
2819
 
2319
2820
  def _find_files_in_dir(self, directory: Path, extension: str) -> list[Path]:
2320
2821
  """Find all files with given extension in directory, excluding common non-source directories."""
@@ -2330,6 +2831,29 @@ class LibraryManager:
2330
2831
  ret_val.append(file_path)
2331
2832
  return ret_val
2332
2833
 
2834
+ def _write_library_schema_to_json(self, library_schema: LibrarySchema, json_path: Path) -> bool:
2835
+ """Write library schema to JSON file using WriteFileRequest.
2836
+
2837
+ Args:
2838
+ library_schema: The library schema to write
2839
+ json_path: Path where the JSON file should be written
2840
+
2841
+ Returns:
2842
+ True if write succeeded, False otherwise
2843
+ """
2844
+ write_request = WriteFileRequest(
2845
+ file_path=str(json_path),
2846
+ content=library_schema.model_dump_json(indent=2),
2847
+ encoding="utf-8",
2848
+ )
2849
+ write_result = GriptapeNodes.handle_request(write_request)
2850
+
2851
+ if write_result.failed():
2852
+ logger.error("Failed to write library schema to '%s': %s", json_path, write_result.result_details)
2853
+ return False
2854
+
2855
+ return True
2856
+
2333
2857
  def _remove_missing_libraries_from_config(self, config_category: str) -> None:
2334
2858
  # Now remove all libraries that were missing from the user's config.
2335
2859
  config_mgr = GriptapeNodes.ConfigManager()
@@ -2337,7 +2861,7 @@ class LibraryManager:
2337
2861
 
2338
2862
  paths_to_remove = set()
2339
2863
  for library_path, library_info in self._library_file_path_to_info.items():
2340
- if library_info.status == LibraryStatus.MISSING:
2864
+ if library_info.fitness == LibraryManager.LibraryFitness.MISSING:
2341
2865
  # Remove this file path from the config.
2342
2866
  paths_to_remove.add(library_path.lower())
2343
2867
 
@@ -2461,24 +2985,236 @@ class LibraryManager:
2461
2985
  )
2462
2986
  return ReloadAllLibrariesResultSuccess(result_details=ResultDetails(message=details, level=logging.INFO))
2463
2987
 
2464
- async def load_libraries_request(self, request: LoadLibrariesRequest) -> ResultPayload: # noqa: ARG002
2465
- # Check if there are any new libraries in config that haven't been loaded yet
2466
- discovered_libraries = {str(path) for path in self._discover_library_files()}
2467
- loaded_libraries = set(self._library_file_path_to_info.keys())
2468
- unloaded_libraries = discovered_libraries - loaded_libraries
2469
-
2470
- if not unloaded_libraries:
2471
- details = "All configured libraries are already loaded, no action needed."
2472
- return LoadLibrariesResultSuccess(result_details=ResultDetails(message=details, level=logging.INFO))
2988
+ def discover_libraries_request(
2989
+ self,
2990
+ request: DiscoverLibrariesRequest,
2991
+ ) -> DiscoverLibrariesResultSuccess | DiscoverLibrariesResultFailure:
2992
+ """Discover libraries from config and track them in discovered state.
2473
2993
 
2994
+ This is the event handler for DiscoverLibrariesRequest.
2995
+ Scans configured library paths and creates LibraryInfo entries in DISCOVERED state.
2996
+ """
2474
2997
  try:
2475
- await self.load_all_libraries_from_config()
2476
- details = "Successfully loaded all libraries from configuration."
2477
- return LoadLibrariesResultSuccess(result_details=ResultDetails(message=details, level=logging.INFO))
2998
+ config_library_paths = set(self._discover_library_files())
2478
2999
  except Exception as e:
2479
- details = f"Failed to load libraries from configuration: {e}"
2480
- logger.error(details)
2481
- return LoadLibrariesResultFailure(result_details=details)
3000
+ logger.exception("Failed to discover library files")
3001
+ return DiscoverLibrariesResultFailure(
3002
+ result_details=f"Failed to discover library files: {e}",
3003
+ )
3004
+
3005
+ discovered_libraries = set()
3006
+
3007
+ # Process sandbox library first if requested
3008
+ if request.include_sandbox:
3009
+ sandbox_library_dir = self._get_sandbox_directory()
3010
+ if sandbox_library_dir:
3011
+ # Generate/update the sandbox library JSON file
3012
+ metadata_result = self.scan_sandbox_directory_request(
3013
+ ScanSandboxDirectoryRequest(directory_path=str(sandbox_library_dir))
3014
+ )
3015
+
3016
+ # If generation succeeded, write JSON and add the sandbox library
3017
+ if isinstance(metadata_result, ScanSandboxDirectoryResultSuccess):
3018
+ sandbox_json_path = sandbox_library_dir / LibraryManager.LIBRARY_CONFIG_FILENAME
3019
+ sandbox_json_path_str = str(sandbox_json_path)
3020
+
3021
+ # Write the schema to JSON so it exists for lifecycle phases
3022
+ write_succeeded = self._write_library_schema_to_json(
3023
+ metadata_result.library_schema, sandbox_json_path
3024
+ )
3025
+ if write_succeeded:
3026
+ logger.debug(
3027
+ "Wrote sandbox library schema with %d nodes to '%s' during discovery",
3028
+ len(metadata_result.library_schema.nodes),
3029
+ sandbox_json_path,
3030
+ )
3031
+ # Continue anyway if write failed - lifecycle will fail gracefully
3032
+
3033
+ # Add to discovered libraries with is_sandbox=True
3034
+ discovered_libraries.add(DiscoveredLibrary(path=sandbox_json_path, is_sandbox=True))
3035
+
3036
+ # Create minimal LibraryInfo entry in discovered state if not already tracked
3037
+ if sandbox_json_path_str not in self._library_file_path_to_info:
3038
+ self._library_file_path_to_info[sandbox_json_path_str] = LibraryManager.LibraryInfo(
3039
+ lifecycle_state=LibraryManager.LibraryLifecycleState.DISCOVERED,
3040
+ fitness=LibraryManager.LibraryFitness.NOT_EVALUATED,
3041
+ library_path=sandbox_json_path_str,
3042
+ is_sandbox=True,
3043
+ library_name=None,
3044
+ library_version=None,
3045
+ )
3046
+
3047
+ # Add all regular libraries from config
3048
+ for file_path in config_library_paths:
3049
+ file_path_str = str(file_path)
3050
+
3051
+ # Add to discovered libraries with is_sandbox=False
3052
+ discovered_libraries.add(DiscoveredLibrary(path=file_path, is_sandbox=False))
3053
+
3054
+ # Skip if already tracked
3055
+ if file_path_str in self._library_file_path_to_info:
3056
+ continue
3057
+
3058
+ # Create minimal LibraryInfo entry in discovered state
3059
+ self._library_file_path_to_info[file_path_str] = LibraryManager.LibraryInfo(
3060
+ lifecycle_state=LibraryManager.LibraryLifecycleState.DISCOVERED,
3061
+ fitness=LibraryManager.LibraryFitness.NOT_EVALUATED,
3062
+ library_path=file_path_str,
3063
+ is_sandbox=False,
3064
+ library_name=None,
3065
+ library_version=None,
3066
+ )
3067
+
3068
+ # Success path at the end
3069
+ return DiscoverLibrariesResultSuccess(
3070
+ result_details=f"Discovered {len(discovered_libraries)} libraries",
3071
+ libraries_discovered=discovered_libraries,
3072
+ )
3073
+
3074
+ def evaluate_library_fitness_request(
3075
+ self, request: EvaluateLibraryFitnessRequest
3076
+ ) -> EvaluateLibraryFitnessResultSuccess | EvaluateLibraryFitnessResultFailure:
3077
+ """Evaluate library fitness using version compatibility checks.
3078
+
3079
+ Extracts version checking logic from _attempt_load_nodes_from_library.
3080
+ Checks engine version compatibility without loading Python modules.
3081
+ """
3082
+ schema = request.schema
3083
+ problems: list[LibraryProblem] = []
3084
+
3085
+ # Check for version-based compatibility issues
3086
+ version_issues = GriptapeNodes.VersionCompatibilityManager().check_library_version_compatibility(schema)
3087
+ has_disqualifying_issues = False
3088
+
3089
+ for issue in version_issues:
3090
+ problems.append(issue.problem)
3091
+ if issue.severity == LibraryManager.LibraryFitness.UNUSABLE:
3092
+ has_disqualifying_issues = True
3093
+
3094
+ if has_disqualifying_issues:
3095
+ return EvaluateLibraryFitnessResultFailure(
3096
+ result_details=f"Library '{schema.name}' has version compatibility issues",
3097
+ fitness=LibraryManager.LibraryFitness.UNUSABLE,
3098
+ problems=problems,
3099
+ )
3100
+
3101
+ # Determine fitness based on whether we have any non-disqualifying issues
3102
+ fitness = LibraryManager.LibraryFitness.FLAWED if problems else LibraryManager.LibraryFitness.GOOD
3103
+
3104
+ return EvaluateLibraryFitnessResultSuccess(
3105
+ result_details=f"Library '{schema.name}' is compatible",
3106
+ fitness=fitness,
3107
+ problems=problems,
3108
+ )
3109
+
3110
+ async def load_libraries_request(self, request: LoadLibrariesRequest) -> ResultPayload: # noqa: ARG002, C901
3111
+ """Load all libraries from configuration (backward compatibility wrapper).
3112
+
3113
+ This is the legacy entry point that loads all configured libraries.
3114
+ New code should use LoadLibraryRequest to load specific libraries instead.
3115
+ """
3116
+ # First, discover all available libraries
3117
+ discover_result = self.discover_libraries_request(DiscoverLibrariesRequest())
3118
+ if isinstance(discover_result, DiscoverLibrariesResultFailure):
3119
+ return LoadLibrariesResultFailure(result_details=f"Discovery failed: {discover_result.result_details}")
3120
+
3121
+ # Build list of library paths to load, preserving is_sandbox flag
3122
+ libraries_to_load = []
3123
+ for discovered_lib in discover_result.libraries_discovered:
3124
+ lib_path = str(discovered_lib.path)
3125
+ lib_info = self._library_file_path_to_info.get(lib_path)
3126
+
3127
+ # Update is_sandbox if library_info exists and discovery says it's sandbox
3128
+ if lib_info and discovered_lib.is_sandbox:
3129
+ lib_info.is_sandbox = True
3130
+
3131
+ if lib_info:
3132
+ libraries_to_load.append(lib_path)
3133
+
3134
+ if not libraries_to_load:
3135
+ details = "No libraries found in configuration."
3136
+ return LoadLibrariesResultSuccess(result_details=ResultDetails(message=details, level=logging.INFO))
3137
+
3138
+ # Load each discovered library by path
3139
+ loaded_count = 0
3140
+ failed_libraries = []
3141
+ total_libraries = len(libraries_to_load)
3142
+
3143
+ for current_library_index, lib_path in enumerate(libraries_to_load, start=1):
3144
+ load_result = await self.register_library_from_file_request(
3145
+ RegisterLibraryFromFileRequest(
3146
+ file_path=lib_path,
3147
+ load_as_default_library=False,
3148
+ )
3149
+ )
3150
+
3151
+ # Get library_name from result for progress events (use path as fallback for failures)
3152
+ if isinstance(load_result, RegisterLibraryFromFileResultSuccess):
3153
+ library_name = load_result.library_name
3154
+ else:
3155
+ library_name = lib_path
3156
+
3157
+ # Emit loading event
3158
+ GriptapeNodes.EventManager().put_event(
3159
+ AppEvent(
3160
+ payload=EngineInitializationProgress(
3161
+ phase=InitializationPhase.LIBRARIES,
3162
+ item_name=library_name,
3163
+ status=InitializationStatus.LOADING,
3164
+ current=current_library_index,
3165
+ total=total_libraries,
3166
+ )
3167
+ )
3168
+ )
3169
+
3170
+ if isinstance(load_result, RegisterLibraryFromFileResultSuccess):
3171
+ loaded_count += 1
3172
+
3173
+ # Emit success event
3174
+ GriptapeNodes.EventManager().put_event(
3175
+ AppEvent(
3176
+ payload=EngineInitializationProgress(
3177
+ phase=InitializationPhase.LIBRARIES,
3178
+ item_name=library_name,
3179
+ status=InitializationStatus.COMPLETE,
3180
+ current=current_library_index,
3181
+ total=total_libraries,
3182
+ )
3183
+ )
3184
+ )
3185
+ else:
3186
+ failed_libraries.append(library_name)
3187
+ logger.warning("Failed to load library '%s': %s", library_name, load_result.result_details)
3188
+
3189
+ # Emit failure event
3190
+ error_message = (
3191
+ load_result.result_details.result_details[0].message
3192
+ if isinstance(load_result.result_details, ResultDetails)
3193
+ else str(load_result.result_details)
3194
+ )
3195
+ GriptapeNodes.EventManager().put_event(
3196
+ AppEvent(
3197
+ payload=EngineInitializationProgress(
3198
+ phase=InitializationPhase.LIBRARIES,
3199
+ item_name=library_name,
3200
+ status=InitializationStatus.FAILED,
3201
+ current=current_library_index,
3202
+ total=total_libraries,
3203
+ error=error_message,
3204
+ )
3205
+ )
3206
+ )
3207
+
3208
+ if loaded_count == 0 and len(failed_libraries) > 0:
3209
+ return LoadLibrariesResultFailure(
3210
+ result_details=f"Failed to load any libraries. Failed: {', '.join(failed_libraries)}"
3211
+ )
3212
+
3213
+ message = f"Loaded {loaded_count} libraries"
3214
+ if failed_libraries:
3215
+ message += f". Failed: {', '.join(failed_libraries)}"
3216
+
3217
+ return LoadLibrariesResultSuccess(result_details=ResultDetails(message=message, level=logging.INFO))
2482
3218
 
2483
3219
  def _discover_library_files(self) -> list[Path]:
2484
3220
  """Discover library JSON files from config and workspace recursively.
@@ -2720,6 +3456,18 @@ class LibraryManager:
2720
3456
  # Use the found file path for reloading
2721
3457
  actual_library_file_path = str(actual_library_file)
2722
3458
 
3459
+ # Create LibraryInfo for tracking this library reload
3460
+ lib_info = LibraryManager.LibraryInfo(
3461
+ lifecycle_state=LibraryManager.LibraryLifecycleState.DISCOVERED,
3462
+ library_path=actual_library_file_path,
3463
+ is_sandbox=False,
3464
+ library_name=library_name,
3465
+ fitness=LibraryManager.LibraryFitness.NOT_EVALUATED,
3466
+ problems=[],
3467
+ )
3468
+ # Store lib_info in dict so register handler can find it
3469
+ self._library_file_path_to_info[actual_library_file_path] = lib_info
3470
+
2723
3471
  # Reload the library from file
2724
3472
  reload_result = await GriptapeNodes.ahandle_request(
2725
3473
  RegisterLibraryFromFileRequest(file_path=actual_library_file_path)
@@ -2954,6 +3702,18 @@ class LibraryManager:
2954
3702
 
2955
3703
  # Automatically register the downloaded library (unless disabled for startup downloads)
2956
3704
  if request.auto_register:
3705
+ # Create LibraryInfo for tracking this downloaded library
3706
+ lib_info = LibraryManager.LibraryInfo(
3707
+ lifecycle_state=LibraryManager.LibraryLifecycleState.DISCOVERED,
3708
+ library_path=str(library_json_path),
3709
+ is_sandbox=False,
3710
+ library_name=library_name,
3711
+ fitness=LibraryManager.LibraryFitness.NOT_EVALUATED,
3712
+ problems=[],
3713
+ )
3714
+ # Store lib_info in dict so register handler can find it
3715
+ self._library_file_path_to_info[str(library_json_path)] = lib_info
3716
+
2957
3717
  register_request = RegisterLibraryFromFileRequest(file_path=str(library_json_path))
2958
3718
  register_result = await GriptapeNodes.ahandle_request(register_request)
2959
3719
  if not register_result.succeeded():