diff --git a/GenHub/GenHub.Core/Constants/CasDefaults.cs b/GenHub/GenHub.Core/Constants/CasDefaults.cs
index f45fe18ec..161adef5f 100644
--- a/GenHub/GenHub.Core/Constants/CasDefaults.cs
+++ b/GenHub/GenHub.Core/Constants/CasDefaults.cs
@@ -24,4 +24,10 @@ public static class CasDefaults
/// Default garbage collection grace period in days.
///
public const int GcGracePeriodDays = 7;
-}
\ No newline at end of file
+
+ ///
+ /// Explains why destructive CAS garbage collection is currently unavailable.
+ ///
+ public const string GarbageCollectionDisabledMessage =
+ "CAS garbage collection is disabled until complete reachability tracking is proven safe. No CAS blobs were deleted.";
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs
index cef33ede9..b47995d1f 100644
--- a/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs
+++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs
@@ -37,13 +37,13 @@ Task> UntrackManifestsAsync(
CancellationToken cancellationToken = default);
///
- /// Runs garbage collection immediately.
- /// Should only be called AFTER all untrack operations are complete.
+ /// Requests garbage collection.
+ /// Destructive collection is currently disabled until reachability tracking is proven complete.
///
/// Whether to force collection regardless of grace period.
/// Optional timeout to wait for the GC lock. Defaults to 5 seconds if not specified.
/// Cancellation token.
- /// Result with GC statistics.
+ /// A disabled result with zero deletion statistics.
Task> RunGarbageCollectionAsync(
bool force = false,
TimeSpan? lockTimeout = null,
diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs
index fb227afd0..ff57d180c 100644
--- a/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs
@@ -53,11 +53,12 @@ public interface ICasService
Task> OpenContentStreamAsync(string hash, CancellationToken cancellationToken = default);
///
- /// Runs garbage collection to remove unreferenced content.
+ /// Requests garbage collection of unreferenced content.
+ /// Destructive collection is currently disabled until reachability tracking is proven complete.
///
/// If true, ignores the grace period and deletes all unreferenced objects immediately.
/// Cancellation token.
- /// The result of the garbage collection operation.
+ /// A clear disabled result; no CAS blobs are deleted.
Task RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default);
///
diff --git a/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs b/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs
index af670ea7c..d5ccecf86 100644
--- a/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs
+++ b/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
namespace GenHub.Core.Models.Content;
@@ -36,4 +37,9 @@ public record ContentRemovalResult
/// Gets the duration of the operation.
///
public TimeSpan Duration { get; init; }
+
+ ///
+ /// Gets non-fatal warnings reported during removal.
+ ///
+ public IReadOnlyList Warnings { get; init; } = [];
}
diff --git a/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs b/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs
index fc69d7e08..a90ee7c13 100644
--- a/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs
+++ b/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs
@@ -1,3 +1,5 @@
+using GenHub.Core.Constants;
+
namespace GenHub.Core.Models.Results.CAS;
///
@@ -5,6 +7,20 @@ namespace GenHub.Core.Models.Results.CAS;
///
public class CasGarbageCollectionResult : ResultBase
{
+ ///
+ /// Creates the fail-closed result returned while destructive garbage collection is disabled.
+ ///
+ /// A disabled result that reports zero deletion.
+ public static CasGarbageCollectionResult CreateDisabled()
+ {
+ return new CasGarbageCollectionResult(
+ false,
+ CasDefaults.GarbageCollectionDisabledMessage)
+ {
+ Disabled = true,
+ };
+ }
+
///
/// Initializes a new instance of the class.
///
@@ -39,6 +55,11 @@ public CasGarbageCollectionResult(bool success, string? error = null, TimeSpan e
/// Gets or sets the number of objects that were referenced and kept.
public int ObjectsReferenced { get; set; }
+ ///
+ /// Gets a value indicating whether destructive garbage collection is disabled.
+ ///
+ public bool Disabled { get; init; }
+
/// Gets the percentage of storage freed.
public double PercentageFreed
{
@@ -63,4 +84,4 @@ public double PercentageFreed
return (double)ObjectsDeleted / ObjectsScanned * 100;
}
}
-}
\ No newline at end of file
+}
diff --git a/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs b/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs
index be239849d..3df51e7bf 100644
--- a/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs
+++ b/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs
@@ -42,6 +42,11 @@ public record GarbageCollectionStats
///
public bool InProgress { get; init; }
+ ///
+ /// Gets a value indicating whether collection was blocked because destructive GC is disabled.
+ ///
+ public bool Disabled { get; init; }
+
///
/// Gets a static instance representing a skipped GC operation.
///
@@ -69,4 +74,19 @@ public record GarbageCollectionStats
Skipped = true,
InProgress = true,
};
+
+ ///
+ /// Gets a static instance representing fail-closed disabled garbage collection.
+ ///
+ public static GarbageCollectionStats DisabledResult { get; } = new()
+ {
+ ObjectsScanned = 0,
+ ObjectsReferenced = 0,
+ ObjectsDeleted = 0,
+ BytesFreed = 0,
+ Duration = TimeSpan.Zero,
+ Skipped = true,
+ InProgress = false,
+ Disabled = true,
+ };
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs
index 876514824..036231d01 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs
@@ -12,6 +12,7 @@
using GenHub.Core.Models.GameProfile;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
+using GenHub.Core.Models.Results.CAS;
using GenHub.Core.Models.Storage;
using GenHub.Core.Models.Workspace;
using GenHub.Features.AppUpdate.Interfaces;
@@ -338,7 +339,7 @@ public void Constructor_HandlesUserSettingsServiceException()
///
/// A representing the asynchronous test operation.
[Fact]
- public async Task DeleteCasStorageCommand_CallsService()
+ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabled()
{
// Arrange
// Setup stats to return valid data so update method works
@@ -350,6 +351,9 @@ public async Task DeleteCasStorageCommand_CallsService()
.ReturnsAsync(OperationResult>.CreateSuccess([]));
_mockProfileManager.Setup(x => x.GetAllProfilesAsync(It.IsAny()))
.ReturnsAsync(ProfileOperationResult>.CreateSuccess([]));
+ _mockCasService
+ .Setup(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CasGarbageCollectionResult.CreateDisabled());
var viewModel = new SettingsViewModel(
_mockConfigService.Object,
@@ -370,6 +374,20 @@ public async Task DeleteCasStorageCommand_CallsService()
// Assert
_mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mockNotificationService.Verify(
+ service => service.ShowInfo(
+ "CAS Cleanup Disabled",
+ CasDefaults.GarbageCollectionDisabledMessage,
+ (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds,
+ It.IsAny()),
+ Times.Once);
+ _mockNotificationService.Verify(
+ service => service.ShowSuccess(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
}
///
@@ -400,4 +418,4 @@ public async Task UninstallGenHubCommand_CallsService()
// Assert
_mockUpdateManager.Verify(x => x.Uninstall(), Times.Once);
}
-}
\ No newline at end of file
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs
new file mode 100644
index 000000000..fa271e226
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs
@@ -0,0 +1,128 @@
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Results;
+using GenHub.Core.Models.Storage;
+using GenHub.Features.Content.Services.Reconciliation;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace GenHub.Tests.Core.Features.Reconciliation;
+
+///
+/// Verifies that reconciliation reports disabled garbage collection without failing
+/// otherwise-successful manifest operations.
+///
+public class ContentReconciliationOrchestratorGarbageCollectionTests
+{
+ ///
+ /// Verifies that replacement results expose the disabled-GC warning.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning()
+ {
+ var reconciliationService = new Mock();
+ reconciliationService
+ .Setup(service => service.OrchestrateBulkUpdateAsync(
+ It.IsAny>(),
+ false,
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(
+ ReconciliationResult.Empty));
+
+ var auditEntries = new List();
+ var orchestrator = CreateOrchestrator(
+ reconciliationService,
+ CreateDisabledLifecycleManager(),
+ auditEntries);
+ var request = new ContentReplacementRequest
+ {
+ ManifestMapping = new Dictionary
+ {
+ ["1.0.publisher.mod.old"] = "2.0.publisher.mod.new",
+ },
+ RemoveOldManifests = false,
+ };
+
+ var result = await orchestrator.ExecuteContentReplacementAsync(request);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
+ Assert.Equal(0, result.Data.CasObjectsCollected);
+ Assert.Equal(0, result.Data.BytesFreed);
+ var auditEntry = Assert.Single(auditEntries);
+ Assert.NotNull(auditEntry.Metadata);
+ Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]);
+ }
+
+ ///
+ /// Verifies that removal results expose the disabled-GC warning.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning()
+ {
+ var reconciliationService = new Mock();
+ var lifecycleManager = CreateDisabledLifecycleManager();
+ lifecycleManager
+ .Setup(manager => manager.UntrackManifestsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(
+ new BulkUntrackResult(0, 0, [])));
+
+ var auditEntries = new List();
+ var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager, auditEntries);
+
+ var result = await orchestrator.ExecuteContentRemovalAsync([]);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
+ Assert.Equal(0, result.Data.CasObjectsCollected);
+ Assert.Equal(0, result.Data.BytesFreed);
+ var auditEntry = Assert.Single(auditEntries);
+ Assert.NotNull(auditEntry.Metadata);
+ Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]);
+ }
+
+ private static Mock CreateDisabledLifecycleManager()
+ {
+ var lifecycleManager = new Mock();
+ lifecycleManager
+ .Setup(manager => manager.RunGarbageCollectionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure(
+ CasDefaults.GarbageCollectionDisabledMessage,
+ GarbageCollectionStats.DisabledResult,
+ TimeSpan.Zero));
+ return lifecycleManager;
+ }
+
+ private static ContentReconciliationOrchestrator CreateOrchestrator(
+ Mock reconciliationService,
+ Mock lifecycleManager,
+ List? auditEntries = null)
+ {
+ var auditLog = new Mock();
+ auditLog
+ .Setup(log => log.LogOperationAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((entry, _) => auditEntries?.Add(entry))
+ .Returns(Task.CompletedTask);
+
+ return new ContentReconciliationOrchestrator(
+ reconciliationService.Object,
+ Mock.Of(),
+ lifecycleManager.Object,
+ auditLog.Object,
+ NullLogger.Instance);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs
new file mode 100644
index 000000000..43d6393ba
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs
@@ -0,0 +1,77 @@
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Models.Results.CAS;
+using GenHub.Core.Models.Storage;
+using GenHub.Features.Storage.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace GenHub.Tests.Core.Features.Storage;
+
+///
+/// Verifies that every programmatic CAS garbage-collection layer fails closed.
+///
+public class CasGarbageCollectionDisabledTests
+{
+ ///
+ /// Verifies that direct service calls cannot scan or delete CAS blobs, including forced calls.
+ ///
+ /// Whether the caller requests forced collection.
+ /// A representing the asynchronous test.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CasService_RunGarbageCollectionAsync_IsDisabledWithoutStorageAccess(bool force)
+ {
+ var storage = new Mock(MockBehavior.Strict);
+ var referenceTracker = new Mock(MockBehavior.Strict);
+ var service = new CasService(
+ storage.Object,
+ referenceTracker.Object,
+ NullLogger.Instance,
+ Options.Create(new CasConfiguration()),
+ Mock.Of(),
+ Mock.Of());
+
+ var result = await service.RunGarbageCollectionAsync(force);
+
+ Assert.False(result.Success);
+ Assert.True(result.Disabled);
+ Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError);
+ Assert.Equal(0, result.ObjectsDeleted);
+ Assert.Equal(0, result.BytesFreed);
+ storage.VerifyNoOtherCalls();
+ referenceTracker.VerifyNoOtherCalls();
+ }
+
+ ///
+ /// Verifies that the lifecycle API preserves the disabled result and reports no deletion.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task CasLifecycleManager_RunGarbageCollectionAsync_ReportsDisabled()
+ {
+ var casService = new Mock();
+ casService
+ .Setup(service => service.RunGarbageCollectionAsync(true, It.IsAny()))
+ .ReturnsAsync(CasGarbageCollectionResult.CreateDisabled());
+
+ using var lifecycleManager = new CasLifecycleManager(
+ Mock.Of(),
+ casService.Object,
+ Mock.Of(),
+ Options.Create(new CasConfiguration()),
+ NullLogger.Instance);
+
+ var result = await lifecycleManager.RunGarbageCollectionAsync(force: true);
+
+ Assert.False(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.True(result.Data.Disabled);
+ Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError);
+ Assert.Equal(0, result.Data.ObjectsDeleted);
+ Assert.Equal(0, result.Data.BytesFreed);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs
index 7a89cff8a..4334e3448 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs
@@ -1,3 +1,4 @@
+using GenHub.Core.Constants;
using GenHub.Core.Models.Results.CAS;
namespace GenHub.Tests.Core.Features.Storage;
@@ -143,4 +144,19 @@ public void Properties_CanBeSetCorrectly()
Assert.Equal(40, result.ObjectsReferenced);
Assert.Equal(20.0, result.PercentageFreed);
}
-}
\ No newline at end of file
+
+ ///
+ /// Verifies that the disabled factory returns a clear fail-closed result.
+ ///
+ [Fact]
+ public void CreateDisabled_ReturnsClearDisabledResult()
+ {
+ var result = CasGarbageCollectionResult.CreateDisabled();
+
+ Assert.False(result.Success);
+ Assert.True(result.Disabled);
+ Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError);
+ Assert.Equal(0, result.ObjectsDeleted);
+ Assert.Equal(0, result.BytesFreed);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
index 47a1c6140..ad7b8659a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
@@ -256,4 +256,28 @@ public async Task OrchestrateBulkUpdateAsync_WhenManifestResolutionFails_ShouldS
It.IsAny()),
Times.Never);
}
-}
\ No newline at end of file
+
+ ///
+ /// Verifies that scheduled garbage collection reports the fail-closed disabled result.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailure()
+ {
+ _casServiceMock
+ .Setup(service => service.RunGarbageCollectionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure(
+ GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage,
+ GarbageCollectionStats.DisabledResult,
+ TimeSpan.Zero));
+
+ var result = await _service.ScheduleGarbageCollectionAsync(force: true);
+
+ result.Success.Should().BeFalse();
+ result.FirstError.Should().Be(
+ GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage);
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
index 3fccea615..bd4247679 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
@@ -354,9 +354,14 @@ public Task ScheduleGarbageCollectionAsync(
try
{
var gcResult = await casLifecycleManager.RunGarbageCollectionAsync(force, lockTimeout: null, cancellationToken);
- return gcResult.Success
- ? OperationResult.CreateSuccess()
- : OperationResult.CreateFailure(gcResult.FirstError ?? "GC failed");
+ if (gcResult.Success)
+ {
+ return OperationResult.CreateSuccess();
+ }
+
+ var error = gcResult.FirstError ?? "GC failed";
+ logger.LogWarning("Scheduled garbage collection did not run: {Error}", error);
+ return OperationResult.CreateFailure(error);
}
catch (OperationCanceledException)
{
diff --git a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs
index c290c9a74..9b19da684 100644
--- a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs
+++ b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs
@@ -155,6 +155,12 @@ public async Task> ExecuteContentRepla
casObjectsCollected = gcResult.Data.ObjectsDeleted;
bytesFreed = gcResult.Data.BytesFreed;
}
+ else
+ {
+ var warning = gcResult.FirstError ?? "Garbage collection did not run";
+ warnings.Add(warning);
+ logger.LogWarning("[Orchestrator:{OpId}] {Warning}", operationId, warning);
+ }
}
}
@@ -171,6 +177,19 @@ public async Task> ExecuteContentRepla
Warnings = warnings,
};
+ var auditMetadata = new Dictionary
+ {
+ ["profilesUpdated"] = profilesUpdated.ToString(),
+ ["workspacesInvalidated"] = workspacesInvalidated.ToString(),
+ ["manifestsRemoved"] = manifestsRemoved.ToString(),
+ ["casObjectsCollected"] = casObjectsCollected.ToString(),
+ ["bytesFreed"] = bytesFreed.ToString(),
+ };
+ if (warnings.Count > 0)
+ {
+ auditMetadata["warnings"] = string.Join("; ", warnings);
+ }
+
await auditLog.LogOperationAsync(
new ReconciliationAuditEntry
{
@@ -183,14 +202,7 @@ await auditLog.LogOperationAsync(
Success = !criticalFailureOccurred,
ErrorMessage = criticalFailureOccurred ? string.Join("; ", warnings) : null,
Duration = stopwatch.Elapsed,
- Metadata = new Dictionary
- {
- ["profilesUpdated"] = profilesUpdated.ToString(),
- ["workspacesInvalidated"] = workspacesInvalidated.ToString(),
- ["manifestsRemoved"] = manifestsRemoved.ToString(),
- ["casObjectsCollected"] = casObjectsCollected.ToString(),
- ["bytesFreed"] = bytesFreed.ToString(),
- },
+ Metadata = auditMetadata,
},
cancellationToken);
@@ -267,6 +279,7 @@ public async Task> ExecuteContentRemovalAs
var stopwatch = Stopwatch.StartNew();
var operationId = Guid.NewGuid().ToString("N")[..ReconciliationConstants.OperationIdLength];
var ids = manifestIds.ToList();
+ var warnings = new List();
logger.LogInformation(
"[Orchestrator:{OpId}] Starting content removal for {Count} manifests",
@@ -366,6 +379,12 @@ public async Task> ExecuteContentRemovalAs
casObjectsCollected = gcResult.Data.ObjectsDeleted;
bytesFreed = gcResult.Data.BytesFreed;
}
+ else
+ {
+ var warning = gcResult.FirstError ?? "Garbage collection did not run";
+ warnings.Add(warning);
+ logger.LogWarning("[Orchestrator:{OpId}] {Warning}", operationId, warning);
+ }
}
stopwatch.Stop();
@@ -378,8 +397,22 @@ public async Task> ExecuteContentRemovalAs
CasObjectsCollected = casObjectsCollected,
BytesFreed = bytesFreed,
Duration = stopwatch.Elapsed,
+ Warnings = warnings,
};
+ var auditMetadata = new Dictionary
+ {
+ ["profilesUpdated"] = profilesUpdated.ToString(),
+ ["workspacesInvalidated"] = invalidatedWorkspacesCount.ToString(),
+ ["manifestsRemoved"] = manifestsRemoved.ToString(),
+ ["casObjectsCollected"] = casObjectsCollected.ToString(),
+ ["bytesFreed"] = bytesFreed.ToString(),
+ };
+ if (warnings.Count > 0)
+ {
+ auditMetadata["warnings"] = string.Join("; ", warnings);
+ }
+
await auditLog.LogOperationAsync(
new ReconciliationAuditEntry
{
@@ -389,14 +422,7 @@ await auditLog.LogOperationAsync(
AffectedManifestIds = ids,
Success = !criticalFailureOccurred,
ErrorMessage = criticalFailureOccurred ? "One or more manifests failed to untrack or be removed from the pool." : null,
- Metadata = new Dictionary
- {
- ["profilesUpdated"] = profilesUpdated.ToString(),
- ["workspacesInvalidated"] = invalidatedWorkspacesCount.ToString(),
- ["manifestsRemoved"] = manifestsRemoved.ToString(),
- ["casObjectsCollected"] = casObjectsCollected.ToString(),
- ["bytesFreed"] = bytesFreed.ToString(),
- },
+ Metadata = auditMetadata,
Duration = stopwatch.Elapsed,
},
cancellationToken);
diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
index b43c9cbe8..74d315300 100644
--- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
+++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
@@ -1088,7 +1088,10 @@ private async Task DeleteAllData()
_installationService.InvalidateCache();
await UpdateDangerZoneDataAsync();
- _notificationService.ShowSuccess("Data Deleted", "All application data has been deleted successfully.", 5000);
+ _notificationService.ShowSuccess(
+ "Data Deleted",
+ $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}",
+ 5000);
}
[RelayCommand]
@@ -1112,15 +1115,22 @@ private async Task DeleteCasStorage()
{
_logger.LogWarning("Deleting CAS storage (forced)");
var result = await _casService.RunGarbageCollectionAsync(force: true, CancellationToken.None);
- if (result.ObjectsDeleted == 0)
+ if (result.Disabled)
+ {
+ _notificationService.ShowInfo(
+ "CAS Cleanup Disabled",
+ result.FirstError ?? CasDefaults.GarbageCollectionDisabledMessage,
+ (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds);
+ }
+ else if (result.ObjectsDeleted == 0)
{
if (result.ObjectsReferenced > 0)
{
- _notificationService.ShowInfo("CAS Clean", "All items in CAS are currently in use and cannot be deleted.", TimeIntervals.NotificationHideDelay.Milliseconds);
+ _notificationService.ShowInfo("CAS Clean", "All items in CAS are currently in use and cannot be deleted.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds);
}
else
{
- _notificationService.ShowInfo("CAS Empty", "CAS storage is already empty.", TimeIntervals.NotificationHideDelay.Milliseconds);
+ _notificationService.ShowInfo("CAS Empty", "CAS storage is already empty.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds);
}
}
else
@@ -1152,7 +1162,7 @@ private async Task DeleteManifests()
await _manifestPool.RemoveManifestAsync(manifest.Id);
}
- _notificationService.ShowSuccess("Manifests Deleted", $"Deleted {count} manifest(s) successfully.", TimeIntervals.NotificationHideDelay.Milliseconds);
+ _notificationService.ShowSuccess("Manifests Deleted", $"Deleted {count} manifest(s) successfully.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds);
}
await UpdateDangerZoneDataAsync();
diff --git a/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs b/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs
index da864569f..0f561ca41 100644
--- a/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs
+++ b/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs
@@ -181,9 +181,20 @@ public async Task> RunGarbageCollectionA
ObjectsDeleted = gcResult.ObjectsDeleted,
BytesFreed = gcResult.BytesFreed,
Duration = stopwatch.Elapsed,
- Skipped = false,
+ Skipped = gcResult.Disabled,
+ Disabled = gcResult.Disabled,
};
+ if (!gcResult.Success)
+ {
+ var error = gcResult.FirstError ?? "CAS garbage collection failed";
+ logger.LogWarning("Garbage collection did not run: {Error}", error);
+ return OperationResult.CreateFailure(
+ error,
+ stats,
+ stopwatch.Elapsed);
+ }
+
logger.LogInformation(
"GC completed: scanned={Scanned}, referenced={Referenced}, deleted={Deleted}, freed={Bytes} bytes",
stats.ObjectsScanned,
diff --git a/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs b/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs
index 757650f51..951fc80b8 100644
--- a/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs
+++ b/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs
@@ -76,7 +76,13 @@ private async Task RunMaintenanceTasksAsync(CancellationToken cancellationToken)
// Run garbage collection
var gcResult = await casService.RunGarbageCollectionAsync(cancellationToken: cancellationToken);
- if (gcResult.Success)
+ if (gcResult.Disabled)
+ {
+ logger.LogWarning(
+ "CAS garbage collection did not run: {Reason}",
+ gcResult.FirstError);
+ }
+ else if (gcResult.Success)
{
logger.LogInformation("CAS garbage collection completed: {ObjectsDeleted} objects deleted, {BytesFreed:N0} bytes freed in {Elapsed}", gcResult.ObjectsDeleted, gcResult.BytesFreed, gcResult.Elapsed);
}
diff --git a/GenHub/GenHub/Features/Storage/Services/CasService.cs b/GenHub/GenHub/Features/Storage/Services/CasService.cs
index 1b961e74a..09df977eb 100644
--- a/GenHub/GenHub/Features/Storage/Services/CasService.cs
+++ b/GenHub/GenHub/Features/Storage/Services/CasService.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Storage;
using GenHub.Core.Models.Enums;
@@ -25,8 +26,6 @@ public class CasService(
IStreamHashProvider streamHashProvider,
ICasPoolManager? poolManager = null) : ICasService
{
- private readonly CasConfiguration _config = config.Value;
-
///
public async Task> StoreContentAsync(string sourcePath, string? expectedHash = null, CancellationToken cancellationToken = default)
{
@@ -257,68 +256,21 @@ public async Task> OpenContentStreamAsync(string hash, C
}
///
- public async Task RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default)
+ public Task RunGarbageCollectionAsync(
+ bool force = false,
+ CancellationToken cancellationToken = default)
{
- var startTime = DateTime.UtcNow;
- var result = new CasGarbageCollectionResult(true, (string?)null);
-
- try
- {
- logger.LogInformation("Starting CAS garbage collection (force={Force})", force);
-
- // Get all objects in CAS
- var allHashes = await storage.GetAllObjectHashesAsync(cancellationToken);
- result.ObjectsScanned = allHashes.Length;
-
- // Get all referenced hashes
- var referencedHashes = await referenceTracker.GetAllReferencedHashesAsync(cancellationToken);
- result.ObjectsReferenced = referencedHashes.Count;
-
- // Find unreferenced objects
- var unreferencedHashes = System.Linq.Enumerable.Except(allHashes, referencedHashes);
-
- // Use configurable grace period unless forced
- var gracePeriod = force ? TimeSpan.Zero : _config.GcGracePeriod;
- long bytesFreed = 0;
- int objectsDeleted = 0;
-
- foreach (var hash in unreferencedHashes)
- {
- try
- {
- var creationTime = await storage.GetObjectCreationTimeAsync(hash, cancellationToken);
- if (force || creationTime == null || DateTime.UtcNow - creationTime.Value > gracePeriod)
- {
- // Get size before deletion
- var objectPath = storage.GetObjectPath(hash);
- if (File.Exists(objectPath))
- {
- var fileInfo = new FileInfo(objectPath);
- bytesFreed += fileInfo.Length;
- }
-
- await storage.DeleteObjectAsync(hash, cancellationToken);
- objectsDeleted++;
- }
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "Failed to delete unreferenced object {Hash}", hash);
- }
- }
-
- result.ObjectsDeleted = objectsDeleted;
- result.BytesFreed = bytesFreed;
-
- logger.LogInformation("CAS garbage collection completed: {ObjectsDeleted} objects deleted, {BytesFreed} bytes freed", objectsDeleted, bytesFreed);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "CAS garbage collection failed");
- result = new CasGarbageCollectionResult(false, ex.Message, DateTime.UtcNow - startTime);
- }
-
- return result;
+ _ = referenceTracker;
+ _ = config;
+
+ // Re-enable only after references cover every persisted manifest, workspace, user-data
+ // link, and CAS pool; startup can rebuild and audit that graph; and crash/concurrency
+ // tests prove that no live blob can be classified as unreachable.
+ logger.LogWarning(
+ "{Message} Requested force={Force}",
+ CasDefaults.GarbageCollectionDisabledMessage,
+ force);
+ return Task.FromResult(CasGarbageCollectionResult.CreateDisabled());
}
///