griptape-nodes 0.60.4__py3-none-any.whl → 0.62.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +0 -1
  2. griptape_nodes/common/macro_parser/__init__.py +16 -1
  3. griptape_nodes/common/macro_parser/core.py +19 -7
  4. griptape_nodes/common/macro_parser/exceptions.py +99 -0
  5. griptape_nodes/common/macro_parser/formats.py +13 -4
  6. griptape_nodes/common/macro_parser/matching.py +5 -2
  7. griptape_nodes/common/macro_parser/parsing.py +48 -8
  8. griptape_nodes/common/macro_parser/resolution.py +23 -5
  9. griptape_nodes/common/project_templates/__init__.py +49 -0
  10. griptape_nodes/common/project_templates/default_project_template.py +87 -0
  11. griptape_nodes/common/project_templates/defaults/README.md +36 -0
  12. griptape_nodes/common/project_templates/directory.py +67 -0
  13. griptape_nodes/common/project_templates/loader.py +342 -0
  14. griptape_nodes/common/project_templates/project.py +252 -0
  15. griptape_nodes/common/project_templates/situation.py +143 -0
  16. griptape_nodes/common/project_templates/validation.py +140 -0
  17. griptape_nodes/exe_types/core_types.py +36 -3
  18. griptape_nodes/exe_types/node_types.py +4 -2
  19. griptape_nodes/exe_types/param_components/progress_bar_component.py +57 -0
  20. griptape_nodes/exe_types/param_types/parameter_audio.py +243 -0
  21. griptape_nodes/exe_types/param_types/parameter_image.py +243 -0
  22. griptape_nodes/exe_types/param_types/parameter_three_d.py +215 -0
  23. griptape_nodes/exe_types/param_types/parameter_video.py +243 -0
  24. griptape_nodes/node_library/workflow_registry.py +1 -1
  25. griptape_nodes/retained_mode/events/execution_events.py +41 -0
  26. griptape_nodes/retained_mode/events/node_events.py +90 -1
  27. griptape_nodes/retained_mode/events/os_events.py +108 -0
  28. griptape_nodes/retained_mode/events/parameter_events.py +1 -1
  29. griptape_nodes/retained_mode/events/project_events.py +528 -0
  30. griptape_nodes/retained_mode/events/workflow_events.py +19 -1
  31. griptape_nodes/retained_mode/griptape_nodes.py +9 -1
  32. griptape_nodes/retained_mode/managers/agent_manager.py +18 -24
  33. griptape_nodes/retained_mode/managers/event_manager.py +6 -9
  34. griptape_nodes/retained_mode/managers/flow_manager.py +63 -0
  35. griptape_nodes/retained_mode/managers/library_manager.py +55 -42
  36. griptape_nodes/retained_mode/managers/mcp_manager.py +14 -6
  37. griptape_nodes/retained_mode/managers/node_manager.py +232 -0
  38. griptape_nodes/retained_mode/managers/os_manager.py +399 -6
  39. griptape_nodes/retained_mode/managers/project_manager.py +1067 -0
  40. griptape_nodes/retained_mode/managers/settings.py +6 -0
  41. griptape_nodes/retained_mode/managers/sync_manager.py +4 -1
  42. griptape_nodes/retained_mode/managers/workflow_manager.py +8 -79
  43. griptape_nodes/traits/button.py +19 -0
  44. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/METADATA +5 -3
  45. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/RECORD +47 -32
  46. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/WHEEL +1 -1
  47. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/entry_points.txt +0 -0
@@ -15,6 +15,12 @@ from rich.console import Console
15
15
  from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
16
16
  from griptape_nodes.retained_mode.events.base_events import ResultDetails, ResultPayload
17
17
  from griptape_nodes.retained_mode.events.os_events import (
18
+ CopyFileRequest,
19
+ CopyFileResultFailure,
20
+ CopyFileResultSuccess,
21
+ CopyTreeRequest,
22
+ CopyTreeResultFailure,
23
+ CopyTreeResultSuccess,
18
24
  CreateFileRequest,
19
25
  CreateFileResultFailure,
20
26
  CreateFileResultSuccess,
@@ -73,6 +79,24 @@ class FileContentResult(NamedTuple):
73
79
  file_size: int
74
80
 
75
81
 
82
+ @dataclass
83
+ class CopyTreeValidationResult:
84
+ """Result from validating copy tree paths."""
85
+
86
+ source_normalized: str
87
+ dest_normalized: str
88
+ source_path: Path
89
+ destination_path: Path
90
+
91
+
92
+ @dataclass
93
+ class CopyTreeStats:
94
+ """Statistics from copying a directory tree."""
95
+
96
+ files_copied: int
97
+ total_bytes_copied: int
98
+
99
+
76
100
  class OSManager:
77
101
  """A class to manage OS-level scenarios.
78
102
 
@@ -105,6 +129,14 @@ class OSManager:
105
129
  request_type=WriteFileRequest, callback=self.on_write_file_request
106
130
  )
107
131
 
132
+ event_manager.assign_manager_to_request_type(
133
+ request_type=CopyTreeRequest, callback=self.on_copy_tree_request
134
+ )
135
+
136
+ event_manager.assign_manager_to_request_type(
137
+ request_type=CopyFileRequest, callback=self.on_copy_file_request
138
+ )
139
+
108
140
  # Register for app initialization event to setup system resources
109
141
  event_manager.add_listener_to_app_event(AppInitializationComplete, self.on_app_initialization_complete)
110
142
 
@@ -123,7 +155,49 @@ class OSManager:
123
155
  """
124
156
  # Expand environment variables first, then tilde
125
157
  expanded_vars = os.path.expandvars(path_str)
126
- return Path(expanded_vars).expanduser().resolve()
158
+ return self.resolve_path_safely(Path(expanded_vars).expanduser())
159
+
160
+ def resolve_path_safely(self, path: Path) -> Path:
161
+ """Resolve a path consistently across platforms.
162
+
163
+ Unlike Path.resolve() which behaves differently on Windows vs Unix
164
+ for non-existent paths, this method provides consistent behavior:
165
+ - Converts relative paths to absolute (using CWD as base)
166
+ - Normalizes path separators and removes . and ..
167
+ - Does NOT resolve symlinks if path doesn't exist
168
+ - Does NOT change path based on CWD for absolute paths
169
+
170
+ Use this instead of .resolve() when:
171
+ - Path might not exist (file creation, validation, user input)
172
+ - You need consistent cross-platform comparison
173
+ - You're about to create the file/directory
174
+
175
+ Use .resolve() when:
176
+ - Path definitely exists and you need symlink resolution
177
+ - You're checking actual file locations
178
+
179
+ Args:
180
+ path: Path to resolve (relative or absolute, existing or not)
181
+
182
+ Returns:
183
+ Absolute, normalized Path object
184
+
185
+ Examples:
186
+ # Relative path
187
+ resolve_path_safely(Path("relative/file.txt"))
188
+ → Path("/current/dir/relative/file.txt")
189
+
190
+ # Absolute non-existent path (Windows safe)
191
+ resolve_path_safely(Path("/abs/nonexistent/path"))
192
+ → Path("/abs/nonexistent/path") # NOT resolved relative to CWD
193
+ """
194
+ # Convert to absolute if relative
195
+ if not path.is_absolute():
196
+ path = Path.cwd() / path
197
+
198
+ # Normalize (remove . and .., collapse slashes) without resolving symlinks
199
+ # This works consistently even for non-existent paths on Windows
200
+ return Path(os.path.normpath(path))
127
201
 
128
202
  def _resolve_file_path(self, path_str: str, *, workspace_only: bool = False) -> Path:
129
203
  """Resolve a file path, handling absolute, relative, and tilde paths.
@@ -140,7 +214,7 @@ class OSManager:
140
214
  # Expand tilde and environment variables for absolute paths or paths starting with ~
141
215
  return self._expand_path(path_str)
142
216
  # Both workspace and system-wide modes resolve relative to current directory
143
- return (self._get_workspace_path() / path_str).resolve()
217
+ return self.resolve_path_safely(self._get_workspace_path() / path_str)
144
218
  except (ValueError, RuntimeError):
145
219
  if workspace_only:
146
220
  msg = f"Path '{path_str}' not found, using workspace directory: {self._get_workspace_path()}"
@@ -161,8 +235,11 @@ class OSManager:
161
235
  workspace = GriptapeNodes.ConfigManager().workspace_path
162
236
 
163
237
  # Ensure both paths are resolved for comparison
238
+ # Both path and workspace should use .resolve() to follow symlinks consistently
239
+ # (e.g., /var -> /private/var on macOS). Even if path doesn't exist yet,
240
+ # .resolve() will resolve parent directories and symlinks in the path.
164
241
  path = path.resolve()
165
- workspace = workspace.resolve()
242
+ workspace = workspace.resolve() # Workspace should always exist
166
243
 
167
244
  msg = f"Validating path: {path} against workspace: {workspace}"
168
245
  logger.debug(msg)
@@ -185,6 +262,9 @@ class OSManager:
185
262
  need the \\?\ prefix to work correctly. This method transparently adds
186
263
  the prefix when needed on Windows.
187
264
 
265
+ Note: This method assumes the path exists or will exist. For non-existent
266
+ paths that need cross-platform normalization, use resolve_path_safely() first.
267
+
188
268
  Args:
189
269
  path: Path object to convert to string
190
270
 
@@ -411,7 +491,7 @@ class OSManager:
411
491
  directory = self._expand_path(request.directory_path)
412
492
  else:
413
493
  # Both workspace and system-wide modes resolve relative to current directory
414
- directory = (self._get_workspace_path() / request.directory_path).resolve()
494
+ directory = self.resolve_path_safely(self._get_workspace_path() / request.directory_path)
415
495
 
416
496
  # Check if directory exists
417
497
  if not directory.exists():
@@ -757,6 +837,30 @@ class OSManager:
757
837
  result_details=f"File written successfully: {file_path}",
758
838
  )
759
839
 
840
+ def _copy_file(self, src_path: Path, dest_path: Path) -> int:
841
+ """Copy a single file from source to destination with platform path normalization.
842
+
843
+ Args:
844
+ src_path: Source file path (Path object)
845
+ dest_path: Destination file path (Path object)
846
+
847
+ Returns:
848
+ Number of bytes copied
849
+
850
+ Raises:
851
+ OSError: If copy operation fails
852
+ PermissionError: If permission denied
853
+ """
854
+ # Normalize both paths for platform (handles Windows long paths)
855
+ src_normalized = self.normalize_path_for_platform(src_path)
856
+ dest_normalized = self.normalize_path_for_platform(dest_path)
857
+
858
+ # Copy file preserving metadata
859
+ shutil.copy2(src_normalized, dest_normalized)
860
+
861
+ # Return size of copied file
862
+ return os.path.getsize(src_normalized) # noqa: PTH202
863
+
760
864
  def _write_file_content(self, normalized_path: str, content: str | bytes, encoding: str, *, append: bool) -> int:
761
865
  """Write content to a file and return bytes written.
762
866
 
@@ -1000,9 +1104,9 @@ class OSManager:
1000
1104
 
1001
1105
  # Resolve path - if absolute, use as-is; if relative, align to workspace
1002
1106
  if is_absolute:
1003
- file_path = Path(full_path_str).resolve()
1107
+ file_path = self.resolve_path_safely(Path(full_path_str))
1004
1108
  else:
1005
- file_path = (self._get_workspace_path() / full_path_str).resolve()
1109
+ file_path = self.resolve_path_safely(self._get_workspace_path() / full_path_str)
1006
1110
 
1007
1111
  # Check if it already exists - warn but treat as success
1008
1112
  if file_path.exists():
@@ -1135,6 +1239,295 @@ class OSManager:
1135
1239
  result_details=ResultDetails(message=details, level=logging.INFO),
1136
1240
  )
1137
1241
 
1242
+ def on_copy_file_request(self, request: CopyFileRequest) -> ResultPayload: # noqa: PLR0911, C901
1243
+ """Handle a request to copy a single file."""
1244
+ # Resolve source path
1245
+ try:
1246
+ source_path = self._resolve_file_path(request.source_path, workspace_only=False)
1247
+ source_normalized = self.normalize_path_for_platform(source_path)
1248
+ except (ValueError, RuntimeError) as e:
1249
+ msg = f"Invalid source path: {e}"
1250
+ logger.error(msg)
1251
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1252
+
1253
+ # Check if source exists
1254
+ if not Path(source_normalized).exists():
1255
+ msg = f"Source file does not exist: {source_path}"
1256
+ logger.error(msg)
1257
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
1258
+
1259
+ # Check if source is a file (not a directory)
1260
+ if not Path(source_normalized).is_file():
1261
+ msg = f"Source path is not a file: {source_path}"
1262
+ logger.error(msg)
1263
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1264
+
1265
+ # Resolve destination path
1266
+ try:
1267
+ destination_path = self._resolve_file_path(request.destination_path, workspace_only=False)
1268
+ dest_normalized = self.normalize_path_for_platform(destination_path)
1269
+ except (ValueError, RuntimeError) as e:
1270
+ msg = f"Invalid destination path: {e}"
1271
+ logger.error(msg)
1272
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1273
+
1274
+ # Check if destination already exists (unless overwrite is True)
1275
+ if Path(dest_normalized).exists() and not request.overwrite:
1276
+ msg = f"Destination file already exists: {destination_path}"
1277
+ logger.error(msg)
1278
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1279
+
1280
+ # Create parent directory if it doesn't exist
1281
+ dest_parent = Path(dest_normalized).parent
1282
+ if not dest_parent.exists():
1283
+ try:
1284
+ dest_parent.mkdir(parents=True)
1285
+ except PermissionError as e:
1286
+ msg = f"Permission denied creating parent directory {dest_parent}: {e}"
1287
+ logger.error(msg)
1288
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
1289
+ except OSError as e:
1290
+ msg = f"I/O error creating parent directory {dest_parent}: {e}"
1291
+ logger.error(msg)
1292
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
1293
+
1294
+ # Copy the file
1295
+ try:
1296
+ bytes_copied = self._copy_file(source_path, destination_path)
1297
+ except PermissionError as e:
1298
+ msg = f"Permission denied copying {source_path} to {destination_path}: {e}"
1299
+ logger.error(msg)
1300
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
1301
+ except OSError as e:
1302
+ if "No space left" in str(e) or "Disk full" in str(e):
1303
+ msg = f"Disk full copying {source_path} to {destination_path}: {e}"
1304
+ logger.error(msg)
1305
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
1306
+
1307
+ msg = f"I/O error copying {source_path} to {destination_path}: {e}"
1308
+ logger.error(msg)
1309
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
1310
+ except Exception as e:
1311
+ msg = f"Unexpected error copying {source_path} to {destination_path}: {type(e).__name__}: {e}"
1312
+ logger.error(msg)
1313
+ return CopyFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
1314
+
1315
+ # SUCCESS PATH
1316
+ return CopyFileResultSuccess(
1317
+ source_path=str(source_path),
1318
+ destination_path=str(destination_path),
1319
+ bytes_copied=bytes_copied,
1320
+ result_details=f"File copied successfully: {source_path} -> {destination_path}",
1321
+ )
1322
+
1323
+ def _validate_copy_tree_paths(
1324
+ self, source_str: str, dest_str: str, *, dirs_exist_ok: bool
1325
+ ) -> CopyTreeValidationResult | CopyTreeResultFailure:
1326
+ """Validate and normalize source and destination paths for copy tree operation.
1327
+
1328
+ Returns:
1329
+ CopyTreeValidationResult on success, CopyTreeResultFailure on validation failure
1330
+ """
1331
+ # Resolve and normalize source path
1332
+ try:
1333
+ source_path = self._resolve_file_path(source_str, workspace_only=False)
1334
+ source_normalized = self.normalize_path_for_platform(source_path)
1335
+ except (ValueError, RuntimeError) as e:
1336
+ msg = f"Invalid source path: {e}"
1337
+ logger.error(msg)
1338
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1339
+
1340
+ # Check if source exists
1341
+ if not Path(source_normalized).exists():
1342
+ msg = f"Source path does not exist: {source_path}"
1343
+ logger.error(msg)
1344
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
1345
+
1346
+ # Check if source is a directory
1347
+ if not Path(source_normalized).is_dir():
1348
+ msg = f"Source path is not a directory: {source_path}"
1349
+ logger.error(msg)
1350
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1351
+
1352
+ # Resolve and normalize destination path
1353
+ try:
1354
+ destination_path = self._resolve_file_path(dest_str, workspace_only=False)
1355
+ dest_normalized = self.normalize_path_for_platform(destination_path)
1356
+ except (ValueError, RuntimeError) as e:
1357
+ msg = f"Invalid destination path: {e}"
1358
+ logger.error(msg)
1359
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1360
+
1361
+ # Check if destination already exists (unless dirs_exist_ok is True)
1362
+ if Path(dest_normalized).exists() and not dirs_exist_ok:
1363
+ msg = f"Destination path already exists: {destination_path}"
1364
+ logger.error(msg)
1365
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1366
+
1367
+ return CopyTreeValidationResult(
1368
+ source_normalized=source_normalized,
1369
+ dest_normalized=dest_normalized,
1370
+ source_path=source_path,
1371
+ destination_path=destination_path,
1372
+ )
1373
+
1374
+ def _copy_directory_tree( # noqa: PLR0912, C901
1375
+ self,
1376
+ source_normalized: str,
1377
+ dest_normalized: str,
1378
+ *,
1379
+ symlinks: bool,
1380
+ ignore_dangling_symlinks: bool,
1381
+ ignore_patterns: list[str] | None = None,
1382
+ ) -> CopyTreeStats:
1383
+ """Copy directory tree from source to destination.
1384
+
1385
+ Args:
1386
+ source_normalized: Normalized source path
1387
+ dest_normalized: Normalized destination path
1388
+ symlinks: If True, copy symbolic links as links
1389
+ ignore_dangling_symlinks: If True, ignore dangling symlinks
1390
+ ignore_patterns: List of glob patterns to ignore (e.g., ["__pycache__", "*.pyc"])
1391
+
1392
+ Returns:
1393
+ CopyTreeStats with files copied and bytes copied
1394
+
1395
+ Raises:
1396
+ OSError: If copy operation fails
1397
+ PermissionError: If permission denied
1398
+ """
1399
+ from fnmatch import fnmatch
1400
+
1401
+ files_copied = 0
1402
+ total_bytes_copied = 0
1403
+ ignore_patterns = ignore_patterns or []
1404
+
1405
+ def should_ignore(name: str) -> bool:
1406
+ """Check if a file/directory name matches any ignore pattern."""
1407
+ return any(fnmatch(name, pattern) for pattern in ignore_patterns)
1408
+
1409
+ # Create destination directory if it doesn't exist
1410
+ dest_path_obj = Path(dest_normalized)
1411
+ if not dest_path_obj.exists():
1412
+ dest_path_obj.mkdir(parents=True)
1413
+
1414
+ # Walk through source directory and copy files/directories
1415
+ for root, dirs, files in os.walk(source_normalized):
1416
+ # Calculate relative path from source
1417
+ root_path = Path(root)
1418
+ source_path_obj = Path(source_normalized)
1419
+ rel_path = root_path.relative_to(source_path_obj)
1420
+
1421
+ # Create corresponding directory in destination
1422
+ if str(rel_path) != ".":
1423
+ dest_dir = dest_path_obj / rel_path
1424
+ else:
1425
+ dest_dir = dest_path_obj
1426
+
1427
+ # Filter out ignored directories and create remaining ones
1428
+ dirs_to_remove = []
1429
+ for dir_name in dirs:
1430
+ if should_ignore(dir_name):
1431
+ dirs_to_remove.append(dir_name)
1432
+ continue
1433
+
1434
+ src_dir = root_path / dir_name
1435
+ dst_dir = dest_dir / dir_name
1436
+
1437
+ # Handle symlinks if requested
1438
+ if src_dir.is_symlink():
1439
+ if symlinks:
1440
+ link_target = src_dir.readlink()
1441
+ dst_dir.symlink_to(link_target)
1442
+ continue
1443
+
1444
+ if not dst_dir.exists():
1445
+ dst_dir.mkdir(parents=True)
1446
+
1447
+ # Remove ignored directories from dirs list to prevent os.walk from descending into them
1448
+ for dir_name in dirs_to_remove:
1449
+ dirs.remove(dir_name)
1450
+
1451
+ # Copy files
1452
+ for file_name in files:
1453
+ # Skip ignored files
1454
+ if should_ignore(file_name):
1455
+ continue
1456
+
1457
+ src_file = root_path / file_name
1458
+ dst_file = dest_dir / file_name
1459
+
1460
+ # Handle symlinks if requested
1461
+ if src_file.is_symlink():
1462
+ if symlinks:
1463
+ try:
1464
+ link_target = src_file.readlink()
1465
+ dst_file.symlink_to(link_target)
1466
+ except OSError:
1467
+ if not ignore_dangling_symlinks:
1468
+ raise
1469
+ continue
1470
+
1471
+ # Copy file
1472
+ bytes_copied = self._copy_file(src_file, dst_file)
1473
+ files_copied += 1
1474
+ total_bytes_copied += bytes_copied
1475
+
1476
+ return CopyTreeStats(files_copied=files_copied, total_bytes_copied=total_bytes_copied)
1477
+
1478
+ def on_copy_tree_request(self, request: CopyTreeRequest) -> ResultPayload:
1479
+ """Handle a request to copy a directory tree."""
1480
+ # Validate paths
1481
+ validation_result = self._validate_copy_tree_paths(
1482
+ request.source_path,
1483
+ request.destination_path,
1484
+ dirs_exist_ok=request.dirs_exist_ok,
1485
+ )
1486
+
1487
+ if isinstance(validation_result, CopyTreeResultFailure):
1488
+ return validation_result
1489
+
1490
+ source_normalized = validation_result.source_normalized
1491
+ dest_normalized = validation_result.dest_normalized
1492
+ source_path = validation_result.source_path
1493
+ destination_path = validation_result.destination_path
1494
+
1495
+ # Copy directory tree
1496
+ try:
1497
+ stats = self._copy_directory_tree(
1498
+ source_normalized,
1499
+ dest_normalized,
1500
+ symlinks=request.symlinks,
1501
+ ignore_dangling_symlinks=request.ignore_dangling_symlinks,
1502
+ ignore_patterns=request.ignore_patterns,
1503
+ )
1504
+ except PermissionError as e:
1505
+ msg = f"Permission denied copying {source_path} to {destination_path}: {e}"
1506
+ logger.error(msg)
1507
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
1508
+ except OSError as e:
1509
+ if "No space left" in str(e) or "Disk full" in str(e):
1510
+ msg = f"Disk full copying {source_path} to {destination_path}: {e}"
1511
+ logger.error(msg)
1512
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
1513
+
1514
+ msg = f"I/O error copying {source_path} to {destination_path}: {e}"
1515
+ logger.error(msg)
1516
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
1517
+ except Exception as e:
1518
+ msg = f"Unexpected error copying {source_path} to {destination_path}: {type(e).__name__}: {e}"
1519
+ logger.error(msg)
1520
+ return CopyTreeResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
1521
+
1522
+ # SUCCESS PATH
1523
+ return CopyTreeResultSuccess(
1524
+ source_path=str(source_path),
1525
+ destination_path=str(destination_path),
1526
+ files_copied=stats.files_copied,
1527
+ total_bytes_copied=stats.total_bytes_copied,
1528
+ result_details=f"Directory tree copied successfully: {source_path} -> {destination_path}",
1529
+ )
1530
+
1138
1531
  def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
1139
1532
  """Handle app initialization complete event by registering system resources."""
1140
1533
  self._register_system_resources()