Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions src/AppLens.Backend/ModuleDetailReadModelService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
namespace AppLens.Backend;

public enum ModuleHealthState
{
Ok,
Missing,
Unknown
}

public sealed class ModuleHealthCheckReadModel
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public string Target { get; init; } = "";
public bool Required { get; init; } = true;
public ModuleHealthState State { get; init; } = ModuleHealthState.Unknown;
public string Detail { get; init; } = "";
public bool IsBlocking { get; init; }
}

public sealed class ModuleStorageRootReadModel
{
public string Name { get; init; } = "";
public string Path { get; init; } = "";
public string PrivacyState { get; init; } = "";
public string Description { get; init; } = "";
public bool Exists { get; init; }
public string Detail { get; init; } = "";
}

public sealed class ModuleActionReadModel
{
public string Name { get; init; } = "";
public string Permission { get; init; } = "";
public bool RequiresApproval { get; init; } = true;
public bool SystemChanging { get; init; }
public string Description { get; init; } = "";
public bool IsRunnable { get; init; }
}

public sealed class ModuleLedgerEventReadModel
{
public string EventId { get; init; } = "";
public BlackboardEventType EventType { get; init; }
public string CorrelationId { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public BlackboardDataState DataState { get; init; }
public BlackboardPrivacyState PrivacyState { get; init; }
public string Summary { get; init; } = "";
}

public sealed class ModuleDetailReadModel
{
public string ModuleId { get; init; } = "";
public string AppId { get; init; } = "";
public string DisplayName { get; init; } = "";
public string ModuleKind { get; init; } = "";
public string Version { get; init; } = "";
public string RiskLevel { get; init; } = "";
public ModuleAvailability Availability { get; init; } = ModuleAvailability.Unavailable;
public string StatusLabel { get; init; } = "";
public string Reason { get; init; } = "";
public string ExpectedSource { get; init; } = "";
public string NextAction { get; init; } = "";
public List<string> Capabilities { get; init; } = [];
public List<string> Entrypoints { get; init; } = [];
public List<string> DataContracts { get; init; } = [];
public List<string> ActionContracts { get; init; } = [];
public List<string> Privacy { get; init; } = [];
public string StatusContract { get; init; } = "";
public List<string> ReportRoots { get; init; } = [];
public List<ModuleStorageRootReadModel> StorageRoots { get; init; } = [];
public List<ModuleHealthCheckReadModel> HealthChecks { get; init; } = [];
public List<ModuleActionReadModel> Actions { get; init; } = [];
public List<ModuleLedgerEventReadModel> RecentLedgerEvents { get; init; } = [];
}

public sealed class ModuleDetailReadModelService
{
private readonly ModuleStatusService _moduleStatusService;
private readonly IBlackboardStore _blackboardStore;

public ModuleDetailReadModelService(ModuleStatusService moduleStatusService, IBlackboardStore blackboardStore)
{
_moduleStatusService = moduleStatusService;
_blackboardStore = blackboardStore;
}

public async Task<ModuleDetailReadModel?> GetModuleDetailAsync(
string moduleId,
int recentEventLimit = 25,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(moduleId))
{
return null;
}

var manifest = _moduleStatusService.GetManifests()
.FirstOrDefault(item => string.Equals(item.ModuleId, moduleId, StringComparison.OrdinalIgnoreCase));

if (manifest is null)
{
return null;
}

var status = _moduleStatusService.GetStatuses()
.FirstOrDefault(item => string.Equals(item.ModuleId, manifest.ModuleId, StringComparison.OrdinalIgnoreCase))
?? new ModuleStatus
{
ModuleId = manifest.ModuleId,
AppId = manifest.AppId,
DisplayName = manifest.DisplayName,
Availability = ModuleAvailability.Unavailable,
Reason = "Module status is unavailable.",
NextAction = "Re-run module detection."
};

var healthChecks = manifest.HealthChecks.Select(EvaluateHealthCheck).ToList();
var storageRoots = manifest.StorageRoots.Select(EvaluateStorageRoot).ToList();
var actions = manifest.Actions.Select(ToAction).ToList();
var events = await GetRecentModuleEventsAsync(manifest.ModuleId, recentEventLimit, cancellationToken).ConfigureAwait(false);

return new ModuleDetailReadModel
{
ModuleId = manifest.ModuleId,
AppId = manifest.AppId,
DisplayName = manifest.DisplayName,
ModuleKind = manifest.ModuleKind,
Version = manifest.Version,
RiskLevel = manifest.RiskLevel,
Availability = status.Availability,
StatusLabel = status.Availability.ToString(),
Reason = status.Reason,
ExpectedSource = status.ExpectedSource,
NextAction = status.NextAction,
Capabilities = manifest.Capabilities,
Entrypoints = manifest.Entrypoints,
DataContracts = manifest.DataContracts,
ActionContracts = manifest.ActionContracts,
Privacy = manifest.Privacy,
StatusContract = manifest.StatusContract,
ReportRoots = manifest.ReportRoots,
StorageRoots = storageRoots,
HealthChecks = healthChecks,
Actions = actions,
RecentLedgerEvents = events
};
}

private async Task<List<ModuleLedgerEventReadModel>> GetRecentModuleEventsAsync(
string moduleId,
int limit,
CancellationToken cancellationToken)
{
if (limit <= 0)
{
return [];
}

var events = await _blackboardStore.QueryAsync(
new BlackboardEventQuery
{
ModuleId = moduleId,
Limit = limit
},
cancellationToken)
.ConfigureAwait(false);

return events.Select(ToLedgerEvent).ToList();
}

private static ModuleHealthCheckReadModel EvaluateHealthCheck(ModuleHealthCheck check)
{
var kind = check.Kind ?? "";
if (kind.Equals("file", StringComparison.OrdinalIgnoreCase))
{
var exists = File.Exists(check.Target);
return new ModuleHealthCheckReadModel
{
Name = check.Name,
Kind = kind,
Target = check.Target,
Required = check.Required,
State = exists ? ModuleHealthState.Ok : ModuleHealthState.Missing,
Detail = exists ? "File present." : "File missing.",
IsBlocking = check.Required && !exists
};
}

if (kind.Equals("directory", StringComparison.OrdinalIgnoreCase))
{
var exists = Directory.Exists(check.Target);
return new ModuleHealthCheckReadModel
{
Name = check.Name,
Kind = kind,
Target = check.Target,
Required = check.Required,
State = exists ? ModuleHealthState.Ok : ModuleHealthState.Missing,
Detail = exists ? "Directory present." : "Directory missing.",
IsBlocking = check.Required && !exists
};
}

return new ModuleHealthCheckReadModel
{
Name = check.Name,
Kind = kind,
Target = check.Target,
Required = check.Required,
State = ModuleHealthState.Unknown,
Detail = "Unsupported health check kind.",
IsBlocking = check.Required
};
}

private static ModuleStorageRootReadModel EvaluateStorageRoot(ModuleStorageRoot root)
{
var exists = Directory.Exists(root.Path) || File.Exists(root.Path);
return new ModuleStorageRootReadModel
{
Name = root.Name,
Path = root.Path,
PrivacyState = root.PrivacyState,
Description = root.Description,
Exists = exists,
Detail = exists ? "Detected." : "Missing."
};
}

private static ModuleActionReadModel ToAction(ModuleActionContract action) =>
new()
{
Name = action.Name,
Permission = action.Permission,
RequiresApproval = action.RequiresApproval,
SystemChanging = action.SystemChanging,
Description = action.Description,
IsRunnable = action.Permission.Contains("execute", StringComparison.OrdinalIgnoreCase)
};

private static ModuleLedgerEventReadModel ToLedgerEvent(BlackboardEvent evt) =>
new()
{
EventId = evt.EventId,
EventType = evt.EventType,
CorrelationId = evt.CorrelationId,
CreatedAt = evt.CreatedAt,
DataState = evt.DataState,
PrivacyState = evt.PrivacyState,
Summary = evt.Summary
};
}

106 changes: 106 additions & 0 deletions tests/AppLens.Backend.Tests/ModuleDetailReadModelServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
namespace AppLens.Backend.Tests;

public sealed class ModuleDetailReadModelServiceTests : IDisposable
{
private readonly string _root;
private readonly AppLensRuntimeStorage _storage;
private readonly BlackboardStore _store;

public ModuleDetailReadModelServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), "AppLens-ModuleDetailReadModelTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_storage = AppLensRuntimeStorage.FromRoot(Path.Combine(_root, "runtime"));
_store = new BlackboardStore(_storage);
}

[Fact]
public async Task Unknown_module_returns_null()
{
var service = CreateService();

var result = await service.GetModuleDetailAsync("missing-module");

Assert.Null(result);
}

[Fact]
public async Task Module_detail_includes_health_storage_and_recent_events()
{
var llmRoot = Path.Combine(_root, "AppLens-LLM");
Directory.CreateDirectory(Path.Combine(llmRoot, "src", "applens_llm"));
Directory.CreateDirectory(Path.Combine(llmRoot, "out"));
File.WriteAllText(Path.Combine(llmRoot, "pyproject.toml"), "[project]\nname='applens-llm'\n");
File.WriteAllText(Path.Combine(llmRoot, "src", "applens_llm", "cli.py"), "# fake cli");

await _store.AppendAsync(new BlackboardEvent
{
EventId = "evt-old",
EventType = BlackboardEventType.ScanCompleted,
ModuleId = "llm",
CorrelationId = "corr-module",
CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero),
Summary = "Scan completed."
});
await _store.AppendAsync(new BlackboardEvent
{
EventId = "evt-new",
EventType = BlackboardEventType.VerificationRecorded,
ModuleId = "llm",
CorrelationId = "corr-module",
CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 5, 0, TimeSpan.Zero),
Summary = "Verification recorded."
});
await _store.AppendAsync(new BlackboardEvent
{
EventId = "evt-other",
EventType = BlackboardEventType.ScanCompleted,
ModuleId = "oracle",
CorrelationId = "corr-other",
CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 10, 0, TimeSpan.Zero),
Summary = "Other module scan."
});

var service = CreateService(new ModuleStatusPaths
{
AppLensLlmRoot = llmRoot,
OracleRoot = Path.Combine(_root, "missing-oracle"),
MailboxRoot = Path.Combine(_root, "missing-mailbox"),
AppLensZeroRoot = Path.Combine(_root, "missing-zero")
});

var detail = await service.GetModuleDetailAsync("llm", recentEventLimit: 2);

Assert.NotNull(detail);
Assert.Equal("llm", detail!.ModuleId);
Assert.Equal(ModuleAvailability.Available, detail.Availability);
Assert.Equal("Available", detail.StatusLabel);
Assert.Equal("AppLens-LLM", detail.DisplayName);
Assert.Contains(detail.HealthChecks, check => check.Name == "package" && check.State == ModuleHealthState.Ok);
Assert.Contains(detail.HealthChecks, check => check.Name == "cli" && check.State == ModuleHealthState.Ok);
Assert.Contains(detail.StorageRoots, root => root.Name == "repository" && root.Exists);
Assert.Contains(detail.StorageRoots, root => root.Name == "runtime-output" && root.Exists);
Assert.Equal(["evt-new", "evt-old"], detail.RecentLedgerEvents.Select(evt => evt.EventId).ToArray());
Assert.All(detail.RecentLedgerEvents, evt => Assert.Equal("corr-module", evt.CorrelationId));
}

public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}

private ModuleDetailReadModelService CreateService(ModuleStatusPaths? paths = null) =>
new(
new ModuleStatusService(paths ?? new ModuleStatusPaths
{
AppLensLlmRoot = Path.Combine(_root, "missing-llm"),
OracleRoot = Path.Combine(_root, "missing-oracle"),
MailboxRoot = Path.Combine(_root, "missing-mailbox"),
AppLensZeroRoot = Path.Combine(_root, "missing-zero")
}),
_store);
}

Loading