From 87e1856e1b81caba712c84faddaaaa6c3bd428d7 Mon Sep 17 00:00:00 2001 From: Cody Date: Sun, 10 May 2026 11:33:33 -0700 Subject: [PATCH] feat: add backend platform loop --- .../plans/2026-05-10-backend-platform-loop.md | 65 ++++++ src/AppLens.Backend/BlackboardEvent.cs | 170 ++++++++++++++++ src/AppLens.Backend/BlackboardStore.cs | 53 +++++ src/AppLens.Backend/ModuleManifest.cs | 32 +++ src/AppLens.Backend/ModuleStatusService.cs | 110 +++++++++- src/AppLens.Backend/PlatformLoopService.cs | 188 ++++++++++++++++++ .../BlackboardStoreTests.cs | 58 +++++- .../ModuleStatusServiceTests.cs | 37 ++++ .../PlatformLoopServiceTests.cs | 128 ++++++++++++ 9 files changed, 832 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-10-backend-platform-loop.md create mode 100644 src/AppLens.Backend/PlatformLoopService.cs create mode 100644 tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs diff --git a/docs/superpowers/plans/2026-05-10-backend-platform-loop.md b/docs/superpowers/plans/2026-05-10-backend-platform-loop.md new file mode 100644 index 0000000..78d0561 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-backend-platform-loop.md @@ -0,0 +1,65 @@ +# Backend Platform Loop 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:** Build the backend-only AppLens platform loop that proves module detection, Tune action proposal, approval-gated execution, and ledger recording work together without frontend changes or live system-changing jobs. + +**Architecture:** Extend the existing backend primitives instead of adding a parallel harness. `ModuleStatusService` owns standardized module manifest/status shape, `BlackboardStore` owns append-only events plus query helpers, and a new platform loop service coordinates detect -> propose -> approve -> execute -> record through `TuneActionExecutor`. + +**Tech Stack:** C#/.NET 10, xUnit, JSONL ledger, SQLite index, existing AppLens backend project. + +--- + +### Task 1: Ledger Query Contract + +**Files:** +- Modify: `src/AppLens.Backend/BlackboardStore.cs` +- Modify: `src/AppLens.Backend/BlackboardEvent.cs` +- Test: `tests/AppLens.Backend.Tests/BlackboardStoreTests.cs` + +- [x] Add `BlackboardEventQuery` with optional filters for event type, module id, app id, correlation id, data state, and result limit. +- [x] Add `IBlackboardStore.QueryAsync(BlackboardEventQuery query, CancellationToken cancellationToken = default)`. +- [x] Test that querying by correlation id and event type returns only matching events in newest-first order. + +### Task 2: Standardized Module Manifest Shape + +**Files:** +- Modify: `src/AppLens.Backend/ModuleManifest.cs` +- Modify: `src/AppLens.Backend/ModuleStatusService.cs` +- Test: `tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs` + +- [x] Add stable manifest metadata: schema version, module kind, storage roots, health checks, and typed action contracts. +- [x] Populate AppLens-LLM, Oracle, Mailbox, and AppLens-Zero with the same manifest shape. +- [x] Test that every manifest has the platform schema version, at least one storage root, at least one health check, and typed action contracts. + +### Task 3: Tune Approval Lifecycle + +**Files:** +- Create: `src/AppLens.Backend/PlatformLoopService.cs` +- Test: `tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs` + +- [x] Add proposal and approval records for Tune actions. +- [x] Propose actions by writing `ActionProposed` ledger events. +- [x] Approve actions by writing `ActionApproved` ledger events with an approval grant id. +- [x] Execute only when an approval record is approved and references the proposal. +- [x] Record execution through `ActionExecuted` ledger events. + +### Task 4: Integration Proof + +**Files:** +- Test: `tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs` + +- [x] Use fake module paths and a fake Tune runtime. +- [x] Prove detect -> propose -> approve -> execute -> record produces ledger events in one shared correlation id. +- [x] Prove rejected approvals do not call the runtime and record a blocked action. + +### Task 5: Verification and PR + +**Files:** +- Commit only backend feature files and tests. + +- [x] Run `dotnet test .\AppLensDesktop.sln`. +- [x] Run `dotnet build .\AppLensDesktop.sln`. +- [ ] Commit the scoped backend platform loop work. +- [ ] Push `codex/backend-platform-next`. +- [ ] Open a draft PR against `main`. diff --git a/src/AppLens.Backend/BlackboardEvent.cs b/src/AppLens.Backend/BlackboardEvent.cs index d783a4d..5c698fa 100644 --- a/src/AppLens.Backend/BlackboardEvent.cs +++ b/src/AppLens.Backend/BlackboardEvent.cs @@ -27,6 +27,175 @@ public sealed class BlackboardEvent public string? SignerKeyId { get; init; } public string? Signature { get; init; } + public static BlackboardEvent ForModuleDetected( + ModuleStatus status, + ModuleManifest manifest, + string correlationId) => + new() + { + EventType = BlackboardEventType.ModuleDetected, + ModuleId = status.ModuleId, + AppId = status.AppId, + CorrelationId = correlationId, + DataState = status.Availability == ModuleAvailability.Available + ? BlackboardDataState.Validated + : BlackboardDataState.Blocked, + Summary = $"{status.DisplayName} module status is {status.Availability}: {status.Reason}", + Payload = new Dictionary + { + ["module_id"] = status.ModuleId, + ["app_id"] = status.AppId, + ["display_name"] = status.DisplayName, + ["availability"] = status.Availability.ToString(), + ["reason"] = status.Reason, + ["expected_source"] = status.ExpectedSource, + ["next_action"] = status.NextAction, + ["manifest_schema_version"] = manifest.SchemaVersion, + ["module_kind"] = manifest.ModuleKind, + ["capability_count"] = manifest.Capabilities.Count.ToString(), + ["action_count"] = manifest.Actions.Count.ToString() + }, + Provenance = new BlackboardProvenance + { + Source = "ModuleStatusService.GetStatuses", + Tool = "AppLens", + ToolVersion = typeof(ModuleStatusService).Assembly.GetName().Version?.ToString() ?? "unknown" + } + }; + + public static BlackboardEvent ForTuneActionProposed(TuneActionProposal proposal, TunePlanItem item) => + new() + { + EventType = BlackboardEventType.ActionProposed, + ModuleId = "tune", + AppId = "applens-tune", + CorrelationId = proposal.CorrelationId, + CreatedAt = proposal.ProposedAt, + Summary = $"Tune action {item.ProposedAction.Kind} proposed for {item.ProposedAction.Target}.", + Payload = new Dictionary + { + ["proposal_id"] = proposal.ProposalId, + ["plan_item_id"] = item.Id, + ["kind"] = item.ProposedAction.Kind.ToString(), + ["target"] = item.ProposedAction.Target, + ["target_context"] = item.ProposedAction.TargetContext, + ["execution_state"] = item.ProposedAction.ExecutionState.ToString(), + ["risk"] = item.Risk.ToString(), + ["requires_admin"] = item.RequiresAdmin.ToString(), + ["description"] = item.ProposedAction.Description + }, + Provenance = new BlackboardProvenance + { + Source = "PlatformLoopService.ProposeTuneActionAsync", + Tool = "AppLens-Tune", + ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown" + }, + PolicyResult = new BlackboardPolicyResult + { + Allowed = false, + BlockedReason = "Pending operator approval.", + RequiresApproval = true, + RequiresAdmin = item.RequiresAdmin, + RiskLevel = item.Risk.ToString().ToLowerInvariant(), + PolicyId = "applens-tune-approval-v1" + } + }; + + public static BlackboardEvent ForTuneActionApproved(TuneActionApproval approval, TuneActionProposal proposal) => + new() + { + EventType = BlackboardEventType.ActionApproved, + ModuleId = "tune", + AppId = "applens-tune", + GrantId = approval.GrantId, + CorrelationId = proposal.CorrelationId, + CreatedAt = approval.DecidedAt, + DataState = approval.Approved ? BlackboardDataState.Validated : BlackboardDataState.Blocked, + Summary = approval.Approved + ? $"Tune action proposal {proposal.ProposalId} approved." + : $"Tune action proposal {proposal.ProposalId} rejected.", + Payload = new Dictionary + { + ["approval_id"] = approval.ApprovalId, + ["grant_id"] = approval.GrantId, + ["proposal_id"] = proposal.ProposalId, + ["approved"] = approval.Approved.ToString(), + ["approved_by"] = approval.ApprovedBy, + ["rationale"] = approval.Rationale + }, + Provenance = new BlackboardProvenance + { + Source = "PlatformLoopService.ApproveTuneActionAsync", + Tool = "AppLens-Tune", + ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown" + }, + PolicyResult = new BlackboardPolicyResult + { + Allowed = approval.Approved, + BlockedReason = approval.Approved ? "" : approval.Rationale, + RequiresApproval = true, + RequiresAdmin = false, + RiskLevel = "low", + PolicyId = "applens-tune-approval-v1" + } + }; + + public static BlackboardEvent ForTuneActionExecuted( + TuneActionRecord action, + TuneActionProposal proposal, + TuneActionApproval approval, + string correlationId) + { + var allowed = action.Status == TuneActionStatus.Succeeded; + return new BlackboardEvent + { + EventType = BlackboardEventType.ActionExecuted, + ModuleId = "tune", + AppId = "applens-tune", + GrantId = approval.GrantId, + CorrelationId = correlationId, + CreatedAt = action.CompletedAt, + DataState = action.Status switch + { + TuneActionStatus.Succeeded => BlackboardDataState.Validated, + TuneActionStatus.Blocked => BlackboardDataState.Blocked, + TuneActionStatus.Failed => BlackboardDataState.Invalidated, + TuneActionStatus.RolledBack => BlackboardDataState.Invalidated, + _ => BlackboardDataState.Blocked + }, + Summary = $"Tune action {action.Kind} for {action.Target} ended with {action.Status}.", + Payload = new Dictionary + { + ["proposal_id"] = proposal.ProposalId, + ["approval_id"] = approval.ApprovalId, + ["grant_id"] = approval.GrantId, + ["action_id"] = action.Id, + ["plan_item_id"] = action.PlanItemId, + ["kind"] = action.Kind.ToString(), + ["status"] = action.Status.ToString(), + ["target"] = action.Target, + ["message"] = action.Message, + ["started_at"] = action.StartedAt.ToString("O"), + ["completed_at"] = action.CompletedAt.ToString("O") + }, + Provenance = new BlackboardProvenance + { + Source = "PlatformLoopService.ExecuteTuneActionAsync", + Tool = "AppLens-Tune", + ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown" + }, + PolicyResult = new BlackboardPolicyResult + { + Allowed = allowed, + BlockedReason = allowed ? "" : action.Message, + RequiresApproval = true, + RequiresAdmin = action.RequiresAdmin, + RiskLevel = action.RequiresAdmin ? "medium" : "low", + PolicyId = "applens-tune-approval-v1" + } + }; + } + public static BlackboardEvent ForScanCompleted(AuditSnapshot snapshot, string? correlationId = null) => new() { @@ -132,6 +301,7 @@ public sealed class BlackboardPolicyResult [JsonConverter(typeof(JsonStringEnumConverter))] public enum BlackboardEventType { + ModuleDetected, CapabilityObserved, EvidenceCaptured, ReportGenerated, diff --git a/src/AppLens.Backend/BlackboardStore.cs b/src/AppLens.Backend/BlackboardStore.cs index 63e414f..1da1e68 100644 --- a/src/AppLens.Backend/BlackboardStore.cs +++ b/src/AppLens.Backend/BlackboardStore.cs @@ -10,9 +10,21 @@ public interface IBlackboardStore Task> ReadAllAsync(CancellationToken cancellationToken = default); + Task> QueryAsync(BlackboardEventQuery query, CancellationToken cancellationToken = default); + Task GetIndexedEventCountAsync(CancellationToken cancellationToken = default); } +public sealed class BlackboardEventQuery +{ + public BlackboardEventType? EventType { get; init; } + public string? ModuleId { get; init; } + public string? AppId { get; init; } + public string? CorrelationId { get; init; } + public BlackboardDataState? DataState { get; init; } + public int? Limit { get; init; } +} + public sealed class BlackboardStore : IBlackboardStore { private static readonly JsonSerializerOptions SerializerOptions = new() @@ -72,6 +84,47 @@ public async Task> ReadAllAsync(CancellationToken cancella return events; } + public async Task> QueryAsync( + BlackboardEventQuery query, + CancellationToken cancellationToken = default) + { + var events = (await ReadAllAsync(cancellationToken).ConfigureAwait(false)).AsEnumerable(); + + if (query.EventType is not null) + { + events = events.Where(evt => evt.EventType == query.EventType); + } + + if (!string.IsNullOrWhiteSpace(query.ModuleId)) + { + events = events.Where(evt => string.Equals(evt.ModuleId, query.ModuleId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(query.AppId)) + { + events = events.Where(evt => string.Equals(evt.AppId, query.AppId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(query.CorrelationId)) + { + events = events.Where(evt => string.Equals(evt.CorrelationId, query.CorrelationId, StringComparison.OrdinalIgnoreCase)); + } + + if (query.DataState is not null) + { + events = events.Where(evt => evt.DataState == query.DataState); + } + + events = events.OrderByDescending(evt => evt.CreatedAt); + + if (query.Limit is > 0) + { + events = events.Take(query.Limit.Value); + } + + return events.ToList(); + } + public static string SerializeEvent(BlackboardEvent evt) => JsonSerializer.Serialize(evt, SerializerOptions); diff --git a/src/AppLens.Backend/ModuleManifest.cs b/src/AppLens.Backend/ModuleManifest.cs index 7843c1c..7338c2d 100644 --- a/src/AppLens.Backend/ModuleManifest.cs +++ b/src/AppLens.Backend/ModuleManifest.cs @@ -2,9 +2,13 @@ namespace AppLens.Backend; public sealed class ModuleManifest { + public const string PlatformSchemaVersion = "1.0"; + + public string SchemaVersion { get; init; } = PlatformSchemaVersion; public string AppId { get; init; } = ""; public string DisplayName { get; init; } = ""; public string ModuleId { get; init; } = ""; + public string ModuleKind { get; init; } = ""; public string Version { get; init; } = "1.0"; public string RiskLevel { get; init; } = "low"; public List Capabilities { get; init; } = []; @@ -14,6 +18,34 @@ public sealed class ModuleManifest 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 sealed class ModuleStorageRoot +{ + public string Name { get; init; } = ""; + public string Path { get; init; } = ""; + public string PrivacyState { get; init; } = "raw_private"; + public string Description { get; init; } = ""; +} + +public sealed class ModuleHealthCheck +{ + public string Name { get; init; } = ""; + public string Kind { get; init; } = "file"; + public string Target { get; init; } = ""; + public bool Required { get; init; } = true; +} + +public sealed class ModuleActionContract +{ + public string Name { get; init; } = ""; + public string Permission { get; init; } = "read"; + public bool RequiresApproval { get; init; } = true; + public bool SystemChanging { get; init; } + public string Description { get; init; } = ""; } public sealed class ModuleStatus diff --git a/src/AppLens.Backend/ModuleStatusService.cs b/src/AppLens.Backend/ModuleStatusService.cs index b340e03..61243a6 100644 --- a/src/AppLens.Backend/ModuleStatusService.cs +++ b/src/AppLens.Backend/ModuleStatusService.cs @@ -64,52 +64,121 @@ public List GetManifests() => AppId = "applens-llm", DisplayName = "AppLens-LLM", ModuleId = "llm", + ModuleKind = "local-llm-adapter", RiskLevel = "medium", Capabilities = ["lane-status", "scorecard-import", "fit-report-import"], Entrypoints = ["status_command", "run_job", "import_reports"], DataContracts = ["runtime-lanes", "blackboard-record", "model-fit-scorecard", "fit-report", "benchmark-record"], ActionContracts = ["bounded-local-command"], Privacy = ["raw_private", "sanitized", "exportable"], - StatusContract = "file-only" + StatusContract = "file-only", + ReportRoots = [Path.Combine(_paths.AppLensLlmRoot, "out")], + StorageRoots = + [ + StorageRoot("repository", _paths.AppLensLlmRoot, "raw_private", "Local AppLens-LLM repository root."), + StorageRoot("runtime-output", Path.Combine(_paths.AppLensLlmRoot, "out"), "raw_private", "Local LLM lane reports and blackboard output.") + ], + HealthChecks = + [ + HealthCheck("package", "file", Path.Combine(_paths.AppLensLlmRoot, "pyproject.toml")), + HealthCheck("cli", "file", Path.Combine(_paths.AppLensLlmRoot, "src", "applens_llm", "cli.py")) + ], + Actions = + [ + Action("read-status", "read", requiresApproval: false, systemChanging: false, "Read local lane and scorecard status."), + Action("import-reports", "write-ledger", requiresApproval: true, systemChanging: false, "Import local LLM reports into the AppLens ledger."), + Action("run-bounded-job", "execute-local", requiresApproval: true, systemChanging: false, "Run a bounded local AppLens-LLM job.") + ] }, new ModuleManifest { AppId = "oracle-workbench", DisplayName = "Oracle", ModuleId = "oracle", + ModuleKind = "research-workload", RiskLevel = "medium", Capabilities = ["research-status", "read-only-job", "report-import"], Entrypoints = ["status_command", "run_job", "import_reports"], DataContracts = ["research-report", "run-record", "data-provenance"], ActionContracts = ["read-only-research-job"], Privacy = ["raw_private", "exportable", "invalidated"], - StatusContract = "file-only" + StatusContract = "file-only", + ReportRoots = [Path.Combine(_paths.OracleRoot, "out")], + StorageRoots = + [ + StorageRoot("repository", _paths.OracleRoot, "raw_private", "Local Oracle repository root."), + StorageRoot("reports", Path.Combine(_paths.OracleRoot, "out"), "raw_private", "Read-only Oracle run reports.") + ], + HealthChecks = + [ + HealthCheck("package", "file", Path.Combine(_paths.OracleRoot, "pyproject.toml")) + ], + Actions = + [ + Action("read-status", "read", requiresApproval: false, systemChanging: false, "Read Oracle run status."), + Action("import-reports", "write-ledger", requiresApproval: true, systemChanging: false, "Import Oracle reports into the AppLens ledger."), + Action("run-read-only-job", "execute-local", requiresApproval: true, systemChanging: false, "Run a bounded read-only Oracle research job.") + ] }, new ModuleManifest { AppId = "mailbox", DisplayName = "Mailbox", ModuleId = "mailbox", + ModuleKind = "folder-ui-app", RiskLevel = "medium", Capabilities = ["open_ui", "status_http", "folder-status"], Entrypoints = ["open_ui", "status_http"], DataContracts = ["filesystem-mailbox", "markdown-message", "attachment-artifact"], ActionContracts = ["open-only-v1"], Privacy = ["raw_private", "sanitized"], - StatusContract = "file-or-http-status" + StatusContract = "file-or-http-status", + ReportRoots = [Path.Combine(_paths.MailboxRoot, "data")], + StorageRoots = + [ + StorageRoot("repository", _paths.MailboxRoot, "raw_private", "Local Mailbox repository root."), + StorageRoot("mailbox-data", Path.Combine(_paths.MailboxRoot, "data"), "raw_private", "Folder-backed mailbox data root.") + ], + HealthChecks = + [ + HealthCheck("server", "file", Path.Combine(_paths.MailboxRoot, "mailbox", "server.py")), + HealthCheck("config", "file", Path.Combine(_paths.MailboxRoot, "config.toml")) + ], + Actions = + [ + Action("read-status", "read", requiresApproval: false, systemChanging: false, "Read Mailbox folder and server status."), + Action("open-ui", "open-local-ui", requiresApproval: true, systemChanging: false, "Open the local Mailbox UI without changing mailbox data.") + ] }, new ModuleManifest { AppId = "applens-zero", DisplayName = "AppLens-Zero", ModuleId = "zero", + ModuleKind = "hardware-lab-adapter", RiskLevel = "high", Capabilities = ["lab-readiness", "passive-import", "authorized-session"], Entrypoints = ["import_reports"], DataContracts = ["lab-authorization", "edge-device-profile", "capture-readiness"], ActionContracts = ["benign-check-only"], Privacy = ["raw_private", "sanitized"], - StatusContract = "file-only" + StatusContract = "file-only", + ReportRoots = [Path.Combine(_paths.AppLensZeroRoot, "raw")], + StorageRoots = + [ + StorageRoot("repository", _paths.AppLensZeroRoot, "raw_private", "Local AppLens-Zero repository root."), + StorageRoot("raw-imports", Path.Combine(_paths.AppLensZeroRoot, "raw"), "raw_private", "Authorized passive import folder.") + ], + HealthChecks = + [ + HealthCheck("platform-design", "file", Path.Combine(_paths.AppLensZeroRoot, "docs", "AppLens-Platform-Host-Design.md")), + HealthCheck("raw-import-folder", "directory", Path.Combine(_paths.AppLensZeroRoot, "raw")) + ], + Actions = + [ + Action("read-status", "read", requiresApproval: false, systemChanging: false, "Read AppLens-Zero lab readiness status."), + Action("import-authorized-artifacts", "write-ledger", requiresApproval: true, systemChanging: false, "Import authorized passive artifacts into the AppLens ledger.") + ] } ]; @@ -188,4 +257,37 @@ private static ModuleStatus Blocked(string moduleId, string appId, string displa ExpectedSource = expectedSource, NextAction = "Connect or configure the local app path before running jobs." }; + + private static ModuleStorageRoot StorageRoot(string name, string path, string privacyState, string description) => + new() + { + Name = name, + Path = path, + PrivacyState = privacyState, + Description = description + }; + + private static ModuleHealthCheck HealthCheck(string name, string kind, string target) => + new() + { + Name = name, + Kind = kind, + Target = target, + Required = true + }; + + private static ModuleActionContract Action( + string name, + string permission, + bool requiresApproval, + bool systemChanging, + string description) => + new() + { + Name = name, + Permission = permission, + RequiresApproval = requiresApproval, + SystemChanging = systemChanging, + Description = description + }; } diff --git a/src/AppLens.Backend/PlatformLoopService.cs b/src/AppLens.Backend/PlatformLoopService.cs new file mode 100644 index 0000000..e352615 --- /dev/null +++ b/src/AppLens.Backend/PlatformLoopService.cs @@ -0,0 +1,188 @@ +namespace AppLens.Backend; + +public sealed class TuneActionProposal +{ + public string ProposalId { get; init; } = $"proposal-{Guid.NewGuid():N}"; + public string PlanItemId { get; init; } = ""; + public ProposedActionKind Kind { get; init; } = ProposedActionKind.None; + public string Target { get; init; } = ""; + public string TargetContext { get; init; } = ""; + public string CorrelationId { get; init; } = ""; + public DateTimeOffset ProposedAt { get; init; } = DateTimeOffset.Now; +} + +public sealed class TuneActionApproval +{ + public string ApprovalId { get; init; } = $"approval-{Guid.NewGuid():N}"; + public string GrantId { get; init; } = $"grant-{Guid.NewGuid():N}"; + public string ProposalId { get; init; } = ""; + public bool Approved { get; init; } + public string ApprovedBy { get; init; } = ""; + public string Rationale { get; init; } = ""; + public DateTimeOffset DecidedAt { get; init; } = DateTimeOffset.Now; +} + +public sealed class PlatformLoopService +{ + private readonly ModuleStatusService _moduleStatusService; + private readonly IBlackboardStore _blackboardStore; + private readonly TuneActionExecutor _tuneActionExecutor; + + public PlatformLoopService( + ModuleStatusService moduleStatusService, + IBlackboardStore blackboardStore, + TuneActionExecutor tuneActionExecutor) + { + _moduleStatusService = moduleStatusService; + _blackboardStore = blackboardStore; + _tuneActionExecutor = tuneActionExecutor; + } + + public async Task> DetectModulesAsync( + string? correlationId = null, + CancellationToken cancellationToken = default) + { + var effectiveCorrelationId = NormalizeCorrelationId(correlationId, "corr-detect"); + var manifests = _moduleStatusService.GetManifests().ToDictionary( + manifest => manifest.ModuleId, + StringComparer.OrdinalIgnoreCase); + var statuses = _moduleStatusService.GetStatuses(); + + foreach (var status in statuses) + { + if (!manifests.TryGetValue(status.ModuleId, out var manifest)) + { + continue; + } + + await _blackboardStore.AppendAsync( + BlackboardEvent.ForModuleDetected(status, manifest, effectiveCorrelationId), + cancellationToken) + .ConfigureAwait(false); + } + + return statuses; + } + + public async Task ProposeTuneActionAsync( + TunePlanItem item, + string? correlationId = null, + CancellationToken cancellationToken = default) + { + var proposal = new TuneActionProposal + { + PlanItemId = item.Id, + Kind = item.ProposedAction.Kind, + Target = item.ProposedAction.Target, + TargetContext = item.ProposedAction.TargetContext, + CorrelationId = NormalizeCorrelationId(correlationId, "corr-tune-proposal") + }; + + await _blackboardStore.AppendAsync( + BlackboardEvent.ForTuneActionProposed(proposal, item), + cancellationToken) + .ConfigureAwait(false); + + return proposal; + } + + public async Task ApproveTuneActionAsync( + TuneActionProposal proposal, + string approvedBy, + bool approved, + string rationale, + string? correlationId = null, + CancellationToken cancellationToken = default) + { + var approval = new TuneActionApproval + { + ProposalId = proposal.ProposalId, + Approved = approved, + ApprovedBy = approvedBy, + Rationale = rationale + }; + + var eventProposal = string.IsNullOrWhiteSpace(correlationId) + ? proposal + : proposal.WithCorrelation(correlationId); + + await _blackboardStore.AppendAsync( + BlackboardEvent.ForTuneActionApproved(approval, eventProposal), + cancellationToken) + .ConfigureAwait(false); + + return approval; + } + + public async Task ExecuteTuneActionAsync( + TunePlanItem item, + TuneActionProposal proposal, + TuneActionApproval approval, + string? correlationId = null, + CancellationToken cancellationToken = default) + { + var effectiveCorrelationId = string.IsNullOrWhiteSpace(correlationId) + ? proposal.CorrelationId + : correlationId; + TuneActionRecord record; + + if (!string.Equals(proposal.PlanItemId, item.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals(approval.ProposalId, proposal.ProposalId, StringComparison.OrdinalIgnoreCase)) + { + record = BlockedRecord(item, "Action blocked because the approval does not match the proposal."); + } + else if (!approval.Approved) + { + record = BlockedRecord(item, "Action blocked because approval was rejected."); + } + else + { + record = await _tuneActionExecutor.ExecuteAsync(item, userApproved: true, cancellationToken) + .ConfigureAwait(false); + } + + await _blackboardStore.AppendAsync( + BlackboardEvent.ForTuneActionExecuted(record, proposal, approval, effectiveCorrelationId), + cancellationToken) + .ConfigureAwait(false); + + return record; + } + + private static TuneActionRecord BlockedRecord(TunePlanItem item, string message) + { + var now = DateTimeOffset.Now; + return new TuneActionRecord + { + Id = Guid.NewGuid().ToString("N"), + PlanItemId = item.Id, + Kind = item.ProposedAction.Kind, + Status = TuneActionStatus.Blocked, + Target = item.ProposedAction.Target, + Message = message, + BackupDetail = item.BackupPlan, + VerificationStep = item.VerificationStep, + StartedAt = now, + CompletedAt = now, + RequiresAdmin = item.RequiresAdmin || item.ProposedAction.ExecutionState == TunePlanExecutionState.RequiresAdmin + }; + } + + private static string NormalizeCorrelationId(string? correlationId, string prefix) => + string.IsNullOrWhiteSpace(correlationId) ? $"{prefix}-{Guid.NewGuid():N}" : correlationId; +} + +file static class TuneActionProposalExtensions +{ + public static TuneActionProposal WithCorrelation(this TuneActionProposal proposal, string correlationId) => + new() + { + ProposalId = proposal.ProposalId, + PlanItemId = proposal.PlanItemId, + Kind = proposal.Kind, + Target = proposal.Target, + TargetContext = proposal.TargetContext, + CorrelationId = correlationId, + ProposedAt = proposal.ProposedAt + }; +} diff --git a/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs b/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs index 2210efd..fd6d378 100644 --- a/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs +++ b/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs @@ -79,6 +79,49 @@ public async Task Indexed_event_count_reads_from_sqlite_index() Assert.Equal(2, await store.GetIndexedEventCountAsync()); } + [Fact] + public async Task Query_filters_events_and_returns_newest_first() + { + var store = new BlackboardStore(_storage); + await store.AppendAsync(SampleEvent( + "evt-old", + BlackboardEventType.ActionProposed, + "tune", + "corr-platform", + new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero))); + await store.AppendAsync(SampleEvent( + "evt-ignore-type", + BlackboardEventType.ScanCompleted, + "tune", + "corr-platform", + new DateTimeOffset(2026, 5, 10, 12, 1, 0, TimeSpan.Zero))); + await store.AppendAsync(SampleEvent( + "evt-new", + BlackboardEventType.ActionProposed, + "tune", + "corr-platform", + new DateTimeOffset(2026, 5, 10, 12, 2, 0, TimeSpan.Zero))); + await store.AppendAsync(SampleEvent( + "evt-ignore-correlation", + BlackboardEventType.ActionProposed, + "tune", + "corr-other", + new DateTimeOffset(2026, 5, 10, 12, 3, 0, TimeSpan.Zero))); + + var events = await store.QueryAsync(new BlackboardEventQuery + { + EventType = BlackboardEventType.ActionProposed, + ModuleId = "tune", + CorrelationId = "corr-platform", + Limit = 2 + }); + + Assert.Collection( + events, + evt => Assert.Equal("evt-new", evt.EventId), + evt => Assert.Equal("evt-old", evt.EventId)); + } + public void Dispose() { if (Directory.Exists(_root)) @@ -87,18 +130,23 @@ public void Dispose() } } - private static BlackboardEvent SampleEvent(string eventId) => + private static BlackboardEvent SampleEvent( + string eventId, + BlackboardEventType eventType = BlackboardEventType.ScanCompleted, + string moduleId = "report", + string correlationId = "corr-1", + DateTimeOffset? createdAt = null) => new() { EventId = eventId, - EventType = BlackboardEventType.ScanCompleted, + EventType = eventType, ParticipantIdentity = "applens-desktop", ParticipantKind = BlackboardParticipantKind.FirstPartyModule, - ModuleId = "report", + ModuleId = moduleId, AppId = "applens-desktop", ScopeId = "local_workstation", - CorrelationId = "corr-1", - CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero), + CorrelationId = correlationId, + CreatedAt = createdAt ?? new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero), LifecycleState = BlackboardLifecycleState.Created, DataState = BlackboardDataState.Validated, PrivacyState = BlackboardPrivacyState.RawPrivate, diff --git a/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs b/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs index 2bc7ca6..bf97d93 100644 --- a/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs +++ b/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs @@ -70,6 +70,43 @@ public void Zero_is_blocked_when_no_import_source_exists() Assert.Contains("Zero", status.Reason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Every_manifest_uses_the_standard_platform_shape() + { + var service = new ModuleStatusService(new ModuleStatusPaths + { + AppLensLlmRoot = Path.Combine(_root, "AppLens-LLM"), + OracleRoot = Path.Combine(_root, "Oracle"), + MailboxRoot = Path.Combine(_root, "Mailbox"), + AppLensZeroRoot = Path.Combine(_root, "AppLens-Zero") + }); + + var manifests = service.GetManifests(); + + Assert.Equal(["llm", "oracle", "mailbox", "zero"], manifests.Select(manifest => manifest.ModuleId).ToArray()); + Assert.All(manifests, manifest => + { + Assert.Equal(ModuleManifest.PlatformSchemaVersion, manifest.SchemaVersion); + Assert.False(string.IsNullOrWhiteSpace(manifest.ModuleKind)); + Assert.NotEmpty(manifest.Capabilities); + Assert.NotEmpty(manifest.StorageRoots); + Assert.NotEmpty(manifest.HealthChecks); + Assert.NotEmpty(manifest.Actions); + Assert.All(manifest.StorageRoots, root => + { + Assert.False(string.IsNullOrWhiteSpace(root.Name)); + Assert.False(string.IsNullOrWhiteSpace(root.Path)); + Assert.False(string.IsNullOrWhiteSpace(root.PrivacyState)); + }); + Assert.All(manifest.Actions, action => + { + Assert.False(string.IsNullOrWhiteSpace(action.Name)); + Assert.False(string.IsNullOrWhiteSpace(action.Permission)); + Assert.False(string.IsNullOrWhiteSpace(action.Description)); + }); + }); + } + public void Dispose() { if (Directory.Exists(_root)) diff --git a/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs b/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs new file mode 100644 index 0000000..52e9324 --- /dev/null +++ b/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs @@ -0,0 +1,128 @@ +namespace AppLens.Backend.Tests; + +public sealed class PlatformLoopServiceTests : IDisposable +{ + private readonly string _root; + private readonly AppLensRuntimeStorage _storage; + private readonly BlackboardStore _store; + + public PlatformLoopServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), "AppLens-PlatformLoopTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _storage = AppLensRuntimeStorage.FromRoot(Path.Combine(_root, "runtime")); + _store = new BlackboardStore(_storage); + } + + [Fact] + public async Task Approved_platform_loop_detects_proposes_approves_executes_and_records() + { + var runtime = new FakeTuneActionRuntime(); + var service = CreateService(runtime); + var item = StartupItem("startup-docker"); + + var statuses = await service.DetectModulesAsync("corr-loop"); + var proposal = await service.ProposeTuneActionAsync(item, "corr-loop"); + var approval = await service.ApproveTuneActionAsync(proposal, approvedBy: "operator", approved: true, rationale: "Approved for test.", correlationId: "corr-loop"); + var record = await service.ExecuteTuneActionAsync(item, proposal, approval); + + var events = await _store.QueryAsync(new BlackboardEventQuery { CorrelationId = "corr-loop" }); + + Assert.Equal(4, statuses.Count); + Assert.Equal(TuneActionStatus.Succeeded, record.Status); + Assert.True(runtime.DisableStartupCalled); + Assert.NotEmpty(approval.GrantId); + Assert.Contains(events, evt => evt.EventType == BlackboardEventType.ModuleDetected); + Assert.Contains(events, evt => evt.EventType == BlackboardEventType.ActionProposed && evt.Payload["proposal_id"] == proposal.ProposalId); + Assert.Contains(events, evt => evt.EventType == BlackboardEventType.ActionApproved && evt.GrantId == approval.GrantId); + Assert.Contains(events, evt => evt.EventType == BlackboardEventType.ActionExecuted && evt.Payload["status"] == TuneActionStatus.Succeeded.ToString()); + } + + [Fact] + public async Task Rejected_approval_does_not_call_runtime_and_records_blocked_execution() + { + var runtime = new FakeTuneActionRuntime(); + var service = CreateService(runtime); + var item = StartupItem("startup-docker"); + + var proposal = await service.ProposeTuneActionAsync(item, "corr-reject"); + var approval = await service.ApproveTuneActionAsync(proposal, approvedBy: "operator", approved: false, rationale: "Not approved.", correlationId: "corr-reject"); + var record = await service.ExecuteTuneActionAsync(item, proposal, approval, "corr-reject"); + + var executionEvents = await _store.QueryAsync(new BlackboardEventQuery + { + CorrelationId = "corr-reject", + EventType = BlackboardEventType.ActionExecuted + }); + + Assert.Equal(TuneActionStatus.Blocked, record.Status); + Assert.False(runtime.DisableStartupCalled); + var executionEvent = Assert.Single(executionEvents); + Assert.Equal(TuneActionStatus.Blocked.ToString(), executionEvent.Payload["status"]); + Assert.Contains("approval", executionEvent.Payload["message"], StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private PlatformLoopService CreateService(FakeTuneActionRuntime runtime) => + new( + new ModuleStatusService(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, + new TuneActionExecutor(runtime)); + + private static TunePlanItem StartupItem(string id) => + new() + { + Id = id, + Category = TunePlanCategory.Optional, + Risk = TunePlanRisk.Low, + Title = "Disable Docker startup", + BackupPlan = "Re-enable startup if needed.", + VerificationStep = "Re-scan startup entries.", + ProposedAction = new ProposedAction + { + Kind = ProposedActionKind.DisableStartup, + ExecutionState = TunePlanExecutionState.RequiresUserConsent, + Target = "Docker Desktop", + TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", + Description = "Disable Docker Desktop startup." + } + }; + + private sealed class FakeTuneActionRuntime : ITuneActionRuntime + { + public bool IsAdministrator => false; + + public bool DisableStartupCalled { get; private set; } + + public Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(0L); + + public Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) + { + DisableStartupCalled = true; + return Task.CompletedTask; + } + + public Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +}