From 8a128f8832c554998d9544cce36aa855cdc12f82 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 11 May 2026 20:35:47 -0700 Subject: [PATCH] feat: add module detail read model service --- .../ModuleDetailReadModelService.cs | 255 ++++++++++++++++++ .../ModuleDetailReadModelServiceTests.cs | 106 ++++++++ 2 files changed, 361 insertions(+) create mode 100644 src/AppLens.Backend/ModuleDetailReadModelService.cs create mode 100644 tests/AppLens.Backend.Tests/ModuleDetailReadModelServiceTests.cs diff --git a/src/AppLens.Backend/ModuleDetailReadModelService.cs b/src/AppLens.Backend/ModuleDetailReadModelService.cs new file mode 100644 index 0000000..506dbd9 --- /dev/null +++ b/src/AppLens.Backend/ModuleDetailReadModelService.cs @@ -0,0 +1,255 @@ +namespace AppLens.Backend; + +public enum ModuleHealthState +{ + Ok, + Missing, + Unknown +} + +public sealed class ModuleHealthCheckReadModel +{ + public string Name { get; init; } = ""; + public string Kind { get; init; } = ""; + public string Target { get; init; } = ""; + public bool Required { get; init; } = true; + public ModuleHealthState State { get; init; } = ModuleHealthState.Unknown; + public string Detail { get; init; } = ""; + public bool IsBlocking { get; init; } +} + +public sealed class ModuleStorageRootReadModel +{ + public string Name { get; init; } = ""; + public string Path { get; init; } = ""; + public string PrivacyState { get; init; } = ""; + public string Description { get; init; } = ""; + public bool Exists { get; init; } + public string Detail { get; init; } = ""; +} + +public sealed class ModuleActionReadModel +{ + public string Name { get; init; } = ""; + public string Permission { get; init; } = ""; + public bool RequiresApproval { get; init; } = true; + public bool SystemChanging { get; init; } + public string Description { get; init; } = ""; + public bool IsRunnable { get; init; } +} + +public sealed class ModuleLedgerEventReadModel +{ + public string EventId { get; init; } = ""; + public BlackboardEventType EventType { get; init; } + public string CorrelationId { get; init; } = ""; + public DateTimeOffset CreatedAt { get; init; } + public BlackboardDataState DataState { get; init; } + public BlackboardPrivacyState PrivacyState { get; init; } + public string Summary { get; init; } = ""; +} + +public sealed class ModuleDetailReadModel +{ + public string ModuleId { get; init; } = ""; + public string AppId { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string ModuleKind { get; init; } = ""; + public string Version { get; init; } = ""; + public string RiskLevel { get; init; } = ""; + public ModuleAvailability Availability { get; init; } = ModuleAvailability.Unavailable; + public string StatusLabel { get; init; } = ""; + public string Reason { get; init; } = ""; + public string ExpectedSource { get; init; } = ""; + public string NextAction { get; init; } = ""; + public List Capabilities { get; init; } = []; + public List Entrypoints { get; init; } = []; + public List DataContracts { get; init; } = []; + public List ActionContracts { get; init; } = []; + public List Privacy { get; init; } = []; + public string StatusContract { get; init; } = ""; + public List ReportRoots { get; init; } = []; + public List StorageRoots { get; init; } = []; + public List HealthChecks { get; init; } = []; + public List Actions { get; init; } = []; + public List RecentLedgerEvents { get; init; } = []; +} + +public sealed class ModuleDetailReadModelService +{ + private readonly ModuleStatusService _moduleStatusService; + private readonly IBlackboardStore _blackboardStore; + + public ModuleDetailReadModelService(ModuleStatusService moduleStatusService, IBlackboardStore blackboardStore) + { + _moduleStatusService = moduleStatusService; + _blackboardStore = blackboardStore; + } + + public async Task GetModuleDetailAsync( + string moduleId, + int recentEventLimit = 25, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(moduleId)) + { + return null; + } + + var manifest = _moduleStatusService.GetManifests() + .FirstOrDefault(item => string.Equals(item.ModuleId, moduleId, StringComparison.OrdinalIgnoreCase)); + + if (manifest is null) + { + return null; + } + + var status = _moduleStatusService.GetStatuses() + .FirstOrDefault(item => string.Equals(item.ModuleId, manifest.ModuleId, StringComparison.OrdinalIgnoreCase)) + ?? new ModuleStatus + { + ModuleId = manifest.ModuleId, + AppId = manifest.AppId, + DisplayName = manifest.DisplayName, + Availability = ModuleAvailability.Unavailable, + Reason = "Module status is unavailable.", + NextAction = "Re-run module detection." + }; + + var healthChecks = manifest.HealthChecks.Select(EvaluateHealthCheck).ToList(); + var storageRoots = manifest.StorageRoots.Select(EvaluateStorageRoot).ToList(); + var actions = manifest.Actions.Select(ToAction).ToList(); + var events = await GetRecentModuleEventsAsync(manifest.ModuleId, recentEventLimit, cancellationToken).ConfigureAwait(false); + + return new ModuleDetailReadModel + { + ModuleId = manifest.ModuleId, + AppId = manifest.AppId, + DisplayName = manifest.DisplayName, + ModuleKind = manifest.ModuleKind, + Version = manifest.Version, + RiskLevel = manifest.RiskLevel, + Availability = status.Availability, + StatusLabel = status.Availability.ToString(), + Reason = status.Reason, + ExpectedSource = status.ExpectedSource, + NextAction = status.NextAction, + Capabilities = manifest.Capabilities, + Entrypoints = manifest.Entrypoints, + DataContracts = manifest.DataContracts, + ActionContracts = manifest.ActionContracts, + Privacy = manifest.Privacy, + StatusContract = manifest.StatusContract, + ReportRoots = manifest.ReportRoots, + StorageRoots = storageRoots, + HealthChecks = healthChecks, + Actions = actions, + RecentLedgerEvents = events + }; + } + + private async Task> GetRecentModuleEventsAsync( + string moduleId, + int limit, + CancellationToken cancellationToken) + { + if (limit <= 0) + { + return []; + } + + var events = await _blackboardStore.QueryAsync( + new BlackboardEventQuery + { + ModuleId = moduleId, + Limit = limit + }, + cancellationToken) + .ConfigureAwait(false); + + return events.Select(ToLedgerEvent).ToList(); + } + + private static ModuleHealthCheckReadModel EvaluateHealthCheck(ModuleHealthCheck check) + { + var kind = check.Kind ?? ""; + if (kind.Equals("file", StringComparison.OrdinalIgnoreCase)) + { + var exists = File.Exists(check.Target); + return new ModuleHealthCheckReadModel + { + Name = check.Name, + Kind = kind, + Target = check.Target, + Required = check.Required, + State = exists ? ModuleHealthState.Ok : ModuleHealthState.Missing, + Detail = exists ? "File present." : "File missing.", + IsBlocking = check.Required && !exists + }; + } + + if (kind.Equals("directory", StringComparison.OrdinalIgnoreCase)) + { + var exists = Directory.Exists(check.Target); + return new ModuleHealthCheckReadModel + { + Name = check.Name, + Kind = kind, + Target = check.Target, + Required = check.Required, + State = exists ? ModuleHealthState.Ok : ModuleHealthState.Missing, + Detail = exists ? "Directory present." : "Directory missing.", + IsBlocking = check.Required && !exists + }; + } + + return new ModuleHealthCheckReadModel + { + Name = check.Name, + Kind = kind, + Target = check.Target, + Required = check.Required, + State = ModuleHealthState.Unknown, + Detail = "Unsupported health check kind.", + IsBlocking = check.Required + }; + } + + private static ModuleStorageRootReadModel EvaluateStorageRoot(ModuleStorageRoot root) + { + var exists = Directory.Exists(root.Path) || File.Exists(root.Path); + return new ModuleStorageRootReadModel + { + Name = root.Name, + Path = root.Path, + PrivacyState = root.PrivacyState, + Description = root.Description, + Exists = exists, + Detail = exists ? "Detected." : "Missing." + }; + } + + private static ModuleActionReadModel ToAction(ModuleActionContract action) => + new() + { + Name = action.Name, + Permission = action.Permission, + RequiresApproval = action.RequiresApproval, + SystemChanging = action.SystemChanging, + Description = action.Description, + IsRunnable = action.Permission.Contains("execute", StringComparison.OrdinalIgnoreCase) + }; + + private static ModuleLedgerEventReadModel ToLedgerEvent(BlackboardEvent evt) => + new() + { + EventId = evt.EventId, + EventType = evt.EventType, + CorrelationId = evt.CorrelationId, + CreatedAt = evt.CreatedAt, + DataState = evt.DataState, + PrivacyState = evt.PrivacyState, + Summary = evt.Summary + }; +} + diff --git a/tests/AppLens.Backend.Tests/ModuleDetailReadModelServiceTests.cs b/tests/AppLens.Backend.Tests/ModuleDetailReadModelServiceTests.cs new file mode 100644 index 0000000..28294b1 --- /dev/null +++ b/tests/AppLens.Backend.Tests/ModuleDetailReadModelServiceTests.cs @@ -0,0 +1,106 @@ +namespace AppLens.Backend.Tests; + +public sealed class ModuleDetailReadModelServiceTests : IDisposable +{ + private readonly string _root; + private readonly AppLensRuntimeStorage _storage; + private readonly BlackboardStore _store; + + public ModuleDetailReadModelServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), "AppLens-ModuleDetailReadModelTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _storage = AppLensRuntimeStorage.FromRoot(Path.Combine(_root, "runtime")); + _store = new BlackboardStore(_storage); + } + + [Fact] + public async Task Unknown_module_returns_null() + { + var service = CreateService(); + + var result = await service.GetModuleDetailAsync("missing-module"); + + Assert.Null(result); + } + + [Fact] + public async Task Module_detail_includes_health_storage_and_recent_events() + { + var llmRoot = Path.Combine(_root, "AppLens-LLM"); + Directory.CreateDirectory(Path.Combine(llmRoot, "src", "applens_llm")); + Directory.CreateDirectory(Path.Combine(llmRoot, "out")); + File.WriteAllText(Path.Combine(llmRoot, "pyproject.toml"), "[project]\nname='applens-llm'\n"); + File.WriteAllText(Path.Combine(llmRoot, "src", "applens_llm", "cli.py"), "# fake cli"); + + await _store.AppendAsync(new BlackboardEvent + { + EventId = "evt-old", + EventType = BlackboardEventType.ScanCompleted, + ModuleId = "llm", + CorrelationId = "corr-module", + CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero), + Summary = "Scan completed." + }); + await _store.AppendAsync(new BlackboardEvent + { + EventId = "evt-new", + EventType = BlackboardEventType.VerificationRecorded, + ModuleId = "llm", + CorrelationId = "corr-module", + CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 5, 0, TimeSpan.Zero), + Summary = "Verification recorded." + }); + await _store.AppendAsync(new BlackboardEvent + { + EventId = "evt-other", + EventType = BlackboardEventType.ScanCompleted, + ModuleId = "oracle", + CorrelationId = "corr-other", + CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 10, 0, TimeSpan.Zero), + Summary = "Other module scan." + }); + + var service = CreateService(new ModuleStatusPaths + { + AppLensLlmRoot = llmRoot, + OracleRoot = Path.Combine(_root, "missing-oracle"), + MailboxRoot = Path.Combine(_root, "missing-mailbox"), + AppLensZeroRoot = Path.Combine(_root, "missing-zero") + }); + + var detail = await service.GetModuleDetailAsync("llm", recentEventLimit: 2); + + Assert.NotNull(detail); + Assert.Equal("llm", detail!.ModuleId); + Assert.Equal(ModuleAvailability.Available, detail.Availability); + Assert.Equal("Available", detail.StatusLabel); + Assert.Equal("AppLens-LLM", detail.DisplayName); + Assert.Contains(detail.HealthChecks, check => check.Name == "package" && check.State == ModuleHealthState.Ok); + Assert.Contains(detail.HealthChecks, check => check.Name == "cli" && check.State == ModuleHealthState.Ok); + Assert.Contains(detail.StorageRoots, root => root.Name == "repository" && root.Exists); + Assert.Contains(detail.StorageRoots, root => root.Name == "runtime-output" && root.Exists); + Assert.Equal(["evt-new", "evt-old"], detail.RecentLedgerEvents.Select(evt => evt.EventId).ToArray()); + Assert.All(detail.RecentLedgerEvents, evt => Assert.Equal("corr-module", evt.CorrelationId)); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private ModuleDetailReadModelService CreateService(ModuleStatusPaths? paths = null) => + new( + new ModuleStatusService(paths ?? new ModuleStatusPaths + { + AppLensLlmRoot = Path.Combine(_root, "missing-llm"), + OracleRoot = Path.Combine(_root, "missing-oracle"), + MailboxRoot = Path.Combine(_root, "missing-mailbox"), + AppLensZeroRoot = Path.Combine(_root, "missing-zero") + }), + _store); +} +