diff --git a/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md b/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md
new file mode 100644
index 0000000..a286726
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md
@@ -0,0 +1,170 @@
+# AppLens V1 Platform Ledger 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 AppLens-local evidence ledger and platform-host foundation without merging external apps or building MCP/A2A in v1.
+
+**Architecture:** AppLens remains snapshot-centric. A new backend ledger writes append-only JSONL events under `%LOCALAPPDATA%\AppLens\ledger\events.jsonl` and maintains a SQLite index under `%LOCALAPPDATA%\AppLens\ledger\index.sqlite`; the desktop orchestrator appends events after scans and Tune actions. Static module manifests and status checks describe LLM, Oracle, Mailbox, and Zero without running live jobs.
+
+**Tech Stack:** .NET, WinUI, xUnit, `Microsoft.Data.Sqlite`, JSONL, `%LOCALAPPDATA%` runtime storage.
+
+---
+
+## Scope
+
+Implement now:
+
+- Local runtime storage root resolver with `%LOCALAPPDATA%\AppLens` default.
+- Evidence ledger event model with future-proof identity/scope/privacy/policy fields.
+- JSONL append log as truth.
+- SQLite index as rebuildable local query layer.
+- Backend tests for append, readback, index creation, and corrupt-line tolerance.
+- Desktop orchestration hooks for scan completion and Tune action completion.
+- Static app/module manifest model and status model for LLM, Oracle, Mailbox, and Zero.
+- UI additions in the current single-page dashboard for ledger storage/status and hosted-app statuses.
+
+Defer:
+
+- MCP adapter.
+- A2A adapter.
+- Full participant registry.
+- Double-handshake grants.
+- Cryptographic signing.
+- Live Oracle research, LLM lane start/stop, Mailbox sync/send.
+- Mailbox, Oracle, AppLens-LLM, or Stigmergent code edits.
+- Broad Tune action expansion unless it fits safely after the ledger is complete.
+
+## File Structure
+
+- Create `src/AppLens.Backend/RuntimeStorage.cs`
+ - Resolves AppLens runtime root and ledger paths.
+- Create `src/AppLens.Backend/BlackboardEvent.cs`
+ - Defines the ledger event contract, enums, artifact refs, provenance, and policy result.
+- Create `src/AppLens.Backend/BlackboardStore.cs`
+ - Implements `IBlackboardStore`, append, read, SQLite index, and rebuild.
+- Create `src/AppLens.Backend/ModuleManifest.cs`
+ - Defines static hosted-module manifest and status contracts.
+- Create `src/AppLens.Backend/ModuleStatusService.cs`
+ - Computes blocked/available statuses without live jobs.
+- Modify `src/AppLens.Backend/AppLens.Backend.csproj`
+ - Add `Microsoft.Data.Sqlite`.
+- Modify `src/AppLens.Desktop/MainWindow.xaml`
+ - Add compact ledger/status section in the existing single-page dashboard.
+- Modify `src/AppLens.Desktop/MainWindow.xaml.cs`
+ - Instantiate ledger/status services and append events after scan and Tune action completion.
+- Create `tests/AppLens.Backend.Tests/BlackboardStoreTests.cs`
+ - TDD coverage for local ledger behavior.
+- Create `tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs`
+ - TDD coverage for static status checks using temp dirs.
+- Modify `tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj`
+ - Add SQLite dependency if needed by test project restore.
+
+## Task 1: Runtime Storage Root
+
+**Files:**
+- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\RuntimeStorage.cs`
+- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\RuntimeStorageTests.cs`
+
+- [ ] Write a failing test proving explicit roots create expected ledger paths.
+- [ ] Run `dotnet test .\tests\AppLens.Backend.Tests\AppLens.Backend.Tests.csproj --filter RuntimeStorageTests`.
+- [ ] Implement `AppLensRuntimeStorage` with `Root`, `LedgerDirectory`, `EventsJsonl`, and `IndexSqlite`.
+- [ ] Run the focused test until green.
+
+## Task 2: Ledger Event Contract
+
+**Files:**
+- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardEvent.cs`
+- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardEventTests.cs`
+
+- [ ] Write a failing test for creating a scan-completed event with schema version, participant identity, module id, app id, scope id, correlation id, data state, privacy state, and lifecycle state.
+- [ ] Run the focused test and verify it fails because the model does not exist.
+- [ ] Implement event records and enums.
+- [ ] Run focused tests until green.
+
+## Task 3: JSONL Append Store
+
+**Files:**
+- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardStore.cs`
+- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardStoreTests.cs`
+
+- [ ] Write a failing test that appends an event and reads it back from JSONL.
+- [ ] Write a failing test that a corrupt JSONL line is skipped while valid lines still load.
+- [ ] Run focused tests and verify failures.
+- [ ] Implement `IBlackboardStore.AppendAsync` and `ReadAllAsync`.
+- [ ] Run focused tests until green.
+
+## Task 4: SQLite Index
+
+**Files:**
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\AppLens.Backend.csproj`
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardStore.cs`
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardStoreTests.cs`
+
+- [ ] Add `Microsoft.Data.Sqlite` to the backend project.
+- [ ] Write a failing test proving appending an event creates `index.sqlite` and indexes event id, type, module id, app id, created time, data state, privacy state, and summary.
+- [ ] Run focused tests and verify failure.
+- [ ] Implement SQLite initialization and indexing.
+- [ ] Run focused tests until green.
+
+## Task 5: Module Manifests And Status
+
+**Files:**
+- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\ModuleManifest.cs`
+- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\ModuleStatusService.cs`
+- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\ModuleStatusServiceTests.cs`
+
+- [ ] Write failing tests for Oracle blocked when repo path is absent.
+- [ ] Write failing tests for Mailbox blocked when config is absent.
+- [ ] Write failing tests for LLM blocked when CLI/package path is absent.
+- [ ] Write failing tests for Zero blocked when no raw/import source is configured.
+- [ ] Implement static manifests and file-only status checks.
+- [ ] Run focused tests until green.
+
+## Task 6: Scan Completion Ledger Hook
+
+**Files:**
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs`
+- Optional test seam: backend factory method in `BlackboardEvent.cs`
+
+- [ ] Add a backend test for a scan-completed event factory using a small `AuditSnapshot`.
+- [ ] Run focused test and verify failure.
+- [ ] Implement the factory method.
+- [ ] Wire desktop scan completion to append the event after `AuditService.RunAsync` returns.
+- [ ] Run backend tests.
+
+## Task 7: Tune Action Ledger Hook
+
+**Files:**
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs`
+- Optional test seam: backend factory method in `BlackboardEvent.cs`
+
+- [ ] Add a backend test for Tune action-completed event factory using a `TuneActionRecord`.
+- [ ] Run focused test and verify failure.
+- [ ] Implement the factory method.
+- [ ] Wire desktop Tune action completion to append one event per returned action record.
+- [ ] Run backend tests.
+
+## Task 8: Dashboard Status Section
+
+**Files:**
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml`
+- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs`
+
+- [ ] Add a compact storage/status section to the existing single-page dashboard.
+- [ ] Show runtime root, ledger file path, indexed event count if available, and module statuses.
+- [ ] Keep the current single-page shape; do not add navigation.
+- [ ] Build the desktop project.
+
+## Task 9: Verification
+
+**Files:**
+- No new files.
+
+- [ ] Run `dotnet test C:\Users\codyl\Desktop\csiOS\Projects\AppLens\AppLensDesktop.sln`.
+- [ ] Run `dotnet build C:\Users\codyl\Desktop\csiOS\Projects\AppLens\AppLensDesktop.sln`.
+- [ ] Inspect `git diff --stat`.
+- [ ] Confirm no files changed in AppLens-LLM, Oracle, Mailbox, or Stigmergent.
+
+## Self-Review
+
+This plan covers the approved v1 platform foundation: local runtime root, JSONL + SQLite ledger, future-proof schema hooks, manifest/status boundaries, scan/Tune persistence, and current UI shape. It intentionally defers MCP, A2A, full registry, double-handshake, live external jobs, and broader Tune side effects until the ledger and host contracts are stable.
diff --git a/src/AppLens.Backend/AppLens.Backend.csproj b/src/AppLens.Backend/AppLens.Backend.csproj
index dced380..97c6fb0 100644
--- a/src/AppLens.Backend/AppLens.Backend.csproj
+++ b/src/AppLens.Backend/AppLens.Backend.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/AppLens.Backend/BlackboardEvent.cs b/src/AppLens.Backend/BlackboardEvent.cs
new file mode 100644
index 0000000..d783a4d
--- /dev/null
+++ b/src/AppLens.Backend/BlackboardEvent.cs
@@ -0,0 +1,188 @@
+using System.Text.Json.Serialization;
+
+namespace AppLens.Backend;
+
+public sealed class BlackboardEvent
+{
+ public string SchemaVersion { get; init; } = "1.0";
+ public string EventId { get; init; } = $"evt-{Guid.NewGuid():N}";
+ public BlackboardEventType EventType { get; init; }
+ public string ParticipantIdentity { get; init; } = "applens-desktop";
+ public BlackboardParticipantKind ParticipantKind { get; init; } = BlackboardParticipantKind.FirstPartyModule;
+ public string ModuleId { get; init; } = "";
+ public string AppId { get; init; } = "applens-desktop";
+ public string ScopeId { get; init; } = "local_workstation";
+ public string? GrantId { get; init; }
+ public string CorrelationId { get; init; } = $"corr-{Guid.NewGuid():N}";
+ public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now;
+ public BlackboardLifecycleState LifecycleState { get; init; } = BlackboardLifecycleState.Created;
+ public BlackboardDataState DataState { get; init; } = BlackboardDataState.Validated;
+ public BlackboardPrivacyState PrivacyState { get; init; } = BlackboardPrivacyState.RawPrivate;
+ public string Summary { get; init; } = "";
+ public Dictionary Payload { get; init; } = [];
+ public List ArtifactRefs { get; init; } = [];
+ public BlackboardProvenance Provenance { get; init; } = new();
+ public BlackboardPolicyResult? PolicyResult { get; init; }
+ public string? CanonicalHash { get; init; }
+ public string? SignerKeyId { get; init; }
+ public string? Signature { get; init; }
+
+ public static BlackboardEvent ForScanCompleted(AuditSnapshot snapshot, string? correlationId = null) =>
+ new()
+ {
+ EventType = BlackboardEventType.ScanCompleted,
+ ModuleId = "report",
+ CorrelationId = correlationId ?? $"corr-scan-{Guid.NewGuid():N}",
+ CreatedAt = snapshot.GeneratedAt,
+ Summary = $"Scan completed with readiness {snapshot.Readiness.Score}/100 {snapshot.Readiness.Rating}.",
+ Payload = new Dictionary
+ {
+ ["readiness_score"] = snapshot.Readiness.Score.ToString(),
+ ["readiness_rating"] = snapshot.Readiness.Rating,
+ ["finding_count"] = snapshot.Findings.Count.ToString(),
+ ["tune_plan_count"] = snapshot.TunePlan.Count.ToString(),
+ ["review_count"] = snapshot.Readiness.ReviewCount.ToString(),
+ ["optional_count"] = snapshot.Readiness.OptionalCount.ToString(),
+ ["admin_required_count"] = snapshot.Readiness.AdminRequiredCount.ToString()
+ },
+ Provenance = new BlackboardProvenance
+ {
+ Source = "AuditService.RunAsync",
+ Tool = "AppLens",
+ ToolVersion = typeof(AuditService).Assembly.GetName().Version?.ToString() ?? "unknown"
+ }
+ };
+
+ public static BlackboardEvent ForTuneAction(TuneActionRecord action, string? correlationId = null)
+ {
+ var allowed = action.Status == TuneActionStatus.Succeeded;
+ return new BlackboardEvent
+ {
+ EventType = BlackboardEventType.TuneActionCompleted,
+ ModuleId = "tune",
+ CorrelationId = correlationId ?? $"corr-tune-{Guid.NewGuid():N}",
+ 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
+ {
+ ["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 = "TuneActionExecutor.ExecuteAsync",
+ Tool = "AppLens-Tune",
+ ToolVersion = typeof(TuneActionExecutor).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-v1"
+ }
+ };
+ }
+}
+
+public sealed class BlackboardArtifactRef
+{
+ public string Path { get; init; } = "";
+ public string Kind { get; init; } = "";
+ public string PrivacyState { get; init; } = "raw_private";
+}
+
+public sealed class BlackboardProvenance
+{
+ public string Source { get; init; } = "";
+ public string Tool { get; init; } = "";
+ public string ToolVersion { get; init; } = "";
+ public string Command { get; init; } = "";
+ public string NodeId { get; init; } = Environment.MachineName;
+ public string ModelId { get; init; } = "";
+ public List InputRefs { get; init; } = [];
+}
+
+public sealed class BlackboardPolicyResult
+{
+ public bool Allowed { get; init; }
+ public string BlockedReason { get; init; } = "";
+ public bool RequiresApproval { get; init; }
+ public bool RequiresAdmin { get; init; }
+ public string RiskLevel { get; init; } = "low";
+ public string PolicyId { get; init; } = "";
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BlackboardEventType
+{
+ CapabilityObserved,
+ EvidenceCaptured,
+ ReportGenerated,
+ ActionProposed,
+ ActionApproved,
+ ActionExecuted,
+ VerificationRecorded,
+ ModelRunRecorded,
+ BlockedState,
+ ScanCompleted,
+ TuneActionCompleted
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BlackboardParticipantKind
+{
+ FirstPartyModule,
+ NodeAgent,
+ McpApp,
+ Connector,
+ HumanOperator,
+ System,
+ ThirdPartyAgent
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BlackboardLifecycleState
+{
+ Created,
+ Imported,
+ Validated,
+ UsedInReport,
+ Invalidated,
+ Expired
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BlackboardDataState
+{
+ Validated,
+ Fixture,
+ Stale,
+ Blocked,
+ Invalidated,
+ Unavailable
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BlackboardPrivacyState
+{
+ RawPrivate,
+ Sanitized,
+ Exportable
+}
diff --git a/src/AppLens.Backend/BlackboardStore.cs b/src/AppLens.Backend/BlackboardStore.cs
new file mode 100644
index 0000000..63e414f
--- /dev/null
+++ b/src/AppLens.Backend/BlackboardStore.cs
@@ -0,0 +1,180 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Data.Sqlite;
+
+namespace AppLens.Backend;
+
+public interface IBlackboardStore
+{
+ Task AppendAsync(BlackboardEvent evt, CancellationToken cancellationToken = default);
+
+ Task> ReadAllAsync(CancellationToken cancellationToken = default);
+
+ Task GetIndexedEventCountAsync(CancellationToken cancellationToken = default);
+}
+
+public sealed class BlackboardStore : IBlackboardStore
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ private readonly AppLensRuntimeStorage _storage;
+
+ public BlackboardStore(AppLensRuntimeStorage storage)
+ {
+ _storage = storage;
+ }
+
+ public async Task AppendAsync(BlackboardEvent evt, CancellationToken cancellationToken = default)
+ {
+ Directory.CreateDirectory(_storage.LedgerDirectory);
+ await File.AppendAllTextAsync(
+ _storage.EventsJsonl,
+ SerializeEvent(evt) + Environment.NewLine,
+ cancellationToken).ConfigureAwait(false);
+
+ await IndexAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task> ReadAllAsync(CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(_storage.EventsJsonl))
+ {
+ return [];
+ }
+
+ var events = new List();
+ foreach (var line in await File.ReadAllLinesAsync(_storage.EventsJsonl, cancellationToken).ConfigureAwait(false))
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ try
+ {
+ var evt = JsonSerializer.Deserialize(line, SerializerOptions);
+ if (evt is not null)
+ {
+ events.Add(evt);
+ }
+ }
+ catch (JsonException)
+ {
+ // Keep the append log readable even if a partial write or manual edit corrupts one row.
+ }
+ }
+
+ return events;
+ }
+
+ public static string SerializeEvent(BlackboardEvent evt) =>
+ JsonSerializer.Serialize(evt, SerializerOptions);
+
+ public async Task GetIndexedEventCountAsync(CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(_storage.IndexSqlite))
+ {
+ return 0;
+ }
+
+ using var connection = new SqliteConnection($"Data Source={_storage.IndexSqlite};Pooling=False");
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
+
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT COUNT(*) FROM events";
+ var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ return Convert.ToInt32(result);
+ }
+
+ private async Task IndexAsync(BlackboardEvent evt, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(_storage.LedgerDirectory);
+ using var connection = new SqliteConnection($"Data Source={_storage.IndexSqlite};Pooling=False");
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
+
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT OR REPLACE INTO events (
+ event_id,
+ schema_version,
+ event_type,
+ participant_identity,
+ participant_kind,
+ module_id,
+ app_id,
+ scope_id,
+ correlation_id,
+ created_at,
+ lifecycle_state,
+ data_state,
+ privacy_state,
+ summary
+ )
+ VALUES (
+ $event_id,
+ $schema_version,
+ $event_type,
+ $participant_identity,
+ $participant_kind,
+ $module_id,
+ $app_id,
+ $scope_id,
+ $correlation_id,
+ $created_at,
+ $lifecycle_state,
+ $data_state,
+ $privacy_state,
+ $summary
+ )
+ """;
+ command.Parameters.AddWithValue("$event_id", evt.EventId);
+ command.Parameters.AddWithValue("$schema_version", evt.SchemaVersion);
+ command.Parameters.AddWithValue("$event_type", evt.EventType.ToString());
+ command.Parameters.AddWithValue("$participant_identity", evt.ParticipantIdentity);
+ command.Parameters.AddWithValue("$participant_kind", evt.ParticipantKind.ToString());
+ command.Parameters.AddWithValue("$module_id", evt.ModuleId);
+ command.Parameters.AddWithValue("$app_id", evt.AppId);
+ command.Parameters.AddWithValue("$scope_id", evt.ScopeId);
+ command.Parameters.AddWithValue("$correlation_id", evt.CorrelationId);
+ command.Parameters.AddWithValue("$created_at", evt.CreatedAt.ToString("O"));
+ command.Parameters.AddWithValue("$lifecycle_state", evt.LifecycleState.ToString());
+ command.Parameters.AddWithValue("$data_state", evt.DataState.ToString());
+ command.Parameters.AddWithValue("$privacy_state", evt.PrivacyState.ToString());
+ command.Parameters.AddWithValue("$summary", evt.Summary);
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ CREATE TABLE IF NOT EXISTS events (
+ event_id TEXT PRIMARY KEY,
+ schema_version TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ participant_identity TEXT NOT NULL,
+ participant_kind TEXT NOT NULL,
+ module_id TEXT NOT NULL,
+ app_id TEXT NOT NULL,
+ scope_id TEXT NOT NULL,
+ correlation_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ lifecycle_state TEXT NOT NULL,
+ data_state TEXT NOT NULL,
+ privacy_state TEXT NOT NULL,
+ summary TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_events_module_created ON events(module_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_events_type_created ON events(event_type, created_at);
+ CREATE INDEX IF NOT EXISTS idx_events_data_state ON events(data_state);
+ """;
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/AppLens.Backend/Models.cs b/src/AppLens.Backend/Models.cs
index e140fdf..164d262 100644
--- a/src/AppLens.Backend/Models.cs
+++ b/src/AppLens.Backend/Models.cs
@@ -12,6 +12,7 @@ public sealed class AuditSnapshot
public ReadinessSummary Readiness { get; init; } = new();
public List Findings { get; init; } = [];
public List TunePlan { get; init; } = [];
+ public List ActionLog { get; init; } = [];
public List ProbeStatuses { get; init; } = [];
}
@@ -178,9 +179,25 @@ public sealed class ProposedAction
public ProposedActionKind Kind { get; init; } = ProposedActionKind.None;
public TunePlanExecutionState ExecutionState { get; init; } = TunePlanExecutionState.ReadOnlyOnly;
public string Target { get; init; } = "";
+ public string TargetContext { get; init; } = "";
public string Description { get; init; } = "";
}
+public sealed class TuneActionRecord
+{
+ public string Id { get; init; } = "";
+ public string PlanItemId { get; init; } = "";
+ public ProposedActionKind Kind { get; init; } = ProposedActionKind.None;
+ public TuneActionStatus Status { get; init; } = TuneActionStatus.Blocked;
+ public string Target { get; init; } = "";
+ public string Message { get; init; } = "";
+ public string BackupDetail { get; init; } = "";
+ public string VerificationStep { get; init; } = "";
+ public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.Now;
+ public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.Now;
+ public bool RequiresAdmin { get; init; }
+}
+
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TunePlanCategory
{
@@ -206,11 +223,13 @@ public enum ProposedActionKind
{
None,
DisableStartup,
+ EnableStartup,
SetServiceManual,
StopService,
ClearRebuildableCache,
UninstallApplication,
MoveRepo,
+ RunLocalAiBenchmark,
ManualReview
}
@@ -220,7 +239,22 @@ public enum TunePlanExecutionState
ReadOnlyOnly,
FutureUserConsent,
FutureAdminRequired,
- Unsupported
+ Unsupported,
+ ReadyToRun,
+ RequiresUserConsent,
+ RequiresAdmin,
+ Completed,
+ Failed,
+ RolledBack
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum TuneActionStatus
+{
+ Succeeded,
+ Blocked,
+ Failed,
+ RolledBack
}
[JsonConverter(typeof(JsonStringEnumConverter))]
diff --git a/src/AppLens.Backend/ModuleManifest.cs b/src/AppLens.Backend/ModuleManifest.cs
new file mode 100644
index 0000000..7843c1c
--- /dev/null
+++ b/src/AppLens.Backend/ModuleManifest.cs
@@ -0,0 +1,35 @@
+namespace AppLens.Backend;
+
+public sealed class ModuleManifest
+{
+ public string AppId { get; init; } = "";
+ public string DisplayName { get; init; } = "";
+ public string ModuleId { get; init; } = "";
+ public string Version { get; init; } = "1.0";
+ public string RiskLevel { get; init; } = "low";
+ 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 sealed class ModuleStatus
+{
+ public string ModuleId { get; init; } = "";
+ public string AppId { get; init; } = "";
+ public string DisplayName { get; init; } = "";
+ public ModuleAvailability Availability { get; init; } = ModuleAvailability.Blocked;
+ public string Reason { get; init; } = "";
+ public string ExpectedSource { get; init; } = "";
+ public string NextAction { get; init; } = "";
+}
+
+public enum ModuleAvailability
+{
+ Available,
+ Blocked,
+ Unavailable
+}
diff --git a/src/AppLens.Backend/ModuleStatusService.cs b/src/AppLens.Backend/ModuleStatusService.cs
new file mode 100644
index 0000000..b340e03
--- /dev/null
+++ b/src/AppLens.Backend/ModuleStatusService.cs
@@ -0,0 +1,191 @@
+namespace AppLens.Backend;
+
+public sealed class ModuleStatusPaths
+{
+ public string AppLensLlmRoot { get; init; } = ConfiguredPath("APPLENS_LLM_ROOT", DefaultProjectPath("AppLens-LLM"));
+ public string OracleRoot { get; init; } = DefaultOraclePath();
+ public string MailboxRoot { get; init; } = ConfiguredPath("APPLENS_MAILBOX_ROOT", DefaultProjectPath("Mailbox"));
+ public string AppLensZeroRoot { get; init; } = ConfiguredPath("APPLENS_ZERO_ROOT", DefaultProjectPath("AppLens-Zero"));
+
+ private static string DefaultProjectPath(string projectName)
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Desktop",
+ "csiOS",
+ "Projects",
+ projectName);
+ }
+
+ private static string DefaultOraclePath()
+ {
+ return ConfiguredPath(
+ "APPLENS_ORACLE_ROOT",
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Desktop",
+ "csiOS",
+ "Oracle"));
+ }
+
+ private static string ConfiguredPath(string environmentVariable, string fallback)
+ {
+ var configured = Environment.GetEnvironmentVariable(environmentVariable);
+ if (!string.IsNullOrWhiteSpace(configured))
+ {
+ return configured;
+ }
+
+ return fallback;
+ }
+}
+
+public sealed class ModuleStatusService
+{
+ private readonly ModuleStatusPaths _paths;
+
+ public ModuleStatusService(ModuleStatusPaths? paths = null)
+ {
+ _paths = paths ?? new ModuleStatusPaths();
+ }
+
+ public List GetStatuses() =>
+ [
+ LlmStatus(),
+ OracleStatus(),
+ MailboxStatus(),
+ ZeroStatus()
+ ];
+
+ public List GetManifests() =>
+ [
+ new ModuleManifest
+ {
+ AppId = "applens-llm",
+ DisplayName = "AppLens-LLM",
+ ModuleId = "llm",
+ 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"
+ },
+ new ModuleManifest
+ {
+ AppId = "oracle-workbench",
+ DisplayName = "Oracle",
+ ModuleId = "oracle",
+ 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"
+ },
+ new ModuleManifest
+ {
+ AppId = "mailbox",
+ DisplayName = "Mailbox",
+ ModuleId = "mailbox",
+ 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"
+ },
+ new ModuleManifest
+ {
+ AppId = "applens-zero",
+ DisplayName = "AppLens-Zero",
+ ModuleId = "zero",
+ 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"
+ }
+ ];
+
+ private ModuleStatus LlmStatus()
+ {
+ var pyproject = Path.Combine(_paths.AppLensLlmRoot, "pyproject.toml");
+ var cli = Path.Combine(_paths.AppLensLlmRoot, "src", "applens_llm", "cli.py");
+ if (!File.Exists(pyproject) || !File.Exists(cli))
+ {
+ return Blocked("llm", "applens-llm", "AppLens-LLM", "AppLens-LLM package path is unavailable.", _paths.AppLensLlmRoot);
+ }
+
+ return Available("llm", "applens-llm", "AppLens-LLM", "Package and CLI source detected.");
+ }
+
+ private ModuleStatus OracleStatus()
+ {
+ var pyproject = Path.Combine(_paths.OracleRoot, "pyproject.toml");
+ if (!File.Exists(pyproject))
+ {
+ return Blocked("oracle", "oracle-workbench", "Oracle", "Oracle repo or pyproject.toml is unavailable.", _paths.OracleRoot);
+ }
+
+ return Available("oracle", "oracle-workbench", "Oracle", "Repo and CLI project detected.");
+ }
+
+ private ModuleStatus MailboxStatus()
+ {
+ var config = Path.Combine(_paths.MailboxRoot, "config.toml");
+ var server = Path.Combine(_paths.MailboxRoot, "mailbox", "server.py");
+ if (!File.Exists(server))
+ {
+ return Blocked("mailbox", "mailbox", "Mailbox", "Mailbox server source is unavailable.", _paths.MailboxRoot);
+ }
+
+ if (!File.Exists(config))
+ {
+ return Blocked("mailbox", "mailbox", "Mailbox", "Mailbox config.toml is unavailable.", _paths.MailboxRoot);
+ }
+
+ return Available("mailbox", "mailbox", "Mailbox", "Config and server source detected.");
+ }
+
+ private ModuleStatus ZeroStatus()
+ {
+ var docs = Path.Combine(_paths.AppLensZeroRoot, "docs", "AppLens-Platform-Host-Design.md");
+ var raw = Path.Combine(_paths.AppLensZeroRoot, "raw");
+ if (!File.Exists(docs) || !Directory.Exists(raw))
+ {
+ return Blocked("zero", "applens-zero", "AppLens-Zero", "Zero docs or import raw folder is unavailable.", _paths.AppLensZeroRoot);
+ }
+
+ return Available("zero", "applens-zero", "AppLens-Zero", "Docs and raw import folder detected.");
+ }
+
+ private static ModuleStatus Available(string moduleId, string appId, string displayName, string reason) =>
+ new()
+ {
+ ModuleId = moduleId,
+ AppId = appId,
+ DisplayName = displayName,
+ Availability = ModuleAvailability.Available,
+ Reason = reason,
+ ExpectedSource = "",
+ NextAction = "Review module status in AppLens."
+ };
+
+ private static ModuleStatus Blocked(string moduleId, string appId, string displayName, string reason, string expectedSource) =>
+ new()
+ {
+ ModuleId = moduleId,
+ AppId = appId,
+ DisplayName = displayName,
+ Availability = ModuleAvailability.Blocked,
+ Reason = reason,
+ ExpectedSource = expectedSource,
+ NextAction = "Connect or configure the local app path before running jobs."
+ };
+}
diff --git a/src/AppLens.Backend/RuntimeStorage.cs b/src/AppLens.Backend/RuntimeStorage.cs
new file mode 100644
index 0000000..1acb357
--- /dev/null
+++ b/src/AppLens.Backend/RuntimeStorage.cs
@@ -0,0 +1,36 @@
+namespace AppLens.Backend;
+
+public sealed class AppLensRuntimeStorage
+{
+ private AppLensRuntimeStorage(string root)
+ {
+ Root = Path.GetFullPath(root);
+ LedgerDirectory = Path.Combine(Root, "ledger");
+ EventsJsonl = Path.Combine(LedgerDirectory, "events.jsonl");
+ IndexSqlite = Path.Combine(LedgerDirectory, "index.sqlite");
+ }
+
+ public string Root { get; }
+
+ public string LedgerDirectory { get; }
+
+ public string EventsJsonl { get; }
+
+ public string IndexSqlite { get; }
+
+ public static AppLensRuntimeStorage Default()
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return FromRoot(Path.Combine(localAppData, "AppLens"));
+ }
+
+ public static AppLensRuntimeStorage FromRoot(string root)
+ {
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ throw new ArgumentException("Runtime storage root is required.", nameof(root));
+ }
+
+ return new AppLensRuntimeStorage(root);
+ }
+}
diff --git a/src/AppLens.Backend/TuneActionExecutor.cs b/src/AppLens.Backend/TuneActionExecutor.cs
new file mode 100644
index 0000000..53e9c2c
--- /dev/null
+++ b/src/AppLens.Backend/TuneActionExecutor.cs
@@ -0,0 +1,383 @@
+using System.Diagnostics;
+using System.Security.Principal;
+using System.ServiceProcess;
+using Microsoft.Win32;
+
+namespace AppLens.Backend;
+
+public sealed class TuneActionExecutor
+{
+ private readonly ITuneActionRuntime _runtime;
+
+ public TuneActionExecutor()
+ : this(new WindowsTuneActionRuntime())
+ {
+ }
+
+ public TuneActionExecutor(ITuneActionRuntime runtime)
+ {
+ _runtime = runtime;
+ }
+
+ public async Task ExecuteAsync(
+ TunePlanItem item,
+ bool userApproved,
+ CancellationToken cancellationToken = default)
+ {
+ var startedAt = DateTimeOffset.Now;
+ var action = item.ProposedAction;
+
+ if (!IsExecutable(action.ExecutionState, action.Kind))
+ {
+ return Record(item, TuneActionStatus.Blocked, startedAt, "This tune-plan item is not executable in this build.");
+ }
+
+ if (!userApproved)
+ {
+ return Record(item, TuneActionStatus.Blocked, startedAt, "Action blocked because Tune consent was not granted.");
+ }
+
+ if ((item.RequiresAdmin || RequiresAdmin(action.ExecutionState)) && !_runtime.IsAdministrator)
+ {
+ return Record(item, TuneActionStatus.Blocked, startedAt, "Action requires an elevated AppLens-Tune session.");
+ }
+
+ try
+ {
+ return action.Kind switch
+ {
+ ProposedActionKind.ClearRebuildableCache => await ClearRebuildableCacheAsync(item, startedAt, cancellationToken)
+ .ConfigureAwait(false),
+ ProposedActionKind.SetServiceManual => await SetServiceManualAsync(item, startedAt, cancellationToken)
+ .ConfigureAwait(false),
+ ProposedActionKind.StopService => await StopServiceAsync(item, startedAt, cancellationToken)
+ .ConfigureAwait(false),
+ ProposedActionKind.DisableStartup => await DisableStartupAsync(item, startedAt, cancellationToken)
+ .ConfigureAwait(false),
+ ProposedActionKind.EnableStartup => await EnableStartupAsync(item, startedAt, cancellationToken)
+ .ConfigureAwait(false),
+ _ => Record(item, TuneActionStatus.Blocked, startedAt, "This action kind is not executable in this build.")
+ };
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return Record(item, TuneActionStatus.Failed, startedAt, ex.Message);
+ }
+ }
+
+ private async Task ClearRebuildableCacheAsync(
+ TunePlanItem item,
+ DateTimeOffset startedAt,
+ CancellationToken cancellationToken)
+ {
+ var path = item.ProposedAction.Target;
+ if (!ActionTargetPolicy.IsClearableCacheTarget(path))
+ {
+ return Record(item, TuneActionStatus.Blocked, startedAt, "Cache cleanup target is outside AppLens-Tune's allowlist.");
+ }
+
+ var bytesDeleted = await _runtime.ClearDirectoryContentsAsync(path, cancellationToken).ConfigureAwait(false);
+ return Record(item, TuneActionStatus.Succeeded, startedAt, $"Cleared {Formatting.Size(bytesDeleted)} from rebuildable cache contents.");
+ }
+
+ private async Task SetServiceManualAsync(
+ TunePlanItem item,
+ DateTimeOffset startedAt,
+ CancellationToken cancellationToken)
+ {
+ await _runtime.SetServiceStartModeManualAsync(item.ProposedAction.Target, cancellationToken).ConfigureAwait(false);
+ return Record(item, TuneActionStatus.Succeeded, startedAt, "Service start mode was set to Manual.");
+ }
+
+ private async Task StopServiceAsync(
+ TunePlanItem item,
+ DateTimeOffset startedAt,
+ CancellationToken cancellationToken)
+ {
+ await _runtime.StopServiceAsync(item.ProposedAction.Target, cancellationToken).ConfigureAwait(false);
+ return Record(item, TuneActionStatus.Succeeded, startedAt, "Service was stopped.");
+ }
+
+ private async Task DisableStartupAsync(
+ TunePlanItem item,
+ DateTimeOffset startedAt,
+ CancellationToken cancellationToken)
+ {
+ await _runtime.DisableStartupEntryAsync(
+ item.ProposedAction.Target,
+ item.ProposedAction.TargetContext,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return Record(item, TuneActionStatus.Succeeded, startedAt, "Startup entry was disabled.");
+ }
+
+ private async Task EnableStartupAsync(
+ TunePlanItem item,
+ DateTimeOffset startedAt,
+ CancellationToken cancellationToken)
+ {
+ await _runtime.EnableStartupEntryAsync(
+ item.ProposedAction.Target,
+ item.ProposedAction.TargetContext,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return Record(item, TuneActionStatus.Succeeded, startedAt, "Startup entry was enabled.");
+ }
+
+ private static TuneActionRecord Record(
+ TunePlanItem item,
+ TuneActionStatus status,
+ DateTimeOffset startedAt,
+ string message)
+ {
+ return new TuneActionRecord
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ PlanItemId = item.Id,
+ Kind = item.ProposedAction.Kind,
+ Status = status,
+ Target = item.ProposedAction.Target,
+ Message = message,
+ BackupDetail = item.BackupPlan,
+ VerificationStep = item.VerificationStep,
+ StartedAt = startedAt,
+ CompletedAt = DateTimeOffset.Now,
+ RequiresAdmin = item.RequiresAdmin || RequiresAdmin(item.ProposedAction.ExecutionState)
+ };
+ }
+
+ private static bool IsExecutable(TunePlanExecutionState state, ProposedActionKind kind) =>
+ kind is not ProposedActionKind.None and not ProposedActionKind.ManualReview and not ProposedActionKind.MoveRepo and not ProposedActionKind.UninstallApplication &&
+ state is TunePlanExecutionState.ReadyToRun
+ or TunePlanExecutionState.RequiresUserConsent
+ or TunePlanExecutionState.RequiresAdmin;
+
+ private static bool RequiresAdmin(TunePlanExecutionState state) =>
+ state is TunePlanExecutionState.RequiresAdmin or TunePlanExecutionState.FutureAdminRequired;
+}
+
+public interface ITuneActionRuntime
+{
+ bool IsAdministrator { get; }
+
+ Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default);
+
+ Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default);
+
+ Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default);
+
+ Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default);
+
+ Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default);
+}
+
+public sealed class WindowsTuneActionRuntime : ITuneActionRuntime
+{
+ public bool IsAdministrator
+ {
+ get
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
+ }
+ }
+
+ public Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default) =>
+ Task.Run(() =>
+ {
+ if (!Directory.Exists(path))
+ {
+ return 0L;
+ }
+
+ var bytes = 0L;
+ foreach (var file in Directory.EnumerateFiles(path))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ bytes += new FileInfo(file).Length;
+ File.Delete(file);
+ }
+ catch
+ {
+ // Keep cleanup best-effort; inaccessible files should not abort the whole tune run.
+ }
+ }
+
+ foreach (var directory in Directory.EnumerateDirectories(path))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var info = new DirectoryInfo(directory);
+ if ((info.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
+ {
+ continue;
+ }
+
+ bytes += DirectorySize(directory, cancellationToken);
+ Directory.Delete(directory, recursive: true);
+ }
+ catch
+ {
+ // Keep cleanup best-effort for locked cache trees.
+ }
+ }
+
+ return bytes;
+ }, cancellationToken);
+
+ public async Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(serviceName))
+ {
+ throw new InvalidOperationException("Service name is missing.");
+ }
+
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo("sc.exe")
+ {
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true
+ }
+ };
+ process.StartInfo.ArgumentList.Add("config");
+ process.StartInfo.ArgumentList.Add(serviceName);
+ process.StartInfo.ArgumentList.Add("start=");
+ process.StartInfo.ArgumentList.Add("demand");
+
+ process.Start();
+ var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? output.Trim() : error.Trim());
+ }
+ }
+
+ public Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default) =>
+ Task.Run(() =>
+ {
+ using var service = new ServiceController(serviceName);
+ if (service.Status == ServiceControllerStatus.Stopped)
+ {
+ return;
+ }
+
+ if (!service.CanStop)
+ {
+ throw new InvalidOperationException("Service does not report that it can be stopped.");
+ }
+
+ service.Stop(stopDependentServices: false);
+ service.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
+ }, cancellationToken);
+
+ public Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) =>
+ SetStartupEntryStateAsync(entryName, location, enabled: false, cancellationToken);
+
+ public Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) =>
+ SetStartupEntryStateAsync(entryName, location, enabled: true, cancellationToken);
+
+ private static Task SetStartupEntryStateAsync(string entryName, string location, bool enabled, CancellationToken cancellationToken = default) =>
+ Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (string.IsNullOrWhiteSpace(entryName))
+ {
+ throw new InvalidOperationException("Startup entry name is missing.");
+ }
+
+ var root = location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase)
+ ? Registry.LocalMachine
+ : Registry.CurrentUser;
+
+ foreach (var path in new[]
+ {
+ @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run",
+ @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32"
+ })
+ {
+ using var key = root.OpenSubKey(path, writable: true);
+ if (key?.GetValue(entryName) is not byte[] bytes || bytes.Length == 0)
+ {
+ continue;
+ }
+
+ bytes[0] = enabled ? (byte)0x02 : (byte)0x03;
+ key.SetValue(entryName, bytes, RegistryValueKind.Binary);
+ return;
+ }
+
+ throw new InvalidOperationException("Startup approval entry was not found.");
+ }, cancellationToken);
+
+ private static long DirectorySize(string root, CancellationToken cancellationToken)
+ {
+ var bytes = 0L;
+ foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ bytes += new FileInfo(file).Length;
+ }
+ catch
+ {
+ // Ignore inaccessible cache files while estimating cleanup.
+ }
+ }
+
+ return bytes;
+ }
+}
+
+public static class ActionTargetPolicy
+{
+ public static bool IsClearableCacheTarget(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var fullPath = Normalize(path);
+ return ClearableCacheRoots().Any(root => IsSameOrChild(fullPath, Normalize(root)));
+ }
+
+ private static IEnumerable ClearableCacheRoots()
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
+
+ yield return Path.GetTempPath();
+ yield return Path.Combine(localAppData, "Temp");
+ yield return Path.Combine(localAppData, "pip", "Cache");
+ yield return Path.Combine(localAppData, "NuGet", "Cache");
+ yield return Path.Combine(localAppData, "uv", "cache");
+ yield return Path.Combine(localAppData, "Yarn", "Cache");
+ yield return Path.Combine(appData, "npm-cache");
+ yield return Path.Combine(appData, "Code", "Cache");
+ yield return Path.Combine(programData, "chocolatey", "lib-bad");
+ }
+
+ private static bool IsSameOrChild(string path, string root)
+ {
+ var normalizedPath = EnsureTrailingSeparator(path);
+ var normalizedRoot = EnsureTrailingSeparator(root);
+ return normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string Normalize(string path) =>
+ Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));
+
+ private static string EnsureTrailingSeparator(string path) =>
+ path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar;
+}
diff --git a/src/AppLens.Backend/TunePlanBuilder.cs b/src/AppLens.Backend/TunePlanBuilder.cs
index 7c4773e..9f614ed 100644
--- a/src/AppLens.Backend/TunePlanBuilder.cs
+++ b/src/AppLens.Backend/TunePlanBuilder.cs
@@ -19,7 +19,9 @@ public List Build(AuditSnapshot snapshot)
}
AddStartupPlanItems(snapshot, items);
+ AddStartupEntryActionItems(snapshot, items);
AddServicePlanItems(snapshot, items);
+ AddStoragePlanItems(snapshot, items);
AddLocalAiPlanItem(snapshot, items);
return items
@@ -42,9 +44,9 @@ public List Build(AuditSnapshot snapshot)
Risk = TunePlanRisk.Info,
Title = finding.Title,
Evidence = finding.Detail,
- Guidance = "Keep AppLens-Tune in read-only mode for V1. Exports remain user-controlled.",
- BackupPlan = "No backup needed because no system changes are made.",
- VerificationStep = "Confirm the exported report says the scan was read-only.",
+ Guidance = "Keep scan evidence local and user-controlled. AppLens observes first; AppLens-Tune acts only after explicit consent.",
+ BackupPlan = "No backup needed for this privacy finding.",
+ VerificationStep = "Confirm exported reports redact user, machine, and profile-path details unless raw details are selected.",
ProposedAction = new ProposedAction
{
Kind = ProposedActionKind.None,
@@ -84,9 +86,9 @@ private static TunePlanItem StartupFinding(Finding finding)
ProposedAction = new ProposedAction
{
Kind = isStable ? ProposedActionKind.None : ProposedActionKind.DisableStartup,
- ExecutionState = isStable ? TunePlanExecutionState.ReadOnlyOnly : TunePlanExecutionState.FutureUserConsent,
+ ExecutionState = isStable ? TunePlanExecutionState.ReadOnlyOnly : TunePlanExecutionState.RequiresUserConsent,
Target = finding.Title,
- Description = isStable ? "No action." : "Future approved action: disable the startup entry without uninstalling the app."
+ Description = isStable ? "No action." : "Approved action: disable the startup entry without uninstalling the app."
}
};
}
@@ -100,16 +102,16 @@ private static TunePlanItem ServiceFinding(Finding finding)
Risk = TunePlanRisk.Medium,
Title = finding.Title,
Evidence = finding.Detail,
- Guidance = "Review the related service or vendor utility. V1 does not stop services or change start modes.",
+ Guidance = "Review the related service or vendor utility before changing its baseline start behavior.",
RequiresAdmin = true,
BackupPlan = "Before any future action, record service name, display name, current status, and start type.",
VerificationStep = "After any future approved change, reboot or sign out and confirm the service state and related processes.",
ProposedAction = new ProposedAction
{
Kind = ProposedActionKind.SetServiceManual,
- ExecutionState = TunePlanExecutionState.FutureAdminRequired,
+ ExecutionState = TunePlanExecutionState.RequiresAdmin,
Target = finding.Title,
- Description = "Future approved action: change selected non-critical services to Manual when an admin permits it."
+ Description = "Approved admin action: change selected non-critical services to Manual."
}
};
}
@@ -129,9 +131,9 @@ private static TunePlanItem StorageFinding(Finding finding)
ProposedAction = new ProposedAction
{
Kind = ProposedActionKind.ClearRebuildableCache,
- ExecutionState = TunePlanExecutionState.FutureUserConsent,
+ ExecutionState = TunePlanExecutionState.RequiresUserConsent,
Target = finding.Title,
- Description = "Future approved action: clear only confirmed rebuildable cache locations."
+ Description = "Approved action: clear only confirmed rebuildable cache locations."
}
};
}
@@ -196,17 +198,74 @@ private static void AddStartupPlanItems(AuditSnapshot snapshot, List items)
+ {
+ foreach (var entry in snapshot.Tune.StartupEntries.Where(IsSupportedStartupDisableAction))
+ {
+ var requiresAdmin = entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase);
+ items.Add(new TunePlanItem
+ {
+ Id = StableId("startup-entry", entry.Name, entry.Location),
+ Category = requiresAdmin ? TunePlanCategory.AdminRequired : TunePlanCategory.UserChoice,
+ Risk = TunePlanRisk.Low,
+ Title = $"Disable startup entry: {entry.Name}",
+ Evidence = $"{entry.Name} is {entry.State} from {entry.Location}.",
+ Guidance = "Disable this only when the user confirms the app does not need to start at sign-in.",
+ RequiresAdmin = requiresAdmin,
+ BackupPlan = "Record the startup entry name, command, location, and approval state before disabling it.",
+ VerificationStep = "Re-scan after the next sign-in and confirm the startup entry is disabled.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.DisableStartup,
+ ExecutionState = requiresAdmin
+ ? TunePlanExecutionState.RequiresAdmin
+ : TunePlanExecutionState.RequiresUserConsent,
+ Target = entry.Name,
+ TargetContext = entry.Location,
+ Description = "Approved action: disable this startup entry without uninstalling the app."
+ }
+ });
+ }
+
+ foreach (var entry in snapshot.Tune.StartupEntries.Where(IsSupportedStartupEnableAction))
+ {
+ var requiresAdmin = entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase);
+ items.Add(new TunePlanItem
+ {
+ Id = StableId("startup-entry-enable", entry.Name, entry.Location),
+ Category = requiresAdmin ? TunePlanCategory.AdminRequired : TunePlanCategory.UserChoice,
+ Risk = TunePlanRisk.Low,
+ Title = $"Enable startup entry: {entry.Name}",
+ Evidence = $"{entry.Name} is {entry.State} from {entry.Location}.",
+ Guidance = "Enable this only when the user confirms the app should start at sign-in.",
+ RequiresAdmin = requiresAdmin,
+ BackupPlan = "Record the startup entry name, command, location, and approval state before enabling it.",
+ VerificationStep = "Re-scan after the next sign-in and confirm the startup entry is enabled.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.EnableStartup,
+ ExecutionState = requiresAdmin
+ ? TunePlanExecutionState.RequiresAdmin
+ : TunePlanExecutionState.RequiresUserConsent,
+ Target = entry.Name,
+ TargetContext = entry.Location,
+ Description = "Approved action: enable this startup entry."
+ }
+ });
+ }
+ }
+
private static void AddServicePlanItems(AuditSnapshot snapshot, List items)
{
var automaticReviewServices = snapshot.Tune.Services
@@ -226,14 +285,41 @@ private static void AddServicePlanItems(AuditSnapshot snapshot, List items)
+ {
+ foreach (var hotspot in snapshot.Tune.StorageHotspots.Where(IsClearableCacheHotspot))
+ {
+ items.Add(new TunePlanItem
+ {
+ Id = StableId("clear-cache", hotspot.Path),
+ Category = TunePlanCategory.Optional,
+ Risk = TunePlanRisk.Low,
+ Title = $"Clear rebuildable cache: {hotspot.Location}",
+ Evidence = $"{hotspot.Location} is using {Formatting.Size(hotspot.Bytes)} at {hotspot.Path}.",
+ Guidance = "Clear this only when the user accepts a rebuildable-cache cleanup. AppLens-Tune deletes contents, not the cache root.",
+ BackupPlan = "Record the cache path and measured size before cleanup.",
+ VerificationStep = "Re-scan storage hotspots and confirm the measured cache size changed.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.ClearRebuildableCache,
+ ExecutionState = TunePlanExecutionState.RequiresUserConsent,
+ Target = hotspot.Path,
+ TargetContext = hotspot.Location,
+ Description = "Approved action: clear this rebuildable cache."
}
});
}
@@ -259,17 +345,54 @@ private static void AddLocalAiPlanItem(AuditSnapshot snapshot, List
+ !string.IsNullOrWhiteSpace(hotspot.Path) &&
+ (hotspot.Bytes ?? 0) > 0 &&
+ ClearableCacheLocations.Contains(hotspot.Location, StringComparer.OrdinalIgnoreCase);
+
+ private static readonly string[] ClearableCacheLocations =
+ [
+ @"LocalAppData\Temp",
+ @"LocalAppData\pip\Cache",
+ @"LocalAppData\NuGet\Cache",
+ @"LocalAppData\uv\cache",
+ @"LocalAppData\Yarn\Cache",
+ @"Roaming\npm-cache",
+ @"Roaming\Code\Cache",
+ @"ProgramData\chocolatey\lib-bad"
+ ];
+
private static bool IsEnabled(StartupEntry entry) =>
entry.State.Equals("Enabled", StringComparison.OrdinalIgnoreCase) ||
entry.State.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
+ private static bool IsSupportedStartupDisableAction(StartupEntry entry) =>
+ entry.State.Equals("Enabled", StringComparison.OrdinalIgnoreCase) &&
+ (entry.Location.StartsWith("HKCU", StringComparison.OrdinalIgnoreCase) ||
+ entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase)) &&
+ !ProtectedStartupEntries.Contains(entry.Name, StringComparer.OrdinalIgnoreCase);
+
+ private static bool IsSupportedStartupEnableAction(StartupEntry entry) =>
+ entry.State.Equals("Disabled", StringComparison.OrdinalIgnoreCase) &&
+ (entry.Location.StartsWith("HKCU", StringComparison.OrdinalIgnoreCase) ||
+ entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase)) &&
+ !ProtectedStartupEntries.Contains(entry.Name, StringComparer.OrdinalIgnoreCase);
+
+ private static readonly string[] ProtectedStartupEntries =
+ [
+ "SecurityHealth",
+ "OneDrive"
+ ];
+
private static bool IsReviewableService(string value) =>
value.Contains("ASUS", StringComparison.OrdinalIgnoreCase) ||
value.Contains("GlideX", StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/AppLens.Desktop/MainWindow.xaml b/src/AppLens.Desktop/MainWindow.xaml
index 9ba4aa1..d6e61cf 100644
--- a/src/AppLens.Desktop/MainWindow.xaml
+++ b/src/AppLens.Desktop/MainWindow.xaml
@@ -18,8 +18,8 @@
-
-
+
+
@@ -32,9 +32,9 @@
-
-
-
+
+
+
@@ -104,6 +104,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -148,23 +192,50 @@
-
-
+
+
+
+
+
+
+
-
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AppLens.Desktop/MainWindow.xaml.cs b/src/AppLens.Desktop/MainWindow.xaml.cs
index d9f33a9..b641ee0 100644
--- a/src/AppLens.Desktop/MainWindow.xaml.cs
+++ b/src/AppLens.Desktop/MainWindow.xaml.cs
@@ -9,33 +9,51 @@ public sealed partial class MainWindow : Window
{
private readonly AuditService _auditService = new();
private readonly ReportWriter _reportWriter = new();
+ private readonly TuneActionExecutor _tuneActionExecutor = new();
+ private readonly AppLensRuntimeStorage _runtimeStorage = AppLensRuntimeStorage.Default();
+ private readonly IBlackboardStore _blackboardStore;
+ private readonly ModuleStatusService _moduleStatusService = new();
private CancellationTokenSource? _scanCancellation;
private AuditSnapshot? _snapshot;
+ private List _actionLog = [];
public MainWindow()
{
+ _blackboardStore = new BlackboardStore(_runtimeStorage);
InitializeComponent();
ExtendsContentIntoTitleBar = false;
SetStatus("Ready");
+ RuntimeRootText.Text = _runtimeStorage.Root;
+ LedgerPathText.Text = _runtimeStorage.EventsJsonl;
+ _ = RefreshPlatformStatusAsync();
}
private async void RunScan_Click(object sender, RoutedEventArgs e)
{
if (ConsentCheckBox.IsChecked != true)
{
- await ShowDialogAsync("Consent required", "Please confirm that you understand this is a read-only local scan.");
+ await ShowDialogAsync("Consent required", "Please confirm that you understand AppLens scans locally and Tune actions require separate approval.");
return;
}
+ await RunScanAsync(preserveActionLog: false);
+ }
+
+ private async Task RunScanAsync(bool preserveActionLog)
+ {
+ var existingActionLog = preserveActionLog ? _actionLog.ToList() : [];
_scanCancellation = new CancellationTokenSource();
SetBusy(true);
SetStatus("Scanning...");
try
{
- _snapshot = await _auditService.RunAsync(_scanCancellation.Token);
+ var snapshot = await _auditService.RunAsync(_scanCancellation.Token);
+ _snapshot = WithActionLog(snapshot, existingActionLog);
+ _actionLog = _snapshot.ActionLog.ToList();
+ var ledgerRecorded = await AppendLedgerEventAsync(BlackboardEvent.ForScanCompleted(_snapshot));
RenderSnapshot(_snapshot);
- SetStatus("Scan complete");
+ SetStatus(ledgerRecorded ? "Scan complete" : "Scan complete; ledger write failed");
}
catch (OperationCanceledException)
{
@@ -88,6 +106,112 @@ private async void ExportBundle_Click(object sender, RoutedEventArgs e)
await ShowDialogAsync("Report bundle exported", $"Reports were saved to:\n{directory}");
}
+ private async void ApplyTuneActions_Click(object sender, RoutedEventArgs e)
+ {
+ if (_snapshot is null)
+ {
+ return;
+ }
+
+ if (TuneConsentCheckBox.IsChecked != true)
+ {
+ await ShowDialogAsync("Tune approval required", "Select the Tune approval checkbox before running AppLens-Tune actions.");
+ return;
+ }
+
+ var selectedItems = TunePlanList.SelectedItems
+ .OfType()
+ .ToList();
+ if (selectedItems.Count == 0)
+ {
+ await ShowDialogAsync("No actions selected", "Select one or more AppLens-Tune plan items first.");
+ return;
+ }
+
+ SetTuneBusy(true);
+ SetStatus("Running Tune actions...");
+ var results = new List();
+
+ try
+ {
+ foreach (var item in selectedItems)
+ {
+ results.Add(await _tuneActionExecutor.ExecuteAsync(item, userApproved: true));
+ }
+
+ var ledgerFailures = 0;
+ foreach (var result in results)
+ {
+ if (!await AppendLedgerEventAsync(BlackboardEvent.ForTuneAction(result)))
+ {
+ ledgerFailures++;
+ }
+ }
+
+ _actionLog.AddRange(results);
+ _snapshot = WithActionLog(_snapshot, _actionLog);
+ RenderSnapshot(_snapshot);
+
+ var succeeded = results.Count(result => result.Status == TuneActionStatus.Succeeded);
+ var blocked = results.Count(result => result.Status == TuneActionStatus.Blocked);
+ var failed = results.Count(result => result.Status == TuneActionStatus.Failed);
+ var ledgerStatus = ledgerFailures == 0 ? "" : $"; {ledgerFailures} ledger write(s) failed";
+ SetStatus($"Tune complete: {succeeded} succeeded, {blocked} blocked, {failed} failed{ledgerStatus}");
+ }
+ finally
+ {
+ SetTuneBusy(false);
+ }
+ }
+
+ private async Task AppendLedgerEventAsync(BlackboardEvent evt)
+ {
+ try
+ {
+ await _blackboardStore.AppendAsync(evt);
+ await RefreshPlatformStatusAsync();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ SetStatus("Ledger write failed");
+ await ShowDialogAsync("Ledger write failed", ex.Message);
+ return false;
+ }
+ }
+
+ private async Task RefreshPlatformStatusAsync()
+ {
+ try
+ {
+ var indexedCount = await _blackboardStore.GetIndexedEventCountAsync();
+ LedgerEventCountText.Text = indexedCount.ToString();
+ }
+ catch
+ {
+ LedgerEventCountText.Text = "unavailable";
+ }
+
+ HostedModulesList.ItemsSource = _moduleStatusService.GetStatuses()
+ .Select(status => new ModuleStatusRow(
+ status.DisplayName,
+ status.Availability.ToString(),
+ status.Reason,
+ status.NextAction))
+ .ToList();
+ }
+
+ private async void VerifyTune_Click(object sender, RoutedEventArgs e)
+ {
+ if (ConsentCheckBox.IsChecked != true)
+ {
+ await ShowDialogAsync("Consent required", "Please confirm that AppLens can rescan this machine locally.");
+ return;
+ }
+
+ await RunScanAsync(preserveActionLog: true);
+ }
+
private async Task ExportAsync(string label, string extension, Func contentFactory)
{
if (_snapshot is null)
@@ -125,6 +249,7 @@ private void RenderSnapshot(AuditSnapshot snapshot)
ReadinessHighlightsList.ItemsSource = snapshot.Readiness.Highlights;
FindingsList.ItemsSource = snapshot.Findings;
TunePlanList.ItemsSource = snapshot.TunePlan;
+ ActionLogList.ItemsSource = snapshot.ActionLog;
AppsList.ItemsSource = snapshot.Inventory.DesktopApplications
.Concat(snapshot.Inventory.StoreApplications)
.Concat(snapshot.Inventory.RuntimesAndFrameworks)
@@ -135,6 +260,8 @@ private void RenderSnapshot(AuditSnapshot snapshot)
ExportMarkdownButton.IsEnabled = true;
ExportHtmlButton.IsEnabled = true;
ExportBundleButton.IsEnabled = true;
+ ApplyTuneActionsButton.IsEnabled = snapshot.TunePlan.Count > 0;
+ VerifyTuneButton.IsEnabled = true;
}
private static List BuildDiagnostics(AuditSnapshot snapshot)
@@ -156,6 +283,16 @@ private void SetBusy(bool isBusy)
RunButton.IsEnabled = !isBusy;
CancelButton.IsEnabled = isBusy;
ScanProgress.IsActive = isBusy;
+ ApplyTuneActionsButton.IsEnabled = !isBusy && _snapshot?.TunePlan.Count > 0;
+ VerifyTuneButton.IsEnabled = !isBusy && _snapshot is not null;
+ }
+
+ private void SetTuneBusy(bool isBusy)
+ {
+ ApplyTuneActionsButton.IsEnabled = !isBusy && _snapshot?.TunePlan.Count > 0;
+ VerifyTuneButton.IsEnabled = !isBusy && _snapshot is not null;
+ RunButton.IsEnabled = !isBusy;
+ ScanProgress.IsActive = isBusy;
}
private void SetStatus(string status)
@@ -174,6 +311,23 @@ private async Task ShowDialogAsync(string title, string content)
};
await dialog.ShowAsync();
}
+
+ private static AuditSnapshot WithActionLog(AuditSnapshot snapshot, List actionLog) =>
+ new()
+ {
+ SchemaVersion = snapshot.SchemaVersion,
+ GeneratedAt = snapshot.GeneratedAt,
+ Machine = snapshot.Machine,
+ Inventory = snapshot.Inventory,
+ Tune = snapshot.Tune,
+ Readiness = snapshot.Readiness,
+ Findings = snapshot.Findings,
+ TunePlan = snapshot.TunePlan,
+ ActionLog = actionLog,
+ ProbeStatuses = snapshot.ProbeStatuses
+ };
}
public sealed record DiagnosticRow(string Name, string Value, string Detail);
+
+public sealed record ModuleStatusRow(string Name, string Status, string Reason, string NextAction);
diff --git a/tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj b/tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj
index 4729ad5..854eda8 100644
--- a/tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj
+++ b/tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/tests/AppLens.Backend.Tests/BlackboardEventTests.cs b/tests/AppLens.Backend.Tests/BlackboardEventTests.cs
new file mode 100644
index 0000000..c3f77e3
--- /dev/null
+++ b/tests/AppLens.Backend.Tests/BlackboardEventTests.cs
@@ -0,0 +1,81 @@
+namespace AppLens.Backend.Tests;
+
+public sealed class BlackboardEventTests
+{
+ [Fact]
+ public void Scan_completed_event_uses_future_proof_contract_fields()
+ {
+ var snapshot = new AuditSnapshot
+ {
+ GeneratedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero),
+ Readiness = new ReadinessSummary
+ {
+ Score = 91,
+ Rating = "Ready",
+ ReviewCount = 2,
+ OptionalCount = 1,
+ AdminRequiredCount = 1
+ },
+ Findings =
+ [
+ new Finding { Severity = FindingSeverity.Review, Category = FindingCategory.Startup, Title = "Startup review" }
+ ],
+ TunePlan =
+ [
+ new TunePlanItem { Id = "startup-docker", Title = "Disable startup entry" }
+ ]
+ };
+
+ var evt = BlackboardEvent.ForScanCompleted(snapshot, correlationId: "corr-scan");
+
+ Assert.Equal("1.0", evt.SchemaVersion);
+ Assert.Equal(BlackboardEventType.ScanCompleted, evt.EventType);
+ Assert.Equal("applens-desktop", evt.ParticipantIdentity);
+ Assert.Equal(BlackboardParticipantKind.FirstPartyModule, evt.ParticipantKind);
+ Assert.Equal("report", evt.ModuleId);
+ Assert.Equal("applens-desktop", evt.AppId);
+ Assert.Equal("local_workstation", evt.ScopeId);
+ Assert.Equal("corr-scan", evt.CorrelationId);
+ Assert.Equal(BlackboardLifecycleState.Created, evt.LifecycleState);
+ Assert.Equal(BlackboardDataState.Validated, evt.DataState);
+ Assert.Equal(BlackboardPrivacyState.RawPrivate, evt.PrivacyState);
+ Assert.Contains("91/100", evt.Summary, StringComparison.Ordinal);
+ Assert.Equal("91", evt.Payload["readiness_score"]);
+ Assert.Equal("1", evt.Payload["finding_count"]);
+ Assert.Equal("1", evt.Payload["tune_plan_count"]);
+ Assert.Null(evt.GrantId);
+ Assert.Null(evt.CanonicalHash);
+ Assert.Null(evt.SignerKeyId);
+ Assert.Null(evt.Signature);
+ }
+
+ [Fact]
+ public void Tune_action_event_records_policy_result()
+ {
+ var action = new TuneActionRecord
+ {
+ Id = "act-1",
+ PlanItemId = "startup-docker",
+ Kind = ProposedActionKind.DisableStartup,
+ Status = TuneActionStatus.Succeeded,
+ Target = "Docker Desktop",
+ Message = "Startup entry was disabled.",
+ RequiresAdmin = false,
+ StartedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero),
+ CompletedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 1, TimeSpan.Zero)
+ };
+
+ var evt = BlackboardEvent.ForTuneAction(action, correlationId: "corr-tune");
+
+ Assert.Equal(BlackboardEventType.TuneActionCompleted, evt.EventType);
+ Assert.Equal("tune", evt.ModuleId);
+ Assert.Equal("corr-tune", evt.CorrelationId);
+ Assert.Equal(BlackboardDataState.Validated, evt.DataState);
+ Assert.Equal("Docker Desktop", evt.Payload["target"]);
+ Assert.NotNull(evt.PolicyResult);
+ Assert.True(evt.PolicyResult.Allowed);
+ Assert.False(evt.PolicyResult.RequiresAdmin);
+ Assert.True(evt.PolicyResult.RequiresApproval);
+ Assert.Equal("low", evt.PolicyResult.RiskLevel);
+ }
+}
diff --git a/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs b/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs
new file mode 100644
index 0000000..2210efd
--- /dev/null
+++ b/tests/AppLens.Backend.Tests/BlackboardStoreTests.cs
@@ -0,0 +1,108 @@
+using Microsoft.Data.Sqlite;
+
+namespace AppLens.Backend.Tests;
+
+public sealed class BlackboardStoreTests : IDisposable
+{
+ private readonly string _root;
+ private readonly AppLensRuntimeStorage _storage;
+
+ public BlackboardStoreTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "AppLens-BlackboardStoreTests", Guid.NewGuid().ToString("N"));
+ _storage = AppLensRuntimeStorage.FromRoot(_root);
+ }
+
+ [Fact]
+ public async Task Append_writes_jsonl_and_read_all_returns_events()
+ {
+ var store = new BlackboardStore(_storage);
+ var evt = SampleEvent("evt-1");
+
+ await store.AppendAsync(evt);
+ var events = await store.ReadAllAsync();
+
+ Assert.True(File.Exists(_storage.EventsJsonl));
+ Assert.Single(events);
+ Assert.Equal("evt-1", events[0].EventId);
+ Assert.Equal(BlackboardEventType.ScanCompleted, events[0].EventType);
+ }
+
+ [Fact]
+ public async Task Read_all_skips_corrupt_jsonl_lines()
+ {
+ Directory.CreateDirectory(_storage.LedgerDirectory);
+ var valid = BlackboardStore.SerializeEvent(SampleEvent("evt-good"));
+ await File.WriteAllTextAsync(_storage.EventsJsonl, valid + Environment.NewLine + "{not json" + Environment.NewLine);
+
+ var events = await new BlackboardStore(_storage).ReadAllAsync();
+
+ Assert.Single(events);
+ Assert.Equal("evt-good", events[0].EventId);
+ }
+
+ [Fact]
+ public async Task Append_creates_sqlite_index_with_queryable_event_row()
+ {
+ var store = new BlackboardStore(_storage);
+
+ await store.AppendAsync(SampleEvent("evt-indexed"));
+
+ Assert.True(File.Exists(_storage.IndexSqlite));
+ using var connection = new SqliteConnection($"Data Source={_storage.IndexSqlite};Pooling=False");
+ await connection.OpenAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT event_id, event_type, module_id, app_id, data_state, privacy_state, summary
+ FROM events
+ WHERE event_id = 'evt-indexed'
+ """;
+ using var reader = await command.ExecuteReaderAsync();
+ Assert.True(await reader.ReadAsync());
+ Assert.Equal("evt-indexed", reader.GetString(0));
+ Assert.Equal("ScanCompleted", reader.GetString(1));
+ Assert.Equal("report", reader.GetString(2));
+ Assert.Equal("applens-desktop", reader.GetString(3));
+ Assert.Equal("Validated", reader.GetString(4));
+ Assert.Equal("RawPrivate", reader.GetString(5));
+ Assert.Equal("sample scan", reader.GetString(6));
+ }
+
+ [Fact]
+ public async Task Indexed_event_count_reads_from_sqlite_index()
+ {
+ var store = new BlackboardStore(_storage);
+
+ await store.AppendAsync(SampleEvent("evt-count-1"));
+ await store.AppendAsync(SampleEvent("evt-count-2"));
+
+ Assert.Equal(2, await store.GetIndexedEventCountAsync());
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ private static BlackboardEvent SampleEvent(string eventId) =>
+ new()
+ {
+ EventId = eventId,
+ EventType = BlackboardEventType.ScanCompleted,
+ ParticipantIdentity = "applens-desktop",
+ ParticipantKind = BlackboardParticipantKind.FirstPartyModule,
+ ModuleId = "report",
+ AppId = "applens-desktop",
+ ScopeId = "local_workstation",
+ CorrelationId = "corr-1",
+ CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero),
+ LifecycleState = BlackboardLifecycleState.Created,
+ DataState = BlackboardDataState.Validated,
+ PrivacyState = BlackboardPrivacyState.RawPrivate,
+ Summary = "sample scan",
+ Payload = new Dictionary { ["readiness_score"] = "100" }
+ };
+}
diff --git a/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs b/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs
new file mode 100644
index 0000000..2bc7ca6
--- /dev/null
+++ b/tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs
@@ -0,0 +1,80 @@
+namespace AppLens.Backend.Tests;
+
+public sealed class ModuleStatusServiceTests : IDisposable
+{
+ private readonly string _root;
+
+ public ModuleStatusServiceTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "AppLens-ModuleStatusServiceTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ [Fact]
+ public void Oracle_is_blocked_when_repo_path_is_absent()
+ {
+ var service = new ModuleStatusService(new ModuleStatusPaths { OracleRoot = Path.Combine(_root, "missing-oracle") });
+
+ var status = service.GetStatuses().Single(item => item.ModuleId == "oracle");
+
+ Assert.Equal(ModuleAvailability.Blocked, status.Availability);
+ Assert.Contains("Oracle repo", status.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Oracle_is_available_when_pyproject_exists()
+ {
+ var oracleRoot = Path.Combine(_root, "Oracle");
+ Directory.CreateDirectory(oracleRoot);
+ File.WriteAllText(Path.Combine(oracleRoot, "pyproject.toml"), "[project]\nname='oracle-workbench'\n");
+ var service = new ModuleStatusService(new ModuleStatusPaths { OracleRoot = oracleRoot });
+
+ var status = service.GetStatuses().Single(item => item.ModuleId == "oracle");
+
+ Assert.Equal(ModuleAvailability.Available, status.Availability);
+ }
+
+ [Fact]
+ public void Mailbox_is_blocked_when_config_is_absent()
+ {
+ var mailboxRoot = Path.Combine(_root, "Mailbox");
+ Directory.CreateDirectory(Path.Combine(mailboxRoot, "mailbox"));
+ File.WriteAllText(Path.Combine(mailboxRoot, "mailbox", "server.py"), "# fake server");
+ var service = new ModuleStatusService(new ModuleStatusPaths { MailboxRoot = mailboxRoot });
+
+ var status = service.GetStatuses().Single(item => item.ModuleId == "mailbox");
+
+ Assert.Equal(ModuleAvailability.Blocked, status.Availability);
+ Assert.Contains("config", status.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Llm_is_blocked_when_package_path_is_absent()
+ {
+ var service = new ModuleStatusService(new ModuleStatusPaths { AppLensLlmRoot = Path.Combine(_root, "missing-llm") });
+
+ var status = service.GetStatuses().Single(item => item.ModuleId == "llm");
+
+ Assert.Equal(ModuleAvailability.Blocked, status.Availability);
+ Assert.Contains("AppLens-LLM", status.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Zero_is_blocked_when_no_import_source_exists()
+ {
+ var service = new ModuleStatusService(new ModuleStatusPaths { AppLensZeroRoot = Path.Combine(_root, "missing-zero") });
+
+ var status = service.GetStatuses().Single(item => item.ModuleId == "zero");
+
+ Assert.Equal(ModuleAvailability.Blocked, status.Availability);
+ Assert.Contains("Zero", status.Reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+}
diff --git a/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs b/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs
new file mode 100644
index 0000000..e9fd0f7
--- /dev/null
+++ b/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs
@@ -0,0 +1,17 @@
+namespace AppLens.Backend.Tests;
+
+public sealed class RuntimeStorageTests
+{
+ [Fact]
+ public void Explicit_root_resolves_ledger_paths()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "AppLens-RuntimeStorageTests", Guid.NewGuid().ToString("N"));
+
+ var storage = AppLensRuntimeStorage.FromRoot(root);
+
+ Assert.Equal(root, storage.Root);
+ Assert.Equal(Path.Combine(root, "ledger"), storage.LedgerDirectory);
+ Assert.Equal(Path.Combine(root, "ledger", "events.jsonl"), storage.EventsJsonl);
+ Assert.Equal(Path.Combine(root, "ledger", "index.sqlite"), storage.IndexSqlite);
+ }
+}
diff --git a/tests/AppLens.Backend.Tests/TuneActionExecutorTests.cs b/tests/AppLens.Backend.Tests/TuneActionExecutorTests.cs
new file mode 100644
index 0000000..0788d4d
--- /dev/null
+++ b/tests/AppLens.Backend.Tests/TuneActionExecutorTests.cs
@@ -0,0 +1,200 @@
+namespace AppLens.Backend.Tests;
+
+public sealed class TuneActionExecutorTests : IDisposable
+{
+ private readonly string _root;
+
+ public TuneActionExecutorTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "AppLens-Tune-ActionTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ [Fact]
+ public async Task Clear_rebuildable_cache_deletes_contents_and_preserves_root()
+ {
+ var childDirectory = Path.Combine(_root, "child");
+ Directory.CreateDirectory(childDirectory);
+ await File.WriteAllTextAsync(Path.Combine(_root, "cache.tmp"), "cache");
+ await File.WriteAllTextAsync(Path.Combine(childDirectory, "nested.tmp"), "cache");
+
+ var item = CacheCleanupItem(_root);
+ var result = await new TuneActionExecutor().ExecuteAsync(item, userApproved: true);
+
+ Assert.Equal(TuneActionStatus.Succeeded, result.Status);
+ Assert.True(Directory.Exists(_root));
+ Assert.Empty(Directory.EnumerateFileSystemEntries(_root));
+ Assert.Equal(item.Id, result.PlanItemId);
+ Assert.Equal(ProposedActionKind.ClearRebuildableCache, result.Kind);
+ }
+
+ [Fact]
+ public async Task Clear_rebuildable_cache_without_user_consent_is_blocked()
+ {
+ var file = Path.Combine(_root, "cache.tmp");
+ await File.WriteAllTextAsync(file, "cache");
+
+ var result = await new TuneActionExecutor().ExecuteAsync(CacheCleanupItem(_root), userApproved: false);
+
+ Assert.Equal(TuneActionStatus.Blocked, result.Status);
+ Assert.True(File.Exists(file));
+ Assert.Contains("consent", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Admin_required_service_action_is_blocked_when_runtime_is_not_elevated()
+ {
+ var runtime = new FakeTuneActionRuntime(isAdministrator: false);
+ var item = new TunePlanItem
+ {
+ Id = "service-asus",
+ RequiresAdmin = true,
+ VerificationStep = "Re-scan service state.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.SetServiceManual,
+ ExecutionState = TunePlanExecutionState.RequiresAdmin,
+ Target = "ASUSSoftwareManager"
+ }
+ };
+
+ var result = await new TuneActionExecutor(runtime).ExecuteAsync(item, userApproved: true);
+
+ Assert.Equal(TuneActionStatus.Blocked, result.Status);
+ Assert.True(result.RequiresAdmin);
+ Assert.False(runtime.SetServiceManualCalled);
+ }
+
+ [Fact]
+ public async Task Unsupported_action_is_blocked()
+ {
+ var item = new TunePlanItem
+ {
+ Id = "move-repo",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.MoveRepo,
+ ExecutionState = TunePlanExecutionState.ReadOnlyOnly,
+ Target = @"C:\Users\codyl\OneDrive\Documents"
+ }
+ };
+
+ var result = await new TuneActionExecutor(new FakeTuneActionRuntime(isAdministrator: true))
+ .ExecuteAsync(item, userApproved: true);
+
+ Assert.Equal(TuneActionStatus.Blocked, result.Status);
+ Assert.Contains("not executable", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Disable_startup_entry_calls_runtime_with_entry_name_and_location()
+ {
+ var runtime = new FakeTuneActionRuntime(isAdministrator: false);
+ var item = new TunePlanItem
+ {
+ Id = "startup-docker",
+ VerificationStep = "Re-scan startup state.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.DisableStartup,
+ ExecutionState = TunePlanExecutionState.RequiresUserConsent,
+ Target = "Docker Desktop",
+ TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
+ }
+ };
+
+ var result = await new TuneActionExecutor(runtime).ExecuteAsync(item, userApproved: true);
+
+ Assert.Equal(TuneActionStatus.Succeeded, result.Status);
+ Assert.Equal("Docker Desktop", runtime.DisabledStartupName);
+ Assert.Equal(@"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", runtime.DisabledStartupLocation);
+ }
+
+ [Fact]
+ public async Task Enable_startup_entry_calls_runtime_with_entry_name_and_location()
+ {
+ var runtime = new FakeTuneActionRuntime(isAdministrator: false);
+ var item = new TunePlanItem
+ {
+ Id = "startup-docker-enable",
+ VerificationStep = "Re-scan startup state.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.EnableStartup,
+ ExecutionState = TunePlanExecutionState.RequiresUserConsent,
+ Target = "Docker Desktop",
+ TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
+ }
+ };
+
+ var result = await new TuneActionExecutor(runtime).ExecuteAsync(item, userApproved: true);
+
+ Assert.Equal(TuneActionStatus.Succeeded, result.Status);
+ Assert.Equal("Docker Desktop", runtime.EnabledStartupName);
+ Assert.Equal(@"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", runtime.EnabledStartupLocation);
+ }
+
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ private static TunePlanItem CacheCleanupItem(string path) =>
+ new()
+ {
+ Id = "clear-cache",
+ VerificationStep = "Re-scan storage hotspots.",
+ ProposedAction = new ProposedAction
+ {
+ Kind = ProposedActionKind.ClearRebuildableCache,
+ ExecutionState = TunePlanExecutionState.RequiresUserConsent,
+ Target = path,
+ TargetContext = @"LocalAppData\Temp"
+ }
+ };
+
+ private sealed class FakeTuneActionRuntime(bool isAdministrator) : ITuneActionRuntime
+ {
+ public bool IsAdministrator { get; } = isAdministrator;
+
+ public bool SetServiceManualCalled { get; private set; }
+
+ public string DisabledStartupName { get; private set; } = "";
+
+ public string DisabledStartupLocation { get; private set; } = "";
+
+ public string EnabledStartupName { get; private set; } = "";
+
+ public string EnabledStartupLocation { get; private set; } = "";
+
+ public Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default) =>
+ Task.FromResult(0L);
+
+ public Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default)
+ {
+ SetServiceManualCalled = true;
+ return Task.CompletedTask;
+ }
+
+ public Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default)
+ {
+ DisabledStartupName = entryName;
+ DisabledStartupLocation = location;
+ return Task.CompletedTask;
+ }
+
+ public Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default)
+ {
+ EnabledStartupName = entryName;
+ EnabledStartupLocation = location;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/AppLens.Backend.Tests/TunePlanBuilderTests.cs b/tests/AppLens.Backend.Tests/TunePlanBuilderTests.cs
index 536f861..438a972 100644
--- a/tests/AppLens.Backend.Tests/TunePlanBuilderTests.cs
+++ b/tests/AppLens.Backend.Tests/TunePlanBuilderTests.cs
@@ -3,7 +3,7 @@ namespace AppLens.Backend.Tests;
public sealed class TunePlanBuilderTests
{
[Fact]
- public void Startup_findings_become_future_user_consent_guidance()
+ public void Startup_findings_become_user_consent_actions()
{
var snapshot = new AuditSnapshot
{
@@ -24,12 +24,12 @@ public void Startup_findings_become_future_user_consent_guidance()
var item = Assert.Single(plan);
Assert.Equal(TunePlanCategory.UserChoice, item.Category);
Assert.Equal(ProposedActionKind.DisableStartup, item.ProposedAction.Kind);
- Assert.Equal(TunePlanExecutionState.FutureUserConsent, item.ProposedAction.ExecutionState);
+ Assert.Equal(TunePlanExecutionState.RequiresUserConsent, item.ProposedAction.ExecutionState);
Assert.False(item.RequiresAdmin);
}
[Fact]
- public void Automatic_review_services_are_admin_required_and_not_executable_in_v1()
+ public void Automatic_review_services_are_admin_required_actions()
{
var snapshot = new AuditSnapshot
{
@@ -53,7 +53,7 @@ public void Automatic_review_services_are_admin_required_and_not_executable_in_v
var item = Assert.Single(plan);
Assert.Equal(TunePlanCategory.AdminRequired, item.Category);
Assert.Equal(ProposedActionKind.SetServiceManual, item.ProposedAction.Kind);
- Assert.Equal(TunePlanExecutionState.FutureAdminRequired, item.ProposedAction.ExecutionState);
+ Assert.Equal(TunePlanExecutionState.RequiresAdmin, item.ProposedAction.ExecutionState);
Assert.True(item.RequiresAdmin);
}
@@ -105,8 +105,98 @@ public void Local_ai_profile_adds_read_only_autoresearch_guidance()
var item = Assert.Single(plan, item => item.Title.Contains("autoresearch", StringComparison.OrdinalIgnoreCase));
Assert.Equal(TunePlanCategory.Review, item.Category);
Assert.Equal(TunePlanRisk.Low, item.Risk);
- Assert.Equal(TunePlanExecutionState.ReadOnlyOnly, item.ProposedAction.ExecutionState);
+ Assert.Equal(ProposedActionKind.RunLocalAiBenchmark, item.ProposedAction.Kind);
+ Assert.Equal(TunePlanExecutionState.FutureUserConsent, item.ProposedAction.ExecutionState);
+ Assert.Contains("Future action", item.ProposedAction.Description, StringComparison.OrdinalIgnoreCase);
Assert.Contains("llama.cpp", item.Guidance, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Training remains gated", item.VerificationStep, StringComparison.OrdinalIgnoreCase);
}
+
+ [Fact]
+ public void Rebuildable_storage_hotspots_become_user_consent_actions_with_path_targets()
+ {
+ var cachePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Temp",
+ "AppLens-Tune-Test");
+ var snapshot = new AuditSnapshot
+ {
+ Tune = new TuneSummary
+ {
+ StorageHotspots =
+ [
+ new StorageHotspot
+ {
+ Location = @"LocalAppData\Temp",
+ Path = cachePath,
+ Bytes = 1024
+ }
+ ]
+ }
+ };
+
+ var plan = new TunePlanBuilder().Build(snapshot);
+
+ var item = Assert.Single(plan, item => item.ProposedAction.Kind == ProposedActionKind.ClearRebuildableCache);
+ Assert.Equal(TunePlanExecutionState.RequiresUserConsent, item.ProposedAction.ExecutionState);
+ Assert.Equal(cachePath, item.ProposedAction.Target);
+ }
+
+ [Fact]
+ public void Enabled_startup_entries_become_user_consent_actions_with_entry_targets()
+ {
+ var snapshot = new AuditSnapshot
+ {
+ Tune = new TuneSummary
+ {
+ StartupEntries =
+ [
+ new StartupEntry
+ {
+ Name = "Docker Desktop",
+ State = "Enabled",
+ Location = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
+ Command = "Docker Desktop.exe"
+ }
+ ]
+ }
+ };
+
+ var plan = new TunePlanBuilder().Build(snapshot);
+
+ var item = Assert.Single(plan, item => item.Title == "Disable startup entry: Docker Desktop");
+ Assert.Equal(ProposedActionKind.DisableStartup, item.ProposedAction.Kind);
+ Assert.Equal(TunePlanExecutionState.RequiresUserConsent, item.ProposedAction.ExecutionState);
+ Assert.Equal("Docker Desktop", item.ProposedAction.Target);
+ Assert.Equal(@"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", item.ProposedAction.TargetContext);
+ }
+
+ [Fact]
+ public void Disabled_startup_entries_become_user_consent_enable_actions_with_entry_targets()
+ {
+ var snapshot = new AuditSnapshot
+ {
+ Tune = new TuneSummary
+ {
+ StartupEntries =
+ [
+ new StartupEntry
+ {
+ Name = "Docker Desktop",
+ State = "Disabled",
+ Location = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
+ Command = "Docker Desktop.exe"
+ }
+ ]
+ }
+ };
+
+ var plan = new TunePlanBuilder().Build(snapshot);
+
+ var item = Assert.Single(plan, item => item.Title == "Enable startup entry: Docker Desktop");
+ Assert.Equal(ProposedActionKind.EnableStartup, item.ProposedAction.Kind);
+ Assert.Equal(TunePlanExecutionState.RequiresUserConsent, item.ProposedAction.ExecutionState);
+ Assert.Equal("Docker Desktop", item.ProposedAction.Target);
+ Assert.Equal(@"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", item.ProposedAction.TargetContext);
+ }
}