diff --git a/docs/superpowers/plans/2026-05-10-dashboard-read-models.md b/docs/superpowers/plans/2026-05-10-dashboard-read-models.md new file mode 100644 index 0000000..2b7774c --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-dashboard-read-models.md @@ -0,0 +1,41 @@ +# Dashboard Read Models Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add backend-only dashboard read models so frontend work can bind to stable module cards, pending Tune approvals, recent ledger events, and summary state. + +**Architecture:** Build a read-only `DashboardReadModelService` on top of `ModuleStatusService` and `IBlackboardStore`. The service must not execute Tune actions or run module jobs; it only maps existing module manifests/statuses and ledger events into UI-ready objects. + +**Tech Stack:** C#/.NET 10, xUnit, existing AppLens backend module status and blackboard ledger services. + +--- + +### Task 1: Dashboard Read Model Tests + +**Files:** +- Create: `tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs` + +- [x] Add tests for module cards built from manifests and module statuses. +- [x] Add tests for pending Tune actions from `ActionProposed` events without matching `ActionApproved` events. +- [x] Add tests for recent ledger event ordering and dashboard summary counts. + +### Task 2: Dashboard Read Model Service + +**Files:** +- Create: `src/AppLens.Backend/DashboardReadModelService.cs` + +- [x] Add `AppLensDashboardState`, `DashboardSummaryReadModel`, `ModuleCardReadModel`, `PendingTuneActionReadModel`, and `LedgerEventReadModel`. +- [x] Add `DashboardReadModelService.GetDashboardStateAsync`. +- [x] Add `DashboardReadModelService.GetModuleCards`. +- [x] Add `DashboardReadModelService.GetPendingActionsAsync`. +- [x] Add `DashboardReadModelService.GetRecentLedgerEventsAsync`. + +### Task 3: Verification and PR + +**Files:** +- Commit only backend read-model files and tests. + +- [x] Run focused read-model tests. +- [x] Run `dotnet test .\AppLensDesktop.sln`. +- [x] Run `dotnet build .\AppLensDesktop.sln`. +- [ ] Commit, push `codex/dashboard-read-models`, and open a draft PR against `main`. diff --git a/src/AppLens.Backend/DashboardReadModelService.cs b/src/AppLens.Backend/DashboardReadModelService.cs new file mode 100644 index 0000000..5fdb87d --- /dev/null +++ b/src/AppLens.Backend/DashboardReadModelService.cs @@ -0,0 +1,219 @@ +namespace AppLens.Backend; + +public sealed class AppLensDashboardState +{ + public DashboardSummaryReadModel Summary { get; init; } = new(); + public List ModuleCards { get; init; } = []; + public List PendingActions { get; init; } = []; + public List RecentLedgerEvents { get; init; } = []; +} + +public sealed class DashboardSummaryReadModel +{ + public int ModuleCount { get; init; } + public int AvailableModuleCount { get; init; } + public int BlockedModuleCount { get; init; } + public int PendingActionCount { get; init; } + public int RecentEventCount { get; init; } + public DateTimeOffset? LastLedgerEventAt { get; init; } + public string OverallState { get; init; } = "Ready"; +} + +public sealed class ModuleCardReadModel +{ + public string ModuleId { get; init; } = ""; + public string AppId { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string ModuleKind { get; init; } = ""; + public ModuleAvailability Availability { get; init; } = ModuleAvailability.Unavailable; + public string StatusLabel { get; init; } = ""; + public string RiskLevel { get; init; } = ""; + public string Reason { get; init; } = ""; + public string NextAction { get; init; } = ""; + public int CapabilityCount { get; init; } + public int ActionCount { get; init; } + public int HealthCheckCount { get; init; } + public int StorageRootCount { get; init; } + public bool HasRunnableActions { get; init; } +} + +public sealed class PendingTuneActionReadModel +{ + public string ProposalId { get; init; } = ""; + public string PlanItemId { get; init; } = ""; + public ProposedActionKind Kind { get; init; } = ProposedActionKind.None; + public string Target { get; init; } = ""; + public string TargetContext { get; init; } = ""; + public string RiskLevel { get; init; } = ""; + public bool RequiresAdmin { get; init; } + public DateTimeOffset ProposedAt { get; init; } + public string Summary { get; init; } = ""; + public string CorrelationId { get; init; } = ""; +} + +public sealed class LedgerEventReadModel +{ + public string EventId { get; init; } = ""; + public BlackboardEventType EventType { get; init; } + public string ModuleId { get; init; } = ""; + public string AppId { 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 DashboardReadModelService +{ + private readonly ModuleStatusService _moduleStatusService; + private readonly IBlackboardStore _blackboardStore; + + public DashboardReadModelService(ModuleStatusService moduleStatusService, IBlackboardStore blackboardStore) + { + _moduleStatusService = moduleStatusService; + _blackboardStore = blackboardStore; + } + + public async Task GetDashboardStateAsync( + int recentEventLimit = 20, + CancellationToken cancellationToken = default) + { + var moduleCards = GetModuleCards(); + var pendingActions = await GetPendingActionsAsync(cancellationToken).ConfigureAwait(false); + var recentEvents = await GetRecentLedgerEventsAsync(recentEventLimit, cancellationToken).ConfigureAwait(false); + + return new AppLensDashboardState + { + Summary = BuildSummary(moduleCards, pendingActions, recentEvents), + ModuleCards = moduleCards, + PendingActions = pendingActions, + RecentLedgerEvents = recentEvents + }; + } + + public List GetModuleCards() + { + var statuses = _moduleStatusService.GetStatuses().ToDictionary( + status => status.ModuleId, + StringComparer.OrdinalIgnoreCase); + + return _moduleStatusService.GetManifests() + .Select(manifest => + { + var status = statuses.TryGetValue(manifest.ModuleId, out var value) + ? value + : new ModuleStatus + { + ModuleId = manifest.ModuleId, + AppId = manifest.AppId, + DisplayName = manifest.DisplayName, + Availability = ModuleAvailability.Unavailable, + Reason = "Module status is unavailable.", + NextAction = "Re-run module detection." + }; + + return new ModuleCardReadModel + { + ModuleId = manifest.ModuleId, + AppId = manifest.AppId, + DisplayName = manifest.DisplayName, + ModuleKind = manifest.ModuleKind, + Availability = status.Availability, + StatusLabel = status.Availability.ToString(), + RiskLevel = manifest.RiskLevel, + Reason = status.Reason, + NextAction = status.NextAction, + CapabilityCount = manifest.Capabilities.Count, + ActionCount = manifest.Actions.Count, + HealthCheckCount = manifest.HealthChecks.Count, + StorageRootCount = manifest.StorageRoots.Count, + HasRunnableActions = manifest.Actions.Any(action => + action.Permission.Contains("execute", StringComparison.OrdinalIgnoreCase)) + }; + }) + .ToList(); + } + + public async Task> GetPendingActionsAsync( + CancellationToken cancellationToken = default) + { + var events = await _blackboardStore.ReadAllAsync(cancellationToken).ConfigureAwait(false); + var closedProposalIds = events + .Where(evt => evt.EventType is BlackboardEventType.ActionApproved or BlackboardEventType.ActionExecuted) + .Select(evt => Payload(evt, "proposal_id")) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return events + .Where(evt => evt.EventType == BlackboardEventType.ActionProposed) + .Where(evt => !closedProposalIds.Contains(Payload(evt, "proposal_id"))) + .OrderByDescending(evt => evt.CreatedAt) + .Select(ToPendingAction) + .ToList(); + } + + public async Task> GetRecentLedgerEventsAsync( + int limit = 20, + CancellationToken cancellationToken = default) + { + var events = await _blackboardStore.QueryAsync( + new BlackboardEventQuery { Limit = limit }, + cancellationToken) + .ConfigureAwait(false); + + return events.Select(ToLedgerEvent).ToList(); + } + + private static DashboardSummaryReadModel BuildSummary( + List moduleCards, + List pendingActions, + List recentEvents) + { + var blockedCount = moduleCards.Count(card => card.Availability == ModuleAvailability.Blocked); + return new DashboardSummaryReadModel + { + ModuleCount = moduleCards.Count, + AvailableModuleCount = moduleCards.Count(card => card.Availability == ModuleAvailability.Available), + BlockedModuleCount = blockedCount, + PendingActionCount = pendingActions.Count, + RecentEventCount = recentEvents.Count, + LastLedgerEventAt = recentEvents.FirstOrDefault()?.CreatedAt, + OverallState = pendingActions.Count > 0 || blockedCount > 0 ? "Action Required" : "Ready" + }; + } + + private static PendingTuneActionReadModel ToPendingAction(BlackboardEvent evt) => + new() + { + ProposalId = Payload(evt, "proposal_id"), + PlanItemId = Payload(evt, "plan_item_id"), + Kind = Enum.TryParse(Payload(evt, "kind"), ignoreCase: true, out var kind) + ? kind + : ProposedActionKind.None, + Target = Payload(evt, "target"), + TargetContext = Payload(evt, "target_context"), + RiskLevel = Payload(evt, "risk"), + RequiresAdmin = bool.TryParse(Payload(evt, "requires_admin"), out var requiresAdmin) && requiresAdmin, + ProposedAt = evt.CreatedAt, + Summary = evt.Summary, + CorrelationId = evt.CorrelationId + }; + + private static LedgerEventReadModel ToLedgerEvent(BlackboardEvent evt) => + new() + { + EventId = evt.EventId, + EventType = evt.EventType, + ModuleId = evt.ModuleId, + AppId = evt.AppId, + CorrelationId = evt.CorrelationId, + CreatedAt = evt.CreatedAt, + DataState = evt.DataState, + PrivacyState = evt.PrivacyState, + Summary = evt.Summary + }; + + private static string Payload(BlackboardEvent evt, string key) => + evt.Payload.TryGetValue(key, out var value) ? value : ""; +} diff --git a/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs b/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs new file mode 100644 index 0000000..8991e91 --- /dev/null +++ b/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs @@ -0,0 +1,172 @@ +namespace AppLens.Backend.Tests; + +public sealed class DashboardReadModelServiceTests : IDisposable +{ + private readonly string _root; + private readonly AppLensRuntimeStorage _storage; + private readonly BlackboardStore _store; + + public DashboardReadModelServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), "AppLens-DashboardReadModelTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _storage = AppLensRuntimeStorage.FromRoot(Path.Combine(_root, "runtime")); + _store = new BlackboardStore(_storage); + } + + [Fact] + public void Module_cards_merge_statuses_and_manifests_for_frontend() + { + var llmRoot = Path.Combine(_root, "AppLens-LLM"); + Directory.CreateDirectory(Path.Combine(llmRoot, "src", "applens_llm")); + File.WriteAllText(Path.Combine(llmRoot, "pyproject.toml"), "[project]\nname='applens-llm'\n"); + File.WriteAllText(Path.Combine(llmRoot, "src", "applens_llm", "cli.py"), "# fake cli"); + 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 cards = service.GetModuleCards(); + + Assert.Equal(["llm", "oracle", "mailbox", "zero"], cards.Select(card => card.ModuleId).ToArray()); + var llm = cards.Single(card => card.ModuleId == "llm"); + Assert.Equal(ModuleAvailability.Available, llm.Availability); + Assert.Equal("local-llm-adapter", llm.ModuleKind); + Assert.True(llm.CapabilityCount > 0); + Assert.True(llm.ActionCount > 0); + Assert.True(llm.HasRunnableActions); + Assert.Equal("Available", llm.StatusLabel); + + var oracle = cards.Single(card => card.ModuleId == "oracle"); + Assert.Equal(ModuleAvailability.Blocked, oracle.Availability); + Assert.Contains("configure", oracle.NextAction, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Pending_actions_return_unapproved_proposals_only() + { + var service = CreateService(); + var pending = Proposal("proposal-pending", "pending-startup", "corr-pending"); + var approved = Proposal("proposal-approved", "approved-startup", "corr-approved"); + var pendingItem = StartupItem(pending.PlanItemId, risk: TunePlanRisk.Low, requiresAdmin: false); + var approvedItem = StartupItem(approved.PlanItemId, risk: TunePlanRisk.Medium, requiresAdmin: true); + + await _store.AppendAsync(BlackboardEvent.ForTuneActionProposed(pending, pendingItem)); + await _store.AppendAsync(BlackboardEvent.ForTuneActionProposed(approved, approvedItem)); + await _store.AppendAsync(BlackboardEvent.ForTuneActionApproved( + new TuneActionApproval + { + ProposalId = approved.ProposalId, + Approved = true, + ApprovedBy = "operator", + Rationale = "Approved." + }, + approved)); + + var actions = await service.GetPendingActionsAsync(); + + var action = Assert.Single(actions); + Assert.Equal("proposal-pending", action.ProposalId); + Assert.Equal("pending-startup", action.PlanItemId); + Assert.Equal(ProposedActionKind.DisableStartup, action.Kind); + Assert.Equal("Docker Desktop", action.Target); + Assert.Equal("Low", action.RiskLevel); + Assert.False(action.RequiresAdmin); + Assert.Equal("corr-pending", action.CorrelationId); + } + + [Fact] + public async Task Dashboard_state_includes_summary_recent_events_and_pending_actions() + { + var service = CreateService(); + var pending = Proposal("proposal-pending", "pending-startup", "corr-dashboard", new DateTimeOffset(2026, 5, 10, 12, 1, 0, TimeSpan.Zero)); + var item = StartupItem(pending.PlanItemId, risk: TunePlanRisk.Low, requiresAdmin: false); + await _store.AppendAsync(new BlackboardEvent + { + EventId = "evt-scan", + EventType = BlackboardEventType.ScanCompleted, + ModuleId = "report", + CorrelationId = "corr-dashboard", + CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero), + Summary = "Scan completed." + }); + await _store.AppendAsync(BlackboardEvent.ForTuneActionProposed(pending, item)); + await _store.AppendAsync(new BlackboardEvent + { + EventId = "evt-verify", + EventType = BlackboardEventType.VerificationRecorded, + ModuleId = "report", + CorrelationId = "corr-dashboard", + CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 2, 0, TimeSpan.Zero), + Summary = "Verification recorded." + }); + + var dashboard = await service.GetDashboardStateAsync(recentEventLimit: 2); + + Assert.Equal(4, dashboard.Summary.ModuleCount); + Assert.Equal(1, dashboard.Summary.PendingActionCount); + Assert.Equal(2, dashboard.Summary.RecentEventCount); + Assert.Equal(new DateTimeOffset(2026, 5, 10, 12, 2, 0, TimeSpan.Zero), dashboard.Summary.LastLedgerEventAt); + Assert.Equal("evt-verify", dashboard.RecentLedgerEvents[0].EventId); + Assert.Equal(BlackboardEventType.ActionProposed, dashboard.RecentLedgerEvents[1].EventType); + Assert.Equal("corr-dashboard", dashboard.RecentLedgerEvents[1].CorrelationId); + Assert.Single(dashboard.PendingActions); + Assert.Equal(4, dashboard.ModuleCards.Count); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private DashboardReadModelService 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); + + private static TuneActionProposal Proposal( + string proposalId, + string planItemId, + string correlationId, + DateTimeOffset? proposedAt = null) => + new() + { + ProposalId = proposalId, + PlanItemId = planItemId, + Kind = ProposedActionKind.DisableStartup, + Target = "Docker Desktop", + TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", + CorrelationId = correlationId, + ProposedAt = proposedAt ?? new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero) + }; + + private static TunePlanItem StartupItem(string id, TunePlanRisk risk, bool requiresAdmin) => + new() + { + Id = id, + Category = TunePlanCategory.Optional, + Risk = risk, + RequiresAdmin = requiresAdmin, + Title = "Disable Docker startup", + ProposedAction = new ProposedAction + { + Kind = ProposedActionKind.DisableStartup, + ExecutionState = requiresAdmin ? TunePlanExecutionState.RequiresAdmin : TunePlanExecutionState.RequiresUserConsent, + Target = "Docker Desktop", + TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", + Description = "Disable Docker Desktop startup." + } + }; +}